Compare commits

..
19 Commits
Author SHA1 Message Date
Moritz Graf 9903194316 feat(shelly): optimize dew point ventilation setpoints, duty cycle, and night-window gating
- Raise target humidity setpoints from 40% to 55% with a 2% hysteresis shut-off
- Eliminate 9999-min unlimited runtime bypass and strictly cap bursts at 35 min
- Rebalance duty cycle to 1:1 ratio (35 min run, 35 min cooldown rest)
- Implement Shelly internal RTC time-of-day check to restrict routine drying to 02:00-07:00
- Add delta-T guard requiring spread >= 3.5°C and max 20 min bursts when outside is >8°C colder
- Update AGENTS.md documentation to reflect optimized gates and thresholds
2026-10-20 15:46:30 +02:00
Moritz Graf 28eedea372 refactor(terraform): move shelly to projects/shelly_taupunktluefter and flatten core infra 2026-10-20 09:39:26 +02:00
Moritz Graf a34fa7ee08 fix(cron): resolve Garmin sync payload mismatch, harden cronjobs, and update wiki submodule 2026-10-18 20:33:26 +02:00
Moritz Graf c33b6e94fb fix(openclaw): configure 64k completion headroom for deepseek-v4.1-flash, promote cloudflare, and onboard fireworks 2026-10-18 11:06:50 +02:00
Moritz Graf dd60a095c6 feat(openclaw): upgrade fleet to DeepSeek V4.1 Flash 1017 and harden operations
- Upgrade OpenClaw primary model from deepseek-v4.1-flash to deepseek-v4.1-flash-1017 across all interactive agents, heartbeats, compaction, and cron jobs.
- Configure hardened OpenRouter provider routing for 1017 (deepinfra, inference-net, parasail, cloudflare) with strict fp8/bf16 quantization and tool_choice verification.
- Retain deepseek-v4.1-flash (0910), z-ai/glm-5.3-flash, and qwen3.8-flash in the automated fallback chain.
- Update ADR 0002 with 1017 economics (30% price reduction on prompt/cache, +60% intelligence benchmark increase) and a 7-point provider qualification playbook.
- Add operational scripts and tooling for session compaction, subagent vector deduplication, gateway preflight, and triage diagnostics in k8s/openclaw.
- Update DuckDB backend engine, git-sync logic, and test suites.
- Enhance Mila tracking UI components and add container build configuration.
- Sync Terraform infrastructure state for Authentik.
2026-10-18 10:54:59 +02:00
Moritz Graf 7ce1da18ab fix(mila): fix last feed display, hide diaper tile when empty today, and add resilient git-init 2026-10-17 08:01:39 +02:00
Moritz Graf 5b8432caf4 feat(family): deploy Single-Writer DuckDB backend, Mila parent cockpit & feeding analytics, and Moritz macro tracker 2026-10-17 07:21:49 +02:00
Moritz Graf ef50d87bd7 fix(openclaw): add automatic mcp-venv fallback to sync_crons.py 2026-10-16 21:28:15 +02:00
Moritz Graf ed3529a9d7 feat(openclaw): modularize scripts, implement crons-as-code and EaC governance 2026-10-16 21:25:33 +02:00
Moritz Graf f4d8210cc4 fix(openclaw): fix tracking tool allowlist, resolve research fastpath and cap bootstrap bloat 2026-10-14 15:34:45 +02:00
Moritz Graf 02fcbd3174 feat(openclaw): strip tracking agent prompt bloat and add 15m deterministic sliding-window compaction
- Disable bootstrap context injection (contextInjection: never) and skill catalog injection (skills: []) on mila_tracker and nutrition_tracker, dropping baseline prompt by 95% (<350 tokens)
- Disable reasoning loops on tracking agents (thinkingDefault: off) for instantaneous sub-second execution
- Add compact_tracking_sessions.py with deterministic 8-line sliding window (--max-lines 8) requiring 0 LLM tokens
- Register automated 15-minute cron job (tracking-session-sliding-window) in OpenClaw gateway
- Streamline setup-compaction-cron.sh to sync python scripts directly and manage both 15m and nightly maintenance
2026-10-14 14:14:35 +02:00
Moritz Graf a0c8c3828c fix(openclaw): resolve tracking compaction hang, restrict tracking tools and enforce lifecycle management 2026-10-14 13:39:38 +02:00
Moritz Graf cab8dd290f feat(openclaw): switch mila_tracker primary model to DeepSeek V4.1 Flash and add koch recipe delegation 2026-10-13 09:49:04 +02:00
Moritz Graf c85a60d920 feat(openclaw): update agent instance configuration and sync knowledge-wiki submodule 2026-10-13 09:41:25 +02:00
Moritz Graf a2cf3bfe39 fix(openclaw): decouple Gemini 3.8 Flash vision perception while preserving GLM-5.3-flash as primary chat model 2026-10-12 22:21:06 +02:00
Moritz Graf d84d7b0bec fix(openclaw): upgrade fitness agent model to gemini-3.8-flash and document issue 28 2026-10-12 22:04:22 +02:00
Moritz Graf 6acceec701 docs(openclaw): document lasagna logging recovery and compaction in issue 27 2026-10-12 21:05:22 +02:00
Moritz Graf 972d629df8 chore(wiki): update knowledge-wiki submodule pointer 2026-10-12 21:04:06 +02:00
Moritz Graf a12b596053 fix(openclaw): allocate Gemini 3.8 Flash to koch agent, harden vision and tool protocols 2026-10-12 19:52:11 +02:00
156 changed files with 19447 additions and 454 deletions
+7
View File
@@ -2,3 +2,10 @@ k8s/gitea-chart/
.vagrant .vagrant
k8s/tekton-pipelines/auth k8s/tekton-pipelines/auth
.DS_Store .DS_Store
node_modules/
**/node_modules/
dist/
**/dist/
*.tsbuildinfo
.venv/
**/__pycache__/
+31 -11
View File
@@ -19,16 +19,12 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
* **OpenClaw**: For OpenClaw AI agent deployment and troubleshooting, see [k8s/openclaw/AGENTS.md](./k8s/openclaw/AGENTS.md) and [k8s/openclaw/TROUBLESHOOTING.md](./k8s/openclaw/TROUBLESHOOTING.md). * **OpenClaw**: For OpenClaw AI agent deployment and troubleshooting, see [k8s/openclaw/AGENTS.md](./k8s/openclaw/AGENTS.md) and [k8s/openclaw/TROUBLESHOOTING.md](./k8s/openclaw/TROUBLESHOOTING.md).
* **Read**: [k8s/AGENTS.md](./k8s/AGENTS.md) * **Read**: [k8s/AGENTS.md](./k8s/AGENTS.md)
### 3. Terraform (`terraform/`) ### 3. [Terraform](./terraform/AGENTS.md) (`terraform/`)
* **Purpose**: Infrastructure as Code divided into two symmetrical modules: * **Purpose**: Declarative core cloud infrastructure as code.
* **Cloud & DNS (`terraform/infrastructure/`)**:
* **DNS Zones**: `haumdaucher.de`, `moritzgraf.de`, `ameliegraf.de`, and others via OVH (`ovh/ovh`). * **DNS Zones**: `haumdaucher.de`, `moritzgraf.de`, `ameliegraf.de`, and others via OVH (`ovh/ovh`).
* **Cloud Services**: Authentik SSO (`authentik.tf`), GCP APIs, backups, and Google Workspace MX/SPF/DKIM. * **Cloud Services**: Authentik SSO (`authentik.tf`), GCP APIs, Velero backup SA (`gcp_backups.tf`), OpenClaw calendar SA (`gcp_calendar.tf`), and Google Workspace MX/SPF/DKIM (`google_workspace.tf`).
* **Usage**: `cd terraform/infrastructure && terraform apply` * **Usage**: `cd terraform && terraform apply`
* **Smart Home Shelly Automation (`terraform/shelly/`)**: * **Read**: [terraform/AGENTS.md](./terraform/AGENTS.md)
* **Taupi Cellar Mold Ventilation**: Automates Shelly Plug S with psychrometric dew point calculations (`Taupi-4.0.js`) and BTHome Bluetooth sensor telemetry.
* **Usage**: `cd terraform/shelly && terraform apply`
* **Read**: [terraform/shelly/AGENTS.md](./terraform/shelly/AGENTS.md)
### 4. Monitoring (`monitoring/`) ### 4. Monitoring (`monitoring/`)
* **Purpose**: Physical ambient BLE sensor fleet monitoring across living areas. * **Purpose**: Physical ambient BLE sensor fleet monitoring across living areas.
@@ -94,12 +90,36 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
* **Key Tools**: Interactive `uv run flash.py` wizard and `./send_message.sh` CLI push messaging. * **Key Tools**: Interactive `uv run flash.py` wizard and `./send_message.sh` CLI push messaging.
* **Read**: [projects/esphome_display_kitchen/AGENTS.md](./projects/esphome_display_kitchen/AGENTS.md) * **Read**: [projects/esphome_display_kitchen/AGENTS.md](./projects/esphome_display_kitchen/AGENTS.md)
### 12. Utility Scripts (`scripts/`) ### 12. [DuckDB Backend](./projects/duckdb-backend/AGENTS.md) (`projects/duckdb-backend/`)
* **Purpose**: High-performance HTTP analytics and event tracking data service executing in-memory DuckDB queries against Knowledge Wiki JSONL datasets.
* **Manifests**: `k8s/family/` (`duckdb-backend.yaml`, `pvc.yaml`, `ingress.yaml` in namespace `family`).
* **Endpoints**: Curated dashboards (`/duckdb-api/v1/mila/dashboard`, `/duckdb-api/v1/moritz/dashboard`), generic SQL queries (`/duckdb-api/v1/query`), and event tracking writes with Git sync.
* **Read**: [projects/duckdb-backend/AGENTS.md](./projects/duckdb-backend/AGENTS.md)
### 13. [Mila Tracking Web App](./projects/mila/AGENTS.md) (`projects/mila/`)
* **Purpose**: Reactive mobile-first PWA for Mila's baby sleep, feeding, diaper tracking, and growth percentiles (`https://family.haumdaucher.de/tracking/mila`).
* **Manifest**: `k8s/family/mila.yaml` (Namespace: `family`).
* **Key Rules**: Vue 3, Vite, TypeScript, and Tailwind CSS; live wake timer; interactive 1-tap and custom-time tracking modal.
* **Read**: [projects/mila/AGENTS.md](./projects/mila/AGENTS.md)
### 14. [Moritz Health & Nutrition Web App](./projects/moritz/AGENTS.md) (`projects/moritz/`)
* **Purpose**: Apple Health-inspired nutrition and metabolic balance dashboard (`https://family.haumdaucher.de/tracking/moritz`).
* **Manifest**: `k8s/family/moritz.yaml` (Namespace: `family`).
* **Key Rules**: Vue 3, Vite, TypeScript, and Tailwind CSS; net calorie balance hero, Garmin health breakdown, and quick meal logger.
* **Read**: [projects/moritz/AGENTS.md](./projects/moritz/AGENTS.md)
### 15. [Shelly Taupunktlüfter](./projects/shelly_taupunktluefter/AGENTS.md) (`projects/shelly_taupunktluefter/`)
* **Purpose**: Automated cellar ventilation controller ("Taupi") on Shelly Plug S Gen 3 to prevent mold growth using psychrometric calculations and BTHome BLE sensors.
* **Usage**: `cd projects/shelly_taupunktluefter && terraform apply`
* **Key Rules**: Sedlbauer LIM I decoupled mold danger evaluation, summer overheating and winter cold guards, optimal night drying window (03:00–07:00), and Prometheus metrics export.
* **Read**: [projects/shelly_taupunktluefter/AGENTS.md](./projects/shelly_taupunktluefter/AGENTS.md)
### 16. Utility Scripts (`scripts/`)
* **Purpose**: Cross-cutting operational and administration CLI scripts that do not belong inside Kubernetes namespace directories. * **Purpose**: Cross-cutting operational and administration CLI scripts that do not belong inside Kubernetes namespace directories.
* **Key Tools**: * **Key Tools**:
* `scripts/htpasswd.py`: Generates bcrypt-hashed htpasswd credentials for ingress basic auth secrets. * `scripts/htpasswd.py`: Generates bcrypt-hashed htpasswd credentials for ingress basic auth secrets.
### 13. Network & Router Integration (`fritzbox/`) ### 17. Network & Router Integration (`fritzbox/`)
* **Purpose**: Site-to-Site WireGuard LAN coupling and DynDNS OVH synchronization for the home FritzBox router. * **Purpose**: Site-to-Site WireGuard LAN coupling and DynDNS OVH synchronization for the home FritzBox router.
* **Cluster Endpoint**: `k8s/wireguard/` (`vpn.haumdaucher.de`). * **Cluster Endpoint**: `k8s/wireguard/` (`vpn.haumdaucher.de`).
* **Read**: [fritzbox/README.md](./fritzbox/README.md) * **Read**: [fritzbox/README.md](./fritzbox/README.md)
+5 -3
View File
@@ -6,15 +6,17 @@ Infrapuzzle is the monorepo for personal infrastructure, Kubernetes cluster oper
- **[`bootstrap/`](bootstrap/)**: Provisioning the single-node Kubernetes cluster (`haumdaucher`) using Kubespray. - **[`bootstrap/`](bootstrap/)**: Provisioning the single-node Kubernetes cluster (`haumdaucher`) using Kubespray.
- **[`k8s/`](k8s/)**: Kubernetes workloads. **Every subdirectory represents a Kubernetes namespace.** - **[`k8s/`](k8s/)**: Kubernetes workloads. **Every subdirectory represents a Kubernetes namespace.**
- **[`terraform/`](terraform/)**: Declarative Infrastructure as Code: - **[`terraform/`](terraform/)**: Declarative Infrastructure as Code for core cloud infrastructure (OVH DNS zones, Authentik SSO, Google Workspace, and GCP APIs).
- `terraform/infrastructure/`: Cloud infrastructure, OVH DNS zones, Authentik SSO, and Google Workspace.
- `terraform/shelly/`: Smart home Shelly Plug S ventilation controller ("Taupi") for cellar mold prevention.
- **[`projects/`](projects/)**: Self-contained hosted applications and projects: - **[`projects/`](projects/)**: Self-contained hosted applications and projects:
- `projects/shelly_taupunktluefter/`: Smart home Shelly Plug S ventilation controller ("Taupi") for cellar mold prevention.
- `projects/sasch40/`: Retro birthday adventure game (`sasch40.haumdaucher.de`). - `projects/sasch40/`: Retro birthday adventure game (`sasch40.haumdaucher.de`).
- `projects/moritzgrafde/`: Personal homepage and executive portfolio (`moritzgraf.de`). - `projects/moritzgrafde/`: Personal homepage and executive portfolio (`moritzgraf.de`).
- `projects/haumdaucherde/`: Official community website & portal (`haumdaucher.de`). - `projects/haumdaucherde/`: Official community website & portal (`haumdaucher.de`).
- `projects/grabengasse/`: Climate & weather dashboard (`grabengasse7.de`). - `projects/grabengasse/`: Climate & weather dashboard (`grabengasse7.de`).
- `projects/esphome_display_kitchen/`: ESP32 e-Paper kitchen status display & flashing wizard. - `projects/esphome_display_kitchen/`: ESP32 e-Paper kitchen status display & flashing wizard.
- `projects/duckdb-backend/`: In-memory analytics and tracking query API.
- `projects/mila/`: Baby tracking PWA (`family.haumdaucher.de/tracking/mila`).
- `projects/moritz/`: Health and nutrition dashboard (`family.haumdaucher.de/tracking/moritz`).
- **[`monitoring/`](monitoring/)**: ESPHome fleet for BLE ambient room temperature & humidity sensors. - **[`monitoring/`](monitoring/)**: ESPHome fleet for BLE ambient room temperature & humidity sensors.
- **[`fritzbox/`](fritzbox/)**: Home router Site-to-Site WireGuard VPN coupling and DynDNS synchronization. - **[`fritzbox/`](fritzbox/)**: Home router Site-to-Site WireGuard VPN coupling and DynDNS synchronization.
- **[`scripts/`](scripts/)**: Operational utility scripts (e.g. `htpasswd.py`). - **[`scripts/`](scripts/)**: Operational utility scripts (e.g. `htpasswd.py`).
@@ -1,5 +1,5 @@
--- ---
status: "accepted (trial: GLM-5.3-Flash)" status: "accepted (fleet-wide primary: DeepSeek-V4.1-Flash-1017; updated 2026-10-17)"
date: 2026-09-13 date: 2026-09-13
decision-makers: ["Moritz Graf", "Antigravity"] decision-makers: ["Moritz Graf", "Antigravity"]
consulted: [] consulted: []
@@ -28,7 +28,7 @@ How do we architect the full-spectrum model selection across **all modalities**
## Decision Drivers ## Decision Drivers
* **State-of-the-Art Recency**: Exclusively employ the latest generation models (e.g. Gemini 3.1, GLM 5.3, Whisper V3 Turbo, Sonar Pro). Completely decommission legacy generations (e.g. Gemini 2.5). * **State-of-the-Art Recency**: Exclusively employ the latest generation models (e.g. DeepSeek V4.1 Flash 1017, Gemini 3.8/3.1, GLM 5.3, Whisper V3 Turbo, Sonar Pro). Completely decommission legacy generations (e.g. Gemini 2.5).
* **Conversational & Reasoning Quality**: High emotional intelligence, nuanced German fluency, personal context recall, and strict adherence to tool-calling protocols (ADR 0001). * **Conversational & Reasoning Quality**: High emotional intelligence, nuanced German fluency, personal context recall, and strict adherence to tool-calling protocols (ADR 0001).
* **Strict Modality Decoupling**: Each capability must route to its dedicated, best-in-class engine: * **Strict Modality Decoupling**: Each capability must route to its dedicated, best-in-class engine:
- Text & Agentic Reasoning - Text & Agentic Reasoning
@@ -38,9 +38,9 @@ How do we architect the full-spectrum model selection across **all modalities**
- Text-to-Speech / Audio Output (synthesizing briefings and podcasts) - Text-to-Speech / Audio Output (synthesizing briefings and podcasts)
- Web Search & Retrieval (grounded search) - Web Search & Retrieval (grounded search)
- Semantic Embeddings (vector memory) - Semantic Embeddings (vector memory)
* **Inference Economics**: Low per-token costs for large conversational contexts (15k–80k tokens), zero background bleed, and predictable billing. * **Inference Economics**: Low per-token costs for large conversational contexts (15k–80k tokens), high prompt-cache discount efficiency (`cacheRead`), zero background bleed, and predictable billing.
* **Latency & Turn Responsiveness**: Conversational turns on Telegram must return within **4–10 seconds**, avoiding provider queuing or massive prefill bottlenecks. * **Latency & Turn Responsiveness**: Conversational turns on Telegram must return within **4–10 seconds**, avoiding provider queuing or massive prefill bottlenecks.
* **Resilience & Fault Tolerance**: Automated fallback chains across independent providers on OpenRouter. * **Resilience & Fault Tolerance**: Automated fallback chains and strict quantization/provider pins (`fp8`/`bf16`, `tool_choice` support) across independent providers on OpenRouter.
--- ---
@@ -60,22 +60,23 @@ How do we architect the full-spectrum model selection across **all modalities**
│ Tier 1 │ │ Tier 2 │ │ Tier 3 │ │ Tier 4 │ │ Tier 5 │ │ Tier 1 │ │ Tier 2 │ │ Tier 3 │ │ Tier 4 │ │ Tier 5 │
│ • Low Latency │ │ • Deep Analysis │ │ • target: none │ │ • Nano Banana 2 │ │ • Whisper V3 │ │ • Low Latency │ │ • Deep Analysis │ │ • target: none │ │ • Nano Banana 2 │ │ • Whisper V3 │
│ • Tool Routing │ │ • Autonomous │ │ • Zero Bleed │ │ • Dedicated │ │ • Gemini 3.1 │ │ • Tool Routing │ │ • Autonomous │ │ • Zero Bleed │ │ • Dedicated │ │ • Gemini 3.1 │
│ • Native Vision │ │ • Isolated Run │ │ • 30m Polling │ │ Raster Head │ │ Flash TTS │ │ • Decoupled OCR │ │ • Isolated Run │ │ • 30m Polling │ │ Raster Head │ │ Flash TTS │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Primary (Trial):│ │ Primary (Trial):│ │ Primary: │ │ Primary: │ │ STT: │ │ Primary: │ │ Primary: │ │ Primary: │ │ Primary: │ │ STT: │
│ z-ai/ │ │ z-ai/ │ │ z-ai/ │ │ google/ │ │ openai/ │ │ deepseek/ │ │ deepseek/ │ │ deepseek/ │ │ google/ │ │ openai/ │
│ glm-5.3-flash │ │ glm-5.3-flash │ │ glm-5.3-flash │ │ gemini-3.1- │ │ whisper-large- │ │ deepseek-v4.1- │ │ deepseek-v4.1- │ │ deepseek-v4.1- │ │ gemini-3.1- │ │ whisper-large- │
│ │ │ │ │ │ │ flash-image │ │ v3-turbo │ │ flash-1017 │ │ flash-1017 │ │ flash-1017 │ │ flash-image │ │ v3-turbo │
│ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ │ │ │ │ │ │ │ │ │ │ │
│ 1. deepseek- │ │ 1. deepseek- │ │ 1. deepseek- │ │ Fallbacks: │ │ TTS: │ │ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ TTS: │
│ v4.1-flash │ │ v4.1-flash │ │ v4.1-flash │ │ 1. gemini-3.1- │ │ google/ │ │ 1. v4.1-flash │ │ 1. v4.1-flash │ │ 1. v4.1-flash │ │ 1. gemini-3.1- │ │ google/ │
│ 2. qwen3.8-flash│ │ 2. qwen3.8-flash│ │ 2. qwen3.8-flash│ │ flash-preview│ │ gemini-3.1- │ │ 2. glm-5.3-flash│ │ 2. glm-5.3-flash│ │ 2. glm-5.3-flash│ │ flash-preview│ │ gemini-3.1- │
│ │ │ │ │ │ │ 2. gemini-3-pro-│ │ flash-tts │ │ 3. qwen3.8-flash│ │ 3. qwen3.8-flash│ │ 3. qwen3.8-flash│ │ 2. gemini-3-pro-│ │ flash-tts │
│ Vision Fallback:│ │ │ │ │ │ image │ │ (FB: MiniMax 2.8│ │ │ │ │ │ │ │ image │ │ (FB: MiniMax 2.8│
│ google/ │ │ │ │ │ │ │ │ Speech HD) │ │ Vision (OCR): │ │ │ │ │ │ │ │ Speech HD) │
│ google/ │ │ │ │ │ │ │ │ │
│ gemini-3.8- │ │ │ │ │ │ │ │ │ │ gemini-3.8- │ │ │ │ │ │ │ │ │
│ flash │ │ │ │ │ │ │ │ │ │ flash │ │ │ │ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
@@ -86,31 +87,35 @@ How do we architect the full-spectrum model selection across **all modalities**
## Modality Specifications & Cost-Benefit Analysis ## Modality Specifications & Cost-Benefit Analysis
### 1. Interactive Conductor & Agentic Reasoning (Tier 1) ### 1. Interactive Conductor & Agentic Reasoning (Tier 1)
* **Primary (Active Trial)**: `openrouter/z-ai/glm-5.3-flash` * **Primary (Fleet-Wide Standard as of 2026-10-17)**: `openrouter/deepseek/deepseek-v4.1-flash-1017`
- **Pricing**: \$0.075 / 1M prompt tokens; \$0.300 / 1M completion tokens. This is 50–75% more cost-effective than DeepSeek V4.1-Flash and offers a 1.3M token context window. Under daytime context headroom (~25k–60k tokens), daily cost is only **~\$0.01–\$0.02 / day**. - **1017 Snapshot Upgrade (Pareto Jump in Quality & Price)**: Released on October 17, 2026, `deepseek/deepseek-v4.1-flash-1017` boosts the Artificial Analysis Intelligence Index from **24.7 to 39.5** (+60%) via Reinforcement Learning with Self-Feedback (RLSF) while cutting DeepInfra `fp8` pricing by an additional **30%** (`$0.098/M` prompt, `$0.294/M` completion, and **`$0.00294/M` `cacheRead`** vs. `$0.140/$0.420/$0.0042` on the 0910 baseline).
- **Performance & Reasoning**: SOTA Chinese & English reasoning, competitive coding benchmarks, and native vision perception. - **Provider Pinning & Tool-Compatibility Verification**:
- **Forensic Diagnosis & Tool-Calling Protocol Fix**: - Pinned `order: ["deepinfra", "cloudflare", "inference-net", "fireworks", "parasail"]`, `quantizations: ["fp8", "bf16", "unknown"]`, and `ignore: ["morph", "baseten", "deepseek", "open-inference", "decart", "sail-research", "wafer"]`.
- *Incident*: On September 14, 2026, GLM-5.3-Flash emitted raw `<tool_call>supermarket__supermarket_search...</tool_call>` XML into Telegram instead of executing tools. - **Cloudflare (#2 Promotion)**: Promoted ahead of InferenceNet and Parasail because Cloudflare delivers a 97% cache discount (`$0.003/M` `cacheRead`), 3× lower completion costs (`$0.60/M` vs. `$1.20/M` on Parasail), and a massive **943,718** completion token limit.
- *Root Cause Identified*: In Zhipu AI's pre-training tokenizer, function calls are emitted as special XML tokens. When models are queried through OpenRouter's OpenAI-compatible API, OpenRouter only intercepts and translates `<tool_call>` tokens into JSON if the tool `name` exists in the request's registered `tools: [...]` array. OpenClaw uses dynamic Tool Search (`tools.toolSearch.mode: "directory"`) where only 3 control tools (`tool_search`, `tool_describe`, `tool_call`) are registered in `tools`. When GLM received deferred schemas (e.g., `supermarket__supermarket_search`), its pre-training prior prompted it to call the tool directly. Because `supermarket__supermarket_search` was not in OpenRouter's `tools` array, OpenRouter passed the raw XML through as message text. Once that text entered the session history (events SEQ 297 & 306), GLM was in-context primed to repeat the XML pattern. - **Fireworks (#4 Addition)**: Onboarded as a high-reliability enterprise tier-1 provider (`$0.30/M` prompt, `$1.20/M` completion, `$0.006/M` `cacheRead`, 100% uptime, 943k max completion tokens, full tool choice and structured outputs support).
- *Remediation & Verification*: We introduced an explicit deferred tool execution protocol into `AGENTS.md` instructing the model that deferred tools must NEVER be called directly and MUST be routed via `tool_call({ id: 'tool_name', args: { ... } })`. Direct OpenRouter API probes confirmed that with this rule, GLM-5.3-Flash calls `tool_call` with 100% compliant JSON and zero XML leakage. Session events were sanitized. - **Completion Token Headroom Guardrail (`maxTokens: 65536`)**: Because reasoning models share the completion token budget between internal chain-of-thought (`reasoningTokens`, typically 2k–4k tokens) and the actual tool-call payload, omitting `maxTokens` previously allowed OpenClaw/OpenRouter to default to an 8,192 token ceiling. During large multi-kilobyte code or website generation, hitting 8,192 tokens abruptly truncated tool-call JSON mid-payload (`Provider returned an incomplete or malformed tool call`). Explicitly configuring `maxTokens: 65536` and `params.max_tokens: 65536` provides ample headroom for complex reasoning and large outputs.
- Live OpenRouter routing forensics verified that `morph/fp8` is filtered out by OpenRouter's `Filter by Tool Compatibility` when `tools` are passed, `deepseek` (official) is filtered by account privacy guardrails (`Paid model training violation`), and `baseten` returns `HTTP 429`. Meanwhile, `deepinfra`, `cloudflare`, `inference-net`, `fireworks`, and `parasail` deliver 100% tool-calling reliability and sub-second to 3s latency.
- **Post-Trial Evaluation of `z-ai/glm-5.3-flash` (2026-09-13 to 2026-10-17)**:
- While `z-ai/glm-5.3-flash` was trialed as primary conductor, 17-day production telemetry (Oct 1–17, 2026) revealed **12 mid-stream tool-call aborts** (`stopReason: "error"`, `errorMessage: "Provider returned an incomplete or malformed tool call"`) across 5 agents (`main`, `grabengasse_media`, `koch`, `hebamme`, `doktor`) when generating nested `tool_call({ id, args })` payloads after `tool_describe`, as well as occasional thinking-only whitespace stalls (Issue 23) and long-context attention drift (Issues 22 & 25).
- Promoting `deepseek/deepseek-v4.1-flash-1017` to primary across all agents eliminates malformed tool-call stream aborts AND lowers total monthly cost by >50% compared to GLM 5.3 Flash.
- **Decoupled Vision Override**: `deepseek/deepseek-v4.1-flash-1017`, `deepseek/deepseek-v4.1-flash`, and `z-ai/glm-5.3-flash` are registered with `input: ["text"]` under `models.providers.openrouter.models` so that OpenClaw always routes user photos through `tools.media.image` (`google/gemini-3.8-flash`) for high-precision German OCR before handing enriched text to the chat model (Issue 29).
* **Fallbacks**: * **Fallbacks**:
1. `openrouter/deepseek/deepseek-v4.1-flash`: Rock-solid JSON tool-calling and sub-3s response times via dedicated OpenRouter ASIC cluster, available immediately if GLM experiences any upstream provider queuing or issues. 1. `openrouter/deepseek/deepseek-v4.1-flash` (`0910` baseline across 25+ providers, pinned to `deepinfra`, `streamlake`, `inference-net`, `digitalocean`, `venice`, `nebius`).
2. `openrouter/qwen/qwen3.8-flash`: Provider diversity. 2. `openrouter/z-ai/glm-5.3-flash`: Secondary fallback pinned to `fp8`/`bf16` providers (`morph`, `near-ai`, `digitalocean`, `venice`, `streamlake`).
3. `openrouter/qwen/qwen3.8-flash`: Provider diversity.
### 2. Autonomous Subagents (Tier 2) ### 2. Autonomous Subagents (Tier 2)
* **General Subagents**: `openrouter/z-ai/glm-5.3-flash` * **All Subagents & Specialists**: `openrouter/deepseek/deepseek-v4.1-flash-1017`
- **Execution**: Spawned via `sessions_spawn` with a 300-second timeout. Inherits the `tool_call` protocol guardrails from `AGENTS.md`. - **Execution**: Spawned via `sessions_spawn` with a 300-second timeout. Provides high-precision reasoning, rock-solid nested JSON tool loops (`tool_search` -> `tool_describe` -> `tool_call`), and zero syntax drift.
* **Deep Analysis & IT Specialists (`itguy`, `recherche`)**: `openrouter/deepseek/deepseek-v4.1-flash` * **Fallback**: `openrouter/deepseek/deepseek-v4.1-flash` / `openrouter/z-ai/glm-5.3-flash` / `openrouter/qwen/qwen3.8-flash`.
- High-precision reasoning, robust nested tool loops without syntax drift, and immune to XML leakage on extensive research queries or CLI diagnostics.
* **Fallback**: `openrouter/deepseek/deepseek-v4.1-flash` / `openrouter/qwen/qwen3.8-flash`.
### 3. Background Daemon & Heartbeats (Tier 3) ### 3. Background Daemon & Heartbeats (Tier 3)
* **Configuration**: `heartbeat.model: "openrouter/z-ai/glm-5.3-flash"`, `heartbeat.target: none`, `heartbeat.lightContext: true`. * **Configuration**: `heartbeat.model: "openrouter/deepseek/deepseek-v4.1-flash-1017"`, `heartbeat.target: none`, `heartbeat.lightContext: true`.
* **Rationale**: Decoupled completely from user conversation transcripts. Polling occurs against an isolated, minimal prompt (~400 tokens), dropping weekly heartbeat token burn from 21,000,000 to <150,000 tokens (<\$0.02 / week). * **Rationale**: Because background heartbeats have a 75%+ `cacheRead` share (`22.86M` `cacheRead` vs. `7.73M` uncached input over 17 days), DeepSeek V4.1 Flash 1017's \$0.00294/M cache read rate on DeepInfra reduces 17-day heartbeat cost to <\$0.90 while keeping prompt cache warm on the primary provider.
### 4. Vision Input vs. Text-to-Image Generation (Tier 4) ### 4. Vision Input vs. Text-to-Image Generation (Tier 4)
* **Crucial Distinction**: * **Crucial Distinction**:
- **Vision Input (`imageModel`)**: Reading photos sent to Telegram (e.g. food ingredients, plants, receipts). Handled by `google/gemini-3.8-flash`, with `z-ai/glm-5.3-flash` as fallback. - **Vision Input (`imageModel` vs. Dedicated Primary)**: Reading photos sent to Telegram (e.g. food ingredients, nutrition tables, plants, receipts). Note: OpenClaw only invokes `imageModel` if the primary model *lacks* image input modality. Since `z-ai/glm-5.3-flash` advertises image modality on OpenRouter, OpenClaw does not auto-divert to `imageModel`. Therefore, vision-intensive agents (specifically `koch`) must explicitly configure `primary: "openrouter/google/gemini-3.8-flash"` to leverage Google's SOTA OCR and dense-table recognition.
- **Text-to-Image Synthesis (`mediaModels.image`)**: Creating new pictures upon user request (e.g. *"Zeichne mir ein Profilbild von Octobot"*). Chat LLMs return 404 here. - **Text-to-Image Synthesis (`mediaModels.image`)**: Creating new pictures upon user request (e.g. *"Zeichne mir ein Profilbild von Octobot"*). Chat LLMs return 404 here.
* **Image Synthesis Engine**: * **Image Synthesis Engine**:
- **Primary**: `openrouter/google/gemini-3.1-flash-image` (Nano Banana 2). - **Primary**: `openrouter/google/gemini-3.1-flash-image` (Nano Banana 2).
@@ -171,14 +176,83 @@ To align on remaining edge cases before final sign-off:
--- ---
## OpenRouter Provider Qualification Criteria & Re-Evaluation Playbook
Whenever a new model snapshot (such as `deepseek/deepseek-v4.1-flash-1017`) is evaluated or re-checked as new hosting providers join OpenRouter, every candidate provider MUST pass the following **7-point qualification gate** before being added to `extra_body.provider.order` in [openclaw-instance.secret.yaml](file:///Users/moritz/src/infrapuzzle/k8s/openclaw/openclaw-instance.secret.yaml):
### 1. Hard Technical Gates (Must Pass All)
1. **Quantization Precision (`fp8` / `bf16` Only)**:
- **Rule**: Must run `fp8` or `bf16` (or verified full-precision `unknown` from trusted cloud hosts like Cloudflare).
- **Reject**: Any `fp4`, `nvfp4`, `mxfp4`, or `int4` quantization (`decart`, `sail-research`, `wafer`, `open-inference`, `phala` fp4). 4-bit quantizations severely degrade nested JSON tool-call syntax and German grammar.
2. **OpenRouter Routing Funnel & Guardrail Compatibility (`tools` + `tool_choice: "auto"` Pass)**:
- **Rule**: Must not be stripped by OpenRouter's internal routing filters (`Filter by Tool Compatibility` or `Filter by Guardrails`) when a request includes `tools: [...]` and `tool_choice: "auto"`.
- **Why**: Metadata in `/api/v1/models/.../endpoints` can claim `supports_tool_choice: true` even when OpenRouter's live router blocks the provider on actual tool calls (e.g. `morph/fp8` is stripped by `Filter by Tool Compatibility`, and `deepseek` official is stripped by `Paid model training violation` account privacy settings).
3. **Nested MCP `tool_call` Serialization Integrity (2/2 Live Pass)**:
- **Rule**: Must cleanly emit `finish_reason: "tool_calls"` with valid, parseable nested JSON arguments (`{"id": "mcp:...", "args": {...}}`) across back-to-back test runs without mid-stream SSE aborts (`Provider returned an incomplete or malformed tool call`) or HTTP `429` upstream rate limits.
4. **Context Window & Completion Headroom**:
- **Rule**: `context_length >= 262,144` (1,048,576 preferred) and `max_completion_tokens >= 131,072`.
- **Why**: Active daytime sessions with system prompts and tool schemas reach 20k–80k tokens.
5. **Uptime & Reliability**:
- **Rule**: `uptime_last_1d >= 95%` and `uptime_last_30m == 100%`.
### 2. Economic & Latency Ranking (How to Order Passing Providers)
Among providers passing all 5 technical gates, rank them in `extra_body.provider.order` by **effective production cost** and **turn latency**:
1. **Implicit Prompt Cache Read Rate (`input_cache_read`) — Highest Weight**:
- Because **73%–94%** of OpenClaw's token volume (both interactive multi-turn chats and 30m background heartbeats) consists of cached prompt reads (`cacheRead`), a provider with `supports_implicit_caching: true` and a **95%–98% cache discount** (`<= $0.005 / 1M` tokens, e.g. `deepinfra` at `$0.00294/M` or `streamlake` at `$0.0030/M`) is vastly cheaper in net monthly spend than a provider with slightly cheaper uncached input but weak or missing `cacheRead` discounts (e.g. `nebius` with `cache_read: null` or `modal`/`primeintellect` at `$0.030/M`).
2. **Base Prompt & Completion Pricing**:
- Target `prompt <= $0.18 / 1M` and `completion <= $0.65 / 1M` for top-priority slots; allow higher-priced providers (`<= $0.30 / $1.20`) only in lower fallback slots (#3/#4) for outage resilience.
3. **End-to-End Tool-Call Latency**:
- Target **1.0s – 5.5s** for a ~300-token reasoning + tool-call turn.
### 3. One-Shot Live Verification Command (For Future Checks)
Any agent (including a fast/lightweight model) can audit all currently available OpenRouter endpoints for `deepseek/deepseek-v4.1-flash-1017` in one step by running:
```bash
# 1. Inspect current endpoint metadata, pricing, cache_read, and 24h uptime:
curl -s https://openrouter.ai/api/v1/models/deepseek/deepseek-v4.1-flash-1017/endpoints | \
jq -r '.data.endpoints[] | "\(.provider_name) | tag=\(.tag) | quant=\(.quantization) | ctx=\(.context_length) | prompt=\(.pricing.prompt) | comp=\(.pricing.completion) | cache=\(.pricing.input_cache_read) | uptime1d=\(.uptime_last_1d)"'
# 2. Live-test candidate providers inside openclaw-0 with real nested tool_call payloads:
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c '
import os, json, time, urllib.request
api_key = os.environ["OPENROUTER_API_KEY"]
tools = [{"type": "function", "function": {"name": "tool_call", "parameters": {"type": "object", "properties": {"id": {"type": "string"}, "args": {"type": "object"}}, "required": ["id", "args"]}}}]
candidates = ["deepinfra", "streamlake", "inference-net", "venice", "digitalocean", "novita", "parasail", "cloudflare", "nebius"]
for prov in candidates:
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=json.dumps({
"model": "deepseek/deepseek-v4.1-flash-1017",
"messages": [{"role": "user", "content": "Call tool_call with id=\"mcp:homeassistant:homeassistant__ha_get_state\" and args={\"entity_id\": \"media_player.bad\"}"}],
"tools": tools, "tool_choice": "auto", "max_tokens": 250,
"provider": {"order": [prov], "allow_fallbacks": False}
}).encode(),
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
)
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=20) as r:
b = json.loads(r.read().decode())
tc = b["choices"][0]["message"].get("tool_calls")
args = tc[0]["function"]["arguments"] if tc else "NONE"
print(f"PASS | {prov:15s} | {time.time()-t0:4.2f}s | actual={b.get(\"provider\")} | args={args}")
except urllib.error.HTTPError as e:
print(f"FAIL | {prov:15s} | HTTP {e.code} | {e.read().decode()[:140]}")
except Exception as e:
print(f"FAIL | {prov:15s} | ERR {e}")
'
```
---
## Consequences ## Consequences
### Positive ### Positive
* **Unified Latest-Generation Stack**: Completely eliminates legacy Gemini 2.5 models; standardizes exclusively on Gemini 3.1, GLM 5.3, Whisper V3 Turbo, and Sonar Pro. * **Unified Latest-Generation Stack**: Completely eliminates legacy Gemini 2.5 models; standardizes exclusively on DeepSeek V4.1 Flash 1017, Gemini 3.8/3.1, GLM 5.3, Whisper V3 Turbo, and Sonar Pro.
* **100% Reliable Image Generation**: Correctly routes image generation requests to Nano Banana 2 (`google/gemini-3.1-flash-image`), completely eliminating HTTP 404 generation errors. * **100% Reliable Image Generation**: Correctly routes image generation requests to Nano Banana 2 (`google/gemini-3.1-flash-image`), completely eliminating HTTP 404 generation errors.
* **Cohesive Voice & Audio Pipeline**: Clear architectural documentation of the Whisper V3 Turbo and Gemini 3.1 Flash TTS integration for dossiers and podcasts. * **Cohesive Voice & Audio Pipeline**: Clear architectural documentation of the Whisper V3 Turbo and Gemini 3.1 Flash TTS integration for dossiers and podcasts.
* **Guaranteed Cost Ceiling**: Drops weekly expenditure from **\$12.55 to ~\$1.20–\$1.80** (85–90% savings) through decoupled heartbeats and nightly compaction. * **Guaranteed Cost Ceiling**: Drops weekly expenditure from **\$12.55 to ~\$0.80–\$1.40** (~90% savings) through DeepSeek V4.1 Flash 1017's `$0.00294/M` cache reads, decoupled heartbeats, and nightly compaction.
### Negative & Mitigations ### Negative & Mitigations
* **OpenRouter Provider Variability**: GLM-5.3-Flash prefill throughput varies across OpenRouter hosting providers. * **OpenRouter Provider Rollout Lag on Day-0 Model Releases**: Newly released snapshots initially launch with 5–10 providers before the full fleet (25+ providers) joins over subsequent days.
* *Mitigation*: Daily compaction bounds transcript size; failover to DeepSeek V4.1-Flash is available at zero architectural switching cost. * *Mitigation*: Codified the 7-point provider qualification gate and one-shot test script above, and configured automatic model fallback to the `0910` snapshot (`openrouter/deepseek/deepseek-v4.1-flash`).
@@ -99,7 +99,7 @@ Chosen Option: **Option 3: Comprehensive Everything-as-Code with Pragmatic State
- Cluster IP mapping to the bare-metal ingress endpoint. - Cluster IP mapping to the bare-metal ingress endpoint.
* **Execution**: Fully automated via `terraform plan` / `terraform apply` directly from version-controlled state. * **Execution**: Fully automated via `terraform plan` / `terraform apply` directly from version-controlled state.
#### Tier 4: Smart Home & IoT Edge Hardware (`terraform_shelly/`, `esphome/`, `monitoring/`) #### Tier 4: Smart Home & IoT Edge Hardware (`projects/shelly_taupunktluefter/`, `esphome/`, `monitoring/`)
* **Tooling**: ESPHome, Shelly Terraform Provider, YAML configs. * **Tooling**: ESPHome, Shelly Terraform Provider, YAML configs.
* **Scope**: * **Scope**:
- Shelly relays and smart switches provisioned declaratively via Terraform. - Shelly relays and smart switches provisioned declaratively via Terraform.
+1 -1
View File
@@ -678,7 +678,7 @@ kubectl create ns migrate
# backup on moritzgraf.de # backup on moritzgraf.de
ssh moritzgraf.de "sudo su - docker -c 'cd /home/docker/mailu && docker-compose stop' && sudo su - root -c 'cd /home/docker && tar czvf mailu.tar.gz ./mailu && mv mailu.tar.gz /home/moritz/ && chown moritz. /home/moritz/mailu.tar.gz' && scp mailu.tar.gz haumdaucher.de:/home/moritz/" ssh moritzgraf.de "sudo su - docker -c 'cd /home/docker/mailu && docker-compose stop' && sudo su - root -c 'cd /home/docker && tar czvf mailu.tar.gz ./mailu && mv mailu.tar.gz /home/moritz/ && chown moritz. /home/moritz/mailu.tar.gz' && scp mailu.tar.gz haumdaucher.de:/home/moritz/"
# terraform change # terraform change
cd ../terraform/infrastructure && terraform apply cd ../terraform && terraform apply
# helm apply # helm apply
cd ../k8s cd ../k8s
helm upgrade --create-namespace --install mailu mailu/mailu -n mailu -f mailu/mailu.secret.yml helm upgrade --create-namespace --install mailu mailu/mailu -n mailu -f mailu/mailu.secret.yml
+2 -2
View File
@@ -170,10 +170,10 @@ The Authentik login screen is customized with a **Haumdaucher** glassmorphism th
* **Branded Footer**: "HAUMDAUCHER Wurst und Spezialitäten n.e.V." subtle watermark. * **Branded Footer**: "HAUMDAUCHER Wurst und Spezialitäten n.e.V." subtle watermark.
### Managing Theme via Terraform ### Managing Theme via Terraform
Branding is provisioned via the `authentik_brand` resource in `terraform/infrastructure/authentik.tf` using the stylesheet `terraform/infrastructure/haumdaucher-theme.css`: Branding is provisioned via the `authentik_brand` resource in `terraform/authentik.tf` using the stylesheet `terraform/haumdaucher-theme.css`:
```bash ```bash
cd terraform/infrastructure cd terraform
terraform apply -target=authentik_brand.haumdaucher terraform apply -target=authentik_brand.haumdaucher
``` ```
+5 -5
View File
@@ -22,7 +22,7 @@ This guide covers everything you need to know about managing users in Authentik
| Question | Answer & Key Mechanism | | Question | Answer & Key Mechanism |
| :--- | :--- | | :--- | :--- |
| **How to add new users?** | **4 Ways**: (1) Admin Web UI (`Directory > Users`), (2) **Invitations flow** (`Directory > Invitations` - recommended for self-service), (3) **Terraform** (`authentik_user` resource), or (4) **REST API / Python script** for bulk imports. | | **How to add new users?** | **4 Ways**: (1) Admin Web UI (`Directory > Users`), (2) **Invitations flow** (`Directory > Invitations` - recommended for self-service), (3) **Terraform** (`authentik_user` resource), or (4) **REST API / Python script** for bulk imports. |
| **Can this be done in Terraform?** | **Yes.** Use `resource "authentik_user"` in `terraform/infrastructure/authentik.tf`. Supports custom attributes via `jsonencode()`, group assignments, and active status. | | **Can this be done in Terraform?** | **Yes.** Use `resource "authentik_user"` in `terraform/authentik.tf`. Supports custom attributes via `jsonencode()`, group assignments, and active status. |
| **Can I change the email sent to the user?** | **Yes.** Configured in **Flows & Stages > Stages > Email Stage**. You can customize the subject line, token expiration, and HTML template (written in Django template syntax with access to user fields and custom attributes). | | **Can I change the email sent to the user?** | **Yes.** Configured in **Flows & Stages > Stages > Email Stage**. You can customize the subject line, token expiration, and HTML template (written in Django template syntax with access to user fields and custom attributes). |
| **Can I limit what a user has access to?** | **Yes.** Through **Policy Bindings** attached to Applications: Group Membership Policies (e.g., `infra-admins` vs `users`), IP/Geo Policies, or Python Expression Policies checking custom attributes (e.g., only active club members). | | **Can I limit what a user has access to?** | **Yes.** Through **Policy Bindings** attached to Applications: Group Membership Policies (e.g., `infra-admins` vs `users`), IP/Geo Policies, or Python Expression Policies checking custom attributes (e.g., only active club members). |
| **How to handle Excel tables with custom attributes?** | Authentik stores arbitrary key-value pairs in `user.attributes` (JSON). You can import an Excel/CSV file with a Python script or Terraform `csvdecode()`, map fields like `haumdaucher_mitgliedsnummer` or `second_email`, and expose them to downstream apps (Nextcloud, Grafana, Gitea) via OIDC Scope Mappings. | | **How to handle Excel tables with custom attributes?** | Authentik stores arbitrary key-value pairs in `user.attributes` (JSON). You can import an Excel/CSV file with a Python script or Terraform `csvdecode()`, map fields like `haumdaucher_mitgliedsnummer` or `second_email`, and expose them to downstream apps (Nextcloud, Grafana, Gitea) via OIDC Scope Mappings. |
@@ -75,7 +75,7 @@ If you encounter this screen when opening an invite link:
### Solution: The Haumdaucher Custom Invitation Flow (`haumdaucher-invitation-flow`) ### Solution: The Haumdaucher Custom Invitation Flow (`haumdaucher-invitation-flow`)
We provision a dedicated, streamlined flow in `terraform/infrastructure/authentik.tf`: We provision a dedicated, streamlined flow in `terraform/authentik.tf`:
```mermaid ```mermaid
graph LR graph LR
@@ -122,7 +122,7 @@ graph LR
## 🏗️ Method 2: User Provisioning via Terraform ## 🏗️ Method 2: User Provisioning via Terraform
You can manage users, groups, and permissions directly in `terraform/infrastructure/authentik.tf`. You can manage users, groups, and permissions directly in `terraform/authentik.tf`.
### 1. Single User Definition ### 1. Single User Definition
```hcl ```hcl
@@ -161,7 +161,7 @@ max.mustermann,Max Mustermann,max@example.com,max.privat@gmail.com,HM-2024-042,f
anna.schmidt,Anna Schmidt,anna@example.com,anna.work@company.de,HM-2024-043,true anna.schmidt,Anna Schmidt,anna@example.com,anna.work@company.de,HM-2024-043,true
``` ```
In `terraform/infrastructure/authentik.tf`: In `terraform/authentik.tf`:
```hcl ```hcl
locals { locals {
csv_users = csvdecode(file("${path.module}/users.csv")) csv_users = csvdecode(file("${path.module}/users.csv"))
@@ -479,7 +479,7 @@ graph TD
| **haumdaucher.de** | `haumdaucher.de` | ForwardAuth / OIDC | **`Admin`** ✅ | **`Member Access`** ✅ | ❌ *Denied* | | **haumdaucher.de** | `haumdaucher.de` | ForwardAuth / OIDC | **`Admin`** ✅ | **`Member Access`** ✅ | ❌ *Denied* |
### Policy Bindings in Terraform ### Policy Bindings in Terraform
All policy bindings are enforced directly in `terraform/infrastructure/authentik.tf`: All policy bindings are enforced directly in `terraform/authentik.tf`:
```hcl ```hcl
# 1. Admin-Only Tools (Restricted strictly to infra-admins) # 1. Admin-Only Tools (Restricted strictly to infra-admins)
+154
View File
@@ -0,0 +1,154 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: duckdb-backend
namespace: family
labels:
app.kubernetes.io/name: duckdb-backend
app.kubernetes.io/part-of: family-portal
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: duckdb-backend
template:
metadata:
labels:
app.kubernetes.io/name: duckdb-backend
app.kubernetes.io/part-of: family-portal
spec:
imagePullSecrets:
- name: registry-haumdaucher-de
initContainers:
- name: git-init
image: alpine/git:latest
command: ["/bin/sh", "-c"]
args:
- |
set -e
export HOME=/tmp
cd /wiki
if [ ! -d ".git" ]; then
echo "Initializing wiki repository on dedicated PVC..."
REPO_URL="https://${GIT_SYNC_USERNAME}:${GIT_SYNC_PASSWORD}@git.moritzgraf.de/moritz/knowledge-wiki.git"
git config --global http.sslVerify false
git config --global --add safe.directory /wiki
git config --global --add safe.directory '*'
git init
git remote add origin "$REPO_URL" || git remote set-url origin "$REPO_URL"
git fetch origin main
git checkout -f -B main origin/main
echo "Clone completed successfully."
else
echo "Repository already initialized on PVC, updating..."
REPO_URL="https://${GIT_SYNC_USERNAME}:${GIT_SYNC_PASSWORD}@git.moritzgraf.de/moritz/knowledge-wiki.git"
git config --global http.sslVerify false
git config --global --add safe.directory /wiki
git config --global --add safe.directory '*'
git remote set-url origin "$REPO_URL"
if [ -f .git/shallow ]; then
echo "Unshallowing existing repository..."
git fetch --unshallow origin main || git fetch origin main
else
git fetch origin main
fi
git rebase --abort 2>/dev/null || true
git merge --abort 2>/dev/null || true
git reset --hard origin/main
git clean -fd
fi
env:
- name: GIT_SYNC_USERNAME
valueFrom:
secretKeyRef:
name: wiki-git-credentials
key: git-sync-username
- name: GIT_SYNC_PASSWORD
valueFrom:
secretKeyRef:
name: wiki-git-credentials
key: wiki-sync-token
volumeMounts:
- name: duckdb-wiki-data
mountPath: /wiki
containers:
- name: duckdb-backend
image: registry.haumdaucher.de/family/duckdb-backend:latest
imagePullPolicy: Always
env:
- name: DATA_DIR
value: "/wiki/data"
- name: DUCKDB_DATA_DIR
value: "/wiki/data"
- name: WIKI_DIR
value: "/wiki"
- name: GIT_REPO_DIR
value: "/wiki"
- name: GIT_REPO_URL
value: "https://git.moritzgraf.de/moritz/knowledge-wiki.git"
- name: GIT_REMOTE_URL
value: "https://git.moritzgraf.de/moritz/knowledge-wiki.git"
- name: GIT_SYNC_USERNAME
valueFrom:
secretKeyRef:
name: wiki-git-credentials
key: git-sync-username
- name: GIT_SYNC_PASSWORD
valueFrom:
secretKeyRef:
name: wiki-git-credentials
key: wiki-sync-token
- name: GIT_SYNC_INTERVAL_SECONDS
value: "60"
- name: PORT
value: "8000"
- name: HOST
value: "0.0.0.0"
ports:
- name: http
containerPort: 8000
volumeMounts:
- name: duckdb-wiki-data
mountPath: /wiki
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 1000m
memory: 1Gi
readinessProbe:
httpGet:
path: /duckdb-api/v1/healthz
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /duckdb-api/v1/healthz
port: 8000
initialDelaySeconds: 15
periodSeconds: 20
volumes:
- name: duckdb-wiki-data
persistentVolumeClaim:
claimName: duckdb-wiki-data
---
apiVersion: v1
kind: Service
metadata:
name: duckdb-backend
namespace: family
labels:
app.kubernetes.io/name: duckdb-backend
app.kubernetes.io/part-of: family-portal
spec:
type: ClusterIP
ports:
- port: 8000
targetPort: 8000
name: http
selector:
app.kubernetes.io/name: duckdb-backend
Binary file not shown.
+55
View File
@@ -0,0 +1,55 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: family-portal
namespace: family
annotations:
kubernetes.io/ingress.class: "traefik"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
kubernetes.io/tls-acme: "true"
traefik.ingress.kubernetes.io/router.middlewares: auth-authentik-forwardauth@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- family.haumdaucher.de
secretName: family-haumdaucher-de-tls
rules:
- host: family.haumdaucher.de
http:
paths:
- path: /duckdb-api
pathType: Prefix
backend:
service:
name: duckdb-backend
port:
number: 8000
- path: /tracking/mila
pathType: Prefix
backend:
service:
name: mila
port:
number: 80
- path: /tracking/moritz
pathType: Prefix
backend:
service:
name: moritz
port:
number: 80
- path: /outpost.goauthentik.io
pathType: Prefix
backend:
service:
name: duckdb-backend
port:
number: 8000
- path: /
pathType: Prefix
backend:
service:
name: mila
port:
number: 80
+64
View File
@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mila
namespace: family
labels:
app.kubernetes.io/name: mila
app.kubernetes.io/part-of: family-portal
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: mila
template:
metadata:
labels:
app.kubernetes.io/name: mila
app.kubernetes.io/part-of: family-portal
spec:
imagePullSecrets:
- name: registry-haumdaucher-de
containers:
- name: mila
image: registry.haumdaucher.de/family/mila:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 80
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
readinessProbe:
httpGet:
path: /tracking/mila/
port: 80
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /tracking/mila/
port: 80
initialDelaySeconds: 10
periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
name: mila
namespace: family
labels:
app.kubernetes.io/name: mila
app.kubernetes.io/part-of: family-portal
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 80
name: http
selector:
app.kubernetes.io/name: mila
+64
View File
@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: moritz
namespace: family
labels:
app.kubernetes.io/name: moritz
app.kubernetes.io/part-of: family-portal
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: moritz
template:
metadata:
labels:
app.kubernetes.io/name: moritz
app.kubernetes.io/part-of: family-portal
spec:
imagePullSecrets:
- name: registry-haumdaucher-de
containers:
- name: moritz
image: registry.haumdaucher.de/family/moritz:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 80
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
readinessProbe:
httpGet:
path: /tracking/moritz/
port: 80
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /tracking/moritz/
port: 80
initialDelaySeconds: 10
periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
name: moritz
namespace: family
labels:
app.kubernetes.io/name: moritz
app.kubernetes.io/part-of: family-portal
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 80
name: http
selector:
app.kubernetes.io/name: moritz
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: family
labels:
name: family
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: duckdb-wiki-data
namespace: family
labels:
app.kubernetes.io/name: duckdb-backend
app.kubernetes.io/part-of: family-portal
spec:
accessModes:
- ReadWriteOnce
storageClassName: openebs-hostpath
resources:
requests:
storage: 5Gi
Binary file not shown.
+30 -9
View File
@@ -10,15 +10,37 @@ spec:
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: Middleware kind: Middleware
metadata: metadata:
name: hosting-legacy-redirect name: hosting-redirect-mila
namespace: minio namespace: minio
spec: spec:
redirectRegex: redirectRegex:
regex: "^https?://([^/]+)/(mila|moritz|nutrition|sleep)\\.html(.*)" regex: "^https?://([^/]+)/(?:family/)?(mila|sleep)\\.html.*"
replacement: "https://${1}/family/${2}.html${3}" replacement: "https://family.haumdaucher.de/tracking/mila/"
permanent: false permanent: true
--- ---
# Public Ingress for hosting.haumdaucher.de (Recipes, Public Assets) apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: hosting-redirect-moritz
namespace: minio
spec:
redirectRegex:
regex: "^https?://([^/]+)/(?:family/)?(moritz|nutrition)\\.html.*"
replacement: "https://family.haumdaucher.de/tracking/moritz/"
permanent: true
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: hosting-redirect-family-root
namespace: minio
spec:
redirectRegex:
regex: "^https?://([^/]+)/family/?$"
replacement: "https://family.haumdaucher.de/"
permanent: true
---
# Public Ingress for hosting.haumdaucher.de (Recipes, Public Assets, Legacy Redirects)
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
@@ -29,7 +51,7 @@ metadata:
cert-manager.io/cluster-issuer: "letsencrypt-prod" cert-manager.io/cluster-issuer: "letsencrypt-prod"
kubernetes.io/tls-acme: "true" kubernetes.io/tls-acme: "true"
traefik.ingress.kubernetes.io/router.priority: "10" traefik.ingress.kubernetes.io/router.priority: "10"
traefik.ingress.kubernetes.io/router.middlewares: minio-hosting-legacy-redirect@kubernetescrd,minio-hosting-add-prefix@kubernetescrd traefik.ingress.kubernetes.io/router.middlewares: minio-hosting-redirect-mila@kubernetescrd,minio-hosting-redirect-moritz@kubernetescrd,minio-hosting-redirect-family-root@kubernetescrd,minio-hosting-add-prefix@kubernetescrd
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -48,8 +70,7 @@ spec:
- hosting.haumdaucher.de - hosting.haumdaucher.de
secretName: hosting-haumdaucher-de-tls secretName: hosting-haumdaucher-de-tls
--- ---
# Protected Ingress for /family (Family Dashboards, Nutrition & Sleep Analytics) # Protected Ingress for /family (Redirects and Authentik Outpost)
# Protected by Authentik ForwardAuth
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
@@ -60,7 +81,7 @@ metadata:
cert-manager.io/cluster-issuer: "letsencrypt-prod" cert-manager.io/cluster-issuer: "letsencrypt-prod"
kubernetes.io/tls-acme: "true" kubernetes.io/tls-acme: "true"
traefik.ingress.kubernetes.io/router.priority: "100" traefik.ingress.kubernetes.io/router.priority: "100"
traefik.ingress.kubernetes.io/router.middlewares: auth-authentik-forwardauth@kubernetescrd,minio-hosting-add-prefix@kubernetescrd traefik.ingress.kubernetes.io/router.middlewares: minio-hosting-redirect-mila@kubernetescrd,minio-hosting-redirect-moritz@kubernetescrd,minio-hosting-redirect-family-root@kubernetescrd,auth-authentik-forwardauth@kubernetescrd,minio-hosting-add-prefix@kubernetescrd
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
+16
View File
@@ -60,8 +60,24 @@ To perform modifications or rotate api keys:
./k8s/openclaw/setup-garmin-cron.sh # Nightly Garmin Connect sync (Daily 03:30) ./k8s/openclaw/setup-garmin-cron.sh # Nightly Garmin Connect sync (Daily 03:30)
./k8s/openclaw/setup-compaction-cron.sh # Nightly multi-agent session compaction (Daily 04:45) ./k8s/openclaw/setup-compaction-cron.sh # Nightly multi-agent session compaction (Daily 04:45)
./k8s/openclaw/setup-itguy-cron.sh # Weekly health audit (Sun 03:00) ./k8s/openclaw/setup-itguy-cron.sh # Weekly health audit (Sun 03:00)
# Or run declarative reconciler:
python3 k8s/openclaw/scripts/sync_crons.py
``` ```
### 🛡️ Everything-as-Code (EaC) Governance for New Crons, Skills & Binaries (ADR 0003)
To prevent PVC snowflake drift, **every** new automation, skill, CLI binary, or Python dependency added to OpenClaw MUST be persisted as code:
1. **New Cron Jobs (`Crons-as-Code`)**:
- **System / Recurring Automations**: Never leave a recurring cron job exclusively inside the pod's SQLite database (`openclaw cron add`). Every permanent automation MUST be codified in `k8s/openclaw/crons.yaml` (or the declarative cron sync scripts) and committed to `infrapuzzle`.
- **Dynamic / User-Created Reminders**: Conversational or temporary crons created by agents at runtime must be exported to `knowledge-wiki/operations/crons-backup.json` during nightly backup so they survive PVC loss.
2. **New Skills (`Skills-as-Code`)**:
- Never leave newly created or agent-authored skills only in `/home/node/.openclaw/skills/`, `workspace*/skills/`, or `skill-workshop/` on the PVC.
- All custom skills MUST be committed and pushed to the **`mop-skills`** repository (`git.moritzgraf.de/moritz/mop-skills`) so they are version-controlled and synced cleanly via the `git-sync` sidecar.
3. **New CLI Binaries, Packages & Patches (`Dependencies-as-Code`)**:
- Never manually drop binaries into `/home/node/.openclaw/bin/` or run ad-hoc `uv pip install` / `npm install -g` on the PVC without codifying them.
- Every new binary (e.g. `ffmpeg`, `kubectl`, `jq`, `openhue`, `sonos`), Python package in `mcp-venv`, or runtime patch MUST be added to the declarative bootstrap script (`k8s/openclaw/scripts/bootstrap-deps.sh` / `configmap-scripts.yaml`) so a fresh PVC rebuild has 100% tool parity.
--- ---
### 4. 🗂️ The Three Core Repositories Architecture & Weekly Self-Audit ### 4. 🗂️ The Three Core Repositories Architecture & Weekly Self-Audit
+180
View File
@@ -870,4 +870,184 @@ This document captures fixed issues, operational quirks, and incident root-cause
- Keep `keepRecentTokens: 3000` and `maxActiveTranscriptBytes: 180kb` in declarative manifests. - Keep `keepRecentTokens: 3000` and `maxActiveTranscriptBytes: 180kb` in declarative manifests.
- Rely on deep-compaction with fallback rather than hard-resets across all topic agents. - Rely on deep-compaction with fallback rather than hard-resets across all topic agents.
---
### Issue 27: OpenClaw Vision Routing Bypass, GLM-5.3-Flash Image OCR Failure, and Tool-Calling Protocol Hardening
- **Date**: 2026-10-12
- **Severity**: High (Photos in Topic 21 #Kochen rejected as "blurry" despite 1080p high quality, turns halted with "The tool run finished, but no final summary was produced", tool errors in recherche/grabengasse)
- **Symptom**:
1. The user posted high-resolution photos of food packaging and nutrition labels (e.g. IronMaxx Clear Whey Isolate Zero Kirsche, 1080x1064 px).
2. Koch agent repeatedly responded that the photo was blurry and illegible.
3. Internal chain-of-thought hallucinated that the label was a small blurry thumbnail with ~4px text reading "MIRKIC" instead of "KIRSCHE".
4. The turn completed with `stopReason: "stop"` without emitting any visible text, causing OpenClaw to emit `The tool run finished, but no final summary was produced. I did not repeat any completed actions.`
5. Ad-hoc Python image manipulation failed due to missing `PIL` (Pillow).
6. Sporadic tool call errors across topics: `tool_search` wrapped in `tool_call`, `sessions_history` negative offsets, `ask_user` flat parameters, missing `googleapiclient` without `uv run`.
- **Root Cause**:
1. **OpenClaw `imageModel` Fallback Behavior**:
OpenClaw's configuration schema defines `imageModel` as: *"Optional image model used when the primary model lacks image input."*
Because `z-ai/glm-5.3-flash` advertises `input_modalities: ["text", "image", "video"]` on OpenRouter, OpenClaw considered it image-capable and never routed images to `imageModel: "openrouter/google/gemini-3.8-flash"`.
GLM-5.3-Flash's actual vision perception on dense German labels is poor, leading it to misread text, conclude it cannot read it, and finish its thinking block without generating any output text.
2. **Empty Turn Finalization Fallback**:
When a model finishes with only thinking tokens and zero output text or tool calls, OpenClaw's delivery mirror triggers `settled-finalization-fallback`.
3. **Python Runtime Deficiencies**:
Core libraries (`pillow`, `google-api-python-client`, `pypdf`) were not installed in the persistent Python interpreter at `/home/openclaw/.openclaw/.local/python/...`.
- **Resolution**:
1. **Dedicated Vision Model for Koch**:
Configured `koch`'s primary model to `openrouter/google/gemini-3.8-flash` (fallbacks: `glm-5.3-flash`, `deepseek-v4.1-flash`) both live via `openclaw config set` and in `openclaw-instance.secret.yaml`. Gemini 3.8 Flash provides SOTA OCR and dense-table extraction at $0.075 / 1M cached prompt tokens.
2. **Prompt-Hardening in `knowledge-wiki`**:
- Updated `knowledge-wiki/agents/koch.md` with strict rules: mandatory visible text output on every turn (no silent thinking stops), Vision-first protocol (transcribe all tables without claiming blurriness).
- Updated `knowledge-wiki/agents/README.md` with cross-agent tool-calling standards (`tool_search` top-level only, `sessions_history` non-negative offset + `sessionKey`, `ask_user` questions array, `uv run` for Python scripts).
3. **Pre-installed Python Libraries**:
Installed `pillow`, `google-api-python-client`, and `pypdf` into the persistent Python environment using `uv pip install --break-system-packages`, and added the command to `openclaw-instance.secret.yaml` for permanent self-healing.
4. **Retroactive Meal Intake Recovery & Compaction**:
The user prompt (`540g selbst gemachte Lasagne mit bechqmele sauce`) that was dropped during the unfinalized GLM loop was logged retroactively into DuckDB (`01M4RQT5PFNN7J4307TCRM3E80`, ~864 kcal, ~43.2 g Protein). Daily totals (both portions 540g + 430g and the 30g Clear Whey shake) updated to 3,266 kcal / 177.9 g protein, web dashboards re-rendered, and Topic 21 compacted from 49.6k tokens down to 4.6k tokens.
- **How to Avoid Recurrence**:
- For agents that routinely process user photos or document scans, assign dedicated vision models (`google/gemini-3.8-flash`) as the agent's primary model rather than relying on `agents.defaults.imageModel`.
- Maintain the response guarantee in agent prompts to prevent empty turns when reasoning models encounter uncertainty.
---
### Issue 28: Topic 20 #Sport Transcript Blindness: GLM-5.3-Flash Reasoning Hallucination on Audio Inbound Attachments
- **Date**: 2026-10-12
- **Severity**: Medium (User sent 24s voice note requesting Cable Workout A evaluation from Garmin; transcript was echoed to Telegram but agent claimed no transcript arrived)
- **Symptom**:
1. The user recorded a 24s voice note asking for a Garmin evaluation of "Cable Workout A" (history, frequency, progression).
2. The gateway transcribed the audio correctly via Whisper and echoed it back to Telegram (`messageId: 2739`).
3. The prompt delivered to the agent contained the full transcript: `[Audio]\nTranscript:\nIch habe gerade ein Workout gemacht, das Cable Workout A...`.
4. Yet after 78 tool steps and 19.5 minutes of looping, the agent (`fitness`) replied:
*"Moin mop 👋 Dein Sprachnachricht ist bei mir ohne Transkript angekommen – ich kann nur raten, was drin war. 🎙️"*.
- **Root Cause**:
1. **GLM-5.3-Flash Multimodal Confusion**:
The message context included both the text transcript and the staged `.ogg` attachment in `message.media`.
In its internal reasoning (`thinking`), GLM-5.3-Flash hallucinated:
*"Voice message from Moritz. Need transcript — it's not provided this time? The context shows a voice message but no transcript. Hmm, the inbound is voice; transcript usually would be in context. Not visible. Maybe I need to transcribe."*
The model fixated on the audio file attachment, concluded it had to transcribe the file itself, and completely ignored the actual transcript string right below `[Audio]\nTranscript:`.
2. **19.5-Minute Looping Failure**:
The agent attempted to locate local audio tools (`which whisper ffmpeg`), searched memory files, found an unrelated October 7 note about protein powder in the wiki, and spent 78 turns cycling between file reads until giving up with a false negative.
- **Resolution**:
1. **Model Upgraded to Gemini 3.8 Flash**:
Replaced `primary: "openrouter/z-ai/glm-5.3-flash"` with `openrouter/google/gemini-3.8-flash` (fallbacks: `glm-5.3-flash`, `deepseek-v4.1-flash`, `qwen3.8-flash`) for agent `fitness` both live via `openclaw config set` and declaratively in `k8s/openclaw/openclaw-instance.secret.yaml`. Gemini 3.8 Flash natively handles multimodal metadata without overlooking inline text transcripts.
2. **Garmin Workout Data Synced & Evaluated**:
Executed `garmin_sync.py` to mirror the latest workouts into `data/garmin/activities/2026-10.jsonl`.
Retrieved all 3 sessions of Moritz's `SuperSetACable-55mins` (2026-09-28, 2026-10-06, 2026-10-12) and extracted the workout progression data.
- **How to Avoid Recurrence**:
- In agent system prompts, reinforce that `[Audio]\nTranscript:\n<text>` already contains the authoritative, finalized speech-to-text result and requires no local audio file re-processing.
### Issue 29: Decoupled Vision Perception — Gemini 3.8 Flash Media-Understanding with GLM-5.3-Flash Primary Chat Engine
- **Symptoms**:
- Switching the primary agent model completely to `google/gemini-3.8-flash` changed the entire conversational tone and agent persona across all text turns, whereas only high-fidelity OCR and nutrition image perception were desired.
- When `openrouter/z-ai/glm-5.3-flash` was the primary model, OpenClaw previously routed photos natively to GLM because the OpenRouter model catalog declared multimodal capability (`image`), causing GLM to fail at OCR reading of nutritional labels.
- **Root Cause & Architectural Discovery**:
1. OpenClaw checks `activeModelSupportsNativeVision` and `resolveRunModelHasVision`. If the primary model advertises vision on OpenRouter AND no explicit image understanding models are declared in `tools.media.models`, OpenClaw bypasses the `media-understanding` pipeline and passes raw images natively to the chat engine.
2. Because GLM-5.3-Flash struggled with German nutritional tables, the entire primary model had been switched to Gemini 3.8 Flash, which Moritz explicitly rejected ("Haben wir nun das Modell für den Koch agent komplett auf Gemini 3.8 flash gewechselt? Das wollte ich nicht. Nur für die Bilderkennung bitte.").
- **Resolution**:
1. **Reverted Primary Chat Models**:
Reverted `agents.entries.koch.model.primary` and `agents.entries.fitness.model.primary` back to `openrouter/z-ai/glm-5.3-flash` (with fallbacks `deepseek-v4.1-flash`, `qwen3.8-flash`) in both `openclaw.json` and `openclaw-instance.secret.yaml`.
2. **Configured Dedicated Media-Understanding Image Engine**:
Added an explicit `image` capability model under `tools.media.models`:
```yaml
tools:
media:
image:
enabled: true
preferredModel: "openrouter/google/gemini-3.8-flash"
models:
- provider: openrouter
model: "google/gemini-3.8-flash"
capabilities: ["image"]
prompt: "Beschreibe das Bild detailliert auf Deutsch. Wenn Nährwertangaben, Zutaten, Barcode, Verpackungstext oder handschriftliche Notizen zu sehen sind, transkribiere alle Zahlen, Gramm-Angaben und Nährwerte (Kalorien, Eiweiß/Protein, Kohlenhydrate, Fett) vollständig und exakt."
```
3. **Declared Text-Only Override for GLM**:
Under `models.providers.openrouter.models`, registered `z-ai/glm-5.3-flash` with `input: ["text"]`. This signals to OpenClaw that GLM must never receive raw images natively, forcing automatic routing of all image attachments through Gemini 3.8 Flash media-understanding before handing off enriched text to GLM.
4. **Updated Persisted SQLite Sessions**:
Reset `session_nodes` for `agent:koch:telegram:group:-1004326921510:topic:21` and `agent:fitness:telegram:group:-1004326921510:topic:20` to `z-ai/glm-5.3-flash`.
- **How to Avoid Recurrence**:
- Always decouple perception tasks (OCR, image description, speech-to-text) into `tools.media` or `imageModel`, keeping primary conversational models focused on reasoning and dialogue without changing the core chat persona.
---
### Issue 30: GLM-5.3-Flash Malformed Tool Call Stream Aborts (`streamlake/fp8`), Cold HDD Prompt Assembly Stall, and Fleet-Wide Migration to DeepSeek V4.1 Flash
- **Date**: 2026-10-17
- **Severity**: High (Topic 164 `#Grabengasse & Media` failed with `Provider returned an incomplete or malformed tool call`; 12 total occurrences across 5 agents in 17 days; 504.5s cold-disk event-loop stall on first morning request)
- **Symptom**:
1. At 07:11:02 CEST, Moritz sent a voice note in Topic 164 (`#Grabengasse & Media`, agent `grabengasse_media`): *"Kannst du mir auf dem Sonos in der Küche die Bibi Blocksberg Playlist Der Affe ist los abspielen?"*
2. The turn stalled for **8 minutes and 24 seconds** (`504,493 ms`) during `system-prompt` assembly (`cpuCoreRatio=0.005`, pure disk I/O wait) before sending the first LLM request at 07:19:26 CEST.
3. In Turn 1 (`SEQ 175`), `z-ai/glm-5.3-flash` called `tool_search({"query": "sonos spotify play"})`. In Turn 2 (`SEQ 177`), it called `tool_describe({"name": "mcp:spotify:spotify__search"})`.
4. In Turn 3 (`SEQ 179`, `responseId: gen-1792214444-T46fmmBLqgmCCe0YJp4X`), after generating 326 reasoning tokens (`"Search for 'Bibi Blocksberg Der Affe ist los' using mcp:spotify:spotify__search"`), the upstream OpenRouter provider (`streamlake/fp8`) aborted the stream mid-JSON while serializing nested `tool_call({"id": "mcp:spotify:spotify__search", "args": {...}})` arguments:
`stopReason: "error"`, `errorMessage: "Provider returned an incomplete or malformed tool call"`.
5. OpenClaw only retries or triggers model fallbacks on HTTP status errors (`429`, `502`, `503`, `504`) prior to stream start—not when an HTTP `200 OK` SSE stream terminates mid-generation with a malformed tool-call chunk.
- **Root Cause & Fleet Forensics**:
1. **`z-ai/glm-5.3-flash` Nested Tool-Call Fragility on `streamlake/fp8` & `siliconflow/fp8`**:
- Across October 1–17, 2026, `Provider returned an incomplete or malformed tool call` occurred **12 times** across `main` (6×), `grabengasse_media` (3×), `koch` (1×), `hebamme` (1×), and `doktor` (1×)—exclusively on `z-ai/glm-5.3-flash`, always right after `tool_describe` when emitting nested `tool_call` JSON payloads.
2. **Suboptimal OpenRouter Provider Routing**:
- `extra_body.provider.order` for `z-ai/glm-5.3-flash` listed `"cloudflare"` first (which does not host `glm-5.3-flash` on OpenRouter), falling through to `streamlake/fp8` (91.7% uptime) and `siliconflow/fp8` (74.1% uptime), and did not block `fp4` (`nvfp4`) quantizations (`phala`, `atlas-cloud`, `parasail`).
3. **Fleet-Wide Cost & Cache-Hit Economics (`DeepSeek V4.1 Flash` vs. `GLM 5.3 Flash`)**:
- Over the 17-day period (Oct 1–17, 2026), 73% to 94% of all tokens across interactive agents (`21.93M` `cacheRead` vs. `8.94M` uncached input) and background heartbeats (`22.86M` `cacheRead` vs. `7.73M` uncached input) were prompt cache reads.
- While `deepseek/deepseek-v4.1-flash` has a slightly higher base uncached prompt rate (`$0.14–$0.15/M` vs. `$0.06–$0.075/M`), its top `fp8` providers (`deepinfra` and `streamlake`, both 100% uptime and full `tool_choice` support) charge **3.5× to 10× less for `cacheRead` tokens** (`$0.0042/M` on DeepInfra, `$0.0030/M` on StreamLake vs. `$0.015–$0.030/M` on GLM 5.3 Flash).
- Consequently, running the entire fleet (interactive agents + background heartbeats) on `deepseek/deepseek-v4.1-flash` via `deepinfra`/`streamlake` costs **~$2.69 over 17 days (~$4.75/month)** vs. **$4.50 actual on `GLM 5.3 Flash` (~$7.95/month)**—a **~40% net cost reduction** alongside 0 malformed tool calls.
4. **Cold Rotational HDD Read Stall After Nightly Backups**:
- Nightly backups (`03:30`) and compaction (`04:45`) evicted the Knowledge Wiki and agent workspace `*.md` files from the Linux kernel page cache, causing the first morning turn's synchronous `system-prompt` loader to block on rotational SATA HDD seeks for 504.5s.
- **Resolution**:
1. **Fleet-Wide Migration to `openrouter/deepseek/deepseek-v4.1-flash-1017` (with `deepseek-v4.1-flash` 0910 as first fallback)**:
- Switched `agents.defaults.model.primary`, `utilityModel`, `heartbeat.model`, `subagents.model`, `memory-core.config.dreaming.model`, and all interactive agent entries (`main`, `fitness`, `koch`, `hebamme`, `travel`, `shopping`, `regensburg`, `recherche`, `doktor`, `itguy`, `grabengasse_media`, `mila_tracker`) to the newly released October 17, 2026 snapshot `openrouter/deepseek/deepseek-v4.1-flash-1017` (Artificial Analysis Intelligence Index 39.5 vs. 24.7 on 0910, and 30% cheaper on DeepInfra at `$0.098/M` prompt, `$0.294/M` completion, `$0.00294/M` `cacheRead`), with `openrouter/deepseek/deepseek-v4.1-flash`, `openrouter/z-ai/glm-5.3-flash`, and `openrouter/qwen/qwen3.8-flash` as fallbacks (`nutrition_tracker` remains on `openrouter/google/gemini-3.8-flash`).
2. **Preserved Decoupled Vision Perception (Issue 29 Compatibility)**:
- Registered both `deepseek/deepseek-v4.1-flash-1017` and `deepseek/deepseek-v4.1-flash` with `input: ["text"]` under `models.providers.openrouter.models` in `openclaw-instance.secret.yaml` so OpenClaw continues routing all image attachments through `tools.media.image` (`google/gemini-3.8-flash`) for OCR before passing enriched text to DeepSeek V4.1 Flash.
3. **Hardened OpenRouter Provider & Quantization Routing (`extra_body.provider`)**:
- Configured `openrouter/deepseek/deepseek-v4.1-flash-1017` with `order: ["deepinfra", "inference-net", "parasail", "cloudflare"]`, `quantizations: ["fp8", "bf16", "unknown"]`, and `ignore: ["morph", "baseten", "deepseek", "open-inference", "decart", "sail-research", "wafer"]` (live routing tests showed `morph/fp8` is rejected by OpenRouter's `Filter by Tool Compatibility` when `tools` are passed, `deepseek` is blocked by account training-opt-out guardrails, and `baseten` returns HTTP 429).
- Configured fallback `openrouter/deepseek/deepseek-v4.1-flash` (`0910`) with `order: ["deepinfra", "streamlake", "inference-net", "digitalocean", "venice", "nebius"]` and `quantizations: ["fp8", "bf16", "unknown"]`.
- Cleaned up `openrouter/z-ai/glm-5.3-flash` fallback routing (`order: ["morph", "near-ai", "digitalocean", "venice", "streamlake"]`, `quantizations: ["fp8", "bf16", "unknown"]`, blocking `fp4`).
4. **Direct MCP Tool Fastpaths & 1-Turn Sonos Playbook for `grabengasse_media` (Topic 164)**:
- Added exact MCP tool IDs (`mcp:spotify:spotify__search`, `mcp:spotify:spotify__play`, `mcp:spotify:spotify__transfer_playback`, `mcp:jelly:jelly__jelly_search`, `mcp:jelly:jelly__jelly_request`, `mcp:homeassistant:homeassistant__ha_get_state`, `mcp:homeassistant:homeassistant__ha_call_service`) as well as all verified Sonos entity IDs (`media_player.bad`, `media_player.wohnzimmer`, `media_player.kuche_sonos`, `media_player.kuche_ikea`, `media_player.dining_room`) and Sonos `source_list` favorites (`"Fusion 2026 Sets"`, `"ChilloutTechno"`, `"deephouse"`, `"OberDITSCHI"`, etc.) directly to `openclaw-instance.secret.yaml`, `knowledge-wiki/agents/grabengasse_media.md`, and `/home/openclaw/.openclaw/workspace-grabengasse_media/AGENTS.md`.
- Eliminates both the 2 MCP discovery turns (`tool_search` + `tool_describe`) and the 2 file-read turns (`read("USER.md")`, `read("MEMORY.md")`, `read("SKILL.md")`).
5. **Extended Linux Page-Cache Pre-Warming to Knowledge Wiki & Workspace Markdown Files**:
- Updated `k8s/openclaw/scripts/gateway-preflight.mjs` and `k8s/openclaw/scripts/compact_sessions.py` (which now also runs `prewarm_page_cache()` at the end of the 04:45 nightly run) to pre-read all Knowledge Wiki (`*.md`, `*.jsonl`) and `workspace*/` `*.md` files into RAM, preventing morning `system-prompt` disk freezes.
6. **SQLite `session_nodes` Trigger (`entry_valid`) & Subagent Vector Re-Indexing Fix (`extraPaths: []`)**:
- Discovered that SQLite `session_nodes` has an `AFTER UPDATE OF entry_json` trigger (`session_nodes_entry_valid_after_entry_update`) that resets `entry_valid = 0` whenever `entry_json` is modified, throwing `SessionCanonicalKeyMigrationRequiredError` until `UPDATE session_nodes SET entry_valid = 1` is executed.
- Hardened `k8s/openclaw/scripts/recover_sessions.py` to always enforce `PRAGMA foreign_keys = ON` before deleting trailing error turns, set `entry_valid = 1` across all rows, and reset `attempts = 0` on pending `channel_ingress_events` in `state/openclaw.sqlite`.
- Explicitly set `memory.search.extraPaths: []` at the top level in `openclaw-instance.secret.yaml` and `dedup_subagent_vectors.py` so `mergeMode: merge` does not leave `extraPaths` active globally in `openclaw.json`, preventing subagents from re-embedding 4,059 wiki chunks on their first turn after startup.
- **How to Avoid Recurrence**:
- Always enforce `quantizations: ["fp8", "bf16"]` in OpenRouter `extra_body.provider` for tool-calling agents so OpenRouter never routes to degraded `fp4` (`nvfp4`) endpoints.
- Whenever updating `entry_json` in `session_nodes` via SQLite, always follow up with `UPDATE session_nodes SET entry_valid = 1` in a separate statement (after the `AFTER UPDATE OF entry_json` trigger fires), and always set `PRAGMA foreign_keys = ON` before deleting from `transcript_events`.
- Keep Knowledge Wiki and workspace Markdown files in the nightly `prewarm_page_cache()` pass so morning turns hit RAM (`<50ms`) instead of cold rotational disks.
---
### Issue 31: DeepSeek V4.1 Flash Tool-Call Truncation (8,192 Default Token Limit), Provider Re-Ranking, and Fireworks Addition
- **Date**: 2026-10-18
- **Severity**: High (Topic 116 `#Regensburg`, agent `regensburg`, failed with `Provider returned an incomplete or malformed tool call` and wedged in status `failed`)
- **Symptom**:
1. Moritz sent an audio message to Topic 116 (`#Regensburg`): *"Ach ja, auf der Website soll dann quasi, also die Karte soll so aussehen, dass quasi die Anzahl an Sternen da steht, direkt auf der Karte... wo sind die besten Dönerladen in der City..."*
2. The agent prepared the dataset `/tmp/doner_web.json` (45 places, 21 KB) and then invoked `openrouter/deepseek/deepseek-v4.1-flash-1017` to construct the single-page interactive Leaflet map application.
3. In Turn 160 (`responseId: gen-1792313455-dX7teZlPrKNOMq1BeMCe`), after 3,376 reasoning tokens (`reasoningTokens`), the model streamed the tool call (`exec`) with the HTML/JS website.
4. At exactly **8,192 output tokens** (`output: 8192`), the stream was abruptly cut off by OpenRouter/Cloudflare (`finish_reason: "length"`).
5. Because the truncation occurred mid-stream inside the tool-call argument JSON payload, OpenClaw threw:
`stopReason: "error"`, `errorMessage: "Provider returned an incomplete or malformed tool call"`.
6. The agent sent `⚠️ openrouter/deepseek/deepseek-v4.1-flash-1017 request failed.` to Telegram and remained stuck in `status: "failed"`.
- **Root Cause**:
1. **Missing `maxTokens` / `max_tokens` Configuration**:
- `models.providers.openrouter.models` registered `deepseek/deepseek-v4.1-flash-1017` without `maxTokens`.
- When `options.maxTokens` is unset, OpenClaw omits `max_tokens` from `/chat/completions`.
- OpenRouter / Cloudflare defaults to **8,192 tokens** when `max_tokens` is omitted.
2. **Reasoning Token Competition**:
- Unlike standard chat models, DeepSeek V4.1 Flash emits 2,000–3,500 reasoning tokens per complex turn, which count directly against the 8,192 completion limit. This left only ~4,800 tokens for the generated code, causing truncation on large single-page web applications.
3. **Suboptimal Provider Ordering in ADR 0002**:
- `order: ["deepinfra", "inference-net", "parasail", "cloudflare"]` had Cloudflare in 4th place behind Parasail.
- Cloudflare offers `$0.10/M` prompt, `$0.60/M` completion, and `$0.003/M` `cacheRead` (3× cheaper than Parasail), with a **943,718** completion token limit.
- **Resolution**:
1. **Configured 64k Completion Headroom (`maxTokens: 65536`)**:
- Added `maxTokens: 65536` and `contextWindow: 1048576` under `models.providers.openrouter.models` in `openclaw-instance.secret.yaml`.
- Added `max_tokens: 65536` to both `params` and `params.extra_body` for `deepseek/deepseek-v4.1-flash-1017` and `deepseek/deepseek-v4.1-flash`.
- Applied the change immediately to `/home/openclaw/.openclaw/openclaw.json` on `openclaw-0`.
2. **Promoted Cloudflare (#2) and Onboarded Fireworks (#4)**:
- Re-ranked provider order to:
`order: ["deepinfra", "cloudflare", "inference-net", "fireworks", "parasail"]`
- Promoted Cloudflare to #2 for its 97% cache discount and 943k token ceiling.
- Added Fireworks as #4 for enterprise tier-1 stability (100% uptime, 943k completion ceiling).
3. **Un-wedged Regensburg Session**:
- Reset `agent:regensburg:telegram:group:-1004326921510:topic:116` in SQLite from `failed` to `done` and updated `entry_valid = 1`.
- **How to Avoid Recurrence**:
- For all reasoning-enabled models (DeepSeek V4.1, Gemini 3, etc.), always explicitly configure `maxTokens: 65536` so chain-of-thought tokens never starve downstream tool call payloads.
+633
View File
@@ -0,0 +1,633 @@
apiVersion: v1
data:
compact_sessions.py: "#!/usr/bin/env python3\n\"\"\"\nNightly multi-agent session
maintenance and compaction script for OpenClaw.\n1. Pre-warms OS page cache for
SQLite databases on spinning disks.\n2. Recovers any wedged or locked sessions
via recover_sessions.py.\n3. Prunes abandoned isolated cron sessions (>48h old)
to prevent token/state bloat.\n4. Tier A Hard-Reset: Resets koch, hebamme, and
shopping topics to 0 tokens (clean archive).\n5. Tier B Smart-Compaction: Compacts
active sessions exceeding token thresholds (5s pacing).\n6. Checkpoints SQLite
WAL journals across all agent databases to disk.\n7. Optimizes SQLite query planner
without blocking full VACUUM.\n\"\"\"\nimport glob\nimport json\nimport os\nimport
sqlite3\nimport subprocess\nimport sys\nimport time\n\nTHRESHOLD_TOKENS = 15000\nTHRESHOLD_TRANSCRIPT_BYTES
= 200000\nTHRESHOLD_EVENT_COUNT = 30\nCRON_MAX_AGE_HOURS = 48\nRPC_TIMEOUT_MS
= 180000\n\n# Tracking Channels: Deterministic sliding-window (mila_tracker, nutrition_tracker).\n#
Strict sliding window of ~5-8 events (2-3 turns max), 0 LLM tokens, instant truncation.\nTRACKING_TARGETS
= [\n (\"mila_tracker\", \"agent:mila_tracker:telegram:group:-1004326921510:topic:2796\"),\n
\ (\"nutrition_tracker\", \"agent:nutrition_tracker:telegram:group:-1004326921510:topic:2797\"),\n]\nMAX_TRACKING_EVENTS
= 8\nMAX_TRACKING_LINES = 8\n\n# Tier A: High-frequency interaction topics (koch,
hebamme, shopping).\n# Deep-compaction keeps a compact summary + latest 2 turns
without completely wiping conversational memory.\nTIER_A_TARGETS = [\n (\"koch\",
\"agent:koch:telegram:group:-1004326921510:topic:21\"),\n (\"hebamme\", \"agent:hebamme:telegram:group:-1004326921510:topic:22\"),\n
\ (\"shopping\", \"agent:shopping:telegram:group:-1004326921510:topic:115\"),\n]\n\ndef
prewarm_page_cache():\n print(\"=== Pre-warming SQLite databases and Knowledge
Wiki into OS page cache ===\")\n t0 = time.time()\n try:\n subprocess.run(\n
\ \"cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite
> /dev/null 2>&1 || true; \"\n \"find /home/node/.openclaw/wiki/main
/home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null
2>&1 || true\",\n shell=True,\n timeout=90.0\n )\n
\ print(f\"Page cache pre-warmed in {time.time() - t0:.2f}s\")\n except
Exception as e:\n print(f\"Page cache pre-warming warning: {e}\")\n\ndef
prune_stale_cron_sessions():\n print(f\"=== Pruning isolated cron sessions
older than {CRON_MAX_AGE_HOURS}h ===\")\n cutoff_ms = int(time.time() * 1000)
- (CRON_MAX_AGE_HOURS * 3600 * 1000)\n total_pruned = 0\n total_tokens_reclaimed
= 0\n\n for db in sorted(glob.glob(\"/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite\")):\n
\ agent = db.split(\"/\")[5]\n try:\n conn = sqlite3.connect(db,
timeout=30.0)\n conn.execute(\"PRAGMA foreign_keys = ON;\")\n cursor
= conn.cursor()\n cursor.execute(\"SELECT name FROM sqlite_master WHERE
type=\\\"table\\\"\")\n all_tables = set(r[0] for r in cursor.fetchall())\n\n
\ cursor.execute(\"SELECT session_key, current_session_id, updated_at,
entry_json FROM session_nodes WHERE session_key LIKE \\\"%:cron:%\\\"\")\n stale
= []\n for k, sid, up, ej in cursor.fetchall():\n if
up and up < cutoff_ms:\n tok = 0\n if ej:\n
\ try:\n tok = json.loads(ej).get(\"totalTokens\",
0) or 0\n except Exception:\n pass\n
\ stale.append((k, sid, tok))\n\n if stale:\n tokens_reclaimed
= sum(t[2] for t in stale)\n for k, sid, _ in stale:\n for
t in [\"trajectory_runtime_events\", \"transcript_events\", \"transcript_event_identities\",
\"session_transcript_active_events\", \"transcript_rewrite_watermarks\", \"session_transcript_index_state\",
\"session_transcript_fts\", \"session_conversations\"]:\n if
t in all_tables and sid:\n cursor.execute(f\"DELETE
FROM {t} WHERE session_id = ?\", (sid,))\n for t in [\"session_participants\",
\"heartbeat_outcomes\", \"session_progress_cards\", \"board_widgets\", \"board_tabs\"]:\n
\ if t in all_tables:\n cursor.execute(f\"DELETE
FROM {t} WHERE session_key = ?\", (k,))\n if \"memory_entry_origins\"
in all_tables:\n cursor.execute(\"DELETE FROM memory_entry_origins
WHERE session_key = ? OR session_id = ?\", (k, sid or \"\"))\n if
\"session_windows\" in all_tables:\n cursor.execute(\"DELETE
FROM session_windows WHERE session_key = ? OR session_id = ?\", (k, sid or \"\"))\n
\ cursor.execute(\"DELETE FROM session_nodes WHERE session_key
= ?\", (k,))\n conn.commit()\n total_pruned += len(stale)\n
\ total_tokens_reclaimed += tokens_reclaimed\n print(f\"
\ [{agent}] Pruned {len(stale)} dead cron sessions ({tokens_reclaimed} tokens
reclaimed)\")\n\n # Unconditionally resolve any dangling foreign key
references across all tables\n while True:\n cursor.execute(\"PRAGMA
foreign_key_check;\")\n violations = cursor.fetchall()\n if
not violations:\n break\n for table, rowid,
parent, fkid in violations:\n cursor.execute(f\"DELETE FROM
{table} WHERE rowid = ?\", (rowid,))\n conn.commit()\n conn.close()\n
\ except Exception as e:\n print(f\" [{agent}] Pruning error:
{e}\")\n\n # Clear resolved quarantine entries if all databases are healthy\n
\ try:\n q_path = \"/home/node/.openclaw/state/openclaw-quarantine.sqlite\"\n
\ if os.path.exists(q_path):\n q_conn = sqlite3.connect(q_path)\n
\ q_cur = q_conn.cursor()\n q_cur.execute(\"DELETE FROM quarantined_databases\")\n
\ q_conn.commit()\n q_conn.close()\n except Exception
as e:\n print(f\"Quarantine cleanup error: {e}\")\n\n print(f\"Cron
pruning complete: {total_pruned} sessions pruned, {total_tokens_reclaimed} tokens
reclaimed.\")\n\ndef compact_tracking_sessions():\n print(\"=== Tracking Channels
Sliding-Window Maintenance (mila_tracker, nutrition_tracker) ===\")\n for agent,
key in TRACKING_TARGETS:\n db_path = f\"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite\"\n
\ if not os.path.exists(db_path):\n continue\n try:\n
\ con = sqlite3.connect(db_path, timeout=5.0)\n row = con.execute(\"SELECT
current_session_id FROM session_nodes WHERE session_key=?\", (key,)).fetchone()\n
\ if not row or not row[0]:\n con.close()\n print(f\"
\ [{agent}] No active session node found for {key}.\")\n continue\n
\ sid = row[0]\n cnt = con.execute(\"SELECT count(*) FROM
transcript_events WHERE session_id=?\", (sid,)).fetchone()[0]\n con.close()\n\n
\ if cnt > MAX_TRACKING_EVENTS:\n print(f\" [{agent}]
Session {key} has {cnt} events (> {MAX_TRACKING_EVENTS}). Truncating to last {MAX_TRACKING_LINES}
lines...\")\n cmd = [\n \"openclaw\", \"sessions\",
\"compact\", key,\n \"--agent\", agent,\n \"--max-lines\",
str(MAX_TRACKING_LINES),\n \"--timeout\", \"30000\"\n ]\n
\ r = subprocess.run(cmd, capture_output=True, text=True)\n if
r.returncode == 0:\n print(f\" ✓ Truncated successfully:
{r.stdout.strip()}\")\n else:\n print(f\" ✗
Truncation failed: {r.stderr.strip()}\")\n else:\n print(f\"
\ [{agent}] Session {key} has {cnt} events (<= {MAX_TRACKING_EVENTS}). Clean.\")\n
\ except Exception as e:\n print(f\" ✗ [{agent}] Error during
tracking compaction: {e}\")\n\ndef compact_tier_a_sessions():\n print(\"===
Tier A: Deep-Compaction (koch, hebamme, shopping) ===\")\n for agent, key in
TIER_A_TARGETS:\n db_path = f\"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite\"\n
\ if not os.path.exists(db_path):\n continue\n try:\n
\ con = sqlite3.connect(db_path, timeout=10.0)\n row = con.execute(\"SELECT
current_session_id FROM session_nodes WHERE session_key=?\", (key,)).fetchone()\n
\ con.close()\n if not row or not row[0]:\n print(f\"
\ [Tier A] {agent} ({key}) already at 0 (no active session).\")\n continue\n
\ ev_cnt, ev_bytes = get_session_metrics(agent, key, row[0])\n if
ev_cnt <= 2 and ev_bytes < 20000:\n print(f\" [Tier A] {agent}
({key}) has only {ev_cnt} events ({ev_bytes} bytes), skipping.\")\n continue\n
\ print(f\" [Tier A] Deep-compacting {agent} ({key}) [{ev_cnt} events,
{ev_bytes//1024}KB]...\")\n r = subprocess.run(\n [\"openclaw\",
\"sessions\", \"compact\", key, \"--agent\", agent, \"--timeout\", str(RPC_TIMEOUT_MS)],\n
\ capture_output=True, text=True\n )\n if
r.returncode == 0:\n print(f\" ✓ Deep-compacted: {r.stdout.strip()}\")\n
\ elif \"Already compacted\" in r.stderr or \"Already compacted\" in
r.stdout:\n print(f\" ✓ Already compacted.\")\n else:\n
\ print(f\" ✗ Standard compact error: {r.stderr.strip()}; retrying
with --max-lines 40...\")\n fb = subprocess.run(\n [\"openclaw\",
\"sessions\", \"compact\", key, \"--agent\", agent, \"--max-lines\", \"40\", \"--timeout\",
str(RPC_TIMEOUT_MS)],\n capture_output=True, text=True\n )\n
\ if fb.returncode == 0:\n print(f\" ✓ Truncation
fallback success: {fb.stdout.strip()}\")\n else:\n print(f\"
\ ✗ Truncation fallback failed: {fb.stderr.strip()}\")\n except Exception
as e:\n print(f\" ✗ Exception deep-compacting {agent}: {e}\")\n
\ time.sleep(2)\n\ndef get_session_metrics(agent, key, sid=None):\n db_path
= f\"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite\"\n if
not os.path.exists(db_path):\n return 0, 0\n try:\n con = sqlite3.connect(db_path,
timeout=5.0)\n cur = con.cursor()\n if not sid:\n row
= cur.execute(\"SELECT current_session_id FROM session_nodes WHERE session_key=?\",
(key,)).fetchone()\n sid = row[0] if row else None\n if not
sid:\n con.close()\n return 0, 0\n ev_cnt, ev_bytes
= cur.execute(\n \"SELECT count(*), coalesce(sum(length(event_json)),
0) FROM transcript_events WHERE session_id=?\", \n (sid,)\n ).fetchone()\n
\ con.close()\n return ev_cnt, ev_bytes\n except Exception:\n
\ return 0, 0\n\ndef compact_active_sessions():\n excluded_keys = set(t[1]
for t in TIER_A_TARGETS) | set(t[1] for t in TRACKING_TARGETS)\n print(f\"===
Tier B: Running Multi-Agent Compaction (threshold: {THRESHOLD_TOKENS} tokens /
{THRESHOLD_TRANSCRIPT_BYTES//1024}KB, LLM summarization) ===\")\n res = subprocess.run([\"openclaw\",
\"sessions\", \"--all-agents\", \"--json\"], capture_output=True, text=True)\n
\ if res.returncode != 0:\n print(f\"Failed to list sessions: {res.stderr}\")\n
\ return\n\n sessions = json.loads(res.stdout).get(\"sessions\", [])\n
\ compacted = 0\n skipped = 0\n\n for s in sessions:\n key = s.get(\"key\",
\"\")\n agent = s.get(\"agentId\") or \"main\"\n tokens = s.get(\"totalTokens\")
or 0\n sid = s.get(\"sessionId\")\n\n # Skip Tier A and Tracking
agents as they have dedicated routines\n if key in excluded_keys:\n continue\n\n
\ # Target main sessions and all telegram sessions (topics and direct)\n
\ is_target = (\n key == \"agent:main:main\" or \n \":telegram:\"
in key or\n (key.startswith(\"agent:\") and key.endswith(\":main\"))\n
\ )\n if is_target and not key.endswith(\":heartbeat\"):\n ev_cnt,
ev_bytes = get_session_metrics(agent, key, sid)\n needs_compaction
= (\n tokens >= THRESHOLD_TOKENS or\n ev_bytes >=
THRESHOLD_TRANSCRIPT_BYTES or\n (tokens == 0 and ev_cnt >= THRESHOLD_EVENT_COUNT)\n
\ )\n if needs_compaction:\n stat_str = f\"{tokens}
tokens\" if tokens else f\"{ev_bytes//1024}KB / {ev_cnt} events\"\n print(f\"Compacting
{agent} ({stat_str}) -> {key}...\")\n c_res = subprocess.run(\n
\ [\"openclaw\", \"sessions\", \"compact\", key, \"--agent\",
agent, \"--timeout\", str(RPC_TIMEOUT_MS)],\n capture_output=True,
text=True\n )\n if c_res.returncode == 0:\n print(f\"
\ ✓ {c_res.stdout.strip()}\")\n compacted += 1\n elif
\"Already compacted\" in c_res.stderr or \"Already compacted\" in c_res.stdout:\n
\ print(f\" ✓ Already compacted.\")\n skipped
+= 1\n else:\n print(f\" ✗ {c_res.stderr.strip()};
retrying with --max-lines 80 fallback...\")\n fb_res = subprocess.run(\n
\ [\"openclaw\", \"sessions\", \"compact\", key, \"--agent\",
agent, \"--max-lines\", \"80\", \"--timeout\", str(RPC_TIMEOUT_MS)],\n capture_output=True,
text=True\n )\n if fb_res.returncode ==
0:\n print(f\" ✓ (fallback) {fb_res.stdout.strip()}\")\n
\ compacted += 1\n else:\n print(f\"
\ ✗ Fallback failed: {fb_res.stderr.strip()}\")\n time.sleep(5)
\ # 5s pause to avoid rotational HDD I/O saturation\n else:\n skipped
+= 1\n\n print(f\"Compaction complete: {compacted} compacted, {skipped} skipped.\")\n\ndef
checkpoint_wal_journals():\n print(\"=== Checkpointing SQLite WAL journals
across all agent databases ===\")\n dbs = [\"/home/node/.openclaw/state/openclaw.sqlite\"]
+ glob.glob(\"/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite\")\n wal_freed
= 0\n for db in dbs:\n wal = db + \"-wal\"\n if os.path.exists(wal):\n
\ sz = os.path.getsize(wal)\n try:\n con =
sqlite3.connect(db, timeout=30.0)\n con.execute(\"PRAGMA wal_checkpoint(TRUNCATE);\")\n
\ con.close()\n wal_freed += sz\n except
Exception as e:\n print(f\" ✗ Error checkpointing {db}: {e}\")\n
\ print(f\"WAL Checkpoint complete: {wal_freed / (1024*1024):.2f} MB merged
to disk.\")\n\ndef vacuum_and_optimize():\n print(\"=== Running SQLite OPTIMIZE
across all databases ===\")\n dbs = [\"/home/node/.openclaw/state/openclaw.sqlite\"]
+ glob.glob(\"/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite\")\n for
db in dbs:\n try:\n con = sqlite3.connect(db, timeout=30.0)\n
\ con.execute(\"PRAGMA optimize;\")\n con.close()\n except
Exception as e:\n print(f\" ✗ Error optimizing {db}: {e}\")\n print(\"SQLite
optimization complete.\")\n\ndef main():\n prewarm_page_cache()\n try:\n
\ subprocess.run([\"python3\", \"/home/node/.openclaw/scripts/recover_sessions.py\"],
check=False)\n except Exception:\n pass\n prune_stale_cron_sessions()\n
\ compact_tracking_sessions()\n compact_tier_a_sessions()\n compact_active_sessions()\n
\ checkpoint_wal_journals()\n vacuum_and_optimize()\n prewarm_page_cache()\n\nif
__name__ == \"__main__\":\n main()\n"
dedup_subagent_vectors.py: |
#!/usr/bin/env python3
import glob, json, os, sqlite3, sys
cfg_path = "/home/node/.openclaw/openclaw.json"
if os.path.exists(cfg_path):
try:
with open(cfg_path, "r") as f:
cfg = json.load(f)
mem_search = cfg.get("memory", {}).get("search", {})
if mem_search.get("extraPaths"):
mem_search["extraPaths"] = []
with open(cfg_path, "w") as f:
json.dump(cfg, f, indent=2)
print("[dedup_subagents] Cleared top-level memory.search.extraPaths from openclaw.json (scoped to main only)")
except Exception as e:
print(f"[dedup_subagents] Warning checking openclaw.json: {e}", file=sys.stderr)
vec_path = "/app/node_modules/sqlite-vec-linux-x64/vec0.so"
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
agent = os.path.basename(os.path.dirname(os.path.dirname(db)))
if agent == "main":
continue
try:
conn = sqlite3.connect(db, timeout=10.0)
if os.path.exists(vec_path):
conn.enable_load_extension(True)
conn.load_extension(vec_path)
cur = conn.cursor()
chk = cur.execute("SELECT count(*) FROM memory_index_chunks").fetchone()[0]
if chk > 0:
print(f"[dedup_subagents] Purging {chk} duplicated vector chunks from agent {agent}...")
for t in [
"memory_index_sources",
"memory_index_chunks",
"memory_index_chunk_recall_metadata",
"memory_index_chunk_provenance",
"memory_entry_origins",
"memory_embedding_cache",
"memory_index_chunks_vec"
]:
try: cur.execute(f"DELETE FROM {t}")
except Exception: pass
conn.commit()
cur.execute("VACUUM")
print(f"[dedup_subagents] Reclaimed disk space for agent {agent}")
conn.close()
except Exception as e:
print(f"[dedup_subagents] Notice for {agent}: {e}", file=sys.stderr)
gateway-preflight.mjs: |
import fs from "node:fs";
import path from "node:path";
import { execSync, spawn } from "node:child_process";
if (!process.env.__OPENCLAW_PREFLIGHT_DONE) {
process.env.__OPENCLAW_PREFLIGHT_DONE = "1";
const isGateway = process.argv.some(arg => arg.endsWith("openclaw.mjs") || arg.endsWith("openclaw")) && process.argv.includes("gateway");
if (isGateway) {
const t0 = Date.now();
console.log("[gateway-preflight] Initializing container self-healing preflight...");
// 1. Clean up stale gateway lock files from ungraceful kills
for (const dir of ["/tmp/openclaw-1000", "/tmp"]) {
if (fs.existsSync(dir)) {
try {
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith("gateway") && file.endsWith(".lock")) {
const fullPath = path.join(dir, file);
fs.unlinkSync(fullPath);
console.log(`[gateway-preflight] Removed stale lock: ${fullPath}`);
}
}
} catch (e) {
console.warn(`[gateway-preflight] Lock cleanup warning in ${dir}:`, e.message);
}
}
}
// 2. Clear false database quarantines
const qPath = "/home/node/.openclaw/state/openclaw-quarantine.sqlite";
if (fs.existsSync(qPath)) {
try {
execSync(`/home/node/.openclaw/.local/bin/python3 -c "import sqlite3; c=sqlite3.connect(\x27${qPath}\x27); c.execute(\x27DELETE FROM quarantined_databases\x27); c.commit(); c.close()"`, { stdio: "ignore" });
console.log("[gateway-preflight] Cleared database quarantine.");
} catch (_) {}
}
// 3. Deduplicate subagent vector databases (keeps non-main agent DBs < 15MB)
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/dedup_subagent_vectors.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Vector deduplication warning:", e.message);
}
// 4. Pre-warm page cache sequentially (transforms random 70 IOPS disk reads into sequential streaming)
try {
const pw0 = Date.now();
execSync("cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true; find /home/node/.openclaw/wiki/main /home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null 2>&1 || true");
console.log(`[gateway-preflight] Pre-warmed SQLite databases and Wiki markdown in ${Date.now() - pw0}ms`);
} catch (_) {}
// 5. Recover any wedged running sessions left over from ungraceful container restart
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/recover_sessions.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Session recovery warning:", e.message);
}
// 6. Spawn background turn replay daemon if interrupted turns were detected
try {
if (fs.existsSync("/tmp/openclaw-interrupted-turns.json")) {
console.log("[gateway-preflight] Spawning background turn replay daemon...");
const child = spawn("/home/node/.openclaw/.local/bin/python3", ["/home/node/.openclaw/scripts/replay_interrupted_turns.py"], {
detached: true,
stdio: "ignore"
});
child.unref();
}
} catch (e) {
console.warn("[gateway-preflight] Could not spawn turn replay daemon:", e.message);
}
console.log(`[gateway-preflight] Self-healing completed in ${Date.now() - t0}ms.`);
}
}
openclaw-triage.py: "#!/usr/bin/env python3\n\"\"\"\nopenclaw-triage.py: Rapid zero-dependency
incident triage for OpenClaw multi-agent sessions.\nDirectly inspects ground-truth
SQLite session state and transcript events in < 1 second.\n\"\"\"\nimport argparse\nimport
glob\nimport json\nimport os\nimport sqlite3\nimport sys\nimport time\n\nTOPIC_TO_AGENT
= {\n \"20\": \"fitness\",\n \"21\": \"koch\",\n \"22\": \"hebamme\",\n
\ \"51\": \"travel\",\n \"115\": \"shopping\",\n \"116\": \"regensburg\",\n
\ \"117\": \"recherche\",\n \"118\": \"doktor\",\n \"119\": \"itguy\",\n
\ \"164\": \"grabengasse_media\",\n \"2796\": \"mila_tracker\",\n \"2797\":
\"nutrition_tracker\",\n}\n\ndef parse_args():\n parser = argparse.ArgumentParser(description=\"Rapid
OpenClaw multi-agent session triage.\")\n parser.add_argument(\"--topic\",
\"-t\", help=\"Telegram forum topic ID (e.g. 21, 22, 117)\")\n parser.add_argument(\"--agent\",
\"-a\", help=\"Agent identifier (e.g. koch, hebamme, recherche)\")\n parser.add_argument(\"--limit\",
\"-n\", type=int, default=10, help=\"Number of recent events to display (default:
10)\")\n parser.add_argument(\"--db-root\", default=\"/home/node/.openclaw/agents\",
help=\"Root path of agent SQLite databases\")\n return parser.parse_args()\n\ndef
triage_agent_session(agent, topic_id=None, limit=10, db_root=\"/home/node/.openclaw/agents\"):\n
\ db_path = os.path.join(db_root, agent, \"agent\", \"openclaw-agent.sqlite\")\n
\ if not os.path.exists(db_path):\n print(f\"[-] Database not found for
agent '{agent}': {db_path}\")\n return False\n\n try:\n con =
sqlite3.connect(db_path, timeout=5.0)\n except Exception as e:\n print(f\"[-]
Failed to connect to SQLite DB for agent '{agent}': {e}\")\n return False\n\n
\ cur = con.cursor()\n # Find matching session\n if topic_id:\n target_pattern
= f\"%topic:{topic_id}%\"\n cur.execute(\n \"SELECT session_key,
current_session_id, status, updated_at, entry_json FROM session_nodes WHERE session_key
LIKE ? AND session_key NOT LIKE '%:heartbeat' ORDER BY updated_at DESC LIMIT 1\",\n
\ (target_pattern,)\n )\n else:\n cur.execute(\n \"SELECT
session_key, current_session_id, status, updated_at, entry_json FROM session_nodes
WHERE session_key NOT LIKE '%:heartbeat' ORDER BY updated_at DESC LIMIT 1\"\n
\ )\n\n row = cur.fetchone()\n if not row:\n print(f\"[-] No
session found in {agent} DB (filter: topic={topic_id})\")\n con.close()\n
\ return False\n\n skey, sid, status, updated_at_ms, entry_json = row\n
\ now_ms = time.time() * 1000\n age_min = (now_ms - updated_at_ms) / 60000.0
if updated_at_ms else 0.0\n\n entry = {}\n if entry_json:\n try:\n
\ entry = json.loads(entry_json)\n except Exception:\n pass\n\n
\ tokens = entry.get(\"totalTokens\", \"?\")\n model = entry.get(\"model\",
\"default\")\n last_run_id = entry.get(\"activeWriterRunId\") or entry.get(\"activeRunId\",
None)\n\n # Detect anomalies\n badges = []\n if isinstance(tokens, int):\n
\ if tokens > 60000:\n badges.append(\"\U0001F6A8 [CRITICAL_CONTEXT_BLOAT:
>60k tokens]\")\n elif tokens > 20000:\n badges.append(\"⚠️
[HIGH_CONTEXT_TOKENS: >20k tokens]\")\n if status == \"running\" and age_min
> 5.0:\n badges.append(\"\U0001F512 [POSSIBLY_WEDGED_RUNNING: updated >5m
ago]\")\n elif status == \"processing\":\n badges.append(\"⚡ [ACTIVE_PROCESSING]\")\n\n
\ # Check ingress queue status\n state_db = \"/home/node/.openclaw/state/openclaw.sqlite\"\n
\ if topic_id and os.path.exists(state_db):\n try:\n s_con
= sqlite3.connect(state_db, timeout=2.0)\n lane_pat = f\"%topic:{topic_id}%\"\n
\ pending_cnt = s_con.execute(\n \"SELECT count(*) FROM
channel_ingress_events WHERE lane_key LIKE ? AND status IN ('pending', 'claimed')\",\n
\ (lane_pat,)\n ).fetchone()[0]\n s_con.close()\n
\ if pending_cnt > 0:\n badges.append(f\"\U0001F4EC [INGRESS_STALLED:
{pending_cnt} message(s) pending]\")\n except Exception:\n pass\n\n
\ print(\"=\" * 72)\n print(f\"\U0001F3AF AGENT: {agent.upper():<12} | TOPIC:
{topic_id or 'N/A':<6} | STATUS: {status.upper()}\")\n print(f\" SESSION
KEY: {skey}\")\n print(f\" SESSION ID: {sid}\")\n print(f\" MODEL:
\ {model}\")\n print(f\" TOKENS: {tokens} | LAST UPDATED: {age_min:.1f}m
ago\")\n if badges:\n print(f\" ANOMALIES: {' '.join(badges)}\")\n
\ print(\"=\" * 72)\n\n # Query transcript events\n cur.execute(\n \"SELECT
seq, created_at, event_json FROM transcript_events WHERE session_id = ? ORDER
BY seq DESC LIMIT ?\",\n (sid, limit)\n )\n events = cur.fetchall()\n
\ con.close()\n\n if not events:\n print(\" (No transcript events
found)\")\n return True\n\n events.reverse()\n print(\"\\n\U0001F4DC
RECENT TRANSCRIPT EVENTS (Chronological):\")\n\n last_role = None\n for
seq, ca_ms, ej_str in events:\n try:\n ev = json.loads(ej_str)\n
\ except Exception:\n print(f\"[{seq}] <unparseable event json>\")\n
\ continue\n\n etype = ev.get(\"type\")\n if etype !=
\"message\":\n continue\n\n msg = ev.get(\"message\", {})\n
\ role = msg.get(\"role\")\n last_role = role\n content =
msg.get(\"content\", [])\n\n if role == \"user\":\n txt = content
if isinstance(content, str) else json.dumps(content)\n # Check for
audio transcript\n if isinstance(content, str) and \"[Audio]\" in content:\n
\ print(f\"\\n\U0001F449 [SEQ {seq}] \U0001F3A4 USER AUDIO PROMPT:\")\n
\ for line in content.splitlines()[:6]:\n print(f\"
\ {line}\")\n if len(content.splitlines()) > 6:\n print(\"
\ ...\")\n else:\n txt_preview = txt.replace(\"\\n\",
\" \")[:240]\n print(f\"\\n\U0001F449 [SEQ {seq}] \U0001F464 USER:
{txt_preview}\")\n\n elif role == \"assistant\":\n print(f\"\\n\U0001F916
[SEQ {seq}] ASSISTANT TURN:\")\n if isinstance(content, list):\n for
item in content:\n if not isinstance(item, dict):\n continue\n
\ itype = item.get(\"type\")\n if itype ==
\"thinking\":\n th = item.get(\"thinking\", \"\").replace(\"\\n\",
\" \")\n print(f\" \U0001F9E0 THINKING: {th[:280]}...\")\n
\ elif itype == \"toolCall\":\n tname
= item.get(\"name\", \"unknown\")\n targs = json.dumps(item.get(\"arguments\",
{}))\n print(f\" ⚙️ TOOL CALL: {tname}({targs[:160]})\")\n
\ elif itype == \"text\":\n tx = item.get(\"text\",
\"\")\n lines = tx.splitlines()\n prev
= lines[0][:150] if lines else \"\"\n print(f\" \U0001F4AC
TEXT OUTPUT: {prev}...\")\n elif isinstance(content, str):\n print(f\"
\ \U0001F4AC TEXT OUTPUT: {content[:200]}...\")\n\n elif role == \"toolResult\":\n
\ is_err = ev.get(\"isError\", False)\n tname = ev.get(\"details\",
{}).get(\"tool\") or msg.get(\"toolName\", \"tool\")\n res_txt = json.dumps(content)
if not isinstance(content, str) else content\n if is_err or \"error\"
in res_txt.lower() or \"must have required property\" in res_txt:\n print(f\"
\ ❌ [SEQ {seq}] TOOL ERROR ({tname}): {res_txt[:260]}\")\n else:\n
\ print(f\" ✅ [SEQ {seq}] TOOL RESULT ({tname}): {res_txt[:180]}\")\n\n
\ if last_role == \"user\":\n print(\"\\n⚠️ WARNING: The last event in
the transcript is an UNANSWERED USER MESSAGE!\")\n print(\" The agent
turn either crashed, timed out, or was killed before producing a response.\")\n\n
\ print(\"\\n\" + \"=\" * 72)\n return True\n\ndef main():\n args = parse_args()\n
\ if not args.topic and not args.agent:\n print(\"Usage: openclaw-triage.py
(--topic <ID> | --agent <NAME>) [--limit <N>]\")\n sys.exit(1)\n\n agent
= args.agent\n topic = args.topic\n\n if topic and not agent:\n agent
= TOPIC_TO_AGENT.get(str(topic))\n if not agent:\n # Search
all DBs\n for a, t in TOPIC_TO_AGENT.items():\n if t
== topic:\n agent = a\n break\n\n if
not agent:\n # Scan all DBs for the topic\n for db in sorted(glob.glob(os.path.join(args.db_root,
\"*/agent/openclaw-agent.sqlite\"))):\n cand_agent = db.split(os.sep)[-3]\n
\ try:\n con = sqlite3.connect(db, timeout=2.0)\n cur
= con.cursor()\n cur.execute(\n \"SELECT session_key
FROM session_nodes WHERE session_key LIKE ? LIMIT 1\",\n (f\"%topic:{topic}%\",)\n
\ )\n if cur.fetchone():\n agent
= cand_agent\n con.close()\n break\n con.close()\n
\ except Exception:\n pass\n\n if not agent:\n print(f\"[-]
Could not map topic '{topic}' to any known OpenClaw agent.\")\n sys.exit(1)\n\n
\ success = triage_agent_session(agent, topic_id=topic, limit=args.limit, db_root=args.db_root)\n
\ sys.exit(0 if success else 1)\n\nif __name__ == \"__main__\":\n main()\n"
recover_sessions.py: |+
#!/usr/bin/env python3
import glob, sqlite3, json, os, sys, time
t0 = time.time()
recovered = 0
interrupted_turns = []
def extract_prompt(msg):
if not msg: return ""
content = msg.get("content")
if isinstance(content, str): return content
if isinstance(content, list):
parts = []
for c in content:
if isinstance(c, dict): parts.append(c.get("text") or c.get("content") or "")
elif isinstance(c, str): parts.append(c)
return "\n".join(filter(None, parts))
return str(content or "")
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
try:
conn = sqlite3.connect(db, timeout=10.0)
cursor = conn.cursor()
while True:
cursor.execute("PRAGMA foreign_key_check;")
violations = cursor.fetchall()
if not violations: break
for table, rowid, parent, fkid in violations:
cursor.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
cursor.execute("SELECT session_key, entry_json, current_session_id, status FROM session_nodes WHERE status IN (\"running\", \"failed\", \"error\")")
rows = cursor.fetchall()
agent_id = os.path.basename(os.path.dirname(os.path.dirname(db)))
for sk, ej, csid, st in rows:
try:
p = json.loads(ej) if ej else {}
sid = csid or p.get("sessionId")
if sid:
ev = cursor.execute("SELECT seq, event_json FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT 1", (sid,)).fetchone()
if ev:
ev_seq, ev_raw = ev
try:
ev_data = json.loads(ev_raw)
msg = ev_data.get("message") or ev_data
if st == "running" and msg.get("role") == "user":
prompt_text = extract_prompt(msg)
ctx = p.get("restartRecoveryDeliveryContext") or p.get("delivery", {}).get("context") or p.get("delivery", {}).get("route", {}).get("context") or {}
interrupted_turns.append({
"agent": agent_id,
"session_key": sk,
"session_id": sid,
"context": ctx,
"prompt": prompt_text
})
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
elif msg.get("role") == "assistant" and msg.get("stopReason") == "error":
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
except Exception as ex:
print(f"[recover_sessions] Error parsing event: {ex}", file=sys.stderr)
p["status"] = "done"
p["activeWriterRunId"] = None
p["activeClaimRunId"] = None
p["restartRecoveryDeliveryRunId"] = None
cursor.execute("UPDATE session_nodes SET status = \"done\", entry_json = ? WHERE session_key = ?", (json.dumps(p), sk))
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE session_key = ?", (sk,))
recovered += 1
except Exception: pass
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE entry_valid != 1")
cursor.execute("UPDATE session_windows SET status = \"done\" WHERE status IN (\"running\", \"failed\", \"error\")")
conn.commit()
conn.close()
except Exception as e:
print(f"[recover_sessions] Warning for {db}: {e}", file=sys.stderr)
state_db = "/home/node/.openclaw/state/openclaw.sqlite"
if os.path.exists(state_db):
try:
sconn = sqlite3.connect(state_db, timeout=10.0)
scur = sconn.cursor()
scur.execute("UPDATE channel_ingress_events SET attempts = 0, last_attempt_at = NULL, last_error = NULL WHERE status = 'pending' AND attempts > 0")
sconn.commit()
sconn.close()
except Exception as e:
print(f"[recover_sessions] Warning resetting ingress backoff: {e}", file=sys.stderr)
if interrupted_turns:
try:
with open("/tmp/openclaw-interrupted-turns.json", "w") as f:
json.dump(interrupted_turns, f, indent=2)
print(f"[recover_sessions] Detected {len(interrupted_turns)} interrupted user turn(s) saved to /tmp/openclaw-interrupted-turns.json")
except Exception as e:
print(f"[recover_sessions] Error writing interrupted turns: {e}", file=sys.stderr)
print(f"[recover_sessions] Completed in {time.time() - t0:.2f}s ({recovered} wedged sessions recovered)")
replay_interrupted_turns.py: |
#!/usr/bin/env python3
import json, os, subprocess, sys, time, urllib.request
INTERRUPTED_FILE = "/tmp/openclaw-interrupted-turns.json"
if not os.path.exists(INTERRUPTED_FILE):
sys.exit(0)
try:
with open(INTERRUPTED_FILE, "r") as f:
turns = json.load(f)
except Exception as e:
print(f"[replay_interrupted_turns] Failed to read {INTERRUPTED_FILE}: {e}", file=sys.stderr)
sys.exit(0)
if not turns:
sys.exit(0)
print(f"[replay_interrupted_turns] Found {len(turns)} interrupted turn(s). Waiting for gateway /healthz...")
gateway_ready = False
for _ in range(60):
try:
with urllib.request.urlopen("http://127.0.0.1:18790/healthz", timeout=3) as resp:
if resp.status == 200:
gateway_ready = True
break
except Exception:
pass
time.sleep(2)
if not gateway_ready:
print("[replay_interrupted_turns] Gateway did not become healthy in 120s. Aborting.", file=sys.stderr)
sys.exit(1)
# Allow 5s for Telegram ingress to bind
time.sleep(5)
for turn in turns:
sk = turn.get("session_key")
sid = turn.get("session_id")
agent_id = turn.get("agent")
ctx = turn.get("context") or {}
prompt = turn.get("prompt") or ""
channel = ctx.get("channel") or "telegram"
to_target = ctx.get("to") or ""
thread_id = ctx.get("threadId")
target = to_target
if target.startswith("telegram:"):
parts = target.split(":")
target = parts[1] if len(parts) > 1 else target
print(f"[replay_interrupted_turns] Processing agent '{agent_id}' on session '{sk}' target='{target}' thread='{thread_id}'")
# 1. Send status notification to Telegram if target exists
if target:
notice = "⚠️ OpenClaw wurde während der Bearbeitung neu gestartet – deine Anfrage wird jetzt nachgeholt..."
notify_cmd = [
"openclaw", "message", "send",
"--channel", channel,
"--target", str(target),
"-m", notice
]
if thread_id is not None:
notify_cmd.extend(["--thread-id", str(thread_id)])
try:
subprocess.run(notify_cmd, check=False, timeout=30)
print(f"[replay_interrupted_turns] Sent recovery notice to {target}")
except Exception as e:
print(f"[replay_interrupted_turns] Notice failed: {e}", file=sys.stderr)
# 2. Replay agent turn
if sk and prompt:
p_short = (prompt[:60] + "...") if len(prompt) > 60 else prompt
print(f"[replay_interrupted_turns] Replaying agent turn: {p_short}")
agent_cmd = [
"openclaw", "agent",
"--session-key", sk,
"-m", prompt,
"--deliver"
]
try:
res = subprocess.run(agent_cmd, capture_output=True, text=True, timeout=300)
if res.returncode == 0:
print(f"[replay_interrupted_turns] Turn replay succeeded for {sk}")
else:
print(f"[replay_interrupted_turns] Turn replay exited with code {res.returncode}: {res.stderr[:200]}", file=sys.stderr)
except Exception as e:
print(f"[replay_interrupted_turns] Replay execution error: {e}", file=sys.stderr)
try:
os.remove(INTERRUPTED_FILE)
print(f"[replay_interrupted_turns] Cleaned up {INTERRUPTED_FILE}")
except Exception:
pass
kind: ConfigMap
metadata:
name: openclaw-scripts
namespace: openclaw
Binary file not shown.
+137
View File
@@ -0,0 +1,137 @@
# Declarative Crons-as-Code for OpenClaw (Haumdaucher)
# Reconciled idempotently via k8s/openclaw/scripts/sync_crons.py
version: 1
jobs:
# 1. Maintenance & State Truncation
- name: "tracking-session-sliding-window"
declarationKey: "tracking-session-sliding-window"
description: "Lightweight sliding-window truncation (max 8 events) for mila_tracker and nutrition_tracker"
schedule:
kind: "every"
every: "15m"
session: "isolated"
delivery: "none"
command: "python3 /home/node/.openclaw/scripts/compact_tracking_sessions.py"
- name: "nightly-session-compaction"
declarationKey: "nightly-session-compaction"
description: "Nightly multi-agent session compaction and SQLite WAL checkpointing"
schedule:
kind: "cron"
expr: "45 4 * * *"
tz: "Europe/Berlin"
session: "isolated"
delivery: "none"
command: "python3 /home/node/.openclaw/scripts/compact_sessions.py"
# 2. Nightly Garmin Connect Mirror
- name: "garmin-nightly-mirror"
declarationKey: "garmin-nightly-mirror"
description: "Nächtlicher deterministischer Spiegel der Garmin Connect Daten (Aktivitäten, Daily Summary, Schlaf) in Git JSONL"
schedule:
kind: "cron"
expr: "30 3 * * *"
tz: "Europe/Berlin"
agent: "fitness"
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
session: "isolated"
channel: "telegram"
to: "-1004326921510"
threadId: "20"
announce: true
message: |
Führe den nächtlichen Garmin Connect Sync durch.
Vorgehen:
1. Führe den Garmin-Sync-Befehl aus:
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/garmin_sync.py --days 2 --data-dir /home/node/.openclaw/wiki/main/data
2. Finale Antwort:
- Bei Erfolg: Gib exakt 'NO_REPLY' aus (intentional silence, kein Telegram-Spam in der Nacht).
- Nur bei Authentifizierungsfehlern: Informiere kurz via Telegram (Thema #Sport), dass ein Garmin-Re-Auth nötig ist, oder starte den Re-Auth-Flow.
# 4. Weekly Autonomous System & Error Audit
- name: "itguy-weekly-health-audit"
declarationKey: "itguy-weekly-health-audit"
description: "Wöchentlicher Self-Audit der OpenClaw-Logs, Tool-Failures und Session-Transkripte mit Git-Commit-Korrelation"
schedule:
kind: "cron"
expr: "0 3 * * 0"
tz: "Europe/Berlin"
agent: "itguy"
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
session: "isolated"
channel: "telegram"
to: "-1004326921510"
threadId: "119"
announce: true
message: |
Führe den wöchentlichen OpenClaw System- und Log-Audit durch.
Vorgehen:
1. Führe den Audit-Befehl aus:
uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 7d --write-report --add-task --git-push
2. Analysiere die Ausgabe und korreliere eventuelle Fehler-Cluster mit den jüngsten Commits in infrapuzzle, mop-skills und knowledge-wiki.
3. Finale Antwort:
- Wenn NEUE Probleme gefunden oder Tasks in tasks/antigravity.md eingetragen wurden: Fasse die Ergebnisse kurz, präzise und lösungsorientiert zusammen (betroffene Tools, vermutete Ursache, Link zur Task-Queue) und gib diese Zusammenfassung als finale Antwort aus.
- Wenn das System gesund ist und KEINE neuen Tasks erzeugt wurden: Gib als finale Antwort exakt 'NO_REPLY' aus (intentional silence, kein Telegram-Spam).
# 5. Weekly AI Roundup (Recherche)
- name: "weekly-ai-roundup"
declarationKey: "weekly-ai-roundup:recherche"
description: "Wöchentlicher AI-Roundup und Tech-Scanning für Topic #Research"
schedule:
kind: "cron"
expr: "30 6 * * 5"
tz: "Europe/Berlin"
agent: "recherche"
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
session: "isolated"
channel: "telegram"
to: "-1004326921510"
threadId: "117"
announce: true
message: |
🗓️ WÖCHENTLICHER AI-ROUNDUP (Freitag). Führe den in sources.md im Workspace definierten Ablauf aus und poste das Ergebnis als EINE kompakte Telegram-Nachricht in diesen Topic (#Research).
1) Hacker News: Algolia API – https://hn.algolia.com/api/v1/search_by_date?tags=story&numericFilters=points>150,created_at_i>{Unix-Timestamp von vor 7 Tagen}&hitsPerPage=100 → daraus die KI-relevanten Top-Stories nach Punkten ziehen.
2) Frontier-Labs prüfen: OpenAI, Anthropic, Google DeepMind, Meta AI, Mistral, xAI.
3) China-Labs prüfen: GLM/Z.ai, Qwen, DeepSeek, Moonshot/Kimi, MiniMax.
4) Tracker: Artificial Analysis Releases, Hugging Face Trending, OpenRouter Rankings, LMArena.
5) Newsletter/Blogs: The Batch, Import AI, Interconnects, Ahead of AI, Simon Willison, TLDR AI.
6) GitHub: Trending (weekly) + neue Releases der Kern-Orgs (openai, anthropics, google-deepmind, QwenLM, deepseek-ai, zai-org, meta-llama, mistralai).
7) YouTube nur als Hype-Indikator (Titel/Snippets), KEINE Transkripte.
Ausgabe (Deutsch, Markdown, KEINE Tabellen):
- Top-3-Highlights der Woche (je 1 Satz + Link)
- Danach Bullets je Kategorie: ① Frontier-Modelle · ② China-Modelle · ③ Tools/Infra/Procedures · ④ Trends (HN/GitHub/YouTube), je Eintrag genau 1 Quell-Link
- Scope 60/40 (Modelle/Tools). Wenn eine Kategorie nichts Neues hat: kurz 'nichts Neues'.
- Am Ende NUR falls nötig eine Zeile: welche Quelle in sources.md ergänzt/entfernt werden sollte.
Nutze web_search/web_fetch. Effort: medium.
# 6. Weekly Bridge Tracker (Holzgartensteg)
- name: "Recherche: Holzgartensteg (wöchentlich)"
declarationKey: "recherche:holzgartensteg"
description: "Wöchentlicher Status-Check zum Holzgartensteg Regensburg via ALLRIS & Web"
schedule:
kind: "cron"
expr: "0 16 * * 5"
tz: "Europe/Berlin"
agent: "main"
session: "isolated"
channel: "telegram"
to: "306373425"
announce: true
message: |
Wöchentlicher Recherche-Task (Holzgartensteg, Stadt Regensburg).
AUFGABE: Prüfe, ob es NEUIGKEITEN zum geplanten Holzgartensteg (geplante Fuß-/Radbrücke zwischen Grieser Spitz und Maria-Beer-Platz / Weichs) gibt — seit dem letzten Check bzw. den letzten ~7 Tagen.
QUELLEN:
1) ALLRIS via Skill 'regensburg-stadtrat': Script /home/node/mop-skills/mop-skills/skills/regensburg-stadtrat/scripts/regensburg_ris.py — Befehle: search "Holzgartensteg" --limit 30 ; calendar --days 30 ; vorlage <ID> ; session <ID>. Achte auf NEUE Vorlagen, Beschlüsse, Anfragen und Sitzungstermine (Ausschuss für Stadtplanung, Verkehr und Wohnungsfragen / Stadtrat).
2) Lokale Medien via web_search: regensburg-digital.de, idowa.de, mittelbayerische.de, tvaktuell.com, regensburger-nachrichten.de.
FOKUS: neue Vorlagen/Beschlüsse, Ausschuss-/Stadtrattermine, Mittel/Budget, Planungsstand (Variante, Kosten, Termin), politische Positionen.
BEKANNTER STAND (nicht wiederholen, nur Abweichungen melden): 2020 aus Investitionsprogramm gestrichen ('beerdigt'); Comeback ab 2025 (Grüne/Verbände); Kommunalwahl 2026 mehrere Parteien dafür; Haushalt 2026: ~100.000 EUR Planungsmittel im Investitionsprogramm, Stadtrat-Beschluss 26.02.2026, Genehmigung Regierung der Oberpfalz 06.03.2026; Koalitionsvereinbarung 2026 mit Wiederaufnahme der Planungen.
AUSGABE: Kurz auf Deutsch an Moritz. Falls echte Neuigkeiten: 3-6 Bullets mit Quelle+Link. Falls NICHTS Neues: eine kurze Zeile, z.B. 'Holzgartensteg: keine neuen Entwicklungen diese Woche.' Kein Hintergrund-Recycling.
Binary file not shown.
+205
View File
@@ -0,0 +1,205 @@
#!/bin/sh
set -e
echo "=== OpenClaw Dependency & Runtime Bootstrap ==="
export PATH="/home/node/.openclaw/bin:/home/node/.openclaw/.local/bin:$PATH"
SCRIPTS_DIR="/home/node/.openclaw/scripts"
mkdir -p /home/node/.openclaw/bin "$SCRIPTS_DIR" /home/node/.openclaw/.config
touch /home/node/.openclaw/.config/keep
# 1. Sanitize legacy config keys in openclaw.json
node -e '
const fs = require("fs");
for (const file of ["/home/node/.openclaw/openclaw.json", "/home/openclaw/.openclaw/openclaw.json"]) {
if (fs.existsSync(file)) {
try {
const cfg = JSON.parse(fs.readFileSync(file, "utf8"));
if (cfg.browser) {
delete cfg.browser.remoteCdpTimeoutMs;
if (cfg.browser.profiles) {
if (cfg.browser.profiles.default) delete cfg.browser.profiles.default.color;
if (cfg.browser.profiles.chrome) delete cfg.browser.profiles.chrome;
}
}
if (cfg.gateway && cfg.gateway.controlUi) {
delete cfg.gateway.controlUi.dangerouslyDisableDeviceAuth;
}
if (!cfg.agents) cfg.agents = {};
cfg.agents.ownership = "explicit";
if (cfg.agents.entries && cfg.agents.entries.main) {
delete cfg.agents.entries.main.default;
}
fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
console.log("Sanitized config file:", file);
} catch (e) {
console.error("Failed to sanitize", file, e.message);
}
}
}
' || true
# 2. Run pre-flight session recovery
echo "Running pre-flight self-healing..."
python3 "$SCRIPTS_DIR/recover_sessions.py" || true
# 3. Doctor fix
echo "Running openclaw doctor --fix to migrate runtime config on PVC..."
openclaw doctor --fix || true
# 4. Garmin OAuth tokens bootstrap / preserve
if [ ! -s /home/node/.openclaw/garmin_tokens/garmin_tokens.json ] && [ -n "$GARMIN_TOKENS_BASE64" ]; then
echo "Bootstrapping initial Garmin OAuth tokens from secret..."
mkdir -p /home/node/.openclaw/garmin_tokens
echo "$GARMIN_TOKENS_BASE64" | base64 -d > /home/node/.openclaw/garmin_tokens/garmin_tokens.json
chmod 700 /home/node/.openclaw/garmin_tokens
chmod 600 /home/node/.openclaw/garmin_tokens/garmin_tokens.json
elif [ -s /home/node/.openclaw/garmin_tokens/garmin_tokens.json ]; then
echo "Preserving live Garmin OAuth tokens on PVC..."
chmod 700 /home/node/.openclaw/garmin_tokens
chmod 600 /home/node/.openclaw/garmin_tokens/garmin_tokens.json
fi
# 5. Cleanup legacy binaries and superseded local skills
rm -rf /home/node/.openclaw/bin/whisper* /home/node/.openclaw/bin/.whisper* /home/node/.openclaw/scripts/whisper* /home/node/.openclaw/models/whisper || true
rm -rf /home/node/.openclaw/skills/memobird /home/node/.openclaw/skills/nerd-report /home/node/.openclaw/skills/rvv-regensburg /home/node/.openclaw/skills/skill-creator || true
rm -rf /home/node/.openclaw/workspace/skills /home/node/.openclaw/workspace-koch/skills /home/node/.openclaw/workspace-recherche/skills || true
# 6. Ensure tools.allow and models.providers.openrouter.apiKey unset
openclaw config unset tools.allow || true
openclaw config unset models.providers.openrouter.apiKey || true
openclaw config unset agents.entries.mila_tracker.tools.profile || true
openclaw config unset agents.entries.nutrition_tracker.tools.profile || true
# 7. Decouple hardlinked bootstrap files
for dir in /home/node/.openclaw/workspace-*; do
if [ -d "$dir" ]; then
for file in USER.md MEMORY.md; do
if [ -f "/home/node/.openclaw/workspace/$file" ]; then
cp --remove-destination "/home/node/.openclaw/workspace/$file" "$dir/$file"
chown node:node "$dir/$file" 2>/dev/null || true
chmod 664 "$dir/$file" 2>/dev/null || true
fi
done
fi
done
# 8. Install CLI binaries
if [ ! -f "/home/node/.openclaw/bin/openhue.real" ]; then
echo "Installing openhue CLI binary..."
curl -sL https://github.com/openhue/openhue-cli/releases/download/0.24/openhue_Linux_x86_64.tar.gz | tar -xz -C /home/node/.openclaw/bin openhue
mv /home/node/.openclaw/bin/openhue /home/node/.openclaw/bin/openhue.real
printf '#!/bin/sh\nexport HOME=/home/node/.openclaw\nexec /home/node/.openclaw/bin/openhue.real "$@"\n' > /home/node/.openclaw/bin/openhue
chmod +x /home/node/.openclaw/bin/openhue /home/node/.openclaw/bin/openhue.real
fi
if [ ! -f "/home/node/.openclaw/bin/summarize" ]; then
echo "Installing summarize CLI via npm..."
npm install -g --prefix /home/node/.openclaw @steipete/summarize || true
fi
if [ ! -f "/home/node/.openclaw/bin/mcp-server-github" ]; then
echo "Installing @modelcontextprotocol/server-github via npm..."
npm install -g --prefix /home/node/.openclaw @modelcontextprotocol/server-github || true
fi
if [ ! -f "/home/node/.openclaw/bin/kubectl" ]; then
echo "Installing kubectl CLI binary..."
curl -fsSL https://dl.k8s.io/release/v1.31.0/bin/linux/amd64/kubectl -o /home/node/.openclaw/bin/kubectl
chmod +x /home/node/.openclaw/bin/kubectl
fi
if [ ! -f "/home/node/.openclaw/bin/jq" ]; then
echo "Installing jq CLI binary..."
curl -fsSL https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64 -o /home/node/.openclaw/bin/jq
chmod +x /home/node/.openclaw/bin/jq
fi
if [ ! -f "/home/node/.openclaw/bin/file" ]; then
echo "Setting up file CLI helper..."
cp "$SCRIPTS_DIR/file_cli.py" /home/node/.openclaw/bin/file
chmod +x /home/node/.openclaw/bin/file
fi
if [ -x "/home/node/.openclaw/.local/bin/python3" ]; then
/home/node/.openclaw/bin/uv pip install --break-system-packages --python /home/node/.openclaw/.local/bin/python3 pillow google-api-python-client pypdf || true
fi
# 9. Clone or pull infrapuzzle repository to PVC
mkdir -p /home/node/.openclaw/repos
if [ ! -d "/home/node/.openclaw/repos/infrapuzzle/.git" ]; then
echo "Cloning infrapuzzle repository to PVC..."
git clone --depth 1 "https://${GIT_SYNC_USERNAME}:${GIT_SYNC_PASSWORD}@git.moritzgraf.de/moritz/infrapuzzle.git" /home/node/.openclaw/repos/infrapuzzle || true
else
echo "Pulling latest infrapuzzle commits on PVC..."
git -C /home/node/.openclaw/repos/infrapuzzle pull origin master || git -C /home/node/.openclaw/repos/infrapuzzle pull origin main || true
fi
# 10. Seed and maintain persistent mcp-venv
if [ ! -d "/home/node/.openclaw/mcp-venv" ]; then
echo "Seeding persistent mcp-venv..."
uv venv --python 3.12 /home/node/.openclaw/mcp-venv
uv pip install --python /home/node/.openclaw/mcp-venv \
prometheus-mcp-server \
ha-mcp \
chuk-mcp-open-meteo \
gitea-mcp \
git+https://github.com/Taxuspt/garmin_mcp \
"mcp>=2.0" \
filetype \
google-api-python-client \
google-auth \
bring-api \
requests \
beautifulsoup4 \
youtube-transcript-api
fi
if [ -d "/home/node/.openclaw/mcp-venv" ]; then
python3 -c 'import os; p="/home/node/.openclaw/mcp-venv/lib/python3.12/site-packages/mcp/server/fastmcp.py"; open(p, "w").write("import os\nfrom fastmcp import FastMCP as _FastMCP, Context\n\nclass FastMCP(_FastMCP):\n def __init__(self, name=\"MCP Server\", host=None, port=None, **kwargs):\n if host is not None: os.environ[\"FASTMCP_HOST\"] = str(host)\n if port is not None: os.environ[\"FASTMCP_PORT\"] = str(port)\n super().__init__(name=name, **kwargs)\n")' || true
python3 "$SCRIPTS_DIR/patch_garmin_mcp.py" || true
fi
# 11. Extensions & Plugins
if [ ! -d "/home/node/.openclaw/extensions/perplexity" ]; then
echo "Installing perplexity plugin..."
openclaw plugins install clawhub:@openclaw/perplexity-plugin --accept-capabilities || true
fi
echo "Enabling memory-wiki plugin..."
openclaw plugins enable memory-wiki || true
echo "Patching perplexity web search runtime for chat completions compatibility..."
node "$SCRIPTS_DIR/patch_perplexity.mjs" || true
# 12. Ensure OpenRouter auth profile in state DB
echo "Ensuring OpenRouter auth profile in state DB..."
node --input-type=module -e '
import { DatabaseSync } from "node:sqlite";
const key = process.env.OPENROUTER_API_KEY;
if (key) {
try {
const dbPath = "/home/node/.openclaw/state/openclaw.sqlite";
const db = new DatabaseSync(dbPath);
db.prepare("CREATE TABLE IF NOT EXISTS config_machine_state (state_key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL)").run();
const row = db.prepare("SELECT value_json FROM config_machine_state WHERE state_key = \x27authProfiles.store\x27").get();
let store = row?.value_json ? JSON.parse(row.value_json) : { version: 1, profiles: {} };
if (!store.profiles) store.profiles = {};
store.profiles["openrouter:default"] = {
type: "api_key",
provider: "openrouter",
key: key
};
db.prepare("INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (\x27authProfiles.store\x27, ?, ?) ON CONFLICT(state_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms")
.run(JSON.stringify(store), Date.now());
console.log("Synchronized openrouter auth profile in state DB.");
} catch (e) {
console.error("Could not sync auth profile:", e.message);
}
}
' || true
# 13. Pre-warm SQLite page cache
echo "Pre-warming SQLite database files into OS page cache for instant cold start..."
cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true
echo "=== Bootstrap Complete ==="
+52 -5
View File
@@ -23,6 +23,15 @@ THRESHOLD_EVENT_COUNT = 30
CRON_MAX_AGE_HOURS = 48 CRON_MAX_AGE_HOURS = 48
RPC_TIMEOUT_MS = 180000 RPC_TIMEOUT_MS = 180000
# Tracking Channels: Deterministic sliding-window (mila_tracker, nutrition_tracker).
# Strict sliding window of ~5-8 events (2-3 turns max), 0 LLM tokens, instant truncation.
TRACKING_TARGETS = [
("mila_tracker", "agent:mila_tracker:telegram:group:-1004326921510:topic:2796"),
("nutrition_tracker", "agent:nutrition_tracker:telegram:group:-1004326921510:topic:2797"),
]
MAX_TRACKING_EVENTS = 8
MAX_TRACKING_LINES = 8
# Tier A: High-frequency interaction topics (koch, hebamme, shopping). # Tier A: High-frequency interaction topics (koch, hebamme, shopping).
# Deep-compaction keeps a compact summary + latest 2 turns without completely wiping conversational memory. # Deep-compaction keeps a compact summary + latest 2 turns without completely wiping conversational memory.
TIER_A_TARGETS = [ TIER_A_TARGETS = [
@@ -32,11 +41,12 @@ TIER_A_TARGETS = [
] ]
def prewarm_page_cache(): def prewarm_page_cache():
print("=== Pre-warming SQLite databases into OS page cache ===") print("=== Pre-warming SQLite databases and Knowledge Wiki into OS page cache ===")
t0 = time.time() t0 = time.time()
try: try:
subprocess.run( subprocess.run(
"cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true", "cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true; "
"find /home/node/.openclaw/wiki/main /home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null 2>&1 || true",
shell=True, shell=True,
timeout=90.0 timeout=90.0
) )
@@ -117,6 +127,41 @@ def prune_stale_cron_sessions():
print(f"Cron pruning complete: {total_pruned} sessions pruned, {total_tokens_reclaimed} tokens reclaimed.") print(f"Cron pruning complete: {total_pruned} sessions pruned, {total_tokens_reclaimed} tokens reclaimed.")
def compact_tracking_sessions():
print("=== Tracking Channels Sliding-Window Maintenance (mila_tracker, nutrition_tracker) ===")
for agent, key in TRACKING_TARGETS:
db_path = f"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite"
if not os.path.exists(db_path):
continue
try:
con = sqlite3.connect(db_path, timeout=5.0)
row = con.execute("SELECT current_session_id FROM session_nodes WHERE session_key=?", (key,)).fetchone()
if not row or not row[0]:
con.close()
print(f" [{agent}] No active session node found for {key}.")
continue
sid = row[0]
cnt = con.execute("SELECT count(*) FROM transcript_events WHERE session_id=?", (sid,)).fetchone()[0]
con.close()
if cnt > MAX_TRACKING_EVENTS:
print(f" [{agent}] Session {key} has {cnt} events (> {MAX_TRACKING_EVENTS}). Truncating to last {MAX_TRACKING_LINES} lines...")
cmd = [
"openclaw", "sessions", "compact", key,
"--agent", agent,
"--max-lines", str(MAX_TRACKING_LINES),
"--timeout", "30000"
]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode == 0:
print(f" ✓ Truncated successfully: {r.stdout.strip()}")
else:
print(f" ✗ Truncation failed: {r.stderr.strip()}")
else:
print(f" [{agent}] Session {key} has {cnt} events (<= {MAX_TRACKING_EVENTS}). Clean.")
except Exception as e:
print(f" ✗ [{agent}] Error during tracking compaction: {e}")
def compact_tier_a_sessions(): def compact_tier_a_sessions():
print("=== Tier A: Deep-Compaction (koch, hebamme, shopping) ===") print("=== Tier A: Deep-Compaction (koch, hebamme, shopping) ===")
for agent, key in TIER_A_TARGETS: for agent, key in TIER_A_TARGETS:
@@ -180,7 +225,7 @@ def get_session_metrics(agent, key, sid=None):
return 0, 0 return 0, 0
def compact_active_sessions(): def compact_active_sessions():
tier_a_keys = set(t[1] for t in TIER_A_TARGETS) excluded_keys = set(t[1] for t in TIER_A_TARGETS) | set(t[1] for t in TRACKING_TARGETS)
print(f"=== Tier B: Running Multi-Agent Compaction (threshold: {THRESHOLD_TOKENS} tokens / {THRESHOLD_TRANSCRIPT_BYTES//1024}KB, LLM summarization) ===") print(f"=== Tier B: Running Multi-Agent Compaction (threshold: {THRESHOLD_TOKENS} tokens / {THRESHOLD_TRANSCRIPT_BYTES//1024}KB, LLM summarization) ===")
res = subprocess.run(["openclaw", "sessions", "--all-agents", "--json"], capture_output=True, text=True) res = subprocess.run(["openclaw", "sessions", "--all-agents", "--json"], capture_output=True, text=True)
if res.returncode != 0: if res.returncode != 0:
@@ -197,8 +242,8 @@ def compact_active_sessions():
tokens = s.get("totalTokens") or 0 tokens = s.get("totalTokens") or 0
sid = s.get("sessionId") sid = s.get("sessionId")
# Skip Tier A agents as they are handled via hard-reset # Skip Tier A and Tracking agents as they have dedicated routines
if key in tier_a_keys: if key in excluded_keys:
continue continue
# Target main sessions and all telegram sessions (topics and direct) # Target main sessions and all telegram sessions (topics and direct)
@@ -280,10 +325,12 @@ def main():
except Exception: except Exception:
pass pass
prune_stale_cron_sessions() prune_stale_cron_sessions()
compact_tracking_sessions()
compact_tier_a_sessions() compact_tier_a_sessions()
compact_active_sessions() compact_active_sessions()
checkpoint_wal_journals() checkpoint_wal_journals()
vacuum_and_optimize() vacuum_and_optimize()
prewarm_page_cache()
if __name__ == "__main__": if __name__ == "__main__":
main() main()
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""
Lightweight sliding-window compaction for OpenClaw tracking channels:
- mila_tracker (topic 2796)
- nutrition_tracker (topic 2797)
Maintains a strict sliding window of ~5-8 transcript events (max 2-3 turns).
Uses native gateway truncation (--max-lines 8) requiring 0 LLM tokens and 0 external API calls.
"""
import os
import sqlite3
import subprocess
import sys
import time
MAX_TRACKING_EVENTS = 8
MAX_TRACKING_LINES = 8
TIMEOUT_MS = 30000
TRACKING_TARGETS = [
("mila_tracker", "agent:mila_tracker:telegram:group:-1004326921510:topic:2796"),
("nutrition_tracker", "agent:nutrition_tracker:telegram:group:-1004326921510:topic:2797"),
]
def check_and_compact_tracking():
compacted_count = 0
skipped_count = 0
for agent, key in TRACKING_TARGETS:
db_path = f"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite"
if not os.path.exists(db_path):
continue
try:
con = sqlite3.connect(db_path, timeout=5.0)
row = con.execute("SELECT current_session_id FROM session_nodes WHERE session_key=?", (key,)).fetchone()
if not row or not row[0]:
con.close()
continue
sid = row[0]
cnt = con.execute("SELECT count(*) FROM transcript_events WHERE session_id=?", (sid,)).fetchone()[0]
con.close()
if cnt > MAX_TRACKING_EVENTS:
print(f"[{agent}] Session {key} has {cnt} events (threshold {MAX_TRACKING_EVENTS}). Truncating to last {MAX_TRACKING_LINES} lines...")
cmd = [
"openclaw", "sessions", "compact", key,
"--agent", agent,
"--max-lines", str(MAX_TRACKING_LINES),
"--timeout", str(TIMEOUT_MS)
]
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode == 0:
print(f" ✓ [{agent}] Truncated successfully: {res.stdout.strip()}")
compacted_count += 1
else:
print(f" ✗ [{agent}] Truncation failed: {res.stderr.strip()}", file=sys.stderr)
else:
skipped_count += 1
except Exception as e:
print(f"[{agent}] Error checking session: {e}", file=sys.stderr)
if compacted_count > 0:
print(f"Tracking maintenance: {compacted_count} sessions truncated, {skipped_count} within window.")
if __name__ == "__main__":
check_and_compact_tracking()
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
import glob, json, os, sqlite3, sys
cfg_path = "/home/node/.openclaw/openclaw.json"
if os.path.exists(cfg_path):
try:
with open(cfg_path, "r") as f:
cfg = json.load(f)
mem_search = cfg.get("memory", {}).get("search", {})
if mem_search.get("extraPaths"):
mem_search["extraPaths"] = []
with open(cfg_path, "w") as f:
json.dump(cfg, f, indent=2)
print("[dedup_subagents] Cleared top-level memory.search.extraPaths from openclaw.json (scoped to main only)")
except Exception as e:
print(f"[dedup_subagents] Warning checking openclaw.json: {e}", file=sys.stderr)
vec_path = "/app/node_modules/sqlite-vec-linux-x64/vec0.so"
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
agent = os.path.basename(os.path.dirname(os.path.dirname(db)))
if agent == "main":
continue
try:
conn = sqlite3.connect(db, timeout=10.0)
if os.path.exists(vec_path):
conn.enable_load_extension(True)
conn.load_extension(vec_path)
cur = conn.cursor()
chk = cur.execute("SELECT count(*) FROM memory_index_chunks").fetchone()[0]
if chk > 0:
print(f"[dedup_subagents] Purging {chk} duplicated vector chunks from agent {agent}...")
for t in [
"memory_index_sources",
"memory_index_chunks",
"memory_index_chunk_recall_metadata",
"memory_index_chunk_provenance",
"memory_entry_origins",
"memory_embedding_cache",
"memory_index_chunks_vec"
]:
try: cur.execute(f"DELETE FROM {t}")
except Exception: pass
conn.commit()
cur.execute("VACUUM")
print(f"[dedup_subagents] Reclaimed disk space for agent {agent}")
conn.close()
except Exception as e:
print(f"[dedup_subagents] Notice for {agent}: {e}", file=sys.stderr)
+50
View File
@@ -0,0 +1,50 @@
#!/home/node/.openclaw/mcp-venv/bin/python3
import sys, os, mimetypes, filetype
def get_file_info(filepath, mime_only=False):
if not os.path.exists(filepath):
return "cannot open (No such file or directory)", "cannot open (No such file or directory)"
if os.path.isdir(filepath):
return "directory", "inode/directory"
kind = filetype.guess(filepath)
if kind is not None:
return f"{kind.extension.upper()} image/media data ({kind.mime})", kind.mime
guessed_mime, _ = mimetypes.guess_type(filepath)
try:
with open(filepath, "rb") as f:
sample = f.read(4096)
if len(sample) == 0:
return "empty", "application/x-empty"
try:
sample.decode("utf-8")
if guessed_mime:
return f"{guessed_mime} text", guessed_mime
return "ASCII text", "text/plain"
except UnicodeDecodeError:
return "data", "application/octet-stream"
except Exception as e:
return f"cannot read ({e})", "application/octet-stream"
def main():
args = sys.argv[1:]
brief, mime_only, files = False, False, []
for arg in args:
if arg in ("-b", "--brief"):
brief = True
elif arg in ("-i", "--mime", "--mime-type"):
mime_only = True
elif arg.startswith("-") and len(arg) > 1 and not arg.startswith("--"):
if "b" in arg: brief = True
if "i" in arg: mime_only = True
else:
files.append(arg)
if not files:
sys.stderr.write("Usage: file [-b] [-i|--mime-type] <file>...\n")
sys.exit(1)
for f in files:
desc, mime = get_file_info(f, mime_only)
res = mime if mime_only else desc
print(res if brief else f"{f}: {res}")
if __name__ == "__main__":
main()
@@ -0,0 +1,78 @@
import fs from "node:fs";
import path from "node:path";
import { execSync, spawn } from "node:child_process";
if (!process.env.__OPENCLAW_PREFLIGHT_DONE) {
process.env.__OPENCLAW_PREFLIGHT_DONE = "1";
const isGateway = process.argv.some(arg => arg.endsWith("openclaw.mjs") || arg.endsWith("openclaw")) && process.argv.includes("gateway");
if (isGateway) {
const t0 = Date.now();
console.log("[gateway-preflight] Initializing container self-healing preflight...");
// 1. Clean up stale gateway lock files from ungraceful kills
for (const dir of ["/tmp/openclaw-1000", "/tmp"]) {
if (fs.existsSync(dir)) {
try {
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith("gateway") && file.endsWith(".lock")) {
const fullPath = path.join(dir, file);
fs.unlinkSync(fullPath);
console.log(`[gateway-preflight] Removed stale lock: ${fullPath}`);
}
}
} catch (e) {
console.warn(`[gateway-preflight] Lock cleanup warning in ${dir}:`, e.message);
}
}
}
// 2. Clear false database quarantines
const qPath = "/home/node/.openclaw/state/openclaw-quarantine.sqlite";
if (fs.existsSync(qPath)) {
try {
execSync(`/home/node/.openclaw/.local/bin/python3 -c "import sqlite3; c=sqlite3.connect(\x27${qPath}\x27); c.execute(\x27DELETE FROM quarantined_databases\x27); c.commit(); c.close()"`, { stdio: "ignore" });
console.log("[gateway-preflight] Cleared database quarantine.");
} catch (_) {}
}
// 3. Deduplicate subagent vector databases (keeps non-main agent DBs < 15MB)
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/dedup_subagent_vectors.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Vector deduplication warning:", e.message);
}
// 4. Pre-warm page cache sequentially (transforms random 70 IOPS disk reads into sequential streaming)
try {
const pw0 = Date.now();
execSync("cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true; find /home/node/.openclaw/wiki/main /home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null 2>&1 || true");
console.log(`[gateway-preflight] Pre-warmed SQLite databases and Wiki markdown in ${Date.now() - pw0}ms`);
} catch (_) {}
// 5. Recover any wedged running sessions left over from ungraceful container restart
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/recover_sessions.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Session recovery warning:", e.message);
}
// 6. Spawn background turn replay daemon if interrupted turns were detected
try {
if (fs.existsSync("/tmp/openclaw-interrupted-turns.json")) {
console.log("[gateway-preflight] Spawning background turn replay daemon...");
const child = spawn("/home/node/.openclaw/.local/bin/python3", ["/home/node/.openclaw/scripts/replay_interrupted_turns.py"], {
detached: true,
stdio: "ignore"
});
child.unref();
}
} catch (e) {
console.warn("[gateway-preflight] Could not spawn turn replay daemon:", e.message);
}
console.log(`[gateway-preflight] Self-healing completed in ${Date.now() - t0}ms.`);
}
}
+18
View File
@@ -22,6 +22,8 @@ TOPIC_TO_AGENT = {
"118": "doktor", "118": "doktor",
"119": "itguy", "119": "itguy",
"164": "grabengasse_media", "164": "grabengasse_media",
"2796": "mila_tracker",
"2797": "nutrition_tracker",
} }
def parse_args(): def parse_args():
@@ -90,6 +92,22 @@ def triage_agent_session(agent, topic_id=None, limit=10, db_root="/home/node/.op
elif status == "processing": elif status == "processing":
badges.append("⚡ [ACTIVE_PROCESSING]") badges.append("⚡ [ACTIVE_PROCESSING]")
# Check ingress queue status
state_db = "/home/node/.openclaw/state/openclaw.sqlite"
if topic_id and os.path.exists(state_db):
try:
s_con = sqlite3.connect(state_db, timeout=2.0)
lane_pat = f"%topic:{topic_id}%"
pending_cnt = s_con.execute(
"SELECT count(*) FROM channel_ingress_events WHERE lane_key LIKE ? AND status IN ('pending', 'claimed')",
(lane_pat,)
).fetchone()[0]
s_con.close()
if pending_cnt > 0:
badges.append(f"📬 [INGRESS_STALLED: {pending_cnt} message(s) pending]")
except Exception:
pass
print("=" * 72) print("=" * 72)
print(f"🎯 AGENT: {agent.upper():<12} | TOPIC: {topic_id or 'N/A':<6} | STATUS: {status.upper()}") print(f"🎯 AGENT: {agent.upper():<12} | TOPIC: {topic_id or 'N/A':<6} | STATUS: {status.upper()}")
print(f" SESSION KEY: {skey}") print(f" SESSION KEY: {skey}")
+64
View File
@@ -0,0 +1,64 @@
import os
p = "/home/node/.openclaw/mcp-venv/lib/python3.12/site-packages/garmin_mcp/workouts.py"
if os.path.exists(p):
with open(p, "r") as f:
c = f.read()
if "_normalize_sport_type" not in c:
patch = '''
SPORT_TYPE_MAPPINGS = {
"running": {"sportTypeId": 1, "sportTypeKey": "running", "displayOrder": 1},
"cycling": {"sportTypeId": 2, "sportTypeKey": "cycling", "displayOrder": 2},
"other": {"sportTypeId": 3, "sportTypeKey": "other", "displayOrder": 3},
"lap_swimming": {"sportTypeId": 4, "sportTypeKey": "lap_swimming", "displayOrder": 4},
"swimming": {"sportTypeId": 4, "sportTypeKey": "lap_swimming", "displayOrder": 4},
"strength_training": {"sportTypeId": 5, "sportTypeKey": "strength_training", "displayOrder": 5},
"cardio_training": {"sportTypeId": 6, "sportTypeKey": "cardio_training", "displayOrder": 6},
"cardio": {"sportTypeId": 6, "sportTypeKey": "cardio_training", "displayOrder": 6},
"yoga": {"sportTypeId": 7, "sportTypeKey": "yoga", "displayOrder": 8},
"pilates": {"sportTypeId": 8, "sportTypeKey": "pilates", "displayOrder": 9},
"hiit": {"sportTypeId": 9, "sportTypeKey": "hiit", "displayOrder": 7},
"mobility": {"sportTypeId": 11, "sportTypeKey": "mobility", "displayOrder": 11},
"walking": {"sportTypeId": 12, "sportTypeKey": "walking", "displayOrder": 10},
"rucking": {"sportTypeId": 13, "sportTypeKey": "rucking", "displayOrder": 12},
}
SPORT_ID_MAPPINGS = {v["sportTypeId"]: v for v in SPORT_TYPE_MAPPINGS.values()}
def _resolve_sport_block(st):
if isinstance(st, str):
k = st.strip().lower()
if k in SPORT_TYPE_MAPPINGS:
return dict(SPORT_TYPE_MAPPINGS[k])
elif isinstance(st, dict):
key = st.get("sportTypeKey")
sid = st.get("sportTypeId")
if key and str(key).strip().lower() in SPORT_TYPE_MAPPINGS:
base = dict(SPORT_TYPE_MAPPINGS[str(key).strip().lower()])
if sid is not None and sid != 0 and sid != base["sportTypeId"]:
raise ValueError(f"sportType mismatch: {key} vs {sid}")
return base
if sid is not None and sid in SPORT_ID_MAPPINGS:
return dict(SPORT_ID_MAPPINGS[sid])
return None
def _normalize_sport_type(workout_data: dict) -> None:
resolved = _resolve_sport_block(workout_data.get("sportType"))
segments = workout_data.get("workoutSegments") or []
if not resolved and segments:
for seg in segments:
resolved = _resolve_sport_block(seg.get("sportType"))
if resolved:
break
if not resolved:
raise ValueError("Missing or invalid sportType. Both sportTypeId and sportTypeKey are required.")
workout_data["sportType"] = resolved
for seg in segments:
st = _resolve_sport_block(seg.get("sportType"))
seg["sportType"] = st if st else dict(resolved)
'''
t = "def _normalize_workout_steps(workout_data: dict) -> None:"
if t in c:
with open(p, "w") as f:
f.write(c.replace(t, patch + t + "\n _normalize_sport_type(workout_data)", 1))
print("Successfully patched garmin_mcp workouts.py!")
+61
View File
@@ -0,0 +1,61 @@
import fs from "node:fs";
import path from "node:path";
import { globSync } from "node:fs";
const baseDir = "/home/node/.openclaw/extensions/perplexity/dist";
if (fs.existsSync(baseDir)) {
const files = fs.readdirSync(baseDir).filter(f => f.startsWith("perplexity-web-search-provider.runtime-") && f.endsWith(".js"));
for (const filename of files) {
const file = path.join(baseDir, filename);
let code = fs.readFileSync(file, "utf8");
if (!code.includes("/* PATCHED_CHAT_COMPLETIONS */")) {
const target = "const structured = runtime.transport === \"search_api\";";
const fastPath = `const structured = runtime.transport === "search_api"; /* PATCHED_CHAT_COMPLETIONS */
if (!structured) {
const cacheKey = buildSearchCacheKey([
"perplexity",
runtime.transport,
runtime.baseUrl,
runtime.model,
query,
freshness
]);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
const cached = readCachedSearchPayload(cacheKey, cacheTtlMs);
if (cached) return cached;
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const result = await runPerplexitySearch({
query,
apiKey: runtime.apiKey,
baseUrl: runtime.baseUrl,
model: runtime.model,
timeoutSeconds,
signal,
freshness
});
const payload = {
query,
provider: "perplexity",
model: runtime.model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true
},
content: wrapWebContent(result.content, "web_search"),
citations: result.citations
};
writeCachedSearchPayload(cacheKey, payload);
return payload;
}`;
if (code.includes(target)) {
code = code.replace(target, fastPath);
fs.writeFileSync(file, code, "utf8");
console.log("Patched perplexity runtime for chat completions:", file);
}
}
}
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
import glob, sqlite3, json, os, sys, time
t0 = time.time()
recovered = 0
interrupted_turns = []
def extract_prompt(msg):
if not msg: return ""
content = msg.get("content")
if isinstance(content, str): return content
if isinstance(content, list):
parts = []
for c in content:
if isinstance(c, dict): parts.append(c.get("text") or c.get("content") or "")
elif isinstance(c, str): parts.append(c)
return "\n".join(filter(None, parts))
return str(content or "")
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
try:
conn = sqlite3.connect(db, timeout=10.0)
cursor = conn.cursor()
while True:
cursor.execute("PRAGMA foreign_key_check;")
violations = cursor.fetchall()
if not violations: break
for table, rowid, parent, fkid in violations:
cursor.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
cursor.execute("SELECT session_key, entry_json, current_session_id, status FROM session_nodes WHERE status IN (\"running\", \"failed\", \"error\")")
rows = cursor.fetchall()
agent_id = os.path.basename(os.path.dirname(os.path.dirname(db)))
for sk, ej, csid, st in rows:
try:
p = json.loads(ej) if ej else {}
sid = csid or p.get("sessionId")
if sid:
ev = cursor.execute("SELECT seq, event_json FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT 1", (sid,)).fetchone()
if ev:
ev_seq, ev_raw = ev
try:
ev_data = json.loads(ev_raw)
msg = ev_data.get("message") or ev_data
if st == "running" and msg.get("role") == "user":
prompt_text = extract_prompt(msg)
ctx = p.get("restartRecoveryDeliveryContext") or p.get("delivery", {}).get("context") or p.get("delivery", {}).get("route", {}).get("context") or {}
interrupted_turns.append({
"agent": agent_id,
"session_key": sk,
"session_id": sid,
"context": ctx,
"prompt": prompt_text
})
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
elif msg.get("role") == "assistant" and msg.get("stopReason") == "error":
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
except Exception as ex:
print(f"[recover_sessions] Error parsing event: {ex}", file=sys.stderr)
p["status"] = "done"
p["activeWriterRunId"] = None
p["activeClaimRunId"] = None
p["restartRecoveryDeliveryRunId"] = None
cursor.execute("UPDATE session_nodes SET status = \"done\", entry_json = ? WHERE session_key = ?", (json.dumps(p), sk))
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE session_key = ?", (sk,))
recovered += 1
except Exception: pass
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE entry_valid != 1")
cursor.execute("UPDATE session_windows SET status = \"done\" WHERE status IN (\"running\", \"failed\", \"error\")")
conn.commit()
conn.close()
except Exception as e:
print(f"[recover_sessions] Warning for {db}: {e}", file=sys.stderr)
state_db = "/home/node/.openclaw/state/openclaw.sqlite"
if os.path.exists(state_db):
try:
sconn = sqlite3.connect(state_db, timeout=10.0)
scur = sconn.cursor()
scur.execute("UPDATE channel_ingress_events SET attempts = 0, last_attempt_at = NULL, last_error = NULL WHERE status = 'pending' AND attempts > 0")
sconn.commit()
sconn.close()
except Exception as e:
print(f"[recover_sessions] Warning resetting ingress backoff: {e}", file=sys.stderr)
if interrupted_turns:
try:
with open("/tmp/openclaw-interrupted-turns.json", "w") as f:
json.dump(interrupted_turns, f, indent=2)
print(f"[recover_sessions] Detected {len(interrupted_turns)} interrupted user turn(s) saved to /tmp/openclaw-interrupted-turns.json")
except Exception as e:
print(f"[recover_sessions] Error writing interrupted turns: {e}", file=sys.stderr)
print(f"[recover_sessions] Completed in {time.time() - t0:.2f}s ({recovered} wedged sessions recovered)")
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
import json, os, subprocess, sys, time, urllib.request
INTERRUPTED_FILE = "/tmp/openclaw-interrupted-turns.json"
if not os.path.exists(INTERRUPTED_FILE):
sys.exit(0)
try:
with open(INTERRUPTED_FILE, "r") as f:
turns = json.load(f)
except Exception as e:
print(f"[replay_interrupted_turns] Failed to read {INTERRUPTED_FILE}: {e}", file=sys.stderr)
sys.exit(0)
if not turns:
sys.exit(0)
print(f"[replay_interrupted_turns] Found {len(turns)} interrupted turn(s). Waiting for gateway /healthz...")
gateway_ready = False
for _ in range(60):
try:
with urllib.request.urlopen("http://127.0.0.1:18790/healthz", timeout=3) as resp:
if resp.status == 200:
gateway_ready = True
break
except Exception:
pass
time.sleep(2)
if not gateway_ready:
print("[replay_interrupted_turns] Gateway did not become healthy in 120s. Aborting.", file=sys.stderr)
sys.exit(1)
# Allow 5s for Telegram ingress to bind
time.sleep(5)
for turn in turns:
sk = turn.get("session_key")
sid = turn.get("session_id")
agent_id = turn.get("agent")
ctx = turn.get("context") or {}
prompt = turn.get("prompt") or ""
channel = ctx.get("channel") or "telegram"
to_target = ctx.get("to") or ""
thread_id = ctx.get("threadId")
target = to_target
if target.startswith("telegram:"):
parts = target.split(":")
target = parts[1] if len(parts) > 1 else target
print(f"[replay_interrupted_turns] Processing agent '{agent_id}' on session '{sk}' target='{target}' thread='{thread_id}'")
# 1. Send status notification to Telegram if target exists
if target:
notice = "⚠️ OpenClaw wurde während der Bearbeitung neu gestartet – deine Anfrage wird jetzt nachgeholt..."
notify_cmd = [
"openclaw", "message", "send",
"--channel", channel,
"--target", str(target),
"-m", notice
]
if thread_id is not None:
notify_cmd.extend(["--thread-id", str(thread_id)])
try:
subprocess.run(notify_cmd, check=False, timeout=30)
print(f"[replay_interrupted_turns] Sent recovery notice to {target}")
except Exception as e:
print(f"[replay_interrupted_turns] Notice failed: {e}", file=sys.stderr)
# 2. Replay agent turn
if sk and prompt:
p_short = (prompt[:60] + "...") if len(prompt) > 60 else prompt
print(f"[replay_interrupted_turns] Replaying agent turn: {p_short}")
agent_cmd = [
"openclaw", "agent",
"--session-key", sk,
"-m", prompt,
"--deliver"
]
try:
res = subprocess.run(agent_cmd, capture_output=True, text=True, timeout=300)
if res.returncode == 0:
print(f"[replay_interrupted_turns] Turn replay succeeded for {sk}")
else:
print(f"[replay_interrupted_turns] Turn replay exited with code {res.returncode}: {res.stderr[:200]}", file=sys.stderr)
except Exception as e:
print(f"[replay_interrupted_turns] Replay execution error: {e}", file=sys.stderr)
try:
os.remove(INTERRUPTED_FILE)
print(f"[replay_interrupted_turns] Cleaned up {INTERRUPTED_FILE}")
except Exception:
pass
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
sync_crons.py
Idempotently reconciles declarative OpenClaw automations defined in crons.yaml
with the running OpenClaw gateway state.
Can run directly inside the container or remotely via kubectl exec.
"""
import json
import os
import subprocess
import sys
try:
import yaml
except ImportError:
venv_py = "/home/node/.openclaw/mcp-venv/bin/python3"
if os.path.exists(venv_py) and sys.executable != venv_py:
os.execv(venv_py, [venv_py] + sys.argv)
raise
def find_crons_file():
candidates = [
os.environ.get("CRONS_FILE"),
"crons.yaml",
"k8s/openclaw/crons.yaml",
"/home/node/.openclaw/scripts/crons.yaml",
os.path.join(os.path.dirname(__file__), "../crons.yaml"),
os.path.join(os.path.dirname(__file__), "crons.yaml"),
]
for c in candidates:
if c and os.path.exists(c):
return os.path.abspath(c)
raise FileNotFoundError("Could not find crons.yaml. Pass via CRONS_FILE environment variable.")
def is_inside_container():
return os.path.exists("/home/node/.openclaw") or os.environ.get("OPENCLAW_HOME") is not None
def run_openclaw_cmd(args):
if is_inside_container():
cmd = ["openclaw"] + args
else:
namespace = os.environ.get("NAMESPACE", "openclaw")
pod = os.environ.get("POD_NAME", "openclaw-0")
container = os.environ.get("CONTAINER_NAME", "openclaw")
cmd = ["kubectl", "exec", "-n", namespace, pod, "-c", container, "--", "openclaw"] + args
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(f"OpenClaw CLI command failed ({' '.join(cmd)}):\n{res.stderr}\n{res.stdout}")
return res.stdout
def list_existing_jobs():
raw = run_openclaw_cmd(["cron", "list", "--json"])
try:
data = json.loads(raw)
return data.get("jobs", [])
except Exception as e:
print(f"Warning: could not parse cron list json: {e}\nRaw output: {raw[:300]}", file=sys.stderr)
return []
def reconcile_jobs():
file_path = find_crons_file()
print(f"=== Syncing OpenClaw Crons-as-Code from {file_path} ===")
with open(file_path, "r", encoding="utf-8") as f:
spec = yaml.safe_load(f)
declarations = spec.get("jobs", [])
existing_jobs = list_existing_jobs()
print(f"Found {len(declarations)} declared jobs in YAML, {len(existing_jobs)} active jobs on gateway.")
for job in declarations:
name = job.get("name")
decl_key = job.get("declarationKey") or name
desc = job.get("description", "")
sched = job.get("schedule", {})
# Match existing job by declarationKey or exact name
match = next((j for j in existing_jobs if j.get("declarationKey") == decl_key or j.get("name") == name), None)
if match:
existing_id = match.get("id")
print(f"Removing existing job '{name}' (ID {existing_id}) for idempotent recreation...")
try:
run_openclaw_cmd(["cron", "rm", existing_id])
except Exception as e:
print(f"Warning removing {existing_id}: {e}")
# Build add command
cmd = [
"cron", "add",
"--name", name,
"--declaration-key", decl_key,
"--description", desc,
]
if sched.get("kind") == "every":
cmd.extend(["--every", sched.get("every", "15m")])
elif sched.get("kind") == "cron":
cmd.extend(["--cron", sched.get("expr", "* * * * *")])
if sched.get("tz"):
cmd.extend(["--tz", sched.get("tz")])
if job.get("session"):
cmd.extend(["--session", job.get("session")])
if job.get("delivery") == "none":
cmd.append("--no-deliver")
if job.get("command"):
cmd.extend(["--command", job.get("command")])
if job.get("agent"):
cmd.extend(["--agent", job.get("agent")])
if job.get("model"):
cmd.extend(["--model", job.get("model")])
if job.get("channel"):
cmd.extend(["--channel", job.get("channel")])
if job.get("to"):
cmd.extend(["--to", job.get("to")])
if job.get("threadId"):
cmd.extend(["--thread-id", str(job.get("threadId"))])
if job.get("announce"):
cmd.append("--announce")
if job.get("message"):
cmd.extend(["--message", job.get("message")])
print(f"Registering job '{name}'...")
run_openclaw_cmd(cmd)
print(f"Successfully registered '{name}'.")
print("=== OpenClaw Crons-as-Code reconciliation complete! ===")
if __name__ == "__main__":
reconcile_jobs()
+56 -319
View File
@@ -1,14 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================== # ==============================================================================
# setup-compaction-cron.sh # setup-compaction-cron.sh
# Declaratively registers the Multi-Agent Nightly Session Compaction in OpenClaw. # Declaratively registers OpenClaw session maintenance automations:
# Schedule: Every night at 04:45 Europe/Berlin (02:45 UTC) (cron: 45 4 * * *) # 1. Tracking Sliding Window (every 15m): Truncates mila_tracker & nutrition_tracker
# # to <= 8 events (~5 messages / 2 turns) via instant native gateway truncation.
# Architecture: # 2. Nightly Deep Compaction (every night at 04:45 Europe/Berlin):
# - Tier A: Deep-Compaction for high-frequency topics (koch, hebamme, shopping) # - Pre-warms SQLite page cache on rotational storage
# (maintains conversational continuity while keeping token count very low) # - Recovers wedged/quarantined sessions
# - Tier B: Smart Compaction for long-running project agents (itguy, recherche, regensburg, fitness, doktor) # - Prunes abandoned isolated cron sessions (>48h)
# - WAL checkpoints and SQLite optimization without blocking full VACUUM # - Deep-compacts conversational topics (koch, hebamme, shopping)
# - Smart-compacts active long-running sessions (itguy, recherche, etc.)
# - Flushes SQLite WAL journals and executes PRAGMA optimize
# ============================================================================== # ==============================================================================
set -euo pipefail set -euo pipefail
@@ -16,324 +18,62 @@ set -euo pipefail
NAMESPACE="${NAMESPACE:-openclaw}" NAMESPACE="${NAMESPACE:-openclaw}"
POD_NAME="${POD_NAME:-openclaw-0}" POD_NAME="${POD_NAME:-openclaw-0}"
CONTAINER_NAME="${CONTAINER_NAME:-openclaw}" CONTAINER_NAME="${CONTAINER_NAME:-openclaw}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
JOB_NAME="nightly-session-compaction" echo "=== Registering Multi-Agent Session Maintenance in OpenClaw ($POD_NAME) ==="
# 1. Install maintenance scripts from repo onto PVC
echo "Syncing compact_tracking_sessions.py to pod..."
kubectl exec -i -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- /bin/sh -c \
'cat > /home/node/.openclaw/scripts/compact_tracking_sessions.py && chmod +x /home/node/.openclaw/scripts/compact_tracking_sessions.py' \
< "$SCRIPT_DIR/scripts/compact_tracking_sessions.py"
echo "Syncing compact_sessions.py to pod..."
kubectl exec -i -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- /bin/sh -c \
'cat > /home/node/.openclaw/scripts/compact_sessions.py && chmod +x /home/node/.openclaw/scripts/compact_sessions.py' \
< "$SCRIPT_DIR/scripts/compact_sessions.py"
# 2. Register / Update 15-Minute Tracking Sliding Window Automation
TRACKING_JOB="tracking-session-sliding-window"
echo "Checking existing OpenClaw Cron automation '$TRACKING_JOB'..."
EXISTING_TRACKING_ID=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list --json 2>/dev/null | python3 -c 'import sys, json; data=json.load(sys.stdin); print(next((j["id"] for j in data.get("jobs", []) if j.get("declarationKey") == "'"$TRACKING_JOB"'"), ""))' 2>/dev/null || true)
if [ -n "$EXISTING_TRACKING_ID" ]; then
echo "Updating existing tracking sliding window automation '$EXISTING_TRACKING_ID' (every 15m)..."
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron edit "$EXISTING_TRACKING_ID" \
--every "15m" \
--command "python3 /home/node/.openclaw/scripts/compact_tracking_sessions.py"
else
echo "Registering new tracking sliding window automation '$TRACKING_JOB' (every 15m)..."
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
--name "Tracking Session Sliding Window" \
--declaration-key "$TRACKING_JOB" \
--description "Lightweight sliding-window truncation (max 8 events) for mila_tracker and nutrition_tracker" \
--every "15m" \
--session "isolated" \
--no-deliver \
--command "python3 /home/node/.openclaw/scripts/compact_tracking_sessions.py"
fi
# 3. Register / Update Nightly Deep Compaction Automation
NIGHTLY_JOB="nightly-session-compaction"
CRON_SCHEDULE="45 4 * * *" CRON_SCHEDULE="45 4 * * *"
TIMEZONE="Europe/Berlin" TIMEZONE="Europe/Berlin"
echo "=== Registering Multi-Agent Nightly Session Maintenance in OpenClaw ($POD_NAME) ===" echo "Checking existing OpenClaw Cron automation '$NIGHTLY_JOB'..."
EXISTING_NIGHTLY_ID=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list --json 2>/dev/null | python3 -c 'import sys, json; data=json.load(sys.stdin); print(next((j["id"] for j in data.get("jobs", []) if j.get("declarationKey") == "'"$NIGHTLY_JOB"'"), ""))' 2>/dev/null || true)
# 1. Install or ensure python maintenance script is present on PVC if [ -n "$EXISTING_NIGHTLY_ID" ]; then
echo "Ensuring /home/node/.openclaw/scripts/compact_sessions.py exists..." echo "Updating existing nightly compaction automation '$EXISTING_NIGHTLY_ID' ($CRON_SCHEDULE @ $TIMEZONE)..."
kubectl exec -i -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- /bin/sh -c 'cat << "EOF" > /home/node/.openclaw/scripts/compact_sessions.py kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron edit "$EXISTING_NIGHTLY_ID" \
#!/usr/bin/env python3
"""
Nightly multi-agent session maintenance and compaction script for OpenClaw.
1. Pre-warms OS page cache for SQLite databases on spinning disks.
2. Recovers any wedged or locked sessions via recover_sessions.py.
3. Prunes abandoned isolated cron sessions (>48h old) to prevent token/state bloat.
4. Tier A Hard-Reset: Resets koch, hebamme, and shopping topics to 0 tokens (clean archive).
5. Tier B Smart-Compaction: Compacts active sessions exceeding token thresholds (5s pacing).
6. Checkpoints SQLite WAL journals across all agent databases to disk.
7. Optimizes SQLite query planner without blocking full VACUUM.
"""
import glob
import json
import os
import sqlite3
import subprocess
import sys
import time
THRESHOLD_TOKENS = 15000
THRESHOLD_TRANSCRIPT_BYTES = 200000
THRESHOLD_EVENT_COUNT = 30
CRON_MAX_AGE_HOURS = 48
RPC_TIMEOUT_MS = 180000
# Tier A: High-frequency interaction topics (koch, hebamme, shopping).
# Deep-compaction keeps a compact summary + latest 2 turns without completely wiping conversational memory.
TIER_A_TARGETS = [
("koch", "agent:koch:telegram:group:-1004326921510:topic:21"),
("hebamme", "agent:hebamme:telegram:group:-1004326921510:topic:22"),
("shopping", "agent:shopping:telegram:group:-1004326921510:topic:115"),
]
def prewarm_page_cache():
print("=== Pre-warming SQLite databases into OS page cache ===")
t0 = time.time()
try:
subprocess.run(
"cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true",
shell=True,
timeout=90.0
)
print(f"Page cache pre-warmed in {time.time() - t0:.2f}s")
except Exception as e:
print(f"Page cache pre-warming warning: {e}")
def prune_stale_cron_sessions():
print(f"=== Pruning isolated cron sessions older than {CRON_MAX_AGE_HOURS}h ===")
cutoff_ms = int(time.time() * 1000) - (CRON_MAX_AGE_HOURS * 3600 * 1000)
total_pruned = 0
total_tokens_reclaimed = 0
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
agent = db.split("/")[5]
try:
conn = sqlite3.connect(db, timeout=30.0)
conn.execute("PRAGMA foreign_keys = ON;")
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type=\"table\"")
all_tables = set(r[0] for r in cursor.fetchall())
cursor.execute("SELECT session_key, current_session_id, updated_at, entry_json FROM session_nodes WHERE session_key LIKE \"%:cron:%\"")
stale = []
for k, sid, up, ej in cursor.fetchall():
if up and up < cutoff_ms:
tok = 0
if ej:
try:
tok = json.loads(ej).get("totalTokens", 0) or 0
except Exception:
pass
stale.append((k, sid, tok))
if stale:
tokens_reclaimed = sum(t[2] for t in stale)
for k, sid, _ in stale:
for t in ["trajectory_runtime_events", "transcript_events", "transcript_event_identities", "session_transcript_active_events", "transcript_rewrite_watermarks", "session_transcript_index_state", "session_transcript_fts", "session_conversations"]:
if t in all_tables and sid:
cursor.execute(f"DELETE FROM {t} WHERE session_id = ?", (sid,))
for t in ["session_participants", "heartbeat_outcomes", "session_progress_cards", "board_widgets", "board_tabs"]:
if t in all_tables:
cursor.execute(f"DELETE FROM {t} WHERE session_key = ?", (k,))
if "memory_entry_origins" in all_tables:
cursor.execute("DELETE FROM memory_entry_origins WHERE session_key = ? OR session_id = ?", (k, sid or ""))
if "session_windows" in all_tables:
cursor.execute("DELETE FROM session_windows WHERE session_key = ? OR session_id = ?", (k, sid or ""))
cursor.execute("DELETE FROM session_nodes WHERE session_key = ?", (k,))
conn.commit()
total_pruned += len(stale)
total_tokens_reclaimed += tokens_reclaimed
print(f" [{agent}] Pruned {len(stale)} dead cron sessions ({tokens_reclaimed} tokens reclaimed)")
# Unconditionally resolve any dangling foreign key references across all tables
while True:
cursor.execute("PRAGMA foreign_key_check;")
violations = cursor.fetchall()
if not violations:
break
for table, rowid, parent, fkid in violations:
cursor.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
conn.commit()
conn.close()
except Exception as e:
print(f" [{agent}] Pruning error: {e}")
# Clear resolved quarantine entries if all databases are healthy
try:
q_path = "/home/node/.openclaw/state/openclaw-quarantine.sqlite"
if os.path.exists(q_path):
q_conn = sqlite3.connect(q_path)
q_cur = q_conn.cursor()
q_cur.execute("DELETE FROM quarantined_databases")
q_conn.commit()
q_conn.close()
except Exception as e:
print(f"Quarantine cleanup error: {e}")
print(f"Cron pruning complete: {total_pruned} sessions pruned, {total_tokens_reclaimed} tokens reclaimed.")
def compact_tier_a_sessions():
print("=== Tier A: Deep-Compaction (koch, hebamme, shopping) ===")
for agent, key in TIER_A_TARGETS:
db_path = f"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite"
if not os.path.exists(db_path):
continue
try:
con = sqlite3.connect(db_path, timeout=10.0)
row = con.execute("SELECT current_session_id FROM session_nodes WHERE session_key=?", (key,)).fetchone()
con.close()
if not row or not row[0]:
print(f" [Tier A] {agent} ({key}) already at 0 (no active session).")
continue
ev_cnt, ev_bytes = get_session_metrics(agent, key, row[0])
if ev_cnt <= 2 and ev_bytes < 20000:
print(f" [Tier A] {agent} ({key}) has only {ev_cnt} events ({ev_bytes} bytes), skipping.")
continue
print(f" [Tier A] Deep-compacting {agent} ({key}) [{ev_cnt} events, {ev_bytes//1024}KB]...")
r = subprocess.run(
["openclaw", "sessions", "compact", key, "--agent", agent, "--timeout", str(RPC_TIMEOUT_MS)],
capture_output=True, text=True
)
if r.returncode == 0:
print(f" ✓ Deep-compacted: {r.stdout.strip()}")
elif "Already compacted" in r.stderr or "Already compacted" in r.stdout:
print(f" ✓ Already compacted.")
else:
print(f" ✗ Standard compact error: {r.stderr.strip()}; retrying with --max-lines 40...")
fb = subprocess.run(
["openclaw", "sessions", "compact", key, "--agent", agent, "--max-lines", "40", "--timeout", str(RPC_TIMEOUT_MS)],
capture_output=True, text=True
)
if fb.returncode == 0:
print(f" ✓ Truncation fallback success: {fb.stdout.strip()}")
else:
print(f" ✗ Truncation fallback failed: {fb.stderr.strip()}")
except Exception as e:
print(f" ✗ Exception deep-compacting {agent}: {e}")
time.sleep(2)
def get_session_metrics(agent, key, sid=None):
db_path = f"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite"
if not os.path.exists(db_path):
return 0, 0
try:
con = sqlite3.connect(db_path, timeout=5.0)
cur = con.cursor()
if not sid:
row = cur.execute("SELECT current_session_id FROM session_nodes WHERE session_key=?", (key,)).fetchone()
sid = row[0] if row else None
if not sid:
con.close()
return 0, 0
ev_cnt, ev_bytes = cur.execute(
"SELECT count(*), coalesce(sum(length(event_json)), 0) FROM transcript_events WHERE session_id=?",
(sid,)
).fetchone()
con.close()
return ev_cnt, ev_bytes
except Exception:
return 0, 0
def compact_active_sessions():
tier_a_keys = set(t[1] for t in TIER_A_TARGETS)
print(f"=== Tier B: Running Multi-Agent Compaction (threshold: {THRESHOLD_TOKENS} tokens / {THRESHOLD_TRANSCRIPT_BYTES//1024}KB, LLM summarization) ===")
res = subprocess.run(["openclaw", "sessions", "--all-agents", "--json"], capture_output=True, text=True)
if res.returncode != 0:
print(f"Failed to list sessions: {res.stderr}")
return
sessions = json.loads(res.stdout).get("sessions", [])
compacted = 0
skipped = 0
for s in sessions:
key = s.get("key", "")
agent = s.get("agentId") or "main"
tokens = s.get("totalTokens") or 0
sid = s.get("sessionId")
# Skip Tier A agents as they are handled via hard-reset
if key in tier_a_keys:
continue
# Target main sessions and all telegram sessions (topics and direct)
is_target = (
key == "agent:main:main" or
":telegram:" in key or
(key.startswith("agent:") and key.endswith(":main"))
)
if is_target and not key.endswith(":heartbeat"):
ev_cnt, ev_bytes = get_session_metrics(agent, key, sid)
needs_compaction = (
tokens >= THRESHOLD_TOKENS or
ev_bytes >= THRESHOLD_TRANSCRIPT_BYTES or
(tokens == 0 and ev_cnt >= THRESHOLD_EVENT_COUNT)
)
if needs_compaction:
stat_str = f"{tokens} tokens" if tokens else f"{ev_bytes//1024}KB / {ev_cnt} events"
print(f"Compacting {agent} ({stat_str}) -> {key}...")
c_res = subprocess.run(
["openclaw", "sessions", "compact", key, "--agent", agent, "--timeout", str(RPC_TIMEOUT_MS)],
capture_output=True, text=True
)
if c_res.returncode == 0:
print(f" ✓ {c_res.stdout.strip()}")
compacted += 1
elif "Already compacted" in c_res.stderr or "Already compacted" in c_res.stdout:
print(f" ✓ Already compacted.")
skipped += 1
else:
print(f" ✗ {c_res.stderr.strip()}; retrying with --max-lines 80 fallback...")
fb_res = subprocess.run(
["openclaw", "sessions", "compact", key, "--agent", agent, "--max-lines", "80", "--timeout", str(RPC_TIMEOUT_MS)],
capture_output=True, text=True
)
if fb_res.returncode == 0:
print(f" ✓ (fallback) {fb_res.stdout.strip()}")
compacted += 1
else:
print(f" ✗ Fallback failed: {fb_res.stderr.strip()}")
time.sleep(5) # 5s pause to avoid rotational HDD I/O saturation
else:
skipped += 1
print(f"Compaction complete: {compacted} compacted, {skipped} skipped.")
def checkpoint_wal_journals():
print("=== Checkpointing SQLite WAL journals across all agent databases ===")
dbs = ["/home/node/.openclaw/state/openclaw.sqlite"] + glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")
wal_freed = 0
for db in dbs:
wal = db + "-wal"
if os.path.exists(wal):
sz = os.path.getsize(wal)
try:
con = sqlite3.connect(db, timeout=30.0)
con.execute("PRAGMA wal_checkpoint(TRUNCATE);")
con.close()
wal_freed += sz
except Exception as e:
print(f" ✗ Error checkpointing {db}: {e}")
print(f"WAL Checkpoint complete: {wal_freed / (1024*1024):.2f} MB merged to disk.")
def vacuum_and_optimize():
print("=== Running SQLite OPTIMIZE across all databases ===")
dbs = ["/home/node/.openclaw/state/openclaw.sqlite"] + glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")
for db in dbs:
try:
con = sqlite3.connect(db, timeout=30.0)
con.execute("PRAGMA optimize;")
con.close()
except Exception as e:
print(f" ✗ Error optimizing {db}: {e}")
print("SQLite optimization complete.")
def main():
prewarm_page_cache()
try:
subprocess.run(["python3", "/home/node/.openclaw/scripts/recover_sessions.py"], check=False)
except Exception:
pass
prune_stale_cron_sessions()
compact_tier_a_sessions()
compact_active_sessions()
checkpoint_wal_journals()
vacuum_and_optimize()
if __name__ == "__main__":
main()
EOF
chmod +x /home/node/.openclaw/scripts/compact_sessions.py
'
# 2. Register / Update the OpenClaw cron job
echo "Checking existing OpenClaw Cron automation '$JOB_NAME'..."
EXISTING_ID=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list --json 2>/dev/null | python3 -c 'import sys, json; data=json.load(sys.stdin); print(next((j["id"] for j in data.get("jobs", []) if j.get("declarationKey") == "'"$JOB_NAME"'"), ""))' 2>/dev/null || true)
if [ -n "$EXISTING_ID" ]; then
echo "Updating existing OpenClaw Cron automation '$EXISTING_ID' ($CRON_SCHEDULE @ $TIMEZONE)..."
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron edit "$EXISTING_ID" \
--cron "$CRON_SCHEDULE" \ --cron "$CRON_SCHEDULE" \
--tz "$TIMEZONE" \ --tz "$TIMEZONE" \
--command "python3 /home/node/.openclaw/scripts/compact_sessions.py" --command "python3 /home/node/.openclaw/scripts/compact_sessions.py"
else else
echo "Registering new OpenClaw Cron automation '$JOB_NAME' ($CRON_SCHEDULE @ $TIMEZONE)..." echo "Registering new nightly compaction automation '$NIGHTLY_JOB' ($CRON_SCHEDULE @ $TIMEZONE)..."
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \ kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
--name "Nightly Session Compaction" \ --name "Nightly Session Compaction" \
--declaration-key "$JOB_NAME" \ --declaration-key "$NIGHTLY_JOB" \
--description "Nightly multi-agent session compaction and SQLite WAL checkpointing" \ --description "Nightly multi-agent session compaction and SQLite WAL checkpointing" \
--cron "$CRON_SCHEDULE" \ --cron "$CRON_SCHEDULE" \
--tz "$TIMEZONE" \ --tz "$TIMEZONE" \
@@ -342,7 +82,4 @@ else
--command "python3 /home/node/.openclaw/scripts/compact_sessions.py" --command "python3 /home/node/.openclaw/scripts/compact_sessions.py"
fi fi
echo "=== Compaction Cron automation successfully registered/updated! ===" echo "=== All session maintenance automations successfully registered/updated! ==="
if [ -n "$EXISTING_ID" ]; then
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron get "$EXISTING_ID" 2>/dev/null || true
fi
+2 -8
View File
@@ -35,13 +35,7 @@ Führe den nächtlichen Garmin Connect Sync durch.
Vorgehen: Vorgehen:
1. Führe den Garmin-Sync-Befehl aus: 1. Führe den Garmin-Sync-Befehl aus:
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/garmin_sync.py --days 2 --data-dir /home/node/.openclaw/wiki/main/data uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/garmin_sync.py --days 2 --data-dir /home/node/.openclaw/wiki/main/data
2. Aktualisiere die gerenderten Markdown-Ansichten: 2. Finale Antwort:
PYTHONPATH=/home/node/mop-skills/mop-skills/skills/datastore/scripts uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/render_views.py --data-dir /home/node/.openclaw/wiki/main/data --wiki-dir /home/node/.openclaw/wiki/main
3. Aktualisiere das persönliche Health & Nutrition Dashboard (moritz.html):
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/update_moritz_website.py --data-dir /home/node/.openclaw/wiki/main/data
4. Git Commit & Push im Knowledge Wiki:
cd /home/node/.openclaw/wiki/main && git add data/garmin/ entities/nutrition-log/ public/ && git diff --staged --quiet || (git commit -m "chore(garmin): automated nightly mirror [skip ci]" && git push origin main)
5. Finale Antwort:
- Bei Erfolg: Gib exakt 'NO_REPLY' aus (intentional silence, kein Telegram-Spam in der Nacht). - Bei Erfolg: Gib exakt 'NO_REPLY' aus (intentional silence, kein Telegram-Spam in der Nacht).
- Nur bei Authentifizierungsfehlern: Informiere kurz via Telegram (Thema #Sport), dass ein Garmin-Re-Auth nötig ist, oder starte den Re-Auth-Flow. - Nur bei Authentifizierungsfehlern: Informiere kurz via Telegram (Thema #Sport), dass ein Garmin-Re-Auth nötig ist, oder starte den Re-Auth-Flow.
EOF EOF
@@ -54,7 +48,7 @@ kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron a
--cron "$CRON_SCHEDULE" \ --cron "$CRON_SCHEDULE" \
--tz "$TIMEZONE" \ --tz "$TIMEZONE" \
--agent "fitness" \ --agent "fitness" \
--model "openrouter/deepseek/deepseek-v4.1-flash" \ --model "openrouter/deepseek/deepseek-v4.1-flash-1017" \
--session "isolated" \ --session "isolated" \
--channel "telegram" \ --channel "telegram" \
--to "$TELEGRAM_GROUP" \ --to "$TELEGRAM_GROUP" \
+1 -1
View File
@@ -48,7 +48,7 @@ kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron a
--cron "$CRON_SCHEDULE" \ --cron "$CRON_SCHEDULE" \
--tz "$TIMEZONE" \ --tz "$TIMEZONE" \
--agent "itguy" \ --agent "itguy" \
--model "openrouter/deepseek/deepseek-v4.1-flash" \ --model "openrouter/deepseek/deepseek-v4.1-flash-1017" \
--session "isolated" \ --session "isolated" \
--channel "telegram" \ --channel "telegram" \
--to "$TELEGRAM_GROUP" \ --to "$TELEGRAM_GROUP" \
+13
View File
@@ -0,0 +1,13 @@
# AGENTS.md — DuckDB Backend
## Purpose
FastAPI HTTP backend executing in-memory DuckDB queries against Git-backed JSONL data in the Knowledge Wiki.
## Development & Testing
```bash
# Run tests
uv run --project projects/duckdb-backend pytest -v projects/duckdb-backend/tests
# Run locally
uv run --project projects/duckdb-backend uvicorn app.main:app --port 8000
```
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.12-slim
# Install system dependencies (git for wiki sync)
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install uv for fast dependency resolution
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
# Install dependencies
COPY pyproject.toml .
RUN uv pip install --system --no-cache -r pyproject.toml
# Copy application source
COPY app/ ./app/
ENV PYTHONUNBUFFERED=1
ENV PORT=8000
ENV HOST=0.0.0.0
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+26
View File
@@ -0,0 +1,26 @@
# DuckDB Backend
HTTP-based analytical and transactional backend for personal logging data stored in the Knowledge Wiki.
Powered by Python 3.12, FastAPI, and in-memory DuckDB querying JSONL files directly.
## Architecture
- **Single Source of Truth**: Knowledge Wiki (`/wiki` git repository with data in `/wiki/data/**/*.jsonl`).
- **Engine**: In-memory DuckDB reading `sleep`, `nutrition`, `mila`, `entities`, and `garmin` partitions.
- **Git Synchronization**:
- Background periodic `git pull --rebase origin main` (every 60s).
- Mutating operations (`POST /duckdb-api/v1/events`) append to typed JSONL partition files, sort them, commit, rebase, and push to origin main.
- Conflicts trigger automatic rebase abort and flag `git_conflict: true` on `/status` and `/healthz`.
## Endpoints
- `GET /duckdb-api/v1/mila/dashboard`: Curated aggregated dashboard for Mila (sleep, wake timer, feedings, diapers, WHO percentiles).
- `GET /duckdb-api/v1/moritz/dashboard`: Curated aggregated dashboard for Moritz (today's intake, yesterday's 24h deficit/surplus, Garmin recovery & sleep, 7d/30d trends).
- `POST /duckdb-api/v1/query`: Generic read-only SQL queries against DuckDB tables and views.
- `POST /duckdb-api/v1/events`: Record new events (sleep, nutrition, diaper, feeding, growth).
- `POST /duckdb-api/v1/events/stop`: Close active open sleep session or interval.
- `DELETE /duckdb-api/v1/events/{id}`: Soft-delete event.
- `POST /duckdb-api/v1/entities`: Upsert entities (`foods`, `recipes`, `places`, `people`).
- `GET /duckdb-api/v1/healthz`: Liveness and readiness probe.
- `GET /duckdb-api/v1/status`: Git synchronization status and table row counts.
- `POST /duckdb-api/v1/git/sync`: Manual pull or webhook sync trigger.
+1
View File
@@ -0,0 +1 @@
# app package
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
from pathlib import Path
def find_default_wiki_dir() -> Path:
env_wiki = os.environ.get("WIKI_DIR") or os.environ.get("GIT_REPO_DIR")
if env_wiki:
return Path(env_wiki).resolve()
candidates = [
Path("/wiki"),
Path.cwd() / "knowledge-wiki",
Path.cwd().parent.parent / "knowledge-wiki",
Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"),
Path.home() / "src" / "infrapuzzle" / "knowledge-wiki",
Path("/home/node/.openclaw/wiki/main"),
]
for c in candidates:
if c.is_dir() and (c / "data").is_dir():
return c.resolve()
return (Path.cwd() / "knowledge-wiki").resolve()
WIKI_DIR = find_default_wiki_dir()
DATA_DIR = Path(
os.environ.get("DATA_DIR")
or os.environ.get("DUCKDB_DATA_DIR")
or (WIKI_DIR / "data")
).resolve()
GIT_REPO_URL = os.environ.get(
"GIT_REPO_URL",
os.environ.get("GIT_REMOTE_URL", "https://git.moritzgraf.de/moritz/knowledge-wiki.git"),
)
GIT_USERNAME = os.environ.get("GIT_SYNC_USERNAME", "")
GIT_PASSWORD = os.environ.get("GIT_SYNC_PASSWORD", os.environ.get("WIKI_SYNC_TOKEN", ""))
GIT_BRANCH = os.environ.get("GIT_BRANCH", "main")
GIT_SYNC_INTERVAL_SECONDS = int(os.environ.get("GIT_SYNC_INTERVAL_SECONDS", "60"))
AUTO_GIT_SYNC = os.environ.get("AUTO_GIT_SYNC", "true").lower() in ("1", "true", "yes")
GIT_AUTHOR_NAME = os.environ.get("GIT_AUTHOR_NAME", "DuckDB Backend")
GIT_AUTHOR_EMAIL = os.environ.get("GIT_AUTHOR_EMAIL", "duckdb-backend@haumdaucher.de")
PORT = int(os.environ.get("PORT", "8000"))
HOST = os.environ.get("HOST", "0.0.0.0")
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
from __future__ import annotations
import asyncio
import datetime
import logging
import os
import subprocess
import threading
import urllib.parse
from pathlib import Path
from typing import Callable, Optional
from app.config import (
AUTO_GIT_SYNC,
GIT_AUTHOR_EMAIL,
GIT_AUTHOR_NAME,
GIT_BRANCH,
GIT_PASSWORD,
GIT_REPO_URL,
GIT_SYNC_INTERVAL_SECONDS,
GIT_USERNAME,
WIKI_DIR,
)
logger = logging.getLogger("duckdb_backend.git_sync")
class GitSyncManager:
def __init__(self, wiki_dir: Optional[Path] = None, on_data_pulled: Optional[Callable[[], None]] = None):
self.wiki_dir = wiki_dir or WIKI_DIR
self.on_data_pulled = on_data_pulled
self._lock = threading.Lock()
self.status = "ok"
self.is_conflict = False
self.last_sync_time: Optional[str] = None
self.last_commit_hash: Optional[str] = None
self.last_error: Optional[str] = None
self._bg_task: Optional[asyncio.Task] = None
self._stop_event = asyncio.Event()
def _configure_git_client(self):
"""Configures git client with safe.directory and author identity."""
try:
subprocess.run(["git", "config", "--global", "http.sslVerify", "false"], check=False)
subprocess.run(["git", "config", "--global", "--add", "safe.directory", "*"], check=False)
if (self.wiki_dir / ".git").exists():
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.name", GIT_AUTHOR_NAME], check=False)
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.email", GIT_AUTHOR_EMAIL], check=False)
except Exception as e:
logger.warning(f"Failed to configure git client options: {e}")
def get_auth_repo_url(self) -> str:
if GIT_USERNAME and GIT_PASSWORD and "://" in GIT_REPO_URL:
proto, rest = GIT_REPO_URL.split("://", 1)
# Remove existing credentials in URL if any
if "@" in rest:
rest = rest.split("@", 1)[1]
user_enc = urllib.parse.quote(GIT_USERNAME, safe="")
pass_enc = urllib.parse.quote(GIT_PASSWORD, safe="")
return f"{proto}://{user_enc}:{pass_enc}@{rest}"
return GIT_REPO_URL
def ensure_repo(self) -> bool:
"""Ensures repository exists and is initialized."""
with self._lock:
self._configure_git_client()
if (self.wiki_dir / ".git").exists():
try:
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
self.last_commit_hash = res.stdout.strip()
return True
except Exception as e:
logger.warning(f"Failed to check git rev: {e}")
return True
if not GIT_REPO_URL:
logger.info(f"Wiki directory {self.wiki_dir} is not a git repo, but no GIT_REPO_URL configured.")
return False
logger.info(f"Initializing repository {GIT_REPO_URL} in {self.wiki_dir}...")
self.wiki_dir.mkdir(parents=True, exist_ok=True)
auth_url = self.get_auth_repo_url()
try:
# Use git init + remote + fetch + checkout to safely support non-empty directories (e.g. lost+found on PVC)
subprocess.run(["git", "-C", str(self.wiki_dir), "init"], capture_output=True, check=False)
self._configure_git_client()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "add", "origin", auth_url],
capture_output=True,
check=False,
)
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "fetch", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=60,
)
if res.returncode == 0:
if (self.wiki_dir / ".git" / "shallow").exists():
subprocess.run(
["git", "-C", str(self.wiki_dir), "fetch", "--unshallow", "origin", GIT_BRANCH],
capture_output=True,
check=False,
)
checkout_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "checkout", "-f", "-B", GIT_BRANCH, f"origin/{GIT_BRANCH}"],
capture_output=True,
text=True,
check=False,
)
if checkout_res.returncode == 0:
logger.info("Successfully initialized and checked out wiki repository.")
self.status = "ok"
self.is_conflict = False
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
return True
else:
self.status = "error"
self.last_error = f"Checkout failed: {checkout_res.stderr.strip()}"
logger.error(self.last_error)
return False
else:
self.status = "error"
self.last_error = f"Fetch failed: {res.stderr.strip()}"
logger.error(self.last_error)
return False
except Exception as e:
self.status = "error"
self.last_error = f"Clone exception: {e}"
logger.error(self.last_error)
return False
def pull_rebase(self) -> bool:
"""Pulls latest changes via rebase. Handles conflicts cleanly."""
if not (self.wiki_dir / ".git").exists():
return False
with self._lock:
try:
# Update remote url with credentials if provided
auth_url = self.get_auth_repo_url()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
# Fetch and rebase with autostash
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "pull", "--rebase", "--autostash", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if res.returncode == 0:
self.status = "ok"
self.is_conflict = False
self.last_error = None
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
# Update commit hash
rev = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if rev.returncode == 0:
self.last_commit_hash = rev.stdout.strip()
if self.on_data_pulled:
try:
self.on_data_pulled()
except Exception as ex:
logger.error(f"Error in on_data_pulled callback: {ex}")
return True
else:
# Check if conflict occurred
stderr = res.stderr or res.stdout
logger.warning(f"Git pull --rebase failed: {stderr}")
# Abort any in-progress rebase to keep working tree stable
subprocess.run(
["git", "-C", str(self.wiki_dir), "rebase", "--abort"],
capture_output=True,
check=False,
)
self.is_conflict = True
self.status = "conflict"
self.last_error = f"Rebase conflict: {stderr.strip()}"
return False
except Exception as e:
self.status = "error"
self.last_error = f"Pull error: {e}"
logger.error(self.last_error)
return False
def commit_and_push(self, commit_msg: str) -> bool:
"""Stages data/, commits with descriptive message, rebases and pushes."""
if not (self.wiki_dir / ".git").exists():
return False
with self._lock:
try:
self._configure_git_client()
data_rel = "data"
subprocess.run(
["git", "-C", str(self.wiki_dir), "add", data_rel],
capture_output=True,
check=False,
)
commit_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "commit", "-m", f"{commit_msg} [skip ci]"],
capture_output=True,
text=True,
check=False,
)
if commit_res.returncode != 0:
# Nothing to commit or already committed
logger.debug(f"Commit returncode {commit_res.returncode}: {commit_res.stdout.strip()}")
return True
# Pull rebase with autostash before push
auth_url = self.get_auth_repo_url()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
rebase_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "pull", "--rebase", "--autostash", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if rebase_res.returncode != 0:
stderr = rebase_res.stderr or rebase_res.stdout
logger.warning(f"Rebase failed during commit_and_push: {stderr}")
subprocess.run(
["git", "-C", str(self.wiki_dir), "rebase", "--abort"],
capture_output=True,
check=False,
)
self.is_conflict = True
self.status = "conflict"
self.last_error = f"Rebase conflict on push: {stderr.strip()}"
return False
# Push to remote
push_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "push", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if push_res.returncode == 0:
self.status = "ok"
self.is_conflict = False
self.last_error = None
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
rev = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if rev.returncode == 0:
self.last_commit_hash = rev.stdout.strip()
return True
else:
self.status = "error"
self.last_error = f"Push error: {push_res.stderr.strip()}"
logger.error(self.last_error)
return False
except Exception as e:
self.status = "error"
self.last_error = f"Commit/push error: {e}"
logger.error(self.last_error)
return False
async def start_periodic_sync(self):
"""Starts background periodic git pull loop."""
if not AUTO_GIT_SYNC:
logger.info("Automatic git sync is disabled by configuration.")
return
self._stop_event.clear()
async def _loop():
logger.info(f"Starting periodic git sync every {GIT_SYNC_INTERVAL_SECONDS}s")
while not self._stop_event.is_set():
try:
await asyncio.to_thread(self.pull_rebase)
except Exception as ex:
logger.error(f"Error during periodic git pull: {ex}")
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=GIT_SYNC_INTERVAL_SECONDS)
except asyncio.TimeoutError:
pass
self._bg_task = asyncio.create_task(_loop())
async def stop_periodic_sync(self):
"""Stops background sync loop."""
self._stop_event.set()
if self._bg_task:
self._bg_task.cancel()
try:
await self._bg_task
except asyncio.CancelledError:
pass
self._bg_task = None
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import DATA_DIR, WIKI_DIR
from app.engine import DuckDBEngine
from app.git_sync import GitSyncManager
from app.routes.dashboard import router as dashboard_router
from app.routes.entities import router as entities_router
from app.routes.events import router as events_router
from app.routes.query import router as query_router
from app.routes.system import router as system_router
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("duckdb_backend")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Initializing DuckDB Backend...")
if getattr(app.state, "git_sync", None) is None or getattr(app.state, "engine", None) is None:
git_sync = GitSyncManager(
wiki_dir=WIKI_DIR,
on_data_pulled=lambda: app.state.engine.reload_all() if hasattr(app.state, "engine") else None,
)
git_sync.ensure_repo()
app.state.git_sync = git_sync
engine = DuckDBEngine(data_dir=DATA_DIR, git_sync_manager=git_sync)
app.state.engine = engine
await git_sync.start_periodic_sync()
logger.info("DuckDB Backend is ready to serve queries.")
try:
yield
finally:
logger.info("Shutting down DuckDB Backend...")
await git_sync.stop_periodic_sync()
else:
yield
app = FastAPI(
title="DuckDB Backend for Knowledge Wiki",
version="1.0.0",
description="HTTP-based analytical and transactional backend for personal logging data powered by DuckDB",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API v1 prefix
v1_router = FastAPI()
app.include_router(dashboard_router, prefix="/duckdb-api/v1")
app.include_router(query_router, prefix="/duckdb-api/v1")
app.include_router(events_router, prefix="/duckdb-api/v1")
app.include_router(entities_router, prefix="/duckdb-api/v1")
app.include_router(system_router, prefix="/duckdb-api/v1")
# Direct root health probes for Kubernetes
app.include_router(system_router)
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, model_validator
class QueryRequest(BaseModel):
query: str = Field(..., description="Read-only SQL query to execute in DuckDB")
params: Optional[List[Any]] = Field(default=None, description="Positional query parameters")
class QueryResponse(BaseModel):
columns: List[str]
rows: List[Dict[str, Any]]
count: int
execution_time_ms: float
class EventCreateRequest(BaseModel):
kind: str = Field(..., description="Event kind, e.g. sleep.session, nutrition.intake, diaper.change, feeding.bottle, growth.measurement")
subject: str = Field(..., description="Event subject, e.g. mila or moritz")
payload: Dict[str, Any] = Field(default_factory=dict, description="Event-specific payload")
actor: Optional[str] = Field(default="web-app", description="Actor who logged the event")
date: Optional[str] = Field(default=None, description="Explicit log date override YYYY-MM-DD")
start: Optional[str] = Field(default=None, description="ISO-8601 start timestamp")
end: Optional[str] = Field(default=None, description="ISO-8601 end timestamp (optional)")
since: Optional[str] = Field(default=None, description="Relative start duration like '15m', '1h'")
estimated: bool = Field(default=False, description="Whether intake is estimated")
meal: Optional[str] = Field(default=None, description="Meal category, e.g. fruehstueck, mittagessen, abendessen, snack")
note: Optional[str] = Field(default=None, description="Free-text note")
source_ref: Optional[str] = Field(default=None, description="Source reference")
allow_duplicate: bool = Field(default=False, description="Allow event within 15min of duplicate")
class EventStopRequest(BaseModel):
kind: str = Field(default="sleep.session", description="Event kind to stop")
subject: str = Field(..., description="Event subject, e.g. mila")
end_time: Optional[str] = Field(default=None, description="ISO-8601 end timestamp")
since: Optional[str] = Field(default=None, description="Relative end duration like '5m'")
event_id: Optional[str] = Field(default=None, description="Specific event ID to close")
actor: Optional[str] = Field(default="web-app", description="Actor who stopped the event")
class EntityPutRequest(BaseModel):
collection: str = Field(..., description="Target collection: foods, recipes, places, people")
record: Dict[str, Any] = Field(..., description="Entity record with 'id'")
class GarminSyncRequest(BaseModel):
daily: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin daily summary records")
sleep: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin sleep records")
activities: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin activity records")
daily_summary: Optional[Dict[str, Any]] = None
sleep_data: Optional[Dict[str, Any]] = None
@model_validator(mode="before")
@classmethod
def normalize_aliases(cls, data: Any) -> Any:
if isinstance(data, dict):
# Normalize daily_summary dict to daily list if daily is missing/empty
if "daily_summary" in data and data["daily_summary"] and not data.get("daily"):
data["daily"] = [data["daily_summary"]]
# Normalize sleep_data dict to sleep list if sleep is missing/empty
if "sleep_data" in data and data["sleep_data"] and not data.get("sleep"):
data["sleep"] = [data["sleep_data"]]
return data
class StatusResponse(BaseModel):
status: str
git_conflict: bool
last_sync_time: Optional[str]
last_commit_hash: Optional[str]
last_error: Optional[str]
data_dir: str
table_counts: Dict[str, int]
@@ -0,0 +1 @@
# routes package
@@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.engine import DuckDBEngine
router = APIRouter(tags=["Dashboards"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.get("/mila/dashboard")
async def get_mila_dashboard(
as_of: Optional[str] = Query(default=None, description="Optional target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_mila_dashboard(as_of_date=as_of)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/moritz/dashboard")
async def get_moritz_dashboard(
as_of: Optional[str] = Query(default=None, description="Optional target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_moritz_dashboard(as_of_date=as_of)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/mila/day/{date}")
async def get_mila_day_detail(
date: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_mila_day_detail(target_date=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/moritz/day/{date}")
async def get_moritz_day_detail(
date: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_moritz_day_detail(target_date=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/summary/{subject}")
async def get_summary(
subject: str,
date: Optional[str] = Query(default=None, description="Target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_summary(subject=subject, date_str=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,51 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.engine import DuckDBEngine
from app.models import EntityPutRequest
router = APIRouter(tags=["Entities"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/entities")
async def put_entity(
req: EntityPutRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.put_entity(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/entities/{collection}")
async def list_entities(
collection: str,
engine: DuckDBEngine = Depends(get_engine),
) -> List[Dict[str, Any]]:
try:
return engine.list_entities(collection=collection)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/places/near")
async def find_places_near(
lat: float = Query(..., description="Latitude"),
lon: float = Query(..., description="Longitude"),
radius_m: float = Query(default=1000.0, description="Radius in meters"),
category: Optional[str] = Query(default=None, description="Category filter"),
engine: DuckDBEngine = Depends(get_engine),
) -> List[Dict[str, Any]]:
try:
return engine.find_places_near(lat=lat, lon=lon, radius_m=radius_m, category=category)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Request
from app.engine import DuckDBEngine
from app.models import EventCreateRequest, EventStopRequest, GarminSyncRequest
router = APIRouter(tags=["Events"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/events")
async def add_event(
req: EventCreateRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.add_event(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/events/stop")
async def stop_event(
req: EventStopRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.stop_event(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/events/{event_id}")
async def delete_event(
event_id: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.delete_event(event_id)
except ValueError as ve:
raise HTTPException(status_code=404, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/garmin/sync")
async def sync_garmin(
req: GarminSyncRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.sync_garmin(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,35 @@
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from app.engine import DuckDBEngine
from app.models import QueryRequest, QueryResponse
router = APIRouter(tags=["Generic Queries"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/query", response_model=QueryResponse)
async def execute_query(
req: QueryRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> QueryResponse:
t0 = time.perf_counter()
try:
rows = engine.query(req.query, req.params)
duration_ms = round((time.perf_counter() - t0) * 1000, 2)
cols = list(rows[0].keys()) if rows else []
return QueryResponse(
columns=cols,
rows=rows,
count=len(rows),
execution_time_ms=duration_ms,
)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,77 @@
from __future__ import annotations
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Request
from app.config import DATA_DIR
from app.engine import DuckDBEngine
from app.git_sync import GitSyncManager
from app.models import StatusResponse
router = APIRouter(tags=["System"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
def get_git_sync(request: Request) -> Optional[GitSyncManager]:
return getattr(request.app.state, "git_sync", None)
@router.get("/healthz")
async def healthz(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
conflict = git_sync.is_conflict if git_sync else False
status_str = "conflict" if conflict else "ok"
return {
"status": status_str,
"conflict": conflict,
"engine_ready": True,
}
@router.get("/status", response_model=StatusResponse)
async def status(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> StatusResponse:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
counts = engine.get_table_counts()
return StatusResponse(
status=git_sync.status if git_sync else "ok",
git_conflict=git_sync.is_conflict if git_sync else False,
last_sync_time=git_sync.last_sync_time if git_sync else None,
last_commit_hash=git_sync.last_commit_hash if git_sync else None,
last_error=git_sync.last_error if git_sync else None,
data_dir=str(engine.data_dir),
table_counts=counts,
)
@router.post("/git/sync")
async def trigger_sync(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
if not git_sync:
raise HTTPException(status_code=400, detail="Git sync is not configured")
success = git_sync.pull_rebase()
if success:
engine.reload_all()
return {
"status": "ok",
"message": "Git pull --rebase completed and DuckDB reloaded.",
"commit": git_sync.last_commit_hash,
}
else:
return {
"status": git_sync.status,
"conflict": git_sync.is_conflict,
"error": git_sync.last_error,
}
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import math
from typing import Any, Dict, List, Optional, Tuple
WHO_GIRLS_WEIGHT = [
{"month": 0, "L": 0.3809, "M": 3.2322, "S": 0.14171, "p3": 2.4, "p50": 3.2, "p97": 4.2},
{"month": 1, "L": 0.1714, "M": 4.1873, "S": 0.13724, "p3": 3.2, "p50": 4.2, "p97": 5.4},
{"month": 2, "L": 0.0962, "M": 5.1282, "S": 0.13000, "p3": 4.0, "p50": 5.1, "p97": 6.5},
{"month": 3, "L": 0.0402, "M": 5.8458, "S": 0.12619, "p3": 4.6, "p50": 5.8, "p97": 7.4},
{"month": 4, "L": -0.0050, "M": 6.4237, "S": 0.12402, "p3": 5.1, "p50": 6.4, "p97": 8.1},
{"month": 5, "L": -0.0430, "M": 6.8985, "S": 0.12274, "p3": 5.5, "p50": 6.9, "p97": 8.7},
{"month": 6, "L": -0.0756, "M": 7.2970, "S": 0.12204, "p3": 5.8, "p50": 7.3, "p97": 9.2},
{"month": 7, "L": -0.1039, "M": 7.6422, "S": 0.12178, "p3": 6.1, "p50": 7.6, "p97": 9.6},
{"month": 8, "L": -0.1288, "M": 7.9487, "S": 0.12181, "p3": 6.3, "p50": 7.9, "p97": 10.0},
{"month": 9, "L": -0.1507, "M": 8.2254, "S": 0.12199, "p3": 6.6, "p50": 8.2, "p97": 10.4},
{"month": 10, "L": -0.1700, "M": 8.4800, "S": 0.12223, "p3": 6.8, "p50": 8.5, "p97": 10.7},
{"month": 11, "L": -0.1872, "M": 8.7192, "S": 0.12247, "p3": 7.0, "p50": 8.7, "p97": 11.0},
{"month": 12, "L": -0.2024, "M": 8.9481, "S": 0.12268, "p3": 7.1, "p50": 8.9, "p97": 11.3},
{"month": 13, "L": -0.2158, "M": 9.1699, "S": 0.12283, "p3": 7.3, "p50": 9.2, "p97": 11.6},
{"month": 14, "L": -0.2278, "M": 9.3870, "S": 0.12294, "p3": 7.5, "p50": 9.4, "p97": 11.9},
{"month": 15, "L": -0.2384, "M": 9.6008, "S": 0.12299, "p3": 7.7, "p50": 9.6, "p97": 12.2},
{"month": 16, "L": -0.2478, "M": 9.8124, "S": 0.12303, "p3": 7.8, "p50": 9.8, "p97": 12.5},
{"month": 17, "L": -0.2562, "M": 10.0226, "S": 0.12306, "p3": 8.0, "p50": 10.0, "p97": 12.7},
{"month": 18, "L": -0.2637, "M": 10.2315, "S": 0.12309, "p3": 8.2, "p50": 10.2, "p97": 13.0},
{"month": 19, "L": -0.2703, "M": 10.4393, "S": 0.12315, "p3": 8.3, "p50": 10.4, "p97": 13.3},
{"month": 20, "L": -0.2762, "M": 10.6464, "S": 0.12323, "p3": 8.5, "p50": 10.6, "p97": 13.5},
{"month": 21, "L": -0.2815, "M": 10.8534, "S": 0.12335, "p3": 8.7, "p50": 10.9, "p97": 13.8},
{"month": 22, "L": -0.2862, "M": 11.0608, "S": 0.12350, "p3": 8.8, "p50": 11.1, "p97": 14.1},
{"month": 23, "L": -0.2903, "M": 11.2688, "S": 0.12369, "p3": 9.0, "p50": 11.3, "p97": 14.3},
{"month": 24, "L": -0.2941, "M": 11.4775, "S": 0.12390, "p3": 9.2, "p50": 11.5, "p97": 14.6},
]
WHO_GIRLS_HEIGHT = [
{"month": 0, "L": 1.0, "M": 49.1477, "S": 0.03790, "p3": 45.6, "p50": 49.1, "p97": 52.7},
{"month": 1, "L": 1.0, "M": 53.6872, "S": 0.03640, "p3": 50.0, "p50": 53.7, "p97": 57.4},
{"month": 2, "L": 1.0, "M": 57.0673, "S": 0.03568, "p3": 53.2, "p50": 57.1, "p97": 60.9},
{"month": 3, "L": 1.0, "M": 59.8029, "S": 0.03520, "p3": 55.8, "p50": 59.8, "p97": 63.8},
{"month": 4, "L": 1.0, "M": 62.0899, "S": 0.03486, "p3": 58.0, "p50": 62.1, "p97": 66.2},
{"month": 5, "L": 1.0, "M": 64.0301, "S": 0.03463, "p3": 59.9, "p50": 64.0, "p97": 68.2},
{"month": 6, "L": 1.0, "M": 65.7311, "S": 0.03448, "p3": 61.5, "p50": 65.7, "p97": 70.0},
{"month": 7, "L": 1.0, "M": 67.2873, "S": 0.03441, "p3": 62.9, "p50": 67.3, "p97": 71.6},
{"month": 8, "L": 1.0, "M": 68.7498, "S": 0.03440, "p3": 64.3, "p50": 68.7, "p97": 73.2},
{"month": 9, "L": 1.0, "M": 70.1435, "S": 0.03444, "p3": 65.6, "p50": 70.1, "p97": 74.7},
{"month": 10, "L": 1.0, "M": 71.4818, "S": 0.03452, "p3": 66.8, "p50": 71.5, "p97": 76.1},
{"month": 11, "L": 1.0, "M": 72.7710, "S": 0.03464, "p3": 68.0, "p50": 72.8, "p97": 77.5},
{"month": 12, "L": 1.0, "M": 74.0150, "S": 0.03479, "p3": 69.2, "p50": 74.0, "p97": 78.9},
{"month": 13, "L": 1.0, "M": 75.2176, "S": 0.03496, "p3": 70.3, "p50": 75.2, "p97": 80.2},
{"month": 14, "L": 1.0, "M": 76.3817, "S": 0.03514, "p3": 71.3, "p50": 76.4, "p97": 81.4},
{"month": 15, "L": 1.0, "M": 77.5099, "S": 0.03534, "p3": 72.4, "p50": 77.5, "p97": 82.7},
{"month": 16, "L": 1.0, "M": 78.6055, "S": 0.03555, "p3": 73.3, "p50": 78.6, "p97": 83.9},
{"month": 17, "L": 1.0, "M": 79.6710, "S": 0.03576, "p3": 74.3, "p50": 79.7, "p97": 85.0},
{"month": 18, "L": 1.0, "M": 80.7079, "S": 0.03598, "p3": 75.2, "p50": 80.7, "p97": 86.2},
{"month": 19, "L": 1.0, "M": 81.7182, "S": 0.03620, "p3": 76.2, "p50": 81.7, "p97": 87.3},
{"month": 20, "L": 1.0, "M": 82.7036, "S": 0.03643, "p3": 77.0, "p50": 82.7, "p97": 88.4},
{"month": 21, "L": 1.0, "M": 83.6654, "S": 0.03666, "p3": 77.9, "p50": 83.7, "p97": 89.4},
{"month": 22, "L": 1.0, "M": 84.6040, "S": 0.03688, "p3": 78.7, "p50": 84.6, "p97": 90.5},
{"month": 23, "L": 1.0, "M": 85.5202, "S": 0.03711, "p3": 79.6, "p50": 85.5, "p97": 91.5},
{"month": 24, "L": 1.0, "M": 86.4153, "S": 0.03734, "p3": 80.3, "p50": 86.4, "p97": 92.5},
]
def _interpolate_lms(table: List[Dict[str, float]], age_months: float) -> Tuple[float, float, float, float, float, float]:
m_clamped = max(0.0, min(float(table[-1]["month"]), float(age_months)))
m_low = int(math.floor(m_clamped))
m_high = int(math.ceil(m_clamped))
r_low = table[m_low]
if m_low == m_high:
return r_low["L"], r_low["M"], r_low["S"], r_low["p3"], r_low["p50"], r_low["p97"]
r_high = table[m_high]
frac = m_clamped - m_low
L = r_low["L"] + frac * (r_high["L"] - r_low["L"])
M = r_low["M"] + frac * (r_high["M"] - r_low["M"])
S = r_low["S"] + frac * (r_high["S"] - r_low["S"])
p3 = r_low["p3"] + frac * (r_high["p3"] - r_low["p3"])
p50 = r_low["p50"] + frac * (r_high["p50"] - r_low["p50"])
p97 = r_low["p97"] + frac * (r_high["p97"] - r_low["p97"])
return L, M, S, p3, p50, p97
def calculate_percentile(metric: str, value: float, age_months: float) -> Dict[str, Any]:
table = WHO_GIRLS_WEIGHT if metric == "weight" else WHO_GIRLS_HEIGHT
L, M, S, p3, p50, p97 = _interpolate_lms(table, age_months)
if value <= 0 or M <= 0 or S <= 0:
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": 0.0,
"percentile": 50.0,
"percentile_str": "P50",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": True,
"status": "normal",
}
if abs(L) > 1e-6:
z = ((value / M) ** L - 1.0) / (L * S)
else:
z = math.log(value / M) / S
p = 0.5 * (1.0 + math.erf(z / math.sqrt(2.0))) * 100.0
p_clamped = max(0.1, min(99.9, p))
in_normal = 3.0 <= p_clamped <= 97.0
if p_clamped < 3.0:
status = "unter P3"
elif p_clamped > 97.0:
status = "über P97"
else:
status = "im WHO-Normbereich"
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": round(z, 2),
"percentile": round(p_clamped, 1),
"percentile_str": f"P{int(round(p_clamped))}",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": in_normal,
"status": status,
}
+28
View File
@@ -0,0 +1,28 @@
[project]
name = "duckdb-backend"
version = "1.0.0"
description = "FastAPI HTTP DuckDB Backend for Knowledge Wiki Personal Data"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"duckdb>=1.1.0",
"python-ulid>=3.0.0",
"pytz>=2024.1",
"pydantic>=2.8.0",
"httpx>=0.27.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
+36
View File
@@ -0,0 +1,36 @@
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import DATA_DIR
from app.engine import DuckDBEngine
from app.git_sync import GitSyncManager
from app.main import app
@pytest.fixture(scope="session")
def test_data_dir():
temp_root = Path(tempfile.mkdtemp())
temp_data = temp_root / "data"
if DATA_DIR.is_dir():
shutil.copytree(DATA_DIR, temp_data)
else:
temp_data.mkdir(parents=True, exist_ok=True)
yield temp_data
shutil.rmtree(temp_root, ignore_errors=True)
@pytest.fixture
def engine(test_data_dir):
return DuckDBEngine(data_dir=test_data_dir)
@pytest.fixture
def client(engine):
app.state.engine = engine
app.state.git_sync = GitSyncManager(wiki_dir=engine.data_dir.parent)
with TestClient(app) as c:
yield c
@@ -0,0 +1,205 @@
import pytest
from fastapi.testclient import TestClient
def test_mila_dashboard_endpoint(client: TestClient):
resp = client.get("/duckdb-api/v1/mila/dashboard")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "mila"
assert "live_status" in data
assert "today" in data
assert "growth" in data
def test_moritz_dashboard_endpoint(client: TestClient):
resp = client.get("/duckdb-api/v1/moritz/dashboard")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "moritz"
assert "today" in data
assert "yesterday" in data
assert "seven_days" in data
assert "thirty_days" in data
def test_healthz_and_status_endpoints(client: TestClient):
resp_h = client.get("/healthz")
assert resp_h.status_code == 200
assert resp_h.json()["engine_ready"] is True
resp_s = client.get("/duckdb-api/v1/status")
assert resp_s.status_code == 200
data_s = resp_s.json()
assert "status" in data_s
assert "table_counts" in data_s
assert data_s["table_counts"]["sleep"] > 0
def test_mila_history_includes_diaper_only_days(client: TestClient):
# Log a diaper change for a specific day without sleep
client.post("/duckdb-api/v1/events", json={
"kind": "diaper.change",
"subject": "mila",
"start": "2026-10-12T12:00:00+02:00",
"payload": {"pee": True, "poo": True},
})
resp = client.get("/duckdb-api/v1/mila/dashboard")
assert resp.status_code == 200
data = resp.json()
history = data["history"]
dates = [h["date"] for h in history]
assert "2026-10-12" in dates
def test_moritz_seven_days_includes_garmin_days(client: TestClient):
resp = client.get("/duckdb-api/v1/moritz/dashboard")
assert resp.status_code == 200
data = resp.json()
days = data["seven_days"]["days"]
assert len(days) > 0
# Every returned day has a valid date and steps or expenditure
for d in days:
assert d["date"] is not None
assert "expenditure_kcal" in d
def test_mila_dashboard_parent_cockpit_and_norms(client: TestClient):
resp = client.get("/duckdb-api/v1/mila/dashboard")
assert resp.status_code == 200
data = resp.json()
# Parent cockpit live fields
live = data["live_status"]
assert "is_sleeping" in live
assert "active_sleep_duration_str" in live
assert "wake_window_minutes" in live
assert "last_feed_since_str" in live
assert "last_diaper_since_str" in live
assert "last_stool_str" in live
# Science age norms
assert "age_norms" in data
norms = data["age_norms"]
assert norms["age_months"] >= 6.0
assert norms["wake_window_min_minutes"] == 135
assert norms["wake_window_max_minutes"] == 180
assert norms["feed_interval_min_minutes"] == 180
# Ensure IDs on items
today = data["today"]
for f in today["feedings"]:
assert "id" in f
for d in today["diapers"]:
assert "id" in d
def test_day_detail_and_summary_endpoints(client: TestClient):
# Test mila day detail
resp_mila = client.get("/duckdb-api/v1/mila/day/2026-10-12")
assert resp_mila.status_code == 200
assert resp_mila.json()["metadata"]["today_date"] == "2026-10-12"
# Test moritz day detail
resp_moritz = client.get("/duckdb-api/v1/moritz/day/2026-10-12")
assert resp_moritz.status_code == 200
assert "today" in resp_moritz.json()
# Test summary
resp_sum = client.get("/duckdb-api/v1/summary/mila?date=2026-10-12")
assert resp_sum.status_code == 200
sum_data = resp_sum.json()
assert sum_data["subject"] == "mila"
assert sum_data["date"] == "2026-10-12"
assert "nutrition" in sum_data
def test_entities_and_places_endpoints(client: TestClient):
# Put and list entity
client.post("/duckdb-api/v1/entities", json={
"collection": "places",
"record": {
"id": "test-biergarten",
"name": "Test Biergarten",
"category": "biergarten",
"lat": 49.02,
"lon": 12.09,
}
})
resp_places = client.get("/duckdb-api/v1/entities/places")
assert resp_places.status_code == 200
places = resp_places.json()
assert any(p["id"] == "test-biergarten" for p in places)
# Places near search
resp_near = client.get("/duckdb-api/v1/places/near?lat=49.02&lon=12.09&radius_m=500")
assert resp_near.status_code == 200
near = resp_near.json()
assert len(near) > 0
assert near[0]["id"] == "test-biergarten"
def test_garmin_sync_and_duplicate_guard(client: TestClient):
# Duplicate guard test
post_data = {
"kind": "feeding.bottle",
"subject": "mila",
"start": "2026-10-12T10:00:00+02:00",
"payload": {"consumed_ml": 180, "milk_type": "pre"},
}
r1 = client.post("/duckdb-api/v1/events", json=post_data)
assert r1.status_code == 200
assert r1.json()["status"] == "success"
assert "feedback" in r1.json()
# Second post within 15min should be duplicate_skipped
r2 = client.post("/duckdb-api/v1/events", json=post_data)
assert r2.status_code == 200
assert r2.json()["status"] == "duplicate_skipped"
# Garmin sync test
garmin_payload = {
"daily": [{
"calendarDate": "2026-10-15",
"totalKilocalories": 2400,
"activeKilocalories": 600,
"bmrKilocalories": 1800,
"totalSteps": 11000,
"restingHeartRate": 52,
}],
"sleep": [],
"activities": [],
}
r_sync = client.post("/duckdb-api/v1/garmin/sync", json=garmin_payload)
assert r_sync.status_code == 200
assert r_sync.json()["status"] == "success"
assert r_sync.json()["synced_daily"] == 1
# Test alias support (daily_summary and sleep_data dicts)
garmin_alias_payload = {
"date": "2026-10-16",
"daily_summary": {
"calendarDate": "2026-10-16",
"totalKilocalories": 2550,
"activeKilocalories": 650,
"bmrKilocalories": 1900,
"totalSteps": 12000,
"restingHeartRate": 50,
},
"sleep_data": {
"dailySleepDTO": {
"calendarDate": "2026-10-16",
"sleepTimeSeconds": 28800,
}
},
"activities": [],
}
r_alias = client.post("/duckdb-api/v1/garmin/sync", json=garmin_alias_payload)
assert r_alias.status_code == 200
assert r_alias.json()["status"] == "success"
assert r_alias.json()["synced_daily"] == 1
assert r_alias.json()["synced_sleep"] == 1
@@ -0,0 +1,176 @@
import tempfile
import pytest
from pathlib import Path
from fastapi.testclient import TestClient
from app.engine import DuckDBEngine
from app.models import EventCreateRequest, EventStopRequest
def test_add_and_stop_sleep_event(tmp_path: Path):
# Use isolated temp directory for write tests so we do not pollute wiki git repo
engine = DuckDBEngine(data_dir=tmp_path)
# 1. Add active sleep event (end=None)
add_req = EventCreateRequest(
kind="sleep.session",
subject="mila",
payload={"type": "nap", "location": "kinderwagen"},
actor="test-runner",
note="Mittagsschlaf Test",
)
res_add = engine.add_event(add_req)
assert res_add["status"] == "success"
event_id = res_add["id"]
assert event_id is not None
# Check that it appears in open sleep
open_rows = engine.query(f"SELECT * FROM sleep WHERE id = '{event_id}' AND (isOpen = true OR endIso IS NULL)")
assert len(open_rows) == 1
# 2. Stop the active sleep event
stop_req = EventStopRequest(
kind="sleep.session",
subject="mila",
event_id=event_id,
actor="test-runner",
)
res_stop = engine.stop_event(stop_req)
assert res_stop["status"] == "success"
assert res_stop["id"] == event_id
# Check that it is now closed
closed_rows = engine.query(f"SELECT isOpen, endIso FROM sleep WHERE id = '{event_id}'")
assert len(closed_rows) == 1
assert closed_rows[0]["isOpen"] is False
assert closed_rows[0]["endIso"] is not None
def test_add_nutrition_and_diaper_event(tmp_path: Path):
engine = DuckDBEngine(data_dir=tmp_path)
# Add nutrition event
nut_req = EventCreateRequest(
kind="nutrition.intake",
subject="moritz",
payload={"item": "Apfel", "qty": 150.0, "kcal": 78.0, "protein_g": 0.5},
meal="snack",
actor="test-runner",
)
res_nut = engine.add_event(nut_req)
assert res_nut["status"] == "success"
nut_id = res_nut["id"]
nut_rows = engine.query(f"SELECT * FROM nutrition WHERE id = '{nut_id}'")
assert len(nut_rows) == 1
# Add diaper event
diaper_req = EventCreateRequest(
kind="diaper.change",
subject="mila",
payload={"pee": True, "poo": False},
actor="test-runner",
)
res_diaper = engine.add_event(diaper_req)
assert res_diaper["status"] == "success"
diaper_id = res_diaper["id"]
diaper_rows = engine.query(f"SELECT * FROM mila WHERE id = '{diaper_id}'")
assert len(diaper_rows) == 1
def test_duplicate_open_sleep_rejected(tmp_path: Path):
engine = DuckDBEngine(data_dir=tmp_path)
# 1. Start first open sleep session
req1 = EventCreateRequest(
kind="sleep.session",
subject="mila",
payload={"type": "nap", "location": "bett"},
actor="parent",
)
res1 = engine.add_event(req1)
assert res1["status"] == "success"
# 2. Attempt to start second open sleep session without stopping the first
req2 = EventCreateRequest(
kind="sleep.session",
subject="mila",
payload={"type": "nap", "location": "kinderwagen"},
actor="parent",
)
with pytest.raises(ValueError, match="already active for subject 'mila'"):
engine.add_event(req2)
def test_duplicate_open_sleep_api_endpoint(client: TestClient):
# 1. Start first session via API
res1 = client.post("/duckdb-api/v1/events", json={
"kind": "sleep.session",
"subject": "test_baby",
"payload": {"type": "nap"},
})
assert res1.status_code == 200
# 2. Start second session via API -> must return 400 Bad Request
res2 = client.post("/duckdb-api/v1/events", json={
"kind": "sleep.session",
"subject": "test_baby",
"payload": {"type": "nap"},
})
assert res2.status_code == 400
assert "already active for subject 'test_baby'" in res2.json()["detail"]
# 3. Clean up by stopping
stop_res = client.post("/duckdb-api/v1/events/stop", json={
"kind": "sleep.session",
"subject": "test_baby",
})
assert stop_res.status_code == 200
def test_agent_utc_wallclock_guard_and_mila_recipe_defaults(tmp_path: Path):
engine = DuckDBEngine(data_dir=tmp_path)
# 1. Agent passes +00:00 on local wall-clock times (e.g. 08:30 to 10:20)
nap_req = EventCreateRequest(
kind="sleep.session",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-17T08:30:00+00:00",
end="2026-10-17T10:20:00+00:00",
payload={"type": "nap", "location": "bett"},
)
res_nap = engine.add_event(nap_req)
assert res_nap["status"] == "success"
assert res_nap["record"]["startIso"] == "2026-10-17T08:30:00+02:00"
assert res_nap["record"]["endIso"] == "2026-10-17T10:20:00+02:00"
assert res_nap["record"]["sleepTimeSeconds"] == 6600
# 2. Solid food without amount_g gets standard recipe defaults
brei_req = EventCreateRequest(
kind="feeding.solid",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-17T12:20:00+00:00",
meal="lunch",
payload={"item": "mittagsbrei"},
)
res_brei = engine.add_event(brei_req)
assert res_brei["record"]["start"] == "2026-10-17T12:20:00+02:00"
assert res_brei["record"]["payload"]["amount_g"] == 190
assert res_brei["record"]["payload"]["kcal"] == 135
abend_req = EventCreateRequest(
kind="feeding.solid",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-17T16:40:00+00:00",
meal="dinner",
payload={"item": "abendbrei"},
)
res_abend = engine.add_event(abend_req)
assert res_abend["record"]["start"] == "2026-10-17T16:40:00+02:00"
assert res_abend["record"]["payload"]["amount_g"] == 200
assert res_abend["record"]["payload"]["kcal"] == 110
@@ -0,0 +1,28 @@
import pytest
from fastapi.testclient import TestClient
def test_generic_query_select(client: TestClient):
payload = {
"query": "SELECT count(*) AS cnt FROM v_sleep WHERE subject = 'mila'"
}
resp = client.post("/duckdb-api/v1/query", json=payload)
assert resp.status_code == 200
data = resp.json()
assert "rows" in data
assert len(data["rows"]) == 1
assert data["rows"][0]["cnt"] > 0
assert data["execution_time_ms"] < 50.0 # verifies sub-50ms execution
def test_generic_query_disallowed_drop(client: TestClient):
payload = {"query": "DROP TABLE sleep;"}
resp = client.post("/duckdb-api/v1/query", json=payload)
assert resp.status_code == 400
assert "not permitted" in resp.json()["detail"]
def test_generic_query_disallowed_insert(client: TestClient):
payload = {"query": "INSERT INTO sleep VALUES ('x', 'y')"}
resp = client.post("/duckdb-api/v1/query", json=payload)
assert resp.status_code == 400
@@ -0,0 +1,36 @@
import pytest
from app.engine import DuckDBEngine
def test_engine_init_and_counts(engine: DuckDBEngine):
counts = engine.get_table_counts()
assert "sleep" in counts
assert "nutrition" in counts
assert "mila" in counts
assert "garmin_daily" in counts
assert counts["sleep"] > 0
assert counts["nutrition"] > 0
def test_mila_dashboard_payload(engine: DuckDBEngine):
data = engine.get_mila_dashboard()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "mila"
assert "live_status" in data
assert "today" in data
assert "averages" in data
assert "history" in data
assert "growth" in data
assert len(data["history"]) > 0
def test_moritz_dashboard_payload(engine: DuckDBEngine):
data = engine.get_moritz_dashboard()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "moritz"
assert "today" in data
assert "yesterday" in data
assert "garmin_recovery" in data
assert "seven_days" in data
assert "thirty_days" in data
assert len(data["thirty_days"]["days"]) > 0
@@ -0,0 +1,68 @@
import subprocess
from pathlib import Path
from app.git_sync import GitSyncManager
def test_git_sync_conflict_handling(tmp_path: Path):
repo_dir = tmp_path / "fake_repo"
repo_dir.mkdir()
subprocess.run(["git", "init", "-b", "main", str(repo_dir)], check=True)
subprocess.run(["git", "-C", str(repo_dir), "config", "user.email", "test@test.local"], check=True)
subprocess.run(["git", "-C", str(repo_dir), "config", "user.name", "Test User"], check=True)
test_file = repo_dir / "test.txt"
test_file.write_text("initial")
subprocess.run(["git", "-C", str(repo_dir), "add", "."], check=True)
subprocess.run(["git", "-C", str(repo_dir), "commit", "-m", "initial commit"], check=True)
mgr = GitSyncManager(wiki_dir=repo_dir)
assert mgr.ensure_repo() is True
assert mgr.status == "ok"
assert mgr.is_conflict is False
def test_git_url_encoding(monkeypatch):
monkeypatch.setattr("app.git_sync.GIT_USERNAME", "moritz@graf")
monkeypatch.setattr("app.git_sync.GIT_PASSWORD", "p@ss:w/ord%123")
monkeypatch.setattr("app.git_sync.GIT_REPO_URL", "https://git.moritzgraf.de/moritz/knowledge-wiki.git")
mgr = GitSyncManager()
auth_url = mgr.get_auth_repo_url()
assert auth_url == "https://moritz%40graf:p%40ss%3Aw%2Ford%25123@git.moritzgraf.de/moritz/knowledge-wiki.git"
def test_ensure_repo_handles_non_empty_dir(tmp_path: Path):
# Simulate a PVC containing lost+found before repo initialization
wiki_pvc = tmp_path / "wiki_pvc"
wiki_pvc.mkdir()
(wiki_pvc / "lost+found").mkdir()
# Create upstream origin to fetch from
origin = tmp_path / "origin.git"
subprocess.run(["git", "init", "--bare", str(origin)], check=True)
# Push initial commit to bare origin
work = tmp_path / "work"
work.mkdir()
subprocess.run(["git", "init", "-b", "main", str(work)], check=True)
subprocess.run(["git", "-C", str(work), "config", "user.name", "Admin"], check=True)
subprocess.run(["git", "-C", str(work), "config", "user.email", "admin@test.local"], check=True)
(work / "README.md").write_text("# Knowledge Wiki")
subprocess.run(["git", "-C", str(work), "add", "."], check=True)
subprocess.run(["git", "-C", str(work), "commit", "-m", "init"], check=True)
subprocess.run(["git", "-C", str(work), "remote", "add", "origin", str(origin)], check=True)
subprocess.run(["git", "-C", str(work), "push", "origin", "main"], check=True)
# Now verify GitSyncManager initializes cleanly into non-empty directory
mgr = GitSyncManager(wiki_dir=wiki_pvc)
# Point URL to file repo
import app.git_sync
app.git_sync.GIT_REPO_URL = str(origin)
app.git_sync.GIT_USERNAME = ""
app.git_sync.GIT_PASSWORD = ""
success = mgr.ensure_repo()
assert success is True
assert (wiki_pvc / ".git").is_dir()
assert (wiki_pvc / "README.md").is_file()
assert (wiki_pvc / "lost+found").is_dir()
+813
View File
@@ -0,0 +1,813 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "duckdb"
version = "1.5.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/59/0b/d65ea3be00ea79aa276a8388bec588a9cbf409ce637c6d306e5316210d15/duckdb-1.5.6.tar.gz", hash = "sha256:166a91dbfacfc0c9f08cc76c0243cb6d3d4296bfab5bad72a3cfb63140a5b7c8", size = 18032957, upload-time = "2026-09-28T13:38:37.978Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/d5/d0ab77a0a1702a43171c93874f44c1f6481e30038bd3987df0d77a16a5c6/duckdb-1.5.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:48d07d0651aaeac2c3974afd37599970154b7b79b54c18f27c319c14ccf98d9d", size = 32810486, upload-time = "2026-09-28T13:37:47.254Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cd/b22201de5377faa3be6c38d5f3eaa504cb480392a448bed6a4d2239469b4/duckdb-1.5.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79de3dfa8705b1ba0d59e7e3252e40ff399e0afd12f485502a6c7bf7c2fd809a", size = 17405278, upload-time = "2026-09-28T13:37:50.135Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6d/f9cfb1493bbdc2f095693a402e42dce1192077f9e11573f00baed6a748de/duckdb-1.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcccce20965e6986cd083fdf192c461685ad0b93cd1ccd0b2a8207f1185f078b", size = 15532943, upload-time = "2026-09-28T13:37:52.927Z" },
{ url = "https://files.pythonhosted.org/packages/53/04/f65ccfaa5a833f2e570c4a140f03c8f95da416da9fe8ed08401f81f8242a/duckdb-1.5.6-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce89a1025a5317ebe9c520876c48032b5247ac574865486648b1a004f6009875", size = 19454940, upload-time = "2026-09-28T13:37:55.732Z" },
{ url = "https://files.pythonhosted.org/packages/4c/99/be75c788a492f8d77b7a1cdc1b19939ae7be0007f2028691ad371a1a33ee/duckdb-1.5.6-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc9619ed7d4ffa117b5155d84b44794366bb6635178d78ed5e13a6024845c757", size = 21568087, upload-time = "2026-09-28T13:37:58.191Z" },
{ url = "https://files.pythonhosted.org/packages/b5/95/889f8508960e47c0a7c75cc5bf57cde8512fc24f8db7b3129cca5388da42/duckdb-1.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:09ff51b230219f0d8b47fc8a1e17fb595ba9fab0c3d96a6de4d00b8ff86b3cf1", size = 13190189, upload-time = "2026-09-28T13:38:00.407Z" },
{ url = "https://files.pythonhosted.org/packages/a4/c9/baab503364a68309f8368c88e77f5341e7d94927bdf3e6d703f0e5035f3e/duckdb-1.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:b8d795c8b2d5634b3269f974aa97f1fdf878f62f032317a52252a151b693fb1e", size = 14021977, upload-time = "2026-09-28T13:38:02.682Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/a476197fcba557738a588ec844747a19bc0a24b0e6f1809e308f29d68c0e/duckdb-1.5.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ae352646374cacf48e9981cf031191c494865192fc436d13667a2531fc5d1da3", size = 32810376, upload-time = "2026-09-28T13:38:05.148Z" },
{ url = "https://files.pythonhosted.org/packages/0c/6d/5466a2b53ddd557644dfa47a763f68748efccdf282e6ae7c4f1bcfb3da69/duckdb-1.5.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a1261e90785e9d29953293e44f60fa073bd1137098924e8de21a037a861b051", size = 17405385, upload-time = "2026-09-28T13:38:07.363Z" },
{ url = "https://files.pythonhosted.org/packages/d4/a0/bf87071170835ee4a34fe764fc11c1c6e7040a0e021b36c1b6f834a4c22f/duckdb-1.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97dd7a555b8f5298b76bc7d48a11cb2c64336e8de9bfde783cffb86ea9f54807", size = 15533132, upload-time = "2026-09-28T13:38:09.681Z" },
{ url = "https://files.pythonhosted.org/packages/31/e0/38095c8e140ecfbe847519ac07bcba94301b8fbb76b2870015e33e07f179/duckdb-1.5.6-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:364992ba1089a2b327391cfcb68fd0bd0ce9090cf293baef861a0ba6847abfee", size = 19454994, upload-time = "2026-09-28T13:38:11.836Z" },
{ url = "https://files.pythonhosted.org/packages/70/21/61dd2876bbaa69cf77d7b5c620e52e8b25faae7096f4d2e4a812b52095d7/duckdb-1.5.6-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:644f54ce99b3b61844bc9a3fe80e0aecb1ea4084b1fffc4396d1569db6111679", size = 21568700, upload-time = "2026-09-28T13:38:14.258Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4a/100730e7785e85268be4d4d5bd62cfc8314e261d2f42efa208243eef35cb/duckdb-1.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:ced693d33ddcee2e5345f077d342c87d2aaa80e41c514e64c9ff2d4e5963c251", size = 13190707, upload-time = "2026-09-28T13:38:16.875Z" },
{ url = "https://files.pythonhosted.org/packages/f3/2e/bc7f44eab4e89ee5c1cb427bb1168ad021d985042e6841ec0694c3d3d501/duckdb-1.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:41ecc75bb9328d72d154a705c1a653d2c5c60f686a5c0c6578aa80020753c884", size = 14020962, upload-time = "2026-09-28T13:38:19.007Z" },
{ url = "https://files.pythonhosted.org/packages/fb/62/a8a30a4c6b94c0861d348ed5633b963f6745a5525527530f02f3c1a7c931/duckdb-1.5.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:aa21d2ad803b2524326e8622d7d96b2bb1ff1d5b60368e1978ee805df9c21fb3", size = 32828003, upload-time = "2026-09-28T13:38:21.414Z" },
{ url = "https://files.pythonhosted.org/packages/71/b7/1dcca0005eb8c67adf9fc06bf0cbb1d2bf4ea1974cc89e7a7c2ad66aac28/duckdb-1.5.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a1b2ad27d414068cbca06c55cfa802eece10f86ea4812ff082f8ab4cb25fc85", size = 17413912, upload-time = "2026-09-28T13:38:23.915Z" },
{ url = "https://files.pythonhosted.org/packages/93/b0/e3ac175443550f3464f2d95731a8b0aae9b4dc3875c3a186c352262b43c2/duckdb-1.5.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c79c6d222b1d015cde73b5139087186b00db65357fb4e2c94c2308fbbf465a72", size = 15543122, upload-time = "2026-09-28T13:38:26.317Z" },
{ url = "https://files.pythonhosted.org/packages/9d/08/cc510a7952aba69d5cdca17f3ef61c95713d86143f2ee9aa3e097d38f50b/duckdb-1.5.6-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1052b8050ef5696e2c0d8c836949c72f3dd11f0690466acbea739613e8e2750b", size = 19457946, upload-time = "2026-09-28T13:38:28.877Z" },
{ url = "https://files.pythonhosted.org/packages/ef/a5/6f8099d9a5a02ddff89e5c85875df3465054845b0920fb0703fbdf8dd2ec/duckdb-1.5.6-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c5e485e59613b8878d1670bcaa7a010f53c5a4da5ae8e08863e5e529ca6182", size = 21575132, upload-time = "2026-09-28T13:38:31.231Z" },
{ url = "https://files.pythonhosted.org/packages/9f/58/762f7159662d7859e201fa05ca29f306795daeabf84f3e087215a966b001/duckdb-1.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:ebcbd09cd8578ab1093393e9b16289cda0e8f1791ac595bf00eb5bad75c3cf00", size = 13713963, upload-time = "2026-09-28T13:38:33.543Z" },
{ url = "https://files.pythonhosted.org/packages/46/69/64d165db322de13f5c3e75d377b6b9694df1821155ad1fa4b14b04601abc/duckdb-1.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:820a8384faef11cd86068ea48c5da57ce2d8f1c7b3d2bdb9be3398317a7c3728", size = 14514368, upload-time = "2026-09-28T13:38:35.676Z" },
]
[[package]]
name = "duckdb-backend"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "duckdb" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "python-ulid" },
{ name = "pytz" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "duckdb", specifier = ">=1.1.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "pydantic", specifier = ">=2.8.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "python-ulid", specifier = ">=3.0.0" },
{ name = "pytz", specifier = ">=2024.1" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
]
provides-extras = ["dev"]
[[package]]
name = "fastapi"
version = "0.143.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" },
{ url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" },
{ url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" },
{ url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" },
{ url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" },
{ url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" },
{ url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" },
{ url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" },
{ url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" },
{ url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" },
{ url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" },
{ url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" },
{ url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" },
{ url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" },
{ url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" },
{ url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" },
{ url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" },
{ url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" },
{ url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" },
{ url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" },
{ url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" },
{ url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" },
{ url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" },
{ url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" },
{ url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" },
{ url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" },
{ url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" },
{ url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" },
{ url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" },
{ url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" },
{ url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" },
{ url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" },
{ url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" },
{ url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" },
{ url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" },
{ url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" },
{ url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" },
{ url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" },
{ url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" },
{ url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" },
{ url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" },
{ url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" },
{ url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" },
{ url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" },
{ url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" },
{ url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" },
{ url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" },
{ url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" },
{ url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" },
{ url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" },
{ url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" },
{ url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" },
{ url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" },
{ url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" },
{ url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" },
{ url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" },
{ url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.45.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" },
]
[[package]]
name = "pydantic-core"
version = "2.50.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" },
{ url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" },
{ url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" },
{ url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" },
{ url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" },
{ url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" },
{ url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" },
{ url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" },
{ url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" },
{ url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" },
{ url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" },
{ url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" },
{ url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" },
{ url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" },
{ url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" },
{ url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" },
{ url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" },
{ url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" },
{ url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" },
{ url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" },
{ url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" },
{ url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" },
{ url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" },
{ url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" },
{ url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" },
{ url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" },
{ url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" },
{ url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" },
{ url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" },
{ url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" },
{ url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" },
{ url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" },
{ url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" },
{ url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" },
{ url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" },
{ url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" },
{ url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" },
{ url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" },
{ url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" },
{ url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" },
{ url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" },
{ url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" },
{ url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" },
{ url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" },
{ url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" },
{ url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" },
{ url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" },
{ url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" },
{ url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" },
{ url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" },
{ url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" },
{ url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" },
{ url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" },
{ url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" },
{ url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" },
{ url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" },
{ url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" },
{ url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" },
{ url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" },
{ url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" },
{ url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" },
{ url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" },
{ url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" },
{ url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" },
{ url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" },
{ url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" },
{ url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" },
{ url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" },
{ url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" },
{ url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.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/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "python-ulid"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/41/65079023c81491a21799c0120bce5925366b6913596bf797806f19973290/python_ulid-4.0.1.tar.gz", hash = "sha256:bbeec02556190bb9dc3401faa7268696acbfbe7b6db9908c155dc3548629f20c", size = 101683, upload-time = "2026-07-20T15:21:41.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/15/8b39b36f55b6618ec4ca9b55134dcfd9c04cecbc72709f5d8e6bdebed9cd/python_ulid-4.0.1-py3-none-any.whl", hash = "sha256:6f1d69ceb97e99fe542df8476ebcd7a668284bf53ee14b3106bcc6a341a95ed9", size = 14609, upload-time = "2026-07-20T15:21:40.214Z" },
]
[[package]]
name = "pytz"
version = "2026.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/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" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "starlette"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/0c/6efb252d091ecccd7d62048ae11f0ea35cd75a4fbaeea5e30f9c3bf91d10/starlette-1.8.0.tar.gz", hash = "sha256:1565dc0b35d5737a271ed1e0e04e949f4e81198799f216d2667b0a0fb9cf9522", size = 2730457, upload-time = "2026-10-13T07:54:39.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b0/5742e4ac7af5eb58ec3470a537a49d7aa507e5539413e504b3a65ef50ba8/starlette-1.8.0-py3-none-any.whl", hash = "sha256:dfdd6b29c26483288088d990eee59631dedadd66ce20d203402a7ca8e3c4656f", size = 79612, upload-time = "2026-10-13T07:54:38.019Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
{ url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
{ url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
{ url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
{ url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
{ url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
{ url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
{ url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
{ url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
{ url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
{ url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
{ url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
{ url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
{ url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
{ url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
{ url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
{ url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
{ url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
{ url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
{ url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
{ url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
{ url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
{ url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
{ url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
]
[[package]]
name = "watchfiles"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" },
{ url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" },
{ url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" },
{ url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" },
{ url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" },
{ url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" },
{ url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" },
{ url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" },
{ url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" },
{ url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" },
]
[[package]]
name = "websockets"
version = "17.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" },
{ url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" },
{ url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" },
{ url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" },
{ url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" },
{ url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" },
{ url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" },
{ url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" },
{ url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" },
{ url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" },
{ url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" },
{ url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" },
{ url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" },
{ url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" },
{ url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" },
{ url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" },
{ url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" },
{ url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" },
{ url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" },
{ url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" },
{ url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" },
{ url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" },
{ url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" },
{ url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" },
{ url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" },
{ url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" },
{ url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" },
{ url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" },
{ url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" },
{ url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" },
{ url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" },
{ url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" },
{ url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" },
{ url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" },
{ url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" },
{ url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" },
{ url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" },
{ url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" },
{ url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" },
{ url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" },
{ url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" },
{ url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" },
{ url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" },
{ url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" },
{ url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" },
{ url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" },
{ url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" },
{ url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" },
{ url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" },
{ url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" },
{ url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" },
{ url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" },
{ url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" },
{ url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" },
{ url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" },
{ url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" },
{ url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" },
{ url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" },
{ url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" },
{ url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" },
{ url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" },
{ url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" },
{ url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" },
{ url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" },
{ url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" },
{ url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" },
{ url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" },
{ url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" },
{ url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" },
{ url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" },
{ url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" },
{ url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" },
{ url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" },
{ url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" },
{ url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" },
{ url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" },
{ url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" },
]
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
.DS_Store
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md — Mila Tracking App
## Development
```bash
cd projects/mila
npm install
npm run dev
npm run build
```
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/ /usr/share/nginx/html/
RUN mkdir -p /usr/share/nginx/html/tracking && ln -s /usr/share/nginx/html /usr/share/nginx/html/tracking/mila
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
# Mila Tracking App
Mobile-first PWA dashboard for baby sleep, feeding, diaper changes, and WHO growth milestones.
Built with Vue 3 (Composition API), Vite, TypeScript, and Tailwind CSS.
## Features
- **Live State & Timers**: Active sleep duration or wake window updated in real time.
- **Sleep & Routine Tracking**: Today's naps and night sleep with running indicators.
- **Nutritional Logging**: Bottles (ml, Pre/Muttermilch) and solids (Brei g, kcal).
- **Diapers**: Instant 1-tap logging for wet and dirty changes.
- **WHO Growth Standards**: Automatic LMS percentile and z-score calculations (Girls 0-24 Months).
- **Git Sync Awareness**: Real-time warning banner if git background sync reports merge conflicts.
- **Backend**: Backed by FastAPI DuckDB backend at `/duckdb-api/v1`.
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="de" class="dark overflow-x-hidden w-full max-w-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Mila — Schlaf & Entwicklung</title>
<!-- PWA & Mobile Web Capabilities -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Mila" />
<meta name="application-name" content="Mila" />
<meta name="theme-color" content="#090A0F" />
<meta name="msapplication-TileColor" content="#090A0F" />
<!-- Manifest with credentials for forward-auth SSO -->
<link rel="manifest" href="/tracking/mila/mila.webmanifest" crossorigin="use-credentials" />
<link rel="apple-touch-icon" href="/tracking/mila/mila-apple-touch-icon-v3.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/tracking/mila/mila-apple-touch-icon-v3.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/tracking/mila/mila-icon-192-v3.png" />
<link rel="apple-touch-icon" sizes="512x512" href="/tracking/mila/mila-icon-512-v3.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/tracking/mila/mila-icon-192-v3.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/tracking/mila/mila-icon-32-v3.png" />
<link rel="icon" type="image/svg+xml" href="/tracking/mila/mila-icon.svg" />
<link rel="shortcut icon" href="/tracking/mila/favicon.ico" />
<!-- Typography -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Syne:wght@700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
</head>
<body class="bg-[#090A0F] text-zinc-100 min-h-screen font-sans antialiased selection:bg-purple-500 selection:text-white pb-20 overflow-x-hidden w-full max-w-full">
<div id="app" class="w-full max-w-full overflow-x-hidden"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
server {
listen 80;
server_name _;
absolute_redirect off;
port_in_redirect off;
root /usr/share/nginx/html;
index index.html;
include /etc/nginx/mime.types;
types {
application/manifest+json webmanifest;
}
location = / {
return 301 /tracking/mila/;
}
location = /tracking/mila {
return 301 /tracking/mila/;
}
location /tracking/mila/ {
try_files $uri $uri/ /tracking/mila/index.html /index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
# Service worker
location ~* /sw\.js$ {
default_type application/javascript;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Service-Worker-Allowed "/tracking/mila/";
}
# Webmanifest and manifest.json
location ~* /(?:mila\.webmanifest|manifest\.webmanifest|manifest\.json)$ {
default_type application/manifest+json;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
# Static assets cache
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2|ttf)$ {
expires 1y;
add_header Cache-Control "public";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+2251
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "mila-tracking",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"lucide-vue-next": "^0.468.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.9",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.9",
"typescript": "^5.7.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+48
View File
@@ -0,0 +1,48 @@
{
"id": "/tracking/mila/",
"name": "Mila — Schlaf & Entwicklung",
"short_name": "Mila",
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
"start_url": "/tracking/mila/",
"scope": "/tracking/mila/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#090A0F",
"theme_color": "#090A0F",
"icons": [
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-apple-touch-icon-v3.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/tracking/mila/mila-icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 219 KiB

File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"id": "/tracking/mila/",
"name": "Mila — Schlaf & Entwicklung",
"short_name": "Mila",
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
"start_url": "/tracking/mila/",
"scope": "/tracking/mila/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#090A0F",
"theme_color": "#090A0F",
"icons": [
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-apple-touch-icon-v3.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/tracking/mila/mila-icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
+51
View File
@@ -0,0 +1,51 @@
// Minimal service worker for Mila PWA with instant cache-bypassing navigation
const CACHE_NAME = 'mila-pwa-v5';
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) return caches.delete(key);
})
)
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
// For HTML documents/navigations: ALWAYS bypass HTTP cache to get latest live server rendering
if (event.request.destination === 'document' || event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request, { cache: 'no-cache' })
.then((response) => {
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// Assets (images, fonts, scripts): network-first with offline cache fallback
event.respondWith(
fetch(event.request)
.then((response) => {
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
});
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
import type { MilaDashboardData, DayDetailData } from './types'
const API_BASE = '/duckdb-api/v1'
export function getLocalDateString(): string {
const d = new Date()
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export async function fetchMilaDashboard(asOf?: string): Promise<MilaDashboardData> {
const url = asOf ? `${API_BASE}/mila/dashboard?as_of=${encodeURIComponent(asOf)}` : `${API_BASE}/mila/dashboard`
const res = await fetch(url)
if (!res.ok) {
throw new Error(`Failed to fetch dashboard: ${res.status} ${res.statusText}`)
}
return res.json()
}
export async function fetchMilaDayDetail(date: string): Promise<DayDetailData> {
const res = await fetch(`${API_BASE}/mila/day/${encodeURIComponent(date)}`)
if (!res.ok) {
throw new Error(`Failed to fetch day detail for ${date}: ${res.statusText}`)
}
return res.json()
}
export async function logEvent(payload: {
kind: string
subject: string
payload: Record<string, any>
start?: string
end?: string
date?: string
since?: string
note?: string
actor?: string
}): Promise<any> {
const res = await fetch(`${API_BASE}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Failed to log event')
}
return res.json()
}
export async function stopSleepEvent(payload: {
subject: string
end_time?: string
since?: string
event_id?: string
}): Promise<any> {
const res = await fetch(`${API_BASE}/events/stop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'sleep.session',
...payload,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Failed to stop sleep event')
}
return res.json()
}
export async function deleteEvent(eventId: string): Promise<any> {
const res = await fetch(`${API_BASE}/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE',
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Failed to delete event')
}
return res.json()
}
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { HistoryRow, MilaDashboardData } from '../types'
import { Utensils, Milk, Apple, Flame, Sparkles } from 'lucide-vue-next'
const props = defineProps<{
history: HistoryRow[]
dashboard: MilaDashboardData
}>()
// Latest 14 days reversed (chronological left-to-right)
const days = computed(() => {
return [...props.history.slice(0, 14)].reverse()
})
// Today's summary
const todayFeed = computed(() => {
return props.dashboard.today.feeding_summary
})
// 7-day averages
const avgBottleMl = computed(() => props.dashboard.averages.avg_7_feeding_ml || 0)
const avgSolidG = computed(() => props.dashboard.averages.avg_7_solid_g || 0)
// Calculated kcal estimate:
// Pre-milk is ~67 kcal / 100 ml (0.67 kcal/ml)
// Brei is roughly ~70 kcal / 100 g (0.70 kcal/g)
const todayEstimatedKcal = computed(() => {
if (todayFeed.value.total_kcal > 0) return todayFeed.value.total_kcal
const milkKcal = (todayFeed.value.total_consumed_ml || 0) * 0.67
const solidKcal = (todayFeed.value.total_solid_g || 0) * 0.70
return Math.round(milkKcal + solidKcal)
})
// Target kcal (approx 650 kcal for 7.2kg infant at ~85 kcal/kg according to EFSA)
const targetKcal = 650
const kcalPercent = computed(() => {
return Math.min(100, Math.round((todayEstimatedKcal.value / targetKcal) * 100))
})
</script>
<template>
<div class="bg-zinc-900/80 border border-zinc-800/90 rounded-3xl p-5 sm:p-6 shadow-xl space-y-4">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-zinc-800/70">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-xl bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-300">
<Utensils class="w-4 h-4" />
</div>
<div>
<h3 class="text-sm sm:text-base font-bold text-white font-heading">Ernährung & Beikost-Wissenschaft</h3>
<p class="text-xs text-zinc-400">Milch-zu-Brei Transition nach DGKJ & FKE Dortmund</p>
</div>
</div>
<!-- Quick Status Badges -->
<div class="flex items-center gap-2 text-xs font-mono">
<span class="px-2.5 py-1 rounded-xl bg-amber-500/15 border border-amber-500/30 text-amber-300 flex items-center gap-1.5">
<Milk class="w-3.5 h-3.5" />
<span>{{ todayFeed.total_consumed_ml }} ml</span>
</span>
<span class="px-2.5 py-1 rounded-xl bg-orange-500/15 border border-orange-500/30 text-orange-300 flex items-center gap-1.5">
<Apple class="w-3.5 h-3.5" />
<span>{{ todayFeed.total_solid_g }} g</span>
</span>
</div>
</div>
<!-- Energy & Transition Hero Card -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<!-- Card 1: Energy & Calorie Goal -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-zinc-400 font-semibold uppercase font-mono flex items-center gap-1">
<Flame class="w-3.5 h-3.5 text-amber-400" />
<span>Tages-Energie</span>
</span>
<span class="font-bold text-amber-300 font-mono">~{{ todayEstimatedKcal }} / {{ targetKcal }} kcal</span>
</div>
<div class="w-full h-2.5 bg-zinc-900 rounded-full overflow-hidden border border-zinc-800">
<div
:style="{ width: `${kcalPercent}%` }"
class="h-full bg-gradient-to-r from-amber-500 to-orange-500 rounded-full transition-all duration-500"
></div>
</div>
<p class="text-[10px] text-zinc-500 leading-tight">
EFSA-Empfehlung: ca. 80–85 kcal/kg Körpergewicht (~620–680 kcal/Tag bei ~7.4 kg).
</p>
</div>
<!-- Card 2: 7-Tage Milch-Ø -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-1">
<span class="text-[11px] text-zinc-400 font-semibold uppercase font-mono block">Milch-Schnitt (7T)</span>
<div class="text-2xl font-extrabold font-mono text-amber-300">{{ avgBottleMl }} ml</div>
<p class="text-[10px] text-zinc-500 leading-tight">
Sichert die Grundversorgung mit Flüssigkeit, Eiweiß und Kalzium im Beikost-Alter.
</p>
</div>
<!-- Card 3: 7-Tage Brei-Ø -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-1">
<span class="text-[11px] text-zinc-400 font-semibold uppercase font-mono block">Brei-Schnitt (7T)</span>
<div class="text-2xl font-extrabold font-mono text-orange-300">{{ avgSolidG }} g</div>
<p class="text-[10px] text-zinc-500 leading-tight">
Fördert Kau- und Schluckmotorik sowie Nährstoffdichte (Eisen, Zink).
</p>
</div>
</div>
<!-- 14-Day Transition Micro-Chart -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs pb-1">
<span class="font-semibold text-zinc-300">14-Tage Verlauf: Milch (ml) vs. Brei (g)</span>
<div class="flex items-center gap-3 text-[11px]">
<span class="flex items-center gap-1 text-amber-300 font-mono">
<span class="w-2.5 h-2.5 rounded bg-amber-500"></span> Milch (ml / 10)
</span>
<span class="flex items-center gap-1 text-orange-300 font-mono">
<span class="w-2.5 h-2.5 rounded bg-orange-500"></span> Brei (g)
</span>
</div>
</div>
<div class="flex items-end justify-between gap-1.5 h-24 pt-2 border-t border-zinc-800/60">
<div
v-for="d in days"
:key="d.date"
class="flex-1 flex flex-col items-center justify-end h-full gap-1 group relative cursor-pointer"
>
<!-- Tooltip hover -->
<div class="hidden group-hover:block absolute bottom-full mb-1 z-20 bg-zinc-900 border border-zinc-700 text-zinc-200 text-[10px] p-1.5 rounded-lg whitespace-nowrap shadow-xl font-mono">
{{ d.weekday }} {{ d.date.slice(5) }}: {{ d.feed_bottles_ml }}ml / {{ d.feed_solid_g }}g
</div>
<div class="w-full flex items-end justify-center gap-0.5 h-16">
<!-- Milk Bar (scaled to 1000ml max = 100%) -->
<div
:style="{ height: `${Math.min(100, (d.feed_bottles_ml / 1000) * 100)}%` }"
class="w-1.5 sm:w-2 bg-amber-500/80 group-hover:bg-amber-400 rounded-t-sm transition-all"
></div>
<!-- Solid Bar (scaled to 300g max = 100%) -->
<div
:style="{ height: `${Math.min(100, (d.feed_solid_g / 300) * 100)}%` }"
class="w-1.5 sm:w-2 bg-orange-500/80 group-hover:bg-orange-400 rounded-t-sm transition-all"
></div>
</div>
<span class="text-[8px] font-mono text-zinc-500 group-hover:text-zinc-300">
{{ d.date.slice(8) }}
</span>
</div>
</div>
</div>
<!-- Pediatric Guidance Stufenplan (FKE Dortmund) -->
<div class="p-4 rounded-2xl bg-zinc-950/40 border border-zinc-800/60 space-y-2">
<div class="flex items-center gap-1.5 text-xs font-bold text-zinc-300 uppercase tracking-wider font-mono">
<Sparkles class="w-3.5 h-3.5 text-purple-400" />
<span>FKE Dortmund: Ernährungsplan im 1. Lebensjahr</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2.5 text-xs pt-1">
<div class="p-2.5 rounded-xl bg-zinc-900/60 border border-zinc-800/80 space-y-1">
<span class="font-bold text-emerald-400 block font-mono">① Mittag: Gemüse-Kartoffel-Fleisch</span>
<p class="text-[11px] text-zinc-400 leading-tight">
Liefert wichtiges Eisen und Zink zur Blutbildung und Hirnentwicklung nach Aufbrauchen der pränatalen Speicher.
</p>
</div>
<div class="p-2.5 rounded-xl bg-zinc-900/60 border border-zinc-800/80 space-y-1">
<span class="font-bold text-sky-400 block font-mono">② Abend: Milch-Getreide-Brei</span>
<p class="text-[11px] text-zinc-400 leading-tight">
Kombiniert Kalzium aus Vollmilch mit komplexen Kohlenhydraten (Hafer/Grieß) für langanhaltende Sättigung in der Nacht.
</p>
</div>
<div class="p-2.5 rounded-xl bg-zinc-900/60 border border-zinc-800/80 space-y-1">
<span class="font-bold text-amber-400 block font-mono">③ Nachmittag: Getreide-Obst-Brei</span>
<p class="text-[11px] text-zinc-400 leading-tight">
Milchfreier Brei; Vitamin C aus Früchten optimiert die pflanzliche Eisenresorption und liefert milde Ballaststoffe.
</p>
</div>
</div>
</div>
</div>
</template>
+643
View File
@@ -0,0 +1,643 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { HistoryRow } from '../types'
import { Moon, BarChart2, Calendar, Award } from 'lucide-vue-next'
const props = defineProps<{
history: HistoryRow[]
}>()
const activePeriod = ref<'7d' | '14d' | '30d'>('7d')
const activeView = ref<'trend' | 'pattern' | 'norms'>('trend')
const activeIndex = ref<number | null>(null)
// Slice history rows for selected period (history is newest first, so reverse for chart chronological left-to-right)
const periodDays = computed(() => {
const count = activePeriod.value === '7d' ? 7 : activePeriod.value === '14d' ? 14 : 30
const slice = props.history.slice(0, count)
return [...slice].reverse()
})
// Max hours for chart Y-axis scale (normally 18h is good ceiling for infant 24h sleep)
const yMaxHours = 18
// Chart dimensions
const chartWidth = 600
const chartHeight = 220
const paddingLeft = 32
const paddingRight = 16
const paddingTop = 20
const paddingBottom = 30
const innerWidth = chartWidth - paddingLeft - paddingRight
const innerHeight = chartHeight - paddingTop - paddingBottom
// Coordinates helper
function getY(hours: number): number {
const clamped = Math.max(0, Math.min(yMaxHours, hours))
return paddingTop + innerHeight - (clamped / yMaxHours) * innerHeight
}
// WHO Recommended sleep band (12h to 15h)
const whoYTop = computed(() => getY(15))
const whoYBottom = computed(() => getY(12))
const whoBandHeight = computed(() => whoYBottom.value - whoYTop.value)
// Day bar calculations with 7-day moving average
const bars = computed(() => {
const days = periodDays.value
if (!days.length) return []
const slotWidth = innerWidth / days.length
const barWidth = Math.max(8, Math.min(28, slotWidth * 0.65))
return days.map((day, idx) => {
const xCenter = paddingLeft + (idx + 0.5) * slotWidth
const x = xCenter - barWidth / 2
let nightHours = 0
let napHours = 0
if (typeof day.night_minutes === 'number' || typeof day.nap_minutes === 'number') {
nightHours = Math.max(0, (day.night_minutes || 0) / 60)
napHours = Math.max(0, (day.nap_minutes || 0) / 60)
} else if (day.sessions && day.sessions.length > 0) {
for (const s of day.sessions) {
const mins = s.duration_minutes || 0
if (s.type === 'night') {
nightHours += mins / 60
} else {
napHours += mins / 60
}
}
} else {
nightHours = Math.min(day.total_hours, Math.max(0, day.total_hours * 0.75))
napHours = Math.max(0, day.total_hours - nightHours)
}
const totalHours = Math.max(0, day.total_hours, nightHours + napHours)
const yTotal = getY(totalHours)
const yNight = getY(nightHours)
const nightHeight = Math.max(0, (paddingTop + innerHeight) - yNight)
const napHeight = Math.max(0, yNight - yTotal)
// Moving average of previous up to 7 days
const windowStart = Math.max(0, idx - 6)
const windowSlice = days.slice(windowStart, idx + 1)
const avg7 = windowSlice.reduce((acc, d) => acc + d.total_hours, 0) / windowSlice.length
const yAvg7 = getY(avg7)
// WHO Compliance
const isWithinWho = totalHours >= 12 && totalHours <= 15
const isBelowWho = totalHours < 12
const isAboveWho = totalHours > 15
return {
date: day.date,
weekday: day.weekday,
formatted_date: day.formatted_date,
is_today: day.is_today,
totalHours,
nightHours,
napHours,
x,
xCenter,
barWidth,
yTotal,
yNight,
nightHeight,
napHeight,
avg7,
yAvg7,
isWithinWho,
isBelowWho,
isAboveWho,
raw: day,
}
})
})
// Moving average path SVG string
const movingAvgPath = computed(() => {
if (bars.value.length < 2) return ''
return bars.value.reduce((acc, b, i) => {
return i === 0 ? `M ${b.xCenter} ${b.yAvg7}` : `${acc} L ${b.xCenter} ${b.yAvg7}`
}, '')
})
// Selected active bar for tooltip scrubber
const activeBar = computed(() => {
if (activeIndex.value === null) {
return bars.value.length ? bars.value[bars.value.length - 1] : null
}
return bars.value[activeIndex.value] || null
})
// Summary metrics over the selected period
const summary = computed(() => {
const days = periodDays.value
if (!days.length) return { avgTotal: '0', avgNight: '0', avgNaps: '0', whoPercent: 0, napCountAvg: '0' }
let sumTot = 0
let sumNight = 0
let sumNaps = 0
let sumNapCount = 0
let inWhoCount = 0
for (const b of bars.value) {
sumTot += b.totalHours
sumNight += b.nightHours
sumNaps += b.napHours
const napSessions = (b.raw.sessions || []).filter(s => s.type !== 'night')
sumNapCount += napSessions.length
if (b.isWithinWho) inWhoCount++
}
const n = bars.value.length
return {
avgTotal: (sumTot / n).toFixed(1),
avgNight: (sumNight / n).toFixed(1),
avgNaps: (sumNaps / n).toFixed(1),
napCountAvg: (sumNapCount / n).toFixed(1),
whoPercent: Math.round((inWhoCount / n) * 100),
}
})
// 24h Hypnogram Raster Barcode data
const rasterDays = computed(() => {
// Newest at top for barcode
return props.history.slice(0, activePeriod.value === '7d' ? 7 : activePeriod.value === '14d' ? 14 : 30)
})
function getMinuteFromTime(timeStr: string): number {
if (!timeStr) return 0
const parts = timeStr.split(':')
if (parts.length < 2) return 0
return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10)
}
interface RasterBlock {
startMin: number
durationMin: number
type: 'night' | 'nap'
label: string
title: string
}
function getDay24hBlocks(day: HistoryRow, dayIdx: number): RasterBlock[] {
const blocks: RasterBlock[] = []
// 1. Check previous calendar day (props.history[dayIdx + 1]) for overnight sleep spilling into 00:00..wake_time
const prevDay = props.history[dayIdx + 1]
if (prevDay?.sessions) {
for (const s of prevDay.sessions) {
if (!s.start) continue
const sMin = getMinuteFromTime(s.start)
const dur = s.duration_minutes || 60
if (sMin + dur > 1440) {
const morningMin = Math.min(1440, (sMin + dur) - 1440)
if (morningMin > 0) {
blocks.push({
startMin: 0,
durationMin: morningMin,
type: s.type === 'night' ? 'night' : 'nap',
label: `${morningMin}m`,
title: `Nachtschlaf (Vorabend ${s.start} – ${s.end || 'morgens'}): ${morningMin} min nach 00:00 Uhr (${s.duration_minutes} min gesamt)`,
})
}
}
}
}
// 2. Add sessions starting on this calendar day (clamped to 24:00)
if (day.sessions) {
for (const s of day.sessions) {
if (!s.start) continue
const sMin = getMinuteFromTime(s.start)
const dur = s.duration_minutes || 60
const clampedDur = Math.max(1, Math.min(dur, 1440 - sMin))
blocks.push({
startMin: sMin,
durationMin: clampedDur,
type: s.type === 'night' ? 'night' : 'nap',
label: `${clampedDur}m`,
title: `${s.type === 'night' ? 'Nachtschlaf' : 'Nap'}: ${s.start}–${s.end || 'jetzt'} (${s.duration_minutes} min)`,
})
}
}
return blocks
}
function handlePointer(event: MouseEvent | TouchEvent) {
const svg = (event.currentTarget as HTMLElement).getBoundingClientRect()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const relativeX = clientX - svg.left
const ratio = relativeX / svg.width
const svgX = ratio * chartWidth
let closestIdx = 0
let closestDist = Infinity
bars.value.forEach((b, idx) => {
const dist = Math.abs(b.xCenter - svgX)
if (dist < closestDist) {
closestDist = dist
closestIdx = idx
}
})
activeIndex.value = closestIdx
}
</script>
<template>
<div class="bg-zinc-900/80 border border-zinc-800/90 rounded-3xl p-5 sm:p-6 shadow-xl space-y-4">
<!-- Header with View & Period Switcher -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-zinc-800/70">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-xl bg-purple-500/15 border border-purple-500/30 flex items-center justify-center text-purple-300">
<Moon class="w-4 h-4" />
</div>
<div>
<h3 class="text-sm sm:text-base font-bold text-white font-heading">Schlafarchitektur & Wissenschaft</h3>
<p class="text-xs text-zinc-400">Trend, 24h-Hypnogramm & pädiatrische Normen</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<!-- View Mode Switcher -->
<div class="inline-flex rounded-xl bg-zinc-950 p-1 border border-zinc-800 text-xs font-semibold">
<button
@click="activeView = 'trend'"
:class="activeView === 'trend' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg transition"
>
<BarChart2 class="w-3.5 h-3.5" />
<span>Trend</span>
</button>
<button
@click="activeView = 'pattern'"
:class="activeView === 'pattern' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg transition"
>
<Calendar class="w-3.5 h-3.5" />
<span>24h-Muster</span>
</button>
<button
@click="activeView = 'norms'"
:class="activeView === 'norms' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg transition"
>
<Award class="w-3.5 h-3.5" />
<span>Normen</span>
</button>
</div>
<!-- Period Switcher -->
<div class="inline-flex rounded-xl bg-zinc-950 p-1 border border-zinc-800 text-xs font-semibold">
<button
v-for="p in (['7d', '14d', '30d'] as const)"
:key="p"
@click="activePeriod = p; activeIndex = null"
:class="activePeriod === p ? 'bg-zinc-800 text-purple-300 shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="px-2.5 py-1 rounded-lg transition"
>
{{ p === '7d' ? '7T' : p === '14d' ? '14T' : '30T' }}
</button>
</div>
</div>
</div>
<!-- ================= VIEW 1: TREND BAR CHART ================= -->
<div v-if="activeView === 'trend'" class="space-y-4">
<!-- Active Tooltip / Scrubber Inspector Card -->
<div v-if="activeBar" class="p-3.5 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-2">
<span class="font-mono font-bold text-zinc-200 text-sm">
{{ activeBar.weekday }}, {{ activeBar.formatted_date }}
</span>
<span v-if="activeBar.is_today" class="px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30 text-[10px] font-semibold">
Heute
</span>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-4 font-mono">
<div class="flex items-center gap-1.5">
<span class="text-zinc-400 font-sans">Gesamt:</span>
<span class="font-bold text-purple-300 text-sm">{{ activeBar.totalHours.toFixed(1) }} h</span>
</div>
<div class="flex items-center gap-1.5 text-indigo-300">
<span>🌙</span>
<span>{{ activeBar.nightHours.toFixed(1) }}h</span>
</div>
<div class="flex items-center gap-1.5 text-emerald-300">
<span>🌿</span>
<span>{{ activeBar.napHours.toFixed(1) }}h</span>
</div>
<div class="flex items-center gap-1.5 text-amber-300 hidden sm:flex">
<span class="text-zinc-400 font-sans">Ø7T:</span>
<span>{{ activeBar.avg7.toFixed(1) }}h</span>
</div>
<span
:class="activeBar.isWithinWho ? 'bg-purple-500/15 border-purple-500/30 text-purple-300' : (activeBar.isBelowWho ? 'bg-amber-500/15 border-amber-500/30 text-amber-300' : 'bg-sky-500/15 border-sky-500/30 text-sky-300')"
class="px-2 py-0.5 rounded-md border text-[11px] font-sans font-medium hidden sm:inline-block"
>
{{ activeBar.isWithinWho ? 'WHO Norm (12–15h)' : (activeBar.isBelowWho ? '< 12h' : '> 15h') }}
</span>
</div>
</div>
<!-- Interactive SVG Chart Area -->
<div
class="relative w-full cursor-pointer select-none touch-pan-x overflow-hidden"
@mousemove="handlePointer"
@touchmove="handlePointer"
@touchstart="handlePointer"
>
<svg
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
class="w-full h-auto overflow-hidden"
>
<!-- Horizontal grid lines -->
<g stroke="#27272a" stroke-width="1" stroke-dasharray="2 3">
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(18)" :y2="getY(18)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(15)" :y2="getY(15)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(12)" :y2="getY(12)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(6)" :y2="getY(6)" />
</g>
<!-- WHO Recommended Band (12h to 15h) -->
<rect
:x="paddingLeft"
:y="whoYTop"
:width="innerWidth"
:height="whoBandHeight"
fill="#a855f7"
fill-opacity="0.08"
stroke="#a855f7"
stroke-opacity="0.3"
stroke-width="1"
stroke-dasharray="3 3"
/>
<text
:x="chartWidth - paddingRight - 4"
:y="whoYTop + 11"
text-anchor="end"
class="fill-purple-400/70 text-[9px] font-mono font-semibold"
>
WHO Richtwert (12–15h)
</text>
<!-- Y-Axis Labels -->
<g class="fill-zinc-500 text-[10px] font-mono" text-anchor="end">
<text :x="paddingLeft - 6" :y="getY(18) + 3">18h</text>
<text :x="paddingLeft - 6" :y="getY(15) + 3">15h</text>
<text :x="paddingLeft - 6" :y="getY(12) + 3">12h</text>
<text :x="paddingLeft - 6" :y="getY(6) + 3">6h</text>
<text :x="paddingLeft - 6" :y="getY(0) - 2">0h</text>
</g>
<!-- Stacked Bars -->
<g v-for="(bar, idx) in bars" :key="bar.date">
<rect
v-if="activeBar?.date === bar.date"
:x="bar.x - 3"
:y="paddingTop"
:width="bar.barWidth + 6"
:height="innerHeight"
fill="#ffffff"
fill-opacity="0.06"
rx="6"
/>
<!-- Night Sleep Segment (Bottom) -->
<rect
:x="bar.x"
:y="bar.yNight"
:width="bar.barWidth"
:height="bar.nightHeight"
class="fill-indigo-500 hover:fill-indigo-400 transition"
rx="3"
/>
<!-- Day Nap Segment (Top of stack) -->
<rect
v-if="bar.napHeight > 0"
:x="bar.x"
:y="bar.yTotal"
:width="bar.barWidth"
:height="bar.napHeight"
class="fill-emerald-400 hover:fill-emerald-300 transition"
rx="3"
/>
<!-- Today Indicator Marker dot -->
<circle
v-if="bar.is_today"
:cx="bar.xCenter"
:cy="bar.yTotal - 6"
r="3"
class="fill-purple-400 animate-pulse"
/>
<!-- X-Axis Labels (Date / Weekday) -->
<text
:x="bar.xCenter"
:y="chartHeight - paddingBottom + 14"
text-anchor="middle"
:class="bar.is_today ? 'fill-purple-300 font-bold' : (activeBar?.date === bar.date ? 'fill-zinc-200 font-bold' : 'fill-zinc-500')"
class="text-[9px] font-mono"
>
{{ bar.weekday }}
</text>
<text
v-if="activePeriod !== '30d' || idx % 3 === 0"
:x="bar.xCenter"
:y="chartHeight - paddingBottom + 25"
text-anchor="middle"
class="fill-zinc-600 text-[8px] font-mono"
>
{{ bar.date.slice(8) }}
</text>
</g>
<!-- Moving Average 7-Day Trend Line -->
<path
v-if="movingAvgPath"
:d="movingAvgPath"
fill="none"
stroke="#f59e0b"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="opacity-80"
/>
</svg>
</div>
<!-- Chart Legend & Science Summary -->
<div class="pt-3 border-t border-zinc-800/60 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-3 sm:gap-4 flex-wrap">
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-indigo-500 shrink-0"></span>
<span class="text-zinc-300 font-medium">Nacht</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-emerald-400 shrink-0"></span>
<span class="text-zinc-300 font-medium">Naps</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-4 h-0.5 bg-amber-500 shrink-0"></span>
<span class="text-amber-300 font-medium">7T-Schnitt</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-purple-500/20 border border-purple-500/40 shrink-0"></span>
<span class="text-purple-300 font-medium">WHO (12–15h)</span>
</div>
</div>
<div class="flex items-center gap-3 font-mono text-xs">
<span class="text-zinc-400">Ø Gesamt: <strong class="text-purple-300">{{ summary.avgTotal }}h</strong></span>
<span class="text-zinc-400">Norm: <strong class="text-emerald-400">{{ summary.whoPercent }}%</strong></span>
</div>
</div>
</div>
<!-- ================= VIEW 2: 24h PATTERN / HYPNOGRAM RASTER ================= -->
<div v-else-if="activeView === 'pattern'" class="space-y-3">
<div class="text-xs text-zinc-400 flex items-center justify-between">
<span>Täglicher Schlafverlauf von 00:00 bis 24:00 Uhr</span>
<div class="flex items-center gap-3">
<span class="flex items-center gap-1 text-indigo-300">
<span class="w-2 h-2 rounded-full bg-indigo-500"></span> Nacht
</span>
<span class="flex items-center gap-1 text-emerald-300">
<span class="w-2 h-2 rounded-full bg-emerald-400"></span> Tagesschläfchen
</span>
</div>
</div>
<!-- Time Header Strip (0h, 4h, 8h, 12h, 16h, 20h, 24h) -->
<div class="flex items-center text-[10px] text-zinc-500 font-mono pl-20 pr-2">
<div class="flex-1 text-left">00:00</div>
<div class="flex-1 text-center">06:00</div>
<div class="flex-1 text-center">12:00</div>
<div class="flex-1 text-center">18:00</div>
<div class="w-8 text-right">24:00</div>
</div>
<!-- Raster Bars -->
<div class="space-y-1.5">
<div
v-for="(day, dayIdx) in rasterDays"
:key="day.date"
class="flex items-center gap-2 group"
>
<!-- Date Label -->
<div class="w-18 shrink-0 font-mono text-[11px] text-zinc-400 group-hover:text-zinc-200 flex items-center gap-1">
<span :class="day.is_today ? 'text-purple-400 font-bold' : ''">{{ day.weekday }}</span>
<span class="text-[10px] text-zinc-500">{{ day.date.slice(5) }}</span>
</div>
<!-- 24h Timeline Track -->
<div class="flex-1 h-6 bg-zinc-950/80 rounded-lg relative overflow-hidden border border-zinc-800/80 group-hover:border-zinc-700">
<!-- 6h Guide Gridlines -->
<div class="absolute inset-0 flex justify-between pointer-events-none opacity-20">
<div class="border-r border-zinc-600 h-full w-1/4"></div>
<div class="border-r border-zinc-600 h-full w-1/4"></div>
<div class="border-r border-zinc-600 h-full w-1/4"></div>
</div>
<!-- Sessions Blocks -->
<template v-if="getDay24hBlocks(day, dayIdx).length > 0">
<div
v-for="(b, bIdx) in getDay24hBlocks(day, dayIdx)"
:key="bIdx"
:style="{
left: `${(b.startMin / 1440) * 100}%`,
width: `${Math.max(1.2, (b.durationMin / 1440) * 100)}%`,
}"
:class="b.type === 'night' ? 'bg-indigo-600/90 border-indigo-400/50 text-indigo-100' : 'bg-emerald-500/90 border-emerald-300/50 text-emerald-100'"
class="absolute top-0 bottom-0 rounded-[3px] border border-opacity-40 flex items-center justify-center text-[9px] font-mono font-bold overflow-hidden cursor-help"
:title="b.title"
>
<span v-if="b.durationMin >= 45" class="truncate px-0.5">
{{ b.label }}
</span>
</div>
</template>
<div v-else class="text-[10px] text-zinc-600 italic px-2 py-0.5">Keine Einzelschläfchen</div>
</div>
</div>
</div>
</div>
<!-- ================= VIEW 3: NORMS & PEDIATRIC CORRIDORS ================= -->
<div v-else-if="activeView === 'norms'" class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<!-- Card 1: 24h Gesamt-Schlaf -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">24h Gesamtschlaf</span>
<span class="text-purple-300 font-mono font-bold">{{ summary.avgTotal }}h / 12–15h</span>
</div>
<!-- Visual corridor progress bar -->
<div class="w-full h-3 bg-zinc-900 rounded-full overflow-hidden relative border border-zinc-800">
<!-- Norm corridor shaded region (12h to 15h in 18h scale: 66% to 83%) -->
<div class="absolute left-[66%] right-[16%] top-0 bottom-0 bg-purple-500/20 border-x border-purple-500/50"></div>
<!-- Mila actual marker -->
<div
:style="{ left: `${Math.min(100, (Number(summary.avgTotal) / 18) * 100)}%` }"
class="absolute top-0 bottom-0 w-2 -ml-1 bg-purple-400 rounded-full shadow-lg shadow-purple-400/50"
></div>
</div>
<p class="text-[11px] text-zinc-400 pt-1 leading-relaxed">
<strong>WHO / AASM Richtwert (12–15h):</strong> Unterstützt die kognitive Konsolidierung, das Längenwachstum durch Wachstumshormone im Tiefschlaf und beugt Übermüdung vor.
</p>
</div>
<!-- Card 2: Nachtschlaf-Architektur -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">Nachtschlaf</span>
<span class="text-indigo-300 font-mono font-bold">{{ summary.avgNight }}h / 10–11.5h</span>
</div>
<div class="w-full h-3 bg-zinc-900 rounded-full overflow-hidden relative border border-zinc-800">
<!-- Norm corridor (10h to 11.5h in 14h scale) -->
<div class="absolute left-[71%] right-[17%] top-0 bottom-0 bg-indigo-500/20 border-x border-indigo-500/50"></div>
<div
:style="{ left: `${Math.min(100, (Number(summary.avgNight) / 14) * 100)}%` }"
class="absolute top-0 bottom-0 w-2 -ml-1 bg-indigo-400 rounded-full shadow-lg shadow-indigo-400/50"
></div>
</div>
<p class="text-[11px] text-zinc-400 pt-1 leading-relaxed">
<strong>Remo H. Largo (Babyjahre):</strong> Im 2. Lebenshalbjahr konsolidiert sich der Nachtschlaf auf ca. 10–11 Stunden am Stück (mit altersgemäßen Aufwachphasen zur Nahrung/Rückversicherung).
</p>
</div>
<!-- Card 3: Tagesschläfchen-Frequenz -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">Nap-Frequenz</span>
<span class="text-emerald-300 font-mono font-bold">{{ summary.napCountAvg }} Naps / 2–3 Naps</span>
</div>
<p class="text-[11px] text-zinc-400 leading-relaxed">
<strong>Entwicklungsübergang (6–9 Monate):</strong> Die meisten Babys wechseln von 3 kurzen Nickerchen zu 2 stabilen Schläfchen (Vormittag + Nachmittag). Mila liegt mit Ø {{ summary.napCountAvg }} Naps voll im Normbereich.
</p>
</div>
<!-- Card 4: Wachfenster-Korridor -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">Sweet-Spot Wachfenster</span>
<span class="text-amber-300 font-mono font-bold">2h 15m – 3h 00m</span>
</div>
<p class="text-[11px] text-zinc-400 leading-relaxed">
<strong>Schlafdruck vs. Übermüdung:</strong> Nach 2h 15m baut sich der ideale Schlafdruck auf. Ab 3h 15m steigt der Cortisol- und Adrenalinspiegel, was das Einschlafen erschwert.
</p>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,579 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Moon, Sun, Baby, Milk, HeartPulse, Check, X } from 'lucide-vue-next'
import { logEvent, stopSleepEvent, getLocalDateString } from '../api'
const props = defineProps<{
isOpen: boolean
isSleeping: boolean
activeSleepId?: string
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'logged'): void
}>()
const activeTab = ref<'sleep' | 'feeding' | 'diaper' | 'growth'>('sleep')
const feedingSubTab = ref<'bottle' | 'solid'>('bottle')
const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null)
const successMessage = ref<string | null>(null)
// --- Sleep Form ---
const sleepType = ref<'nap' | 'night'>('nap')
const sleepLocation = ref('bett')
const sleepStartTime = ref('')
const sleepEndTime = ref('')
const sleepNote = ref('')
// --- Bottle Form ---
const bottleMl = ref<number | ''>(150)
const bottleMilkType = ref('Pre')
const bottleTime = ref('')
const bottleNote = ref('')
// --- Solid Form ---
const solidItem = ref('')
const solidGrams = ref<number | ''>(100)
const solidKcal = ref<number | ''>(70)
const solidTime = ref('')
const solidNote = ref('')
// --- Diaper Form ---
const diaperTexture = ref('weich')
const diaperNote = ref('')
// --- Growth Form ---
const growthWeight = ref<number | ''>(7.2)
const growthHeight = ref<number | ''>(66.0)
const growthHeadCirc = ref<number | ''>(42.5)
const growthNote = ref('')
const targetDate = ref(getLocalDateString())
function resetMessages() {
errorMessage.value = null
successMessage.value = null
}
async function handleStartSleepNow() {
resetMessages()
isSubmitting.value = true
try {
await logEvent({
kind: 'sleep.session',
subject: 'mila',
payload: { type: sleepType.value, location: sleepLocation.value },
date: targetDate.value || undefined,
note: sleepNote.value || undefined,
})
successMessage.value = 'Schläfchen gestartet!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Starten des Schläfchens'
} finally {
isSubmitting.value = false
}
}
async function handleStopSleepNow() {
resetMessages()
isSubmitting.value = true
try {
await stopSleepEvent({
subject: 'mila',
event_id: props.activeSleepId,
})
successMessage.value = 'Schläfchen erfolgreich beendet!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Beenden des Schläfchens'
} finally {
isSubmitting.value = false
}
}
async function handleCustomSleepSubmit() {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
let startIso: string | undefined = undefined
let endIso: string | undefined = undefined
if (sleepStartTime.value) {
startIso = `${baseDate}T${sleepStartTime.value}:00`
}
if (sleepEndTime.value) {
if (sleepStartTime.value && sleepEndTime.value < sleepStartTime.value) {
// Crosses midnight: end timestamp is next day
const [y, m, d] = baseDate.split('-').map(Number)
const nextDay = new Date(y, m - 1, d + 1)
const ny = nextDay.getFullYear()
const nm = String(nextDay.getMonth() + 1).padStart(2, '0')
const nd = String(nextDay.getDate()).padStart(2, '0')
endIso = `${ny}-${nm}-${nd}T${sleepEndTime.value}:00`
} else {
endIso = `${baseDate}T${sleepEndTime.value}:00`
}
}
await logEvent({
kind: 'sleep.session',
subject: 'mila',
payload: { type: sleepType.value, location: sleepLocation.value },
start: startIso,
end: endIso,
date: baseDate,
note: sleepNote.value || undefined,
})
successMessage.value = 'Schlaf eingetragen!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
async function handleFeedingSubmit() {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
if (feedingSubTab.value === 'bottle') {
const startIso = bottleTime.value ? `${baseDate}T${bottleTime.value}:00` : undefined
await logEvent({
kind: 'feeding.bottle',
subject: 'mila',
payload: {
consumed_ml: Number(bottleMl.value) || 0,
milk_type: bottleMilkType.value,
},
start: startIso,
date: baseDate,
note: bottleNote.value || undefined,
})
successMessage.value = `${bottleMl.value} ml Fläschchen eingetragen!`
} else {
const startIso = solidTime.value ? `${baseDate}T${solidTime.value}:00` : undefined
await logEvent({
kind: 'feeding.solid',
subject: 'mila',
payload: {
item: solidItem.value || 'Brei',
consumed_g: Number(solidGrams.value) || 0,
kcal: Number(solidKcal.value) || 0,
},
start: startIso,
date: baseDate,
note: solidNote.value || undefined,
})
successMessage.value = `${solidGrams.value} g Brei eingetragen!`
}
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
async function handleDiaperQuickSubmit(pee: boolean, poo: boolean) {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
await logEvent({
kind: 'diaper.change',
subject: 'mila',
payload: {
pee,
poo,
texture: poo ? diaperTexture.value : undefined,
},
date: baseDate,
note: diaperNote.value || undefined,
})
successMessage.value = 'Windelwechsel erfasst!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
async function handleGrowthSubmit() {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
await logEvent({
kind: 'growth.measurement',
subject: 'mila',
payload: {
weight_kg: Number(growthWeight.value) || null,
height_cm: Number(growthHeight.value) || null,
head_circ_cm: Number(growthHeadCirc.value) || null,
},
date: baseDate,
note: growthNote.value || undefined,
})
successMessage.value = 'Messung erfasst!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/70 backdrop-blur-md">
<div class="w-full sm:max-w-lg bg-zinc-950 border border-zinc-800 rounded-t-3xl sm:rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<!-- Modal Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-zinc-800 bg-zinc-900/50">
<div class="flex items-center gap-2">
<span class="text-xl">✨</span>
<h2 class="text-base font-bold text-white font-heading">Ereignis erfassen</h2>
</div>
<button @click="emit('close')" class="p-1 rounded-xl text-zinc-400 hover:text-white hover:bg-zinc-800 transition">
<X class="w-5 h-5" />
</button>
</div>
<!-- Tab Navigation -->
<div class="grid grid-cols-4 p-1.5 bg-zinc-900 border-b border-zinc-800 text-xs font-semibold">
<button
@click="activeTab = 'sleep'; resetMessages()"
:class="activeTab === 'sleep' ? 'bg-purple-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<Moon class="w-3.5 h-3.5" />
<span>Schlaf</span>
</button>
<button
@click="activeTab = 'feeding'; resetMessages()"
:class="activeTab === 'feeding' ? 'bg-amber-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<Milk class="w-3.5 h-3.5" />
<span>Nahrung</span>
</button>
<button
@click="activeTab = 'diaper'; resetMessages()"
:class="activeTab === 'diaper' ? 'bg-teal-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<Baby class="w-3.5 h-3.5" />
<span>Windel</span>
</button>
<button
@click="activeTab = 'growth'; resetMessages()"
:class="activeTab === 'growth' ? 'bg-pink-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<HeartPulse class="w-3.5 h-3.5" />
<span>Wachstum</span>
</button>
</div>
<!-- Date Selection Bar (Retroactive Tracking) -->
<div class="px-5 py-2 bg-zinc-900/60 border-b border-zinc-800 flex items-center justify-between text-xs text-zinc-400">
<span class="flex items-center gap-1.5 font-medium">
<span>📅</span>
<span>Ereignis-Datum:</span>
</span>
<input
v-model="targetDate"
type="date"
class="bg-zinc-950 border border-zinc-700/80 rounded-lg px-2.5 py-1 text-xs text-zinc-100 font-mono focus:border-purple-500 focus:outline-none"
/>
</div>
<!-- Messages -->
<div v-if="errorMessage" class="mx-5 mt-4 p-3 rounded-xl bg-red-950/60 border border-red-800/80 text-red-200 text-xs">
{{ errorMessage }}
</div>
<div v-if="successMessage" class="mx-5 mt-4 p-3 rounded-xl bg-emerald-950/60 border border-emerald-800/80 text-emerald-200 text-xs flex items-center gap-2">
<Check class="w-4 h-4 text-emerald-400" />
<span>{{ successMessage }}</span>
</div>
<!-- Tab Content Area -->
<div class="p-5 overflow-y-auto space-y-4 flex-1">
<!-- ================= TAB 1: SCHLAF ================= -->
<div v-if="activeTab === 'sleep'" class="space-y-4">
<!-- 1-Tap Quick Action -->
<div class="p-4 rounded-2xl bg-zinc-900/80 border border-purple-500/20 space-y-3">
<div class="text-xs font-semibold text-purple-300 uppercase tracking-wider">1-Tap Sofort-Logging</div>
<div v-if="!isSleeping" class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
@click="sleepType = 'nap'; handleStartSleepNow()"
:disabled="isSubmitting"
class="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-bold text-sm flex items-center justify-center gap-2 shadow-lg shadow-purple-600/20 active:scale-95 transition"
>
<Moon class="w-4 h-4" />
<span>Schläfchen JETZT starten</span>
</button>
<button
@click="sleepType = 'night'; handleStartSleepNow()"
:disabled="isSubmitting"
class="w-full py-3 px-4 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-sky-300 border border-sky-500/30 font-semibold text-sm flex items-center justify-center gap-2 active:scale-95 transition"
>
<span>🌙 Nachtschlaf starten</span>
</button>
</div>
<div v-else class="space-y-2">
<button
@click="handleStopSleepNow()"
:disabled="isSubmitting"
class="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white font-extrabold text-sm flex items-center justify-center gap-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition animate-pulse"
>
<Sun class="w-4 h-4" />
<span>Schlaf JETZT beenden (Aufgewacht!)</span>
</button>
</div>
</div>
<!-- Custom Sleep Form -->
<form @submit.prevent="handleCustomSleepSubmit" class="space-y-3 pt-2 border-t border-zinc-800">
<div class="text-xs font-semibold text-zinc-400">Oder manuell mit Uhrzeit:</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Typ</label>
<select v-model="sleepType" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="nap">Tagesschläfchen (Nap)</option>
<option value="night">Nachtschlaf</option>
</select>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Ort</label>
<select v-model="sleepLocation" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="bett">Bett</option>
<option value="kinderwagen">Kinderwagen</option>
<option value="auto">Auto</option>
<option value="trage">Trage</option>
<option value="sofa">Sofa</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Startzeit</label>
<input v-model="sleepStartTime" type="time" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Endzeit (optional)</label>
<input v-model="sleepEndTime" type="time" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Notiz</label>
<input v-model="sleepNote" type="text" placeholder="z.B. schnell eingeschlafen" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<button
type="submit"
:disabled="isSubmitting"
class="w-full py-2.5 rounded-xl bg-purple-700 hover:bg-purple-600 text-white font-semibold text-xs transition"
>
Schlaf manuell speichern
</button>
</form>
</div>
<!-- ================= TAB 2: NAHRUNG ================= -->
<div v-if="activeTab === 'feeding'" class="space-y-4">
<!-- Sub-tab Toggle -->
<div class="flex rounded-xl bg-zinc-900 p-1 border border-zinc-800 text-xs">
<button
@click="feedingSubTab = 'bottle'"
:class="feedingSubTab === 'bottle' ? 'bg-amber-500/20 text-amber-300 border border-amber-500/30' : 'text-zinc-400'"
class="flex-1 py-1.5 rounded-lg font-semibold transition"
>
🍼 Fläschchen
</button>
<button
@click="feedingSubTab = 'solid'"
:class="feedingSubTab === 'solid' ? 'bg-orange-500/20 text-orange-300 border border-orange-500/30' : 'text-zinc-400'"
class="flex-1 py-1.5 rounded-lg font-semibold transition"
>
🥣 Brei / Beikost
</button>
</div>
<!-- Bottle Section -->
<div v-if="feedingSubTab === 'bottle'" class="space-y-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Schnellwahl (ml)</label>
<div class="grid grid-cols-6 gap-1.5">
<button
v-for="ml in [60, 90, 120, 150, 180, 210]"
:key="ml"
type="button"
@click="bottleMl = ml"
:class="bottleMl === ml ? 'bg-amber-500 text-zinc-950 font-bold' : 'bg-zinc-900 text-zinc-300 border border-zinc-800'"
class="py-2 rounded-xl text-xs font-mono transition"
>
{{ ml }}
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Getrunkene Menge (ml)</label>
<input v-model="bottleMl" type="number" step="10" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Milchtyp</label>
<select v-model="bottleMilkType" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="Pre">Pre</option>
<option value="Muttermilch">Muttermilch</option>
<option value="1er">1er Nahrung</option>
</select>
</div>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Uhrzeit (leer = Jetzt)</label>
<input v-model="bottleTime" type="time" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<button
@click="handleFeedingSubmit"
:disabled="isSubmitting"
class="w-full py-3 rounded-xl bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 text-white font-bold text-sm shadow-lg shadow-amber-600/20 active:scale-95 transition"
>
Flasche speichern
</button>
</div>
<!-- Solid Section -->
<div v-else class="space-y-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Brei-Bezeichnung</label>
<input v-model="solidItem" type="text" placeholder="z.B. Kürbis-Kartoffel Brei" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Schnellwahl Gramm</label>
<div class="grid grid-cols-4 gap-2">
<button
v-for="g in [50, 100, 150, 190]"
:key="g"
type="button"
@click="solidGrams = g"
:class="solidGrams === g ? 'bg-orange-500 text-zinc-950 font-bold' : 'bg-zinc-900 text-zinc-300 border border-zinc-800'"
class="py-2 rounded-xl text-xs font-mono transition"
>
{{ g }}g
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Menge (g)</label>
<input v-model="solidGrams" type="number" step="10" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Kalorien (~kcal)</label>
<input v-model="solidKcal" type="number" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
</div>
<button
@click="handleFeedingSubmit"
:disabled="isSubmitting"
class="w-full py-3 rounded-xl bg-gradient-to-r from-orange-600 to-amber-600 hover:from-orange-500 hover:to-amber-500 text-white font-bold text-sm shadow-lg shadow-orange-600/20 active:scale-95 transition"
>
Brei speichern
</button>
</div>
</div>
<!-- ================= TAB 3: WINDEL ================= -->
<div v-if="activeTab === 'diaper'" class="space-y-4">
<div class="text-xs font-semibold text-teal-300 uppercase tracking-wider">1-Tap Windelwechsel</div>
<div class="grid grid-cols-3 gap-2">
<button
@click="handleDiaperQuickSubmit(true, false)"
:disabled="isSubmitting"
class="p-4 rounded-2xl bg-zinc-900 border border-teal-500/30 hover:border-teal-500/60 hover:bg-teal-500/10 flex flex-col items-center gap-1.5 transition active:scale-95"
>
<span class="text-2xl">💧</span>
<span class="text-xs font-bold text-teal-300">Nur Pipi</span>
</button>
<button
@click="handleDiaperQuickSubmit(false, true)"
:disabled="isSubmitting"
class="p-4 rounded-2xl bg-zinc-900 border border-amber-500/30 hover:border-amber-500/60 hover:bg-amber-500/10 flex flex-col items-center gap-1.5 transition active:scale-95"
>
<span class="text-2xl">💩</span>
<span class="text-xs font-bold text-amber-300">Nur Kacka</span>
</button>
<button
@click="handleDiaperQuickSubmit(true, true)"
:disabled="isSubmitting"
class="p-4 rounded-2xl bg-zinc-900 border border-emerald-500/30 hover:border-emerald-500/60 hover:bg-emerald-500/10 flex flex-col items-center gap-1.5 transition active:scale-95"
>
<span class="text-2xl">🧷</span>
<span class="text-xs font-bold text-emerald-300">Beides</span>
</button>
</div>
<div class="space-y-3 pt-3 border-t border-zinc-800">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Konsistenz (optional)</label>
<select v-model="diaperTexture" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="weich">Weich</option>
<option value="breiig">Breiig</option>
<option value="fest">Fest</option>
<option value="flüssig">Flüssig</option>
</select>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Notiz</label>
<input v-model="diaperNote" type="text" placeholder="z.B. nach dem Aufwachen" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
</div>
</div>
<!-- ================= TAB 4: WACHSTUM ================= -->
<div v-if="activeTab === 'growth'" class="space-y-3">
<div class="grid grid-cols-3 gap-2">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Gewicht (kg)</label>
<input v-model="growthWeight" type="number" step="0.05" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Größe (cm)</label>
<input v-model="growthHeight" type="number" step="0.5" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Kopf (cm)</label>
<input v-model="growthHeadCirc" type="number" step="0.5" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Notiz</label>
<input v-model="growthNote" type="text" placeholder="z.B. U5 Untersuchung beim Kinderarzt" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<button
@click="handleGrowthSubmit"
:disabled="isSubmitting"
class="w-full py-3 rounded-xl bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white font-bold text-sm shadow-lg shadow-pink-600/20 active:scale-95 transition"
>
Messung speichern
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,378 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { GrowthMeasurement } from '../types'
import { Ruler, Sparkles } from 'lucide-vue-next'
const props = defineProps<{
measurements: GrowthMeasurement[]
latest: GrowthMeasurement | null
currentAgeMonths?: number
}>()
const activeMetric = ref<'weight' | 'height'>('weight')
const activeMonthHover = ref<number | null>(null)
// WHO Girls Growth Standards 0-18 Months
const WHO_WEIGHT = [
{ m: 0, p3: 2.4, p15: 2.8, p50: 3.2, p85: 3.7, p97: 4.2 },
{ m: 1, p3: 3.2, p15: 3.6, p50: 4.2, p85: 4.8, p97: 5.4 },
{ m: 2, p3: 4.0, p15: 4.5, p50: 5.1, p85: 5.8, p97: 6.5 },
{ m: 3, p3: 4.6, p15: 5.2, p50: 5.8, p85: 6.6, p97: 7.4 },
{ m: 4, p3: 5.1, p15: 5.7, p50: 6.4, p85: 7.3, p97: 8.1 },
{ m: 5, p3: 5.5, p15: 6.1, p50: 6.9, p85: 7.8, p97: 8.7 },
{ m: 6, p3: 5.8, p15: 6.5, p50: 7.3, p85: 8.2, p97: 9.2 },
{ m: 7, p3: 6.1, p15: 6.8, p50: 7.6, p85: 8.6, p97: 9.6 },
{ m: 8, p3: 6.3, p15: 7.0, p50: 7.9, p85: 8.9, p97: 10.0 },
{ m: 9, p3: 6.6, p15: 7.3, p50: 8.2, p85: 9.3, p97: 10.4 },
{ m: 10, p3: 6.8, p15: 7.5, p50: 8.5, p85: 9.6, p97: 10.7 },
{ m: 11, p3: 7.0, p15: 7.7, p50: 8.7, p85: 9.9, p97: 11.0 },
{ m: 12, p3: 7.1, p15: 7.9, p50: 8.9, p85: 10.1, p97: 11.3 },
{ m: 15, p3: 7.7, p15: 8.5, p50: 9.6, p85: 10.9, p97: 12.2 },
{ m: 18, p3: 8.2, p15: 9.1, p50: 10.2, p85: 11.6, p97: 13.0 },
]
const WHO_HEIGHT = [
{ m: 0, p3: 45.6, p15: 47.3, p50: 49.1, p85: 51.0, p97: 52.7 },
{ m: 1, p3: 50.0, p15: 51.7, p50: 53.7, p85: 55.6, p97: 57.4 },
{ m: 2, p3: 53.2, p15: 55.0, p50: 57.1, p85: 59.1, p97: 60.9 },
{ m: 3, p3: 55.8, p15: 57.7, p50: 59.8, p85: 61.9, p97: 63.8 },
{ m: 4, p3: 58.0, p15: 59.9, p50: 62.1, p85: 64.3, p97: 66.2 },
{ m: 5, p3: 59.9, p15: 61.8, p50: 64.0, p85: 66.3, p97: 68.2 },
{ m: 6, p3: 61.5, p15: 63.5, p50: 65.7, p85: 68.0, p97: 70.0 },
{ m: 7, p3: 62.9, p15: 65.0, p50: 67.3, p85: 69.6, p97: 71.6 },
{ m: 8, p3: 64.3, p15: 66.4, p50: 68.7, p85: 71.1, p97: 73.2 },
{ m: 9, p3: 65.6, p15: 67.8, p50: 70.1, p85: 72.5, p97: 74.7 },
{ m: 10, p3: 66.8, p15: 69.0, p50: 71.5, p85: 73.9, p97: 76.1 },
{ m: 11, p3: 68.0, p15: 70.3, p50: 72.8, p85: 75.2, p97: 77.5 },
{ m: 12, p3: 69.2, p15: 71.4, p50: 74.0, p85: 76.5, p97: 78.9 },
{ m: 15, p3: 72.4, p15: 74.8, p50: 77.5, p85: 80.2, p97: 82.7 },
{ m: 18, p3: 75.2, p15: 77.8, p50: 80.7, p85: 83.5, p97: 86.2 },
]
const chartWidth = 600
const chartHeight = 220
const paddingLeft = 38
const paddingRight = 20
const paddingTop = 20
const paddingBottom = 30
const innerWidth = chartWidth - paddingLeft - paddingRight
const innerHeight = chartHeight - paddingTop - paddingBottom
const maxMonth = 18
// Min/Max Y scale based on active metric
const yMin = computed(() => (activeMetric.value === 'weight' ? 2 : 44))
const yMax = computed(() => (activeMetric.value === 'weight' ? 14 : 90))
function getX(month: number): number {
const clamped = Math.max(0, Math.min(maxMonth, month))
return paddingLeft + (clamped / maxMonth) * innerWidth
}
function getY(val: number): number {
const clamped = Math.max(yMin.value, Math.min(yMax.value, val))
return paddingTop + innerHeight - ((clamped - yMin.value) / (yMax.value - yMin.value)) * innerHeight
}
const currentTable = computed(() => (activeMetric.value === 'weight' ? WHO_WEIGHT : WHO_HEIGHT))
// Path builder for WHO percentile lines
function buildPath(key: 'p3' | 'p15' | 'p50' | 'p85' | 'p97'): string {
const pts = currentTable.value.map((row) => `${getX(row.m).toFixed(1)},${getY(row[key]).toFixed(1)}`)
return `M ${pts.join(' L ')}`
}
// Shaded polygon area between P15 and P85
const normalBandPath = computed(() => {
const topPts = currentTable.value.map((r) => `${getX(r.m).toFixed(1)},${getY(r.p85).toFixed(1)}`)
const botPts = [...currentTable.value].reverse().map((r) => `${getX(r.m).toFixed(1)},${getY(r.p15).toFixed(1)}`)
return `M ${topPts.join(' L ')} L ${botPts.join(' L ')} Z`
})
// Mila's plotted points
const plottedPoints = computed(() => {
return props.measurements
.filter((m) => (activeMetric.value === 'weight' ? m.weight_kg !== null : m.height_cm !== null))
.map((m) => {
const val = (activeMetric.value === 'weight' ? m.weight_kg : m.height_cm) as number
const pctStr = activeMetric.value === 'weight' ? m.weight_pct : m.height_pct
return {
id: m.id,
date_pretty: m.date_pretty,
age_str: m.age_str,
age_months: m.age_months,
val,
pctStr,
cx: getX(m.age_months),
cy: getY(val),
}
})
})
// Active inspected month info for hover/touch
const inspectedData = computed(() => {
const m = activeMonthHover.value ?? (props.currentAgeMonths ? Math.round(props.currentAgeMonths) : 7)
const clampedM = Math.max(0, Math.min(maxMonth, m))
// Find nearest WHO row
let nearest = currentTable.value[0]
let minDist = Infinity
for (const row of currentTable.value) {
const d = Math.abs(row.m - clampedM)
if (d < minDist) {
minDist = d
nearest = row
}
}
// Find if Mila has a measurement near this month
const milaPt = plottedPoints.value.find((pt) => Math.abs(pt.age_months - clampedM) <= 1)
return {
month: clampedM,
p50: nearest.p50,
p15: nearest.p15,
p85: nearest.p85,
unit: activeMetric.value === 'weight' ? 'kg' : 'cm',
milaPt,
}
})
function handlePointer(event: MouseEvent | TouchEvent) {
const svg = (event.currentTarget as HTMLElement).getBoundingClientRect()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const relativeX = clientX - svg.left
const ratio = (relativeX / svg.width) * chartWidth
const month = ((ratio - paddingLeft) / innerWidth) * maxMonth
activeMonthHover.value = Math.max(0, Math.min(maxMonth, Math.round(month)))
}
</script>
<template>
<div class="bg-zinc-900/80 border border-zinc-800/90 rounded-3xl p-5 sm:p-6 shadow-xl space-y-4">
<!-- Header with Metric Switcher -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-zinc-800/70">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-xl bg-pink-500/15 border border-pink-500/30 flex items-center justify-center text-pink-300">
<Ruler class="w-4 h-4" />
</div>
<div>
<h3 class="text-sm sm:text-base font-bold text-white font-heading">WHO Wachstums- & Perzentilenkurven</h3>
<p class="text-xs text-zinc-400">Offizielle WHO-Standards für Mädchen (0–18 Monate)</p>
</div>
</div>
<!-- Toggle Gewicht vs Größe -->
<div class="inline-flex rounded-xl bg-zinc-950 p-1 border border-zinc-800 text-xs font-semibold self-start sm:self-auto">
<button
@click="activeMetric = 'weight'"
:class="activeMetric === 'weight' ? 'bg-pink-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="px-3 py-1 rounded-lg transition"
>
⚖️ Gewicht (kg)
</button>
<button
@click="activeMetric = 'height'"
:class="activeMetric === 'height' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="px-3 py-1 rounded-lg transition"
>
📏 Körpergröße (cm)
</button>
</div>
</div>
<!-- Active Scrubber Inspector Card -->
<div class="p-3.5 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-2">
<Sparkles class="w-4 h-4 text-pink-400 shrink-0" />
<span class="font-bold text-zinc-200">
Alter: {{ inspectedData.month }} Monate
</span>
<span class="text-zinc-400">· WHO P50 Median:</span>
<strong class="font-mono text-purple-300">{{ inspectedData.p50 }} {{ inspectedData.unit }}</strong>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3 font-mono">
<span class="text-zinc-400">
Normalbereich (P15–P85):
<strong class="text-zinc-300">{{ inspectedData.p15 }} – {{ inspectedData.p85 }} {{ inspectedData.unit }}</strong>
</span>
<span
v-if="inspectedData.milaPt"
class="px-2 py-0.5 rounded-md bg-pink-500/20 border border-pink-500/40 text-pink-300 font-semibold"
>
Mila: {{ inspectedData.milaPt.val }} {{ inspectedData.unit }} ({{ inspectedData.milaPt.pctStr }})
</span>
</div>
</div>
<!-- Interactive SVG Curve Canvas -->
<div
class="relative w-full cursor-pointer select-none touch-pan-x overflow-hidden"
@mousemove="handlePointer"
@touchmove="handlePointer"
@touchstart="handlePointer"
>
<svg
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
class="w-full h-auto overflow-hidden"
>
<!-- Horizontal Grid Lines -->
<g stroke="#27272a" stroke-width="1" stroke-dasharray="2 3">
<line
v-for="yTick in (activeMetric === 'weight' ? [3, 6, 9, 12] : [50, 60, 70, 80])"
:key="yTick"
:x1="paddingLeft"
:x2="chartWidth - paddingRight"
:y1="getY(yTick)"
:y2="getY(yTick)"
/>
</g>
<!-- Shaded Normal Range Corridor (P15 to P85) -->
<path
:d="normalBandPath"
fill="#ec4899"
fill-opacity="0.08"
/>
<!-- P3 Curve (Lower bound) -->
<path
:d="buildPath('p3')"
fill="none"
stroke="#f43f5e"
stroke-width="1.2"
stroke-dasharray="3 3"
stroke-opacity="0.6"
/>
<!-- P15 Curve -->
<path
:d="buildPath('p15')"
fill="none"
stroke="#ec4899"
stroke-width="1"
stroke-dasharray="2 2"
stroke-opacity="0.4"
/>
<!-- P50 Median Curve (Prominent) -->
<path
:d="buildPath('p50')"
fill="none"
stroke="#a855f7"
stroke-width="2.5"
stroke-linecap="round"
/>
<!-- P85 Curve -->
<path
:d="buildPath('p85')"
fill="none"
stroke="#ec4899"
stroke-width="1"
stroke-dasharray="2 2"
stroke-opacity="0.4"
/>
<!-- P97 Curve (Upper bound) -->
<path
:d="buildPath('p97')"
fill="none"
stroke="#f43f5e"
stroke-width="1.2"
stroke-dasharray="3 3"
stroke-opacity="0.6"
/>
<!-- Y-Axis Labels -->
<g class="fill-zinc-500 text-[10px] font-mono" text-anchor="end">
<template v-if="activeMetric === 'weight'">
<text :x="paddingLeft - 6" :y="getY(14) + 3">14kg</text>
<text :x="paddingLeft - 6" :y="getY(10) + 3">10kg</text>
<text :x="paddingLeft - 6" :y="getY(6) + 3">6kg</text>
<text :x="paddingLeft - 6" :y="getY(2) - 2">2kg</text>
</template>
<template v-else>
<text :x="paddingLeft - 6" :y="getY(85) + 3">85cm</text>
<text :x="paddingLeft - 6" :y="getY(70) + 3">70cm</text>
<text :x="paddingLeft - 6" :y="getY(55) + 3">55cm</text>
</template>
</g>
<!-- Month X-Axis Ticks & Labels -->
<g class="fill-zinc-500 text-[9px] font-mono" text-anchor="middle">
<template v-for="m in [0, 2, 4, 6, 8, 10, 12, 15, 18]" :key="m">
<line
:x1="getX(m)"
:x2="getX(m)"
:y1="chartHeight - paddingBottom"
:y2="chartHeight - paddingBottom + 4"
stroke="#3f3f46"
/>
<text :x="getX(m)" :y="chartHeight - paddingBottom + 16">{{ m }}m</text>
</template>
</g>
<!-- Vertical Scrubber Guideline -->
<line
v-if="activeMonthHover !== null"
:x1="getX(activeMonthHover)"
:x2="getX(activeMonthHover)"
:y1="paddingTop"
:y2="chartHeight - paddingBottom"
stroke="#ffffff"
stroke-width="1.5"
stroke-dasharray="2 2"
stroke-opacity="0.4"
/>
<!-- Mila's Actual Measurement Markers -->
<g v-for="pt in plottedPoints" :key="pt.id">
<!-- Outer glow -->
<circle
:cx="pt.cx"
:cy="pt.cy"
r="8"
fill="#ec4899"
fill-opacity="0.25"
class="animate-pulse"
/>
<!-- Core dot -->
<circle
:cx="pt.cx"
:cy="pt.cy"
r="4.5"
fill="#f43f5e"
stroke="#ffffff"
stroke-width="2"
/>
</g>
</svg>
</div>
<!-- Science Legend & Notes -->
<div class="pt-3 border-t border-zinc-800/60 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<span class="w-3.5 h-1 rounded bg-purple-500 shrink-0"></span>
<span class="text-zinc-300 font-medium">P50 (Median)</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3.5 h-2.5 rounded bg-pink-500/20 border border-pink-500/40 shrink-0"></span>
<span class="text-zinc-300 font-medium">P15–P85 (Normalspanne)</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-2.5 h-2.5 rounded-full bg-rose-500 border border-white shrink-0"></span>
<span class="text-pink-300 font-medium">Mila Messpunkt</span>
</div>
</div>
<div class="text-[11px] text-zinc-400">
WHO Child Growth Standards (WHO MGRS)
</div>
</div>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
/// <reference types="vite/client" />
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
// Client-side canonical path enforcement
if (typeof window !== 'undefined' && (window.location.pathname === '' || window.location.pathname === '/')) {
window.location.replace('/tracking/mila/')
}
createApp(App).mount('#app')
// Reliable Service Worker registration (catches readyState complete and load)
if ('serviceWorker' in navigator) {
const registerSW = () => {
navigator.serviceWorker
.register('/tracking/mila/sw.js', { scope: '/tracking/mila/' })
.then((reg) => {
// Check for updates
reg.update().catch(() => {})
})
.catch((err) => console.warn('Mila Service Worker registration failed:', err))
}
if (document.readyState === 'complete') {
registerSW()
} else {
window.addEventListener('load', registerSW)
}
}
+16
View File
@@ -0,0 +1,16 @@
@import "tailwindcss";
@layer base {
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-tap-highlight-color: transparent;
}
.font-heading {
font-family: 'Syne', sans-serif;
}
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
}
+173
View File
@@ -0,0 +1,173 @@
export interface NapSession {
id: string
start: string
end: string
sleep_type: string
location: string
note: string
duration_minutes: number | null
is_open: boolean
}
export interface FeedingItem {
id?: string
time: string
consumed_ml: number | null
milk_type: string
item: string
solid_g: number | null
kcal: number | null
kind: string
note: string
}
export interface DiaperItem {
id?: string
time: string
pee: boolean
poo: boolean
texture: string
note: string
}
export interface HistoryRow {
date: string
weekday: string
formatted_date: string
is_today: boolean
total_hours: number
night_minutes?: number
nap_minutes?: number
night_duration_str: string
nap_str: string
feed_bottles_ml: number
feed_bottles_count: number
feed_solid_g: number
feed_solid_count: number
diaper_changes: number
diaper_dirty: number
sessions: {
id?: string
start: string
end: string
type: string
duration_minutes: number | null
is_open: boolean
note: string
location?: string
}[]
}
export interface GrowthMeasurement {
id: string
date_str: string
date_pretty: string
age_str: string
age_months: number
weight_kg: number | null
weight_pct: string
height_cm: number | null
height_pct: string
head_circ_cm: number | null
note: string
}
export interface DayDetailData {
date: string
weekday: string
sleep_sessions: NapSession[]
feedings: FeedingItem[]
diapers: DiaperItem[]
growth?: GrowthMeasurement[]
}
export interface AgeNorms {
category: string
sweet_spot_wake_window_min: [number, number]
total_sleep_norm_hours: [number, number]
night_sleep_norm_hours: [number, number]
nap_count_norm: [number, number]
feed_interval_hours: [number, number]
guidelines_reference: string
}
export interface MilaDashboardData {
status: string
execution_time_ms: number
git_status: string
git_conflict: boolean
metadata: {
subject: string
today_date: string
today_pretty: string
age_str: string
}
live_status: {
is_sleeping: boolean
active_sleep_id: string | null
active_sleep_duration_str: string
active_sleep_start_time: string
active_sleep_iso: string
wake_window_minutes: number | null
wake_window_str: string
last_wake_time_str: string
last_wake_iso: string
last_feed_iso?: string | null
last_feed_time_str?: string | null
last_feed_time?: string | null
last_feed_since_str?: string | null
last_feed_since_minutes?: number | null
last_feed_elapsed_minutes?: number | null
last_feed_summary?: string | null
last_feed_item?: string | null
last_feed_amount?: number | null
last_diaper_iso?: string | null
last_diaper_time_str?: string | null
last_diaper_time?: string | null
last_diaper_since_str?: string | null
last_diaper_since_minutes?: number | null
last_diaper_elapsed_minutes?: number | null
last_diaper_type?: string | null
last_stool_iso?: string | null
last_stool_str?: string | null
last_stool_time?: string | null
last_stool_elapsed_minutes?: number | null
}
age_norms?: AgeNorms
today: {
total_sleep_hours_str: string
nap_count: number
nap_minutes: number
night_duration_str: string
naps: NapSession[]
nights: NapSession[]
feedings: FeedingItem[]
feeding_summary: {
bottle_count: number
total_consumed_ml: number
solid_count: number
total_solid_g: number
total_kcal: number
}
diapers: DiaperItem[]
diaper_summary: {
total_changes: number
wet_count: number
dirty_count: number
}
}
averages: {
avg_7_tot: string
avg_7_night: string
avg_7_naps: string
avg_30_tot: string
avg_7_feeding_ml?: number
avg_7_solid_g?: number
avg_7_diapers?: number
}
history: HistoryRow[]
growth: {
measurements: GrowthMeasurement[]
latest: GrowthMeasurement | null
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
base: '/tracking/mila/',
plugins: [
vue(),
tailwindcss(),
],
server: {
port: 5173,
proxy: {
'/duckdb-api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md — Moritz Tracking App
## Development
```bash
cd projects/moritz
npm install
npm run dev
npm run build
```
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/ /usr/share/nginx/html/
RUN mkdir -p /usr/share/nginx/html/tracking && ln -s /usr/share/nginx/html /usr/share/nginx/html/tracking/moritz
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Some files were not shown because too many files have changed in this diff Show More