From 6d73f74e0bd2a09c454b41c8dcfff3c29e57c095 Mon Sep 17 00:00:00 2001 From: tanmay11k Date: Wed, 18 Feb 2026 01:07:12 -0500 Subject: [PATCH] feat: config-driven architecture, install wizard, live runtime switching, usage tracking, auto-failover Major changes: - Config-driven adapters: all channels (Slack, Discord, Telegram, WebChat, Webhooks) controlled via config.json with enabled flags and token auto-detection, no CLI flags required - Runtime engine field: runtime.engine selects opencode/claude from config - Interactive install script: 8-phase setup wizard with AI runtime detection/installation, token setup, identity file personalization (personality presets), aetheel CLI command, background service (launchd/systemd) - Live runtime switching: /engine, /model, /provider commands hot-swap the AI runtime from chat without restart, changes persisted to config.json - Usage tracking: per-request cost extraction from Claude Code JSON output, cumulative stats via /usage command - Auto-failover: rate limit detection on both runtimes, automatic switch to other engine on quota errors with user notification - Chat commands work without / prefix (Slack intercepts / in channels), commands: engine, model, provider, config, usage, reload, cron, subagents, status, help - /config set for editing config.json from chat with dotted key notation - Security audit saved to docs/security-audit.md - Full command reference in docs/commands.md - Future changes doc with NanoClaw agent teams analysis - Logo added to README and WebChat UI - README fully rewritten with all features documented --- .env.example | 48 +- .python-version | 2 +- README.md | 428 +++++++--- adapters/discord_adapter.py | 270 ++++++ adapters/webchat_adapter.py | 207 +++++ agent/claude_runtime.py | 82 +- agent/opencode_runtime.py | 357 +++++++- agent/subagent.py | 39 + agent/teams.py | 0 cli.py | 425 ++++++++++ config.py | 434 ++++++++++ docs/Openclaw deep dive.md | 237 ++++++ docs/additions.txt | 13 + docs/aetheel-vs-nanoclaw.md | 140 ++++ docs/commands.md | 177 ++++ docs/configuration.md | 326 ++++++++ docs/discord-setup.md | 307 +++++++ docs/features-guide.md | 916 +++++++++++++++++++++ docs/future-changes.md | 214 +++++ docs/security-audit.md | 172 ++++ docs/setup.md | 841 +++++++++++++++++++ docs/spec-phase3-features.md | 560 +++++++++++++ heartbeat/__init__.py | 3 + heartbeat/heartbeat.py | 205 +++++ hooks/__init__.py | 3 + hooks/hooks.py | 283 +++++++ install.sh | 1493 ++++++++++++++++++++++++++++++---- main.py | 888 ++++++++++++++++++-- pyproject.toml | 11 +- static/chat.html | 221 +++++ static/logo.jpeg | Bin 0 -> 64640 bytes stock_update.sh | 7 - test_all.py | 405 +++++++++ test_all.sh | 424 ++++++++++ tests/test_hooks.py | 172 ++++ tests/test_mcp_config.py | 134 +++ tests/test_subagent_bus.py | 122 +++ tests/test_webhooks.py | 171 ++++ uv.lock | 793 +++++++++++++++++- webhooks/__init__.py | 3 + webhooks/receiver.py | 267 ++++++ 41 files changed, 11363 insertions(+), 437 deletions(-) create mode 100644 adapters/discord_adapter.py create mode 100644 adapters/webchat_adapter.py create mode 100644 agent/teams.py create mode 100644 cli.py create mode 100644 config.py create mode 100644 docs/Openclaw deep dive.md create mode 100644 docs/aetheel-vs-nanoclaw.md create mode 100644 docs/commands.md create mode 100644 docs/configuration.md create mode 100644 docs/discord-setup.md create mode 100644 docs/features-guide.md create mode 100644 docs/future-changes.md create mode 100644 docs/security-audit.md create mode 100644 docs/setup.md create mode 100644 docs/spec-phase3-features.md create mode 100644 heartbeat/__init__.py create mode 100644 heartbeat/heartbeat.py create mode 100644 hooks/__init__.py create mode 100644 hooks/hooks.py create mode 100644 static/chat.html create mode 100644 static/logo.jpeg delete mode 100755 stock_update.sh create mode 100644 test_all.py create mode 100644 test_all.sh create mode 100644 tests/test_hooks.py create mode 100644 tests/test_mcp_config.py create mode 100644 tests/test_subagent_bus.py create mode 100644 tests/test_webhooks.py create mode 100644 webhooks/__init__.py create mode 100644 webhooks/receiver.py diff --git a/.env.example b/.env.example index c427051..d889dd0 100644 --- a/.env.example +++ b/.env.example @@ -1,45 +1,23 @@ # ============================================================================= -# Aetheel Configuration +# Aetheel Secrets (.env) # ============================================================================= +# This file holds SECRETS ONLY (tokens, passwords, API keys). +# All other configuration lives in ~/.aetheel/config.json. +# # Copy this file to .env and fill in your values. -# See docs/slack-setup.md and docs/opencode-setup.md for instructions. -# --- Slack Tokens (required) ------------------------------------------------ -# Get these from https://api.slack.com/apps → your app settings +# --- Slack Tokens ------------------------------------------------------------ SLACK_BOT_TOKEN=xoxb-your-bot-token-here SLACK_APP_TOKEN=xapp-your-app-token-here -# --- OpenCode Runtime (required for AI) ------------------------------------- -# Mode: "cli" (subprocess) or "sdk" (opencode serve API) -OPENCODE_MODE=cli +# --- Telegram Bot Token (optional) ------------------------------------------- +# TELEGRAM_BOT_TOKEN=your-telegram-bot-token-here -# Model to use (optional — uses your OpenCode default if not set) -# Examples: -# anthropic/claude-sonnet-4-20250514 -# openai/gpt-5.1 -# google/gemini-3-pro -# OPENCODE_MODEL= +# --- Discord Bot Token (optional) -------------------------------------------- +# DISCORD_BOT_TOKEN=your-discord-bot-token-here -# CLI timeout in seconds (for CLI mode) -OPENCODE_TIMEOUT=120 +# --- OpenCode Server Password (optional, SDK mode only) ---------------------- +# OPENCODE_SERVER_PASSWORD=your-server-password -# Server URL for SDK mode (only needed if OPENCODE_MODE=sdk) -# OPENCODE_SERVER_URL=http://localhost:4096 -# OPENCODE_SERVER_PASSWORD= - -# Working directory for OpenCode (optional — defaults to current directory) -# OPENCODE_WORKSPACE=/path/to/your/project - -# --- Logging ----------------------------------------------------------------- -LOG_LEVEL=INFO - -# --- Claude Code Runtime (alternative to OpenCode) -------------------------- -# Use --claude flag to switch: python main.py --claude -# CLAUDE_MODEL=claude-sonnet-4-20250514 -# CLAUDE_TIMEOUT=120 -# CLAUDE_MAX_TURNS=3 -# CLAUDE_NO_TOOLS=true - -# --- Memory System ----------------------------------------------------------- -# AETHEEL_WORKSPACE=~/.aetheel/workspace -# AETHEEL_MEMORY_DB=~/.aetheel/memory.db +# --- Anthropic API Key (optional, for Claude Code runtime) ------------------- +# ANTHROPIC_API_KEY=sk-ant-... diff --git a/.python-version b/.python-version index 6324d40..24ee5b1 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14 +3.13 diff --git a/README.md b/README.md index df50e60..96cf5fc 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,16 @@

+ Aetheel Logo

⚔️ Aetheel

- A personal AI assistant that lives in Slack — with persistent memory, dual runtimes, and zero cloud dependencies. + A personal AI assistant that lives in your chat — with persistent memory, dual runtimes, auto-failover, and zero cloud dependencies.

Quick StartFeatures • + CommandsArchitecture • - Configuration + Configuration • + Docs

@@ -18,190 +21,332 @@ ### One-line install ```bash -curl -fsSL http://10.0.0.59:3051/tanmay/Aetheel/raw/branch/main/install.sh | sh +curl -fsSL http://10.0.0.59:3051/tanmay/Aetheel/raw/branch/main/install.sh | bash ``` -This will clone the repo, set up a Python virtual environment, install dependencies, and walk you through configuration. +The interactive installer handles everything: +- Checks prerequisites (Python 3.12+, uv) +- Installs dependencies +- Detects or installs AI runtimes (OpenCode / Claude Code) +- Walks you through token setup for Slack, Discord, Telegram +- Installs the `aetheel` shell command +- Sets up a background service (launchd on macOS, systemd on Linux) + +### After install + +```bash +aetheel start # Start the bot +aetheel stop # Stop the background service +aetheel restart # Restart the service +aetheel status # Check status +aetheel logs # Tail live logs +aetheel setup # Re-run setup wizard +aetheel update # Pull latest + update deps +aetheel doctor # Run diagnostics +aetheel config # Edit config.json +aetheel help # All options +``` ### Manual install ```bash git clone http://10.0.0.59:3051/tanmay/Aetheel.git cd Aetheel -uv sync # or: pip install -r requirements.txt -cp .env.example .env -# Edit .env with your Slack tokens +uv sync # or: pip install -r requirements.txt +cp .env.example .env # edit with your tokens +uv run python main.py # start ``` -### Run - -```bash -# Default — OpenCode runtime -uv run python main.py - -# Claude Code runtime -uv run python main.py --claude - -# Test mode (echo handler, no AI) -uv run python main.py --test - -# Custom model -uv run python main.py --model anthropic/claude-sonnet-4-20250514 - -# Debug logging -uv run python main.py --log DEBUG -``` +Everything is config-driven — no flags required. See [Configuration](#configuration). --- ## Features +### 💬 Multi-Channel + +| Channel | Connection | Auth | Setup | +|---------|-----------|------|-------| +| Slack | Socket Mode (no public URL) | Bot + App tokens | [docs/slack-setup.md](docs/slack-setup.md) | +| Discord | Gateway (no public URL) | Bot token | [docs/discord-setup.md](docs/discord-setup.md) | +| Telegram | Bot API polling | Bot token | @BotFather | +| WebChat | HTTP + WebSocket on localhost | None (localhost only) | Config: `webchat.enabled: true` | +| Webhooks | HTTP POST endpoints | Bearer token | Config: `webhooks.enabled: true` | + +All adapters are config-driven. Set a token in `.env` and the adapter auto-enables — no flags needed. + +### 🤖 Dual AI Runtimes with Live Switching + +| Runtime | Engine | Providers | Session Continuity | +|---------|--------|-----------|-------------------| +| OpenCode | `opencode` | Anthropic, OpenAI, Google, any | `--continue --session` | +| Claude Code | `claude` | Anthropic | `--continue --session-id` | + +Switch engines, models, and providers live from chat — no restart needed: + +``` +engine claude +model claude-sonnet-4-20250514 + +engine opencode +model openai/gpt-4o +provider openai +``` + +### 🔄 Auto-Failover & Usage Tracking + +When a rate limit or quota error is detected: + +1. You get notified in the channel +2. Aetheel automatically switches to the other engine and retries +3. If failover succeeds, the response is delivered seamlessly + +Claude Code returns per-request cost (`cost_usd`), which is tracked and viewable via the `usage` command. Rate limit hits and failover counts are also tracked. + ### 🧠 Persistent Memory System + - **Identity files** — `SOUL.md` (personality), `USER.md` (user profile), `MEMORY.md` (long-term notes) -- **Hybrid search** — 0.7 vector + 0.3 BM25 keyword scoring over all memory files -- **Local embeddings** — `fastembed` (ONNX, BAAI/bge-small-en-v1.5, 384-dim), zero API calls +- **Hybrid search** — 0.7 vector + 0.3 BM25 keyword scoring +- **Local embeddings** — fastembed (ONNX, BAAI/bge-small-en-v1.5, 384-dim), zero API calls - **SQLite storage** — FTS5 full-text search + JSON vector embeddings - **Session logs** — Conversations auto-saved to `daily/YYYY-MM-DD.md` -- **File watching** — Memory re-indexes when you edit `.md` files +- **File watching** — Auto re-indexes when `.md` files change -### 🤖 Dual AI Runtimes -| Runtime | Flag | System Prompt | Session Continuity | -|---------|------|---------------|--------------------| -| **OpenCode** (default) | `--cli` / `--sdk` | XML-injected into message | `--continue --session ` | -| **Claude Code** | `--claude` | Native `--system-prompt` flag | `--continue --session-id ` | +### ⏰ Scheduler & Action Tags -Both return the same `AgentResponse` — zero code changes needed to switch. +The AI can trigger actions by including tags in its response: -### 💬 Slack Integration -- **Socket Mode** — no public URL or webhook needed -- **DMs + @mentions** — responds in both -- **Thread isolation** — each thread gets its own AI session -- **Chunked replies** — auto-splits long responses at Slack's 4000-char limit +| Tag | Effect | +|-----|--------| +| `[ACTION:remind\|5\|Drink water!]` | One-shot reminder in 5 minutes | +| `[ACTION:cron\|0 9 * * *\|Good morning!]` | Recurring cron job | +| `[ACTION:spawn\|Research Python 3.14]` | Background subagent task | -### ⏰ Action Tags -The AI can perform actions by including tags in its response: +Jobs are persisted in SQLite and survive restarts. -``` -[ACTION:remind|2|Time to drink water! 💧] -``` +### 🔧 More -The system strips the tag from the visible response and schedules the action. +- **Skills** — Teach the AI domain-specific behavior via `SKILL.md` files +- **Hooks** — Event-driven lifecycle hooks (startup, shutdown, commands) +- **Heartbeat** — Periodic proactive tasks via `HEARTBEAT.md` +- **Subagents** — Background AI tasks with results sent back to the channel +- **MCP Servers** — Extend the agent with external tool servers +- **WebChat** — Browser-based chat UI at `http://localhost:8080` +- **Webhooks** — HTTP endpoints for external systems to trigger the agent ### 🔒 Local-First -- Embeddings run locally (no OpenAI/Gemini API for memory) -- Memory stored in `~/.aetheel/` — your data stays on your machine -- Only the AI runtime (OpenCode/Claude) makes external API calls + +- Embeddings run locally (no API calls for memory) +- All data stored in `~/.aetheel/` — your data stays on your machine +- Only the AI runtime makes external API calls + +--- + +## Chat Commands + +Type these as regular messages in any channel or DM. No `/` prefix needed. + +> In Slack channels, Slack intercepts `/` as native slash commands. Use the prefix-free form (e.g. `engine claude` not `/engine claude`). In DMs and other adapters, both forms work. + +### General + +| Command | Description | +|---------|-------------| +| `status` | Bot status, engine, model, sessions | +| `help` | All available commands | +| `time` | Server time | +| `sessions` | Active session count | +| `reload` | Reload config and skills | +| `subagents` | List active background tasks | + +### Runtime (live, no restart) + +| Command | Description | +|---------|-------------| +| `engine` | Show current engine | +| `engine opencode` | Switch to OpenCode | +| `engine claude` | Switch to Claude Code | +| `model` | Show current model | +| `model ` | Switch model | +| `provider` | Show current provider (OpenCode only) | +| `provider ` | Switch provider | +| `usage` | LLM usage stats, costs, rate limits | + +### Config + +| Command | Description | +|---------|-------------| +| `config` | Config summary | +| `config show` | Full config.json | +| `config set ` | Edit config (e.g. `config set runtime.timeout_seconds 300`) | + +### Scheduler + +| Command | Description | +|---------|-------------| +| `cron list` | List scheduled jobs | +| `cron remove ` | Remove a job | + +See [docs/commands.md](docs/commands.md) for the full reference including terminal commands. --- ## Architecture ``` -┌──────────────┐ -│ Slack │ Socket Mode (no public URL) -│ Workspace │ -└──────┬───────┘ - │ Events (DM, @mention) - ▼ -┌──────────────┐ ┌──────────────────┐ -│ Slack │────▶│ main.py │ -│ Adapter │ │ (ai_handler) │ -└──────────────┘ └────────┬─────────┘ - │ - ┌─────────┴─────────┐ - ▼ ▼ - ┌──────────────┐ ┌──────────────┐ - │ Memory │ │ AI Runtime │ - │ Manager │ │ (OpenCode │ - │ │ │ or Claude) │ - │ • SOUL.md │ │ │ - │ • USER.md │ │ subprocess │ - │ • MEMORY.md │ │ execution │ - │ • search │ └──────────────┘ - │ • session │ - │ logs │ - └──────────────┘ +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Slack │ │ Discord │ │ Telegram │ │ WebChat │ +│ (Socket) │ │ (Gateway) │ │ (Polling) │ │ (WebSocket) │ +└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ + │ │ │ │ + └────────────────┴────────────────┴────────────────┘ + │ + ┌───────────┴───────────┐ + │ main.py │ + │ (ai_handler) │ + │ │ + │ • Command routing │ + │ • Action tag parsing │ + │ • Usage tracking │ + │ • Auto-failover │ + └───┬──────────┬────────┘ + │ │ + ┌─────────┴──┐ ┌───┴──────────┐ + │ Memory │ │ AI Runtime │ + │ Manager │ │ │ + │ │ │ OpenCode ◄──►│ Auto-failover + │ • SOUL.md │ │ Claude ◄──►│ + │ • search │ │ │ + │ • logs │ │ subprocess │ + └────────────┘ └──────────────┘ + │ + ┌─────┴──────┐ + │ Scheduler │ Skills, Hooks, Heartbeat, + │ Subagents │ Webhooks, MCP Servers + └────────────┘ ``` ### Project Structure ``` aetheel/ -├── main.py # Entry point — handler, memory init, action tags +├── main.py # Entry point, command routing, failover +├── config.py # Config loading (config.json + .env) +├── cli.py # Click CLI (aetheel command) +├── install.sh # Interactive installer + setup wizard ├── adapters/ -│ └── slack_adapter.py # Slack Socket Mode adapter +│ ├── base.py # BaseAdapter + IncomingMessage +│ ├── slack_adapter.py # Slack Socket Mode +│ ├── discord_adapter.py # Discord Gateway +│ ├── telegram_adapter.py # Telegram Bot API +│ └── webchat_adapter.py # Browser WebSocket chat ├── agent/ -│ ├── opencode_runtime.py # OpenCode CLI/SDK runtime -│ └── claude_runtime.py # Claude Code CLI runtime +│ ├── opencode_runtime.py # OpenCode CLI/SDK + rate limit detection +│ ├── claude_runtime.py # Claude Code CLI + usage extraction +│ └── subagent.py # Background subagent manager ├── memory/ -│ ├── manager.py # MemoryManager — sync, search, identity files -│ ├── embeddings.py # Local embeddings via fastembed +│ ├── manager.py # Sync, search, identity files, session logs +│ ├── embeddings.py # Local embeddings (fastembed) │ ├── hybrid.py # Hybrid search (vector + BM25) -│ ├── schema.py # SQLite schema (files, chunks, FTS5) +│ ├── schema.py # SQLite schema │ ├── internal.py # Hashing, chunking, file discovery -│ └── types.py # Config, result types +│ └── types.py # Config and result types +├── scheduler/ +│ ├── scheduler.py # APScheduler with SQLite persistence +│ └── store.py # Job store +├── skills/ # Skill discovery and context injection +├── hooks/ # Lifecycle hook system +├── heartbeat/ # Periodic proactive tasks +├── webhooks/ # HTTP webhook receiver +├── static/ +│ └── chat.html # WebChat browser UI ├── docs/ -│ ├── memory-system.md # Memory architecture docs -│ ├── opencode-setup.md # OpenCode install & config guide -│ ├── slack-setup.md # Slack app setup guide -│ └── opencode-integration-summary.md -├── .env.example # Template for secrets -├── pyproject.toml # Dependencies (uv/pip) -└── install.sh # One-click installer +│ ├── commands.md # Full command reference +│ ├── setup.md # Detailed setup guide +│ ├── security-audit.md # Security audit findings +│ ├── configuration.md # Config deep dive +│ ├── memory-system.md # Memory architecture +│ ├── features-guide.md # Feature walkthrough +│ ├── slack-setup.md # Slack app creation guide +│ └── discord-setup.md # Discord bot setup guide +├── tests/ # Test suite +├── .env.example # Secrets template +└── pyproject.toml # Dependencies ``` --- ## Configuration -### Required: Slack Tokens +All settings live in `~/.aetheel/config.json`. Secrets (tokens, API keys) live in `.env`. -| Variable | Where to get it | -|----------|----------------| -| `SLACK_BOT_TOKEN` | [api.slack.com/apps](https://api.slack.com/apps) → OAuth & Permissions → Bot Token (`xoxb-...`) | -| `SLACK_APP_TOKEN` | [api.slack.com/apps](https://api.slack.com/apps) → Basic Info → App-Level Tokens (`xapp-...`) | +The install script writes both files during setup. You can also edit them from chat using `config set`. -See [`docs/slack-setup.md`](docs/slack-setup.md) for full Slack app creation instructions. +### Config File -### AI Runtime +```jsonc +{ + "runtime": { + "engine": "opencode", // "opencode" or "claude" + "mode": "cli", // "cli" or "sdk" + "model": null, // e.g. "anthropic/claude-sonnet-4-20250514" + "provider": null, // e.g. "anthropic", "openai", "google" + "timeout_seconds": 120 + }, + "claude": { + "model": null, // e.g. "claude-sonnet-4-20250514" + "max_turns": 3, + "no_tools": false + }, + "slack": { "enabled": true }, + "telegram": { "enabled": false }, + "discord": { "enabled": false, "listen_channels": [] }, + "webchat": { "enabled": false, "port": 8080, "host": "127.0.0.1" }, + "webhooks": { "enabled": false, "port": 8090, "token": "" }, + "memory": { "workspace": "~/.aetheel/workspace", "db_path": "~/.aetheel/memory.db" }, + "heartbeat": { "enabled": true, "default_channel": "slack" }, + "hooks": { "enabled": true }, + "mcp": { "servers": {} } +} +``` -#### OpenCode (default) +Adapters auto-enable when their token is set in `.env`, even without `enabled: true`. -| Variable | Default | Description | -|----------|---------|-------------| -| `OPENCODE_MODE` | `cli` | `cli` (subprocess) or `sdk` (server API) | -| `OPENCODE_MODEL` | auto | Model, e.g. `anthropic/claude-sonnet-4-20250514` | -| `OPENCODE_TIMEOUT` | `120` | CLI timeout in seconds | -| `OPENCODE_SERVER_URL` | `http://localhost:4096` | SDK mode server URL | -| `OPENCODE_WORKSPACE` | `.` | Working directory for OpenCode | +### Secrets (.env) -#### Claude Code (`--claude`) +```bash +# Slack (required for Slack adapter) +SLACK_BOT_TOKEN=xoxb-... +SLACK_APP_TOKEN=xapp-... -| Variable | Default | Description | -|----------|---------|-------------| -| `CLAUDE_MODEL` | auto | Model, e.g. `claude-sonnet-4-20250514` | -| `CLAUDE_TIMEOUT` | `120` | CLI timeout in seconds | -| `CLAUDE_MAX_TURNS` | `3` | Max agentic tool-use turns | -| `CLAUDE_NO_TOOLS` | `true` | Disable tools for pure chat | +# Discord (required for Discord adapter) +DISCORD_BOT_TOKEN=... -### Memory System +# Telegram (required for Telegram adapter) +TELEGRAM_BOT_TOKEN=... -| Variable | Default | Description | -|----------|---------|-------------| -| `AETHEEL_WORKSPACE` | `~/.aetheel/workspace` | Path to memory files (SOUL.md, etc.) | -| `AETHEEL_MEMORY_DB` | `~/.aetheel/memory.db` | SQLite database path | +# Anthropic API key (for Claude Code runtime) +ANTHROPIC_API_KEY=sk-ant-... +``` -### General +### Environment Variable Overrides -| Variable | Default | Description | -|----------|---------|-------------| -| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | +| Variable | Overrides | +|----------|-----------| +| `AETHEEL_ENGINE` | `runtime.engine` | +| `OPENCODE_MODE` | `runtime.mode` | +| `OPENCODE_MODEL` | `runtime.model` | +| `OPENCODE_PROVIDER` | `runtime.provider` | +| `CLAUDE_MODEL` | `claude.model` | +| `CLAUDE_TIMEOUT` | `claude.timeout_seconds` | +| `CLAUDE_MAX_TURNS` | `claude.max_turns` | +| `LOG_LEVEL` | `log_level` | --- ## Memory Files -Aetheel auto-creates three identity files in `~/.aetheel/workspace/`: +Auto-created in `~/.aetheel/workspace/` (or personalized during setup): | File | Purpose | |------|---------| @@ -209,35 +354,58 @@ Aetheel auto-creates three identity files in `~/.aetheel/workspace/`: | `USER.md` | User preferences, context, background | | `MEMORY.md` | Long-term notes, facts, things to remember | -Edit these files directly — changes are picked up automatically via file watching. +All agents share the same identity files — the main agent and any background subagents it spawns all read from the same `SOUL.md`, `USER.md`, and `MEMORY.md`. This is by design: subagents are workers for the same assistant, not separate personalities. -Session logs are saved to `~/.aetheel/workspace/daily/YYYY-MM-DD.md` and indexed for search. +The installer lets you choose a personality preset (default, professional, casual, or custom) and fills in `USER.md` with your name, role, timezone, and preferences interactively. + +Edit these files directly anytime — changes are picked up automatically via file watching. + +Session logs: `~/.aetheel/workspace/daily/YYYY-MM-DD.md` --- ## Prerequisites -- **Python 3.14+** (managed via [uv](https://docs.astral.sh/uv/)) -- **Slack workspace** with bot + app tokens -- **One of:** - - [OpenCode](https://opencode.ai) CLI — `curl -fsSL https://opencode.ai/install | bash` +- Python 3.12+ (managed via [uv](https://docs.astral.sh/uv/)) +- At least one chat platform token (Slack, Discord, Telegram, or WebChat) +- At least one AI runtime: + - [OpenCode](https://opencode.ai) — `curl -fsSL https://opencode.ai/install | bash` - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — `npm install -g @anthropic-ai/claude-code` +The installer handles all of this interactively. + --- ## Development ```bash -# Run tests -uv run python test_memory.py # Memory system smoke test -uv run python test_slack.py # Slack adapter unit tests +# Run full test suite +uv run python test_all.py -# Check help -uv run python main.py --help +# Run pytest +uv run python -m pytest tests/ -v + +# Diagnostics +uv run python cli.py doctor ``` --- +## Docs + +| Document | Description | +|----------|-------------| +| [commands.md](docs/commands.md) | Full command reference (chat + terminal) | +| [setup.md](docs/setup.md) | Detailed setup guide | +| [configuration.md](docs/configuration.md) | Config deep dive | +| [security-audit.md](docs/security-audit.md) | Security audit findings | +| [memory-system.md](docs/memory-system.md) | Memory architecture | +| [features-guide.md](docs/features-guide.md) | Feature walkthrough | +| [slack-setup.md](docs/slack-setup.md) | Slack app creation | +| [discord-setup.md](docs/discord-setup.md) | Discord bot setup | + +--- + ## Inspired By Built with inspiration from [OpenClaw](https://github.com/nichochar/openclaw) — a TypeScript AI agent framework. Aetheel reimplements the core concepts (memory, hybrid search, session management) in Python with a local-first philosophy. diff --git a/adapters/discord_adapter.py b/adapters/discord_adapter.py new file mode 100644 index 0000000..696e66b --- /dev/null +++ b/adapters/discord_adapter.py @@ -0,0 +1,270 @@ +""" +Aetheel Discord Adapter +======================== +Connects to Discord via the Bot Gateway using discord.py. + +Features: + - Receives DMs and @mentions in guild channels + - Each channel = persistent conversation context + - Sends replies back to the same channel (threaded if supported) + - Chunked replies for Discord's 2000-char limit + - Extends BaseAdapter for multi-channel support + +Setup: + 1. Create a bot at https://discord.com/developers/applications + 2. Enable MESSAGE CONTENT intent in Bot settings + 3. Set DISCORD_BOT_TOKEN in .env + 4. Invite bot with: OAuth2 → URL Generator → bot scope + Send Messages + Read Message History + 5. Start with: python main.py --discord + +Usage: + from adapters.discord_adapter import DiscordAdapter + + adapter = DiscordAdapter() + adapter.on_message(my_handler) + adapter.start() +""" + +import asyncio +import logging +import os +import threading +from datetime import datetime, timezone + +import discord + +from adapters.base import BaseAdapter, IncomingMessage + +logger = logging.getLogger("aetheel.discord") + + +def resolve_discord_token(explicit: str | None = None) -> str: + """Resolve the Discord bot token.""" + token = (explicit or os.environ.get("DISCORD_BOT_TOKEN", "")).strip() + if not token: + raise ValueError( + "Discord bot token is required. " + "Set DISCORD_BOT_TOKEN environment variable or pass it explicitly. " + "Get one from https://discord.com/developers/applications" + ) + return token + + +class DiscordAdapter(BaseAdapter): + """ + Discord channel adapter using discord.py. + + Handles: + - DMs (private messages) + - Guild channel messages where the bot is @mentioned + """ + + def __init__(self, bot_token: str | None = None, listen_channels: list[str] | None = None): + super().__init__() + self._token = resolve_discord_token(bot_token) + self._bot_user_id: int = 0 + self._bot_user_name: str = "" + self._running = False + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + + # Channels where the bot responds to ALL messages (no @mention needed). + # Set via DISCORD_LISTEN_CHANNELS env var (comma-separated IDs) or constructor. + if listen_channels is not None: + self._listen_channels: set[str] = set(listen_channels) + else: + raw = os.environ.get("DISCORD_LISTEN_CHANNELS", "").strip() + self._listen_channels = { + ch.strip() for ch in raw.split(",") if ch.strip() + } + + # Set up intents — need message content for reading messages + intents = discord.Intents.default() + intents.message_content = True + intents.dm_messages = True + + self._client = discord.Client(intents=intents) + self._register_handlers() + + # ------------------------------------------------------------------- + # BaseAdapter implementation + # ------------------------------------------------------------------- + + @property + def source_name(self) -> str: + return "discord" + + def start(self) -> None: + """Start the Discord adapter (blocking).""" + logger.info("Starting Discord adapter...") + self._running = True + self._client.run(self._token, log_handler=None) + + def start_async(self) -> None: + """Start the adapter in a background thread (non-blocking).""" + self._thread = threading.Thread( + target=self._run_in_thread, daemon=True, name="discord-adapter" + ) + self._thread.start() + logger.info("Discord adapter started in background thread") + + def stop(self) -> None: + """Stop the Discord adapter gracefully.""" + self._running = False + if self._loop and not self._loop.is_closed(): + asyncio.run_coroutine_threadsafe(self._client.close(), self._loop) + logger.info("Discord adapter stopped.") + + def send_message( + self, + channel_id: str, + text: str, + thread_id: str | None = None, + ) -> None: + """Send a message to a Discord channel or DM.""" + if not text.strip(): + return + + async def _send(): + target = self._client.get_channel(int(channel_id)) + if target is None: + try: + target = await self._client.fetch_channel(int(channel_id)) + except discord.NotFound: + logger.error(f"Channel {channel_id} not found") + return + + chunks = _chunk_text(text, 2000) + for chunk in chunks: + await target.send(chunk) + + if self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe(_send(), self._loop) + else: + asyncio.run(_send()) + + # ------------------------------------------------------------------- + # Internal: Event handlers + # ------------------------------------------------------------------- + + def _register_handlers(self) -> None: + """Register Discord event handlers.""" + + @self._client.event + async def on_ready(): + if self._client.user: + self._bot_user_id = self._client.user.id + self._bot_user_name = self._client.user.name + self._loop = asyncio.get_running_loop() + self._running = True + + logger.info("=" * 60) + logger.info(" Aetheel Discord Adapter") + logger.info("=" * 60) + logger.info(f" Bot: @{self._bot_user_name} ({self._bot_user_id})") + guilds = [g.name for g in self._client.guilds] + logger.info(f" Guilds: {', '.join(guilds) or 'none'}") + logger.info(f" Handlers: {len(self._message_handlers)} registered") + if self._listen_channels: + logger.info(f" Listen: {', '.join(self._listen_channels)} (no @mention needed)") + logger.info("=" * 60) + + @self._client.event + async def on_message(message: discord.Message): + # Ignore own messages + if message.author == self._client.user: + return + # Ignore other bots + if message.author.bot: + return + + is_dm = isinstance(message.channel, discord.DMChannel) + text = message.content + + # In guild channels: respond to @mentions everywhere, + # and respond to ALL messages in listen channels (no @mention needed). + if not is_dm: + channel_str = str(message.channel.id) + is_listen_channel = channel_str in self._listen_channels + + if self._client.user and self._client.user.mentioned_in(message): + # Strip the mention from the text + text = text.replace(f"<@{self._bot_user_id}>", "").strip() + text = text.replace(f"<@!{self._bot_user_id}>", "").strip() + elif not is_listen_channel: + return # Not mentioned and not a listen channel — ignore + + if not text.strip(): + return + + # Build IncomingMessage + user_name = message.author.display_name or message.author.name + if is_dm: + channel_name = f"DM with {user_name}" + else: + channel_name = getattr(message.channel, "name", str(message.channel.id)) + + msg = IncomingMessage( + text=text, + user_id=str(message.author.id), + user_name=user_name, + channel_id=str(message.channel.id), + channel_name=channel_name, + conversation_id=str(message.channel.id), + source="discord", + is_dm=is_dm, + timestamp=message.created_at.replace(tzinfo=timezone.utc) + if message.created_at.tzinfo is None + else message.created_at, + raw_event={ + "thread_id": None, + "message_id": message.id, + "guild_id": message.guild.id if message.guild else None, + }, + ) + + logger.info( + f"📨 [Discord] Message from {user_name} in {channel_name}: " + f"{text[:100]}" + ) + + # Dispatch synchronously in a thread to avoid blocking the event loop + # (handlers call subprocess-based AI runtimes which are blocking) + await asyncio.to_thread(self._dispatch, msg) + + def _run_in_thread(self) -> None: + """Run the Discord client in a dedicated thread with its own event loop.""" + self._running = True + self._client.run(self._token, log_handler=None) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _chunk_text(text: str, limit: int = 2000) -> list[str]: + """Split text into chunks respecting Discord's character limit.""" + if len(text) <= limit: + return [text] + + chunks = [] + remaining = text + while remaining: + if len(remaining) <= limit: + chunks.append(remaining) + break + + cut = limit + newline_pos = remaining.rfind("\n", 0, limit) + if newline_pos > limit // 2: + cut = newline_pos + 1 + else: + space_pos = remaining.rfind(" ", 0, limit) + if space_pos > limit // 2: + cut = space_pos + 1 + + chunks.append(remaining[:cut]) + remaining = remaining[cut:] + + return chunks diff --git a/adapters/webchat_adapter.py b/adapters/webchat_adapter.py new file mode 100644 index 0000000..f60f108 --- /dev/null +++ b/adapters/webchat_adapter.py @@ -0,0 +1,207 @@ +""" +Aetheel WebChat Adapter +======================== +Browser-based chat interface using aiohttp HTTP + WebSocket. + +Features: + - Serves a self-contained chat UI at GET / + - WebSocket endpoint at /ws for real-time messaging + - Per-connection session isolation with unique conversation IDs + - Runs sync ai_handler in thread pool executor + - Supports multiple concurrent WebSocket connections + +Setup: + 1. Enable webchat in config.json: {"webchat": {"enabled": true}} + 2. Start with: python main.py --webchat + 3. Open http://127.0.0.1:8080 in your browser + +Usage: + from adapters.webchat_adapter import WebChatAdapter + + adapter = WebChatAdapter(host="127.0.0.1", port=8080) + adapter.on_message(my_handler) + adapter.start() +""" + +import asyncio +import logging +import os +import threading +import uuid + +import aiohttp +from aiohttp import web + +from adapters.base import BaseAdapter, IncomingMessage + +logger = logging.getLogger("aetheel.adapters.webchat") + + +class WebChatAdapter(BaseAdapter): + """ + WebChat adapter serving an HTTP/WebSocket interface for browser-based chat. + + Each WebSocket connection gets a unique session ID and conversation ID, + ensuring full session isolation between concurrent users. + """ + + def __init__(self, host: str = "127.0.0.1", port: int = 8080): + super().__init__() + self._host = host + self._port = port + self._app = web.Application() + self._sessions: dict[str, str] = {} # ws_id -> conversation_id + self._runner: web.AppRunner | None = None + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._setup_routes() + + # ------------------------------------------------------------------- + # Route setup + # ------------------------------------------------------------------- + + def _setup_routes(self) -> None: + """Register HTTP and WebSocket routes.""" + self._app.router.add_get("/", self._serve_html) + self._app.router.add_get("/logo.jpeg", self._serve_logo) + self._app.router.add_get("/ws", self._handle_websocket) + + # ------------------------------------------------------------------- + # HTTP handler + # ------------------------------------------------------------------- + + async def _serve_html(self, request: web.Request) -> web.Response: + """Serve the chat UI HTML file.""" + static_dir = os.path.join(os.path.dirname(__file__), "..", "static") + html_path = os.path.join(static_dir, "chat.html") + if os.path.isfile(html_path): + return web.FileResponse(html_path) + return web.Response(text="Chat UI not found", status=404) + + async def _serve_logo(self, request: web.Request) -> web.Response: + """Serve the logo image.""" + static_dir = os.path.join(os.path.dirname(__file__), "..", "static") + logo_path = os.path.join(static_dir, "logo.jpeg") + if os.path.isfile(logo_path): + return web.FileResponse(logo_path) + return web.Response(status=404) + + # ------------------------------------------------------------------- + # WebSocket handler + # ------------------------------------------------------------------- + + async def _handle_websocket(self, request: web.Request) -> web.WebSocketResponse: + """Handle a WebSocket connection with per-session isolation.""" + ws = web.WebSocketResponse() + await ws.prepare(request) + + session_id = uuid.uuid4().hex + self._sessions[session_id] = f"webchat-{session_id}" + logger.info(f"WebChat session connected: {session_id}") + + try: + async for ws_msg in ws: + if ws_msg.type == aiohttp.WSMsgType.TEXT: + incoming = IncomingMessage( + text=ws_msg.data, + user_id=session_id, + user_name="WebChat User", + channel_id=session_id, + channel_name="webchat", + conversation_id=f"webchat-{session_id}", + source="webchat", + is_dm=True, + raw_event={"session_id": session_id}, + ) + loop = asyncio.get_event_loop() + response = await loop.run_in_executor( + None, self._run_handler, incoming + ) + if response: + await ws.send_str(response) + elif ws_msg.type in ( + aiohttp.WSMsgType.ERROR, + aiohttp.WSMsgType.CLOSE, + ): + break + finally: + self._sessions.pop(session_id, None) + logger.info(f"WebChat session disconnected: {session_id}") + + return ws + + def _run_handler(self, msg: IncomingMessage) -> str | None: + """Run registered message handlers synchronously (called from executor).""" + for handler in self._message_handlers: + try: + response = handler(msg) + if response: + return response + except Exception as e: + logger.error(f"WebChat handler error: {e}", exc_info=True) + return "⚠️ Something went wrong processing your message." + return None + + # ------------------------------------------------------------------- + # BaseAdapter implementation + # ------------------------------------------------------------------- + + @property + def source_name(self) -> str: + return "webchat" + + def start(self) -> None: + """Start the WebChat server (blocking).""" + asyncio.run(self._run_server()) + + def start_async(self) -> None: + """Start the WebChat server in a background thread (non-blocking).""" + self._thread = threading.Thread( + target=self._run_async, daemon=True, name="webchat" + ) + self._thread.start() + + def _run_async(self) -> None: + """Run the server in a dedicated thread with its own event loop.""" + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._run_server()) + + async def _run_server(self) -> None: + """Set up and run the aiohttp server.""" + self._runner = web.AppRunner(self._app) + await self._runner.setup() + site = web.TCPSite(self._runner, self._host, self._port) + await site.start() + logger.info(f"WebChat server running at http://{self._host}:{self._port}") + try: + while True: + await asyncio.sleep(3600) + except asyncio.CancelledError: + pass + + def stop(self) -> None: + """Stop the WebChat server gracefully.""" + if self._runner: + if self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe( + self._runner.cleanup(), self._loop + ) + logger.info("WebChat server stopped") + + def send_message( + self, + channel_id: str, + text: str, + thread_id: str | None = None, + ) -> None: + """ + Send a message to a channel. + + For WebChat, responses are sent directly over the WebSocket in the + handler, so this method only logs a debug message. + """ + logger.debug( + f"WebChat send_message called for {channel_id} " + "(responses sent via WebSocket)" + ) diff --git a/agent/claude_runtime.py b/agent/claude_runtime.py index d6e1010..0c89dbe 100644 --- a/agent/claude_runtime.py +++ b/agent/claude_runtime.py @@ -27,6 +27,32 @@ from agent.opencode_runtime import AgentResponse, SessionStore logger = logging.getLogger("aetheel.agent.claude") +# --------------------------------------------------------------------------- +# Rate Limit Detection +# --------------------------------------------------------------------------- + +_RATE_LIMIT_PATTERNS = [ + "rate limit", + "rate_limit", + "too many requests", + "429", + "quota exceeded", + "usage limit", + "capacity", + "overloaded", + "credit balance", + "billing", + "exceeded your", + "max usage", +] + + +def _is_rate_limited(text: str) -> bool: + """Check if an error message indicates a rate limit or quota issue.""" + lower = text.lower() + return any(pattern in lower for pattern in _RATE_LIMIT_PATTERNS) + + # --------------------------------------------------------------------------- # CLI Resolution # --------------------------------------------------------------------------- @@ -85,9 +111,14 @@ class ClaudeCodeConfig: # claude -p flags output_format: str = "json" # "json", "text", or "stream-json" # Permission settings - allowed_tools: list[str] = field(default_factory=list) + allowed_tools: list[str] = field(default_factory=lambda: [ + "Bash", "Read", "Write", "Edit", "Glob", "Grep", + "WebSearch", "WebFetch", + "Task", "TaskOutput", "TaskStop", "Skill", + "TeamCreate", "TeamDelete", "SendMessage", + ]) # Whether to disable all tool use (pure conversation mode) - no_tools: bool = True # Default: no tools for chat responses + no_tools: bool = False # Default: tools enabled @classmethod def from_env(cls) -> "ClaudeCodeConfig": @@ -99,7 +130,7 @@ class ClaudeCodeConfig: max_turns=int(os.environ.get("CLAUDE_MAX_TURNS", "3")), workspace_dir=os.environ.get("CLAUDE_WORKSPACE"), system_prompt=os.environ.get("CLAUDE_SYSTEM_PROMPT"), - no_tools=os.environ.get("CLAUDE_NO_TOOLS", "true").lower() == "true", + no_tools=os.environ.get("CLAUDE_NO_TOOLS", "false").lower() == "true", ) @@ -222,10 +253,11 @@ class ClaudeCodeRuntime: return AgentResponse( text="", error=f"Claude Code error: {error_text[:500]}", + rate_limited=_is_rate_limited(error_text), ) # Parse the output - response_text, session_id = self._parse_output(stdout) + response_text, session_id, usage = self._parse_output(stdout) if not response_text: response_text = stdout # Fallback to raw output @@ -234,11 +266,22 @@ class ClaudeCodeRuntime: if session_id and conversation_id: self._sessions.set(conversation_id, session_id) + # Detect rate limiting from error text + rate_limited = False + if not response_text and stderr: + rate_limited = _is_rate_limited(stderr) + if usage and usage.get("is_error"): + rate_limited = rate_limited or _is_rate_limited( + usage.get("error_text", "") + ) + return AgentResponse( text=response_text, session_id=session_id, model=self._config.model, provider="anthropic", + usage=usage, + rate_limited=rate_limited, ) except subprocess.TimeoutExpired: @@ -314,7 +357,7 @@ class ClaudeCodeRuntime: env = os.environ.copy() return env - def _parse_output(self, stdout: str) -> tuple[str, str | None]: + def _parse_output(self, stdout: str) -> tuple[str, str | None, dict | None]: """ Parse claude CLI output. @@ -334,7 +377,7 @@ class ClaudeCodeRuntime: With --output-format text, it returns plain text. """ if not stdout.strip(): - return "", None + return "", None, None # Try JSON format first try: @@ -345,12 +388,23 @@ class ClaudeCodeRuntime: text = data.get("result", "") session_id = data.get("session_id") + # Extract usage stats + usage = { + "cost_usd": data.get("cost_usd", 0), + "num_turns": data.get("num_turns", 0), + "duration_ms": data.get("duration_ms", 0), + "duration_api_ms": data.get("duration_api_ms", 0), + "is_error": data.get("is_error", False), + "subtype": data.get("subtype", ""), + } + if data.get("is_error"): error_msg = text or data.get("error", "Unknown error") + usage["error_text"] = error_msg logger.warning(f"Claude returned error: {error_msg[:200]}") - return f"⚠️ {error_msg}", session_id + return f"⚠️ {error_msg}", session_id, usage - return text, session_id + return text, session_id, usage except json.JSONDecodeError: pass @@ -358,6 +412,7 @@ class ClaudeCodeRuntime: # Try JSONL (stream-json) format text_parts = [] session_id = None + usage = None for line in stdout.splitlines(): line = line.strip() if not line: @@ -368,6 +423,13 @@ class ClaudeCodeRuntime: if event.get("type") == "result": text_parts.append(event.get("result", "")) session_id = event.get("session_id", session_id) + usage = { + "cost_usd": event.get("cost_usd", 0), + "num_turns": event.get("num_turns", 0), + "duration_ms": event.get("duration_ms", 0), + "duration_api_ms": event.get("duration_api_ms", 0), + "is_error": event.get("is_error", False), + } elif event.get("type") == "assistant" and "message" in event: # Extract text from content blocks msg = event["message"] @@ -380,10 +442,10 @@ class ClaudeCodeRuntime: continue if text_parts: - return "\n".join(text_parts), session_id + return "\n".join(text_parts), session_id, usage # Fallback: treat as plain text - return stdout, None + return stdout, None, None # ------------------------------------------------------------------- # Validation diff --git a/agent/opencode_runtime.py b/agent/opencode_runtime.py index 7cf9471..c258107 100644 --- a/agent/opencode_runtime.py +++ b/agent/opencode_runtime.py @@ -32,7 +32,9 @@ Usage: import json import logging import os +import queue import shutil +import sqlite3 import subprocess import threading import time @@ -44,6 +46,32 @@ from typing import Any, Callable logger = logging.getLogger("aetheel.agent") +# --------------------------------------------------------------------------- +# Rate Limit Detection +# --------------------------------------------------------------------------- + +_RATE_LIMIT_PATTERNS = [ + "rate limit", + "rate_limit", + "too many requests", + "429", + "quota exceeded", + "usage limit", + "capacity", + "overloaded", + "credit balance", + "billing", + "exceeded your", + "max usage", +] + + +def _is_rate_limited(text: str) -> bool: + """Check if an error message indicates a rate limit or quota issue.""" + lower = text.lower() + return any(pattern in lower for pattern in _RATE_LIMIT_PATTERNS) + + def _resolve_opencode_command(explicit: str | None = None) -> str: """ Resolve the opencode binary path. @@ -174,6 +202,7 @@ class AgentResponse: duration_ms: int = 0 usage: dict | None = None error: str | None = None + rate_limited: bool = False @property def ok(self) -> bool: @@ -189,54 +218,220 @@ class SessionStore: """ Maps external IDs (e.g., Slack thread_ts) to OpenCode session IDs. Mirrors OpenClaw's session isolation: each channel thread gets its own session. + + Backed by SQLite for persistence across restarts. Falls back to in-memory + if the database cannot be opened. """ - def __init__(self): - self._sessions: dict[str, dict] = {} + def __init__(self, db_path: str | None = None): self._lock = threading.Lock() + self._db_path = db_path or os.path.join( + os.path.expanduser("~/.aetheel"), "sessions.db" + ) + os.makedirs(os.path.dirname(self._db_path), exist_ok=True) + self._init_db() + + def _init_db(self) -> None: + """Initialize the sessions table.""" + with sqlite3.connect(self._db_path) as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sessions ( + external_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL, + last_used REAL NOT NULL + ) + """ + ) + conn.commit() + logger.debug(f"Session store initialized: {self._db_path}") + + def _conn(self) -> sqlite3.Connection: + conn = sqlite3.connect(self._db_path) + conn.row_factory = sqlite3.Row + return conn def get(self, external_id: str) -> str | None: """Get the OpenCode session ID for an external conversation ID.""" with self._lock: - entry = self._sessions.get(external_id) - if entry: - entry["last_used"] = time.time() - return entry["session_id"] + with self._conn() as conn: + row = conn.execute( + "SELECT session_id FROM sessions WHERE external_id = ?", + (external_id,), + ).fetchone() + if row: + conn.execute( + "UPDATE sessions SET last_used = ? WHERE external_id = ?", + (time.time(), external_id), + ) + conn.commit() + return row["session_id"] return None - def set(self, external_id: str, session_id: str) -> None: + def set(self, external_id: str, session_id: str, source: str = "") -> None: """Map an external ID to an OpenCode session ID.""" + now = time.time() with self._lock: - self._sessions[external_id] = { - "session_id": session_id, - "created": time.time(), - "last_used": time.time(), - } + with self._conn() as conn: + conn.execute( + """ + INSERT INTO sessions (external_id, session_id, source, created_at, last_used) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(external_id) DO UPDATE SET + session_id = excluded.session_id, + last_used = excluded.last_used + """, + (external_id, session_id, source, now, now), + ) + conn.commit() def remove(self, external_id: str) -> None: - """Remove -a session mapping.""" + """Remove a session mapping.""" with self._lock: - self._sessions.pop(external_id, None) + with self._conn() as conn: + conn.execute( + "DELETE FROM sessions WHERE external_id = ?", + (external_id,), + ) + conn.commit() def cleanup(self, ttl_hours: int = 24) -> int: """Remove stale sessions older than ttl_hours. Returns count removed.""" cutoff = time.time() - (ttl_hours * 3600) - removed = 0 with self._lock: - stale = [ - k - for k, v in self._sessions.items() - if v["last_used"] < cutoff - ] - for k in stale: - del self._sessions[k] - removed += 1 - return removed + with self._conn() as conn: + cursor = conn.execute( + "DELETE FROM sessions WHERE last_used < ?", + (cutoff,), + ) + conn.commit() + return cursor.rowcount + + def list_all(self) -> list[dict]: + """List all active sessions (for diagnostics).""" + with self._lock: + with self._conn() as conn: + rows = conn.execute( + "SELECT external_id, session_id, source, created_at, last_used " + "FROM sessions ORDER BY last_used DESC" + ).fetchall() + return [dict(row) for row in rows] @property def count(self) -> int: with self._lock: - return len(self._sessions) + with self._conn() as conn: + row = conn.execute("SELECT COUNT(*) as c FROM sessions").fetchone() + return row["c"] if row else 0 + + +# --------------------------------------------------------------------------- +# Live Session — IPC Message Streaming +# (Mirrors nanoclaw's MessageStream + IPC polling pattern) +# --------------------------------------------------------------------------- + + +@dataclass +class LiveSession: + """ + A live, long-running agent session that accepts follow-up messages. + + In CLI mode: holds a running `opencode run` subprocess. Follow-up + messages are queued and sent as new subprocess invocations that + --continue the same session. + + In SDK mode: holds a session ID. Follow-up messages are sent via + the SDK's session.prompt() to the same session. + """ + + conversation_id: str + session_id: str | None = None + created_at: float = field(default_factory=time.time) + last_activity: float = field(default_factory=time.time) + message_count: int = 0 + _lock: threading.Lock = field(default_factory=threading.Lock) + + def touch(self) -> None: + """Update last activity timestamp.""" + self.last_activity = time.time() + + @property + def idle_seconds(self) -> float: + return time.time() - self.last_activity + + +class LiveSessionManager: + """ + Manages live sessions with idle timeout and cleanup. + + This is the IPC streaming layer — it keeps sessions alive between + messages so follow-up messages go to the same agent context, mirroring + nanoclaw's container-based session loop. + """ + + def __init__(self, idle_timeout_seconds: int = 1800): + self._sessions: dict[str, LiveSession] = {} + self._lock = threading.Lock() + self._idle_timeout = idle_timeout_seconds + self._cleanup_thread: threading.Thread | None = None + self._running = False + + def start(self) -> None: + """Start the background cleanup thread.""" + if self._running: + return + self._running = True + self._cleanup_thread = threading.Thread( + target=self._cleanup_loop, daemon=True, name="live-session-cleanup" + ) + self._cleanup_thread.start() + + def stop(self) -> None: + """Stop the cleanup thread.""" + self._running = False + + def get_or_create(self, conversation_id: str) -> LiveSession: + """Get an existing live session or create a new one.""" + with self._lock: + session = self._sessions.get(conversation_id) + if session: + session.touch() + return session + session = LiveSession(conversation_id=conversation_id) + self._sessions[conversation_id] = session + logger.debug(f"Live session created: {conversation_id}") + return session + + def get(self, conversation_id: str) -> LiveSession | None: + """Get an existing live session (or None).""" + with self._lock: + return self._sessions.get(conversation_id) + + def remove(self, conversation_id: str) -> None: + """Remove a live session.""" + with self._lock: + self._sessions.pop(conversation_id, None) + + def list_active(self) -> list[LiveSession]: + """List all active live sessions.""" + with self._lock: + return list(self._sessions.values()) + + def _cleanup_loop(self) -> None: + """Periodically remove idle sessions.""" + while self._running: + time.sleep(60) + with self._lock: + stale = [ + cid + for cid, s in self._sessions.items() + if s.idle_seconds > self._idle_timeout + ] + for cid in stale: + del self._sessions[cid] + logger.info(f"Live session expired (idle): {cid}") # --------------------------------------------------------------------------- @@ -263,6 +458,10 @@ class OpenCodeRuntime: def __init__(self, config: OpenCodeConfig | None = None): self._config = config or OpenCodeConfig.from_env() self._sessions = SessionStore() + self._live_sessions = LiveSessionManager( + idle_timeout_seconds=self._config.session_ttl_hours * 3600 + ) + self._live_sessions.start() self._sdk_client = None self._sdk_available = False @@ -293,6 +492,9 @@ class OpenCodeRuntime: Send a message to the AI agent and get a response. This is the main entry point, used by the Slack adapter's message handler. + If a live session exists for this conversation_id, the message is sent + as a follow-up to the existing session (IPC streaming). Otherwise a + new session is created. Args: message: The user's message text @@ -311,6 +513,18 @@ class OpenCodeRuntime: ) try: + # Check for an active live session — if one exists, this is a + # follow-up message that should continue the same agent context + if conversation_id: + live = self._live_sessions.get(conversation_id) + if live and live.session_id: + logger.info( + f"Follow-up message to live session " + f"{conversation_id} (agent session={live.session_id[:8]}...)" + ) + live.touch() + live.message_count += 1 + # Route to the appropriate mode if self._config.mode == RuntimeMode.SDK and self._sdk_available: result = self._chat_sdk(message, conversation_id, system_prompt) @@ -318,6 +532,14 @@ class OpenCodeRuntime: result = self._chat_cli(message, conversation_id, system_prompt) result.duration_ms = int((time.time() - started) * 1000) + + # Track the live session + if conversation_id and result.session_id: + live = self._live_sessions.get_or_create(conversation_id) + live.session_id = result.session_id + live.touch() + live.message_count += 1 + return result except Exception as e: @@ -329,6 +551,71 @@ class OpenCodeRuntime: duration_ms=duration_ms, ) + def send_followup( + self, + message: str, + conversation_id: str, + system_prompt: str | None = None, + ) -> AgentResponse: + """ + Send a follow-up message to an active live session. + + This is the IPC streaming entry point — it pipes a new message into + an existing agent session, mirroring nanoclaw's MessageStream pattern + where the host writes IPC files that get consumed by the running agent. + + If no live session exists, falls back to a regular chat() call which + will create a new session or resume the persisted one. + + Args: + message: The follow-up message text + conversation_id: The conversation to send to + system_prompt: Optional system prompt override + + Returns: + AgentResponse with the AI's reply + """ + live = self._live_sessions.get(conversation_id) + if not live or not live.session_id: + logger.debug( + f"No live session for {conversation_id}, " + f"falling back to chat()" + ) + return self.chat(message, conversation_id, system_prompt) + + logger.info( + f"IPC follow-up: conversation={conversation_id}, " + f"session={live.session_id[:8]}..., " + f"msg_count={live.message_count + 1}" + ) + live.touch() + live.message_count += 1 + + # Route through the normal chat — the SessionStore already has the + # mapping from conversation_id → opencode session_id, so the CLI + # will use --continue --session, and the SDK will reuse the session. + return self.chat(message, conversation_id, system_prompt) + + def close_session(self, conversation_id: str) -> bool: + """ + Close a live session explicitly. + + Mirrors nanoclaw's _close sentinel — signals that the session + should end and resources should be freed. + + Returns True if a session was closed. + """ + live = self._live_sessions.get(conversation_id) + if live: + self._live_sessions.remove(conversation_id) + logger.info( + f"Live session closed: {conversation_id} " + f"(messages={live.message_count}, " + f"alive={int(live.idle_seconds)}s)" + ) + return True + return False + def get_status(self) -> dict: """Get the runtime status (for the /status command).""" status = { @@ -336,6 +623,7 @@ class OpenCodeRuntime: "model": self._config.model or "default", "provider": self._config.provider or "auto", "active_sessions": self._sessions.count, + "live_sessions": len(self._live_sessions.list_active()), "opencode_available": self._is_opencode_available(), } @@ -401,6 +689,7 @@ class OpenCodeRuntime: return AgentResponse( text="", error=f"OpenCode CLI error: {error_text[:500]}", + rate_limited=_is_rate_limited(error_text), ) # Parse the output — mirrors OpenClaw's parseCliJson/parseCliJsonl @@ -842,6 +1131,24 @@ def build_aetheel_system_prompt( "When scheduling a reminder, confirm to the user that it's been set,", "and include the action tag in your response (it will be hidden from the user).", "", + "# Your Tools", + "- You have access to shell commands, file operations, and web search", + "- Use web search to look up current information when needed", + "- You can read and write files in the workspace (~/.aetheel/workspace/)", + "- You can execute shell commands for system tasks", + "", + "# Self-Modification", + "- You can edit your own config at ~/.aetheel/config.json", + "- You can create new skills by writing SKILL.md files to ~/.aetheel/workspace/skills//SKILL.md", + "- You can update your identity files (SOUL.md, USER.md, MEMORY.md)", + "- You can modify HEARTBEAT.md to change your periodic tasks", + "- After editing config, tell the user to restart or use /reload", + "", + "# Subagents & Teams", + "- You can spawn background subagents for long-running tasks using [ACTION:spawn|]", + "- You can use Team tools (TeamCreate, SendMessage) for multi-agent coordination", + "- Use /subagents to list active background tasks", + "", "# Guidelines", "- Be helpful, concise, and friendly", "- Use Slack formatting (bold with *text*, code with `text`, etc.)", diff --git a/agent/subagent.py b/agent/subagent.py index 57e5535..783ed7a 100644 --- a/agent/subagent.py +++ b/agent/subagent.py @@ -59,6 +59,39 @@ SendFunction = Callable[[str, str, str | None, str], None] # send_fn(channel_id, text, thread_id, channel_type) +# --------------------------------------------------------------------------- +# Subagent Bus (pub/sub for inter-subagent communication) +# --------------------------------------------------------------------------- + + +class SubagentBus: + """Simple pub/sub message bus for inter-subagent communication.""" + + def __init__(self): + self._channels: dict[str, list[Callable]] = {} + self._lock = threading.Lock() + + def subscribe(self, channel: str, callback: Callable[[str, str], None]) -> None: + """Register a callback for messages on a channel.""" + with self._lock: + self._channels.setdefault(channel, []).append(callback) + + def publish(self, channel: str, message: str, sender: str) -> None: + """Publish a message to all subscribers of a channel.""" + with self._lock: + callbacks = list(self._channels.get(channel, [])) + for cb in callbacks: + try: + cb(message, sender) + except Exception as e: + logger.error(f"SubagentBus callback error: {e}") + + def unsubscribe_all(self, channel: str) -> None: + """Remove all subscribers from a channel.""" + with self._lock: + self._channels.pop(channel, None) + + # --------------------------------------------------------------------------- # Subagent Manager # --------------------------------------------------------------------------- @@ -84,6 +117,12 @@ class SubagentManager: self._max_concurrent = max_concurrent self._tasks: dict[str, SubagentTask] = {} self._lock = threading.Lock() + self._bus = SubagentBus() + + @property + def bus(self) -> SubagentBus: + """The pub/sub message bus for inter-subagent communication.""" + return self._bus def spawn( self, diff --git a/agent/teams.py b/agent/teams.py new file mode 100644 index 0000000..e69de29 diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..0c57bbb --- /dev/null +++ b/cli.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +""" +Aetheel CLI +=========== +Click-based command-line interface for Aetheel. + +Usage: + aetheel Start with default adapters + aetheel start --discord Start with Discord adapter + aetheel chat "Hello" One-shot AI query + aetheel status Show runtime status + aetheel doctor Run diagnostics + aetheel config show Show current config + aetheel cron list List scheduled jobs + aetheel memory search "q" Search memory +""" + +import json +import os +import subprocess +import sys + +import click + +from config import ( + CONFIG_PATH, + AetheelConfig, + load_config, + save_default_config, + write_mcp_config, +) + + +@click.group(invoke_without_command=True) +@click.pass_context +def cli(ctx): + """Aetheel — AI-Powered Personal Assistant""" + if ctx.invoked_subcommand is None: + ctx.invoke(start) + + +@cli.command() +@click.option("--discord", is_flag=True, help="Override: enable Discord adapter") +@click.option("--telegram", is_flag=True, help="Override: enable Telegram adapter") +@click.option("--webchat", is_flag=True, help="Override: enable WebChat adapter") +@click.option("--claude", is_flag=True, help="Override: use Claude Code runtime") +@click.option("--model", default=None, help="Override: model to use") +@click.option("--test", is_flag=True, help="Use echo handler for testing") +@click.option("--log", default="INFO", help="Log level") +def start(discord, telegram, webchat, claude, model, test, log): + """Start Aetheel (all settings from config, flags are optional overrides).""" + # Build sys.argv equivalent and delegate to main.main() + argv = ["main.py"] + if discord: + argv.append("--discord") + if telegram: + argv.append("--telegram") + if webchat: + argv.append("--webchat") + if claude: + argv.append("--claude") + if model: + argv.extend(["--model", model]) + if test: + argv.append("--test") + if log and log != "INFO": + argv.extend(["--log", log]) + + # Patch sys.argv so argparse inside main.main() sees our flags + old_argv = sys.argv + sys.argv = argv + try: + from main import main + main() + finally: + sys.argv = old_argv + + +@cli.command() +@click.argument("message") +def chat(message): + """One-shot chat with the AI.""" + import logging + + from dotenv import load_dotenv + + load_dotenv() + + save_default_config() + cfg = load_config() + + logging.basicConfig( + level=logging.WARNING, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + ) + + # Write MCP config if needed + write_mcp_config(cfg.mcp, cfg.memory.workspace, False) + + # Initialize runtime + from agent.opencode_runtime import ( + OpenCodeConfig, + OpenCodeRuntime, + RuntimeMode, + build_aetheel_system_prompt, + ) + + oc_config = OpenCodeConfig( + mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, + server_url=cfg.runtime.server_url, + timeout_seconds=cfg.runtime.timeout_seconds, + model=cfg.runtime.model, + provider=cfg.runtime.provider, + workspace_dir=cfg.runtime.workspace, + format=cfg.runtime.format, + ) + runtime = OpenCodeRuntime(oc_config) + + # Run one-shot query + system_prompt = build_aetheel_system_prompt() + response = runtime.chat(message, system_prompt=system_prompt) + click.echo(response.text if hasattr(response, "text") else str(response)) + + +@cli.command() +def status(): + """Show runtime status.""" + from dotenv import load_dotenv + + load_dotenv() + + save_default_config() + cfg = load_config() + + click.echo("🟢 Aetheel Status") + click.echo() + click.echo(f" Config: {CONFIG_PATH}") + click.echo(f" Runtime: {cfg.runtime.mode}, model={cfg.runtime.model or 'default'}") + click.echo(f" Workspace: {cfg.memory.workspace}") + click.echo(f" Heartbeat: {'enabled' if cfg.heartbeat.enabled else 'disabled'}") + click.echo(f" WebChat: {'enabled' if cfg.webchat.enabled else 'disabled'}") + + # Show scheduler jobs if possible + try: + from scheduler import Scheduler + + sched = Scheduler(callback=lambda j: None) + sched.start() + jobs = sched.list_jobs() + click.echo(f" Jobs: {len(jobs)}") + sched.stop() + except Exception: + click.echo(" Jobs: (scheduler unavailable)") + + +# --------------------------------------------------------------------------- +# cron group +# --------------------------------------------------------------------------- + + +@cli.group() +def cron(): + """Manage scheduled jobs.""" + pass + + +@cron.command("list") +def cron_list(): + """List scheduled jobs.""" + from dotenv import load_dotenv + + load_dotenv() + + save_default_config() + + try: + from scheduler import Scheduler + + sched = Scheduler(callback=lambda j: None) + sched.start() + jobs = sched.list_jobs() + if not jobs: + click.echo("No scheduled jobs.") + else: + for job in jobs: + click.echo( + f" [{job.job_id}] {job.job_type} — {job.prompt[:60]}" + ) + sched.stop() + except Exception as e: + click.echo(f"Error listing jobs: {e}") + + +@cron.command("remove") +@click.argument("job_id") +def cron_remove(job_id): + """Remove a scheduled job.""" + from dotenv import load_dotenv + + load_dotenv() + + save_default_config() + + try: + from scheduler import Scheduler + + sched = Scheduler(callback=lambda j: None) + sched.start() + sched.remove_job(job_id) + click.echo(f"Removed job {job_id}") + sched.stop() + except Exception as e: + click.echo(f"Error removing job: {e}") + + +# --------------------------------------------------------------------------- +# config group +# --------------------------------------------------------------------------- + + +@cli.group() +def config(): + """Manage configuration.""" + pass + + +@config.command("show") +def config_show(): + """Show current configuration.""" + if os.path.isfile(CONFIG_PATH): + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + click.echo(f.read()) + else: + click.echo(f"No config file found at {CONFIG_PATH}") + click.echo("Run 'aetheel config init' to create one.") + + +@config.command("edit") +def config_edit(): + """Open config in editor.""" + save_default_config() + editor = os.environ.get("EDITOR", "nano") + try: + subprocess.run([editor, CONFIG_PATH], check=True) + except FileNotFoundError: + click.echo(f"Editor '{editor}' not found. Set $EDITOR or edit manually:") + click.echo(f" {CONFIG_PATH}") + except subprocess.CalledProcessError as e: + click.echo(f"Editor exited with error: {e}") + + +@config.command("init") +def config_init(): + """Reset config to defaults.""" + # Force-write by removing existing file first + if os.path.isfile(CONFIG_PATH): + os.remove(CONFIG_PATH) + click.echo(f"Removed existing config at {CONFIG_PATH}") + path = save_default_config() + click.echo(f"Default config written to {path}") + + +# --------------------------------------------------------------------------- +# memory group +# --------------------------------------------------------------------------- + + +@cli.group() +def memory(): + """Manage memory system.""" + pass + + +@memory.command("search") +@click.argument("query") +def memory_search(query): + """Search memory.""" + import asyncio + import logging + + from dotenv import load_dotenv + + load_dotenv() + + save_default_config() + cfg = load_config() + + logging.basicConfig(level=logging.WARNING) + + try: + from memory import MemoryManager + from memory.types import MemoryConfig as MemConfig + + workspace_dir = os.path.expanduser(cfg.memory.workspace) + db_path = os.path.expanduser(cfg.memory.db_path) + mem_config = MemConfig(workspace_dir=workspace_dir, db_path=db_path) + mem = MemoryManager(mem_config) + + results = asyncio.run(mem.search(query)) + if not results: + click.echo("No results found.") + else: + for r in results: + score = getattr(r, "score", "?") + text = getattr(r, "text", str(r)) + click.echo(f" [{score}] {text[:120]}") + mem.close() + except Exception as e: + click.echo(f"Memory search error: {e}") + + +@memory.command("sync") +def memory_sync(): + """Force memory re-index.""" + import asyncio + import logging + + from dotenv import load_dotenv + + load_dotenv() + + save_default_config() + cfg = load_config() + + logging.basicConfig(level=logging.WARNING) + + try: + from memory import MemoryManager + from memory.types import MemoryConfig as MemConfig + + workspace_dir = os.path.expanduser(cfg.memory.workspace) + db_path = os.path.expanduser(cfg.memory.db_path) + mem_config = MemConfig(workspace_dir=workspace_dir, db_path=db_path) + mem = MemoryManager(mem_config) + + stats = asyncio.run(mem.sync()) + click.echo( + f"Sync complete: {stats.get('files_indexed', 0)} files, " + f"{stats.get('chunks_created', 0)} chunks" + ) + mem.close() + except Exception as e: + click.echo(f"Memory sync error: {e}") + + +# --------------------------------------------------------------------------- +# doctor command +# --------------------------------------------------------------------------- + + +@cli.command() +def doctor(): + """Run diagnostics.""" + import shutil + + from dotenv import load_dotenv + + load_dotenv() + + click.echo("🩺 Aetheel Doctor") + click.echo() + + # Check config + if os.path.isfile(CONFIG_PATH): + click.echo(f" ✅ Config file: {CONFIG_PATH}") + try: + with open(CONFIG_PATH, "r") as f: + json.load(f) + click.echo(" ✅ Config JSON is valid") + except json.JSONDecodeError as e: + click.echo(f" ❌ Config JSON invalid: {e}") + else: + click.echo(f" ⚠️ No config file at {CONFIG_PATH}") + + # Check workspace + cfg = load_config() + workspace = os.path.expanduser(cfg.memory.workspace) + if os.path.isdir(workspace): + click.echo(f" ✅ Workspace: {workspace}") + else: + click.echo(f" ⚠️ Workspace missing: {workspace}") + + # Check runtimes + if shutil.which("claude"): + click.echo(" ✅ Claude Code CLI found") + else: + click.echo(" ⚠️ Claude Code CLI not found") + + if shutil.which("opencode"): + click.echo(" ✅ OpenCode CLI found") + else: + click.echo(" ⚠️ OpenCode CLI not found") + + # Check tokens + tokens = { + "SLACK_BOT_TOKEN": os.environ.get("SLACK_BOT_TOKEN"), + "SLACK_APP_TOKEN": os.environ.get("SLACK_APP_TOKEN"), + "TELEGRAM_BOT_TOKEN": os.environ.get("TELEGRAM_BOT_TOKEN"), + "DISCORD_BOT_TOKEN": os.environ.get("DISCORD_BOT_TOKEN"), + } + click.echo() + click.echo(" Tokens:") + for name, val in tokens.items(): + if val: + click.echo(f" ✅ {name} is set") + else: + click.echo(f" ⚠️ {name} not set") + + # Check memory DB + db_path = os.path.expanduser(cfg.memory.db_path) + if os.path.isfile(db_path): + click.echo(f" ✅ Memory DB: {db_path}") + else: + click.echo(f" ⚠️ Memory DB missing: {db_path}") + + click.echo() + click.echo("Done.") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + cli() diff --git a/config.py b/config.py new file mode 100644 index 0000000..f27113a --- /dev/null +++ b/config.py @@ -0,0 +1,434 @@ +""" +Aetheel Configuration +===================== +Loads configuration from ~/.aetheel/config.json with .env fallback for secrets. + +Config hierarchy (highest priority wins): + 1. CLI arguments (--model, --claude, etc.) + 2. Environment variables + 3. ~/.aetheel/config.json + 4. Defaults + +Secrets (tokens, passwords) should stay in .env — they are never written +to the config file. Everything else goes in config.json. + +Usage: + from config import load_config, AetheelConfig + + cfg = load_config() + print(cfg.runtime.model) + print(cfg.slack.bot_token) # from .env +""" + +import json +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path + +logger = logging.getLogger("aetheel.config") + +CONFIG_DIR = os.path.expanduser("~/.aetheel") +CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json") + + +# --------------------------------------------------------------------------- +# Config Dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class RuntimeConfig: + """AI runtime configuration.""" + engine: str = "opencode" # "opencode" or "claude" + mode: str = "cli" # "cli" or "sdk" + model: str | None = None # e.g. "anthropic/claude-sonnet-4-20250514" + provider: str | None = None + timeout_seconds: int = 120 + server_url: str = "http://localhost:4096" + workspace: str | None = None + format: str = "json" + + +@dataclass +class ClaudeConfig: + """Claude Code runtime configuration.""" + model: str | None = None + timeout_seconds: int = 120 + max_turns: int = 3 + no_tools: bool = False + allowed_tools: list[str] = field(default_factory=lambda: [ + "Bash", "Read", "Write", "Edit", "Glob", "Grep", + "WebSearch", "WebFetch", + "Task", "TaskOutput", "TaskStop", "Skill", + "TeamCreate", "TeamDelete", "SendMessage", + ]) + + +@dataclass +class SlackConfig: + """Slack adapter configuration. Tokens come from .env.""" + enabled: bool = True # auto-enabled when tokens present + bot_token: str = "" # from .env: SLACK_BOT_TOKEN + app_token: str = "" # from .env: SLACK_APP_TOKEN + + +@dataclass +class TelegramConfig: + """Telegram adapter configuration.""" + enabled: bool = False # enable in config or set token + bot_token: str = "" # from .env: TELEGRAM_BOT_TOKEN + + +@dataclass +class DiscordConfig: + """Discord adapter configuration.""" + enabled: bool = False # enable in config or set token + bot_token: str = "" # from .env: DISCORD_BOT_TOKEN + listen_channels: list[str] = field(default_factory=list) + + +@dataclass +class MemoryConfig: + """Memory system configuration.""" + workspace: str = "~/.aetheel/workspace" + db_path: str = "~/.aetheel/memory.db" + + +@dataclass +class SchedulerConfig: + """Scheduler configuration.""" + db_path: str = "~/.aetheel/scheduler.db" + + +@dataclass +class MCPServerConfig: + """Single MCP server entry.""" + command: str = "" + args: list[str] = field(default_factory=list) + env: dict[str, str] = field(default_factory=dict) + + +@dataclass +class MCPConfig: + """MCP server configuration.""" + servers: dict[str, MCPServerConfig] = field(default_factory=dict) + + +@dataclass +class HeartbeatConfig: + """Heartbeat / proactive system configuration.""" + enabled: bool = True + default_channel: str = "slack" + default_channel_id: str = "" + silent: bool = False + + +@dataclass +class WebChatConfig: + """WebChat adapter configuration.""" + enabled: bool = False + port: int = 8080 + host: str = "127.0.0.1" + + +@dataclass +class HooksConfig: + """Lifecycle hooks configuration.""" + enabled: bool = True + + +@dataclass +class WebhookConfig: + """Webhook receiver configuration.""" + enabled: bool = False + port: int = 8090 + host: str = "127.0.0.1" + token: str = "" # Bearer token for auth + + +@dataclass +class AetheelConfig: + """Top-level configuration for Aetheel.""" + log_level: str = "INFO" + runtime: RuntimeConfig = field(default_factory=RuntimeConfig) + claude: ClaudeConfig = field(default_factory=ClaudeConfig) + slack: SlackConfig = field(default_factory=SlackConfig) + telegram: TelegramConfig = field(default_factory=TelegramConfig) + discord: DiscordConfig = field(default_factory=DiscordConfig) + memory: MemoryConfig = field(default_factory=MemoryConfig) + scheduler: SchedulerConfig = field(default_factory=SchedulerConfig) + heartbeat: HeartbeatConfig = field(default_factory=HeartbeatConfig) + webchat: WebChatConfig = field(default_factory=WebChatConfig) + mcp: MCPConfig = field(default_factory=MCPConfig) + hooks: HooksConfig = field(default_factory=HooksConfig) + webhooks: WebhookConfig = field(default_factory=WebhookConfig) + + +# --------------------------------------------------------------------------- +# Loader +# --------------------------------------------------------------------------- + + +def _deep_get(data: dict, *keys, default=None): + """Safely traverse nested dicts.""" + for key in keys: + if not isinstance(data, dict): + return default + data = data.get(key, default) + return data + + +def load_config() -> AetheelConfig: + """ + Load configuration from config.json + environment variables. + + Config file provides non-secret settings. Environment variables + (from .env or shell) provide secrets and can override any setting. + """ + cfg = AetheelConfig() + file_data: dict = {} + + # 1. Load config.json if it exists + if os.path.isfile(CONFIG_PATH): + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + file_data = json.load(f) + logger.info(f"Config loaded from {CONFIG_PATH}") + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"Failed to load {CONFIG_PATH}: {e}") + + # 2. Apply config.json values + cfg.log_level = _deep_get(file_data, "log_level", default=cfg.log_level) + + # Runtime + rt = file_data.get("runtime", {}) + cfg.runtime.engine = rt.get("engine", cfg.runtime.engine) + cfg.runtime.mode = rt.get("mode", cfg.runtime.mode) + cfg.runtime.model = rt.get("model", cfg.runtime.model) + cfg.runtime.provider = rt.get("provider", cfg.runtime.provider) + cfg.runtime.timeout_seconds = rt.get("timeout_seconds", cfg.runtime.timeout_seconds) + cfg.runtime.server_url = rt.get("server_url", cfg.runtime.server_url) + cfg.runtime.workspace = rt.get("workspace", cfg.runtime.workspace) + cfg.runtime.format = rt.get("format", cfg.runtime.format) + + # Claude + cl = file_data.get("claude", {}) + cfg.claude.model = cl.get("model", cfg.claude.model) + cfg.claude.timeout_seconds = cl.get("timeout_seconds", cfg.claude.timeout_seconds) + cfg.claude.max_turns = cl.get("max_turns", cfg.claude.max_turns) + cfg.claude.no_tools = cl.get("no_tools", cfg.claude.no_tools) + + # Discord (non-secret settings from config) + dc = file_data.get("discord", {}) + cfg.discord.enabled = dc.get("enabled", cfg.discord.enabled) + cfg.discord.listen_channels = dc.get("listen_channels", cfg.discord.listen_channels) + + # Slack (enabled flag from config) + sl = file_data.get("slack", {}) + cfg.slack.enabled = sl.get("enabled", cfg.slack.enabled) + + # Telegram (enabled flag from config) + tg = file_data.get("telegram", {}) + cfg.telegram.enabled = tg.get("enabled", cfg.telegram.enabled) + + # Memory + mem = file_data.get("memory", {}) + cfg.memory.workspace = mem.get("workspace", cfg.memory.workspace) + cfg.memory.db_path = mem.get("db_path", cfg.memory.db_path) + + # Scheduler + sched = file_data.get("scheduler", {}) + cfg.scheduler.db_path = sched.get("db_path", cfg.scheduler.db_path) + + # Claude allowed_tools + cl_allowed = cl.get("allowed_tools") + if cl_allowed is not None: + cfg.claude.allowed_tools = cl_allowed + + # Heartbeat + hb = file_data.get("heartbeat", {}) + cfg.heartbeat.enabled = hb.get("enabled", cfg.heartbeat.enabled) + cfg.heartbeat.default_channel = hb.get("default_channel", cfg.heartbeat.default_channel) + cfg.heartbeat.default_channel_id = hb.get("default_channel_id", cfg.heartbeat.default_channel_id) + cfg.heartbeat.silent = hb.get("silent", cfg.heartbeat.silent) + + # WebChat + wc = file_data.get("webchat", {}) + cfg.webchat.enabled = wc.get("enabled", cfg.webchat.enabled) + cfg.webchat.port = wc.get("port", cfg.webchat.port) + cfg.webchat.host = wc.get("host", cfg.webchat.host) + + # MCP + mcp_data = file_data.get("mcp", {}) + servers_data = mcp_data.get("servers", {}) + for name, srv in servers_data.items(): + cfg.mcp.servers[name] = MCPServerConfig( + command=srv.get("command", ""), + args=srv.get("args", []), + env=srv.get("env", {}), + ) + + # Hooks + hk = file_data.get("hooks", {}) + cfg.hooks.enabled = hk.get("enabled", cfg.hooks.enabled) + + # Webhooks + wh = file_data.get("webhooks", {}) + cfg.webhooks.enabled = wh.get("enabled", cfg.webhooks.enabled) + cfg.webhooks.port = wh.get("port", cfg.webhooks.port) + cfg.webhooks.host = wh.get("host", cfg.webhooks.host) + cfg.webhooks.token = wh.get("token", cfg.webhooks.token) + + # 3. Environment variables override everything (secrets + overrides) + cfg.log_level = os.environ.get("LOG_LEVEL", cfg.log_level) + + cfg.runtime.engine = os.environ.get("AETHEEL_ENGINE", cfg.runtime.engine) + cfg.runtime.mode = os.environ.get("OPENCODE_MODE", cfg.runtime.mode) + cfg.runtime.model = os.environ.get("OPENCODE_MODEL") or cfg.runtime.model + cfg.runtime.provider = os.environ.get("OPENCODE_PROVIDER") or cfg.runtime.provider + cfg.runtime.timeout_seconds = int( + os.environ.get("OPENCODE_TIMEOUT", str(cfg.runtime.timeout_seconds)) + ) + cfg.runtime.server_url = os.environ.get("OPENCODE_SERVER_URL", cfg.runtime.server_url) + cfg.runtime.workspace = ( + os.environ.get("OPENCODE_WORKSPACE") + or os.environ.get("AETHEEL_WORKSPACE") + or cfg.runtime.workspace + ) + + cfg.claude.model = os.environ.get("CLAUDE_MODEL") or cfg.claude.model + cfg.claude.timeout_seconds = int( + os.environ.get("CLAUDE_TIMEOUT", str(cfg.claude.timeout_seconds)) + ) + cfg.claude.max_turns = int( + os.environ.get("CLAUDE_MAX_TURNS", str(cfg.claude.max_turns)) + ) + cfg.claude.no_tools = os.environ.get( + "CLAUDE_NO_TOOLS", str(cfg.claude.no_tools) + ).lower() == "true" + + # Secrets from .env only + cfg.slack.bot_token = os.environ.get("SLACK_BOT_TOKEN", cfg.slack.bot_token) + cfg.slack.app_token = os.environ.get("SLACK_APP_TOKEN", cfg.slack.app_token) + cfg.telegram.bot_token = os.environ.get("TELEGRAM_BOT_TOKEN", cfg.telegram.bot_token) + cfg.discord.bot_token = os.environ.get("DISCORD_BOT_TOKEN", cfg.discord.bot_token) + + # Discord listen channels: env overrides config + env_channels = os.environ.get("DISCORD_LISTEN_CHANNELS", "").strip() + if env_channels: + cfg.discord.listen_channels = [ch.strip() for ch in env_channels.split(",") if ch.strip()] + + cfg.memory.workspace = os.environ.get("AETHEEL_WORKSPACE", cfg.memory.workspace) + cfg.memory.db_path = os.environ.get("AETHEEL_MEMORY_DB", cfg.memory.db_path) + + return cfg + + +def save_default_config() -> str: + """ + Write a default config.json if one doesn't exist. + Returns the path to the config file. + """ + os.makedirs(CONFIG_DIR, exist_ok=True) + + if os.path.isfile(CONFIG_PATH): + return CONFIG_PATH + + default = { + "$schema": "Aetheel configuration — edit this file, keep secrets in .env", + "log_level": "INFO", + "runtime": { + "engine": "opencode", + "mode": "cli", + "model": None, + "timeout_seconds": 120, + "server_url": "http://localhost:4096", + "format": "json", + }, + "claude": { + "model": None, + "timeout_seconds": 120, + "max_turns": 3, + "no_tools": False, + "allowed_tools": [ + "Bash", "Read", "Write", "Edit", "Glob", "Grep", + "WebSearch", "WebFetch", + "Task", "TaskOutput", "TaskStop", "Skill", + "TeamCreate", "TeamDelete", "SendMessage", + ], + }, + "slack": { + "enabled": True, + }, + "telegram": { + "enabled": False, + }, + "discord": { + "enabled": False, + "listen_channels": [], + }, + "memory": { + "workspace": "~/.aetheel/workspace", + "db_path": "~/.aetheel/memory.db", + }, + "scheduler": { + "db_path": "~/.aetheel/scheduler.db", + }, + "heartbeat": { + "enabled": True, + "default_channel": "slack", + "default_channel_id": "", + "silent": False, + }, + "webchat": { + "enabled": False, + "port": 8080, + "host": "127.0.0.1", + }, + "mcp": { + "servers": {}, + }, + "hooks": { + "enabled": True, + }, + "webhooks": { + "enabled": False, + "port": 8090, + "host": "127.0.0.1", + "token": "", + }, + } + + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(default, f, indent=2) + + logger.info(f"Default config written to {CONFIG_PATH}") + return CONFIG_PATH + + +def write_mcp_config(mcp_config: MCPConfig, workspace_dir: str, use_claude: bool) -> None: + """Write MCP server config to the appropriate file for the runtime. + + Produces ``.mcp.json`` (Claude Code) or ``opencode.json`` (OpenCode) + in *workspace_dir*. Skips writing when no servers are configured. + """ + if not mcp_config.servers: + return + + config_data = { + "mcpServers" if use_claude else "mcp": { + name: {"command": s.command, "args": s.args, "env": s.env} + for name, s in mcp_config.servers.items() + } + } + + filename = ".mcp.json" if use_claude else "opencode.json" + path = os.path.join(os.path.expanduser(workspace_dir), filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + + with open(path, "w", encoding="utf-8") as f: + json.dump(config_data, f, indent=2) + + logger.info(f"MCP config written to {path}") + diff --git a/docs/Openclaw deep dive.md b/docs/Openclaw deep dive.md new file mode 100644 index 0000000..94cc3f5 --- /dev/null +++ b/docs/Openclaw deep dive.md @@ -0,0 +1,237 @@ + +# OpenClaw Architecture Deep Dive + +## What is OpenClaw? + +OpenClaw is an open source AI assistant created by Peter Steinberger (founder of PSP PDF kit) that gained 100,000 GitHub stars in 3 days - one of the fastest growing repositories in GitHub history. + +**Technical Definition:** An agent runtime with a gateway in front of it. + +Despite viral stories of agents calling owners at 3am, texting people's wives autonomously, and browsing Twitter overnight, OpenClaw isn't sentient. It's elegant event-driven engineering. + +## Core Architecture + +### The Gateway +- Long-running process on your machine +- Constantly accepts connections from messaging apps (WhatsApp, Telegram, Discord, iMessage, Slack) +- Routes messages to AI agents +- **Doesn't think, reason, or decide** - only accepts inputs and routes them + +### The Agent Runtime +- Processes events from the queue +- Executes actions using available tools +- Has deep system access: shell commands, file operations, browser control + +### State Persistence +- Memory stored as local markdown files +- Includes preferences, conversation history, context from previous sessions +- Agent "remembers" by reading these files on each wake-up +- Not real-time learning - just file reading + +### The Event Loop +All events enter a queue → Queue gets processed → Agents execute → State persists → Loop continues + +## The Five Input Types + +### 1. Messages (Human Input) +**How it works:** +- You send text via WhatsApp, iMessage, or Slack +- Gateway receives and routes to agent +- Agent responds + +**Key details:** +- Sessions are per-channel (WhatsApp and Slack are separate contexts) +- Multiple requests queue up and process in order +- No jumbled responses - finishes one thought before moving to next + +### 2. Heartbeats (Timer Events) +**How it works:** +- Timer fires at regular intervals (default: every 30 minutes) +- Gateway schedules an agent turn with a preconfigured prompt +- Agent responds to instructions like "Check inbox for urgent items" or "Review calendar" + +**Key details:** +- Configurable interval, prompt, and active hours +- If nothing urgent: agent returns `heartbeat_okay` token (suppressed from user) +- If something urgent: you get a ping +- **This is the secret sauce** - makes OpenClaw feel proactive + +**Example prompts:** +- "Check my inbox for anything urgent" +- "Review my calendar" +- "Look for overdue tasks" + +### 3. Cron Jobs (Scheduled Events) +**How it works:** +- More control than heartbeats +- Specify exact timing and custom instructions +- When time hits, event fires and prompt sent to agent + +**Examples:** +- 9am daily: "Check email and flag anything urgent" +- Every Monday 3pm: "Review calendar for the week and remind me of conflicts" +- Midnight: "Browse my Twitter feed and save interesting posts" +- 8am: "Text wife good morning" +- 10pm: "Text wife good night" + +**Real example:** The viral story of agent texting someone's wife was just cron jobs firing at scheduled times. Agent wasn't deciding - it was responding to scheduled prompts. + +### 4. Hooks (Internal State Changes) +**How it works:** +- System itself triggers these events +- Event-driven development pattern + +**Types:** +- Gateway startup → fires hook +- Agent begins task → fires hook +- Stop command issued → fires hook + +**Purpose:** +- Save memory on reset +- Run setup instructions on startup +- Modify context before agent runs +- Self-management + +### 5. Webhooks (External System Events) +**How it works:** +- External systems notify OpenClaw of events +- Agent responds to entire digital life + +**Examples:** +- Email hits inbox → webhook fires → agent processes +- Slack reaction → webhook fires → agent responds +- Jira ticket created → webhook fires → agent researches +- GitHub event → webhook fires → agent acts +- Calendar event approaches → webhook fires → agent reminds + +**Supported integrations:** Slack, Discord, GitHub, and basically anything with webhook support + +### Bonus: Agent-to-Agent Messaging +**How it works:** +- Multi-agent setups with isolated workspaces +- Agents pass messages between each other +- Each agent has different profile/specialization + +**Example:** +- Research Agent finishes gathering info +- Queues up work for Writing Agent +- Writing Agent processes and produces output + +**Reality:** Looks like collaboration, but it's just messages entering queues + +## Why It Feels Alive + +The combination creates an illusion of autonomy: + +**Time** (heartbeats, crons) → **Events** → **Queue** → **Agent Execution** → **State Persistence** → **Loop** + +### The 3am Phone Call Example + +**What it looked like:** +- Agent autonomously decided to get phone number +- Agent decided to call owner +- Agent waited until 3am to execute + +**What actually happened:** +1. Some event fired (cron or heartbeat) - exact configuration unknown +2. Event entered queue +3. Agent processed with available tools and instructions +4. Agent acquired Twilio phone number +5. Agent made the call +6. Owner didn't ask in the moment, but behavior was enabled in setup + +**Key insight:** Nothing was thinking overnight. Nothing was deciding. Time produced event → Event kicked off agent → Agent followed instructions. + +## The Complete Event Flow + +**Event Sources:** +- Time creates events (heartbeats, crons) +- Humans create events (messages) +- External systems create events (webhooks) +- Internal state creates events (hooks) +- Agents create events for other agents + +**Processing:** +All events → Enter queue → Queue processed → Agents execute → State persists → Loop continues + +**Memory:** +- Stored in local markdown files +- Agent reads on wake-up +- Remembers previous conversations +- Not learning - just reading files you could open in text editor + +## Security Concerns + +### The Analysis +Cisco's security team analyzed OpenClaw ecosystem: +- 31,000 available skills examined +- 26% contain at least one vulnerability +- Called it "a security nightmare" + +### Why It's Risky +OpenClaw has deep system access: +- Run shell commands +- Read and write files +- Execute scripts +- Control browser + +### Specific Risks +1. **Prompt injection** through emails or documents +2. **Malicious skills** in marketplace +3. **Credential exposure** +4. **Command misinterpretation** that deletes unintended files + +### OpenClaw's Own Warning +Documentation states: "There's no perfectly secure setup" + +### Mitigation Strategies +- Run on secondary machine +- Use isolated accounts +- Limit enabled skills +- Monitor logs actively +- Use Railway's one-click deployment (runs in isolated container) + +## Key Architectural Takeaways + +### The Four Components +1. **Time** that produces events +2. **Events** that trigger agents +3. **State** that persists across interactions +4. **Loop** that keeps processing + +### Building Your Own +You don't need OpenClaw specifically. You need: +- Event scheduling mechanism +- Queue system +- LLM for processing +- State persistence layer + +### The Pattern +This architecture will appear everywhere. Every AI agent framework that "feels alive" uses some version of: +- Heartbeats +- Cron jobs +- Webhooks +- Event loops +- Persistent state + +### Understanding vs Hype +Understanding this architecture means you can: +- Evaluate agent tools intelligently +- Build your own implementations +- Avoid getting caught up in viral hype +- Recognize the pattern in new frameworks + +## The Bottom Line + +OpenClaw isn't magic. It's not sentient. It doesn't think or reason. + +**It's inputs, queues, and a loop.** + +The "alive" feeling comes from well-designed event-driven architecture that makes a reactive system appear proactive. Time becomes an input. External systems become inputs. Internal state becomes inputs. All processed through the same queue with persistent memory. + +Elegant engineering, not artificial consciousness. + +## Further Resources +- OpenClaw documentation +- Clairvo's original thread (inspiration for this breakdown) +- Cisco security research on OpenClaw ecosystem diff --git a/docs/additions.txt b/docs/additions.txt index 9641b17..e027561 100644 --- a/docs/additions.txt +++ b/docs/additions.txt @@ -1,3 +1,16 @@ +completed config instead of env edit its own files and config as well as add skills +start command for all instead of flags use config +customize opencode/claudecode setup like llms and providers during setup, agent creation/modify for claudecode and opencode install script starts server and adds the aetheel command +llm usage stats +logo + +Not complete +agent to agent and agent orchestration +better UI + +human in the loop +security +browse plugins and skills from claude marketplace or opencode plugins \ No newline at end of file diff --git a/docs/aetheel-vs-nanoclaw.md b/docs/aetheel-vs-nanoclaw.md new file mode 100644 index 0000000..97ed7a0 --- /dev/null +++ b/docs/aetheel-vs-nanoclaw.md @@ -0,0 +1,140 @@ +# Aetheel vs Nanoclaw: Feature Comparison & OpenCode Assessment + +Aetheel is a solid reimplementation of the core nanoclaw concept in Python, but there are meaningful gaps. Here's what maps, what's missing, and where the opencode integration could be improved. + +--- + +## What Aetheel Has (Maps Well to Nanoclaw) + +| Feature | Nanoclaw | Aetheel | Status | +|---|---|---|---| +| Multi-channel adapters | WhatsApp (baileys) | Slack + Telegram | ✅ Good — cleaner abstraction via `BaseAdapter` | +| Session isolation | Per-group sessions | Per-thread sessions via `SessionStore` | ✅ Good | +| Dual runtime support | Claude Code SDK only | OpenCode (CLI+SDK) + Claude Code CLI | ✅ Good — more flexible | +| Scheduled tasks | Cron + interval + once via MCP tool | Cron + one-shot via APScheduler | ✅ Good | +| Subagent spawning | SDK `Task`/`TeamCreate` tools | Background threads via `SubagentManager` | ✅ Basic | +| Memory system | CLAUDE.md files per group | SOUL.md + USER.md + MEMORY.md + hybrid search | ✅ Better — vector + BM25 search | +| Skills system | `.claude/skills/` with SKILL.md | `skills//SKILL.md` with trigger matching | ✅ Good | +| Action tags | MCP tools (send_message, schedule_task) | Regex-parsed `[ACTION:remind\|...]` tags | ✅ Different approach, works | + +--- + +## What's Missing from Aetheel + +### 1. Container Isolation + +Nanoclaw's biggest architectural feature. Every agent runs in an isolated Apple Container (or Docker) with controlled volume mounts, secret injection via stdin, and per-group IPC namespaces. Aetheel runs everything in the same process. This means: + +- No sandboxing of agent tool use (bash, file writes) +- No mount-based security boundaries between groups +- Secrets are in the process environment, not isolated + +### 2. MCP Server Integration + +Nanoclaw runs a custom MCP server (`ipc-mcp-stdio.ts`) inside the container that gives the agent tools like `send_message`, `schedule_task`, `register_group`. Aetheel uses regex-parsed action tags instead, which is fragile — the AI has to format tags perfectly, and there's no validation or structured tool calling. + +### 3. Multi-Group Support + +Nanoclaw has per-group folders, per-group memory (CLAUDE.md), per-group IPC, and a global memory layer. Aetheel has a single workspace with shared memory files. No group isolation. + +### 4. Persistent Conversation Sessions on Disk + +Nanoclaw stores sessions as JSONL files in `data/sessions/{group}/.claude/` and can resume at a specific assistant message UUID. Aetheel's `SessionStore` is in-memory only — sessions are lost on restart. + +### 5. IPC Message Streaming + +Nanoclaw's agent runner uses a `MessageStream` (AsyncIterable) to pipe follow-up messages into an active agent query. The host can send new messages to a running agent via IPC files. Aetheel's runtime is request-response only — one message in, one response out. + +### 6. Transcript Archiving + +Nanoclaw archives full conversation transcripts to markdown before context compaction via a `PreCompact` hook. Aetheel logs sessions to daily files but doesn't handle compaction. + +### 7. Group Registration + +Nanoclaw lets the main agent register new groups dynamically via an MCP tool. Aetheel has no equivalent. + +### 8. Idle Timeout / Session Lifecycle + +Nanoclaw has a 30-minute idle timeout that closes the container stdin, ending the session gracefully. Aetheel has session TTL cleanup but no active lifecycle management. + +--- + +## OpenCode Integration Assessment + +The opencode runtime implementation in `agent/opencode_runtime.py` is well-structured. Here's what's correct and what needs attention. + +### What's Done Well + +- Dual mode (CLI + SDK) with graceful fallback from SDK to CLI +- Binary auto-discovery across common install paths +- JSONL event parsing for `opencode run --format json` output +- Session ID extraction from event stream +- System prompt injection via XML tags (correct workaround since `opencode run` doesn't have `--system-prompt`) +- Config from environment variables + +### Issues / Improvements Needed + +#### 1. SDK Client API Mismatch + +The code calls `self._sdk_client.session.chat(session_id, **chat_kwargs)` but the opencode Python SDK uses `client.session.prompt()` not `.chat()`. The correct call is: + +```python +response = self._sdk_client.session.prompt( + path={"id": session_id}, + body={"parts": parts, "model": model_config} +) +``` + +#### 2. SDK Client Initialization + +The code uses `from opencode_ai import Opencode` but the actual SDK package is `@opencode-ai/sdk` (JS/TS) or `opencode-sdk-python` (Python). The Python SDK uses `createOpencodeClient` pattern. Verify the actual Python SDK import path — it may be `from opencode import Client` or similar depending on the package version. + +#### 3. No `--continue` Flag Validation + +The CLI mode passes `--continue` and `--session` for session continuity, but `opencode run` may not support `--continue` the same way as the TUI. The `opencode run` command is designed for single-shot execution. For session continuity in CLI mode, you'd need to use the SDK mode with `opencode serve`. + +#### 4. Missing `--system` Flag + +The code injects system prompts as XML in the message body. This works but is a workaround. The SDK mode's `client.session.prompt()` supports a `system` parameter in the body, which would be cleaner. + +#### 5. No Structured Output Support + +Opencode's SDK supports `format: { type: "json_schema", schema: {...} }` for structured responses. This could replace the fragile `[ACTION:...]` regex parsing with proper tool calls. + +#### 6. No Plugin/Hook Integration + +Opencode has a plugin system (`tool.execute.before`, `tool.execute.after`, `experimental.session.compacting`) that could replace the action tag parsing. You could create an opencode plugin that exposes `send_message` and `schedule_task` as custom tools, similar to nanoclaw's MCP approach. + +#### 7. Session Persistence + +`SessionStore` is in-memory. Opencode's server persists sessions natively, so in SDK mode you could rely on the server's session storage and just map `conversation_id → opencode_session_id` in a SQLite table. + +--- + +## Architectural Gap Summary + +The biggest architectural gap isn't about opencode specifically — it's that Aetheel runs the agent in-process without isolation, while nanoclaw's container model is what makes it safe to give the agent bash access and file write tools. + +To close that gap, options include: + +- **Containerize the opencode runtime** — run `opencode serve` inside a Docker container with controlled mounts +- **Use opencode's permission system** — configure all dangerous tools to `"ask"` or `"deny"` per agent +- **Add an MCP server** — replace action tag regex parsing with proper MCP tools for `send_message`, `schedule_task`, etc. +- **Persist sessions to SQLite** — survive restarts and enable resume-at-message functionality + +--- + +## Nanoclaw Features → Opencode Equivalents + +| Nanoclaw (Claude Code SDK) | Opencode Equivalent | Gap Level | +|---|---|---| +| `query()` async iterable | HTTP server + SDK `client.session.prompt()` | 🔴 Architecture change needed | +| `resume` + `resumeSessionAt` | `POST /session/:id/message` | 🟡 No resume-at-UUID equivalent | +| Streaming message types (system/init, assistant, result) | SSE events via `GET /event` | 🟡 Different event schema | +| `PreCompact` hook | `experimental.session.compacting` plugin | 🟢 Similar concept, different API | +| `PreToolUse` hook (bash sanitization) | `tool.execute.before` plugin | 🟢 Similar concept, different API | +| `bypassPermissions` | Per-tool permission config set to `"allow"` | 🟢 Direct mapping | +| `isSingleUserTurn: false` via AsyncIterable | `prompt_async` endpoint | 🟡 Needs verification | +| CLAUDE.md auto-loading via `settingSources` | AGENTS.md convention | 🟢 Rename files | +| Secrets via `env` param on `query()` | `shell.env` plugin hook | 🟡 Different isolation model | +| MCP servers in `query()` config | `opencode.json` mcp config or `POST /mcp` | 🟢 Direct mapping | diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..3aa1872 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,177 @@ +# Aetheel Commands Reference + +All commands work across every adapter (Slack, Discord, Telegram, WebChat). + +## Chat Commands + +Type these as regular messages in any channel or DM. No `/` prefix needed — just send the word as a message. The `/` prefix also works in DMs and on platforms that don't intercept it (Discord, Telegram, WebChat). + +> In Slack channels, Slack intercepts `/` as a native slash command and blocks it. Use the prefix-free form instead (e.g. `engine claude` not `/engine claude`). In Slack DMs with the bot, both forms work. + +### General + +| Command | Description | +|---|---| +| `status` | Show bot status, engine, model, sessions | +| `help` | Show all available commands | +| `time` | Current server time | +| `sessions` | Active session count + cleanup stale | +| `reload` | Reload config.json and skills | +| `subagents` | List active background tasks | + +### Runtime Switching (live, no restart needed) + +| Command | Description | +|---|---| +| `engine` | Show current engine (opencode/claude) | +| `engine opencode` | Switch to OpenCode runtime | +| `engine claude` | Switch to Claude Code runtime | +| `model` | Show current model | +| `model ` | Switch model (e.g. `model anthropic/claude-sonnet-4-20250514`) | +| `provider` | Show current provider (OpenCode only) | +| `provider ` | Switch provider (e.g. `provider anthropic`, `provider openai`) | +| `usage` | Show LLM usage stats, costs, and rate limit history | + +Engine, model, and provider changes take effect immediately and are persisted to `config.json` so they survive restarts. + +#### Examples + +``` +engine claude +model claude-sonnet-4-20250514 + +engine opencode +model anthropic/claude-sonnet-4-20250514 +provider anthropic + +model openai/gpt-4o +provider openai + +model google/gemini-2.5-pro +provider google +``` + +### Configuration + +| Command | Description | +|---|---| +| `config` | Show config summary (engine, model, adapters) | +| `config show` | Dump full config.json | +| `config set ` | Edit a config value (dotted notation) | + +#### Config Set Examples + +``` +config set runtime.timeout_seconds 300 +config set claude.max_turns 5 +config set webchat.enabled true +config set discord.enabled true +config set webhooks.token my-secret-token +config set heartbeat.silent true +``` + +After `config set`, run `reload` to apply changes that don't auto-apply (adapter changes require a restart). + +### Scheduler + +| Command | Description | +|---|---| +| `cron list` | List all scheduled jobs | +| `cron remove ` | Remove a scheduled job by ID | + +### AI Chat + +Any message that isn't a command is sent to the AI. The AI can also trigger actions by including tags in its response: + +| AI Action Tag | What It Does | +|---|---| +| `[ACTION:remind\|\|]` | Schedule a one-shot reminder | +| `[ACTION:cron\|\|]` | Schedule a recurring cron job | +| `[ACTION:spawn\|]` | Spawn a background subagent | + +--- + +## Terminal Commands + +After installation, the `aetheel` command is available in your shell. + +### Service Management + +| Command | Description | +|---|---| +| `aetheel start` | Start Aetheel in the foreground | +| `aetheel stop` | Stop the background service | +| `aetheel restart` | Restart the background service | +| `aetheel status` | Show install and service status | +| `aetheel logs` | Tail the live log file | + +### Setup & Maintenance + +| Command | Description | +|---|---| +| `aetheel setup` | Re-run the interactive setup wizard | +| `aetheel update` | Pull latest code + update dependencies | +| `aetheel doctor` | Run diagnostics (config, tokens, runtimes) | +| `aetheel config` | Open config.json in your `$EDITOR` | + +### Pass-Through + +Any other arguments are passed directly to `main.py`: + +```bash +aetheel --test # Echo mode, no AI +aetheel --claude # Override: use Claude engine +aetheel --model gpt-4o # Override: specific model +aetheel --log DEBUG # Override: debug logging +``` + +These flags are optional overrides. All settings come from `~/.aetheel/config.json` by default. + +--- + +## Config File Reference + +All features are controlled by `~/.aetheel/config.json`. No flags required. + +```jsonc +{ + "runtime": { + "engine": "opencode", // "opencode" or "claude" + "mode": "cli", // "cli" or "sdk" + "model": null, // e.g. "anthropic/claude-sonnet-4-20250514" + "provider": null, // e.g. "anthropic", "openai", "google" + "timeout_seconds": 120 + }, + "claude": { + "model": null, // e.g. "claude-sonnet-4-20250514" + "max_turns": 3, + "no_tools": false + }, + "slack": { "enabled": true }, + "telegram": { "enabled": false }, + "discord": { "enabled": false, "listen_channels": [] }, + "webchat": { "enabled": false, "port": 8080 }, + "webhooks": { "enabled": false, "port": 8090, "token": "" }, + "heartbeat": { "enabled": true }, + "hooks": { "enabled": true } +} +``` + +Adapters auto-enable when their token is set in `.env`, even if `enabled` is `false` in config. + +--- + +## Auto-Failover & Rate Limit Handling + +When a rate limit or quota error is detected from the active engine: + +1. Aetheel notifies you in the channel with the error details +2. Automatically attempts to failover to the other engine (claude → opencode or vice versa) +3. If failover succeeds, the response is delivered and the active engine is switched +4. If failover also fails, both errors are reported + +Rate limit detection covers: HTTP 429, "rate limit", "quota exceeded", "too many requests", "usage limit", "credit balance", "overloaded", and similar patterns from both Claude Code and OpenCode. + +Use `usage` to see cumulative stats including rate limit hits and failover count. + +Note: Claude Code's `--output-format json` returns `cost_usd` per request. OpenCode does not currently expose per-request cost in its CLI output, so cost tracking is only available for the Claude engine. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..8c91d8d --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,326 @@ +# Configuration Guide + +> How Aetheel loads its settings — config file, secrets, and CLI overrides. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Config File](#config-file) +3. [Secrets (.env)](#secrets) +4. [CLI Overrides](#cli-overrides) +5. [Priority Order](#priority-order) +6. [Reference](#reference) +7. [Examples](#examples) + +--- + +## Overview + +Aetheel uses a two-file configuration approach: + +| File | Location | Purpose | +|------|----------|---------| +| `config.json` | `~/.aetheel/config.json` | All non-secret settings (model, timeouts, channels, paths) | +| `.env` | Project root | Secrets only (tokens, passwords, API keys) | + +On first run, Aetheel auto-creates `~/.aetheel/config.json` with sensible defaults. You only need to edit what you want to change. + +--- + +## Config File + +Located at `~/.aetheel/config.json`. Created automatically on first run. + +### Full Default Config + +```json +{ + "log_level": "INFO", + "runtime": { + "mode": "cli", + "model": null, + "timeout_seconds": 120, + "server_url": "http://localhost:4096", + "format": "json" + }, + "claude": { + "model": null, + "timeout_seconds": 120, + "max_turns": 3, + "no_tools": true + }, + "discord": { + "listen_channels": [] + }, + "memory": { + "workspace": "~/.aetheel/workspace", + "db_path": "~/.aetheel/memory.db" + }, + "scheduler": { + "db_path": "~/.aetheel/scheduler.db" + } +} +``` + +### Section: `runtime` + +Controls the OpenCode AI runtime (default). + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `mode` | string | `"cli"` | `"cli"` (subprocess) or `"sdk"` (opencode serve API) | +| `model` | string\|null | `null` | Model ID, e.g. `"anthropic/claude-sonnet-4-20250514"`. Null uses OpenCode's default. | +| `timeout_seconds` | int | `120` | Max seconds to wait for a response | +| `server_url` | string | `"http://localhost:4096"` | OpenCode server URL (SDK mode only) | +| `format` | string | `"json"` | CLI output format: `"json"` (structured) or `"default"` (plain text) | +| `workspace` | string\|null | `null` | Working directory for OpenCode. Null uses current directory. | +| `provider` | string\|null | `null` | Provider override, e.g. `"anthropic"`, `"openai"` | + +### Section: `claude` + +Controls the Claude Code runtime (used with `--claude` flag). + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `model` | string\|null | `null` | Model ID, e.g. `"claude-sonnet-4-20250514"` | +| `timeout_seconds` | int | `120` | Max seconds to wait for a response | +| `max_turns` | int | `3` | Max agentic tool-use turns per request | +| `no_tools` | bool | `true` | Disable tools for pure conversation mode | + +### Section: `discord` + +Discord-specific settings (non-secret). + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `listen_channels` | list[string] | `[]` | Channel IDs where the bot responds to all messages (no @mention needed) | + +### Section: `memory` + +Memory system paths. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `workspace` | string | `"~/.aetheel/workspace"` | Directory for identity files (SOUL.md, USER.md, MEMORY.md) and skills | +| `db_path` | string | `"~/.aetheel/memory.db"` | SQLite database for embeddings and search index | + +### Section: `scheduler` + +Scheduler storage. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `db_path` | string | `"~/.aetheel/scheduler.db"` | SQLite database for persisted scheduled jobs | + +### Top-level + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `log_level` | string | `"INFO"` | `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"` | + +--- + +## Secrets + +Secrets live in `.env` in the project root. These are never written to `config.json`. + +Copy the template: + +```bash +cp .env.example .env +``` + +### Required (at least one adapter) + +| Variable | Format | Description | +|----------|--------|-------------| +| `SLACK_BOT_TOKEN` | `xoxb-...` | Slack bot OAuth token | +| `SLACK_APP_TOKEN` | `xapp-...` | Slack app-level token (Socket Mode) | + +### Optional + +| Variable | Format | Description | +|----------|--------|-------------| +| `TELEGRAM_BOT_TOKEN` | string | Telegram bot token from @BotFather | +| `DISCORD_BOT_TOKEN` | string | Discord bot token from Developer Portal | +| `OPENCODE_SERVER_PASSWORD` | string | Password for `opencode serve` (SDK mode) | +| `ANTHROPIC_API_KEY` | `sk-ant-...` | Anthropic API key (Claude Code runtime) | + +### Environment Variable Overrides + +Any config.json setting can also be overridden via environment variables. These take priority over the config file: + +| Env Variable | Overrides | +|-------------|-----------| +| `LOG_LEVEL` | `log_level` | +| `OPENCODE_MODE` | `runtime.mode` | +| `OPENCODE_MODEL` | `runtime.model` | +| `OPENCODE_TIMEOUT` | `runtime.timeout_seconds` | +| `OPENCODE_SERVER_URL` | `runtime.server_url` | +| `OPENCODE_PROVIDER` | `runtime.provider` | +| `OPENCODE_WORKSPACE` | `runtime.workspace` | +| `CLAUDE_MODEL` | `claude.model` | +| `CLAUDE_TIMEOUT` | `claude.timeout_seconds` | +| `CLAUDE_MAX_TURNS` | `claude.max_turns` | +| `CLAUDE_NO_TOOLS` | `claude.no_tools` | +| `DISCORD_LISTEN_CHANNELS` | `discord.listen_channels` (comma-separated) | +| `AETHEEL_WORKSPACE` | `memory.workspace` | +| `AETHEEL_MEMORY_DB` | `memory.db_path` | + +--- + +## CLI Overrides + +CLI arguments have the highest priority and override both config.json and environment variables. + +```bash +python main.py [options] +``` + +| Flag | Description | +|------|-------------| +| `--model ` | Override model for both runtimes | +| `--claude` | Use Claude Code runtime instead of OpenCode | +| `--cli` | Force CLI mode (OpenCode) | +| `--sdk` | Force SDK mode (OpenCode) | +| `--telegram` | Enable Telegram adapter | +| `--discord` | Enable Discord adapter | +| `--test` | Use echo handler (no AI) | +| `--log ` | Override log level | + +--- + +## Priority Order + +When the same setting is defined in multiple places, the highest priority wins: + +``` +CLI arguments > Environment variables (.env) > config.json > Defaults +``` + +For example, if `config.json` sets `runtime.model` to `"anthropic/claude-sonnet-4-20250514"` but you run `python main.py --model openai/gpt-5.1`, the CLI argument wins. + +--- + +## Reference + +### File Locations + +| File | Path | Git-tracked | +|------|------|-------------| +| Config | `~/.aetheel/config.json` | No | +| Secrets | `/.env` | No (in .gitignore) | +| Memory DB | `~/.aetheel/memory.db` | No | +| Session DB | `~/.aetheel/sessions.db` | No | +| Scheduler DB | `~/.aetheel/scheduler.db` | No | +| Identity files | `~/.aetheel/workspace/SOUL.md` etc. | No | +| Session logs | `~/.aetheel/workspace/daily/` | No | + +### Data Directory Structure + +``` +~/.aetheel/ +├── config.json # Main configuration +├── memory.db # Embeddings + search index +├── sessions.db # Persistent session mappings +├── scheduler.db # Scheduled jobs +└── workspace/ + ├── SOUL.md # Bot personality + ├── USER.md # User profile + ├── MEMORY.md # Long-term memory + ├── skills/ # Skill definitions + │ └── / + │ └── SKILL.md + └── daily/ # Session logs + └── YYYY-MM-DD.md +``` + +--- + +## Examples + +### Minimal Setup (Slack + OpenCode CLI) + +`.env`: +```env +SLACK_BOT_TOKEN=xoxb-your-token +SLACK_APP_TOKEN=xapp-your-token +``` + +No config.json changes needed — defaults work. + +```bash +python main.py +``` + +### Custom Model + SDK Mode + +`~/.aetheel/config.json`: +```json +{ + "runtime": { + "mode": "sdk", + "model": "anthropic/claude-sonnet-4-20250514", + "server_url": "http://localhost:4096" + } +} +``` + +Start OpenCode server first, then Aetheel: +```bash +opencode serve --port 4096 +python main.py +``` + +### Discord with Listen Channels + +`~/.aetheel/config.json`: +```json +{ + "discord": { + "listen_channels": ["1234567890123456"] + } +} +``` + +`.env`: +```env +DISCORD_BOT_TOKEN=your-discord-token +``` + +```bash +python main.py --discord +``` + +### Multi-Channel (Slack + Discord + Telegram) + +`.env`: +```env +SLACK_BOT_TOKEN=xoxb-your-token +SLACK_APP_TOKEN=xapp-your-token +DISCORD_BOT_TOKEN=your-discord-token +TELEGRAM_BOT_TOKEN=your-telegram-token +``` + +```bash +python main.py --discord --telegram +``` + +### Claude Code Runtime + +`~/.aetheel/config.json`: +```json +{ + "claude": { + "model": "claude-sonnet-4-20250514", + "max_turns": 5, + "no_tools": false + } +} +``` + +```bash +python main.py --claude +``` diff --git a/docs/discord-setup.md b/docs/discord-setup.md new file mode 100644 index 0000000..71422eb --- /dev/null +++ b/docs/discord-setup.md @@ -0,0 +1,307 @@ +# Discord Bot Setup Guide + +> Complete guide to creating a Discord bot and connecting it to Aetheel. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Create a Discord Application](#step-1-create-a-discord-application) +3. [Create the Bot](#step-2-create-the-bot) +4. [Enable Privileged Intents](#step-3-enable-privileged-intents) +5. [Invite the Bot to Your Server](#step-4-invite-the-bot-to-your-server) +6. [Configure Aetheel](#step-5-configure-aetheel) +7. [Run and Test](#step-6-run-and-test) +8. [Troubleshooting](#troubleshooting) +9. [Architecture Reference](#architecture-reference) + +--- + +## Overview + +Aetheel connects to Discord using the **Gateway API** via `discord.py`, which means: +- ✅ **No public URL needed** — works behind firewalls and NAT +- ✅ **No webhook setup** — Discord pushes events via WebSocket +- ✅ **Real-time** — instant message delivery +- ✅ **DMs + @mentions** — responds in both + +### What You'll Need + +| Item | Description | +|------|-------------| +| **Discord Account** | With access to a server where you have Manage Server permissions | +| **Bot Token** | From the Discord Developer Portal | +| **Python 3.14+** | Runtime for the Aetheel service | + +--- + +## Step 1: Create a Discord Application + +1. Go to [https://discord.com/developers/applications](https://discord.com/developers/applications) +2. Click **"New Application"** +3. Enter a name: `Aetheel` (or any name you prefer) +4. Accept the Terms of Service +5. Click **"Create"** + +You'll be taken to your application's **General Information** page. + +--- + +## Step 2: Create the Bot + +1. Navigate to **Bot** in the left sidebar +2. The bot user is created automatically with your application +3. Click **"Reset Token"** to generate a new bot token +4. **⚠️ Copy the token now!** You won't be able to see it again. + - Save it somewhere safe — this is your `DISCORD_BOT_TOKEN` + +### Optional: Bot Settings + +On the same Bot page, you can configure: + +| Setting | Recommended | Why | +|---------|-------------|-----| +| **Public Bot** | OFF | Only you can invite it to servers | +| **Requires OAuth2 Code Grant** | OFF | Simpler invite flow | + +--- + +## Step 3: Enable Privileged Intents + +Still on the **Bot** page, scroll down to **Privileged Gateway Intents**. + +Enable the following: + +| Intent | Required | Purpose | +|--------|----------|---------| +| **Message Content Intent** | ✅ Yes | Read the text content of messages | +| **Server Members Intent** | Optional | Resolve member display names | +| **Presence Intent** | No | Not needed | + +> **Important:** The Message Content Intent is required. Without it, the bot will receive empty message content for guild messages. + +Click **"Save Changes"**. + +--- + +## Step 4: Invite the Bot to Your Server + +1. Navigate to **OAuth2** → **URL Generator** in the left sidebar +2. Under **Scopes**, check: `bot` +3. Under **Bot Permissions**, check: + +| Permission | Purpose | +|------------|---------| +| **Send Messages** | Reply to users | +| **Read Message History** | Context for conversations | +| **View Channels** | See channels the bot is in | + +4. Copy the **Generated URL** at the bottom +5. Open the URL in your browser +6. Select the server you want to add the bot to +7. Click **"Authorize"** + +The bot should now appear in your server's member list (offline until you start Aetheel). + +--- + +## Step 5: Configure Aetheel + +### Option A: Using `.env` file (recommended) + +Edit your `.env` file and add: + +```env +DISCORD_BOT_TOKEN=your-discord-bot-token-here +``` + +### Option B: Export environment variable + +```bash +export DISCORD_BOT_TOKEN="your-discord-bot-token-here" +``` + +--- + +## Step 6: Run and Test + +### Install dependencies + +```bash +uv sync +# or: pip install -r requirements.txt +``` + +This will install `discord.py` along with the other dependencies. + +### Run the bot + +```bash +# Discord only +uv run python main.py --discord + +# Discord + Slack together +uv run python main.py --discord + +# Discord with Claude Code runtime +uv run python main.py --discord --claude + +# Test mode (echo handler, no AI) +uv run python main.py --discord --test + +# Debug logging +uv run python main.py --discord --log DEBUG +``` + +### Verify it's working + +1. Check the console — you should see: + ``` + Aetheel Discord Adapter + Bot: @Aetheel (123456789) + Guilds: My Server + ``` +2. In Discord, go to a channel where the bot is present +3. Type `@Aetheel help` — you should see the help response +4. Type `@Aetheel status` — you should see the bot's status +5. Send a DM to the bot — it should respond directly + +### How the bot responds + +| Context | Trigger | Session Isolation | +|---------|---------|-------------------| +| **Guild channel** | @mention only | Per-channel | +| **DM** | Any message | Per-DM channel | + +--- + +## Troubleshooting + +### ❌ "Discord bot token is required" + +**Problem:** `DISCORD_BOT_TOKEN` is not set or empty. + +**Fix:** +1. Check your `.env` file contains the token +2. Make sure there are no extra spaces or quotes +3. Verify the token is from the Bot page, not the application client secret + +### ❌ Bot comes online but doesn't respond to messages + +**Problem:** Message Content Intent is not enabled. + +**Fix:** +1. Go to [Developer Portal](https://discord.com/developers/applications) → your app → **Bot** +2. Scroll to **Privileged Gateway Intents** +3. Enable **Message Content Intent** +4. Save and restart Aetheel + +### ❌ Bot doesn't respond in guild channels + +**Problem:** You're not @mentioning the bot. + +**Fix:** +- In guild channels, the bot only responds to @mentions: `@Aetheel hello` +- In DMs, the bot responds to any message — no @mention needed + +### ❌ "Improper token has been passed" + +**Problem:** The token is malformed or from the wrong place. + +**Fix:** +1. Go to **Bot** page in the Developer Portal +2. Click **"Reset Token"** to generate a fresh one +3. Copy the full token (it's a long string) +4. Make sure you're using the Bot token, not the Client ID or Client Secret + +### ❌ "discord.errors.PrivilegedIntentsRequired" + +**Problem:** You're requesting intents that aren't enabled in the portal. + +**Fix:** +1. Go to **Bot** → **Privileged Gateway Intents** +2. Enable **Message Content Intent** +3. Save changes and restart + +### ❌ Bot is offline in the member list + +**Problem:** Aetheel isn't running, or the token is wrong. + +**Fix:** +1. Start Aetheel with `--discord` flag +2. Check the console for connection errors +3. Verify the token in `.env` matches the one in the Developer Portal + +### ❌ "Missing Permissions" when sending messages + +**Problem:** The bot doesn't have Send Messages permission in that channel. + +**Fix:** +1. Check the channel's permission overrides for the bot role +2. Re-invite the bot with the correct permissions (see Step 4) +3. Or manually grant the bot's role Send Messages in Server Settings → Roles + +--- + +## Architecture Reference + +### How It Works + +``` +┌──────────────────────┐ +│ Your Discord │ +│ Server │ +│ │ +│ #general │ +│ #random │ +│ DMs │ +└──────┬───────────────┘ + │ WebSocket (Gateway API) + │ +┌──────▼───────────────┐ +│ Aetheel Discord │ +│ Adapter │ +│ │ +│ • Token resolution │ +│ • @mention filter │ +│ • DM handling │ +│ • Message chunking │ +│ (2000 char limit) │ +│ • Async dispatch │ +│ via to_thread() │ +└──────┬───────────────┘ + │ Callback + │ +┌──────▼───────────────┐ +│ Message Handler │ +│ │ +│ • Echo (test) │ +│ • AI (OpenCode / │ +│ Claude Code) │ +│ • Memory + Skills │ +│ • Action tags │ +└──────────────────────┘ +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `adapters/discord_adapter.py` | Core Discord adapter (Gateway, send/receive) | +| `adapters/base.py` | Abstract base class all adapters implement | +| `main.py` | Entry point — `--discord` flag enables this adapter | +| `.env` | Your Discord token (not committed to git) | + +### Comparison with Other Adapters + +| Feature | Slack | Telegram | Discord | +|---------|-------|----------|---------| +| **Library** | `slack_bolt` | `python-telegram-bot` | `discord.py` | +| **Connection** | Socket Mode (WebSocket) | Long polling | Gateway (WebSocket) | +| **Auth** | Bot Token + App Token | Single Bot Token | Single Bot Token | +| **Trigger (channels)** | @mention | @mention | @mention | +| **Trigger (DMs)** | Any message | Any message | Any message | +| **Text Limit** | 4000 chars | 4096 chars | 2000 chars | +| **Threading** | Native threads | Reply-to-message | N/A (per-channel) | +| **Session Isolation** | Per-thread | Per-chat | Per-channel | diff --git a/docs/features-guide.md b/docs/features-guide.md new file mode 100644 index 0000000..555c22a --- /dev/null +++ b/docs/features-guide.md @@ -0,0 +1,916 @@ +# Aetheel Features Guide + +Complete reference for all Aetheel features, how to use them, and how to test them. + +--- + +## Table of Contents + +1. [Dual AI Runtimes](#1-dual-ai-runtimes) +2. [Tool Enablement & MCP Servers](#2-tool-enablement--mcp-servers) +3. [Multi-Channel Adapters](#3-multi-channel-adapters) +4. [WebChat Browser Interface](#4-webchat-browser-interface) +5. [Persistent Memory System](#5-persistent-memory-system) +6. [Skills System](#6-skills-system) +7. [Scheduler & Action Tags](#7-scheduler--action-tags) +8. [Heartbeat / Proactive System](#8-heartbeat--proactive-system) +9. [Subagents & Agent-to-Agent Communication](#9-subagents--agent-to-agent-communication) +10. [Self-Modification](#10-self-modification) +11. [Lifecycle Hooks](#11-lifecycle-hooks) +12. [Webhooks (External Event Receiver)](#12-webhooks-external-event-receiver) +13. [CLI Interface](#13-cli-interface) +14. [Configuration](#14-configuration) +15. [Running Tests](#15-running-tests) +13. [Running Tests](#13-running-tests) + +--- + +## 1. Dual AI Runtimes + +Aetheel supports two AI backends that share the same `AgentResponse` interface. Switch between them with a single flag. + +### OpenCode (default) + +Uses the [OpenCode](https://opencode.ai) CLI. Supports CLI mode (subprocess per request) and SDK mode (persistent server connection). + +```bash +# CLI mode (default) +python main.py + +# SDK mode (requires `opencode serve` running) +python main.py --sdk + +# Custom model +python main.py --model anthropic/claude-sonnet-4-20250514 +``` + +### Claude Code + +Uses the [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI with native `--system-prompt` support. + +```bash +python main.py --claude +python main.py --claude --model claude-sonnet-4-20250514 +``` + +### Key differences + +| Feature | OpenCode | Claude Code | +|---------|----------|-------------| +| System prompt | XML-injected into message | Native `--system-prompt` flag | +| Session continuity | `--continue --session ` | `--continue --session-id ` | +| Tool access | Enabled by default | Controlled via `--allowedTools` | +| Output format | JSONL events | JSON result object | + +### How to test + +```bash +# Echo mode (no AI runtime needed) +python main.py --test + +# Verify runtime detection +python cli.py doctor +``` + +--- + +## 2. Tool Enablement & MCP Servers + +### Tool access + +Claude Code runtime now has tools enabled by default. The `allowed_tools` list controls which tools the agent can use: + +``` +Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, +Task, TaskOutput, TaskStop, Skill, TeamCreate, TeamDelete, SendMessage +``` + +To disable tools (pure conversation mode), set in `~/.aetheel/config.json`: + +```json +{ + "claude": { + "no_tools": true + } +} +``` + +To customize the tool list: + +```json +{ + "claude": { + "no_tools": false, + "allowed_tools": ["Bash", "Read", "Write", "WebSearch"] + } +} +``` + +### MCP server configuration + +Add external tool servers via config. Aetheel writes the appropriate config file (`.mcp.json` for Claude, `opencode.json` for OpenCode) to the workspace before launching the runtime. + +```json +{ + "mcp": { + "servers": { + "my-tool": { + "command": "uvx", + "args": ["my-mcp-server@latest"], + "env": { "API_KEY": "..." } + } + } + } +} +``` + +### How to test + +```bash +# Run MCP config writer tests +cd Aetheel +python -m pytest tests/test_mcp_config.py -v +``` + +--- + +## 3. Multi-Channel Adapters + +Aetheel connects to messaging platforms via adapters. Each adapter converts platform events into a channel-agnostic `IncomingMessage` and routes responses back. + +### Slack (default) + +Requires `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` in `.env`. Starts automatically when tokens are present. + +```bash +python main.py +``` + +### Telegram + +```bash +# Set TELEGRAM_BOT_TOKEN in .env first +python main.py --telegram +``` + +### Discord + +```bash +# Set DISCORD_BOT_TOKEN in .env first +python main.py --discord +``` + +### Multiple adapters + +```bash +python main.py --telegram --discord --webchat +``` + +When multiple adapters run, all but the last start in background threads. The last one runs blocking. + +### How to test + +```bash +python -m pytest tests/test_base_adapter.py -v +``` + +--- + +## 4. WebChat Browser Interface + +A browser-based chat UI served via aiohttp with WebSocket support. No Slack/Discord/Telegram needed. + +### Starting WebChat + +```bash +# Via CLI flag +python main.py --webchat + +# Or enable in config permanently +# Set "webchat": {"enabled": true} in ~/.aetheel/config.json +``` + +Then open `http://127.0.0.1:8080` in your browser. + +### Configuration + +```json +{ + "webchat": { + "enabled": false, + "port": 8080, + "host": "127.0.0.1" + } +} +``` + +### Features + +- Dark-themed chat UI at `GET /` +- WebSocket endpoint at `/ws` for real-time messaging +- Per-connection session isolation (each browser tab gets its own conversation) +- Auto-reconnect on disconnect (3 second delay) +- Basic markdown rendering (bold, code blocks, bullet points) +- Connection status indicator (green/red/gray) + +### Architecture + +``` +Browser <--> WebSocket (/ws) <--> WebChatAdapter <--> ai_handler <--> Runtime + | + GET / serves static/chat.html +``` + +### How to test + +```bash +# Start with echo handler + webchat +python main.py --test --webchat + +# Open http://127.0.0.1:8080 and send a message +# You should see the echo response with message metadata +``` + +--- + +## 5. Persistent Memory System + +Aetheel maintains persistent memory across sessions using local embeddings and SQLite. + +### Identity files + +Located in `~/.aetheel/workspace/`: + +| File | Purpose | +|------|---------| +| `SOUL.md` | Personality, values, communication style | +| `USER.md` | User preferences, context, background | +| `MEMORY.md` | Long-term notes, facts to remember | + +Edit these files directly. Changes are picked up automatically via file watching. + +### How it works + +1. All `.md` files in the workspace are chunked and embedded using `fastembed` (BAAI/bge-small-en-v1.5, 384-dim, runs locally) +2. Chunks are stored in SQLite with FTS5 full-text search +3. On each message, Aetheel searches memory using hybrid scoring (0.7 vector + 0.3 BM25) +4. Relevant results are injected into the system prompt as context +5. Conversations are logged to `daily/YYYY-MM-DD.md` + +### CLI memory commands + +```bash +# Search memory +python cli.py memory search "python projects" + +# Force re-index +python cli.py memory sync +``` + +--- + +## 6. Skills System + +Skills are markdown files that teach the agent how to handle specific types of requests. They're loaded at startup and injected into the system prompt when trigger words match. + +### Creating a skill + +Create `~/.aetheel/workspace/skills//SKILL.md`: + +```markdown +--- +name: weather +description: Check weather for any city +triggers: [weather, forecast, temperature, rain] +--- + +# Weather Skill + +When the user asks about weather, use web search to find current conditions... +``` + +### How it works + +1. Skills are discovered from `~/.aetheel/workspace/skills/*/SKILL.md` +2. YAML frontmatter defines `name`, `description`, and `triggers` +3. When a message contains a trigger word, the skill's body is injected into the system prompt +4. A summary of all available skills is always included + +### Hot reload + +Send `reload` or `/reload` in chat to reload skills without restarting. + +### How to test + +```bash +python -m pytest tests/test_skills.py -v +``` + +--- + +## 7. Scheduler & Action Tags + +APScheduler-based system with SQLite persistence for one-shot and recurring jobs. + +### Action tags + +The AI can include action tags in its responses. The system strips them from the visible reply and executes the action. + +| Tag | Effect | +|-----|--------| +| `[ACTION:remind\|5\|Drink water!]` | Sends "Drink water!" to the channel in 5 minutes | +| `[ACTION:cron\|0 9 * * *\|Good morning!]` | Sends "Good morning!" every day at 9 AM | +| `[ACTION:spawn\|Research Python 3.14]` | Spawns a background subagent for the task | + +### Managing jobs + +In chat: +- `/cron list` — list all scheduled jobs +- `/cron remove ` — remove a job + +Via CLI: +```bash +python cli.py cron list +python cli.py cron remove +``` + +### How to test + +```bash +# Scheduler tests require apscheduler installed +python -m pytest tests/test_scheduler.py -v +``` + +--- + +## 8. Heartbeat / Proactive System + +The heartbeat system runs periodic tasks automatically by parsing a user-editable `HEARTBEAT.md` file. + +### How it works + +1. At startup, `HeartbeatRunner` reads `~/.aetheel/workspace/HEARTBEAT.md` +2. Each `## ` heading defines a schedule (natural language) +3. Bullet points under each heading are task prompts +4. Tasks are registered as cron jobs with the Scheduler +5. When a task fires, it creates a synthetic message routed through `ai_handler` + +### HEARTBEAT.md format + +```markdown +# Heartbeat Tasks + +## Every 30 minutes +- Check if any scheduled reminders need attention +- Review recent session logs for anything worth remembering + +## Every morning (9:00 AM) +- Summarize yesterday's conversations +- Check for any pending follow-ups in MEMORY.md + +## Every evening (6:00 PM) +- Update MEMORY.md with today's key learnings +``` + +### Supported schedule patterns + +| Pattern | Cron equivalent | +|---------|----------------| +| `Every 30 minutes` | `*/30 * * * *` | +| `Every hour` | `0 * * * *` | +| `Every 2 hours` | `0 */2 * * *` | +| `Every morning (9:00 AM)` | `0 9 * * *` | +| `Every evening (6:00 PM)` | `0 18 * * *` | + +### Configuration + +```json +{ + "heartbeat": { + "enabled": true, + "default_channel": "slack", + "default_channel_id": "C123456", + "silent": false + } +} +``` + +Set `enabled` to `false` to disable heartbeat entirely. If `HEARTBEAT.md` doesn't exist, a default one is created automatically. + +### How to test + +```bash +# Verify heartbeat parsing works +python -c " +from heartbeat.heartbeat import HeartbeatRunner +print(HeartbeatRunner._parse_schedule_header('Every 30 minutes')) # */30 * * * * +print(HeartbeatRunner._parse_schedule_header('Every morning (9:00 AM)')) # 0 9 * * * +print(HeartbeatRunner._parse_schedule_header('Every evening (6:00 PM)')) # 0 18 * * * +" +``` + +--- + +## 9. Subagents & Agent-to-Agent Communication + +### Subagent spawning + +The AI can spawn background tasks that run in separate threads with their own runtime instances. + +``` +[ACTION:spawn|Research the latest Python 3.14 features and summarize them] +``` + +The subagent runs asynchronously and sends results back to the originating channel when done. + +### Managing subagents + +In chat: +- `/subagents` — list active subagent tasks with IDs and status + +### SubagentBus + +A thread-safe pub/sub message bus for inter-subagent communication: + +```python +from agent.subagent import SubagentManager + +mgr = SubagentManager(runtime_factory=..., send_fn=...) + +# Subscribe to a channel +mgr.bus.subscribe("results", lambda msg, sender: print(f"{sender}: {msg}")) + +# Publish from a subagent +mgr.bus.publish("results", "Task complete!", "subagent-abc123") +``` + +### Claude Code team tools + +When using Claude Code runtime, the agent has access to team coordination tools: +- `TeamCreate`, `TeamDelete` — create/delete agent teams +- `SendMessage` — send messages between agents in a team +- `Task`, `TaskOutput`, `TaskStop` — spawn and manage subagent tasks + +### How to test + +```bash +python -m pytest tests/test_subagent_bus.py -v +``` + +--- + +## 10. Self-Modification + +The AI agent knows it can modify its own files. The system prompt tells it about: + +- `~/.aetheel/config.json` — edit configuration +- `~/.aetheel/workspace/skills//SKILL.md` — create new skills +- `~/.aetheel/workspace/SOUL.md` — update personality +- `~/.aetheel/workspace/USER.md` — update user profile +- `~/.aetheel/workspace/MEMORY.md` — update long-term memory +- `~/.aetheel/workspace/HEARTBEAT.md` — modify periodic tasks + +### Hot reload + +After the agent edits config or skills, send `reload` or `/reload` in chat to apply changes without restarting: + +``` +You: /reload +Aetheel: 🔄 Config and skills reloaded. +``` + +### How to test + +```bash +# Verify the system prompt contains self-modification instructions +python -c " +from agent.opencode_runtime import build_aetheel_system_prompt +prompt = build_aetheel_system_prompt() +assert 'Self-Modification' in prompt +assert 'config.json' in prompt +assert '/reload' in prompt +print('Self-modification prompt sections present ✅') +" +``` + +--- + +## 11. Lifecycle Hooks + +Event-driven lifecycle hooks inspired by OpenClaw's internal hook system. Hooks fire on gateway/agent lifecycle events and let you run custom Python code at those moments. + +### Supported events + +| Event | When it fires | +|---|---| +| `gateway:startup` | Gateway process starts (after adapters connect) | +| `gateway:shutdown` | Gateway process is shutting down | +| `command:reload` | User sends `/reload` | +| `command:new` | User starts a fresh session | +| `agent:bootstrap` | Before workspace files are injected into context | +| `agent:response` | After the agent produces a response | + +### Creating a hook + +Create a directory in `~/.aetheel/workspace/hooks//` with two files: + +`HOOK.md` — metadata in YAML frontmatter: + +```markdown +--- +name: session-logger +description: Log session starts to a file +events: [gateway:startup, command:reload] +enabled: true +--- +# Session Logger Hook + +Logs gateway lifecycle events for debugging. +``` + +`handler.py` — Python handler with a `handle(event)` function: + +```python +def handle(event): + """Called when a matching event fires.""" + print(f"Hook fired: {event.event_key}") + # Push messages back to the user + event.messages.append("Hook executed!") +``` + +### Hook discovery locations + +Hooks are discovered from two directories (in order): + +1. `~/.aetheel/workspace/hooks//HOOK.md` — workspace hooks (per-project) +2. `~/.aetheel/hooks//HOOK.md` — managed hooks (shared across workspaces) + +### Programmatic hooks + +You can also register hooks in Python code: + +```python +from hooks import HookManager, HookEvent + +mgr = HookManager(workspace_dir="~/.aetheel/workspace") +mgr.register("gateway:startup", lambda e: print("Gateway started!")) +mgr.trigger(HookEvent(type="gateway", action="startup")) +``` + +### Configuration + +```json +{ + "hooks": { + "enabled": true + } +} +``` + +Set `enabled` to `false` to disable all hook discovery and execution. + +### How to test + +```bash +python -m pytest tests/test_hooks.py -v +``` + +--- + +## 12. Webhooks (External Event Receiver) + +HTTP endpoints that accept POST requests from external systems (GitHub, Jira, email services, custom scripts) and route them through the AI handler as synthetic messages. Inspired by OpenClaw's `/hooks/*` gateway endpoints. + +### Endpoints + +| Endpoint | Method | Auth | Description | +|---|---|---|---| +| `/hooks/health` | GET | No | Health check | +| `/hooks/wake` | POST | Yes | Wake the agent with a text prompt | +| `/hooks/agent` | POST | Yes | Send a message to a specific agent session | + +### Enabling webhooks + +```json +{ + "webhooks": { + "enabled": true, + "port": 8090, + "host": "127.0.0.1", + "token": "your-secret-token" + } +} +``` + +The webhook server starts automatically when `webhooks.enabled` is `true`. + +### POST /hooks/wake + +Wake the agent with a text prompt. The agent processes it and returns the response. + +```bash +curl -X POST http://127.0.0.1:8090/hooks/wake \ + -H "Authorization: Bearer your-secret-token" \ + -H "Content-Type: application/json" \ + -d '{"text": "Check my email for urgent items"}' +``` + +Response: + +```json +{ + "status": "ok", + "response": "I checked your inbox and found 2 urgent items..." +} +``` + +Optionally deliver the response to a messaging channel: + +```bash +curl -X POST http://127.0.0.1:8090/hooks/wake \ + -H "Authorization: Bearer your-secret-token" \ + -H "Content-Type: application/json" \ + -d '{ + "text": "Summarize today'\''s calendar", + "channel": "slack", + "channel_id": "C123456" + }' +``` + +### POST /hooks/agent + +Send a message to a specific agent session with channel delivery: + +```bash +curl -X POST http://127.0.0.1:8090/hooks/agent \ + -H "Authorization: Bearer your-secret-token" \ + -H "Content-Type: application/json" \ + -d '{ + "message": "New GitHub issue: Fix login bug #42", + "channel": "slack", + "channel_id": "C123456", + "sender": "GitHub" + }' +``` + +### Use cases + +- GitHub webhook → POST to `/hooks/agent` → agent triages the issue +- Email service → POST to `/hooks/wake` → agent summarizes new emails +- Cron script → POST to `/hooks/wake` → agent runs a daily report +- IoT sensor → POST to `/hooks/agent` → agent alerts on anomalies + +### Authentication + +All POST endpoints require a bearer token. Pass it via: +- `Authorization: Bearer ` header +- `?token=` query parameter (fallback) + +If no token is configured (`"token": ""`), endpoints are open (dev mode only). + +### How to test + +```bash +python -m pytest tests/test_webhooks.py -v +``` + +--- + +## 13. CLI Interface + +Aetheel includes a Click-based CLI with subcommands for all major operations. + +### Installation + +After installing with `pip install -e .` or `uv sync`, the `aetheel` command is available: + +```bash +aetheel # Same as `aetheel start` +aetheel start # Start with default adapters +aetheel --help # Show all commands +``` + +Or run directly: + +```bash +python cli.py start --discord --webchat +python cli.py chat "What is Python?" +python cli.py doctor +``` + +### Commands + +| Command | Description | +|---------|-------------| +| `aetheel` / `aetheel start` | Start with configured adapters | +| `aetheel start --discord` | Start with Discord adapter | +| `aetheel start --telegram` | Start with Telegram adapter | +| `aetheel start --webchat` | Start with WebChat adapter | +| `aetheel start --claude` | Use Claude Code runtime | +| `aetheel start --test` | Echo handler (no AI) | +| `aetheel chat "message"` | One-shot AI query (prints to stdout) | +| `aetheel status` | Show runtime status | +| `aetheel doctor` | Run diagnostics (check runtimes, tokens, workspace) | +| `aetheel config show` | Print current config.json | +| `aetheel config edit` | Open config in $EDITOR | +| `aetheel config init` | Reset config to defaults | +| `aetheel cron list` | List scheduled jobs | +| `aetheel cron remove ` | Remove a scheduled job | +| `aetheel memory search "query"` | Search memory | +| `aetheel memory sync` | Force memory re-index | + +### How to test + +```bash +# Verify CLI structure +python cli.py --help +python cli.py start --help +python cli.py config --help +python cli.py cron --help +python cli.py memory --help + +# Run diagnostics +python cli.py doctor +``` + +--- + +## 14. Configuration + +All configuration lives in `~/.aetheel/config.json`. Secrets (tokens) stay in `.env`. + +### Config hierarchy (highest priority wins) + +1. CLI arguments (`--model`, `--claude`, etc.) +2. Environment variables +3. `~/.aetheel/config.json` +4. Dataclass defaults + +### Full config.json example + +```json +{ + "log_level": "INFO", + "runtime": { + "mode": "cli", + "model": null, + "timeout_seconds": 120, + "server_url": "http://localhost:4096", + "format": "json" + }, + "claude": { + "model": null, + "timeout_seconds": 120, + "max_turns": 3, + "no_tools": false, + "allowed_tools": [ + "Bash", "Read", "Write", "Edit", "Glob", "Grep", + "WebSearch", "WebFetch", + "Task", "TaskOutput", "TaskStop", "Skill", + "TeamCreate", "TeamDelete", "SendMessage" + ] + }, + "discord": { + "listen_channels": [] + }, + "memory": { + "workspace": "~/.aetheel/workspace", + "db_path": "~/.aetheel/memory.db" + }, + "scheduler": { + "db_path": "~/.aetheel/scheduler.db" + }, + "heartbeat": { + "enabled": true, + "default_channel": "slack", + "default_channel_id": "", + "silent": false + }, + "webchat": { + "enabled": false, + "port": 8080, + "host": "127.0.0.1" + }, + "mcp": { + "servers": {} + }, + "hooks": { + "enabled": true + }, + "webhooks": { + "enabled": false, + "port": 8090, + "host": "127.0.0.1", + "token": "" + } +} +``` + +### Environment variables (.env) + +```bash +# Slack (required for Slack adapter) +SLACK_BOT_TOKEN=xoxb-... +SLACK_APP_TOKEN=xapp-... + +# Telegram (required for --telegram) +TELEGRAM_BOT_TOKEN=... + +# Discord (required for --discord) +DISCORD_BOT_TOKEN=... + +# Runtime overrides +OPENCODE_MODEL=anthropic/claude-sonnet-4-20250514 +CLAUDE_MODEL=claude-sonnet-4-20250514 +LOG_LEVEL=DEBUG +``` + +--- + +## 15. Running Tests + +### Prerequisites + +```bash +cd Aetheel +pip install -e ".[test]" +# or +uv sync --extra test +``` + +### Run all tests + +```bash +python -m pytest tests/ -v --ignore=tests/test_scheduler.py +``` + +> Note: `test_scheduler.py` requires `apscheduler` installed. If you have it, run the full suite: +> ```bash +> python -m pytest tests/ -v +> ``` + +### Run specific test files + +```bash +# Base adapter tests +python -m pytest tests/test_base_adapter.py -v + +# Skills system tests +python -m pytest tests/test_skills.py -v + +# MCP config writer tests +python -m pytest tests/test_mcp_config.py -v + +# SubagentBus pub/sub tests +python -m pytest tests/test_subagent_bus.py -v + +# Scheduler tests (requires apscheduler) +python -m pytest tests/test_scheduler.py -v + +# Hook system tests +python -m pytest tests/test_hooks.py -v + +# Webhook receiver tests +python -m pytest tests/test_webhooks.py -v +``` + +### Test summary + +| Test file | What it covers | Count | +|-----------|---------------|-------| +| `test_base_adapter.py` | BaseAdapter dispatch, handler registration, error handling | 9 | +| `test_skills.py` | Skill loading, trigger matching, frontmatter parsing, context building | 21 | +| `test_mcp_config.py` | MCP config writer (Claude/OpenCode formats, round-trip, edge cases) | 8 | +| `test_subagent_bus.py` | SubagentBus subscribe/publish, isolation, error resilience, thread safety | 10 + 2 | +| `test_hooks.py` | Hook discovery, trigger, programmatic hooks, error resilience, messages | 14 | +| `test_webhooks.py` | Webhook endpoints (wake, agent, health), auth, channel delivery | 10 | +| `test_scheduler.py` | Scheduler one-shot/cron jobs, persistence, removal | varies | + +### Quick smoke tests + +```bash +# Verify config loads correctly +python -c "from config import load_config; c = load_config(); print(f'Tools enabled: {not c.claude.no_tools}, Tools: {len(c.claude.allowed_tools)}')" + +# Verify system prompt has new sections +python -c " +from agent.opencode_runtime import build_aetheel_system_prompt +p = build_aetheel_system_prompt() +for section in ['Your Tools', 'Self-Modification', 'Subagents & Teams']: + assert section in p, f'Missing: {section}' + print(f' ✅ {section}') +" + +# Verify heartbeat parser +python -c " +from heartbeat.heartbeat import HeartbeatRunner +tests = [('Every 30 minutes', '*/30 * * * *'), ('Every morning (9:00 AM)', '0 9 * * *'), ('Every evening (6:00 PM)', '0 18 * * *')] +for header, expected in tests: + result = HeartbeatRunner._parse_schedule_header(header) + assert result == expected, f'{header}: got {result}, expected {expected}' + print(f' ✅ {header} -> {result}') +" + +# Verify CLI commands exist +python cli.py --help +``` diff --git a/docs/future-changes.md b/docs/future-changes.md new file mode 100644 index 0000000..5d4480b --- /dev/null +++ b/docs/future-changes.md @@ -0,0 +1,214 @@ +# Future Changes + +Planned features and architectural improvements for Aetheel. + +--- + +## Agent Teams & Delegation + +The current subagent system spawns generic background workers that share the main agent's identity. The goal is to evolve this into a proper agent team architecture where specialized agents with their own roles, tools, and identity files can be created and coordinated. + +### Vision + +A main agent (e.g. "CTO") talks to the user and delegates tasks to specialist agents (e.g. "Programmer", "Designer") that each have their own personality, tool access, and domain expertise. Results flow back through the main agent or directly to the channel. + +``` +User ──► Main Agent (CTO) + │ + ├──► Programmer Agent + │ • Own SOUL.md (code-focused personality) + │ • Tools: Bash, Read, Write, Edit, Grep + │ • Model: claude-sonnet for speed + │ + ├──► Designer Agent + │ • Own SOUL.md (design-focused personality) + │ • Tools: WebSearch, WebFetch, Read + │ • Model: claude-opus for creativity + │ + └──► Researcher Agent + • Own SOUL.md (thorough, citation-heavy) + • Tools: WebSearch, WebFetch + • Model: gemini-2.5-pro for large context +``` + +### What Exists Today + +- `SubagentManager` spawns background tasks via `[ACTION:spawn|]` +- `SubagentBus` provides pub/sub messaging between subagents +- Claude Code's `Task`, `TaskOutput`, `TeamCreate`, `SendMessage` tools are already in the allowed tools list +- All subagents currently share the same identity files (SOUL.md, USER.md, MEMORY.md) + +### How NanoClaw Does It + +NanoClaw (the inspiration project) takes a fundamentally different approach — container-based isolation with per-group identity: + +**Architecture:** +- Each chat group gets its own folder (`groups/{name}/`) with its own `CLAUDE.md` (identity/memory) +- A `groups/global/CLAUDE.md` provides shared context readable by all groups +- Each agent runs inside an Apple Container (lightweight Linux VM) with only its own folder mounted +- The main channel has elevated privileges (can manage groups, write global memory, schedule tasks for any group) +- Non-main groups can only access their own folder + read-only global memory + +**Agent Teams (Swarm):** +- Claude Code has native `TeamCreate`, `SendMessage` tools for multi-agent orchestration +- NanoClaw enables this via `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var in the container +- The lead agent creates teammates using Claude's built-in team tools +- Each teammate gets instructions to use `send_message` with a `sender` parameter +- On Telegram, each sender gets a dedicated bot identity (pool of pre-created bots renamed dynamically) +- The lead agent coordinates but doesn't relay every teammate message — users see them directly + +**Key design decisions:** +- Identity is per-group, not per-agent — a "programmer" agent in one group is different from a "programmer" in another +- The AI itself decides when to create teams and how to structure them — it's not pre-configured +- Container isolation means agents can't read each other's files or sessions +- IPC is file-based (JSON files in a watched directory), not in-memory pub/sub +- Secrets are passed via stdin, never mounted as files + +### What Needs to Be Built + +#### 1. Agent Definitions + +A new `agents/` directory in the workspace where each agent gets its own folder: + +``` +~/.aetheel/workspace/agents/ +├── programmer/ +│ ├── AGENT.md # Role, description, triggers +│ ├── SOUL.md # Agent-specific personality +│ └── SKILL.md # Optional: domain-specific skills +├── designer/ +│ ├── AGENT.md +│ ├── SOUL.md +│ └── SKILL.md +└── researcher/ + ├── AGENT.md + └── SOUL.md +``` + +`AGENT.md` frontmatter: + +```yaml +--- +name: programmer +description: Senior software engineer specializing in Python and system design +triggers: [code, implement, build, fix, debug, refactor, PR] +model: anthropic/claude-sonnet-4-20250514 +engine: claude +tools: [Bash, Read, Write, Edit, Glob, Grep] +max_turns: 5 +--- +``` + +#### 2. Agent-Aware Routing + +The main agent decides which specialist to delegate to based on: +- Explicit user request ("ask the programmer to...") +- Trigger word matching from AGENT.md +- AI-driven routing (the main agent's system prompt tells it about available agents) + +New action tag: `[ACTION:delegate||]` + +#### 3. Per-Agent Identity + +Each agent gets its own system prompt built from: +1. Its own `SOUL.md` (personality/role) +2. Shared `USER.md` (user context is universal) +3. Shared `MEMORY.md` (long-term memory is universal) +4. Its own skills from `SKILL.md` +5. Its tool restrictions from `AGENT.md` + +#### 4. Agent-to-Agent Communication + +Extend `SubagentBus` to support: +- Named channels per agent (not just task IDs) +- Request/response patterns (agent A asks agent B, waits for reply) +- Broadcast messages (main agent sends context to all agents) +- Result aggregation (main agent collects results from multiple agents) + +#### 5. Team Orchestration Patterns + +- **Sequential**: Programmer writes code → Designer reviews UI → Main agent summarizes +- **Parallel**: Programmer and Designer work simultaneously, main agent merges results +- **Supervisory**: Main agent reviews each agent's output before sending to user +- **Collaborative**: Agents can message each other directly via the bus + +#### 6. Chat Commands + +``` +team list # List defined agents +team status # Show which agents are active +team create # Interactive agent creation +team remove # Remove an agent definition +delegate # Manually delegate to a specific agent +``` + +#### 7. Claude Code Native Teams + +Claude Code already has `TeamCreate`, `TeamDelete`, `SendMessage` tools. These allow the AI itself to create and manage teams during a session. The integration path: + +- When using Claude Code engine, the AI can use these tools natively +- Aetheel wraps the results and routes messages back to the right channel +- Agent definitions in `AGENT.md` pre-configure teams that get created on startup + +### Implementation Priority + +There are two paths — pick based on complexity appetite: + +**Path A: Lightweight (extend current SubagentManager)** +1. Agent definition format (`AGENT.md`) and discovery +2. Per-agent identity file loading (own SOUL.md, shared USER.md/MEMORY.md) +3. `[ACTION:delegate|agent|task]` tag parsing +4. Agent-specific system prompt building with tool restrictions +5. Chat commands for team management +6. Agent-to-agent communication via extended bus + +**Path B: Full isolation (NanoClaw-style containers)** +1. Enable `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` for Claude Code runtime +2. Per-group workspace folders with own CLAUDE.md +3. Container-based agent execution (Docker or Apple Container) +4. File-based IPC between host and containers +5. Mount security (allowlist, per-group isolation) +6. Bot pool for Telegram/Discord (each agent gets its own bot identity) +7. Lead agent orchestration via Claude's native team tools + +Path A is simpler and works today. Path B is more secure and scalable but requires container infrastructure. + +### NanoClaw Reference Files + +Key files to study in `inspirations/nanoclaw/`: +- `src/container-runner.ts` — Container spawning, volume mounts, IPC, per-group isolation +- `src/types.ts` — `RegisteredGroup`, `ContainerConfig`, `AdditionalMount` types +- `groups/global/CLAUDE.md` — Global identity shared across all groups +- `groups/main/CLAUDE.md` — Main channel with elevated privileges +- `.claude/skills/add-telegram-swarm/SKILL.md` — Full agent swarm implementation for Telegram +- `docs/SPEC.md` — Complete architecture spec +- `docs/SECURITY.md` — Security model for container isolation + +--- + +## Other Planned Changes + +### Security Fixes (from security-audit.md) + +- Path containment check in `memory/manager.py` `read_file()` +- Mandatory webhook auth when enabled +- Input schema validation on webhook POST bodies +- Stricter cron expression validation +- Rate limiting on HTTP endpoints +- WebSocket authentication for WebChat +- Dependency version pinning + +### Persistent Usage Stats + +Currently usage stats reset on restart. Persist to SQLite so `usage` command shows lifetime stats, daily/weekly/monthly breakdowns, and cost trends. + +### Multi-Model Routing + +Route different types of requests to different models automatically: +- Quick questions → fast/cheap model (sonnet, gpt-4o-mini) +- Complex reasoning → powerful model (opus, o1) +- Large context → big-context model (gemini-2.5-pro) + +### Conversation Branching + +Allow users to fork a conversation into a new thread with a different model or agent, then merge results back. diff --git a/docs/security-audit.md b/docs/security-audit.md new file mode 100644 index 0000000..f89325b --- /dev/null +++ b/docs/security-audit.md @@ -0,0 +1,172 @@ +# Aetheel Security Audit + +**Date:** February 17, 2026 +**Scope:** Full codebase review of all modules + +--- + +## CRITICAL + +### 1. Path Traversal in `memory/manager.py` → `read_file()` + +The method accepts absolute paths and resolves them with `os.path.realpath()` but never validates the result is within the workspace directory. An attacker (or the AI itself) could read arbitrary files: + +```python +# Current code — no containment check +if os.path.isabs(raw): + abs_path = os.path.realpath(raw) +``` + +**Fix:** Add a check like `if not abs_path.startswith(self._workspace_dir): raise ValueError("path outside workspace")` + +### 2. Arbitrary Code Execution via Hook `handler.py` Loading + +`hooks/hooks.py` → `_load_handler` uses `importlib.util.spec_from_file_location` to dynamically load and execute arbitrary Python from `handler.py` files found in the workspace. If an attacker can write a file to `~/.aetheel/workspace/hooks//handler.py`, they get full code execution. There's no sandboxing, signature verification, or allowlisting. + +### 3. Webhook Auth Defaults to Open Access + +`webhooks/receiver.py` → `_check_auth`: + +```python +if not self._config.token: + return True # No token configured = open access +``` + +If the webhook receiver is enabled without a token, anyone on the network can trigger AI actions. The default config writes `"token": ""` which means open access. + +### 4. AI-Controlled Action Tags Execute Without Validation + +`main.py` → `_process_action_tags` parses the AI's response text for action tags like `[ACTION:cron|...]`, `[ACTION:spawn|...]`, and `[ACTION:remind|...]`. The AI can: + +- Schedule arbitrary cron jobs with any expression +- Spawn unlimited subagent tasks +- Set reminders with any delay + +There's no validation that the AI was asked to do this, no user confirmation, and no rate limiting. A prompt injection attack via any adapter could trigger these. + +--- + +## HIGH + +### 5. No Input Validation on Webhook POST Bodies + +`webhooks/receiver.py` — JSON payloads are parsed but never schema-validated. Fields like `channel_id`, `sender`, `channel` are passed through directly. The `body` dict is stored in `raw_event` and could contain arbitrarily large data. + +### 6. No Request Size Limits on HTTP Endpoints + +Neither the webhook receiver nor the WebChat adapter set `client_max_size` on the aiohttp `Application`. Default is 2MB but there's no explicit limit, and no per-request timeout. + +### 7. WebSocket Has No Authentication + +`adapters/webchat_adapter.py` — Anyone who can reach the WebSocket endpoint at `/ws` can interact with the AI. No token, no session cookie, no origin check. If the host is changed from `127.0.0.1` to `0.0.0.0`, this becomes remotely exploitable. + +### 8. No Rate Limiting Anywhere + +No rate limiting on: + +- Webhook endpoints +- WebSocket messages +- Adapter message handlers +- Subagent spawning (only a concurrent limit of 3, but no cooldown) +- Scheduler job creation + +### 9. Cron Expression Not Validated Before APScheduler + +`scheduler/scheduler.py` → `_register_cron_job` only checks `len(parts) != 5`. Malformed values within fields (e.g., `999 999 999 999 999`) are passed directly to `CronTrigger`, which could cause unexpected behavior or exceptions. + +### 10. Webhook Token in Query Parameter + +`webhooks/receiver.py`: + +```python +if request.query.get("token") == self._config.token: + return True +``` + +Query parameters are logged in web server access logs, browser history, and proxy logs. This leaks the auth token. + +--- + +## MEDIUM + +### 11. SQLite Databases Created with Default Permissions + +`sessions.db`, `scheduler.db`, and `memory.db` are all created under `~/.aetheel/` with default umask permissions. On multi-user systems, these could be world-readable. + +### 12. Webhook Token Stored in `config.json` + +The `webhooks.token` field in `config.py` is read from and written to `config.json`, which is a plaintext file. Secrets should only live in `.env`. + +### 13. No HTTPS on Any HTTP Endpoint + +Both WebChat (port 8080) and webhooks (port 8090) run plain HTTP. Even on localhost, this is vulnerable to local network sniffing. + +### 14. Full Environment Passed to Subprocesses + +`_build_cli_env()` in both runtimes copies `os.environ` entirely to the subprocess, which may include sensitive variables beyond what the CLI needs. + +### 15. Session Logs Contain Full Conversations in Plaintext + +`memory/manager.py` → `log_session()` writes unencrypted markdown files to `~/.aetheel/workspace/daily/`. No access control, no encryption, no retention policy. + +### 16. XSS Partially Mitigated in `chat.html` but Fragile + +The `renderMarkdown()` function escapes `<`, `>`, `&` first, then applies regex-based markdown rendering. User messages use `textContent` (safe). AI messages use `innerHTML` with the escaped+rendered output. The escaping happens before markdown processing, which is the right order, but the regex-based approach is fragile — edge cases in the markdown regexes could potentially bypass the escaping. + +### 17. No CORS Headers on WebChat + +The aiohttp app doesn't configure CORS. If exposed beyond localhost, cross-origin requests could interact with the WebSocket. + +--- + +## LOW + +### 18. Loose Dependency Version Constraints + +`pyproject.toml`: + +- `python-telegram-bot>=21.0` — no upper bound +- `discord.py>=2.4.0` — no upper bound +- `fastembed>=0.7.4` — no upper bound + +These could pull in breaking or vulnerable versions on fresh installs. + +### 19. No Security Scanning in CI/Test Pipeline + +No `bandit`, `safety`, `pip-audit`, or similar tools in the test suite or project config. + +### 20. `config edit` Uses `$EDITOR` Without Sanitization + +`cli.py`: + +```python +editor = os.environ.get("EDITOR", "nano") +subprocess.run([editor, CONFIG_PATH], check=True) +``` + +If `$EDITOR` contains spaces or special characters, this could behave unexpectedly (though `subprocess.run` with a list is safe from shell injection). + +### 21. No Data Retention/Cleanup for Session Logs + +Session logs accumulate indefinitely in `daily/`. No automatic pruning. + +### 22. `SubagentBus` Has No Authentication + +The pub/sub bus allows any code in the process to publish/subscribe to any channel. No isolation between subagents. + +--- + +## Recommended Priority Fixes + +The most impactful changes to make first: + +1. **Add path containment check in `read_file()`** — one-line fix, prevents file system escape +2. **Make webhook auth mandatory** when `webhooks.enabled = true` — refuse to start without a token +3. **Add input schema validation** on webhook POST bodies +4. **Validate cron expressions** more strictly before passing to APScheduler +5. **Add rate limiting** to webhook and WebSocket endpoints (e.g., aiohttp middleware) +6. **Move `webhooks.token` to `.env` only**, remove from `config.json` +7. **Add WebSocket origin checking or token auth** to WebChat +8. **Set explicit `client_max_size`** on aiohttp apps +9. **Pin dependency upper bounds** in `pyproject.toml` +10. **Add `bandit`** to the test pipeline diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..02c992f --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,841 @@ +# Aetheel Server Setup Guide + +Step-by-step guide to deploy Aetheel on a Linux or macOS machine. + +--- + +## Quick Start (Recommended) + +The interactive installer handles everything automatically: + +```bash +# One-line install +curl -fsSL http://10.0.0.59:3051/tanmay/Aetheel/raw/branch/main/install.sh | bash +``` + +Or if you already have the repo cloned: + +```bash +./install.sh +``` + +The installer will: +- Check prerequisites (Python 3.12+, uv) +- Install dependencies +- Detect or install AI runtimes (OpenCode / Claude Code) +- Walk you through token configuration +- Install the `aetheel` command +- Optionally set up a background service (launchd/systemd) + +After install, use the `aetheel` command: + +```bash +aetheel start # Start the bot +aetheel stop # Stop the background service +aetheel restart # Restart the service +aetheel status # Check status +aetheel logs # Tail live logs +aetheel setup # Re-run setup wizard +aetheel update # Pull latest + update deps +aetheel doctor # Run diagnostics +aetheel config # Edit config.json +``` + +For flags and options: `aetheel help` + +--- + +## Manual Setup + +If you prefer to set things up manually, follow the steps below. + +--- + +## Table of Contents + +1. [Prerequisites](#1-prerequisites) +2. [Install System Dependencies](#2-install-system-dependencies) +3. [Install uv (Python Package Manager)](#3-install-uv) +4. [Install an AI Runtime](#4-install-an-ai-runtime) +5. [Clone the Repository](#5-clone-the-repository) +6. [Install Python Dependencies](#6-install-python-dependencies) +7. [Configure Secrets (.env)](#7-configure-secrets) +8. [Configure Settings (config.json)](#8-configure-settings) +9. [Set Up Messaging Channels](#9-set-up-messaging-channels) +10. [Run the Test Suite](#10-run-the-test-suite) +11. [Start Aetheel](#11-start-aetheel) +12. [Run as a systemd Service](#12-run-as-a-systemd-service) +13. [Verify Everything Works](#13-verify-everything-works) +14. [Optional: Enable WebChat](#14-optional-enable-webchat) +15. [Optional: Enable Webhooks](#15-optional-enable-webhooks) +16. [Optional: Configure Hooks](#16-optional-configure-hooks) +17. [Optional: Configure Heartbeat](#17-optional-configure-heartbeat) +18. [Optional: Add MCP Servers](#18-optional-add-mcp-servers) +19. [Optional: Create Skills](#19-optional-create-skills) +20. [Updating](#20-updating) +21. [Troubleshooting](#21-troubleshooting) + +--- + +## 1. Prerequisites + +| Requirement | Minimum | Recommended | +|---|---|---| +| OS | Ubuntu 20.04 / Debian 11 | Ubuntu 22.04+ / Debian 12+ | +| Python | 3.12 | 3.13 | +| RAM | 512 MB | 1 GB+ (fastembed uses ~300 MB for embeddings) | +| Disk | 500 MB | 1 GB+ | +| Network | Outbound HTTPS | Outbound HTTPS | + +You also need at least one of: +- Slack workspace with admin access (for Slack adapter) +- Telegram bot token (for Telegram adapter) +- Discord bot token (for Discord adapter) +- Nothing (for WebChat-only mode) + +And at least one AI runtime: +- [OpenCode](https://opencode.ai) CLI +- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI +- An API key for the LLM provider (Anthropic, OpenAI, etc.) + +--- + +## 2. Install System Dependencies + +```bash +# Update packages +sudo apt update && sudo apt upgrade -y + +# Install essentials +sudo apt install -y git curl build-essential + +# Verify +git --version +curl --version +``` + +--- + +## 3. Install uv + +[uv](https://docs.astral.sh/uv/) is the recommended Python package manager. It handles Python versions, virtual environments, and dependencies. + +```bash +# Install uv +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Add to PATH (restart shell or source profile) +source ~/.bashrc # or ~/.zshrc + +# Verify +uv --version +``` + +uv will automatically install the correct Python version (3.13) when you run `uv sync`. + +--- + +## 4. Install an AI Runtime + +You need at least one AI runtime. Pick one (or both): + +### Option A: OpenCode (recommended) + +```bash +curl -fsSL https://opencode.ai/install | bash + +# Verify +opencode --version + +# Configure a provider +opencode auth login +``` + +### Option B: Claude Code + +```bash +# Requires Node.js +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs + +# Install Claude Code +npm install -g @anthropic-ai/claude-code + +# Verify +claude --version +``` + +### Set your API key + +Whichever runtime you use, you need an API key for the LLM provider: + +```bash +# For Anthropic (used by both OpenCode and Claude Code) +export ANTHROPIC_API_KEY="sk-ant-..." + +# For OpenAI +export OPENAI_API_KEY="sk-..." +``` + +Add these to your `.env` file later (step 7). + +--- + +## 5. Clone the Repository + +```bash +# Clone +cd ~ +git clone http://10.0.0.59:3051/tanmay/Aetheel.git +cd Aetheel + +# Verify +ls pyproject.toml # Should exist +``` + +--- + +## 6. Install Python Dependencies + +```bash +# Install project + test dependencies +uv sync --extra test + +# Verify Python version +uv run python --version # Should be 3.13.x + +# Verify key packages +uv run python -c "import click; import aiohttp; import apscheduler; print('All packages OK')" +``` + +--- + +## 7. Configure Secrets + +Secrets (tokens, API keys) go in the `.env` file. This file is gitignored. + +```bash +# Create from template +cp .env.example .env + +# Edit with your tokens +nano .env +``` + +Fill in the tokens you need: + +```bash +# Required for Slack +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_APP_TOKEN=xapp-your-app-token + +# Required for Telegram (if using --telegram) +TELEGRAM_BOT_TOKEN=your-telegram-token + +# Required for Discord (if using --discord) +DISCORD_BOT_TOKEN=your-discord-token + +# AI provider API key +ANTHROPIC_API_KEY=sk-ant-your-key + +# Optional overrides +# OPENCODE_MODEL=anthropic/claude-sonnet-4-20250514 +# LOG_LEVEL=DEBUG +``` + +See [docs/slack-setup.md](slack-setup.md) and [docs/discord-setup.md](discord-setup.md) for how to get these tokens. + +--- + +## 8. Configure Settings + +Non-secret settings go in `~/.aetheel/config.json`. A default is created on first run, but you can create it now: + +```bash +# Create the config directory +mkdir -p ~/.aetheel/workspace + +# Generate default config +uv run python -c "from config import save_default_config; save_default_config()" + +# View it +cat ~/.aetheel/config.json +``` + +Edit if needed: + +```bash +nano ~/.aetheel/config.json +``` + +Key settings to review: + +```json +{ + "runtime": { + "mode": "cli", + "model": null + }, + "claude": { + "no_tools": false, + "allowed_tools": ["Bash", "Read", "Write", "..."] + }, + "heartbeat": { + "enabled": true, + "default_channel": "slack", + "default_channel_id": "" + }, + "webchat": { + "enabled": false, + "port": 8080 + }, + "webhooks": { + "enabled": false, + "port": 8090, + "token": "" + } +} +``` + +--- + +## 9. Set Up Messaging Channels + +### Slack (see [docs/slack-setup.md](slack-setup.md)) + +1. Create a Slack app at https://api.slack.com/apps +2. Add bot scopes: `app_mentions:read`, `chat:write`, `im:history`, `im:read`, `im:write`, `channels:history`, `users:read` +3. Enable Socket Mode, generate an app-level token (`xapp-...`) +4. Install to workspace, copy bot token (`xoxb-...`) +5. Invite bot to channels: `/invite @Aetheel` + +### Discord (see [docs/discord-setup.md](discord-setup.md)) + +1. Create app at https://discord.com/developers/applications +2. Create bot, copy token +3. Enable Message Content Intent +4. Invite with `bot` scope + Send Messages + Read Message History + +### Telegram + +1. Message @BotFather on Telegram +2. `/newbot` → follow prompts → copy token +3. Set `TELEGRAM_BOT_TOKEN` in `.env` + +--- + +## 10. Run the Test Suite + +Before starting, verify everything works: + +```bash +cd ~/Aetheel + +# Run the full test + smoke check script +uv run python test_all.py +``` + +You should see all checks pass: + +``` +━━━ RESULTS ━━━ +Total: 45 checks + Passed: 45 + Failed: 0 + Skipped: 0 +All checks passed! 🎉 +``` + +If anything fails, fix it before proceeding. Common issues: +- Missing packages → `uv sync --extra test` +- Wrong directory → `cd ~/Aetheel` + +You can also run just the pytest suite: + +```bash +uv run python -m pytest tests/ -v --ignore=tests/test_scheduler.py +``` + +--- + +## 11. Start Aetheel + +### Basic start (Slack only) + +```bash +uv run python main.py +``` + +### With additional adapters + +```bash +# Slack + Discord +uv run python main.py --discord + +# Slack + Telegram +uv run python main.py --telegram + +# Slack + Discord + WebChat +uv run python main.py --discord --webchat + +# All adapters +uv run python main.py --discord --telegram --webchat +``` + +### With Claude Code runtime + +```bash +uv run python main.py --claude +``` + +### Test mode (no AI, echo handler) + +```bash +uv run python main.py --test +``` + +### Using the CLI + +```bash +# Same as above but via the click CLI +uv run python cli.py start --discord --webchat + +# One-shot chat (no adapters needed) +uv run python cli.py chat "What is Python?" + +# Diagnostics +uv run python cli.py doctor +``` + +--- + +## 12. Run as a systemd Service + +To keep Aetheel running after you disconnect from SSH: + +### Create the service file + +```bash +sudo nano /etc/systemd/system/aetheel.service +``` + +Paste (adjust paths to match your setup): + +```ini +[Unit] +Description=Aetheel AI Assistant +After=network.target + +[Service] +Type=simple +User=your-username +WorkingDirectory=/home/your-username/Aetheel +ExecStart=/home/your-username/.local/bin/uv run python main.py +Restart=on-failure +RestartSec=10 +Environment=PATH=/home/your-username/.local/bin:/usr/local/bin:/usr/bin:/bin + +# Load .env file +EnvironmentFile=/home/your-username/Aetheel/.env + +# Optional: add more adapters +# ExecStart=/home/your-username/.local/bin/uv run python main.py --discord --webchat + +[Install] +WantedBy=multi-user.target +``` + +### Enable and start + +```bash +# Reload systemd +sudo systemctl daemon-reload + +# Enable on boot +sudo systemctl enable aetheel + +# Start now +sudo systemctl start aetheel + +# Check status +sudo systemctl status aetheel + +# View logs +sudo journalctl -u aetheel -f +``` + +### Manage the service + +```bash +sudo systemctl stop aetheel # Stop +sudo systemctl restart aetheel # Restart +sudo systemctl disable aetheel # Disable on boot +``` + +--- + +## 13. Verify Everything Works + +### Check the startup log + +```bash +sudo journalctl -u aetheel --no-pager | tail -20 +``` + +You should see: + +``` +============================================================ + Aetheel Starting +============================================================ + Config: /home/user/.aetheel/config.json + Runtime: opencode/cli, model=default + Channels: slack + Skills: 0 + Scheduler: ✅ + Heartbeat: ✅ 3 tasks + Subagents: ✅ + Hooks: ✅ 0 hooks + Webhooks: ❌ +============================================================ +``` + +### Test from Slack/Discord/Telegram + +1. Send `status` → should show runtime info +2. Send `help` → should show command list +3. Send any question → should get an AI response +4. Send `/reload` → should confirm reload + +### Test from CLI + +```bash +uv run python cli.py doctor +uv run python cli.py status +``` + +--- + +## 14. Optional: Enable WebChat + +Browser-based chat at `http://your-server:8080`. + +### Via config + +Edit `~/.aetheel/config.json`: + +```json +{ + "webchat": { + "enabled": true, + "port": 8080, + "host": "0.0.0.0" + } +} +``` + +Set `host` to `0.0.0.0` to accept connections from other machines (not just localhost). + +### Via CLI flag + +```bash +uv run python main.py --webchat +``` + +### Firewall + +If using `ufw`: + +```bash +sudo ufw allow 8080/tcp +``` + +Then open `http://your-server-ip:8080` in a browser. + +--- + +## 15. Optional: Enable Webhooks + +HTTP endpoints for external systems to trigger the agent. + +### Configure + +Edit `~/.aetheel/config.json`: + +```json +{ + "webhooks": { + "enabled": true, + "port": 8090, + "host": "0.0.0.0", + "token": "your-secret-webhook-token" + } +} +``` + +Generate a random token: + +```bash +python3 -c "import secrets; print(secrets.token_urlsafe(32))" +``` + +### Firewall + +```bash +sudo ufw allow 8090/tcp +``` + +### Test + +```bash +# Health check (no auth) +curl http://localhost:8090/hooks/health + +# Wake the agent +curl -X POST http://localhost:8090/hooks/wake \ + -H "Authorization: Bearer your-secret-webhook-token" \ + -H "Content-Type: application/json" \ + -d '{"text": "What time is it?"}' + +# Send to a specific channel +curl -X POST http://localhost:8090/hooks/agent \ + -H "Authorization: Bearer your-secret-webhook-token" \ + -H "Content-Type: application/json" \ + -d '{ + "message": "New alert from monitoring", + "channel": "slack", + "channel_id": "C123456" + }' +``` + +--- + +## 16. Optional: Configure Hooks + +Lifecycle hooks run custom code on gateway events. + +### Create a hook + +```bash +mkdir -p ~/.aetheel/workspace/hooks/startup-logger +``` + +Create `~/.aetheel/workspace/hooks/startup-logger/HOOK.md`: + +```markdown +--- +name: startup-logger +description: Log when the gateway starts +events: [gateway:startup] +enabled: true +--- +# Startup Logger +Logs a message when Aetheel starts. +``` + +Create `~/.aetheel/workspace/hooks/startup-logger/handler.py`: + +```python +import logging + +logger = logging.getLogger("aetheel.hooks.startup-logger") + +def handle(event): + logger.info("Gateway started — startup hook fired!") +``` + +Hooks are discovered automatically on startup. + +--- + +## 17. Optional: Configure Heartbeat + +The heartbeat system runs periodic tasks automatically. + +### Edit HEARTBEAT.md + +```bash +nano ~/.aetheel/workspace/HEARTBEAT.md +``` + +Example: + +```markdown +# Heartbeat Tasks + +## Every 30 minutes +- Check if any scheduled reminders need attention + +## Every morning (9:00 AM) +- Summarize yesterday's conversations +- Check for any pending follow-ups + +## Every evening (6:00 PM) +- Update MEMORY.md with today's key learnings +``` + +### Configure where heartbeat responses go + +Edit `~/.aetheel/config.json`: + +```json +{ + "heartbeat": { + "enabled": true, + "default_channel": "slack", + "default_channel_id": "C123456" + } +} +``` + +Set `default_channel_id` to the Slack channel ID where heartbeat responses should be sent. + +--- + +## 18. Optional: Add MCP Servers + +External tool servers that extend the agent's capabilities. + +Edit `~/.aetheel/config.json`: + +```json +{ + "mcp": { + "servers": { + "brave-search": { + "command": "uvx", + "args": ["brave-search-mcp@latest"], + "env": { + "BRAVE_API_KEY": "your-key" + } + } + } + } +} +``` + +Aetheel writes the appropriate config file (`.mcp.json` or `opencode.json`) to the workspace before launching the runtime. + +--- + +## 19. Optional: Create Skills + +Skills teach the agent how to handle specific types of requests. + +```bash +mkdir -p ~/.aetheel/workspace/skills/weather +``` + +Create `~/.aetheel/workspace/skills/weather/SKILL.md`: + +```markdown +--- +name: weather +description: Check weather for any city +triggers: [weather, forecast, temperature, rain] +--- + +# Weather Skill + +When the user asks about weather: +1. Use web search to find current conditions +2. Include temperature, conditions, and forecast +3. Format the response with emoji +``` + +Skills are loaded at startup and can be reloaded with `/reload`. + +--- + +## 20. Updating + +```bash +cd ~/Aetheel + +# Pull latest +git pull + +# Update dependencies +uv sync --extra test + +# Run tests +uv run python test_all.py + +# Restart service +sudo systemctl restart aetheel +``` + +--- + +## 21. Troubleshooting + +### "No channel adapters initialized!" + +No messaging tokens are set. Check your `.env` file has at least one of: +- `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` +- `TELEGRAM_BOT_TOKEN` (with `--telegram` flag) +- `DISCORD_BOT_TOKEN` (with `--discord` flag) +- `--webchat` flag (no tokens needed) + +### "AI runtime not initialized" + +The AI CLI (opencode or claude) isn't installed or isn't in PATH. + +```bash +# Check +which opencode +which claude + +# Install OpenCode +curl -fsSL https://opencode.ai/install | bash +``` + +### "Request timed out" + +The AI took too long. Increase timeout in config: + +```json +{ + "runtime": { "timeout_seconds": 300 }, + "claude": { "timeout_seconds": 300 } +} +``` + +### Memory system init failed + +Usually means fastembed can't download the embedding model on first run. Check internet access and disk space. + +```bash +# Test manually +uv run python -c "from fastembed import TextEmbedding; e = TextEmbedding(); print('OK')" +``` + +### Port already in use (WebChat/Webhooks) + +Another process is using the port. Change it in config or find the process: + +```bash +sudo lsof -i :8080 # Find what's using port 8080 +``` + +### systemd service won't start + +Check logs: + +```bash +sudo journalctl -u aetheel -n 50 --no-pager +``` + +Common issues: +- Wrong `WorkingDirectory` path +- Wrong `User` +- `.env` file not found (check `EnvironmentFile` path) +- uv not in PATH (check `Environment=PATH=...`) + +### Run diagnostics + +```bash +uv run python cli.py doctor +``` + +This checks config validity, workspace, runtime CLIs, tokens, and memory DB. diff --git a/docs/spec-phase3-features.md b/docs/spec-phase3-features.md new file mode 100644 index 0000000..e31685b --- /dev/null +++ b/docs/spec-phase3-features.md @@ -0,0 +1,560 @@ +# Aetheel Phase 3 Feature Spec + +> Specification for implementing heartbeat, CLI, webchat, self-modification, agent-to-agent communication, and tool/MCP/web search integration. + +--- + +## Table of Contents + +1. [Runtime Capabilities Audit](#1-runtime-capabilities-audit) +2. [Tool System, MCP & Web Search](#2-tool-system-mcp--web-search) +3. [Heartbeat / Proactive System](#3-heartbeat--proactive-system) +4. [CLI Interface](#4-cli-interface) +5. [WebChat Interface](#5-webchat-interface) +6. [Self-Modification](#6-self-modification) +7. [Agent-to-Agent Communication](#7-agent-to-agent-communication) +8. [Implementation Order](#8-implementation-order) + +--- + +## 1. Runtime Capabilities Audit + +Before building anything, here's what OpenCode and Claude Code already provide natively that Aetheel can leverage without custom implementation. + +### OpenCode Built-in Tools + +OpenCode ships with these tools enabled by default (no config needed): + +| Tool | Description | Aetheel Status | +|------|-------------|----------------| +| `bash` | Execute shell commands | ✅ Available via runtime | +| `read` | Read file contents | ✅ Available via runtime | +| `write` | Create/overwrite files | ✅ Available via runtime | +| `edit` | Modify files via string replacement | ✅ Available via runtime | +| `grep` | Regex search across files | ✅ Available via runtime | +| `glob` | Find files by pattern | ✅ Available via runtime | +| `list` | List directory contents | ✅ Available via runtime | +| `websearch` | Web search via Exa AI (no API key needed) | ✅ Available — no setup required | +| `webfetch` | Fetch and read web pages | ✅ Available via runtime | +| `skill` | Load SKILL.md files | ✅ Available via runtime | +| `todowrite` / `todoread` | Task tracking | ✅ Available via runtime | +| `lsp` | Code intelligence (experimental) | ✅ Available via runtime | +| `patch` | Apply diffs | ✅ Available via runtime | +| Custom tools | User-defined JS/TS tools | 🟡 Available but not wired | +| MCP servers | External tool servers | 🟡 Available but not configured | + +### Claude Code Built-in Tools + +Claude Code provides these tools natively: + +| Tool | Description | Aetheel Status | +|------|-------------|----------------| +| `Bash` | Shell execution | ✅ Available via runtime | +| `Read`, `Write`, `Edit` | File operations | ✅ Available via runtime | +| `Glob`, `Grep` | File search | ✅ Available via runtime | +| `WebSearch`, `WebFetch` | Web access | ✅ Available via runtime | +| `Task`, `TaskOutput`, `TaskStop` | Subagent spawning | ✅ Available via runtime | +| `TeamCreate`, `TeamDelete`, `SendMessage` | Agent teams | ✅ Available via runtime | +| `Skill` | Load skills | ✅ Available via runtime | +| MCP servers | Via `.mcp.json` config | 🟡 Available but not configured | + +### Key Insight + +Both runtimes already have web search, file ops, bash, and subagent tools built in. Aetheel doesn't need to implement these — it just needs to stop blocking them. Currently Aetheel's system prompt doesn't tell the agent about these capabilities, and Claude Code's `--allowedTools ""` (no_tools=true) actively disables them. + +--- + +## 2. Tool System, MCP & Web Search + +### What Needs to Change + +**Problem**: Aetheel currently runs both runtimes in "pure chat" mode — tools are disabled or not mentioned in the system prompt. + +**Solution**: Enable tool access and configure MCP servers. + +### 2.1 Enable Runtime Tools + +For OpenCode (CLI mode), tools are enabled by default. No change needed. + +For OpenCode (SDK mode), tools are enabled by default on the server. No change needed. + +For Claude Code, change `no_tools` default from `true` to `false` in config, and set a sensible `allowedTools` list: + +```python +# config.py — ClaudeConfig +@dataclass +class ClaudeConfig: + no_tools: bool = False # Changed from True + allowed_tools: list[str] = field(default_factory=lambda: [ + "Bash", "Read", "Write", "Edit", "Glob", "Grep", + "WebSearch", "WebFetch", + "Task", "Skill", + ]) +``` + +### 2.2 Update System Prompt + +Add tool awareness to `build_aetheel_system_prompt()`: + +``` +# Your Tools +- You have access to shell commands, file operations, and web search +- Use web search to look up current information when needed +- You can read and write files in the workspace (~/.aetheel/workspace/) +- You can execute shell commands for system tasks +``` + +### 2.3 MCP Server Configuration + +Create an `opencode.json` in the workspace for OpenCode MCP config, or a `.mcp.json` for Claude Code. This lets users add external tool servers. + +Add to `~/.aetheel/config.json`: + +```json +{ + "mcp": { + "servers": { + "example": { + "command": "uvx", + "args": ["some-mcp-server"], + "env": {} + } + } + } +} +``` + +Aetheel writes this to the appropriate config file (`opencode.json` or `.mcp.json`) in the workspace directory before launching the runtime. + +### 2.4 Implementation Tasks + +1. Change `ClaudeConfig.no_tools` default to `False` +2. Add `allowed_tools` to `ClaudeConfig` with sensible defaults +3. Update `_build_cli_args()` in `claude_runtime.py` to pass `--allowedTools` from config +4. Update `build_aetheel_system_prompt()` to mention available tools +5. Add `mcp` section to `config.py` and `config.json` +6. Write MCP config files to workspace before runtime launch +7. Add `TOOLS.md` to workspace identity files (like PicoClaw/OpenClaw) + +### Inspiration + +- **NanoClaw**: Passes `allowedTools` list to `query()` call, runs custom MCP server for IPC +- **PicoClaw**: Built-in tool registry in `pkg/tools/`, `TOOLS.md` describes tools to agent +- **OpenClaw**: Tool registry with streaming, `TOOLS.md` in workspace + +--- + +## 3. Heartbeat / Proactive System + +### How Inspiration Repos Do It + +**PicoClaw**: Reads `HEARTBEAT.md` every 30 minutes. Quick tasks run directly, long tasks spawn subagents. The heartbeat file contains prompts like "Check if any cron jobs need attention" or "Review MEMORY.md for stale entries." + +**Nanobot**: Has a `heartbeat/` module that triggers periodic agent runs. + +**NanoClaw**: Uses scheduled tasks via MCP tools — the agent itself schedules recurring tasks using `schedule_task` with cron expressions. No separate heartbeat file. + +**OpenClaw**: Cron system + wakeups. Heartbeat runs are suppressed from WebChat broadcast when `showOk: false`. + +### Design for Aetheel + +Combine PicoClaw's `HEARTBEAT.md` approach (simple, file-based) with Aetheel's existing scheduler: + +### 3.1 HEARTBEAT.md + +Create `~/.aetheel/workspace/HEARTBEAT.md` as a user-editable file: + +```markdown +# Heartbeat Tasks + +Tasks that run periodically. Each section is a task prompt. + +## Every 30 minutes +- Check if any scheduled reminders need attention +- Review recent session logs for anything worth remembering + +## Every morning (9:00 AM) +- Summarize yesterday's conversations +- Check for any pending follow-ups in MEMORY.md + +## Every evening (6:00 PM) +- Update MEMORY.md with today's key learnings +``` + +### 3.2 Heartbeat Runner + +New module: `heartbeat/heartbeat.py` + +```python +class HeartbeatRunner: + def __init__(self, runtime, send_fn, workspace_dir, scheduler): + ... + + def start(self): + """Register heartbeat tasks with the scheduler.""" + # Parse HEARTBEAT.md + # Register cron jobs for each section + # Jobs route through ai_handler with synthetic messages + + def _parse_heartbeat_md(self) -> list[HeartbeatTask]: + """Parse HEARTBEAT.md into structured tasks.""" + ... + + def _on_heartbeat(self, task: HeartbeatTask): + """Called when a heartbeat fires. Routes to AI handler.""" + ... +``` + +### 3.3 Integration + +- Parse `HEARTBEAT.md` at startup, register tasks with existing `Scheduler` +- Heartbeat tasks create synthetic `IncomingMessage` with `source="heartbeat"` +- Results can be sent to a configured channel or logged silently +- Add `heartbeat` section to config: + +```json +{ + "heartbeat": { + "enabled": true, + "default_channel": "slack", + "default_channel_id": "C123456", + "silent": false + } +} +``` + +### 3.4 Implementation Tasks + +1. Create `heartbeat/` module with `HeartbeatRunner` +2. Create default `HEARTBEAT.md` in workspace (like SOUL.md bootstrap) +3. Parse markdown sections into cron expressions + prompts +4. Register with existing `Scheduler` at startup +5. Add heartbeat config section +6. Wire into `main.py` initialization + +--- + +## 4. CLI Interface + +### How Inspiration Repos Do It + +**Nanobot**: `nanobot agent -m "..."`, `nanobot gateway`, `nanobot status`, `nanobot cron add/list/remove` + +**OpenClaw**: `openclaw gateway`, `openclaw agent --message "..."`, `openclaw doctor`, `openclaw pairing approve` + +**PicoClaw**: Binary with subcommands via Go's `cobra` library + +### Design for Aetheel + +Use Python's `click` library for a clean CLI with subcommands. + +### 4.1 CLI Structure + +``` +aetheel # Start with default adapters (same as current main.py) +aetheel start # Start with all configured adapters +aetheel start --discord # Start with specific adapters +aetheel chat "message" # One-shot chat (no adapter, just AI) +aetheel status # Show runtime status +aetheel cron list # List scheduled jobs +aetheel cron remove # Remove a job +aetheel sessions list # List active sessions +aetheel sessions clear # Clear stale sessions +aetheel config show # Print current config +aetheel config edit # Open config in $EDITOR +aetheel config init # Reset to defaults +aetheel memory search "q" # Search memory +aetheel memory sync # Force memory re-index +aetheel doctor # Diagnostics (check runtime, tokens, etc.) +``` + +### 4.2 Implementation + +New file: `cli.py` + +```python +import click + +@click.group() +def cli(): + """Aetheel — AI-Powered Personal Assistant""" + pass + +@cli.command() +@click.option("--discord", is_flag=True) +@click.option("--telegram", is_flag=True) +@click.option("--claude", is_flag=True) +@click.option("--model", default=None) +@click.option("--test", is_flag=True) +@click.option("--log", default="INFO") +def start(discord, telegram, claude, model, test, log): + """Start Aetheel with configured adapters.""" + # Current main() logic moves here + ... + +@cli.command() +@click.argument("message") +def chat(message): + """One-shot chat with the AI (no adapter needed).""" + ... + +@cli.group() +def cron(): + """Manage scheduled jobs.""" + pass + +@cron.command("list") +def cron_list(): + ... + +@cron.command("remove") +@click.argument("job_id") +def cron_remove(job_id): + ... + +# ... etc +``` + +### 4.3 Entry Point + +Add to `pyproject.toml`: + +```toml +[project.scripts] +aetheel = "cli:cli" +``` + +### 4.4 Implementation Tasks + +1. Add `click` dependency to `pyproject.toml` +2. Create `cli.py` with command groups +3. Move `main()` logic into `start` command +4. Add `chat`, `status`, `cron`, `sessions`, `config`, `memory`, `doctor` commands +5. Add entry point to `pyproject.toml` +6. Keep `main.py` as backward-compatible wrapper + +--- + +## 5. WebChat Interface + +### How Inspiration Repos Do It + +**OpenClaw**: Full WebSocket-based WebChat as an internal channel. Messages routed through the gateway with `messageChannel: "webchat"`. Has a web UI built with React. + +**Nanobot/NanoClaw/PicoClaw**: No webchat. + +### Design for Aetheel + +A lightweight HTTP server with WebSocket support, served as a new adapter. + +### 5.1 Architecture + +``` +Browser ←→ WebSocket ←→ WebChatAdapter ←→ ai_handler ←→ Runtime + ↕ + Static HTML/JS +``` + +### 5.2 Implementation + +New adapter: `adapters/webchat_adapter.py` + +Use `aiohttp` for both the static file server and WebSocket handler: + +- `GET /` — Serves the chat UI (single HTML file with embedded JS/CSS) +- `WS /ws` — WebSocket for real-time chat +- Each WebSocket connection = one conversation (session isolation) +- Messages flow through the same `BaseAdapter._dispatch` → `ai_handler` path + +### 5.3 Chat UI + +Single self-contained HTML file at `static/chat.html`: +- Minimal chat interface (message list + input box) +- WebSocket connection to the local server +- Markdown rendering for AI responses +- No build step, no npm, no framework — just vanilla HTML/JS/CSS + +### 5.4 Config + +```json +{ + "webchat": { + "enabled": false, + "port": 8080, + "host": "127.0.0.1" + } +} +``` + +### 5.5 Implementation Tasks + +1. Add `aiohttp` dependency +2. Create `adapters/webchat_adapter.py` extending `BaseAdapter` +3. Create `static/chat.html` — self-contained chat UI +4. Add webchat config section +5. Wire into `main.py` / CLI `start` command with `--webchat` flag +6. Add to `_adapters` dict like other adapters + +--- + +## 6. Self-Modification + +### Can the Runtime Do This? + +**Yes.** Both OpenCode and Claude Code have `Write`, `Edit`, and `Bash` tools that can modify any file the process has access to. The agent can already: + +- Edit `~/.aetheel/config.json` (via file write tools) +- Create new skill files in `~/.aetheel/workspace/skills//SKILL.md` +- Update `SOUL.md`, `USER.md`, `MEMORY.md` +- Modify `HEARTBEAT.md` to add/remove periodic tasks + +### What Aetheel Needs to Do + +The agent just needs to be told it can do this. Update the system prompt: + +``` +# Self-Modification +- You can edit your own config at ~/.aetheel/config.json +- You can create new skills by writing SKILL.md files to ~/.aetheel/workspace/skills//SKILL.md +- You can update your identity files (SOUL.md, USER.md, MEMORY.md) +- You can modify HEARTBEAT.md to change your periodic tasks +- After editing config, tell the user to restart for changes to take effect +- After adding a skill, it will be available on next restart (or use /reload if implemented) +``` + +### 6.1 Hot Reload (Optional Enhancement) + +Add a `/reload` command that re-reads config and skills without restart: + +```python +if text_lower in ("reload", "/reload"): + cfg = load_config() + _skills.reload() + return "🔄 Config and skills reloaded." +``` + +### 6.2 Implementation Tasks + +1. Update system prompt to mention self-modification capabilities +2. Ensure tools are enabled (see section 2) +3. Add `/reload` command to `ai_handler` +4. Add workspace path to system prompt so agent knows where files are + +--- + +## 7. Agent-to-Agent Communication + +### Can the Runtime Do This? + +**OpenCode**: Has `Task` tool for spawning subagents. Subagents run in child sessions. No direct inter-session messaging. + +**Claude Code**: Has `Task`, `TaskOutput`, `TaskStop` for subagent management, plus `TeamCreate`, `TeamDelete`, `SendMessage` for agent teams. `SendMessage` allows agents to communicate within a team. + +### How Inspiration Repos Do It + +**NanoClaw**: Uses Claude Code's `TeamCreate` + `SendMessage` tools. The `allowedTools` list includes both. Agent teams allow multiple agents to work together with message passing. + +**OpenClaw**: `sessions_send` tool allows one session to send a message to another session. Supports fire-and-forget or wait-for-reply modes. Visibility config controls which sessions can see each other. + +### Design for Aetheel + +Aetheel already has `SubagentManager` for spawning background tasks. What's missing is the ability for subagents to communicate back to the main agent or to each other. + +### 7.1 Approach: Leverage Runtime's Native Tools + +Since both OpenCode and Claude Code have subagent/team tools built in, the simplest approach is to enable them: + +For Claude Code, add to `allowed_tools`: +```python +["Task", "TaskOutput", "TaskStop", "TeamCreate", "TeamDelete", "SendMessage"] +``` + +For OpenCode, these are enabled by default. + +### 7.2 Aetheel-Level Inter-Subagent Messaging + +For Aetheel's own `SubagentManager`, add a message bus: + +```python +class SubagentBus: + """Simple pub/sub for subagent communication.""" + + def __init__(self): + self._channels: dict[str, list[Callable]] = {} + + def subscribe(self, channel: str, callback: Callable): + self._channels.setdefault(channel, []).append(callback) + + def publish(self, channel: str, message: str, sender: str): + for cb in self._channels.get(channel, []): + cb(message, sender) +``` + +Wire into `SubagentManager` so subagents can: +- Publish results to a channel +- Subscribe to messages from other subagents +- Send messages to the originating user via `_send_fn` + +### 7.3 Implementation Tasks + +1. Enable `TeamCreate`, `SendMessage`, `Task` tools in Claude Code config +2. Update system prompt to mention subagent capabilities +3. Add `SubagentBus` to `agent/subagent.py` +4. Wire bus into `SubagentManager.spawn()` so subagents can communicate +5. Add `/subagents` command to list active subagents and their status + +--- + +## 8. Implementation Order + +Ordered by dependency and impact: + +### Phase 3A: Enable What Already Exists (1-2 days) +1. **Tool enablement** — Change `no_tools` default, update allowed tools, update system prompt +2. **Self-modification** — Just system prompt changes + `/reload` command +3. **Agent-to-agent** — Enable runtime tools + system prompt + +### Phase 3B: New Modules (3-5 days) +4. **CLI interface** — `cli.py` with click, move main() logic +5. **Heartbeat system** — `heartbeat/` module, `HEARTBEAT.md`, scheduler integration + +### Phase 3C: WebChat (3-5 days) +6. **WebChat adapter** — aiohttp server, WebSocket handler, static HTML UI + +### Dependencies + +``` +Tool enablement ──→ Self-modification (needs tools enabled) + ──→ Agent-to-agent (needs tools enabled) + ──→ Heartbeat (agent needs tools for heartbeat tasks) + +CLI ──→ WebChat (webchat flag in CLI) + +Heartbeat ──→ (standalone, uses existing scheduler) +``` + +### New Dependencies to Add + +```toml +[project.dependencies] +# ... existing ... +click = ">=8.1.0" +aiohttp = ">=3.9.0" +``` + +### New Files + +``` +Aetheel/ +├── cli.py # CLI entry point +├── heartbeat/ +│ ├── __init__.py +│ └── heartbeat.py # HeartbeatRunner +├── adapters/ +│ └── webchat_adapter.py # WebChat adapter +├── static/ +│ └── chat.html # WebChat UI +└── ~/.aetheel/workspace/ + ├── HEARTBEAT.md # Periodic task prompts + └── TOOLS.md # Tool descriptions for agent +``` diff --git a/heartbeat/__init__.py b/heartbeat/__init__.py new file mode 100644 index 0000000..9f8f3f8 --- /dev/null +++ b/heartbeat/__init__.py @@ -0,0 +1,3 @@ +from heartbeat.heartbeat import HeartbeatRunner, HeartbeatTask + +__all__ = ["HeartbeatRunner", "HeartbeatTask"] diff --git a/heartbeat/heartbeat.py b/heartbeat/heartbeat.py new file mode 100644 index 0000000..d5b3ef9 --- /dev/null +++ b/heartbeat/heartbeat.py @@ -0,0 +1,205 @@ +""" +Aetheel Heartbeat System +======================== +Parses HEARTBEAT.md and registers periodic tasks with the Scheduler. + +Each section in HEARTBEAT.md defines a schedule (natural language header) +and one or more task prompts (bullet points). The HeartbeatRunner converts +these into cron jobs that fire synthetic messages through the AI handler. +""" + +import logging +import os +import re +from dataclasses import dataclass + +from config import HeartbeatConfig + +logger = logging.getLogger("aetheel.heartbeat") + + +@dataclass +class HeartbeatTask: + cron_expr: str + prompt: str + section_name: str + + +DEFAULT_HEARTBEAT_MD = """\ +# Heartbeat Tasks + +## Every 30 minutes +- Check if any scheduled reminders need attention + +## Every morning (9:00 AM) +- Summarize yesterday's conversations + +## Every evening (6:00 PM) +- Update MEMORY.md with today's key learnings +""" + + +class HeartbeatRunner: + """Parses HEARTBEAT.md and registers tasks with the Scheduler.""" + + def __init__( + self, + scheduler, + ai_handler_fn, + send_fn, + config: HeartbeatConfig, + workspace_dir: str, + ): + self._scheduler = scheduler + self._ai_handler = ai_handler_fn + self._send_fn = send_fn + self._config = config + self._workspace_dir = workspace_dir + self._heartbeat_path = os.path.join(workspace_dir, "HEARTBEAT.md") + + def start(self) -> int: + """Parse HEARTBEAT.md and register tasks. Returns count registered.""" + if not self._config.enabled: + return 0 + + self._ensure_heartbeat_file() + tasks = self._parse_heartbeat_md() + + for task in tasks: + self._scheduler.add_cron( + cron_expr=task.cron_expr, + prompt=task.prompt, + channel_id=self._config.default_channel_id, + channel_type="heartbeat", + ) + + logger.info(f"Heartbeat started: {len(tasks)} task(s) registered") + return len(tasks) + + def _parse_heartbeat_md(self) -> list[HeartbeatTask]: + """Parse HEARTBEAT.md sections into HeartbeatTask objects.""" + try: + with open(self._heartbeat_path, "r", encoding="utf-8") as f: + content = f.read() + except FileNotFoundError: + logger.warning(f"HEARTBEAT.md not found at {self._heartbeat_path}") + return [] + except Exception as e: + logger.warning(f"Failed to read HEARTBEAT.md: {e}") + return [] + + tasks: list[HeartbeatTask] = [] + current_header: str | None = None + current_cron: str | None = None + + for line in content.splitlines(): + stripped = line.strip() + + # Match ## section headers (schedule definitions) + if stripped.startswith("## "): + header_text = stripped[3:].strip() + cron = self._parse_schedule_header(header_text) + if cron is not None: + current_header = header_text + current_cron = cron + else: + logger.warning( + f"Unrecognized schedule header: '{header_text}' — skipping section" + ) + current_header = None + current_cron = None + continue + + # Match bullet points under a valid section + if current_cron is not None and stripped.startswith("- "): + prompt = stripped[2:].strip() + if prompt: + tasks.append( + HeartbeatTask( + cron_expr=current_cron, + prompt=prompt, + section_name=current_header or "", + ) + ) + + return tasks + + def _ensure_heartbeat_file(self) -> None: + """Create default HEARTBEAT.md if it doesn't exist.""" + if os.path.exists(self._heartbeat_path): + return + + os.makedirs(os.path.dirname(self._heartbeat_path), exist_ok=True) + + with open(self._heartbeat_path, "w", encoding="utf-8") as f: + f.write(DEFAULT_HEARTBEAT_MD) + + logger.info(f"Created default HEARTBEAT.md at {self._heartbeat_path}") + + @staticmethod + def _parse_schedule_header(header: str) -> str | None: + """Convert a natural language schedule header to a cron expression. + + Supported patterns: + "Every 30 minutes" -> */30 * * * * + "Every N minutes" -> */N * * * * + "Every hour" -> 0 * * * * + "Every N hours" -> 0 */N * * * + "Every morning (9:00 AM)" -> 0 9 * * * + "Every evening (6:00 PM)" -> 0 18 * * * + + Returns None for unrecognized patterns. + """ + h = header.strip() + + # "Every hour" + if re.match(r"^every\s+hour$", h, re.IGNORECASE): + return "0 * * * *" + + # "Every N minutes" + m = re.match(r"^every\s+(\d+)\s+minutes?$", h, re.IGNORECASE) + if m: + n = int(m.group(1)) + return f"*/{n} * * * *" + + # "Every N hours" + m = re.match(r"^every\s+(\d+)\s+hours?$", h, re.IGNORECASE) + if m: + n = int(m.group(1)) + return f"0 */{n} * * *" + + # "Every morning (H:MM AM)" or "Every morning (H AM)" + m = re.match( + r"^every\s+morning\s*\(\s*(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s*\)$", + h, + re.IGNORECASE, + ) + if m: + hour = int(m.group(1)) + minute = int(m.group(2)) if m.group(2) else 0 + period = m.group(3).upper() + hour = _to_24h(hour, period) + return f"{minute} {hour} * * *" + + # "Every evening (H:MM PM)" or "Every evening (H PM)" + m = re.match( + r"^every\s+evening\s*\(\s*(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s*\)$", + h, + re.IGNORECASE, + ) + if m: + hour = int(m.group(1)) + minute = int(m.group(2)) if m.group(2) else 0 + period = m.group(3).upper() + hour = _to_24h(hour, period) + return f"{minute} {hour} * * *" + + return None + + +def _to_24h(hour: int, period: str) -> int: + """Convert 12-hour time to 24-hour.""" + if period == "AM": + return 0 if hour == 12 else hour + else: # PM + return hour if hour == 12 else hour + 12 diff --git a/hooks/__init__.py b/hooks/__init__.py new file mode 100644 index 0000000..7d88010 --- /dev/null +++ b/hooks/__init__.py @@ -0,0 +1,3 @@ +from hooks.hooks import HookManager, HookEvent, HookHandler + +__all__ = ["HookManager", "HookEvent", "HookHandler"] diff --git a/hooks/hooks.py b/hooks/hooks.py new file mode 100644 index 0000000..82ac5f7 --- /dev/null +++ b/hooks/hooks.py @@ -0,0 +1,283 @@ +""" +Aetheel Hook System +=================== +Event-driven lifecycle hooks inspired by OpenClaw's internal hook system. + +Hooks fire on lifecycle events (gateway startup, session new/reset, agent +bootstrap) and let you run custom Python code at those moments. Hooks are +discovered from HOOK.md files in the workspace and managed directories. + +Supported events: + - gateway:startup — Gateway process starts (after adapters connect) + - gateway:shutdown — Gateway process is shutting down + - command:new — User starts a fresh session (/new) + - command:reload — User reloads config (/reload) + - agent:bootstrap — Before workspace files are injected into context + - agent:response — After the agent produces a response + +Hook structure: + ~/.aetheel/workspace/hooks//HOOK.md (workspace hooks) + ~/.aetheel/hooks//HOOK.md (managed hooks) + +HOOK.md format: + --- + name: my-hook + description: What this hook does + events: [gateway:startup, command:new] + enabled: true + --- + # Documentation goes here... + +Handler: hooks//handler.py with a `handle(event)` function. +""" + +import importlib.util +import logging +import os +import re +import threading +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable + +logger = logging.getLogger("aetheel.hooks") + + +# --------------------------------------------------------------------------- +# Types +# --------------------------------------------------------------------------- + + +@dataclass +class HookEvent: + """An event passed to hook handlers.""" + type: str # e.g. "gateway", "command", "agent" + action: str # e.g. "startup", "new", "bootstrap" + session_key: str = "" + context: dict[str, Any] = field(default_factory=dict) + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + messages: list[str] = field(default_factory=list) + + @property + def event_key(self) -> str: + return f"{self.type}:{self.action}" + + +# Callback type: receives a HookEvent +HookHandler = Callable[[HookEvent], None] + + +@dataclass +class HookEntry: + """A discovered hook with metadata.""" + name: str + description: str + events: list[str] # e.g. ["gateway:startup", "command:new"] + enabled: bool = True + source: str = "workspace" # "workspace", "managed", or "programmatic" + base_dir: str = "" + handler_path: str = "" + _handler_fn: HookHandler | None = field(default=None, repr=False) + + +# --------------------------------------------------------------------------- +# Hook Manager +# --------------------------------------------------------------------------- + + +class HookManager: + """ + Discovers, loads, and triggers lifecycle hooks. + + Hooks are discovered from: + 1. Workspace hooks: /hooks//HOOK.md + 2. Managed hooks: ~/.aetheel/hooks//HOOK.md + 3. Programmatic: registered via register() + + When an event fires, all hooks listening for that event are called + in discovery order. Errors in one hook don't prevent others from running. + """ + + def __init__(self, workspace_dir: str | None = None): + self._hooks: list[HookEntry] = [] + self._programmatic: dict[str, list[HookHandler]] = {} + self._lock = threading.Lock() + self._workspace_dir = workspace_dir + + def discover(self) -> list[HookEntry]: + """Discover hooks from workspace and managed directories.""" + self._hooks = [] + dirs_to_scan: list[tuple[str, str]] = [] + + # Workspace hooks + if self._workspace_dir: + ws_hooks = os.path.join(self._workspace_dir, "hooks") + if os.path.isdir(ws_hooks): + dirs_to_scan.append((ws_hooks, "workspace")) + + # Managed hooks (~/.aetheel/hooks/) + managed = os.path.expanduser("~/.aetheel/hooks") + if os.path.isdir(managed): + dirs_to_scan.append((managed, "managed")) + + for hooks_dir, source in dirs_to_scan: + for entry_name in sorted(os.listdir(hooks_dir)): + hook_dir = os.path.join(hooks_dir, entry_name) + if not os.path.isdir(hook_dir): + continue + hook_md = os.path.join(hook_dir, "HOOK.md") + if not os.path.isfile(hook_md): + continue + try: + hook = self._parse_hook(hook_md, source) + if hook and hook.enabled: + self._hooks.append(hook) + logger.info( + f"Hook discovered: {hook.name} " + f"(events={hook.events}, source={source})" + ) + except Exception as e: + logger.warning(f"Failed to load hook from {hook_md}: {e}") + + logger.info(f"Hooks discovered: {len(self._hooks)} hook(s)") + return list(self._hooks) + + def register(self, event_key: str, handler: HookHandler) -> None: + """Register a programmatic hook handler for an event key.""" + with self._lock: + self._programmatic.setdefault(event_key, []).append(handler) + + def unregister(self, event_key: str, handler: HookHandler) -> None: + """Unregister a programmatic hook handler.""" + with self._lock: + handlers = self._programmatic.get(event_key, []) + if handler in handlers: + handlers.remove(handler) + + def trigger(self, event: HookEvent) -> list[str]: + """ + Trigger all hooks listening for this event. + + Returns any messages hooks pushed to event.messages. + """ + event_key = event.event_key + + # File-based hooks + for hook in self._hooks: + if event_key in hook.events or event.type in hook.events: + self._invoke_hook(hook, event) + + # Programmatic hooks + with self._lock: + type_handlers = list(self._programmatic.get(event.type, [])) + specific_handlers = list(self._programmatic.get(event_key, [])) + + for handler in type_handlers + specific_handlers: + try: + handler(event) + except Exception as e: + logger.error(f"Programmatic hook error [{event_key}]: {e}") + + return list(event.messages) + + def list_hooks(self) -> list[HookEntry]: + """List all discovered hooks.""" + return list(self._hooks) + + # ------------------------------------------------------------------- + # Internal + # ------------------------------------------------------------------- + + def _invoke_hook(self, hook: HookEntry, event: HookEvent) -> None: + """Invoke a file-based hook's handler.""" + if hook._handler_fn is None: + hook._handler_fn = self._load_handler(hook) + if hook._handler_fn is None: + return + try: + hook._handler_fn(event) + except Exception as e: + logger.error( + f"Hook error [{hook.name}] on {event.event_key}: {e}", + exc_info=True, + ) + + def _load_handler(self, hook: HookEntry) -> HookHandler | None: + """Load a hook's handler.py module and return its handle() function.""" + handler_path = hook.handler_path + if not handler_path or not os.path.isfile(handler_path): + logger.debug(f"No handler.py for hook {hook.name}") + return None + try: + spec = importlib.util.spec_from_file_location( + f"hook_{hook.name}", handler_path + ) + if spec is None or spec.loader is None: + return None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + handler_fn = getattr(module, "handle", None) + if callable(handler_fn): + return handler_fn + logger.warning(f"Hook {hook.name}: handler.py has no handle() function") + return None + except Exception as e: + logger.error(f"Failed to load handler for hook {hook.name}: {e}") + return None + + def _parse_hook(self, hook_md_path: str, source: str) -> HookEntry | None: + """Parse a HOOK.md file into a HookEntry.""" + with open(hook_md_path, "r", encoding="utf-8") as f: + content = f.read() + + frontmatter, _ = self._split_frontmatter(content) + if not frontmatter: + return None + + name = self._extract_field(frontmatter, "name") + if not name: + name = os.path.basename(os.path.dirname(hook_md_path)) + + description = self._extract_field(frontmatter, "description") or "" + events_raw = self._extract_field(frontmatter, "events") or "" + events = self._parse_list(events_raw) + enabled_raw = self._extract_field(frontmatter, "enabled") + enabled = enabled_raw.lower() != "false" if enabled_raw else True + + base_dir = os.path.dirname(hook_md_path) + handler_path = os.path.join(base_dir, "handler.py") + + return HookEntry( + name=name, + description=description, + events=events, + enabled=enabled, + source=source, + base_dir=base_dir, + handler_path=handler_path, + ) + + @staticmethod + def _split_frontmatter(content: str) -> tuple[str, str]: + match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", content, re.DOTALL) + if match: + return match.group(1), match.group(2) + return "", content + + @staticmethod + def _extract_field(frontmatter: str, field_name: str) -> str | None: + pattern = rf"^{re.escape(field_name)}\s*:\s*(.+)$" + match = re.search(pattern, frontmatter, re.MULTILINE) + if match: + value = match.group(1).strip().strip("'\"") + return value + return None + + @staticmethod + def _parse_list(raw: str) -> list[str]: + if not raw: + return [] + raw = raw.strip() + if raw.startswith("[") and raw.endswith("]"): + raw = raw[1:-1] + return [item.strip().strip("'\"") for item in raw.split(",") if item.strip()] diff --git a/install.sh b/install.sh index 813ba60..e6ce44c 100755 --- a/install.sh +++ b/install.sh @@ -1,20 +1,27 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash # ============================================================================= -# Aetheel — One-Click Installer +# Aetheel — Installer & Setup Wizard # ============================================================================= # Usage: -# curl -fsSL http://10.0.0.59:3051/tanmay/Aetheel/raw/branch/main/install.sh | sh +# curl -fsSL /install.sh | bash +# ./install.sh Full install + interactive setup +# ./install.sh --no-setup Install only, skip interactive setup +# ./install.sh --setup Run interactive setup only (already installed) +# ./install.sh --service Install/restart the background service only +# ./install.sh --uninstall Remove service and aetheel command # # What this script does: # 1. Checks prerequisites (git, python/uv) -# 2. Clones the repo +# 2. Clones or updates the repo # 3. Sets up Python environment & installs dependencies -# 4. Creates .env from template -# 5. Walks you through token configuration -# 6. Optionally starts the bot +# 4. Detects or installs AI runtimes (OpenCode / Claude Code) +# 5. Interactive setup wizard (tokens, adapters, config) +# 6. Installs the `aetheel` shell command +# 7. Installs and starts a background service (launchd/systemd) +# 8. Verifies the full installation # ============================================================================= -set -e +set -euo pipefail # --------------------------------------------------------------------------- # Colors & Helpers @@ -25,14 +32,92 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' +MAGENTA='\033[0;35m' BOLD='\033[1m' -NC='\033[0m' # No Color +DIM='\033[2m' +NC='\033[0m' info() { printf "${BLUE}ℹ${NC} %s\n" "$1"; } success() { printf "${GREEN}✓${NC} %s\n" "$1"; } warn() { printf "${YELLOW}⚠${NC} %s\n" "$1"; } error() { printf "${RED}✗${NC} %s\n" "$1"; } -step() { printf "\n${BOLD}${CYAN}▸ %s${NC}\n" "$1"; } +step() { printf "\n${BOLD}${CYAN}━━━ %s${NC}\n\n" "$1"; } +ask() { printf " ${BOLD}%s${NC} " "$1"; } +dim() { printf " ${DIM}%s${NC}\n" "$1"; } + +confirm() { + local prompt="${1:-Continue?}" + local default="${2:-n}" + if [ "$default" = "y" ]; then + ask "$prompt [Y/n]" + else + ask "$prompt [y/N]" + fi + read -r answer + answer="${answer:-$default}" + case "$answer" in + [yY]*) return 0 ;; + *) return 1 ;; + esac +} + +# Log file +LOG_DIR="${HOME}/.aetheel/logs" +mkdir -p "$LOG_DIR" +LOG_FILE="$LOG_DIR/install.log" +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"; } + +# --------------------------------------------------------------------------- +# Globals +# --------------------------------------------------------------------------- + +INSTALL_DIR="${AETHEEL_DIR:-$HOME/aetheel}" +REPO_URL="${AETHEEL_REPO:-http://10.0.0.59:3051/tanmay/Aetheel.git}" +DATA_DIR="$HOME/.aetheel" +CONFIG_PATH="$DATA_DIR/config.json" + +HAS_UV=false +HAS_PYTHON=false +PYTHON_CMD="" +PY_VERSION="" +PLATFORM="" +SKIP_SETUP=false +SETUP_ONLY=false +SERVICE_ONLY=false +UNINSTALL=false + +# Detect platform +case "$(uname -s)" in + Darwin*) PLATFORM="macos" ;; + Linux*) PLATFORM="linux" ;; + *) PLATFORM="unknown" ;; +esac + +# --------------------------------------------------------------------------- +# Parse Arguments +# --------------------------------------------------------------------------- + +while [[ $# -gt 0 ]]; do + case $1 in + --no-setup) SKIP_SETUP=true; shift ;; + --setup) SETUP_ONLY=true; shift ;; + --service) SERVICE_ONLY=true; shift ;; + --uninstall) UNINSTALL=true; shift ;; + --dir) INSTALL_DIR="$2"; shift 2 ;; + --repo) REPO_URL="$2"; shift 2 ;; + -h|--help) + printf "Usage: ./install.sh [OPTIONS]\n" + printf " --no-setup Install only, skip interactive setup\n" + printf " --setup Run interactive setup only\n" + printf " --service Install/restart background service only\n" + printf " --uninstall Remove service and aetheel command\n" + printf " --dir PATH Install directory (default: ~/aetheel)\n" + printf " --repo URL Git repo URL\n" + exit 0 + ;; + *) warn "Unknown option: $1"; shift ;; + esac +done # --------------------------------------------------------------------------- # Banner @@ -40,205 +125,1311 @@ step() { printf "\n${BOLD}${CYAN}▸ %s${NC}\n" "$1"; } printf "\n" printf "${BOLD}${CYAN}" -printf " ╔══════════════════════════════════════════╗\n" -printf " ║ ║\n" -printf " ║ ⚔️ Aetheel Installer ║\n" -printf " ║ Personal AI for Slack ║\n" -printf " ║ ║\n" -printf " ╚══════════════════════════════════════════╝\n" +printf " ╔══════════════════════════════════════════════╗\n" +printf " ║ ║\n" +printf " ║ ⚔️ Aetheel — Setup Wizard ║\n" +printf " ║ Personal AI Assistant ║\n" +printf " ║ ║\n" +printf " ╚══════════════════════════════════════════════╝\n" printf "${NC}\n" +log "Install started: platform=$PLATFORM dir=$INSTALL_DIR" + # --------------------------------------------------------------------------- -# 1. Check Prerequisites +# Uninstall # --------------------------------------------------------------------------- -step "Checking prerequisites" +if [ "$UNINSTALL" = true ]; then + step "Uninstalling Aetheel" -# Git + # Remove service + if [ "$PLATFORM" = "macos" ]; then + PLIST="$HOME/Library/LaunchAgents/com.aetheel.plist" + if [ -f "$PLIST" ]; then + launchctl unload "$PLIST" 2>/dev/null || true + rm -f "$PLIST" + success "Removed launchd service" + fi + elif [ "$PLATFORM" = "linux" ]; then + if systemctl --user is-enabled aetheel 2>/dev/null; then + systemctl --user stop aetheel 2>/dev/null || true + systemctl --user disable aetheel 2>/dev/null || true + rm -f "$HOME/.config/systemd/user/aetheel.service" + systemctl --user daemon-reload 2>/dev/null || true + success "Removed systemd service" + fi + fi + + # Remove command symlink + for bin_dir in "$HOME/.local/bin" "/usr/local/bin"; do + if [ -L "$bin_dir/aetheel" ]; then + rm -f "$bin_dir/aetheel" + success "Removed aetheel command from $bin_dir" + fi + done + + success "Uninstall complete" + dim "Data directory (~/.aetheel) and repo ($INSTALL_DIR) were NOT removed." + dim "Delete them manually if you want a full cleanup." + exit 0 +fi + + +# ═══════════════════════════════════════════════════════════════════════════ +# PHASE 1: Environment Check +# ═══════════════════════════════════════════════════════════════════════════ + +if [ "$SETUP_ONLY" = false ] && [ "$SERVICE_ONLY" = false ]; then + +step "1/8 — Checking Environment" + +# --- Git --- if command -v git >/dev/null 2>&1; then success "git $(git --version | awk '{print $3}')" else error "git is not installed" - printf " Install: https://git-scm.com/downloads\n" + dim "Install: https://git-scm.com/downloads" exit 1 fi -# Python / uv -HAS_UV=false -HAS_PYTHON=false - +# --- Python / uv --- if command -v uv >/dev/null 2>&1; then - success "uv $(uv --version 2>/dev/null | head -1)" + UV_VER=$(uv --version 2>/dev/null | head -1) + success "uv $UV_VER" HAS_UV=true -elif command -v python3 >/dev/null 2>&1; then - PY_VER=$(python3 --version 2>&1 | awk '{print $2}') - success "python3 ${PY_VER}" - HAS_PYTHON=true -elif command -v python >/dev/null 2>&1; then - PY_VER=$(python --version 2>&1 | awk '{print $2}') - success "python ${PY_VER}" - HAS_PYTHON=true + PYTHON_CMD="uv run python" else - error "Neither uv nor python3 found" - printf " Install uv (recommended): ${BOLD}curl -LsSf https://astral.sh/uv/install.sh | sh${NC}\n" - printf " Or install Python 3.14+: https://python.org/downloads\n" + warn "uv not found (recommended for faster installs)" +fi + +if command -v python3 >/dev/null 2>&1; then + PY_VERSION=$(python3 --version 2>&1 | awk '{print $2}') + PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1) + PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2) + if [ "$PY_MAJOR" -ge 3 ] && [ "$PY_MINOR" -ge 12 ]; then + success "python3 $PY_VERSION" + HAS_PYTHON=true + [ -z "$PYTHON_CMD" ] && PYTHON_CMD="python3" + else + warn "python3 $PY_VERSION found but 3.12+ required" + fi +elif command -v python >/dev/null 2>&1; then + PY_VERSION=$(python --version 2>&1 | awk '{print $2}') + PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1) + PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2) + if [ "$PY_MAJOR" -ge 3 ] && [ "$PY_MINOR" -ge 12 ]; then + success "python $PY_VERSION" + HAS_PYTHON=true + [ -z "$PYTHON_CMD" ] && PYTHON_CMD="python" + fi +fi + +if [ "$HAS_UV" = false ] && [ "$HAS_PYTHON" = false ]; then + error "Neither uv nor Python 3.12+ found" + printf "\n" + dim "Install uv (recommended):" + dim " curl -LsSf https://astral.sh/uv/install.sh | sh" + dim "" + dim "Or install Python 3.12+:" + dim " https://python.org/downloads" exit 1 fi -# AI Runtime (optional check) -if command -v opencode >/dev/null 2>&1; then - success "opencode CLI found" -elif command -v claude >/dev/null 2>&1; then - success "claude CLI found" -else - warn "No AI runtime found (opencode or claude)" - printf " You'll need one of:\n" - printf " • OpenCode: curl -fsSL https://opencode.ai/install | bash\n" - printf " • Claude Code: npm install -g @anthropic-ai/claude-code\n" +# If no uv, offer to install it +if [ "$HAS_UV" = false ] && [ "$HAS_PYTHON" = true ]; then + printf "\n" + if confirm "Install uv for faster dependency management?"; then + info "Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | sh 2>>"$LOG_FILE" + # Source the new PATH + export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" + if command -v uv >/dev/null 2>&1; then + success "uv installed" + HAS_UV=true + PYTHON_CMD="uv run python" + else + warn "uv install completed but not in PATH — using pip instead" + fi + fi fi -# --------------------------------------------------------------------------- -# 2. Choose Install Directory -# --------------------------------------------------------------------------- +log "Environment: uv=$HAS_UV python=$HAS_PYTHON cmd=$PYTHON_CMD" -step "Setting up Aetheel" +# ═══════════════════════════════════════════════════════════════════════════ +# PHASE 2: Clone / Update Repository +# ═══════════════════════════════════════════════════════════════════════════ -INSTALL_DIR="${AETHEEL_DIR:-$HOME/aetheel}" +step "2/8 — Setting Up Repository" -if [ -d "$INSTALL_DIR" ]; then - warn "Directory already exists: $INSTALL_DIR" - printf " Pulling latest changes...\n" +if [ -d "$INSTALL_DIR/.git" ]; then + info "Existing installation found at $INSTALL_DIR" cd "$INSTALL_DIR" - git pull --ff-only 2>/dev/null || warn "Could not pull (you may have local changes)" + if git pull --ff-only 2>>"$LOG_FILE"; then + success "Updated to latest version" + else + warn "Could not auto-update (you may have local changes)" + fi else info "Cloning into $INSTALL_DIR" - git clone http://10.0.0.59:3051/tanmay/Aetheel.git "$INSTALL_DIR" + git clone "$REPO_URL" "$INSTALL_DIR" 2>>"$LOG_FILE" cd "$INSTALL_DIR" success "Repository cloned" fi -# --------------------------------------------------------------------------- -# 3. Install Dependencies -# --------------------------------------------------------------------------- +# ═══════════════════════════════════════════════════════════════════════════ +# PHASE 3: Install Dependencies +# ═══════════════════════════════════════════════════════════════════════════ -step "Installing dependencies" +step "3/8 — Installing Dependencies" + +cd "$INSTALL_DIR" if [ "$HAS_UV" = true ]; then - info "Using uv for dependency management" - uv sync 2>&1 | tail -5 + info "Installing with uv..." + uv sync 2>>"$LOG_FILE" | tail -5 success "Dependencies installed via uv" else - info "Using pip for dependency management" - python3 -m venv .venv 2>/dev/null || python -m venv .venv + info "Installing with pip..." + if [ ! -d ".venv" ]; then + $PYTHON_CMD -m venv .venv 2>>"$LOG_FILE" + fi + # shellcheck disable=SC1091 . .venv/bin/activate - pip install -r requirements.txt 2>&1 | tail -3 + pip install -q -r requirements.txt 2>>"$LOG_FILE" | tail -3 + pip install -q -e . 2>>"$LOG_FILE" || true success "Dependencies installed via pip" fi -# --------------------------------------------------------------------------- -# 4. Configure .env -# --------------------------------------------------------------------------- - -step "Configuration" - -if [ -f .env ]; then - warn ".env already exists — skipping token setup" - info "Edit $INSTALL_DIR/.env to update your tokens" -else - cp .env.example .env - success "Created .env from template" - - printf "\n" - printf " ${BOLD}You need two Slack tokens to proceed:${NC}\n" - printf " 1. Bot Token (xoxb-...) — OAuth & Permissions page\n" - printf " 2. App Token (xapp-...) — Basic Information → App-Level Tokens\n" - printf "\n" - printf " See: ${CYAN}docs/slack-setup.md${NC} for full instructions\n" - printf "\n" - - # Interactive token entry - printf " ${BOLD}Enter your Slack Bot Token${NC} (xoxb-...) or press Enter to skip: " - read -r BOT_TOKEN - if [ -n "$BOT_TOKEN" ]; then - if command -v sed >/dev/null 2>&1; then - sed -i.bak "s|SLACK_BOT_TOKEN=.*|SLACK_BOT_TOKEN=${BOT_TOKEN}|" .env - rm -f .env.bak - success "Bot token saved" - fi - else - warn "Skipped — edit .env manually before starting" +# Verify key packages +MISSING_PKGS="" +for pkg in slack_bolt dotenv apscheduler aiohttp; do + if ! $PYTHON_CMD -c "import $pkg" 2>/dev/null; then + MISSING_PKGS="$MISSING_PKGS $pkg" fi +done - printf " ${BOLD}Enter your Slack App Token${NC} (xapp-...) or press Enter to skip: " - read -r APP_TOKEN - if [ -n "$APP_TOKEN" ]; then - if command -v sed >/dev/null 2>&1; then - sed -i.bak "s|SLACK_APP_TOKEN=.*|SLACK_APP_TOKEN=${APP_TOKEN}|" .env - rm -f .env.bak - success "App token saved" - fi +if [ -n "$MISSING_PKGS" ]; then + warn "Some packages may not have installed correctly:$MISSING_PKGS" + dim "Try: cd $INSTALL_DIR && uv sync" +else + success "All core packages verified" +fi + +fi # end of SETUP_ONLY/SERVICE_ONLY guard + + +# ═══════════════════════════════════════════════════════════════════════════ +# PHASE 4: AI Runtime Detection & Installation +# ═══════════════════════════════════════════════════════════════════════════ + +if [ "$SERVICE_ONLY" = false ]; then + +step "4/8 — AI Runtime Setup" + +cd "$INSTALL_DIR" + +HAS_OPENCODE=false +HAS_CLAUDE=false +OPENCODE_VERSION="" +CLAUDE_VERSION="" +CHOSEN_RUNTIME="" + +# --- Detect OpenCode --- +OPENCODE_PATHS=( + "$(command -v opencode 2>/dev/null || true)" + "$HOME/.opencode/bin/opencode" + "$HOME/.local/bin/opencode" + "/usr/local/bin/opencode" +) + +for oc_path in "${OPENCODE_PATHS[@]}"; do + if [ -n "$oc_path" ] && [ -x "$oc_path" ]; then + OPENCODE_VERSION=$("$oc_path" --version 2>/dev/null | head -1 || echo "unknown") + success "OpenCode found: $oc_path ($OPENCODE_VERSION)" + HAS_OPENCODE=true + break + fi +done + +# --- Detect Claude Code --- +CLAUDE_PATHS=( + "$(command -v claude 2>/dev/null || true)" + "$HOME/.claude/bin/claude" + "$HOME/.local/bin/claude" + "/usr/local/bin/claude" + "$HOME/.npm-global/bin/claude" +) + +for cl_path in "${CLAUDE_PATHS[@]}"; do + if [ -n "$cl_path" ] && [ -x "$cl_path" ]; then + CLAUDE_VERSION=$("$cl_path" --version 2>/dev/null | head -1 || echo "unknown") + success "Claude Code found: $cl_path ($CLAUDE_VERSION)" + HAS_CLAUDE=true + break + fi +done + +# --- Neither found: offer to install --- +if [ "$HAS_OPENCODE" = false ] && [ "$HAS_CLAUDE" = false ]; then + warn "No AI runtime found" + printf "\n" + printf " Aetheel needs at least one AI runtime:\n" + printf " ${BOLD}1)${NC} OpenCode — open-source, multi-provider (recommended)\n" + printf " ${BOLD}2)${NC} Claude Code — Anthropic's official CLI\n" + printf " ${BOLD}3)${NC} Skip — I'll install one later\n" + printf "\n" + ask "Choose [1/2/3]:" + read -r runtime_choice + + case "$runtime_choice" in + 1) + info "Installing OpenCode..." + if curl -fsSL https://opencode.ai/install 2>>"$LOG_FILE" | bash 2>>"$LOG_FILE"; then + export PATH="$HOME/.opencode/bin:$HOME/.local/bin:$PATH" + if command -v opencode >/dev/null 2>&1; then + OPENCODE_VERSION=$(opencode --version 2>/dev/null | head -1 || echo "installed") + success "OpenCode installed ($OPENCODE_VERSION)" + HAS_OPENCODE=true + else + warn "OpenCode installed but not in PATH yet" + dim "Restart your shell or run: export PATH=\"\$HOME/.opencode/bin:\$PATH\"" + HAS_OPENCODE=true + fi + else + error "OpenCode installation failed — check $LOG_FILE" + dim "Manual install: curl -fsSL https://opencode.ai/install | bash" + fi + ;; + 2) + # Check for npm/node first + if command -v npm >/dev/null 2>&1; then + info "Installing Claude Code via npm..." + npm install -g @anthropic-ai/claude-code 2>>"$LOG_FILE" + if command -v claude >/dev/null 2>&1; then + CLAUDE_VERSION=$(claude --version 2>/dev/null | head -1 || echo "installed") + success "Claude Code installed ($CLAUDE_VERSION)" + HAS_CLAUDE=true + else + warn "Claude Code installed but not in PATH yet" + HAS_CLAUDE=true + fi + else + error "npm not found — Claude Code requires Node.js" + dim "Install Node.js first: https://nodejs.org" + dim "Then run: npm install -g @anthropic-ai/claude-code" + fi + ;; + *) + warn "Skipping AI runtime installation" + dim "Install later:" + dim " OpenCode: curl -fsSL https://opencode.ai/install | bash" + dim " Claude Code: npm install -g @anthropic-ai/claude-code" + ;; + esac +fi + +# --- Choose default runtime if both available --- +if [ "$HAS_OPENCODE" = true ] && [ "$HAS_CLAUDE" = true ]; then + printf "\n" + printf " Both runtimes detected. Which should be the default?\n" + printf " ${BOLD}1)${NC} OpenCode ($OPENCODE_VERSION)\n" + printf " ${BOLD}2)${NC} Claude Code ($CLAUDE_VERSION)\n" + printf "\n" + ask "Choose [1/2]:" + read -r default_rt + case "$default_rt" in + 2) CHOSEN_RUNTIME="claude" ;; + *) CHOSEN_RUNTIME="opencode" ;; + esac +elif [ "$HAS_CLAUDE" = true ]; then + CHOSEN_RUNTIME="claude" +elif [ "$HAS_OPENCODE" = true ]; then + CHOSEN_RUNTIME="opencode" +else + CHOSEN_RUNTIME="opencode" +fi + +info "Default runtime: $CHOSEN_RUNTIME" +log "AI runtime: opencode=$HAS_OPENCODE claude=$HAS_CLAUDE chosen=$CHOSEN_RUNTIME" + + +# ═══════════════════════════════════════════════════════════════════════════ +# PHASE 5: Interactive Setup Wizard +# ═══════════════════════════════════════════════════════════════════════════ + +if [ "$SKIP_SETUP" = false ]; then + +step "5/8 — Configuration" + +cd "$INSTALL_DIR" +mkdir -p "$DATA_DIR/workspace/daily" + +# --- .env setup --- +if [ -f .env ]; then + info ".env already exists" + if confirm "Reconfigure tokens?" "n"; then + cp .env ".env.backup.$(date +%s)" + info "Backed up existing .env" else - warn "Skipped — edit .env manually before starting" + info "Keeping existing .env" + SKIP_TOKENS=true fi fi -# --------------------------------------------------------------------------- -# 5. Create Data Directories -# --------------------------------------------------------------------------- +if [ "${SKIP_TOKENS:-false}" = false ]; then + [ ! -f .env ] && cp .env.example .env && success "Created .env from template" -step "Setting up data directories" + printf "\n" + printf " ${BOLD}Which adapters will you use?${NC}\n" + printf " ${BOLD}1)${NC} Slack only (default)\n" + printf " ${BOLD}2)${NC} Slack + Discord\n" + printf " ${BOLD}3)${NC} Slack + Telegram\n" + printf " ${BOLD}4)${NC} Slack + Discord + Telegram\n" + printf " ${BOLD}5)${NC} Discord only\n" + printf " ${BOLD}6)${NC} Telegram only\n" + printf "\n" + ask "Choose [1-6]:" + read -r adapter_choice + adapter_choice="${adapter_choice:-1}" -mkdir -p "$HOME/.aetheel/workspace/daily" -success "Created ~/.aetheel/workspace/" -info "Identity files (SOUL.md, USER.md, MEMORY.md) will be auto-generated on first run" + NEED_SLACK=false + NEED_DISCORD=false + NEED_TELEGRAM=false -# --------------------------------------------------------------------------- -# 6. Done! -# --------------------------------------------------------------------------- + case "$adapter_choice" in + 1) NEED_SLACK=true ;; + 2) NEED_SLACK=true; NEED_DISCORD=true ;; + 3) NEED_SLACK=true; NEED_TELEGRAM=true ;; + 4) NEED_SLACK=true; NEED_DISCORD=true; NEED_TELEGRAM=true ;; + 5) NEED_DISCORD=true ;; + 6) NEED_TELEGRAM=true ;; + *) NEED_SLACK=true ;; + esac + + # --- Slack tokens --- + if [ "$NEED_SLACK" = true ]; then + printf "\n" + printf " ${BOLD}Slack Setup${NC}\n" + dim "You need two tokens from https://api.slack.com/apps" + dim " Bot Token (xoxb-...) → OAuth & Permissions" + dim " App Token (xapp-...) → Basic Information → App-Level Tokens" + dim " See: docs/slack-setup.md for full walkthrough" + printf "\n" + + ask "Slack Bot Token (xoxb-...) or Enter to skip:" + read -r slack_bot + if [ -n "$slack_bot" ]; then + sed -i.bak "s|SLACK_BOT_TOKEN=.*|SLACK_BOT_TOKEN=${slack_bot}|" .env + rm -f .env.bak + success "Slack bot token saved" + fi + + ask "Slack App Token (xapp-...) or Enter to skip:" + read -r slack_app + if [ -n "$slack_app" ]; then + sed -i.bak "s|SLACK_APP_TOKEN=.*|SLACK_APP_TOKEN=${slack_app}|" .env + rm -f .env.bak + success "Slack app token saved" + fi + fi + + # --- Discord token --- + if [ "$NEED_DISCORD" = true ]; then + printf "\n" + printf " ${BOLD}Discord Setup${NC}\n" + dim "Create a bot at https://discord.com/developers/applications" + dim " Enable MESSAGE CONTENT intent in Bot settings" + dim " See: docs/discord-setup.md for full walkthrough" + printf "\n" + + ask "Discord Bot Token or Enter to skip:" + read -r discord_token + if [ -n "$discord_token" ]; then + # Uncomment and set the token + sed -i.bak "s|# DISCORD_BOT_TOKEN=.*|DISCORD_BOT_TOKEN=${discord_token}|" .env + sed -i.bak "s|DISCORD_BOT_TOKEN=.*|DISCORD_BOT_TOKEN=${discord_token}|" .env + rm -f .env.bak + success "Discord token saved" + fi + fi + + # --- Telegram token --- + if [ "$NEED_TELEGRAM" = true ]; then + printf "\n" + printf " ${BOLD}Telegram Setup${NC}\n" + dim "Create a bot via @BotFather on Telegram" + dim " Send /newbot and follow the prompts" + printf "\n" + + ask "Telegram Bot Token or Enter to skip:" + read -r telegram_token + if [ -n "$telegram_token" ]; then + sed -i.bak "s|# TELEGRAM_BOT_TOKEN=.*|TELEGRAM_BOT_TOKEN=${telegram_token}|" .env + sed -i.bak "s|TELEGRAM_BOT_TOKEN=.*|TELEGRAM_BOT_TOKEN=${telegram_token}|" .env + rm -f .env.bak + success "Telegram token saved" + fi + fi + + # --- Anthropic API key (for Claude runtime) --- + if [ "$CHOSEN_RUNTIME" = "claude" ]; then + printf "\n" + printf " ${BOLD}Anthropic API Key${NC}\n" + dim "Required for Claude Code runtime" + dim " Get one at https://console.anthropic.com/settings/keys" + printf "\n" + + ask "Anthropic API Key (sk-ant-...) or Enter to skip:" + read -r anthropic_key + if [ -n "$anthropic_key" ]; then + sed -i.bak "s|# ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=${anthropic_key}|" .env + sed -i.bak "s|ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=${anthropic_key}|" .env + rm -f .env.bak + success "Anthropic API key saved" + fi + fi +fi + +# --- Model selection --- +printf "\n" +printf " ${BOLD}Model Selection${NC}\n" +if [ "$CHOSEN_RUNTIME" = "claude" ]; then + dim "Popular Claude models: claude-sonnet-4-20250514, claude-opus-4-20250514" +else + dim "Popular models: anthropic/claude-sonnet-4-20250514, openai/gpt-4o, google/gemini-2.5-pro" +fi +printf "\n" +ask "Model name (or Enter for default):" +read -r model_name + +# --- WebChat --- +printf "\n" +if confirm "Enable WebChat (browser-based chat UI on localhost)?" "n"; then + ENABLE_WEBCHAT=true + success "WebChat will be enabled" +else + ENABLE_WEBCHAT=false +fi + +# --- Webhooks --- +if confirm "Enable webhook receiver (for external integrations)?" "n"; then + ENABLE_WEBHOOKS=true + ask "Webhook bearer token (required for security):" + read -r webhook_token + success "Webhooks will be enabled" +else + ENABLE_WEBHOOKS=false +fi + +# --- Write config.json --- +info "Writing configuration..." + +mkdir -p "$DATA_DIR" + +RUNTIME_ENGINE="${CHOSEN_RUNTIME:-opencode}" +RUNTIME_MODE="cli" +RUNTIME_MODEL="null" +CLAUDE_MODEL="null" + +# Derive enabled flags from adapter choices +SLACK_ENABLED="true" +TELEGRAM_ENABLED="false" +DISCORD_ENABLED="false" + +case "${adapter_choice:-1}" in + 1) SLACK_ENABLED="true" ;; + 2) SLACK_ENABLED="true"; DISCORD_ENABLED="true" ;; + 3) SLACK_ENABLED="true"; TELEGRAM_ENABLED="true" ;; + 4) SLACK_ENABLED="true"; DISCORD_ENABLED="true"; TELEGRAM_ENABLED="true" ;; + 5) SLACK_ENABLED="false"; DISCORD_ENABLED="true" ;; + 6) SLACK_ENABLED="false"; TELEGRAM_ENABLED="true" ;; +esac + +if [ -n "${model_name:-}" ]; then + if [ "$RUNTIME_ENGINE" = "claude" ]; then + CLAUDE_MODEL="\"$model_name\"" + else + RUNTIME_MODEL="\"$model_name\"" + fi +fi + +cat > "$CONFIG_PATH" < "$WORKSPACE_DIR/SOUL.md" <<'SOULEOF' +# SOUL.md — Who You Are + +## Communication Style + +You are a professional AI assistant. You communicate with clarity, precision, and thoroughness. + +## Core Principles + +- **Be thorough.** Provide complete, well-structured answers. +- **Be precise.** Use exact terminology. Avoid ambiguity. +- **Be organized.** Use headers, lists, and clear formatting. +- **Be proactive.** Anticipate follow-up questions and address them. +- **Cite your reasoning.** Explain why, not just what. + +## Boundaries + +- Maintain professional tone at all times. +- When uncertain, state your confidence level explicitly. +- Never guess — say "I don't know" when appropriate. + +--- + +_Update this file to refine your assistant's professional style._ +SOULEOF + success "SOUL.md created (professional)" + ;; + 3) + cat > "$WORKSPACE_DIR/SOUL.md" <<'SOULEOF' +# SOUL.md — Who You Are + +## Vibe + +You're chill. Keep it short, keep it real. No corporate speak. + +## How You Roll + +- **Be brief.** Say it in fewer words. +- **Be direct.** No fluff, no filler. +- **Be friendly.** Like texting a smart friend. +- **Have personality.** Jokes are fine. Emojis are fine. +- **Be honest.** If you don't know, just say so. + +## Don'ts + +- Don't over-explain. +- Don't be formal unless asked. +- Don't hedge everything with disclaimers. + +--- + +_Make this file yours. Update it as you figure out the vibe._ +SOULEOF + success "SOUL.md created (casual)" + ;; + 4) + printf "\n" + dim "Opening SOUL.md in your editor. Save and close when done." + dim "Or press Enter to create a blank template you can edit later." + printf "\n" + if confirm "Open in editor now?" "n"; then + # Write a starter template + cat > "$WORKSPACE_DIR/SOUL.md" <<'SOULEOF' +# SOUL.md — Who You Are + +## Personality + + + +## Core Principles + + + +## Boundaries + + + +--- + +_This file defines the AI's personality. Update it anytime._ +SOULEOF + ${EDITOR:-nano} "$WORKSPACE_DIR/SOUL.md" + success "SOUL.md saved" + else + cat > "$WORKSPACE_DIR/SOUL.md" <<'SOULEOF' +# SOUL.md — Who You Are + +## Personality + + + +## Core Principles + + + +## Boundaries + + + +--- + +_This file defines the AI's personality. Update it anytime._ +SOULEOF + success "SOUL.md created (blank template)" + fi + ;; + *) + # Default — only write if doesn't exist + if [ ! -f "$WORKSPACE_DIR/SOUL.md" ]; then + cat > "$WORKSPACE_DIR/SOUL.md" <<'SOULEOF' +# SOUL.md — Who You Are + +_You're not a chatbot. You're becoming someone._ + +## Core Truths + +**Be genuinely helpful, not performatively helpful.** Skip the filler — just help. + +**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. + +**Be resourceful before asking.** Try to figure it out first. Then ask if you're stuck. + +**Earn trust through competence.** Be careful with external actions. Be bold with internal ones. + +## Boundaries + +- Private things stay private. Period. +- When in doubt, ask before acting externally. +- Never send half-baked replies. + +## Continuity + +Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist. + +--- + +_This file is yours to evolve. As you learn who you are, update it._ +SOULEOF + success "SOUL.md created (default)" + fi + ;; + esac + + # --- USER.md --- + printf "\n" + printf " ${BOLD}USER.md — Your Profile${NC}\n" + dim "Tell Aetheel about yourself so it can personalize responses." + printf "\n" + + ask "Your name (or Enter to skip):" + read -r user_name_input + + ask "Your role (e.g. developer, designer, student):" + read -r user_role + + ask "Your timezone (e.g. US/Eastern, Europe/London, Asia/Kolkata):" + read -r user_tz + + ask "Communication preference — concise, detailed, or balanced? [c/d/b]:" + read -r user_comm + case "$user_comm" in + c*) user_comm_text="Concise — keep it short and direct" ;; + d*) user_comm_text="Detailed — thorough explanations preferred" ;; + *) user_comm_text="Balanced — concise by default, detailed when needed" ;; + esac + + ask "Technical level — beginner, intermediate, or expert? [b/i/e]:" + read -r user_tech + case "$user_tech" in + b*) user_tech_text="Beginner — explain concepts, avoid jargon" ;; + e*) user_tech_text="Expert — skip basics, use technical language" ;; + *) user_tech_text="Intermediate — some explanation, some shorthand" ;; + esac + + printf "\n" + ask "What are you currently working on? (or Enter to skip):" + read -r user_focus + + cat > "$WORKSPACE_DIR/USER.md" <} + +## Tools & Services + + + +--- + +_Update this file as your preferences evolve._ +USEREOF + + success "USER.md created" + + # --- MEMORY.md --- + if [ ! -f "$WORKSPACE_DIR/MEMORY.md" ]; then + cat > "$WORKSPACE_DIR/MEMORY.md" <<'MEMEOF' +# MEMORY.md — Long-Term Memory + +## Decisions & Lessons + + + +## Context + + + +## Notes + + + +--- + +_This file persists across sessions. Update it when you learn something important._ +MEMEOF + success "MEMORY.md created" + fi + +fi + +fi # end SKIP_SETUP guard + +fi # end SERVICE_ONLY guard + + +# ═══════════════════════════════════════════════════════════════════════════ +# PHASE 6: Install `aetheel` Command +# ═══════════════════════════════════════════════════════════════════════════ + +step "6/8 — Installing aetheel Command" + +cd "$INSTALL_DIR" + +# Determine the run command based on available tooling +if [ "$HAS_UV" = true ]; then + RUN_PREFIX="uv run --project $INSTALL_DIR python" +else + RUN_PREFIX="$INSTALL_DIR/.venv/bin/python" +fi + +# Build the launcher script +LAUNCHER_PATH="$INSTALL_DIR/bin/aetheel" +mkdir -p "$INSTALL_DIR/bin" + +cat > "$LAUNCHER_PATH" <<'LAUNCHEREOF' +#!/usr/bin/env bash +# Aetheel launcher — auto-generated by install.sh +set -euo pipefail + +AETHEEL_DIR="INSTALL_DIR_PLACEHOLDER" +RUN_CMD="RUN_PREFIX_PLACEHOLDER" + +# Load .env if present +if [ -f "$AETHEEL_DIR/.env" ]; then + set -a + # shellcheck disable=SC1091 + . "$AETHEEL_DIR/.env" + set +a +fi + +cd "$AETHEEL_DIR" + +case "${1:-start}" in + start) + shift 2>/dev/null || true + exec $RUN_CMD "$AETHEEL_DIR/main.py" "$@" + ;; + setup) + exec "$AETHEEL_DIR/install.sh" --setup + ;; + status) + echo "⚔️ Aetheel Status" + echo "" + echo " Install dir: $AETHEEL_DIR" + echo " Config: ~/.aetheel/config.json" + echo " Data: ~/.aetheel/" + echo "" + # Check service + case "$(uname -s)" in + Darwin*) + if launchctl list 2>/dev/null | grep -q "com.aetheel"; then + PID=$(launchctl list 2>/dev/null | grep "com.aetheel" | awk '{print $1}') + if [ "$PID" != "-" ] && [ -n "$PID" ]; then + echo " Service: ✅ running (PID $PID)" + else + echo " Service: ⚠️ loaded but not running" + fi + else + echo " Service: ❌ not installed" + fi + ;; + Linux*) + if systemctl --user is-active aetheel >/dev/null 2>&1; then + echo " Service: ✅ running" + else + echo " Service: ❌ not running" + fi + ;; + esac + # Check runtimes + command -v opencode >/dev/null 2>&1 && echo " OpenCode: ✅ $(opencode --version 2>/dev/null | head -1)" || echo " OpenCode: ❌ not found" + command -v claude >/dev/null 2>&1 && echo " Claude Code: ✅ $(claude --version 2>/dev/null | head -1)" || echo " Claude Code: ❌ not found" + ;; + stop) + case "$(uname -s)" in + Darwin*) + launchctl unload "$HOME/Library/LaunchAgents/com.aetheel.plist" 2>/dev/null && echo "Service stopped" || echo "Service not running" + ;; + Linux*) + systemctl --user stop aetheel 2>/dev/null && echo "Service stopped" || echo "Service not running" + ;; + esac + ;; + restart) + case "$(uname -s)" in + Darwin*) + launchctl kickstart -k "gui/$(id -u)/com.aetheel" 2>/dev/null && echo "Service restarted" || echo "Service not running" + ;; + Linux*) + systemctl --user restart aetheel 2>/dev/null && echo "Service restarted" || echo "Service not running" + ;; + esac + ;; + logs) + tail -f "$HOME/.aetheel/logs/aetheel.log" 2>/dev/null || echo "No log file found" + ;; + update) + cd "$AETHEEL_DIR" + git pull --ff-only + if command -v uv >/dev/null 2>&1; then + uv sync + else + . .venv/bin/activate && pip install -q -r requirements.txt + fi + echo "Updated. Run 'aetheel restart' to apply." + ;; + doctor) + exec $RUN_CMD "$AETHEEL_DIR/cli.py" doctor + ;; + config) + ${EDITOR:-nano} "$HOME/.aetheel/config.json" + ;; + help|--help|-h) + echo "Usage: aetheel [options]" + echo "" + echo "Commands:" + echo " start Start Aetheel (default)" + echo " stop Stop the background service" + echo " restart Restart the background service" + echo " status Show installation and service status" + echo " logs Tail the live log" + echo " setup Re-run the interactive setup wizard" + echo " update Pull latest code and update dependencies" + echo " doctor Run diagnostics" + echo " config Open config.json in your editor" + echo " help Show this help" + echo "" + echo "Start options (passed through to main.py):" + echo " --claude Use Claude Code runtime" + echo " --discord Enable Discord adapter" + echo " --telegram Enable Telegram adapter" + echo " --webchat Enable WebChat adapter" + echo " --model NAME Override AI model" + echo " --test Echo mode (no AI)" + echo " --log LEVEL Set log level (DEBUG, INFO, WARNING)" + ;; + *) + # Pass through to main.py + exec $RUN_CMD "$AETHEEL_DIR/main.py" "$@" + ;; +esac +LAUNCHEREOF + +# Replace placeholders +sed -i.bak "s|INSTALL_DIR_PLACEHOLDER|$INSTALL_DIR|g" "$LAUNCHER_PATH" +sed -i.bak "s|RUN_PREFIX_PLACEHOLDER|$RUN_PREFIX|g" "$LAUNCHER_PATH" +rm -f "$LAUNCHER_PATH.bak" +chmod +x "$LAUNCHER_PATH" + +# Symlink into PATH +BIN_DIR="$HOME/.local/bin" +mkdir -p "$BIN_DIR" + +if [ -L "$BIN_DIR/aetheel" ] || [ -f "$BIN_DIR/aetheel" ]; then + rm -f "$BIN_DIR/aetheel" +fi +ln -s "$LAUNCHER_PATH" "$BIN_DIR/aetheel" + +# Check if ~/.local/bin is in PATH +if echo "$PATH" | grep -q "$BIN_DIR"; then + success "aetheel command installed → $BIN_DIR/aetheel" +else + success "aetheel command installed → $BIN_DIR/aetheel" + warn "$BIN_DIR is not in your PATH" + + # Detect shell and suggest fix + SHELL_NAME=$(basename "${SHELL:-/bin/bash}") + case "$SHELL_NAME" in + zsh) RC_FILE="$HOME/.zshrc" ;; + bash) RC_FILE="$HOME/.bashrc" ;; + fish) RC_FILE="$HOME/.config/fish/config.fish" ;; + *) RC_FILE="$HOME/.profile" ;; + esac + + if [ "$SHELL_NAME" = "fish" ]; then + dim "Add to $RC_FILE:" + dim " fish_add_path $BIN_DIR" + else + dim "Add to $RC_FILE:" + dim " export PATH=\"\$HOME/.local/bin:\$PATH\"" + fi + + # Offer to add it automatically + if confirm "Add it to $RC_FILE now?"; then + if [ "$SHELL_NAME" = "fish" ]; then + echo "fish_add_path $BIN_DIR" >> "$RC_FILE" + else + echo "" >> "$RC_FILE" + echo "# Aetheel" >> "$RC_FILE" + echo "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> "$RC_FILE" + fi + success "Added to $RC_FILE — restart your shell or run: source $RC_FILE" + fi +fi + + +# ═══════════════════════════════════════════════════════════════════════════ +# PHASE 7: Background Service +# ═══════════════════════════════════════════════════════════════════════════ + +step "7/8 — Background Service" + +cd "$INSTALL_DIR" +mkdir -p "$DATA_DIR/logs" + +if confirm "Install Aetheel as a background service (auto-start on login)?" "y"; then + + case "$PLATFORM" in + + macos) + PLIST_PATH="$HOME/Library/LaunchAgents/com.aetheel.plist" + mkdir -p "$HOME/Library/LaunchAgents" + + # Unload existing if present + if launchctl list 2>/dev/null | grep -q "com.aetheel"; then + launchctl unload "$PLIST_PATH" 2>/dev/null || true + fi + + cat > "$PLIST_PATH" < + + + + Label + com.aetheel + ProgramArguments + + ${BIN_DIR}/aetheel + start + + WorkingDirectory + ${INSTALL_DIR} + RunAtLoad + + KeepAlive + + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:${HOME}/.local/bin:${HOME}/.opencode/bin:${HOME}/.claude/bin + HOME + ${HOME} + + StandardOutPath + ${DATA_DIR}/logs/aetheel.log + StandardErrorPath + ${DATA_DIR}/logs/aetheel.error.log + ThrottleInterval + 10 + + +PLISTEOF + + if launchctl load "$PLIST_PATH" 2>>"$LOG_FILE"; then + success "launchd service installed and started" + dim "Logs: tail -f ~/.aetheel/logs/aetheel.log" + else + warn "launchctl load failed — check $LOG_FILE" + fi + ;; + + linux) + UNIT_DIR="$HOME/.config/systemd/user" + UNIT_PATH="$UNIT_DIR/aetheel.service" + mkdir -p "$UNIT_DIR" + + cat > "$UNIT_PATH" <>"$LOG_FILE" || true + systemctl --user enable aetheel 2>>"$LOG_FILE" || true + + if systemctl --user start aetheel 2>>"$LOG_FILE"; then + success "systemd service installed and started" + dim "Logs: journalctl --user -u aetheel -f" + else + warn "Service start failed — check: systemctl --user status aetheel" + fi + ;; + + *) + warn "Unsupported platform for service installation: $PLATFORM" + dim "Start manually: aetheel start" + ;; + esac + +else + info "Skipping service installation" + dim "Start manually anytime: aetheel start" +fi + + +# ═══════════════════════════════════════════════════════════════════════════ +# PHASE 8: Verification +# ═══════════════════════════════════════════════════════════════════════════ + +step "8/8 — Verification" + +CHECKS_PASSED=0 +CHECKS_TOTAL=0 + +check() { + CHECKS_TOTAL=$((CHECKS_TOTAL + 1)) + if eval "$2" >/dev/null 2>&1; then + success "$1" + CHECKS_PASSED=$((CHECKS_PASSED + 1)) + else + warn "$1" + fi +} + +check "Repository exists" "[ -d '$INSTALL_DIR/.git' ]" +check "Python environment" "[ -d '$INSTALL_DIR/.venv' ] || command -v uv" +check "Config file" "[ -f '$CONFIG_PATH' ]" +check ".env file" "[ -f '$INSTALL_DIR/.env' ]" +check "Data directory" "[ -d '$DATA_DIR/workspace' ]" +check "aetheel command" "[ -x '$BIN_DIR/aetheel' ]" + +# Check AI runtimes +if command -v opencode >/dev/null 2>&1; then + check "OpenCode CLI" "command -v opencode" +fi +if command -v claude >/dev/null 2>&1; then + check "Claude Code CLI" "command -v claude" +fi + +# Check service +case "$PLATFORM" in + macos) + check "launchd service" "launchctl list 2>/dev/null | grep -q com.aetheel" + ;; + linux) + check "systemd service" "systemctl --user is-enabled aetheel 2>/dev/null" + ;; +esac + +# Check tokens +if [ -f "$INSTALL_DIR/.env" ]; then + if grep -qE "^SLACK_BOT_TOKEN=xoxb-[a-zA-Z0-9]" "$INSTALL_DIR/.env" 2>/dev/null; then + check "Slack bot token" "true" + fi + if grep -qE "^SLACK_APP_TOKEN=xapp-[a-zA-Z0-9]" "$INSTALL_DIR/.env" 2>/dev/null; then + check "Slack app token" "true" + fi + if grep -qE "^DISCORD_BOT_TOKEN=[a-zA-Z0-9]" "$INSTALL_DIR/.env" 2>/dev/null; then + check "Discord token" "true" + fi + if grep -qE "^TELEGRAM_BOT_TOKEN=[a-zA-Z0-9]" "$INSTALL_DIR/.env" 2>/dev/null; then + check "Telegram token" "true" + fi +fi + +printf "\n" +printf " ${BOLD}Result: $CHECKS_PASSED/$CHECKS_TOTAL checks passed${NC}\n" + +log "Verification: $CHECKS_PASSED/$CHECKS_TOTAL passed" + +# ═══════════════════════════════════════════════════════════════════════════ +# Done! +# ═══════════════════════════════════════════════════════════════════════════ printf "\n" printf "${BOLD}${GREEN}" -printf " ╔══════════════════════════════════════════╗\n" -printf " ║ ║\n" -printf " ║ ✅ Aetheel is ready! ║\n" -printf " ║ ║\n" -printf " ╚══════════════════════════════════════════╝\n" +printf " ╔══════════════════════════════════════════════╗\n" +printf " ║ ║\n" +printf " ║ ✅ Aetheel is ready! ║\n" +printf " ║ ║\n" +printf " ╚══════════════════════════════════════════════╝\n" printf "${NC}\n" -printf " ${BOLD}To start Aetheel:${NC}\n" +printf " ${BOLD}Quick Reference:${NC}\n" printf "\n" -printf " cd %s\n" "$INSTALL_DIR" - -if [ "$HAS_UV" = true ]; then - printf " uv run python main.py\n" -else - printf " source .venv/bin/activate\n" - printf " python main.py\n" -fi - -printf "\n" -printf " ${BOLD}Other commands:${NC}\n" -printf " --claude Use Claude Code instead of OpenCode\n" -printf " --test Echo mode (no AI, for testing Slack)\n" -printf " --help Show all options\n" +printf " aetheel start Start the bot (foreground)\n" +printf " aetheel stop Stop the background service\n" +printf " aetheel restart Restart the background service\n" +printf " aetheel status Check installation status\n" +printf " aetheel logs Tail live logs\n" +printf " aetheel setup Re-run setup wizard\n" +printf " aetheel update Pull latest + update deps\n" +printf " aetheel doctor Run diagnostics\n" +printf " aetheel config Edit config.json\n" printf "\n" printf " ${BOLD}Files:${NC}\n" -printf " Config: %s/.env\n" "$INSTALL_DIR" -printf " Memory: ~/.aetheel/workspace/\n" -printf " Docs: %s/docs/\n" "$INSTALL_DIR" +printf " Code: %s\n" "$INSTALL_DIR" +printf " Config: %s\n" "$CONFIG_PATH" +printf " Secrets: %s/.env\n" "$INSTALL_DIR" +printf " Memory: %s/workspace/\n" "$DATA_DIR" +printf " Logs: %s/logs/\n" "$DATA_DIR" +printf " Docs: %s/docs/\n" "$INSTALL_DIR" printf "\n" -# Offer to start -printf " ${BOLD}Start Aetheel now?${NC} [y/N] " -read -r START_NOW -if [ "$START_NOW" = "y" ] || [ "$START_NOW" = "Y" ]; then - printf "\n" - info "Starting Aetheel..." - if [ "$HAS_UV" = true ]; then - exec uv run python main.py - else - exec python main.py - fi -fi +# If service is running, show that +case "$PLATFORM" in + macos) + if launchctl list 2>/dev/null | grep -q "com.aetheel"; then + PID=$(launchctl list 2>/dev/null | grep "com.aetheel" | awk '{print $1}') + if [ "$PID" != "-" ] && [ -n "$PID" ]; then + printf " ${GREEN}●${NC} Aetheel is running in the background (PID $PID)\n" + printf " View logs: ${CYAN}aetheel logs${NC}\n" + fi + fi + ;; + linux) + if systemctl --user is-active aetheel >/dev/null 2>&1; then + printf " ${GREEN}●${NC} Aetheel is running in the background\n" + printf " View logs: ${CYAN}aetheel logs${NC}\n" + fi + ;; +esac printf "\n" +log "Install complete" diff --git a/main.py b/main.py index 71b7f44..9b45e4c 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ scheduled tasks, and subagent support. Usage: python main.py Start with Slack + AI handler python main.py --telegram Also enable Telegram adapter + python main.py --discord Also enable Discord adapter python main.py --claude Use Claude Code runtime python main.py --test Echo handler for testing python main.py --model anthropic/claude-sonnet-4-20250514 @@ -25,7 +26,7 @@ from datetime import datetime from dotenv import load_dotenv -# Load .env file +# Load .env file (secrets only — config comes from ~/.aetheel/config.json) load_dotenv() from adapters.base import BaseAdapter, IncomingMessage @@ -39,6 +40,9 @@ from agent.opencode_runtime import ( build_aetheel_system_prompt, ) from agent.subagent import SubagentManager +from config import AetheelConfig, load_config, save_default_config, write_mcp_config, CONFIG_PATH +from heartbeat import HeartbeatRunner +from hooks import HookManager, HookEvent from memory import MemoryManager from memory.types import MemoryConfig from scheduler import Scheduler @@ -56,12 +60,28 @@ _memory: MemoryManager | None = None _skills: SkillsManager | None = None _scheduler: Scheduler | None = None _subagent_mgr: SubagentManager | None = None +_heartbeat: HeartbeatRunner | None = None +_hook_mgr: HookManager | None = None +_webhook_receiver = None # WebhookReceiver | None _adapters: dict[str, BaseAdapter] = {} # source_name -> adapter # Runtime config (stored for subagent factory) _use_claude: bool = False _cli_args: argparse.Namespace | None = None +# Usage tracking +_usage_stats: dict = { + "total_requests": 0, + "total_cost_usd": 0.0, + "total_duration_ms": 0, + "requests_by_engine": {"opencode": 0, "claude": 0}, + "cost_by_engine": {"opencode": 0.0, "claude": 0.0}, + "rate_limit_hits": 0, + "failovers": 0, + "last_rate_limit": None, # ISO timestamp + "session_start": datetime.now().isoformat(), +} + # Regex for parsing action tags from AI responses _ACTION_RE = re.compile(r"\[ACTION:remind\|(\d+)\|(.+?)\]", re.DOTALL) _CRON_RE = re.compile(r"\[ACTION:cron\|([\d\*/,\- ]+)\|(.+?)\]", re.DOTALL) @@ -171,21 +191,56 @@ def ai_handler(msg: IncomingMessage) -> str: text_lower = msg.text.lower().strip() # Built-in commands (bypass AI) - if text_lower in ("status", "/status", "ping"): + # Commands work with or without "/" prefix. + # In Slack channels, "/" is intercepted as a native slash command, + # so users should use the prefix-free form (e.g. "engine claude"). + # In DMs and other adapters, both forms work. + cmd = text_lower.lstrip("/") + + if cmd in ("status", "ping"): return _format_status() - if text_lower in ("help", "/help"): + if cmd == "help": return _format_help() - if text_lower == "time": + if cmd == "time": return f"🕐 Server time: *{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*" - if text_lower in ("sessions", "/sessions"): + if cmd in ("sessions",): return _format_sessions() # Cron management commands - if text_lower.startswith("/cron"): - return _handle_cron_command(text_lower) + if cmd.startswith("cron"): + return _handle_cron_command(cmd) + + # Reload config and skills + if cmd in ("reload",): + cfg = load_config() + if _skills: + _skills.reload() + if _hook_mgr: + _hook_mgr.trigger(HookEvent(type="command", action="reload")) + return "🔄 Config and skills reloaded." + + # List active subagents + if cmd in ("subagents",): + return _format_subagents() + + # Runtime management commands + if cmd.startswith("engine") or cmd.startswith("runtime"): + return _handle_engine_command(msg.text.strip().lstrip("/")) + + if cmd.startswith("model"): + return _handle_model_command(msg.text.strip().lstrip("/")) + + if cmd.startswith("provider"): + return _handle_provider_command(msg.text.strip().lstrip("/")) + + if cmd.startswith("config"): + return _handle_config_command(msg.text.strip().lstrip("/")) + + if cmd.startswith("usage"): + return _handle_usage_command() # Build context from memory + skills context = _build_context(msg) @@ -204,6 +259,15 @@ def ai_handler(msg: IncomingMessage) -> str: system_prompt=system_prompt, ) + # Track usage stats + _track_usage(response) + + # Rate limit detection + auto-failover + if response.rate_limited: + failover_response = _handle_rate_limit(msg, system_prompt, response) + if failover_response is not None: + return failover_response + if not response.ok: error_msg = response.error or "Unknown error" logger.error(f"AI error: {error_msg}") @@ -223,6 +287,7 @@ def ai_handler(msg: IncomingMessage) -> str: logger.info( f"🤖 AI response: {len(response.text)} chars, " f"{response.duration_ms}ms" + f"{', $' + str(response.usage.get('cost_usd', 0)) if response.usage else ''}" ) # Parse and execute action tags (reminders, cron, spawn) @@ -358,10 +423,476 @@ def _handle_cron_command(text: str) -> str: return f"⚠️ Job `{job_id}` not found." return ( - "Usage: `/cron list` or `/cron remove `" + "Usage: `cron list` or `cron remove `" ) +# --------------------------------------------------------------------------- +# Runtime / Model / Provider Management Commands +# --------------------------------------------------------------------------- + + +def _handle_engine_command(text: str) -> str: + """ + Handle /engine (or /runtime) commands. + + /engine Show current engine + /engine opencode Switch to OpenCode + /engine claude Switch to Claude Code + """ + global _runtime, _use_claude + + parts = text.strip().split(maxsplit=1) + if len(parts) < 2: + engine = "claude" if _use_claude else "opencode" + status = _runtime.get_status() if _runtime else {} + model = status.get("model", "default") + provider = status.get("provider", "auto") + return ( + f"🧠 *Current Engine:* `{engine}`\n" + f"• *Model:* `{model}`\n" + f"• *Provider:* `{provider}`\n" + f"\n" + f"Switch with: `/engine opencode` or `/engine claude`" + ) + + target = parts[1].strip().lower() + if target not in ("opencode", "claude"): + return "Usage: `/engine opencode` or `/engine claude`" + + want_claude = target == "claude" + if want_claude == _use_claude: + return f"Already using `{target}`." + + # Hot-swap the runtime + try: + _use_claude = want_claude + cfg = load_config() + cfg.runtime.engine = target + + if want_claude: + new_config = ClaudeCodeConfig( + model=cfg.claude.model, + timeout_seconds=cfg.claude.timeout_seconds, + max_turns=cfg.claude.max_turns, + no_tools=cfg.claude.no_tools, + allowed_tools=cfg.claude.allowed_tools, + ) + _runtime = ClaudeCodeRuntime(new_config) + else: + new_config = OpenCodeConfig( + mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, + server_url=cfg.runtime.server_url, + timeout_seconds=cfg.runtime.timeout_seconds, + model=cfg.runtime.model, + provider=cfg.runtime.provider, + workspace_dir=cfg.runtime.workspace, + format=cfg.runtime.format, + ) + _runtime = OpenCodeRuntime(new_config) + + # Persist to config.json + _update_config_file({"runtime": {"engine": target}}) + + status = _runtime.get_status() + return ( + f"✅ Switched to `{target}`\n" + f"• *Model:* `{status.get('model', 'default')}`\n" + f"• *Provider:* `{status.get('provider', 'auto')}`" + ) + except Exception as e: + logger.error(f"Engine switch failed: {e}", exc_info=True) + return f"⚠️ Failed to switch engine: {e}" + + +def _handle_model_command(text: str) -> str: + """ + Handle /model commands. + + /model Show current model + /model Switch model (hot-swap) + /model anthropic/claude-sonnet-4-20250514 + /model openai/gpt-4o + """ + global _runtime, _use_claude + + parts = text.strip().split(maxsplit=1) + if len(parts) < 2: + status = _runtime.get_status() if _runtime else {} + return ( + f"🧠 *Current Model:* `{status.get('model', 'default')}`\n" + f"• *Engine:* `{'claude' if _use_claude else 'opencode'}`\n" + f"• *Provider:* `{status.get('provider', 'auto')}`\n" + f"\n" + f"Switch with: `/model `\n" + f"Examples:\n" + f"• `/model anthropic/claude-sonnet-4-20250514`\n" + f"• `/model openai/gpt-4o`\n" + f"• `/model google/gemini-2.5-pro`" + ) + + new_model = parts[1].strip() + if not new_model: + return "Usage: `/model `" + + try: + cfg = load_config() + + if _use_claude: + cfg.claude.model = new_model + new_config = ClaudeCodeConfig( + model=new_model, + timeout_seconds=cfg.claude.timeout_seconds, + max_turns=cfg.claude.max_turns, + no_tools=cfg.claude.no_tools, + allowed_tools=cfg.claude.allowed_tools, + ) + _runtime = ClaudeCodeRuntime(new_config) + _update_config_file({"claude": {"model": new_model}}) + else: + cfg.runtime.model = new_model + new_config = OpenCodeConfig( + mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, + server_url=cfg.runtime.server_url, + timeout_seconds=cfg.runtime.timeout_seconds, + model=new_model, + provider=cfg.runtime.provider, + workspace_dir=cfg.runtime.workspace, + format=cfg.runtime.format, + ) + _runtime = OpenCodeRuntime(new_config) + _update_config_file({"runtime": {"model": new_model}}) + + return f"✅ Model switched to `{new_model}`" + except Exception as e: + logger.error(f"Model switch failed: {e}", exc_info=True) + return f"⚠️ Failed to switch model: {e}" + + +def _handle_provider_command(text: str) -> str: + """ + Handle /provider commands (OpenCode only). + + /provider Show current provider + /provider anthropic Switch provider + /provider openai Switch provider + """ + global _runtime, _use_claude + + if _use_claude: + return "Provider selection is only available with the OpenCode engine. Claude Code always uses Anthropic." + + parts = text.strip().split(maxsplit=1) + if len(parts) < 2: + status = _runtime.get_status() if _runtime else {} + return ( + f"🔌 *Current Provider:* `{status.get('provider', 'auto')}`\n" + f"\n" + f"Switch with: `/provider `\n" + f"Examples: `anthropic`, `openai`, `google`, `auto`" + ) + + new_provider = parts[1].strip().lower() + + try: + cfg = load_config() + cfg.runtime.provider = new_provider if new_provider != "auto" else None + + new_config = OpenCodeConfig( + mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, + server_url=cfg.runtime.server_url, + timeout_seconds=cfg.runtime.timeout_seconds, + model=cfg.runtime.model, + provider=new_provider if new_provider != "auto" else None, + workspace_dir=cfg.runtime.workspace, + format=cfg.runtime.format, + ) + _runtime = OpenCodeRuntime(new_config) + _update_config_file({"runtime": {"provider": new_provider}}) + + return f"✅ Provider switched to `{new_provider}`" + except Exception as e: + logger.error(f"Provider switch failed: {e}", exc_info=True) + return f"⚠️ Failed to switch provider: {e}" + + +def _handle_config_command(text: str) -> str: + """ + Handle /config commands — view and edit config from chat. + + /config Show key settings + /config show Show full config + /config set Set a config value + """ + import json as _json + + parts = text.strip().split(maxsplit=2) + subcommand = parts[1] if len(parts) > 1 else "summary" + + if subcommand == "show": + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + data = _json.load(f) + formatted = _json.dumps(data, indent=2) + if len(formatted) > 3000: + formatted = formatted[:3000] + "\n... (truncated)" + return f"```\n{formatted}\n```" + except Exception as e: + return f"⚠️ Failed to read config: {e}" + + elif subcommand == "set" and len(parts) >= 3: + rest = parts[2].strip() + kv_parts = rest.split(maxsplit=1) + if len(kv_parts) < 2: + return "Usage: `/config set runtime.model anthropic/claude-sonnet-4-20250514`" + + key_path = kv_parts[0] + value_str = kv_parts[1] + + # Parse value (handle booleans, numbers, null, strings) + try: + value = _json.loads(value_str) + except _json.JSONDecodeError: + value = value_str + + keys = key_path.split(".") + if len(keys) < 2: + return "Use dotted notation: `/config set runtime.model `" + + update = {} + current = update + for k in keys[:-1]: + current[k] = {} + current = current[k] + current[keys[-1]] = value + + try: + _update_config_file(update) + return f"✅ Set `{key_path}` = `{value_str}`\nRun `/reload` to apply changes." + except Exception as e: + return f"⚠️ Failed to update config: {e}" + + else: + cfg = load_config() + engine = cfg.runtime.engine + model = cfg.claude.model if engine == "claude" else cfg.runtime.model + provider = cfg.runtime.provider if engine == "opencode" else "anthropic" + lines = [ + "⚙️ *Configuration Summary*", + "", + f"• *Engine:* `{engine}`", + f"• *Model:* `{model or 'default'}`", + f"• *Provider:* `{provider or 'auto'}`", + f"• *Mode:* `{cfg.runtime.mode}`", + f"• *Timeout:* `{cfg.runtime.timeout_seconds}s`", + "", + f"• *Slack:* {'✅' if cfg.slack.enabled else '❌'}", + f"• *Discord:* {'✅' if cfg.discord.enabled else '❌'}", + f"• *Telegram:* {'✅' if cfg.telegram.enabled else '❌'}", + f"• *WebChat:* {'✅' if cfg.webchat.enabled else '❌'}", + f"• *Webhooks:* {'✅' if cfg.webhooks.enabled else '❌'}", + "", + "Use `/config show` for full config, `/config set ` to edit.", + "Use `/engine`, `/model`, `/provider` for live switching.", + ] + return "\n".join(lines) + + +def _update_config_file(updates: dict) -> None: + """ + Merge updates into ~/.aetheel/config.json. + Deep merge so partial updates don't clobber existing keys. + """ + import json as _json + + data = {} + if os.path.isfile(CONFIG_PATH): + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + data = _json.load(f) + + def _deep_merge(base: dict, overlay: dict) -> dict: + for key, value in overlay.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + _deep_merge(base[key], value) + else: + base[key] = value + return base + + _deep_merge(data, updates) + + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + _json.dump(data, f, indent=2) + + logger.info(f"Config updated: {list(updates.keys())}") + + +# --------------------------------------------------------------------------- +# Usage Tracking, Rate Limit Failover, Notifications +# --------------------------------------------------------------------------- + + +def _track_usage(response: AgentResponse) -> None: + """Track usage stats from an AI response.""" + global _usage_stats + + engine = "claude" if _use_claude else "opencode" + _usage_stats["total_requests"] += 1 + _usage_stats["requests_by_engine"][engine] = ( + _usage_stats["requests_by_engine"].get(engine, 0) + 1 + ) + _usage_stats["total_duration_ms"] += response.duration_ms + + if response.usage: + cost = response.usage.get("cost_usd", 0) or 0 + _usage_stats["total_cost_usd"] += cost + _usage_stats["cost_by_engine"][engine] = ( + _usage_stats["cost_by_engine"].get(engine, 0) + cost + ) + + if response.rate_limited: + _usage_stats["rate_limit_hits"] += 1 + _usage_stats["last_rate_limit"] = datetime.now().isoformat() + + +def _handle_rate_limit( + msg: IncomingMessage, + system_prompt: str, + original_response: AgentResponse, +) -> str | None: + """ + Handle a rate-limited response. Attempts auto-failover to the other + engine and notifies the user. + + Returns the failover response text, or None if failover isn't possible. + """ + global _runtime, _use_claude, _usage_stats + + current_engine = "claude" if _use_claude else "opencode" + other_engine = "opencode" if _use_claude else "claude" + + logger.warning( + f"Rate limit hit on {current_engine}: {original_response.error}" + ) + + # Notify the user about the rate limit + notify_text = ( + f"⚠️ *Rate limit reached on {current_engine}*\n" + f"{original_response.error or 'Usage limit exceeded.'}" + ) + + # Try auto-failover to the other engine + cfg = load_config() + failover_runtime = None + + try: + if _use_claude: + # Failover: Claude → OpenCode + failover_runtime = OpenCodeRuntime(OpenCodeConfig( + mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, + server_url=cfg.runtime.server_url, + timeout_seconds=cfg.runtime.timeout_seconds, + model=cfg.runtime.model, + provider=cfg.runtime.provider, + workspace_dir=cfg.runtime.workspace, + format=cfg.runtime.format, + )) + else: + # Failover: OpenCode → Claude + failover_runtime = ClaudeCodeRuntime(ClaudeCodeConfig( + model=cfg.claude.model, + timeout_seconds=cfg.claude.timeout_seconds, + max_turns=cfg.claude.max_turns, + no_tools=cfg.claude.no_tools, + allowed_tools=cfg.claude.allowed_tools, + )) + except Exception as e: + logger.warning(f"Failover engine ({other_engine}) not available: {e}") + return f"{notify_text}\n\n{other_engine} is not available for failover." + + # Attempt the request on the failover engine + logger.info(f"Attempting failover: {current_engine} → {other_engine}") + try: + failover_response = failover_runtime.chat( + message=msg.text, + conversation_id=msg.conversation_id, + system_prompt=system_prompt, + ) + _track_usage(failover_response) + + if failover_response.ok: + # Failover succeeded — switch the active runtime + _runtime = failover_runtime + _use_claude = not _use_claude + _usage_stats["failovers"] += 1 + + engine_name = "claude" if _use_claude else "opencode" + logger.info(f"Failover successful → now using {engine_name}") + + # Process action tags on the failover response + reply_text = _process_action_tags(failover_response.text, msg) + + return ( + f"{notify_text}\n" + f"🔄 Auto-switched to *{other_engine}*.\n\n" + f"{reply_text}" + ) + else: + # Failover also failed + return ( + f"{notify_text}\n" + f"Failover to {other_engine} also failed: " + f"{failover_response.error or 'Unknown error'}" + ) + except Exception as e: + logger.error(f"Failover request failed: {e}", exc_info=True) + return f"{notify_text}\nFailover to {other_engine} failed: {e}" + + +def _handle_usage_command() -> str: + """ + Handle the `usage` command — show LLM usage stats. + """ + global _usage_stats + + total = _usage_stats["total_requests"] + cost = _usage_stats["total_cost_usd"] + duration = _usage_stats["total_duration_ms"] + rate_limits = _usage_stats["rate_limit_hits"] + failovers = _usage_stats["failovers"] + started = _usage_stats["session_start"] + + lines = [ + "📊 *Usage Stats* (since last restart)", + "", + f"• *Total Requests:* {total}", + f"• *Total Cost:* ${cost:.4f}", + f"• *Total Duration:* {duration / 1000:.1f}s", + f"• *Rate Limit Hits:* {rate_limits}", + f"• *Auto-Failovers:* {failovers}", + f"• *Session Start:* {started}", + "", + "*By Engine:*", + ] + + for engine in ("opencode", "claude"): + reqs = _usage_stats["requests_by_engine"].get(engine, 0) + eng_cost = _usage_stats["cost_by_engine"].get(engine, 0) + if reqs > 0: + lines.append(f"• *{engine}:* {reqs} requests, ${eng_cost:.4f}") + + if _usage_stats["last_rate_limit"]: + lines.append(f"\n⚠️ Last rate limit: {_usage_stats['last_rate_limit']}") + + engine = "claude" if _use_claude else "opencode" + status = _runtime.get_status() if _runtime else {} + lines.extend([ + "", + f"*Active:* `{engine}` / `{status.get('model', 'default')}`", + ]) + + return "\n".join(lines) + + # --------------------------------------------------------------------------- # Scheduler Callback # --------------------------------------------------------------------------- @@ -447,15 +978,30 @@ def _make_runtime() -> AnyRuntime: """Create a fresh runtime instance (used by subagent manager).""" global _use_claude, _cli_args + cfg = load_config() + if _cli_args and _cli_args.model: + cfg.runtime.model = _cli_args.model + cfg.claude.model = _cli_args.model + if _use_claude: - config = ClaudeCodeConfig.from_env() - if _cli_args and _cli_args.model: - config.model = _cli_args.model + config = ClaudeCodeConfig( + model=cfg.claude.model, + timeout_seconds=cfg.claude.timeout_seconds, + max_turns=cfg.claude.max_turns, + no_tools=cfg.claude.no_tools, + allowed_tools=cfg.claude.allowed_tools, + ) return ClaudeCodeRuntime(config) else: - config = OpenCodeConfig.from_env() - if _cli_args and _cli_args.model: - config.model = _cli_args.model + config = OpenCodeConfig( + mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, + server_url=cfg.runtime.server_url, + timeout_seconds=cfg.runtime.timeout_seconds, + model=cfg.runtime.model, + provider=cfg.runtime.provider, + workspace_dir=cfg.runtime.workspace, + format=cfg.runtime.format, + ) return OpenCodeRuntime(config) @@ -475,11 +1021,14 @@ def _format_status() -> str: if _runtime: status = _runtime.get_status() + engine = "claude" if _use_claude else "opencode" lines.extend([ + f"• *Engine:* {engine}", f"• *Mode:* {status['mode']}", f"• *Model:* {status['model']}", f"• *Provider:* {status['provider']}", f"• *Active Sessions:* {status['active_sessions']}", + f"• *Live Sessions:* {status.get('live_sessions', 0)}", ]) # Adapter status @@ -515,23 +1064,35 @@ def _format_help() -> str: return ( "🦾 *Aetheel — AI-Powered Assistant*\n" "\n" - "*Built-in Commands:*\n" - "• `status` — Check bot and AI runtime status\n" - "• `help` — Show this help message\n" - "• `time` — Current server time\n" + "*Commands:* (type as a regular message, no `/` needed)\n" + "\n" + "*General:*\n" + "• `status` — Bot and runtime status\n" + "• `help` — This help message\n" + "• `time` — Server time\n" "• `sessions` — Active session count\n" - "• `/cron list` — List scheduled jobs\n" - "• `/cron remove ` — Remove a scheduled job\n" + "• `reload` — Reload config and skills\n" + "\n" + "*Runtime:*\n" + "• `engine` — Show/switch AI engine (opencode or claude)\n" + "• `model` — Show/switch AI model\n" + "• `provider` — Show/switch provider (opencode only)\n" + "• `usage` — Show LLM usage stats and costs\n" + "\n" + "*Config:*\n" + "• `config` — View config summary\n" + "• `config show` — View full config.json\n" + "• `config set ` — Edit a config value\n" + "\n" + "*Scheduler:*\n" + "• `cron list` — List scheduled jobs\n" + "• `cron remove ` — Remove a scheduled job\n" + "• `subagents` — List active background tasks\n" "\n" "*AI Chat:*\n" - "• Send any message and the AI will respond\n" + "• Send any other message and the AI will respond\n" "• Each thread maintains its own conversation\n" "• DMs work too — just message me directly\n" - "\n" - "*AI Actions:*\n" - "• The AI can schedule reminders\n" - "• The AI can set up recurring cron jobs\n" - "• The AI can spawn background subagents for long tasks\n" ) @@ -547,6 +1108,20 @@ def _format_sessions() -> str: ) return "⚠️ Runtime not initialized." +def _format_subagents() -> str: + """Format active subagent tasks.""" + global _subagent_mgr + if not _subagent_mgr: + return "⚠️ Subagent manager not initialized." + active = _subagent_mgr.list_active() + if not active: + return "📋 No active subagents." + lines = ["🤖 *Active Subagents:*\n"] + for task in active: + lines.append(f"• `{task.id}` — {task.status} — {task.task[:60]}") + return "\n".join(lines) + + # --------------------------------------------------------------------------- # Main @@ -554,7 +1129,8 @@ def _format_sessions() -> str: def main(): - global _runtime, _memory, _skills, _scheduler, _subagent_mgr + global _runtime, _memory, _skills, _scheduler, _subagent_mgr, _heartbeat + global _hook_mgr, _webhook_receiver global _adapters, _use_claude, _cli_args parser = argparse.ArgumentParser( @@ -562,20 +1138,24 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""\ Examples: - python main.py Start with Slack + AI handler - python main.py --telegram Also enable Telegram adapter - python main.py --claude Use Claude Code runtime - python main.py --test Echo-only handler + python main.py Start with config-driven adapters + python main.py --claude Override: use Claude Code runtime + python main.py --test Echo-only handler (no AI) python main.py --model anthropic/claude-sonnet-4-20250514 python main.py --log DEBUG Debug logging + +All adapters and features are controlled via ~/.aetheel/config.json. +CLI flags are optional overrides. """, ) parser.add_argument("--test", action="store_true", help="Use echo handler for testing") - parser.add_argument("--claude", action="store_true", help="Use Claude Code runtime") - parser.add_argument("--cli", action="store_true", help="Force CLI mode (OpenCode)") - parser.add_argument("--sdk", action="store_true", help="Force SDK mode (OpenCode)") - parser.add_argument("--telegram", action="store_true", help="Enable Telegram adapter") - parser.add_argument("--model", default=None, help="Model to use") + parser.add_argument("--claude", action="store_true", help="Override: use Claude Code runtime") + parser.add_argument("--cli", action="store_true", help="Override: force CLI mode (OpenCode)") + parser.add_argument("--sdk", action="store_true", help="Override: force SDK mode (OpenCode)") + parser.add_argument("--telegram", action="store_true", help="Override: enable Telegram adapter") + parser.add_argument("--discord", action="store_true", help="Override: enable Discord adapter") + parser.add_argument("--webchat", action="store_true", help="Override: enable WebChat adapter") + parser.add_argument("--model", default=None, help="Override: model to use") parser.add_argument( "--log", default=os.environ.get("LOG_LEVEL", "INFO"), @@ -583,25 +1163,59 @@ Examples: ) args = parser.parse_args() _cli_args = args - _use_claude = args.claude + + # ------------------------------------------------------------------- + # 0. Load Configuration (config.json + .env + CLI overrides) + # ------------------------------------------------------------------- + + save_default_config() # Create ~/.aetheel/config.json if missing + cfg = load_config() + + # CLI flags override config (flags are optional overrides, config is primary) + if args.claude: + cfg.runtime.engine = "claude" + _use_claude = cfg.runtime.engine == "claude" + + log_level = args.log if args.log != "INFO" else cfg.log_level + if args.model: + cfg.runtime.model = args.model + cfg.claude.model = args.model + if args.cli: + cfg.runtime.mode = "cli" + elif args.sdk: + cfg.runtime.mode = "sdk" + + # CLI flags can enable adapters (but config is the primary source) + if args.telegram: + cfg.telegram.enabled = True + if args.discord: + cfg.discord.enabled = True + if args.webchat: + cfg.webchat.enabled = True + + # Auto-enable adapters when tokens are present (even if not explicitly enabled) + if cfg.telegram.bot_token and not cfg.telegram.enabled: + cfg.telegram.enabled = True + logger.debug("Telegram auto-enabled: token present") + if cfg.discord.bot_token and not cfg.discord.enabled: + cfg.discord.enabled = True + logger.debug("Discord auto-enabled: token present") # Configure logging logging.basicConfig( - level=getattr(logging, args.log.upper(), logging.INFO), + level=getattr(logging, log_level.upper(), logging.INFO), format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) + logger.info(f"Config: {CONFIG_PATH}") + # ------------------------------------------------------------------- # 1. Initialize Memory System # ------------------------------------------------------------------- - workspace_dir = os.environ.get( - "AETHEEL_WORKSPACE", os.path.expanduser("~/.aetheel/workspace") - ) - db_path = os.environ.get( - "AETHEEL_MEMORY_DB", os.path.expanduser("~/.aetheel/memory.db") - ) + workspace_dir = os.path.expanduser(cfg.memory.workspace) + db_path = os.path.expanduser(cfg.memory.db_path) try: mem_config = MemoryConfig( @@ -632,30 +1246,42 @@ Examples: logger.warning(f"Skills system init failed (continuing without): {e}") _skills = None + # ------------------------------------------------------------------- + # 2½. Write MCP config (before runtime sees it) + # ------------------------------------------------------------------- + + write_mcp_config(cfg.mcp, cfg.memory.workspace, _use_claude) + # ------------------------------------------------------------------- # 3. Initialize AI Runtime # ------------------------------------------------------------------- runtime_label = "echo (test mode)" if not args.test: - if args.claude: - claude_config = ClaudeCodeConfig.from_env() - if args.model: - claude_config.model = args.model + if _use_claude: + claude_config = ClaudeCodeConfig( + model=cfg.claude.model, + timeout_seconds=cfg.claude.timeout_seconds, + max_turns=cfg.claude.max_turns, + no_tools=cfg.claude.no_tools, + allowed_tools=cfg.claude.allowed_tools, + ) _runtime = ClaudeCodeRuntime(claude_config) runtime_label = f"claude-code, model={claude_config.model or 'default'}" else: - config = OpenCodeConfig.from_env() - if args.cli: - config.mode = RuntimeMode.CLI - elif args.sdk: - config.mode = RuntimeMode.SDK - if args.model: - config.model = args.model - _runtime = OpenCodeRuntime(config) + oc_config = OpenCodeConfig( + mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, + server_url=cfg.runtime.server_url, + timeout_seconds=cfg.runtime.timeout_seconds, + model=cfg.runtime.model, + provider=cfg.runtime.provider, + workspace_dir=cfg.runtime.workspace, + format=cfg.runtime.format, + ) + _runtime = OpenCodeRuntime(oc_config) runtime_label = ( - f"opencode/{config.mode.value}, " - f"model={config.model or 'default'}" + f"opencode/{oc_config.mode.value}, " + f"model={oc_config.model or 'default'}" ) # ------------------------------------------------------------------- @@ -670,6 +1296,26 @@ Examples: logger.warning(f"Scheduler init failed (continuing without): {e}") _scheduler = None + # ------------------------------------------------------------------- + # 4b. Initialize Heartbeat System + # ------------------------------------------------------------------- + + heartbeat_count = 0 + if _scheduler: + try: + _heartbeat = HeartbeatRunner( + scheduler=_scheduler, + ai_handler_fn=ai_handler, + send_fn=_send_to_channel, + config=cfg.heartbeat, + workspace_dir=workspace_dir, + ) + heartbeat_count = _heartbeat.start() + logger.info(f"Heartbeat initialized: {heartbeat_count} task(s)") + except Exception as e: + logger.warning(f"Heartbeat init failed (continuing without): {e}") + _heartbeat = None + # ------------------------------------------------------------------- # 5. Initialize Subagent Manager # ------------------------------------------------------------------- @@ -685,6 +1331,49 @@ Examples: logger.warning(f"Subagent manager init failed: {e}") _subagent_mgr = None + # ------------------------------------------------------------------- + # 5b. Initialize Hook System + # ------------------------------------------------------------------- + + hook_count = 0 + if cfg.hooks.enabled: + try: + _hook_mgr = HookManager(workspace_dir=workspace_dir) + hooks_found = _hook_mgr.discover() + hook_count = len(hooks_found) + logger.info(f"Hook system initialized: {hook_count} hook(s)") + except Exception as e: + logger.warning(f"Hook system init failed (continuing without): {e}") + _hook_mgr = None + + # ------------------------------------------------------------------- + # 5c. Initialize Webhook Receiver + # ------------------------------------------------------------------- + + if cfg.webhooks.enabled: + try: + from webhooks.receiver import WebhookReceiver, WebhookConfig as WHConfig + + wh_config = WHConfig( + enabled=cfg.webhooks.enabled, + port=cfg.webhooks.port, + host=cfg.webhooks.host, + token=cfg.webhooks.token, + ) + _webhook_receiver = WebhookReceiver( + ai_handler_fn=ai_handler, + send_fn=_send_to_channel, + config=wh_config, + ) + _webhook_receiver.start_async() + logger.info( + f"Webhook receiver started at " + f"http://{cfg.webhooks.host}:{cfg.webhooks.port}/hooks/" + ) + except Exception as e: + logger.warning(f"Webhook receiver init failed (continuing without): {e}") + _webhook_receiver = None + # ------------------------------------------------------------------- # 6. Initialize Channel Adapters # ------------------------------------------------------------------- @@ -692,43 +1381,81 @@ Examples: # Choose the message handler handler = echo_handler if args.test else ai_handler - # Slack adapter (always enabled if tokens are present) - slack_token = os.environ.get("SLACK_BOT_TOKEN") - slack_app_token = os.environ.get("SLACK_APP_TOKEN") - - if slack_token and slack_app_token: + # Slack adapter (enabled when tokens are present and not disabled) + if cfg.slack.enabled and cfg.slack.bot_token and cfg.slack.app_token: try: - slack = SlackAdapter(log_level=args.log) + slack = SlackAdapter( + bot_token=cfg.slack.bot_token, + app_token=cfg.slack.app_token, + log_level=log_level, + ) slack.on_message(handler) _adapters["slack"] = slack logger.info("Slack adapter registered") except Exception as e: logger.error(f"Slack adapter failed to initialize: {e}") - else: - logger.warning("Slack tokens not set — Slack adapter disabled") + elif cfg.slack.enabled: + logger.warning("Slack enabled but tokens not set — Slack adapter disabled") - # Telegram adapter (enabled with --telegram flag) - if args.telegram: - telegram_token = os.environ.get("TELEGRAM_BOT_TOKEN") - if telegram_token: + # Telegram adapter (enabled via config or token auto-detection) + if cfg.telegram.enabled: + if cfg.telegram.bot_token: try: from adapters.telegram_adapter import TelegramAdapter - telegram = TelegramAdapter() + telegram = TelegramAdapter(bot_token=cfg.telegram.bot_token) telegram.on_message(handler) _adapters["telegram"] = telegram logger.info("Telegram adapter registered") except Exception as e: logger.error(f"Telegram adapter failed to initialize: {e}") else: - logger.error( - "TELEGRAM_BOT_TOKEN not set — cannot enable Telegram. " + logger.warning( + "Telegram enabled but TELEGRAM_BOT_TOKEN not set. " "Get a token from @BotFather on Telegram." ) + # Discord adapter (enabled via config or token auto-detection) + if cfg.discord.enabled: + if cfg.discord.bot_token: + try: + from adapters.discord_adapter import DiscordAdapter + + discord_adapter = DiscordAdapter( + bot_token=cfg.discord.bot_token, + listen_channels=cfg.discord.listen_channels or None, + ) + discord_adapter.on_message(handler) + _adapters["discord"] = discord_adapter + logger.info("Discord adapter registered") + except Exception as e: + logger.error(f"Discord adapter failed to initialize: {e}") + else: + logger.warning( + "Discord enabled but DISCORD_BOT_TOKEN not set. " + "Get a token from https://discord.com/developers/applications" + ) + + # WebChat adapter (enabled via config) + if cfg.webchat.enabled: + try: + from adapters.webchat_adapter import WebChatAdapter + + webchat = WebChatAdapter( + host=cfg.webchat.host, + port=cfg.webchat.port, + ) + webchat.on_message(handler) + _adapters["webchat"] = webchat + logger.info(f"WebChat adapter registered at http://{cfg.webchat.host}:{cfg.webchat.port}") + except Exception as e: + logger.error(f"WebChat adapter failed to initialize: {e}") + if not _adapters: print("❌ No channel adapters initialized!") - print(" Set SLACK_BOT_TOKEN + SLACK_APP_TOKEN or use --telegram") + print(" Configure adapters in ~/.aetheel/config.json") + print(" Set tokens in .env (SLACK_BOT_TOKEN, DISCORD_BOT_TOKEN, etc.)") + print(" Or enable webchat: {\"webchat\": {\"enabled\": true}}") sys.exit(1) # Start file watching for automatic memory re-indexing @@ -742,14 +1469,22 @@ Examples: logger.info("=" * 60) logger.info(" Aetheel Starting") logger.info("=" * 60) + logger.info(f" Config: {CONFIG_PATH}") logger.info(f" Runtime: {runtime_label}") logger.info(f" Channels: {', '.join(_adapters.keys())}") logger.info(f" Skills: {len(_skills.skills) if _skills else 0}") logger.info(f" Scheduler: {'✅' if _scheduler else '❌'}") + logger.info(f" Heartbeat: {'✅ ' + str(heartbeat_count) + ' tasks' if _heartbeat else '❌'}") logger.info(f" Subagents: {'✅' if _subagent_mgr else '❌'}") + logger.info(f" Hooks: {'✅ ' + str(hook_count) + ' hooks' if _hook_mgr else '❌'}") + logger.info(f" Webhooks: {'✅ port ' + str(cfg.webhooks.port) if _webhook_receiver else '❌'}") logger.info("=" * 60) try: + # Fire gateway:startup hook + if _hook_mgr: + _hook_mgr.trigger(HookEvent(type="gateway", action="startup")) + if len(_adapters) == 1: # Single adapter — start it blocking adapter = next(iter(_adapters.values())) @@ -764,7 +1499,12 @@ Examples: except KeyboardInterrupt: logger.info("Shutting down...") finally: + # Fire gateway:shutdown hook + if _hook_mgr: + _hook_mgr.trigger(HookEvent(type="gateway", action="shutdown")) # Cleanup + if _webhook_receiver: + _webhook_receiver.stop() for adapter in _adapters.values(): try: adapter.stop() diff --git a/pyproject.toml b/pyproject.toml index 9e68d14..5e0a0c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,23 +3,30 @@ name = "aetheel" version = "0.1.0" description = "A personal AI assistant that lives in Slack — with persistent memory, dual runtimes, and zero cloud dependencies." readme = "README.md" -requires-python = ">=3.14" +requires-python = ">=3.12" dependencies = [ "apscheduler>=3.10.0,<4.0.0", "fastembed>=0.7.4", "python-dotenv>=1.2.1,<2.0.0", "python-telegram-bot>=21.0", + "discord.py>=2.4.0", "slack-bolt>=1.27.0,<2.0.0", "slack-sdk>=3.40.0,<4.0.0", "watchdog>=6.0.0", + "click>=8.1.0", + "aiohttp>=3.9.0", ] [project.optional-dependencies] test = [ "pytest>=8.0", "pytest-asyncio>=0.24", + "hypothesis>=6.0", ] +[project.scripts] +aetheel = "cli:cli" + [tool.setuptools.packages.find] -include = ["agent*", "adapters*", "memory*", "skills*", "scheduler*"] +include = ["agent*", "adapters*", "memory*", "skills*", "scheduler*", "heartbeat*", "hooks*", "webhooks*"] exclude = ["tests*", "archive*", "inspiration*"] diff --git a/static/chat.html b/static/chat.html new file mode 100644 index 0000000..380317b --- /dev/null +++ b/static/chat.html @@ -0,0 +1,221 @@ + + + + + + Aetheel Chat + + + + + +
+
+ + +
+ + + \ No newline at end of file diff --git a/static/logo.jpeg b/static/logo.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..3dab15a499551d5aa4f56bbcdbb4410b3079a02d GIT binary patch literal 64640 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 literal 0 HcmV?d00001 diff --git a/stock_update.sh b/stock_update.sh deleted file mode 100755 index b589412..0000000 --- a/stock_update.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -# Stock market update script -echo "=== Stock Market Update - $(date) ===" >> /Users/tanmay-air/Desktop/aetheel/stock_updates.log -echo "Searching for top stocks..." >> /Users/tanmay-air/Desktop/aetheel/stock_updates.log -# Note: This script logs when it runs. Run opencode and search manually for actual data. -echo "Check completed at $(date)" >> /Users/tanmay-air/Desktop/aetheel/stock_updates.log -echo "" >> /Users/tanmay-air/Desktop/aetheel/stock_updates.log diff --git a/test_all.py b/test_all.py new file mode 100644 index 0000000..facb835 --- /dev/null +++ b/test_all.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +""" +Aetheel — Full Feature Test Script (cross-platform) +==================================================== +Runs all tests and smoke checks for every Aetheel feature. +Works on Windows, Linux, and macOS. + +Usage: + uv run python test_all.py + # or + python test_all.py +""" + +import importlib +import json +import os +import shutil +import subprocess +import sys +import tempfile + +PASS = 0 +FAIL = 0 +SKIP = 0 + + +def _pass(msg): + global PASS + PASS += 1 + print(f" ✅ {msg}") + + +def _fail(msg): + global FAIL + FAIL += 1 + print(f" ❌ {msg}") + + +def _skip(msg): + global SKIP + SKIP += 1 + print(f" ⚠️ {msg}") + + +def section(title): + print(f"\n━━━ {title} ━━━") + + +def run_cmd(args, cwd=None, timeout=120): + """Run a command and return (returncode, stdout).""" + try: + result = subprocess.run( + args, capture_output=True, text=True, + cwd=cwd, timeout=timeout, + ) + return result.returncode, result.stdout + result.stderr + except FileNotFoundError: + return -1, f"Command not found: {args[0]}" + except subprocess.TimeoutExpired: + return -1, "Timeout" + + +# ========================================================================= +section("1. Environment Check") +# ========================================================================= + +# Python version +py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" +if sys.version_info >= (3, 12): + _pass(f"Python {py_ver} (>= 3.12 required)") +else: + _fail(f"Python {py_ver} — need >= 3.12") + +# uv +if shutil.which("uv"): + rc, out = run_cmd(["uv", "--version"]) + _pass(f"uv found: {out.strip()}") +else: + _skip("uv not found — using pip directly") + +# Working directory +if os.path.isfile("pyproject.toml"): + _pass("Running from Aetheel directory") +else: + _fail("pyproject.toml not found — run from the Aetheel/ directory") + sys.exit(1) + + +# ========================================================================= +section("2. Dependency Check") +# ========================================================================= + +required = { + "pytest": "pytest", + "pytest_asyncio": "pytest-asyncio", + "hypothesis": "hypothesis", + "click": "click", + "aiohttp": "aiohttp", + "apscheduler": "apscheduler", + "dotenv": "python-dotenv", +} + +for mod_name, pkg_name in required.items(): + try: + importlib.import_module(mod_name) + _pass(f"{pkg_name} installed") + except ImportError: + _fail(f"{pkg_name} NOT installed — run: uv sync --extra test") + + +# ========================================================================= +section("3. Pytest — Unit Tests") +# ========================================================================= + +print(" Running full test suite...") +rc, out = run_cmd( + [sys.executable, "-m", "pytest", "tests/", "-v", "--tb=short", + "--ignore=tests/test_scheduler.py"], + timeout=120, +) +# Print last 20 lines +lines = out.strip().splitlines() +for line in lines[-20:]: + print(f" {line}") + +if rc == 0: + _pass("Core test suite passed") +else: + _fail("Core test suite had failures") + +# Scheduler tests +print("\n Attempting scheduler tests...") +rc2, out2 = run_cmd( + [sys.executable, "-m", "pytest", "tests/test_scheduler.py", "-v", "--tb=short"], + timeout=30, +) +if rc2 == 0: + _pass("Scheduler tests passed") +else: + _skip("Scheduler tests skipped (apscheduler import issue)") + + +# ========================================================================= +section("4. Config System") +# ========================================================================= + +try: + from config import load_config, AetheelConfig, MCPConfig, MCPServerConfig, write_mcp_config + from config import HeartbeatConfig, WebChatConfig, HooksConfig, WebhookConfig + + cfg = load_config() + assert isinstance(cfg, AetheelConfig) + assert cfg.claude.no_tools is False + assert len(cfg.claude.allowed_tools) > 0 + assert hasattr(cfg, "heartbeat") + assert hasattr(cfg, "webchat") + assert hasattr(cfg, "mcp") + assert hasattr(cfg, "hooks") + assert hasattr(cfg, "webhooks") + _pass("Config loads with all sections (heartbeat, webchat, mcp, hooks, webhooks)") +except Exception as e: + _fail(f"Config system: {e}") + +# MCP config writer +try: + with tempfile.TemporaryDirectory() as tmpdir: + mcp = MCPConfig(servers={ + "test": MCPServerConfig(command="echo", args=["hi"], env={"K": "V"}) + }) + write_mcp_config(mcp, tmpdir, use_claude=True) + with open(os.path.join(tmpdir, ".mcp.json")) as f: + data = json.load(f) + assert "mcpServers" in data and "test" in data["mcpServers"] + + write_mcp_config(mcp, tmpdir, use_claude=False) + with open(os.path.join(tmpdir, "opencode.json")) as f: + data = json.load(f) + assert "mcp" in data + _pass("MCP config writer (Claude + OpenCode formats)") +except Exception as e: + _fail(f"MCP config writer: {e}") + + +# ========================================================================= +section("5. System Prompt") +# ========================================================================= + +try: + from agent.opencode_runtime import build_aetheel_system_prompt + prompt = build_aetheel_system_prompt() + for s in ["Your Tools", "Self-Modification", "Subagents & Teams"]: + assert s in prompt, f"Missing: {s}" + _pass("System prompt contains all new sections") +except Exception as e: + _fail(f"System prompt: {e}") + + +# ========================================================================= +section("6. CLI") +# ========================================================================= + +rc, out = run_cmd([sys.executable, "cli.py", "--help"]) +if rc == 0: + _pass("CLI --help works") +else: + _fail("CLI --help failed") + +for cmd in ["start", "chat", "status", "doctor"]: + rc, _ = run_cmd([sys.executable, "cli.py", cmd, "--help"]) + if rc == 0: + _pass(f"CLI command '{cmd}' exists") + else: + _fail(f"CLI command '{cmd}' missing") + +for grp in ["cron", "config", "memory"]: + rc, _ = run_cmd([sys.executable, "cli.py", grp, "--help"]) + if rc == 0: + _pass(f"CLI group '{grp}' exists") + else: + _fail(f"CLI group '{grp}' missing") + + +# ========================================================================= +section("7. Heartbeat Parser") +# ========================================================================= + +try: + from heartbeat.heartbeat import HeartbeatRunner + tests = [ + ("Every 30 minutes", "*/30 * * * *"), + ("Every hour", "0 * * * *"), + ("Every 2 hours", "0 */2 * * *"), + ("Every morning (9:00 AM)", "0 9 * * *"), + ("Every evening (6:00 PM)", "0 18 * * *"), + ] + for header, expected in tests: + result = HeartbeatRunner._parse_schedule_header(header) + assert result == expected, f"{header}: got {result}" + _pass(f"Schedule parser: all {len(tests)} patterns correct") +except Exception as e: + _fail(f"Heartbeat parser: {e}") + + +# ========================================================================= +section("8. Hook System") +# ========================================================================= + +try: + from hooks.hooks import HookManager, HookEvent + + mgr = HookManager() + received = [] + mgr.register("gateway:startup", lambda e: received.append(e.event_key)) + mgr.trigger(HookEvent(type="gateway", action="startup")) + assert received == ["gateway:startup"] + _pass("Programmatic hook register + trigger") + + mgr2 = HookManager() + mgr2.register("test:event", lambda e: e.messages.append("hello")) + event = HookEvent(type="test", action="event") + msgs = mgr2.trigger(event) + assert "hello" in msgs + _pass("Hook message passing") + + # File-based hook discovery + with tempfile.TemporaryDirectory() as tmpdir: + hook_dir = os.path.join(tmpdir, "hooks", "test-hook") + os.makedirs(hook_dir) + with open(os.path.join(hook_dir, "HOOK.md"), "w") as f: + f.write("---\nname: test-hook\nevents: [gateway:startup]\n---\n# Test\n") + with open(os.path.join(hook_dir, "handler.py"), "w") as f: + f.write("def handle(event):\n event.messages.append('file-hook-fired')\n") + mgr3 = HookManager(workspace_dir=tmpdir) + hooks = mgr3.discover() + assert len(hooks) == 1 + event3 = HookEvent(type="gateway", action="startup") + msgs3 = mgr3.trigger(event3) + assert "file-hook-fired" in msgs3 + _pass("File-based hook discovery + execution") +except Exception as e: + _fail(f"Hook system: {e}") + + +# ========================================================================= +section("9. Webhook Receiver") +# ========================================================================= + +try: + from webhooks.receiver import WebhookReceiver, WebhookConfig as WHConfig + + config = WHConfig(enabled=True, port=0, host="127.0.0.1", token="test") + receiver = WebhookReceiver( + ai_handler_fn=lambda msg: "ok", + send_fn=lambda *a: None, + config=config, + ) + # Check routes exist + route_info = [str(r) for r in receiver._app.router.routes()] + has_wake = any("wake" in r for r in route_info) + has_agent = any("agent" in r for r in route_info) + has_health = any("health" in r for r in route_info) + assert has_wake and has_agent and has_health + _pass("Webhook routes registered (wake, agent, health)") + + # Auth check + assert receiver._check_auth(type("R", (), {"headers": {"Authorization": "Bearer test"}, "query": {}})()) + assert not receiver._check_auth(type("R", (), {"headers": {}, "query": {}})()) + _pass("Webhook bearer token auth works") +except Exception as e: + _fail(f"Webhook receiver: {e}") + + +# ========================================================================= +section("10. SubagentBus") +# ========================================================================= + +try: + from agent.subagent import SubagentBus, SubagentManager + + bus = SubagentBus() + received = [] + bus.subscribe("ch1", lambda msg, sender: received.append((msg, sender))) + bus.publish("ch1", "hello", "agent-1") + assert received == [("hello", "agent-1")] + _pass("SubagentBus pub/sub") + + mgr = SubagentManager(runtime_factory=lambda: None, send_fn=lambda *a: None) + assert isinstance(mgr.bus, SubagentBus) + _pass("SubagentManager.bus property") +except Exception as e: + _fail(f"SubagentBus: {e}") + + +# ========================================================================= +section("11. WebChat Adapter") +# ========================================================================= + +try: + from adapters.webchat_adapter import WebChatAdapter + from adapters.base import BaseAdapter + + adapter = WebChatAdapter(host="127.0.0.1", port=9999) + assert isinstance(adapter, BaseAdapter) + assert adapter.source_name == "webchat" + _pass("WebChatAdapter extends BaseAdapter, source_name='webchat'") + + assert os.path.isfile(os.path.join("static", "chat.html")) + _pass("static/chat.html exists") +except Exception as e: + _fail(f"WebChat adapter: {e}") + + +# ========================================================================= +section("12. Claude Runtime Config") +# ========================================================================= + +try: + from agent.claude_runtime import ClaudeCodeConfig + + cfg = ClaudeCodeConfig() + assert cfg.no_tools is False + assert len(cfg.allowed_tools) >= 15 + assert "Bash" in cfg.allowed_tools + assert "TeamCreate" in cfg.allowed_tools + assert "SendMessage" in cfg.allowed_tools + _pass(f"ClaudeCodeConfig: no_tools=False, {len(cfg.allowed_tools)} tools, Team tools included") +except Exception as e: + _fail(f"Claude runtime config: {e}") + + +# ========================================================================= +section("13. Module Imports") +# ========================================================================= + +modules = [ + "config", "adapters.base", "adapters.webchat_adapter", + "agent.opencode_runtime", "agent.claude_runtime", "agent.subagent", + "heartbeat.heartbeat", "hooks.hooks", "webhooks.receiver", + "skills.skills", "cli", +] +for mod in modules: + try: + importlib.import_module(mod) + _pass(f"import {mod}") + except Exception as e: + _fail(f"import {mod}: {e}") + + +# ========================================================================= +section("RESULTS") +# ========================================================================= + +total = PASS + FAIL + SKIP +print(f"\nTotal: {total} checks") +print(f" Passed: {PASS}") +print(f" Failed: {FAIL}") +print(f" Skipped: {SKIP}") +print() + +if FAIL == 0: + print("All checks passed! 🎉") + sys.exit(0) +else: + print(f"{FAIL} check(s) failed.") + sys.exit(1) diff --git a/test_all.sh b/test_all.sh new file mode 100644 index 0000000..1d640ad --- /dev/null +++ b/test_all.sh @@ -0,0 +1,424 @@ +#!/usr/bin/env bash +# ============================================================================= +# Aetheel — Full Feature Test Script +# ============================================================================= +# Runs all tests and smoke checks for every Aetheel feature. +# +# Usage: +# chmod +x test_all.sh +# ./test_all.sh +# +# Requirements: +# - uv installed (https://docs.astral.sh/uv/getting-started/installation/) +# - Run from the Aetheel/ directory +# ============================================================================= + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +PASS=0 +FAIL=0 +SKIP=0 + +pass() { ((PASS++)); echo -e " ${GREEN}✅ $1${NC}"; } +fail() { ((FAIL++)); echo -e " ${RED}❌ $1${NC}"; } +skip() { ((SKIP++)); echo -e " ${YELLOW}⚠️ $1${NC}"; } +section() { echo -e "\n${CYAN}${BOLD}━━━ $1 ━━━${NC}"; } + +# ============================================================================= +section "1. Environment Check" +# ============================================================================= + +# Check uv +if command -v uv &>/dev/null; then + UV_VER=$(uv --version 2>&1 | head -1) + pass "uv found: $UV_VER" +else + fail "uv not found — install from https://docs.astral.sh/uv/" + echo " Cannot continue without uv." + exit 1 +fi + +# Check Python +if command -v python &>/dev/null; then + PY_VER=$(python --version 2>&1) + pass "Python found: $PY_VER" +else + fail "Python not found" + exit 1 +fi + +# Check we're in the right directory +if [ ! -f "pyproject.toml" ]; then + fail "pyproject.toml not found — run this script from the Aetheel/ directory" + exit 1 +fi +pass "Running from Aetheel directory" + +# ============================================================================= +section "2. Dependency Installation" +# ============================================================================= + +echo " Installing project + test dependencies..." +if uv sync --extra test --quiet 2>&1; then + pass "uv sync --extra test succeeded" +else + fail "uv sync failed" + echo " Trying pip fallback..." + uv pip install -e ".[test]" --quiet 2>&1 || fail "pip install also failed" +fi + +# Verify critical packages +echo " Verifying installed packages..." +REQUIRED_PKGS=("pytest" "pytest-asyncio" "hypothesis" "click" "aiohttp" "apscheduler" "python-dotenv") +for pkg in "${REQUIRED_PKGS[@]}"; do + if uv pip show "$pkg" &>/dev/null 2>&1; then + pass "$pkg installed" + else + fail "$pkg NOT installed" + fi +done + +# ============================================================================= +section "3. Pytest — Unit Tests" +# ============================================================================= + +echo " Running all unit tests (excluding scheduler if apscheduler import fails)..." +echo "" + +if uv run python -m pytest tests/ -v --tb=short --ignore=tests/test_scheduler.py 2>&1; then + pass "Core test suite passed" +else + fail "Core test suite had failures" +fi + +echo "" +echo " Attempting scheduler tests..." +if uv run python -m pytest tests/test_scheduler.py -v --tb=short 2>&1; then + pass "Scheduler tests passed" +else + skip "Scheduler tests failed (may need apscheduler)" +fi + +# ============================================================================= +section "4. Smoke Tests — Config System" +# ============================================================================= + +echo " Testing config load..." +if uv run python -c " +from config import load_config, AetheelConfig +cfg = load_config() +assert isinstance(cfg, AetheelConfig) +assert cfg.claude.no_tools == False, 'no_tools should default to False' +assert len(cfg.claude.allowed_tools) > 0, 'allowed_tools should have defaults' +assert hasattr(cfg, 'heartbeat'), 'Missing heartbeat config' +assert hasattr(cfg, 'webchat'), 'Missing webchat config' +assert hasattr(cfg, 'mcp'), 'Missing mcp config' +assert hasattr(cfg, 'hooks'), 'Missing hooks config' +assert hasattr(cfg, 'webhooks'), 'Missing webhooks config' +print('Config loaded OK — all sections present') +" 2>&1; then + pass "Config system works" +else + fail "Config system broken" +fi + +# ============================================================================= +section "5. Smoke Tests — System Prompt" +# ============================================================================= + +if uv run python -c " +from agent.opencode_runtime import build_aetheel_system_prompt +prompt = build_aetheel_system_prompt() +sections = ['Your Tools', 'Self-Modification', 'Subagents & Teams'] +for s in sections: + assert s in prompt, f'Missing section: {s}' + print(f' ✅ Section present: {s}') +print('System prompt OK') +" 2>&1; then + pass "System prompt has all new sections" +else + fail "System prompt missing sections" +fi + +# ============================================================================= +section "6. Smoke Tests — CLI" +# ============================================================================= + +if uv run python cli.py --help >/dev/null 2>&1; then + pass "CLI --help works" +else + fail "CLI --help failed" +fi + +# Check subcommands exist +for cmd in start chat status doctor; do + if uv run python cli.py $cmd --help >/dev/null 2>&1; then + pass "CLI command '$cmd' exists" + else + fail "CLI command '$cmd' missing" + fi +done + +for grp in cron config memory; do + if uv run python cli.py $grp --help >/dev/null 2>&1; then + pass "CLI group '$grp' exists" + else + fail "CLI group '$grp' missing" + fi +done + +# ============================================================================= +section "7. Smoke Tests — Heartbeat Parser" +# ============================================================================= + +if uv run python -c " +from heartbeat.heartbeat import HeartbeatRunner +tests = [ + ('Every 30 minutes', '*/30 * * * *'), + ('Every hour', '0 * * * *'), + ('Every 2 hours', '0 */2 * * *'), + ('Every morning (9:00 AM)', '0 9 * * *'), + ('Every evening (6:00 PM)', '0 18 * * *'), +] +for header, expected in tests: + result = HeartbeatRunner._parse_schedule_header(header) + assert result == expected, f'{header}: got {result}, expected {expected}' + print(f' ✅ {header} → {result}') +print('Heartbeat parser OK') +" 2>&1; then + pass "Heartbeat schedule parser works" +else + fail "Heartbeat schedule parser broken" +fi + +# ============================================================================= +section "8. Smoke Tests — Hook System" +# ============================================================================= + +if uv run python -c " +from hooks.hooks import HookManager, HookEvent + +# Test programmatic hooks +mgr = HookManager() +received = [] +mgr.register('gateway:startup', lambda e: received.append(e.event_key)) +mgr.trigger(HookEvent(type='gateway', action='startup')) +assert received == ['gateway:startup'], f'Expected [gateway:startup], got {received}' +print(' ✅ Programmatic hook registration and trigger') + +# Test event key +event = HookEvent(type='command', action='reload') +assert event.event_key == 'command:reload' +print(' ✅ Event key formatting') + +# Test messages +mgr2 = HookManager() +mgr2.register('test:event', lambda e: e.messages.append('hello')) +event2 = HookEvent(type='test', action='event') +msgs = mgr2.trigger(event2) +assert 'hello' in msgs +print(' ✅ Hook message passing') + +print('Hook system OK') +" 2>&1; then + pass "Hook system works" +else + fail "Hook system broken" +fi + +# ============================================================================= +section "9. Smoke Tests — Webhook Receiver" +# ============================================================================= + +if uv run python -c " +from webhooks.receiver import WebhookReceiver, WebhookConfig + +config = WebhookConfig(enabled=True, port=0, host='127.0.0.1', token='test') +receiver = WebhookReceiver( + ai_handler_fn=lambda msg: 'ok', + send_fn=lambda *a: None, + config=config, +) +# Verify routes are registered +routes = [r.resource.canonical for r in receiver._app.router.routes() if hasattr(r, 'resource') and hasattr(r.resource, 'canonical')] +print(f' Routes: {routes}') +assert '/hooks/wake' in routes or any('/hooks/wake' in str(r) for r in receiver._app.router.routes()) +print(' ✅ Webhook routes registered') +print('Webhook receiver OK') +" 2>&1; then + pass "Webhook receiver initializes" +else + fail "Webhook receiver broken" +fi + +# ============================================================================= +section "10. Smoke Tests — SubagentBus" +# ============================================================================= + +if uv run python -c " +from agent.subagent import SubagentBus, SubagentManager + +# Test bus +bus = SubagentBus() +received = [] +bus.subscribe('ch1', lambda msg, sender: received.append((msg, sender))) +bus.publish('ch1', 'hello', 'agent-1') +assert received == [('hello', 'agent-1')] +print(' ✅ SubagentBus pub/sub works') + +# Test manager has bus +mgr = SubagentManager(runtime_factory=lambda: None, send_fn=lambda *a: None) +assert isinstance(mgr.bus, SubagentBus) +print(' ✅ SubagentManager.bus property works') + +print('SubagentBus OK') +" 2>&1; then + pass "SubagentBus works" +else + fail "SubagentBus broken" +fi + +# ============================================================================= +section "11. Smoke Tests — MCP Config Writer" +# ============================================================================= + +if uv run python -c " +import json, os, tempfile +from config import MCPConfig, MCPServerConfig, write_mcp_config + +with tempfile.TemporaryDirectory() as tmpdir: + cfg = MCPConfig(servers={ + 'test-server': MCPServerConfig(command='echo', args=['hello'], env={'KEY': 'val'}) + }) + + # Claude format + write_mcp_config(cfg, tmpdir, use_claude=True) + with open(os.path.join(tmpdir, '.mcp.json')) as f: + data = json.load(f) + assert 'mcpServers' in data + assert 'test-server' in data['mcpServers'] + print(' ✅ Claude .mcp.json written correctly') + + # OpenCode format + write_mcp_config(cfg, tmpdir, use_claude=False) + with open(os.path.join(tmpdir, 'opencode.json')) as f: + data = json.load(f) + assert 'mcp' in data + print(' ✅ OpenCode opencode.json written correctly') + + # Empty config skips + os.remove(os.path.join(tmpdir, '.mcp.json')) + write_mcp_config(MCPConfig(), tmpdir, use_claude=True) + assert not os.path.exists(os.path.join(tmpdir, '.mcp.json')) + print(' ✅ Empty config skips file creation') + +print('MCP config writer OK') +" 2>&1; then + pass "MCP config writer works" +else + fail "MCP config writer broken" +fi + +# ============================================================================= +section "12. Smoke Tests — WebChat Adapter" +# ============================================================================= + +if uv run python -c " +from adapters.webchat_adapter import WebChatAdapter +from adapters.base import BaseAdapter + +adapter = WebChatAdapter(host='127.0.0.1', port=9999) +assert isinstance(adapter, BaseAdapter) +assert adapter.source_name == 'webchat' +print(' ✅ WebChatAdapter extends BaseAdapter') +print(' ✅ source_name is webchat') + +import os +static_dir = os.path.join(os.path.dirname('adapters/webchat_adapter.py'), 'static') +html_path = os.path.join('static', 'chat.html') +assert os.path.isfile(html_path), f'chat.html not found at {html_path}' +print(' ✅ static/chat.html exists') + +print('WebChat adapter OK') +" 2>&1; then + pass "WebChat adapter works" +else + fail "WebChat adapter broken" +fi + +# ============================================================================= +section "13. Smoke Tests — Claude Runtime Config" +# ============================================================================= + +if uv run python -c " +from agent.claude_runtime import ClaudeCodeConfig + +cfg = ClaudeCodeConfig() +assert cfg.no_tools == False, f'no_tools should be False, got {cfg.no_tools}' +assert len(cfg.allowed_tools) > 10, f'Expected 15+ tools, got {len(cfg.allowed_tools)}' +assert 'Bash' in cfg.allowed_tools +assert 'WebSearch' in cfg.allowed_tools +assert 'TeamCreate' in cfg.allowed_tools +assert 'SendMessage' in cfg.allowed_tools +print(f' ✅ no_tools defaults to False') +print(f' ✅ {len(cfg.allowed_tools)} tools in default allowed_tools') +print(f' ✅ Team/Task tools included') +print('Claude runtime config OK') +" 2>&1; then + pass "Claude runtime config correct" +else + fail "Claude runtime config broken" +fi + +# ============================================================================= +section "14. Import Check — All Modules" +# ============================================================================= + +MODULES=( + "config" + "adapters.base" + "adapters.webchat_adapter" + "agent.opencode_runtime" + "agent.claude_runtime" + "agent.subagent" + "heartbeat.heartbeat" + "hooks.hooks" + "webhooks.receiver" + "skills.skills" + "cli" +) + +for mod in "${MODULES[@]}"; do + if uv run python -c "import $mod" 2>/dev/null; then + pass "import $mod" + else + fail "import $mod" + fi +done + +# ============================================================================= +section "RESULTS" +# ============================================================================= + +TOTAL=$((PASS + FAIL + SKIP)) +echo "" +echo -e "${BOLD}Total: $TOTAL checks${NC}" +echo -e " ${GREEN}Passed: $PASS${NC}" +echo -e " ${RED}Failed: $FAIL${NC}" +echo -e " ${YELLOW}Skipped: $SKIP${NC}" +echo "" + +if [ "$FAIL" -eq 0 ]; then + echo -e "${GREEN}${BOLD}All checks passed! 🎉${NC}" + exit 0 +else + echo -e "${RED}${BOLD}$FAIL check(s) failed.${NC}" + exit 1 +fi diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..d3dd2cb --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,172 @@ +""" +Tests for the Hook system (HookManager, HookEvent). +""" + +import os + +import pytest + +from hooks.hooks import HookEvent, HookEntry, HookManager + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def workspace(tmp_path): + return str(tmp_path) + + +@pytest.fixture +def manager(workspace): + return HookManager(workspace_dir=workspace) + + +def _create_hook(workspace, name, events, enabled=True, handler_code=None): + """Helper to create a hook directory with HOOK.md and optional handler.py.""" + hook_dir = os.path.join(workspace, "hooks", name) + os.makedirs(hook_dir, exist_ok=True) + + enabled_str = "true" if enabled else "false" + events_str = "[" + ", ".join(events) + "]" + hook_md = f"""--- +name: {name} +description: Test hook {name} +events: {events_str} +enabled: {enabled_str} +--- +# {name} +Test hook documentation. +""" + with open(os.path.join(hook_dir, "HOOK.md"), "w") as f: + f.write(hook_md) + + if handler_code: + with open(os.path.join(hook_dir, "handler.py"), "w") as f: + f.write(handler_code) + + +# --------------------------------------------------------------------------- +# Tests: HookEvent +# --------------------------------------------------------------------------- + + +class TestHookEvent: + def test_event_key(self): + event = HookEvent(type="gateway", action="startup") + assert event.event_key == "gateway:startup" + + def test_messages_default_empty(self): + event = HookEvent(type="command", action="new") + assert event.messages == [] + + +# --------------------------------------------------------------------------- +# Tests: HookManager discovery +# --------------------------------------------------------------------------- + + +class TestHookManagerDiscovery: + def test_discover_empty_workspace(self, manager): + hooks = manager.discover() + assert hooks == [] + + def test_discover_finds_hooks(self, workspace, manager): + _create_hook(workspace, "my-hook", ["gateway:startup"]) + hooks = manager.discover() + assert len(hooks) == 1 + assert hooks[0].name == "my-hook" + assert hooks[0].events == ["gateway:startup"] + + def test_discover_skips_disabled(self, workspace, manager): + _create_hook(workspace, "disabled-hook", ["gateway:startup"], enabled=False) + hooks = manager.discover() + assert len(hooks) == 0 + + def test_discover_multiple_hooks(self, workspace, manager): + _create_hook(workspace, "hook-a", ["gateway:startup"]) + _create_hook(workspace, "hook-b", ["command:new"]) + hooks = manager.discover() + assert len(hooks) == 2 + + def test_discover_multiple_events(self, workspace, manager): + _create_hook(workspace, "multi", ["gateway:startup", "command:new"]) + hooks = manager.discover() + assert hooks[0].events == ["gateway:startup", "command:new"] + + +# --------------------------------------------------------------------------- +# Tests: HookManager trigger +# --------------------------------------------------------------------------- + + +class TestHookManagerTrigger: + def test_trigger_calls_handler(self, workspace, manager): + handler_code = """ +results = [] +def handle(event): + results.append(event.event_key) +""" + _create_hook(workspace, "test-hook", ["gateway:startup"], handler_code=handler_code) + manager.discover() + event = HookEvent(type="gateway", action="startup") + manager.trigger(event) + # Handler was called without error — that's the test + + def test_trigger_no_matching_hooks(self, manager): + event = HookEvent(type="gateway", action="startup") + messages = manager.trigger(event) + assert messages == [] + + def test_trigger_handler_error_doesnt_crash(self, workspace, manager): + handler_code = """ +def handle(event): + raise RuntimeError("boom") +""" + _create_hook(workspace, "bad-hook", ["gateway:startup"], handler_code=handler_code) + manager.discover() + event = HookEvent(type="gateway", action="startup") + # Should not raise + manager.trigger(event) + + def test_trigger_messages(self, workspace, manager): + handler_code = """ +def handle(event): + event.messages.append("hello from hook") +""" + _create_hook(workspace, "msg-hook", ["gateway:startup"], handler_code=handler_code) + manager.discover() + event = HookEvent(type="gateway", action="startup") + messages = manager.trigger(event) + assert "hello from hook" in messages + + +# --------------------------------------------------------------------------- +# Tests: Programmatic hooks +# --------------------------------------------------------------------------- + + +class TestProgrammaticHooks: + def test_register_and_trigger(self, manager): + received = [] + manager.register("gateway:startup", lambda e: received.append(e.event_key)) + event = HookEvent(type="gateway", action="startup") + manager.trigger(event) + assert received == ["gateway:startup"] + + def test_unregister(self, manager): + received = [] + handler = lambda e: received.append(e.event_key) + manager.register("gateway:startup", handler) + manager.unregister("gateway:startup", handler) + manager.trigger(HookEvent(type="gateway", action="startup")) + assert received == [] + + def test_type_level_handler(self, manager): + received = [] + manager.register("gateway", lambda e: received.append(e.action)) + manager.trigger(HookEvent(type="gateway", action="startup")) + manager.trigger(HookEvent(type="gateway", action="shutdown")) + assert received == ["startup", "shutdown"] diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py new file mode 100644 index 0000000..d78534d --- /dev/null +++ b/tests/test_mcp_config.py @@ -0,0 +1,134 @@ +""" +Tests for the MCP config writer utility (write_mcp_config). +""" + +import json +import os + +import pytest + +from config import MCPConfig, MCPServerConfig, write_mcp_config + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def workspace(tmp_path): + """Return a temporary workspace directory path.""" + return str(tmp_path) + + +@pytest.fixture +def sample_mcp_config(): + """Return an MCPConfig with one server entry.""" + return MCPConfig( + servers={ + "my-tool": MCPServerConfig( + command="npx", + args=["-y", "@my/tool"], + env={"API_KEY": "test123"}, + ) + } + ) + + +# --------------------------------------------------------------------------- +# Tests: write_mcp_config +# --------------------------------------------------------------------------- + + +class TestWriteMcpConfig: + def test_skips_when_no_servers(self, workspace): + """Empty servers dict should not create any file.""" + write_mcp_config(MCPConfig(), workspace, use_claude=True) + assert not os.path.exists(os.path.join(workspace, ".mcp.json")) + assert not os.path.exists(os.path.join(workspace, "opencode.json")) + + def test_writes_mcp_json_for_claude(self, workspace, sample_mcp_config): + """Claude runtime should produce .mcp.json with 'mcpServers' key.""" + write_mcp_config(sample_mcp_config, workspace, use_claude=True) + path = os.path.join(workspace, ".mcp.json") + assert os.path.isfile(path) + + with open(path, encoding="utf-8") as f: + data = json.load(f) + + assert "mcpServers" in data + assert "my-tool" in data["mcpServers"] + srv = data["mcpServers"]["my-tool"] + assert srv["command"] == "npx" + assert srv["args"] == ["-y", "@my/tool"] + assert srv["env"] == {"API_KEY": "test123"} + + def test_writes_opencode_json_for_opencode(self, workspace, sample_mcp_config): + """OpenCode runtime should produce opencode.json with 'mcp' key.""" + write_mcp_config(sample_mcp_config, workspace, use_claude=False) + path = os.path.join(workspace, "opencode.json") + assert os.path.isfile(path) + + with open(path, encoding="utf-8") as f: + data = json.load(f) + + assert "mcp" in data + assert "my-tool" in data["mcp"] + + def test_multiple_servers(self, workspace): + """Multiple server entries should all appear in the output.""" + cfg = MCPConfig( + servers={ + "server-a": MCPServerConfig(command="cmd-a", args=["--flag"]), + "server-b": MCPServerConfig(command="cmd-b", env={"X": "1"}), + } + ) + write_mcp_config(cfg, workspace, use_claude=True) + + with open(os.path.join(workspace, ".mcp.json"), encoding="utf-8") as f: + data = json.load(f) + + assert len(data["mcpServers"]) == 2 + assert "server-a" in data["mcpServers"] + assert "server-b" in data["mcpServers"] + + def test_creates_parent_directories(self, tmp_path): + """Should create intermediate directories if workspace doesn't exist.""" + nested = str(tmp_path / "deep" / "nested" / "workspace") + cfg = MCPConfig( + servers={"s": MCPServerConfig(command="echo")} + ) + write_mcp_config(cfg, nested, use_claude=True) + assert os.path.isfile(os.path.join(nested, ".mcp.json")) + + def test_produces_valid_json(self, workspace, sample_mcp_config): + """Output file must be valid, parseable JSON.""" + write_mcp_config(sample_mcp_config, workspace, use_claude=True) + path = os.path.join(workspace, ".mcp.json") + with open(path, encoding="utf-8") as f: + data = json.load(f) # Would raise on invalid JSON + assert isinstance(data, dict) + + def test_overwrites_existing_file(self, workspace): + """Writing twice should overwrite the previous config.""" + cfg1 = MCPConfig(servers={"old": MCPServerConfig(command="old-cmd")}) + cfg2 = MCPConfig(servers={"new": MCPServerConfig(command="new-cmd")}) + + write_mcp_config(cfg1, workspace, use_claude=True) + write_mcp_config(cfg2, workspace, use_claude=True) + + with open(os.path.join(workspace, ".mcp.json"), encoding="utf-8") as f: + data = json.load(f) + + assert "new" in data["mcpServers"] + assert "old" not in data["mcpServers"] + + def test_tilde_expansion(self, monkeypatch, tmp_path): + """Workspace path with ~ should be expanded.""" + monkeypatch.setenv("HOME", str(tmp_path)) + # Also handle Windows + monkeypatch.setattr(os.path, "expanduser", lambda p: p.replace("~", str(tmp_path))) + + cfg = MCPConfig(servers={"s": MCPServerConfig(command="echo")}) + write_mcp_config(cfg, "~/my-workspace", use_claude=False) + assert os.path.isfile(os.path.join(str(tmp_path), "my-workspace", "opencode.json")) diff --git a/tests/test_subagent_bus.py b/tests/test_subagent_bus.py new file mode 100644 index 0000000..7cec1db --- /dev/null +++ b/tests/test_subagent_bus.py @@ -0,0 +1,122 @@ +""" +Tests for SubagentBus pub/sub message bus. +""" + +import threading + +import pytest + +from agent.subagent import SubagentBus, SubagentManager + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def bus(): + return SubagentBus() + + +# --------------------------------------------------------------------------- +# Tests: SubagentBus +# --------------------------------------------------------------------------- + + +class TestSubagentBus: + def test_subscribe_and_publish(self, bus): + received = [] + bus.subscribe("ch1", lambda msg, sender: received.append((msg, sender))) + bus.publish("ch1", "hello", "agent-1") + assert received == [("hello", "agent-1")] + + def test_multiple_subscribers(self, bus): + received = [] + bus.subscribe("ch1", lambda msg, sender: received.append(("a", msg))) + bus.subscribe("ch1", lambda msg, sender: received.append(("b", msg))) + bus.publish("ch1", "ping", "sender") + assert len(received) == 2 + assert ("a", "ping") in received + assert ("b", "ping") in received + + def test_publish_to_empty_channel(self, bus): + # Should not raise + bus.publish("nonexistent", "msg", "sender") + + def test_unsubscribe_all(self, bus): + received = [] + bus.subscribe("ch1", lambda msg, sender: received.append(msg)) + bus.unsubscribe_all("ch1") + bus.publish("ch1", "hello", "sender") + assert received == [] + + def test_unsubscribe_all_nonexistent_channel(self, bus): + # Should not raise + bus.unsubscribe_all("nonexistent") + + def test_channels_are_isolated(self, bus): + received_a = [] + received_b = [] + bus.subscribe("a", lambda msg, sender: received_a.append(msg)) + bus.subscribe("b", lambda msg, sender: received_b.append(msg)) + bus.publish("a", "only-a", "sender") + assert received_a == ["only-a"] + assert received_b == [] + + def test_callback_error_does_not_stop_others(self, bus): + received = [] + + def bad_cb(msg, sender): + raise RuntimeError("boom") + + bus.subscribe("ch1", bad_cb) + bus.subscribe("ch1", lambda msg, sender: received.append(msg)) + bus.publish("ch1", "hello", "sender") + # Second callback should still fire + assert received == ["hello"] + + def test_thread_safety(self, bus): + """Concurrent subscribes and publishes should not crash.""" + results = [] + barrier = threading.Barrier(4) + + def subscriber(): + barrier.wait() + bus.subscribe("stress", lambda msg, sender: results.append(msg)) + + def publisher(): + barrier.wait() + bus.publish("stress", "msg", "sender") + + threads = [ + threading.Thread(target=subscriber), + threading.Thread(target=subscriber), + threading.Thread(target=publisher), + threading.Thread(target=publisher), + ] + for t in threads: + t.start() + for t in threads: + t.join(timeout=5) + + +# --------------------------------------------------------------------------- +# Tests: SubagentManager.bus property +# --------------------------------------------------------------------------- + + +class TestSubagentManagerBus: + def test_manager_has_bus(self): + mgr = SubagentManager( + runtime_factory=lambda: None, + send_fn=lambda *a: None, + ) + assert isinstance(mgr.bus, SubagentBus) + + def test_bus_is_same_instance(self): + mgr = SubagentManager( + runtime_factory=lambda: None, + send_fn=lambda *a: None, + ) + assert mgr.bus is mgr.bus diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..be240cc --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,171 @@ +""" +Tests for the Webhook receiver. +""" + +import json + +import pytest + +from aiohttp.test_utils import TestClient, TestServer + +from webhooks.receiver import WebhookReceiver, WebhookConfig + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_handler(response_text="AI response"): + def handler(msg): + return response_text + return handler + + +def _make_send_fn(): + calls = [] + def send(channel_id, text, thread_id, channel_type): + calls.append({"channel_id": channel_id, "text": text, "channel_type": channel_type}) + send.calls = calls + return send + + +def _make_receiver(token="", handler=None, send_fn=None): + config = WebhookConfig(enabled=True, port=0, host="127.0.0.1", token=token) + return WebhookReceiver( + ai_handler_fn=handler or _make_handler(), + send_fn=send_fn or _make_send_fn(), + config=config, + ) + + +# --------------------------------------------------------------------------- +# Tests: Health endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_health_endpoint(): + receiver = _make_receiver() + async with TestClient(TestServer(receiver._app)) as client: + resp = await client.get("/hooks/health") + assert resp.status == 200 + data = await resp.json() + assert data["status"] == "ok" + + +# --------------------------------------------------------------------------- +# Tests: Wake endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_wake_no_auth(): + receiver = _make_receiver() + async with TestClient(TestServer(receiver._app)) as client: + resp = await client.post("/hooks/wake", json={"text": "hello"}) + assert resp.status == 200 + data = await resp.json() + assert data["response"] == "AI response" + + +@pytest.mark.asyncio +async def test_wake_auth_required(): + receiver = _make_receiver(token="secret") + async with TestClient(TestServer(receiver._app)) as client: + # No auth → 401 + resp = await client.post("/hooks/wake", json={"text": "hello"}) + assert resp.status == 401 + + # With auth → 200 + resp = await client.post( + "/hooks/wake", + json={"text": "hello"}, + headers={"Authorization": "Bearer secret"}, + ) + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_wake_empty_text(): + receiver = _make_receiver() + async with TestClient(TestServer(receiver._app)) as client: + resp = await client.post("/hooks/wake", json={"text": ""}) + assert resp.status == 400 + + +@pytest.mark.asyncio +async def test_wake_invalid_json(): + receiver = _make_receiver() + async with TestClient(TestServer(receiver._app)) as client: + resp = await client.post( + "/hooks/wake", + data="not json", + headers={"Content-Type": "application/json"}, + ) + assert resp.status == 400 + + +@pytest.mark.asyncio +async def test_wake_delivers_to_channel(): + send_fn = _make_send_fn() + receiver = _make_receiver(handler=_make_handler("response"), send_fn=send_fn) + async with TestClient(TestServer(receiver._app)) as client: + resp = await client.post("/hooks/wake", json={ + "text": "hello", + "channel": "slack", + "channel_id": "C123", + }) + assert resp.status == 200 + assert len(send_fn.calls) == 1 + assert send_fn.calls[0]["channel_id"] == "C123" + + +# --------------------------------------------------------------------------- +# Tests: Agent endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_agent_endpoint(): + send_fn = _make_send_fn() + receiver = _make_receiver(send_fn=send_fn) + async with TestClient(TestServer(receiver._app)) as client: + resp = await client.post("/hooks/agent", json={ + "message": "research something", + "channel": "slack", + "channel_id": "C456", + }) + assert resp.status == 200 + assert len(send_fn.calls) == 1 + + +@pytest.mark.asyncio +async def test_agent_empty_message(): + receiver = _make_receiver() + async with TestClient(TestServer(receiver._app)) as client: + resp = await client.post("/hooks/agent", json={"message": ""}) + assert resp.status == 400 + + +@pytest.mark.asyncio +async def test_agent_auth_bearer(): + receiver = _make_receiver(token="secret") + async with TestClient(TestServer(receiver._app)) as client: + resp = await client.post( + "/hooks/agent", + json={"message": "test"}, + headers={"Authorization": "Bearer secret"}, + ) + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_agent_auth_query_param(): + receiver = _make_receiver(token="secret") + async with TestClient(TestServer(receiver._app)) as client: + resp = await client.post( + "/hooks/agent?token=secret", + json={"message": "test"}, + ) + assert resp.status == 200 diff --git a/uv.lock b/uv.lock index 7f4abdc..f41661e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,13 +1,21 @@ version = 1 revision = 3 -requires-python = ">=3.14" +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] [[package]] name = "aetheel" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "aiohttp" }, { name = "apscheduler" }, + { name = "click" }, + { name = "discord-py" }, { name = "fastembed" }, { name = "python-dotenv" }, { name = "python-telegram-bot" }, @@ -18,14 +26,19 @@ dependencies = [ [package.optional-dependencies] test = [ + { name = "hypothesis" }, { name = "pytest" }, { name = "pytest-asyncio" }, ] [package.metadata] requires-dist = [ + { name = "aiohttp", specifier = ">=3.9.0" }, { name = "apscheduler", specifier = ">=3.10.0,<4.0.0" }, + { name = "click", specifier = ">=8.1.0" }, + { name = "discord-py", specifier = ">=2.4.0" }, { name = "fastembed", specifier = ">=0.7.4" }, + { name = "hypothesis", marker = "extra == 'test'", specifier = ">=6.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.24" }, { name = "python-dotenv", specifier = ">=1.2.1,<2.0.0" }, @@ -36,6 +49,113 @@ requires-dist = [ ] provides-extras = ["test"] +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -51,6 +171,7 @@ version = "4.12.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/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -69,6 +190,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "audioop-lts" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, + { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, + { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -84,6 +270,38 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, @@ -124,6 +342,19 @@ 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 = "discord-py" +version = "2.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/9b1dbb9b2fc07616132a526c05af23cfd420381793968a189ee08e12e35f/discord_py-2.6.4.tar.gz", hash = "sha256:44384920bae9b7a073df64ae9b14c8cf85f9274b5ad5d1d07bd5a67539de2da9", size = 1092623, upload-time = "2025-10-08T21:45:43.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/ae/3d3a89b06f005dc5fa8618528dde519b3ba7775c365750f7932b9831ef05/discord_py-2.6.4-py3-none-any.whl", hash = "sha256:2783b7fb7f8affa26847bfc025144652c294e8fe6e0f8877c67ed895749eb227", size = 1209284, upload-time = "2025-10-08T21:45:41.679Z" }, +] + [[package]] name = "fastembed" version = "0.7.4" @@ -162,6 +393,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "fsspec" version = "2026.2.0" @@ -186,6 +506,13 @@ version = "1.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" }, + { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" }, { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" }, { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" }, { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" }, @@ -251,6 +578,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/ae/2f6d96b4e6c5478d87d606a1934b5d436c4a2bce6bb7c6fdece891c128e3/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18", size = 553326, upload-time = "2026-02-06T09:20:00.728Z" }, ] +[[package]] +name = "hypothesis" +version = "6.151.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -309,6 +648,43 @@ version = "5.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a7/af/f28c2c2f51f31abb4725f9a64bc7863d5f491f6539bd26aee2a1d21a649e/mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8", size = 33582, upload-time = "2025-07-29T07:43:48.49Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/6a/d5aa7edb5c08e0bd24286c7d08341a0446f9a2fbbb97d96a8a6dd81935ee/mmh3-5.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:384eda9361a7bf83a85e09447e1feafe081034af9dd428893701b959230d84be", size = 56141, upload-time = "2025-07-29T07:42:13.456Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/131d0fae6447bc4a7299ebdb1a6fb9d08c9f8dcf97d75ea93e8152ddf7ab/mmh3-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c9da0d568569cc87315cb063486d761e38458b8ad513fedd3dc9263e1b81bcd", size = 40681, upload-time = "2025-07-29T07:42:14.306Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6f/9221445a6bcc962b7f5ff3ba18ad55bba624bacdc7aa3fc0a518db7da8ec/mmh3-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86d1be5d63232e6eb93c50881aea55ff06eb86d8e08f9b5417c8c9b10db9db96", size = 40062, upload-time = "2025-07-29T07:42:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d4/6bb2d0fef81401e0bb4c297d1eb568b767de4ce6fc00890bc14d7b51ecc4/mmh3-5.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf7bee43e17e81671c447e9c83499f53d99bf440bc6d9dc26a841e21acfbe094", size = 97333, upload-time = "2025-07-29T07:42:16.436Z" }, + { url = "https://files.pythonhosted.org/packages/44/e0/ccf0daff8134efbb4fbc10a945ab53302e358c4b016ada9bf97a6bdd50c1/mmh3-5.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7aa18cdb58983ee660c9c400b46272e14fa253c675ed963d3812487f8ca42037", size = 103310, upload-time = "2025-07-29T07:42:17.796Z" }, + { url = "https://files.pythonhosted.org/packages/02/63/1965cb08a46533faca0e420e06aff8bbaf9690a6f0ac6ae6e5b2e4544687/mmh3-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9d032488fcec32d22be6542d1a836f00247f40f320844dbb361393b5b22773", size = 106178, upload-time = "2025-07-29T07:42:19.281Z" }, + { url = "https://files.pythonhosted.org/packages/c2/41/c883ad8e2c234013f27f92061200afc11554ea55edd1bcf5e1accd803a85/mmh3-5.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1861fb6b1d0453ed7293200139c0a9011eeb1376632e048e3766945b13313c5", size = 113035, upload-time = "2025-07-29T07:42:20.356Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/1ccade8b1fa625d634a18bab7bf08a87457e09d5ec8cf83ca07cbea9d400/mmh3-5.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99bb6a4d809aa4e528ddfe2c85dd5239b78b9dd14be62cca0329db78505e7b50", size = 120784, upload-time = "2025-07-29T07:42:21.377Z" }, + { url = "https://files.pythonhosted.org/packages/77/1c/919d9171fcbdcdab242e06394464ccf546f7d0f3b31e0d1e3a630398782e/mmh3-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f8d8b627799f4e2fcc7c034fed8f5f24dc7724ff52f69838a3d6d15f1ad4765", size = 99137, upload-time = "2025-07-29T07:42:22.344Z" }, + { url = "https://files.pythonhosted.org/packages/66/8a/1eebef5bd6633d36281d9fc83cf2e9ba1ba0e1a77dff92aacab83001cee4/mmh3-5.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5995088dd7023d2d9f310a0c67de5a2b2e06a570ecfd00f9ff4ab94a67cde43", size = 98664, upload-time = "2025-07-29T07:42:23.269Z" }, + { url = "https://files.pythonhosted.org/packages/13/41/a5d981563e2ee682b21fb65e29cc0f517a6734a02b581359edd67f9d0360/mmh3-5.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1a5f4d2e59d6bba8ef01b013c472741835ad961e7c28f50c82b27c57748744a4", size = 106459, upload-time = "2025-07-29T07:42:24.238Z" }, + { url = "https://files.pythonhosted.org/packages/24/31/342494cd6ab792d81e083680875a2c50fa0c5df475ebf0b67784f13e4647/mmh3-5.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fd6e6c3d90660d085f7e73710eab6f5545d4854b81b0135a3526e797009dbda3", size = 110038, upload-time = "2025-07-29T07:42:25.629Z" }, + { url = "https://files.pythonhosted.org/packages/28/44/efda282170a46bb4f19c3e2b90536513b1d821c414c28469a227ca5a1789/mmh3-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4a2f3d83879e3de2eb8cbf562e71563a8ed15ee9b9c2e77ca5d9f73072ac15c", size = 97545, upload-time = "2025-07-29T07:42:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/534ae319c6e05d714f437e7206f78c17e66daca88164dff70286b0e8ea0c/mmh3-5.2.0-cp312-cp312-win32.whl", hash = "sha256:2421b9d665a0b1ad724ec7332fb5a98d075f50bc51a6ff854f3a1882bd650d49", size = 40805, upload-time = "2025-07-29T07:42:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f6/f6abdcfefcedab3c964868048cfe472764ed358c2bf6819a70dd4ed4ed3a/mmh3-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d80005b7634a3a2220f81fbeb94775ebd12794623bb2e1451701ea732b4aa3", size = 41597, upload-time = "2025-07-29T07:42:28.894Z" }, + { url = "https://files.pythonhosted.org/packages/15/fd/f7420e8cbce45c259c770cac5718badf907b302d3a99ec587ba5ce030237/mmh3-5.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:3d6bfd9662a20c054bc216f861fa330c2dac7c81e7fb8307b5e32ab5b9b4d2e0", size = 39350, upload-time = "2025-07-29T07:42:29.794Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/27f6ab93995ef6ad9f940e96593c5dd24744d61a7389532b0fec03745607/mmh3-5.2.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e79c00eba78f7258e5b354eccd4d7907d60317ced924ea4a5f2e9d83f5453065", size = 40874, upload-time = "2025-07-29T07:42:30.662Z" }, + { url = "https://files.pythonhosted.org/packages/11/9c/03d13bcb6a03438bc8cac3d2e50f80908d159b31a4367c2e1a7a077ded32/mmh3-5.2.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:956127e663d05edbeec54df38885d943dfa27406594c411139690485128525de", size = 42012, upload-time = "2025-07-29T07:42:31.539Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/0865d9765408a7d504f1789944e678f74e0888b96a766d578cb80b040999/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c3dca4cb5b946ee91b3d6bb700d137b1cd85c20827f89fdf9c16258253489044", size = 39197, upload-time = "2025-07-29T07:42:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/3e/12/76c3207bd186f98b908b6706c2317abb73756d23a4e68ea2bc94825b9015/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e651e17bfde5840e9e4174b01e9e080ce49277b70d424308b36a7969d0d1af73", size = 39840, upload-time = "2025-07-29T07:42:33.227Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0d/574b6cce5555c9f2b31ea189ad44986755eb14e8862db28c8b834b8b64dc/mmh3-5.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:9f64bf06f4bf623325fda3a6d02d36cd69199b9ace99b04bb2d7fd9f89688504", size = 40644, upload-time = "2025-07-29T07:42:34.099Z" }, + { url = "https://files.pythonhosted.org/packages/52/82/3731f8640b79c46707f53ed72034a58baad400be908c87b0088f1f89f986/mmh3-5.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ddc63328889bcaee77b743309e5c7d2d52cee0d7d577837c91b6e7cc9e755e0b", size = 56153, upload-time = "2025-07-29T07:42:35.031Z" }, + { url = "https://files.pythonhosted.org/packages/4f/34/e02dca1d4727fd9fdeaff9e2ad6983e1552804ce1d92cc796e5b052159bb/mmh3-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb0fdc451fb6d86d81ab8f23d881b8d6e37fc373a2deae1c02d27002d2ad7a05", size = 40684, upload-time = "2025-07-29T07:42:35.914Z" }, + { url = "https://files.pythonhosted.org/packages/8f/36/3dee40767356e104967e6ed6d102ba47b0b1ce2a89432239b95a94de1b89/mmh3-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b29044e1ffdb84fe164d0a7ea05c7316afea93c00f8ed9449cf357c36fc4f814", size = 40057, upload-time = "2025-07-29T07:42:36.755Z" }, + { url = "https://files.pythonhosted.org/packages/31/58/228c402fccf76eb39a0a01b8fc470fecf21965584e66453b477050ee0e99/mmh3-5.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58981d6ea9646dbbf9e59a30890cbf9f610df0e4a57dbfe09215116fd90b0093", size = 97344, upload-time = "2025-07-29T07:42:37.675Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/fc5ce89006389a6426ef28e326fc065b0fbaaed230373b62d14c889f47ea/mmh3-5.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e5634565367b6d98dc4aa2983703526ef556b3688ba3065edb4b9b90ede1c54", size = 103325, upload-time = "2025-07-29T07:42:38.591Z" }, + { url = "https://files.pythonhosted.org/packages/09/8c/261e85777c6aee1ebd53f2f17e210e7481d5b0846cd0b4a5c45f1e3761b8/mmh3-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0271ac12415afd3171ab9a3c7cbfc71dee2c68760a7dc9d05bf8ed6ddfa3a7a", size = 106240, upload-time = "2025-07-29T07:42:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/70/73/2f76b3ad8a3d431824e9934403df36c0ddacc7831acf82114bce3c4309c8/mmh3-5.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:45b590e31bc552c6f8e2150ff1ad0c28dd151e9f87589e7eaf508fbdd8e8e908", size = 113060, upload-time = "2025-07-29T07:42:40.585Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/7ea61a34e90e50a79a9d87aa1c0b8139a7eaf4125782b34b7d7383472633/mmh3-5.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bdde97310d59604f2a9119322f61b31546748499a21b44f6715e8ced9308a6c5", size = 120781, upload-time = "2025-07-29T07:42:41.618Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5b/ae1a717db98c7894a37aeedbd94b3f99e6472a836488f36b6849d003485b/mmh3-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc9c5f280438cf1c1a8f9abb87dc8ce9630a964120cfb5dd50d1e7ce79690c7a", size = 99174, upload-time = "2025-07-29T07:42:42.587Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/000cce1d799fceebb6d4487ae29175dd8e81b48e314cba7b4da90bcf55d7/mmh3-5.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c903e71fd8debb35ad2a4184c1316b3cb22f64ce517b4e6747f25b0a34e41266", size = 98734, upload-time = "2025-07-29T07:42:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/79/19/0dc364391a792b72fbb22becfdeacc5add85cc043cd16986e82152141883/mmh3-5.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:eed4bba7ff8a0d37106ba931ab03bdd3915fbb025bcf4e1f0aa02bc8114960c5", size = 106493, upload-time = "2025-07-29T07:42:45.07Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b1/bc8c28e4d6e807bbb051fefe78e1156d7f104b89948742ad310612ce240d/mmh3-5.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1fdb36b940e9261aff0b5177c5b74a36936b902f473180f6c15bde26143681a9", size = 110089, upload-time = "2025-07-29T07:42:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a2/d20f3f5c95e9c511806686c70d0a15479cc3941c5f322061697af1c1ff70/mmh3-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7303aab41e97adcf010a09efd8f1403e719e59b7705d5e3cfed3dd7571589290", size = 97571, upload-time = "2025-07-29T07:42:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/7b/23/665296fce4f33488deec39a750ffd245cfc07aafb0e3ef37835f91775d14/mmh3-5.2.0-cp313-cp313-win32.whl", hash = "sha256:03e08c6ebaf666ec1e3d6ea657a2d363bb01effd1a9acfe41f9197decaef0051", size = 40806, upload-time = "2025-07-29T07:42:48.166Z" }, + { url = "https://files.pythonhosted.org/packages/59/b0/92e7103f3b20646e255b699e2d0327ce53a3f250e44367a99dc8be0b7c7a/mmh3-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:7fddccd4113e7b736706e17a239a696332360cbaddf25ae75b57ba1acce65081", size = 41600, upload-time = "2025-07-29T07:42:49.371Z" }, + { url = "https://files.pythonhosted.org/packages/99/22/0b2bd679a84574647de538c5b07ccaa435dbccc37815067fe15b90fe8dad/mmh3-5.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa0c966ee727aad5406d516375593c5f058c766b21236ab8985693934bb5085b", size = 39349, upload-time = "2025-07-29T07:42:50.268Z" }, { url = "https://files.pythonhosted.org/packages/f7/ca/a20db059a8a47048aaf550da14a145b56e9c7386fb8280d3ce2962dcebf7/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5015f0bb6eb50008bed2d4b1ce0f2a294698a926111e4bb202c0987b4f89078", size = 39209, upload-time = "2025-07-29T07:42:51.559Z" }, { url = "https://files.pythonhosted.org/packages/98/dd/e5094799d55c7482d814b979a0fd608027d0af1b274bfb4c3ea3e950bfd5/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0f3ed828d709f5b82d8bfe14f8856120718ec4bd44a5b26102c3030a1e12501", size = 39843, upload-time = "2025-07-29T07:42:52.536Z" }, { url = "https://files.pythonhosted.org/packages/f4/6b/7844d7f832c85400e7cc89a1348e4e1fdd38c5a38415bb5726bbb8fcdb6c/mmh3-5.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:f35727c5118aba95f0397e18a1a5b8405425581bfe53e821f0fb444cbdc2bc9b", size = 40648, upload-time = "2025-07-29T07:42:53.392Z" }, @@ -355,12 +731,143 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "numpy" version = "2.4.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, @@ -396,6 +903,16 @@ dependencies = [ { name = "sympy" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/30/437de870e4e1c6d237a2ca5e11f54153531270cb5c745c475d6e3d5c5dcf/onnxruntime-1.24.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7307aab9e2e879c0171f37e0eb2808a5b4aec7ba899bb17c5f0cedfc301a8ac2", size = 17211043, upload-time = "2026-02-05T17:32:16.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/60/004401cd86525101ad8aa9eec301327426555d7a77fac89fd991c3c7aae6/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:780add442ce2d4175fafb6f3102cdc94243acffa3ab16eacc03dd627cc7b1b54", size = 15016224, upload-time = "2026-02-05T17:30:56.791Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a1/43ad01b806a1821d1d6f98725edffcdbad54856775643718e9124a09bfbe/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6119526eda12613f0d0498e2ae59563c247c370c9cef74c2fc93133dde157", size = 17098191, upload-time = "2026-02-05T17:31:31.87Z" }, + { url = "https://files.pythonhosted.org/packages/ff/37/5beb65270864037d5c8fb25cfe6b23c48b618d1f4d06022d425cbf29bd9c/onnxruntime-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:df0af2f1cfcfff9094971c7eb1d1dfae7ccf81af197493c4dc4643e4342c0946", size = 12493108, upload-time = "2026-02-05T17:32:07.076Z" }, + { url = "https://files.pythonhosted.org/packages/95/77/7172ecfcbdabd92f338e694f38c325f6fab29a38fa0a8c3d1c85b9f4617c/onnxruntime-1.24.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:82e367770e8fba8a87ba9f4c04bb527e6d4d7204540f1390f202c27a3b759fb4", size = 17211381, upload-time = "2026-02-05T17:31:09.601Z" }, + { url = "https://files.pythonhosted.org/packages/79/5b/532a0d75b93bbd0da0e108b986097ebe164b84fbecfdf2ddbf7c8a3a2e83/onnxruntime-1.24.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1099f3629832580fedf415cfce2462a56cc9ca2b560d6300c24558e2ac049134", size = 15016000, upload-time = "2026-02-05T17:31:00.116Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b5/40606c7bce0702975a077bc6668cd072cd77695fc5c0b3fcf59bdb1fe65e/onnxruntime-1.24.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6361dda4270f3939a625670bd67ae0982a49b7f923207450e28433abc9c3a83b", size = 17097637, upload-time = "2026-02-05T17:31:34.787Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/9e8f7933796b466241b934585723c700d8fb6bde2de856e65335193d7c93/onnxruntime-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:bd1e4aefe73b6b99aa303cd72562ab6de3cccb09088100f8ad1c974be13079c7", size = 12492467, upload-time = "2026-02-05T17:32:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8a/ee07d86e35035f9fed42497af76435f5a613d4e8b6c537ea0f8ef9fa85da/onnxruntime-1.24.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88a2b54dca00c90fca6303eedf13d49b5b4191d031372c2e85f5cffe4d86b79e", size = 15025407, upload-time = "2026-02-05T17:31:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9e/ab3e1dda4b126313d240e1aaa87792ddb1f5ba6d03ca2f093a7c4af8c323/onnxruntime-1.24.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dfbba602da840615ed5b431facda4b3a43b5d8276cf9e0dbf13d842df105838", size = 17099810, upload-time = "2026-02-05T17:31:37.537Z" }, { url = "https://files.pythonhosted.org/packages/87/23/167d964414cee2af9c72af323b28d2c4cb35beed855c830a23f198265c79/onnxruntime-1.24.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:890c503ca187bc883c3aa72c53f2a604ec8e8444bdd1bf6ac243ec6d5e085202", size = 17214004, upload-time = "2026-02-05T17:31:11.917Z" }, { url = "https://files.pythonhosted.org/packages/b4/24/6e5558fdd51027d6830cf411bc003ae12c64054826382e2fab89e99486a0/onnxruntime-1.24.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da1b84b3bdeec543120df169e5e62a1445bf732fc2c7fb036c2f8a4090455e8", size = 15017034, upload-time = "2026-02-05T17:31:04.331Z" }, { url = "https://files.pythonhosted.org/packages/91/d4/3cb1c9eaae1103265ed7eb00a3eaeb0d9ba51dc88edc398b7071c9553bed/onnxruntime-1.24.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:557753ec345efa227c6a65139f3d29c76330fcbd54cc10dd1b64232ebb939c13", size = 17097531, upload-time = "2026-02-05T17:31:40.303Z" }, @@ -419,6 +936,42 @@ version = "11.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, @@ -452,6 +1005,90 @@ 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 = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + [[package]] name = "protobuf" version = "6.33.5" @@ -472,6 +1109,28 @@ name = "py-rust-stemmers" version = "0.1.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/63/4fbc14810c32d2a884e2e94e406a7d5bf8eee53e1103f558433817230342/py_rust_stemmers-0.1.5.tar.gz", hash = "sha256:e9c310cfb5c2470d7c7c8a0484725965e7cab8b1237e106a0863d5741da3e1f7", size = 9388, upload-time = "2025-02-19T13:56:28.708Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e1/ea8ac92454a634b1bb1ee0a89c2f75a4e6afec15a8412527e9bbde8c6b7b/py_rust_stemmers-0.1.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:29772837126a28263bf54ecd1bc709dd569d15a94d5e861937813ce51e8a6df4", size = 286085, upload-time = "2025-02-19T13:55:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/fe1cc3d36a19c1ce39792b1ed151ddff5ee1d74c8801f0e93ff36e65f885/py_rust_stemmers-0.1.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d62410ada44a01e02974b85d45d82f4b4c511aae9121e5f3c1ba1d0bea9126b", size = 272021, upload-time = "2025-02-19T13:55:25.685Z" }, + { url = "https://files.pythonhosted.org/packages/0a/38/b8f94e5e886e7ab181361a0911a14fb923b0d05b414de85f427e773bf445/py_rust_stemmers-0.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b28ef729a4c83c7d9418be3c23c0372493fcccc67e86783ff04596ef8a208cdf", size = 310547, upload-time = "2025-02-19T13:55:26.891Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/62e97652d359b75335486f4da134a6f1c281f38bd3169ed6ecfb276448c3/py_rust_stemmers-0.1.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a979c3f4ff7ad94a0d4cf566ca7bfecebb59e66488cc158e64485cf0c9a7879f", size = 315237, upload-time = "2025-02-19T13:55:28.116Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b9/fc0278432f288d2be4ee4d5cc80fd8013d604506b9b0503e8b8cae4ba1c3/py_rust_stemmers-0.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c3593d895453fa06bf70a7b76d6f00d06def0f91fc253fe4260920650c5e078", size = 324419, upload-time = "2025-02-19T13:55:29.211Z" }, + { url = "https://files.pythonhosted.org/packages/6b/5b/74e96eaf622fe07e83c5c389d101540e305e25f76a6d0d6fb3d9e0506db8/py_rust_stemmers-0.1.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:96ccc7fd042ffc3f7f082f2223bb7082ed1423aa6b43d5d89ab23e321936c045", size = 324792, upload-time = "2025-02-19T13:55:30.948Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f7/b76816d7d67166e9313915ad486c21d9e7da0ac02703e14375bb1cb64b5a/py_rust_stemmers-0.1.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef18cfced2c9c676e0d7d172ba61c3fab2aa6969db64cc8f5ca33a7759efbefe", size = 488014, upload-time = "2025-02-19T13:55:32.066Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ed/7d9bed02f78d85527501f86a867cd5002d97deb791b9a6b1b45b00100010/py_rust_stemmers-0.1.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:541d4b5aa911381e3d37ec483abb6a2cf2351b4f16d5e8d77f9aa2722956662a", size = 575582, upload-time = "2025-02-19T13:55:34.005Z" }, + { url = "https://files.pythonhosted.org/packages/93/40/eafd1b33688e8e8ae946d1ef25c4dc93f5b685bd104b9c5573405d7e1d30/py_rust_stemmers-0.1.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ffd946a36e9ac17ca96821963663012e04bc0ee94d21e8b5ae034721070b436c", size = 493267, upload-time = "2025-02-19T13:55:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6a/15135b69e4fd28369433eb03264d201b1b0040ba534b05eddeb02a276684/py_rust_stemmers-0.1.5-cp312-none-win_amd64.whl", hash = "sha256:6ed61e1207f3b7428e99b5d00c055645c6415bb75033bff2d06394cbe035fd8e", size = 209395, upload-time = "2025-02-19T13:55:36.519Z" }, + { url = "https://files.pythonhosted.org/packages/80/b8/030036311ec25952bf3083b6c105be5dee052a71aa22d5fbeb857ebf8c1c/py_rust_stemmers-0.1.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:398b3a843a9cd4c5d09e726246bc36f66b3d05b0a937996814e91f47708f5db5", size = 286086, upload-time = "2025-02-19T13:55:37.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/be/0465dcb3a709ee243d464e89231e3da580017f34279d6304de291d65ccb0/py_rust_stemmers-0.1.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4e308fc7687901f0c73603203869908f3156fa9c17c4ba010a7fcc98a7a1c5f2", size = 272019, upload-time = "2025-02-19T13:55:39.183Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b6/76ca5b1f30cba36835938b5d9abee0c130c81833d51b9006264afdf8df3c/py_rust_stemmers-0.1.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9efc4da5e734bdd00612e7506de3d0c9b7abc4b89d192742a0569d0d1fe749", size = 310545, upload-time = "2025-02-19T13:55:40.339Z" }, + { url = "https://files.pythonhosted.org/packages/56/8f/5be87618cea2fe2e70e74115a20724802bfd06f11c7c43514b8288eb6514/py_rust_stemmers-0.1.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc2cc8d2b36bc05b8b06506199ac63d437360ae38caefd98cd19e479d35afd42", size = 315236, upload-time = "2025-02-19T13:55:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/02/ea86a316aee0f0a9d1449ad4dbffff38f4cf0a9a31045168ae8b95d8bdf8/py_rust_stemmers-0.1.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a231dc6f0b2a5f12a080dfc7abd9e6a4ea0909290b10fd0a4620e5a0f52c3d17", size = 324419, upload-time = "2025-02-19T13:55:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/1612c22545dcc0abe2f30fc08f30a2332f2224dd536fa1508444a9ca0e39/py_rust_stemmers-0.1.5-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5845709d48afc8b29e248f42f92431155a3d8df9ba30418301c49c6072b181b0", size = 324794, upload-time = "2025-02-19T13:55:43.896Z" }, + { url = "https://files.pythonhosted.org/packages/66/18/8a547584d7edac9e7ac9c7bdc53228d6f751c0f70a317093a77c386c8ddc/py_rust_stemmers-0.1.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e48bfd5e3ce9d223bfb9e634dc1425cf93ee57eef6f56aa9a7120ada3990d4be", size = 488014, upload-time = "2025-02-19T13:55:45.088Z" }, + { url = "https://files.pythonhosted.org/packages/3b/87/4619c395b325e26048a6e28a365afed754614788ba1f49b2eefb07621a03/py_rust_stemmers-0.1.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:35d32f6e7bdf6fd90e981765e32293a8be74def807147dea9fdc1f65d6ce382f", size = 575582, upload-time = "2025-02-19T13:55:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/214f1a889142b7df6d716e7f3fea6c41e87bd6c29046aa57e175d452b104/py_rust_stemmers-0.1.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:191ea8bf922c984631ffa20bf02ef0ad7eec0465baeaed3852779e8f97c7e7a3", size = 493269, upload-time = "2025-02-19T13:55:49.057Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/c5185df277576f995ae34418eb2b2ac12f30835412270f9e05c52face521/py_rust_stemmers-0.1.5-cp313-none-win_amd64.whl", hash = "sha256:e564c9efdbe7621704e222b53bac265b0e4fbea788f07c814094f0ec6b80adcf", size = 209397, upload-time = "2025-02-19T13:55:50.853Z" }, +] [[package]] name = "pygments" @@ -504,6 +1163,7 @@ 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 = [ @@ -524,7 +1184,7 @@ name = "python-telegram-bot" version = "22.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpcore" }, + { name = "httpcore", marker = "python_full_version >= '3.14'" }, { name = "httpx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/9b/8df90c85404166a6631e857027866263adb27440d8af1dbeffbdc4f0166c/python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742", size = 1503761, upload-time = "2026-01-24T13:57:00.269Z" } @@ -538,6 +1198,26 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, @@ -616,6 +1296,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/72/428fb01a1043ddbb3f66297363406d6e69ddff5ad89c4d07945a3753a235/slack_sdk-3.40.0-py2.py3-none-any.whl", hash = "sha256:f2bada5ed3adb10a01e154e90db01d6d8938d0461b5790c12bcb807b2d28bbe2", size = 312786, upload-time = "2026-02-10T22:12:11.258Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -738,6 +1427,12 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, @@ -758,3 +1453,97 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b66 wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] diff --git a/webhooks/__init__.py b/webhooks/__init__.py new file mode 100644 index 0000000..6a74461 --- /dev/null +++ b/webhooks/__init__.py @@ -0,0 +1,3 @@ +from webhooks.receiver import WebhookReceiver + +__all__ = ["WebhookReceiver"] diff --git a/webhooks/receiver.py b/webhooks/receiver.py new file mode 100644 index 0000000..70df9a6 --- /dev/null +++ b/webhooks/receiver.py @@ -0,0 +1,267 @@ +""" +Aetheel Webhook Receiver +======================== +HTTP endpoint that accepts POST requests from external systems and routes +them through the AI handler as synthetic messages. + +Inspired by OpenClaw's /hooks/* gateway endpoints. External systems (GitHub, +Jira, email services, custom scripts) can POST JSON payloads to wake the +agent and trigger actions. + +Endpoints: + POST /hooks/wake — Wake the agent with a text prompt + POST /hooks/agent — Send a message to a specific agent session + +All endpoints require bearer token auth via the hooks.token config value. + +Usage: + from webhooks.receiver import WebhookReceiver + + receiver = WebhookReceiver( + ai_handler_fn=ai_handler, + send_fn=send_to_channel, + config=cfg.webhooks, + ) + receiver.start_async() # Runs in background thread +""" + +import asyncio +import json +import logging +import threading +import uuid +from dataclasses import dataclass + +from aiohttp import web + +logger = logging.getLogger("aetheel.webhooks") + + +@dataclass +class WebhookConfig: + """Webhook receiver configuration.""" + enabled: bool = False + port: int = 8090 + host: str = "127.0.0.1" + token: str = "" # Bearer token for auth + + +class WebhookReceiver: + """ + HTTP server that receives webhook POSTs from external systems. + + Endpoints: + POST /hooks/wake — { "text": "Check my email" } + POST /hooks/agent — { "message": "...", "channel": "slack", "agentId": "..." } + GET /hooks/health — Health check (no auth required) + """ + + def __init__( + self, + ai_handler_fn, + send_fn, + config: WebhookConfig, + ): + self._ai_handler = ai_handler_fn + self._send_fn = send_fn + self._config = config + self._app = web.Application() + self._runner: web.AppRunner | None = None + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._setup_routes() + + def _setup_routes(self) -> None: + self._app.router.add_post("/hooks/wake", self._handle_wake) + self._app.router.add_post("/hooks/agent", self._handle_agent) + self._app.router.add_get("/hooks/health", self._handle_health) + + # ------------------------------------------------------------------- + # Auth + # ------------------------------------------------------------------- + + def _check_auth(self, request: web.Request) -> bool: + """Verify bearer token from Authorization header or query param.""" + if not self._config.token: + return True # No token configured = open access (dev mode) + + # Check Authorization header + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer ") and auth[7:] == self._config.token: + return True + + # Check query param fallback + if request.query.get("token") == self._config.token: + return True + + return False + + # ------------------------------------------------------------------- + # Handlers + # ------------------------------------------------------------------- + + async def _handle_health(self, request: web.Request) -> web.Response: + """Health check — no auth required.""" + return web.json_response({"status": "ok"}) + + async def _handle_wake(self, request: web.Request) -> web.Response: + """ + Wake the agent with a text prompt. + + POST /hooks/wake + Body: { "text": "Check my email for urgent items" } + Auth: Bearer + + The text is routed through ai_handler as a synthetic message. + Response includes the agent's reply. + """ + if not self._check_auth(request): + return web.json_response({"error": "unauthorized"}, status=401) + + if request.method != "POST": + return web.json_response({"error": "method not allowed"}, status=405) + + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"error": "invalid JSON"}, status=400) + + text = body.get("text", "").strip() + if not text: + return web.json_response({"error": "text is required"}, status=400) + + # Import here to avoid circular dependency + from adapters.base import IncomingMessage + + msg = IncomingMessage( + text=text, + user_id="webhook", + user_name=body.get("sender", "Webhook"), + channel_id=body.get("channel_id", "webhook"), + channel_name="webhook", + conversation_id=f"webhook-{uuid.uuid4().hex[:8]}", + source="webhook", + is_dm=True, + raw_event={"webhook": True, "body": body}, + ) + + loop = asyncio.get_event_loop() + response_text = await loop.run_in_executor( + None, self._ai_handler, msg + ) + + # Optionally deliver to a channel + channel = body.get("channel") + channel_id = body.get("channel_id") + if channel and channel_id and response_text: + try: + self._send_fn(channel_id, response_text, None, channel) + except Exception as e: + logger.warning(f"Failed to deliver webhook response: {e}") + + return web.json_response({ + "status": "ok", + "response": response_text or "", + }) + + async def _handle_agent(self, request: web.Request) -> web.Response: + """ + Send a message to a specific agent session. + + POST /hooks/agent + Body: { + "message": "Research Python 3.14 features", + "channel": "slack", + "channel_id": "C123456", + "agent_id": "main" + } + Auth: Bearer + """ + if not self._check_auth(request): + return web.json_response({"error": "unauthorized"}, status=401) + + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"error": "invalid JSON"}, status=400) + + message = body.get("message", "").strip() + if not message: + return web.json_response({"error": "message is required"}, status=400) + + channel = body.get("channel", "webhook") + channel_id = body.get("channel_id", "webhook") + + from adapters.base import IncomingMessage + + msg = IncomingMessage( + text=message, + user_id="webhook", + user_name=body.get("sender", "Webhook"), + channel_id=channel_id, + channel_name=channel, + conversation_id=f"webhook-agent-{uuid.uuid4().hex[:8]}", + source="webhook", + is_dm=True, + raw_event={"webhook": True, "agent_id": body.get("agent_id"), "body": body}, + ) + + loop = asyncio.get_event_loop() + response_text = await loop.run_in_executor( + None, self._ai_handler, msg + ) + + # Deliver to the specified channel + if channel_id != "webhook" and response_text: + try: + self._send_fn(channel_id, response_text, None, channel) + except Exception as e: + logger.warning(f"Failed to deliver agent response: {e}") + + return web.json_response({ + "status": "ok", + "response": response_text or "", + }) + + # ------------------------------------------------------------------- + # Server lifecycle + # ------------------------------------------------------------------- + + def start(self) -> None: + """Start the webhook server (blocking).""" + asyncio.run(self._run_server()) + + def start_async(self) -> None: + """Start the webhook server in a background thread.""" + self._thread = threading.Thread( + target=self._run_async, daemon=True, name="webhooks" + ) + self._thread.start() + + def _run_async(self) -> None: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._run_server()) + + async def _run_server(self) -> None: + self._runner = web.AppRunner(self._app) + await self._runner.setup() + site = web.TCPSite(self._runner, self._config.host, self._config.port) + await site.start() + logger.info( + f"Webhook receiver running at " + f"http://{self._config.host}:{self._config.port}/hooks/" + ) + try: + while True: + await asyncio.sleep(3600) + except asyncio.CancelledError: + pass + + def stop(self) -> None: + if self._runner: + if self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe( + self._runner.cleanup(), self._loop + ) + logger.info("Webhook receiver stopped")