Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9903194316 | ||
|
|
28eedea372 | ||
|
|
a34fa7ee08 | ||
|
|
c33b6e94fb | ||
|
|
dd60a095c6 | ||
|
|
7ce1da18ab | ||
|
|
5b8432caf4 | ||
|
|
ef50d87bd7 | ||
|
|
ed3529a9d7 | ||
|
|
f4d8210cc4 | ||
|
|
02fcbd3174 | ||
|
|
a0c8c3828c | ||
|
|
cab8dd290f | ||
|
|
c85a60d920 | ||
|
|
a2cf3bfe39 | ||
|
|
d84d7b0bec | ||
|
|
6acceec701 | ||
|
|
972d629df8 | ||
|
|
a12b596053 | ||
|
|
393d928b19 | ||
|
|
8f01b75bd6 | ||
|
|
4831147087 | ||
|
|
3b190988be | ||
|
|
f225d4fe4e | ||
|
|
ebade04e81 | ||
|
|
0c48b2e9c1 | ||
|
|
978fdae051 | ||
|
|
9e52e279dd | ||
|
|
71188a5117 | ||
|
|
692bcc2084 | ||
|
|
6b3b024726 | ||
|
|
b45ca01e8e | ||
|
|
86030cf474 | ||
|
|
4705de9602 | ||
|
|
4d30c654e9 | ||
|
|
e59297a404 | ||
|
|
dc96a1a547 | ||
|
|
ccfce0fa5d | ||
|
|
fbc04f8892 | ||
|
|
68c8dd91b9 | ||
|
|
d778883fe3 | ||
|
|
6ae70d8ade | ||
|
|
ac6adcd0f4 | ||
|
|
4f51fb6a83 | ||
|
|
bb5ca769db | ||
|
|
5b3dfe2a50 | ||
|
|
6ea08ea497 | ||
|
|
0cbd39d0d8 | ||
|
|
299343798f | ||
|
|
abba4d6da1 | ||
|
|
2d2dfd94c6 | ||
|
|
a590d190bd | ||
|
|
45562c23d9 | ||
|
|
4a63e273fc | ||
|
|
36e0887c73 | ||
|
|
aeea7a8fea | ||
|
|
e0ee1dc7b4 | ||
|
|
53f132d446 | ||
|
|
a0e461a0b7 | ||
|
|
4e2a7120d6 | ||
|
|
bfa8ee19b7 |
@@ -0,0 +1,231 @@
|
||||
---
|
||||
name: openclaw-ops
|
||||
license: Apache-2.0
|
||||
description: Diagnose, troubleshoot, compact, and recover OpenClaw multi-agent sessions, SQLite state, and Telegram topic channels. Use when investigating why a Telegram topic is silent or not answering, inspecting session nodes and transcript events in SQLite, recovering wedged or locked sessions, running multi-agent transcript compactions, checking and clearing database quarantines or lockfiles, pre-warming Linux page cache on mechanical HDDs, or sending out-of-band direct messages to Telegram.
|
||||
---
|
||||
|
||||
# OpenClaw Operations & Troubleshooting Runbook
|
||||
|
||||
> **Target Environment**: Kubernetes cluster `haumdaucher`, namespace `openclaw`, pod `openclaw-0`.
|
||||
> **Key References**: [`k8s/openclaw/AGENTS.md`](../../../k8s/openclaw/AGENTS.md) and [`k8s/openclaw/TROUBLESHOOTING.md`](../../../k8s/openclaw/TROUBLESHOOTING.md).
|
||||
|
||||
---
|
||||
|
||||
## 0. Instant One-Shot Triage (< 2 Seconds)
|
||||
|
||||
Whenever investigating an issue in a Telegram forum topic or agent, **run the one-shot triage tool FIRST**.
|
||||
Do NOT run piecemeal SQLite queries, guess column names, or scan the root filesystem.
|
||||
|
||||
### Primary Command:
|
||||
```bash
|
||||
# By Telegram forum topic number (e.g. 21=Kochen, 22=Mila, 117=Research)
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 /home/node/.openclaw/scripts/triage.py --topic <topicNumber>
|
||||
|
||||
# Or by agent name
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 /home/node/.openclaw/scripts/triage.py --agent <agentId>
|
||||
```
|
||||
|
||||
The script automatically reports:
|
||||
1. Ground-truth session status (`done`, `processing`, `running`) and token size.
|
||||
2. Inbound user prompt (including speech-to-text audio transcripts).
|
||||
3. Assistant execution trace: thinking excerpts, tool calls with arguments, tool errors (`❌`), and final text output.
|
||||
4. Instant anomaly flags: `[CRITICAL_CONTEXT_BLOAT >60k]`, `[HIGH_CONTEXT_TOKENS >20k]`, `[POSSIBLY_WEDGED_RUNNING]`, `[UNANSWERED USER MESSAGE]`.
|
||||
|
||||
### 🚫 Anti-Patterns & Operational Guardrails:
|
||||
- ❌ **NEVER run `find /` or broad `grep -r` on the root container filesystem.** Host `haumdaucher` uses spinning mechanical HDDs (`rotational: 1`). Root sweeps take 5+ minutes, freeze I/O, and trigger background tasks.
|
||||
- ❌ **NEVER guess SQLite table or column names.**
|
||||
- **`session_nodes`**: `(session_key, current_session_id, status, updated_at, entry_json)`
|
||||
- **`transcript_events`**: `(session_id, seq, event_json, created_at)`
|
||||
- 📍 **Known Path Anchors (Use Directly):**
|
||||
- Knowledge Wiki: `/home/node/.openclaw/wiki/main`
|
||||
- Agent SQLite DBs: `/home/node/.openclaw/agents/<agent>/agent/openclaw-agent.sqlite`
|
||||
- Gateway State DB: `/home/node/.openclaw/state/openclaw.sqlite`
|
||||
- Agent Workspace: `/home/openclaw/.openclaw/workspace-<agent>`
|
||||
- Standalone Triage Script: `/home/node/.openclaw/scripts/triage.py` (Repo: `k8s/openclaw/scripts/openclaw-triage.py`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Diagnostic Playbook: "Bot is Not Answering / Silent"
|
||||
|
||||
When a user reports that a Telegram forum topic or direct chat is unresponsive:
|
||||
|
||||
### ⚠️ Golden Rule: Do NOT Assume Container Crash
|
||||
Long turns (multi-step tools, deep research, or inline compaction) can cause multi-minute delays while the container is perfectly healthy (`5/5 Running`).
|
||||
|
||||
### Step 1: Check Live Logs for In-Flight Turns
|
||||
```bash
|
||||
kubectl logs openclaw-0 -n openclaw -c openclaw --tail=100
|
||||
```
|
||||
Look for:
|
||||
- `compact:start reason=auto-threshold`: Inline safeguard compaction in progress (takes 30–60s).
|
||||
- `embedded run tool start ... tool=write|exec`: Agent is actively executing tool steps.
|
||||
- `[model-fetch] start ... status=200`: Waiting on LLM token streaming.
|
||||
- `heartbeat: webhooks=... active=X`: Active turn count in gateway.
|
||||
|
||||
### Step 2: Query Ground-Truth Session Status in SQLite
|
||||
Each agent maintains its own SQLite database at `/home/node/.openclaw/agents/<agent>/agent/openclaw-agent.sqlite`.
|
||||
```bash
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||
import sqlite3, glob
|
||||
for db in sorted(glob.glob('/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite')):
|
||||
agent = db.split('/')[5]
|
||||
con = sqlite3.connect(db)
|
||||
rows = con.execute('SELECT session_key, status, active_writer_run_id, updated_at FROM session_nodes WHERE session_key LIKE \"%topic:20%\"').fetchall()
|
||||
if rows:
|
||||
print(f'[{agent}]', rows)
|
||||
"
|
||||
```
|
||||
- If `status == "processing"`: Turn is actively executing.
|
||||
- If `status == "done"`: Turn completed. Check outbound Telegram dispatch.
|
||||
- If `status == "running"` with old `updated_at`: Session is wedged (see Recovery below).
|
||||
|
||||
### Step 3: Inspect Recent Transcript Events
|
||||
```bash
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||
import sqlite3, json
|
||||
con = sqlite3.connect('/home/node/.openclaw/agents/<agent>/agent/openclaw-agent.sqlite')
|
||||
sid = con.execute('SELECT current_session_id FROM session_nodes WHERE session_key = ?', ('<sessionKey>',)).fetchone()[0]
|
||||
for seq, ca, ej in con.execute('SELECT seq, created_at, substr(event_json, 1, 250) FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT 5', (sid,)).fetchall():
|
||||
print(seq, ca, ej)
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Session Recovery & Lock Clearing
|
||||
|
||||
### Un-wedge a Stuck Session Node
|
||||
If a turn was killed mid-execution (e.g. OOMKill or node reboot):
|
||||
```bash
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||
import sqlite3
|
||||
con = sqlite3.connect('/home/node/.openclaw/agents/<agent>/agent/openclaw-agent.sqlite')
|
||||
con.execute('UPDATE session_nodes SET status = \"done\", active_writer_run_id = NULL, entry_valid = 1 WHERE session_key = ?', ('<sessionKey>',))
|
||||
con.commit()
|
||||
print('Session un-wedged successfully.')
|
||||
"
|
||||
```
|
||||
|
||||
### Clean Stale State Locks
|
||||
```bash
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- rm -f /tmp/openclaw-1000/gateway.state.lock /tmp/.openclaw-1000/gateway.state.lock
|
||||
```
|
||||
|
||||
### Check Foreign Key Violations & Clear Quarantine
|
||||
```bash
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||
import sqlite3, glob
|
||||
for db in glob.glob('/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite'):
|
||||
con = sqlite3.connect(db)
|
||||
fk = con.execute('PRAGMA foreign_key_check;').fetchall()
|
||||
if fk:
|
||||
print(f'FK violations in {db}:', fk)
|
||||
"
|
||||
# Clear quarantine table if present
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||
import sqlite3, os
|
||||
p = '/home/node/.openclaw/state/openclaw-quarantine.sqlite'
|
||||
if os.path.exists(p):
|
||||
c = sqlite3.connect(p)
|
||||
c.execute('DELETE FROM quarantined_databases')
|
||||
c.commit()
|
||||
print('Quarantine cleared.')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Multi-Agent Compaction & Transcript Bloat Control
|
||||
|
||||
### Pitfall: Stale Sessions Report `totalTokens: null`
|
||||
`openclaw sessions --all-agents --json` returns `totalTokens: null` (`totalTokensFresh: false`) for sessions that haven't received a message in several days. **Always inspect SQLite directly**:
|
||||
```bash
|
||||
# Check size of active sessions across all agents
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||
import glob, sqlite3, json
|
||||
for db in sorted(glob.glob('/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite')):
|
||||
agent = db.split('/')[5]
|
||||
con = sqlite3.connect(db)
|
||||
for skey, sid, ej in con.execute('SELECT session_key, current_session_id, entry_json FROM session_nodes WHERE session_key LIKE \"%:telegram:%\"').fetchall():
|
||||
cnt, bsz = con.execute('SELECT count(*), coalesce(sum(length(event_json)), 0) FROM transcript_events WHERE session_id=?', (sid,)).fetchone()
|
||||
if bsz > 300000 or cnt > 40:
|
||||
print(f'{agent:12} {bsz//1024:6} KB {cnt:4} events {skey}')
|
||||
"
|
||||
```
|
||||
|
||||
### Manually Compact a Stale or Bloated Session
|
||||
```bash
|
||||
# LLM summarization with 3-minute timeout
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- openclaw sessions compact "<sessionKey>" --agent <agentId> --timeout 180000
|
||||
|
||||
# Truncation fallback (if LLM summarization times out)
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- openclaw sessions compact "<sessionKey>" --agent <agentId> --max-lines 80 --timeout 180000
|
||||
```
|
||||
|
||||
### Ensure User Compaction Notification is Enabled
|
||||
To prevent silent user-perceived freezes during daytime turns:
|
||||
```bash
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- openclaw config set agents.defaults.compaction.notifyUser true --strict-json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Mechanical HDD / Page Cache Pre-Warming
|
||||
|
||||
Node `haumdaucher` uses spinning HDDs (`rotational: 1`). Cold database opens take up to 20–50 minutes without page cache warming.
|
||||
|
||||
### Warm Page Cache Instantly (<1s)
|
||||
```bash
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- /bin/sh -c \
|
||||
"cat /home/node/.openclaw/state/*.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true"
|
||||
```
|
||||
|
||||
### WAL Checkpoints & SQLite Optimization
|
||||
```bash
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c "
|
||||
import glob, sqlite3
|
||||
dbs = ['/home/node/.openclaw/state/openclaw.sqlite'] + glob.glob('/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite')
|
||||
for db in dbs:
|
||||
con = sqlite3.connect(db)
|
||||
con.execute('PRAGMA wal_checkpoint(TRUNCATE);')
|
||||
con.execute('PRAGMA optimize;')
|
||||
con.close()
|
||||
print('WAL checkpoints and optimizations complete.')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Emergency Telegram Direct Message Bypass
|
||||
|
||||
If OpenClaw's turn engine failed to send a message or an urgent human notification is required without spawning a conversation turn:
|
||||
```bash
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- openclaw message send \
|
||||
--channel telegram \
|
||||
--target "-1004326921510" \
|
||||
--thread-id <topicNumber> \
|
||||
--message "Dein Nachrichtentext hier..."
|
||||
```
|
||||
*(Common topics: Sport = 20, Kochen = 21, Mila = 22, Travel = 51, Shopping = 115, Regensburg = 116, Research = 117, Medizin = 118, IT Guy = 119, Media = 164).*
|
||||
|
||||
---
|
||||
|
||||
## 6. Post-Troubleshooting Evolution Loop & Simplicity Invariant
|
||||
|
||||
At the end of **every single troubleshooting task, incident investigation, or bugfix**:
|
||||
|
||||
### 🔄 Mandatory Dual-Reflection
|
||||
The AI agent MUST proactively evaluate and document two distinct vectors of improvement before concluding:
|
||||
1. **Troubleshooting Process Optimization**:
|
||||
- Did gathering information or isolating the root cause take too long?
|
||||
- Did any query fail due to syntax, guessing, or slow filesystem sweeps?
|
||||
- What new diagnostic fastpath, schema shortcut, or check should be codified into `openclaw-ops` or `triage.py` so the next diagnosis takes seconds?
|
||||
2. **System Maturity & Long-Term Hardening**:
|
||||
- What fragile assumption or missing guardrail allowed this error to surface?
|
||||
- How can the prompt, schema, datastore script, or cronjob be hardened permanently so this class of error never recurs?
|
||||
|
||||
### ⚖️ The Simplicity Invariant (The 98% Rule)
|
||||
- **Simplicity and clean, transparent architecture are the central design principles.**
|
||||
- Always prefer a simple, pragmatic, easily debuggable solution that covers **98% of the goal** over an intricate, over-engineered abstraction that attempts 100%.
|
||||
- Avoid speculative background tasks, complex event chains, or hidden state manipulations. Single-responsibility scripts, transparent alerts, and direct deterministic data flows always take precedence.
|
||||
|
||||
@@ -2,3 +2,10 @@ k8s/gitea-chart/
|
||||
.vagrant
|
||||
k8s/tekton-pipelines/auth
|
||||
.DS_Store
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
dist/
|
||||
**/dist/
|
||||
*.tsbuildinfo
|
||||
.venv/
|
||||
**/__pycache__/
|
||||
|
||||
@@ -14,36 +14,28 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
|
||||
|
||||
### 2. [Kubernetes Workloads](./k8s/AGENTS.md) (`k8s/`)
|
||||
* **Purpose**: Deploying applications and services to the cluster.
|
||||
* **Method**: GitOps-style directory structure (Namespace = Folder).
|
||||
* **Constraints**: Enforces `ingress-nginx`, `cert-manager` (Let's Encrypt), and `git-crypt` for secrets.
|
||||
* **Strict Namespace Rule**: **Every top-level subdirectory in `k8s/` strictly corresponds to a Kubernetes namespace.** Non-namespace folders, loose scripts, or documentation do not belong here.
|
||||
* **Constraints**: Enforces `ingress-nginx` / `traefik`, `cert-manager` (Let's Encrypt), and `git-crypt` for secrets.
|
||||
* **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)
|
||||
|
||||
### 3. Terraform (`terraform/`)
|
||||
* **Purpose**: Infrastructure as Code for external resources, primarily **DNS**.
|
||||
* **Provider**: OVH (`ovh/ovh`).
|
||||
* **Scope**:
|
||||
* **DNS Zones**: `haumdaucher.de`, `moritzgraf.de`, `ameliegraf.de`, and others.
|
||||
* **Mail Config**: Google Workspace MX/SPF/DKIM records.
|
||||
* **Cluster IP**: Points domains to the Haumdaucher node IP (`136.243.23.215`).
|
||||
* **Usage**:
|
||||
```bash
|
||||
cd terraform
|
||||
terraform init
|
||||
terraform apply
|
||||
```
|
||||
### 3. [Terraform](./terraform/AGENTS.md) (`terraform/`)
|
||||
* **Purpose**: Declarative core cloud infrastructure as code.
|
||||
* **DNS Zones**: `haumdaucher.de`, `moritzgraf.de`, `ameliegraf.de`, and others via OVH (`ovh/ovh`).
|
||||
* **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 && terraform apply`
|
||||
* **Read**: [terraform/AGENTS.md](./terraform/AGENTS.md)
|
||||
|
||||
### 4. Monitoring (`monitoring/`)
|
||||
* **Purpose**: IoT and physical device monitoring, separate from K8s metrics.
|
||||
* **Purpose**: Physical ambient BLE sensor fleet monitoring across living areas.
|
||||
* **Tool**: [ESPHome](https://esphome.io).
|
||||
* **Devices**: Temperature sensors, plant monitors (Xiaomi/BLE), ESP32 bridges.
|
||||
* **Devices**: Temperature & humidity sensors, Xiaomi BLE plants, ESP32 bridges (`muc-kitchen`, `muc-living`, `rgbg-living`).
|
||||
* **Usage**:
|
||||
```bash
|
||||
cd monitoring
|
||||
# Flash or update a device
|
||||
esphome run config/<device>.secret.yml
|
||||
```
|
||||
* **Network**: Devices typically operate on static IPs in the `192.168.x.x` range.
|
||||
* **Read**: [monitoring/README.md](./monitoring/README.md)
|
||||
|
||||
### 5. Knowledge Wiki & OpenClaw Tasks (`knowledge-wiki/`)
|
||||
* **Purpose**: Shared personal knowledge wiki and task dispatch between OpenClaw (mobile/Telegram concierge) and Antigravity (local pair programming agent).
|
||||
@@ -56,7 +48,7 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
|
||||
3. Implement the chosen task in the appropriate repository files.
|
||||
4. Move the task to `## ✅ Completed Tasks` with completion details and commit info.
|
||||
5. Push back: `git -C knowledge-wiki commit -am "chore(tasks): complete <task>" && git -C knowledge-wiki push origin main`.
|
||||
- When Antigravity needs knowledge about family, home, servers, or setup:
|
||||
|
||||
### 6. Architecture Decision Records (`docs/adr/`)
|
||||
* **Purpose**: Formal record of architectural decisions, technical trade-offs, and design standards across the monorepo.
|
||||
* **Format**: [MADR](https://adr.github.io/madr/) (Markdown Architectural Decision Records).
|
||||
@@ -75,9 +67,63 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
|
||||
* **Purpose**: Personal homepage and portfolio for Moritz Graf (`https://moritzgraf.de`).
|
||||
* **Manifest**: `k8s/web/moritzgrafde.yaml` (Namespace: `web`).
|
||||
* **Image**: `registry.haumdaucher.de/moritzgrafde:latest`.
|
||||
* **Key Rules**: Executive portfolio built with Vue 3, Vite, TypeScript, and Tailwind CSS served via Nginx in Docker. Migrated from legacy Hugo to a modern reactive platform showcase.
|
||||
* **Key Rules**: Executive portfolio built with Vue 3, Vite, TypeScript, and Tailwind CSS served via Nginx in Docker.
|
||||
* **Read**: [projects/moritzgrafde/AGENTS.md](./projects/moritzgrafde/AGENTS.md)
|
||||
|
||||
### 9. [Haumdaucher Official Website](./projects/haumdaucherde/AGENTS.md) (`projects/haumdaucherde/`)
|
||||
* **Purpose**: Official community platform and club portal for Haumdaucher Regensburg (`https://haumdaucher.de`).
|
||||
* **Manifest**: `k8s/haumdaucher/haumdaucher.yaml` (Namespace: `haumdaucher`).
|
||||
* **Image**: `registry.haumdaucher.de/haumdaucher-website:latest`.
|
||||
* **Key Rules**: PWA built with Vue 3, Vite, TypeScript, and Firebase Auth; dynamic theming with NAT mode easter egg; strict compliance with German Vereinsrecht (§ 5 DDG Impressum for n.e.V.); user creation and mail forwarding Apps Script automations.
|
||||
* **Read**: [projects/haumdaucherde/AGENTS.md](./projects/haumdaucherde/AGENTS.md)
|
||||
|
||||
### 10. [Grabengasse 7 Home Portal](./projects/grabengasse/AGENTS.md) (`projects/grabengasse/`)
|
||||
* **Purpose**: Local climate, weather, and environmental dashboard for Grabengasse 7 (`https://grabengasse7.de`).
|
||||
* **Manifests**: `k8s/grabengasse/` (`frontend.yaml`, `backend.yaml`, `ingress.yaml` in namespace `grabengasse`).
|
||||
* **Images**: `registry.haumdaucher.de/website/grabengasse_frontend:latest`, `registry.haumdaucher.de/website/grabengasse_backend:latest`.
|
||||
* **Key Rules**: Vue 3 retro pixel art frontend + FastAPI backend querying cluster Prometheus for Shelly WS90 weather telemetry.
|
||||
* **Read**: [projects/grabengasse/AGENTS.md](./projects/grabengasse/AGENTS.md)
|
||||
|
||||
### 11. [Kitchen e-Paper Display](./projects/esphome_display_kitchen/AGENTS.md) (`projects/esphome_display_kitchen/`)
|
||||
* **Purpose**: Dedicated ESP32 microcontroller driving the Waveshare e-Paper status display in the kitchen.
|
||||
* **Integrations**: Home Assistant Native API (`input_text.epaper_message`), Prometheus client, SNTP time sync.
|
||||
* **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)
|
||||
|
||||
### 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.
|
||||
* **Key Tools**:
|
||||
* `scripts/htpasswd.py`: Generates bcrypt-hashed htpasswd credentials for ingress basic auth secrets.
|
||||
|
||||
### 17. Network & Router Integration (`fritzbox/`)
|
||||
* **Purpose**: Site-to-Site WireGuard LAN coupling and DynDNS OVH synchronization for the home FritzBox router.
|
||||
* **Cluster Endpoint**: `k8s/wireguard/` (`vpn.haumdaucher.de`).
|
||||
* **Read**: [fritzbox/README.md](./fritzbox/README.md)
|
||||
|
||||
## Global Constraints
|
||||
* **Secrets**: `git-crypt` is used extensively. Ensure you have the unlock key before editing `*.secret` files.
|
||||
* **Identity**:
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
# Infrapuzzle
|
||||
# Infrapuzzle 🧩
|
||||
|
||||
Infrapuzzle is the newly restructured way of implementing my private infrastructure. Currently the following things are in project scope:
|
||||
Infrapuzzle is the monorepo for personal infrastructure, Kubernetes cluster operations, DNS, IoT monitoring, and hosted applications.
|
||||
|
||||
* single node k8s "cluster" implemented with kubespray
|
||||
* development with Vagrant
|
||||
* host directory storage
|
||||
* Ingress via host network
|
||||
## Repository Layout
|
||||
|
||||
## terraform
|
||||
- **[`bootstrap/`](bootstrap/)**: Provisioning the single-node Kubernetes cluster (`haumdaucher`) using Kubespray.
|
||||
- **[`k8s/`](k8s/)**: Kubernetes workloads. **Every subdirectory represents a Kubernetes namespace.**
|
||||
- **[`terraform/`](terraform/)**: Declarative Infrastructure as Code for core cloud infrastructure (OVH DNS zones, Authentik SSO, Google Workspace, and GCP APIs).
|
||||
- **[`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/moritzgrafde/`: Personal homepage and executive portfolio (`moritzgraf.de`).
|
||||
- `projects/haumdaucherde/`: Official community website & portal (`haumdaucher.de`).
|
||||
- `projects/grabengasse/`: Climate & weather dashboard (`grabengasse7.de`).
|
||||
- `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.
|
||||
- **[`fritzbox/`](fritzbox/)**: Home router Site-to-Site WireGuard VPN coupling and DynDNS synchronization.
|
||||
- **[`scripts/`](scripts/)**: Operational utility scripts (e.g. `htpasswd.py`).
|
||||
- **[`knowledge-wiki/`](knowledge-wiki/)**: Shared personal knowledge base and OpenClaw task queue submodule.
|
||||
- **[`docs/`](docs/)**: Architecture Decision Records (ADRs) following the MADR standard.
|
||||
|
||||
[Documentation in subfolder](./terraform/README.md). It takes care of setting DNS records on OVH nameservers.
|
||||
|
||||
## bootstrap
|
||||
|
||||
[Documentation in subfolder](./bootstrap/README.md). It takes care of setting up the k8s cluster itself, including a vagrant dev environment.
|
||||
|
||||
## k8s
|
||||
|
||||
[Documentation in subfolder](./k8s/README.md). The services themselfes.
|
||||
|
||||
|
||||
|
||||
## Links used
|
||||
|
||||
* [ingress via host network](https://kubernetes.github.io/ingress-nginx/deploy/baremetal/#via-the-host-network)
|
||||
* [Install K8s with Kubespray](https://kubernetes.io/docs/setup/production-environment/tools/kubespray/)
|
||||
For architectural standards and operational guidelines, refer to [AGENTS.md](AGENTS.md).
|
||||
@@ -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
|
||||
decision-makers: ["Moritz Graf", "Antigravity"]
|
||||
consulted: []
|
||||
@@ -28,7 +28,7 @@ How do we architect the full-spectrum model selection across **all modalities**
|
||||
|
||||
## 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).
|
||||
* **Strict Modality Decoupling**: Each capability must route to its dedicated, best-in-class engine:
|
||||
- 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)
|
||||
- Web Search & Retrieval (grounded search)
|
||||
- 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.
|
||||
* **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,24 +60,25 @@ How do we architect the full-spectrum model selection across **all modalities**
|
||||
│ Tier 1 │ │ Tier 2 │ │ Tier 3 │ │ Tier 4 │ │ Tier 5 │
|
||||
│ • Low Latency │ │ • Deep Analysis │ │ • target: none │ │ • Nano Banana 2 │ │ • Whisper V3 │
|
||||
│ • 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: │
|
||||
│ z-ai/ │ │ z-ai/ │ │ z-ai/ │ │ google/ │ │ openai/ │
|
||||
│ glm-5.3-flash │ │ glm-5.3-flash │ │ glm-5.3-flash │ │ gemini-3.1- │ │ whisper-large- │
|
||||
│ │ │ │ │ │ │ flash-image │ │ v3-turbo │
|
||||
│ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ │ │
|
||||
│ 1. deepseek- │ │ 1. deepseek- │ │ 1. deepseek- │ │ Fallbacks: │ │ TTS: │
|
||||
│ v4.1-flash │ │ v4.1-flash │ │ 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. gemini-3-pro-│ │ flash-tts │
|
||||
│ Vision Fallback:│ │ │ │ │ │ image │ │ (FB: MiniMax 2.8│
|
||||
│ google/ │ │ │ │ │ │ │ │ Speech HD) │
|
||||
│ gemini-3.1- │ │ │ │ │ │ │ │ │
|
||||
│ flash-image │ │ │ │ │ │ │ │ │
|
||||
│ Primary: │ │ Primary: │ │ Primary: │ │ Primary: │ │ STT: │
|
||||
│ deepseek/ │ │ deepseek/ │ │ deepseek/ │ │ google/ │ │ openai/ │
|
||||
│ deepseek-v4.1- │ │ deepseek-v4.1- │ │ deepseek-v4.1- │ │ gemini-3.1- │ │ whisper-large- │
|
||||
│ flash-1017 │ │ flash-1017 │ │ flash-1017 │ │ flash-image │ │ v3-turbo │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ TTS: │
|
||||
│ 1. v4.1-flash │ │ 1. v4.1-flash │ │ 1. v4.1-flash │ │ 1. gemini-3.1- │ │ google/ │
|
||||
│ 2. glm-5.3-flash│ │ 2. glm-5.3-flash│ │ 2. glm-5.3-flash│ │ flash-preview│ │ gemini-3.1- │
|
||||
│ 3. qwen3.8-flash│ │ 3. qwen3.8-flash│ │ 3. qwen3.8-flash│ │ 2. gemini-3-pro-│ │ flash-tts │
|
||||
│ │ │ │ │ │ │ image │ │ (FB: MiniMax 2.8│
|
||||
│ Vision (OCR): │ │ │ │ │ │ │ │ Speech HD) │
|
||||
│ google/ │ │ │ │ │ │ │ │ │
|
||||
│ gemini-3.8- │ │ │ │ │ │ │ │ │
|
||||
│ flash │ │ │ │ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
@@ -86,31 +87,35 @@ How do we architect the full-spectrum model selection across **all modalities**
|
||||
## Modality Specifications & Cost-Benefit Analysis
|
||||
|
||||
### 1. Interactive Conductor & Agentic Reasoning (Tier 1)
|
||||
* **Primary (Active Trial)**: `openrouter/z-ai/glm-5.3-flash`
|
||||
- **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**.
|
||||
- **Performance & Reasoning**: SOTA Chinese & English reasoning, competitive coding benchmarks, and native vision perception.
|
||||
- **Forensic Diagnosis & Tool-Calling Protocol Fix**:
|
||||
- *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.
|
||||
- *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.
|
||||
- *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.
|
||||
* **Primary (Fleet-Wide Standard as of 2026-10-17)**: `openrouter/deepseek/deepseek-v4.1-flash-1017`
|
||||
- **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).
|
||||
- **Provider Pinning & Tool-Compatibility Verification**:
|
||||
- Pinned `order: ["deepinfra", "cloudflare", "inference-net", "fireworks", "parasail"]`, `quantizations: ["fp8", "bf16", "unknown"]`, and `ignore: ["morph", "baseten", "deepseek", "open-inference", "decart", "sail-research", "wafer"]`.
|
||||
- **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.
|
||||
- **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).
|
||||
- **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**:
|
||||
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.
|
||||
2. `openrouter/qwen/qwen3.8-flash`: Provider diversity.
|
||||
1. `openrouter/deepseek/deepseek-v4.1-flash` (`0910` baseline across 25+ providers, pinned to `deepinfra`, `streamlake`, `inference-net`, `digitalocean`, `venice`, `nebius`).
|
||||
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)
|
||||
* **General Subagents**: `openrouter/z-ai/glm-5.3-flash`
|
||||
- **Execution**: Spawned via `sessions_spawn` with a 300-second timeout. Inherits the `tool_call` protocol guardrails from `AGENTS.md`.
|
||||
* **Deep Analysis & IT Specialists (`itguy`, `recherche`)**: `openrouter/deepseek/deepseek-v4.1-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`.
|
||||
* **All Subagents & Specialists**: `openrouter/deepseek/deepseek-v4.1-flash-1017`
|
||||
- **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.
|
||||
* **Fallback**: `openrouter/deepseek/deepseek-v4.1-flash` / `openrouter/z-ai/glm-5.3-flash` / `openrouter/qwen/qwen3.8-flash`.
|
||||
|
||||
### 3. Background Daemon & Heartbeats (Tier 3)
|
||||
* **Configuration**: `heartbeat.model: "openrouter/z-ai/glm-5.3-flash"`, `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).
|
||||
* **Configuration**: `heartbeat.model: "openrouter/deepseek/deepseek-v4.1-flash-1017"`, `heartbeat.target: none`, `heartbeat.lightContext: true`.
|
||||
* **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)
|
||||
* **Crucial Distinction**:
|
||||
- **Vision Input (`imageModel`)**: Reading photos sent to Telegram (e.g. food ingredients, plants, receipts). Handled natively by `z-ai/glm-5.3-flash`, with `google/gemini-3.1-flash-image` 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.
|
||||
* **Image Synthesis Engine**:
|
||||
- **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
|
||||
|
||||
### 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.
|
||||
* **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
|
||||
* **OpenRouter Provider Variability**: GLM-5.3-Flash prefill throughput varies across OpenRouter hosting providers.
|
||||
* *Mitigation*: Daily compaction bounds transcript size; failover to DeepSeek V4.1-Flash is available at zero architectural switching cost.
|
||||
* **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*: 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.
|
||||
* **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.
|
||||
* **Scope**:
|
||||
- Shelly relays and smart switches provisioned declaratively via Terraform.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
status: "accepted"
|
||||
date: 2026-10-11
|
||||
decision-makers: ["Moritz Graf", "Antigravity"]
|
||||
consulted: []
|
||||
informed: []
|
||||
---
|
||||
|
||||
# ADR 0005: Personal Data Isolation, Authentik Authentication Boundaries, and Hosting Segregation
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
On 2026-10-11, during the development of mobile-first PWA dashboards for baby monitoring (Mila Graf) and personal nutrition/Garmin energy balance (Moritz Graf), personal health, sleep, diaper, and nutrition data was temporarily made available at unauthenticated root URLs:
|
||||
- `https://hosting.haumdaucher.de/mila.html`
|
||||
- `https://hosting.haumdaucher.de/moritz.html`
|
||||
|
||||
While [ADR 0004](0004-structured-personal-data-datastore.md) established the conceptual routing design (`/family/*` protected by Traefik ForwardAuth, public recipes at `/recipes/*`), a gap analysis revealed critical systemic vulnerabilities:
|
||||
1. **Unapplied Kubernetes Ingress:** The ForwardAuth Ingress (`minio-hosting-family`) declared in `k8s/minio/hosting-ingress.yaml` was never applied to the Kubernetes cluster.
|
||||
2. **Missing Authentik Identity Provider:** Authentik had no Proxy Provider or Application configured for `hosting.haumdaucher.de`, returning HTTP 404 on ForwardAuth checks.
|
||||
3. **Dual-Publishing Anti-Pattern:** Dashboard generator scripts (`update_website.py`, `update_moritz_website.py`) mirrored files to root (`/mila.html`, `/moritz.html`) for convenience, completely bypassing the planned `/family/` path boundary.
|
||||
4. **Lack of Automated Ingress & Storage Auditing:** No automated test or CI/Cron audit prevented personal data from being uploaded to public prefixes.
|
||||
|
||||
How do we establish a permanent, verifiable, and automated security boundary that guarantees personal and family data can never be exposed publicly without authentication?
|
||||
|
||||
---
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* **Zero Public Exposure for Personal Data:** Health, sleep, nutrition, body, and family data must NEVER be accessible without authentication.
|
||||
* **Defense-in-Depth:** Security must not rely on a single layer (e.g. secret URLs or developer memory). Protection must be enforced at identity provider, reverse proxy, storage bucket, and generator script levels.
|
||||
* **Declarative Infrastructure as Code (ADR 0003):** All Authentik providers, policies, groups, and Kubernetes Ingresses must be versioned in Git and applied via Terraform/Kubectl.
|
||||
* **Automated Guardrails & Fast Feedback:** Continuous audits must detect and quarantine any unauthorized files outside protected paths.
|
||||
* **Seamless PWA Experience:** The PWA capabilities (manifest, service worker, offline shell) must function within the authenticated cookie session without breaking ForwardAuth.
|
||||
|
||||
---
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen Strategy: **Multi-Layer Boundary Enforcement with ForwardAuth, Strict Storage Segregation, and Continuous Auditing**.
|
||||
|
||||
### 1. Storage & URL Path Segmentation Architecture
|
||||
|
||||
```
|
||||
Internet Request to https://hosting.haumdaucher.de
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Traefik Ingress │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
Path Inspection & Priority
|
||||
│
|
||||
┌────────────────┴────────────────┐
|
||||
│ │
|
||||
Path: /family/* Path: /* (Fallback)
|
||||
Priority: 100 Priority: 10
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ Authentik ForwardAuth │ │ Public Ingress │
|
||||
│ • Host: hosting.haumd...│ │ • ONLY /recipes/* │
|
||||
│ • Allowed: 'family' & │ │ • ONLY /showcase/* │
|
||||
│ 'infra-admins' groups │ │ • NO personal files │
|
||||
└────────────┬────────────┘ └────────────┬────────────┘
|
||||
│ │
|
||||
HTTP 200 (Authed) │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ MinIO Bucket │ │ MinIO Bucket │
|
||||
│ key: family/* │ │ key: public/* │
|
||||
│ (Protected Data) │ │ (Public Static) │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
#### Rule 1: Canonical Protected Prefix `/family/`
|
||||
- All dashboards containing personal, familial, or physiological metrics MUST reside under `/family/`:
|
||||
- `https://hosting.haumdaucher.de/family/mila.html` (Baby sleep, feeds, diapers)
|
||||
- `https://hosting.haumdaucher.de/family/moritz.html` (Personal nutrition, Garmin deficits, health)
|
||||
- `https://hosting.haumdaucher.de/family/nutrition.html` (Family nutrition overview)
|
||||
- `https://hosting.haumdaucher.de/family/sleep.html` (Sleep analytics)
|
||||
- Associated PWA assets (manifests, service workers) MUST also be scoped within `/family/` (e.g. `/family/mila.webmanifest`, `/family/sw.js`).
|
||||
|
||||
#### Rule 2: Root Prefix `/` Strictly Restricted
|
||||
- The root of `hosting.haumdaucher.de` is restricted to public static assets (`/recipes/*`, `/showcase/*`).
|
||||
- Publishing scripts MUST NEVER write personal dashboards to the bucket root.
|
||||
|
||||
---
|
||||
|
||||
### 2. Declarative Authentik ForwardAuth Configuration
|
||||
|
||||
Authentik configuration is codified in `terraform/authentik.tf`:
|
||||
1. **Proxy Provider (`authentik_provider_proxy.hosting_family`):**
|
||||
- External Host: `https://hosting.haumdaucher.de`
|
||||
- Mode: `forward_single`
|
||||
- Bound to `authentik_outpost.embedded`
|
||||
- Session duration: 30 days (`access_token_validity = "days=30"`)
|
||||
2. **Application (`authentik_application.hosting_family`):**
|
||||
- Slug: `hosting-family`
|
||||
- Protocol Provider: `authentik_provider_proxy.hosting_family.id`
|
||||
3. **Role-Based Policy Bindings:**
|
||||
- Group `family` (Moritz & Amelie Graf): Granted access
|
||||
- Group `infra-admins`: Granted access
|
||||
- Unauthenticated requests are redirected (HTTP 302) to `https://auth.haumdaucher.de/application/o/authorize/...`
|
||||
|
||||
---
|
||||
|
||||
### 3. Kubernetes Ingress Deployment
|
||||
|
||||
`k8s/minio/hosting-ingress.yaml` defines the two ingress tiers:
|
||||
- **`minio-hosting-family` (Priority 100):**
|
||||
- Paths:
|
||||
- `/family` (Prefix) -> Protected personal dashboards
|
||||
- `/outpost.goauthentik.io` (Prefix) -> Authentik ForwardAuth OAuth callback & session negotiation
|
||||
- Middlewares: `auth-authentik-forwardauth@kubernetescrd,minio-hosting-add-prefix@kubernetescrd`
|
||||
- **`minio-hosting` (Priority 10):**
|
||||
- Path: `/` (Prefix)
|
||||
- Middlewares: `minio-hosting-add-prefix@kubernetescrd`
|
||||
|
||||
---
|
||||
|
||||
### 4. Generator Scripts & Codebase Hardening
|
||||
|
||||
1. **Elimination of Root Duplication:**
|
||||
- `skills/datastore/scripts/update_website.py`: Removed all S3 upload and local sync calls to root `mila.html`. Target is strictly `family/mila.html`.
|
||||
- `skills/datastore/scripts/update_moritz_website.py`: Removed S3 upload and local sync calls to root `moritz.html`. Target is strictly `family/moritz.html` (and `family/nutrition.html`).
|
||||
2. **PWA Scope Alignment:**
|
||||
- Web App Manifests configure `start_url: "/family/mila.html"` and `scope: "/family/"`.
|
||||
- Service workers are registered at `/family/sw.js`.
|
||||
3. **Telegram Link Consistency:**
|
||||
- Agent prompts for `mila` (Topic 20) and `koch` (Topic 21) only broadcast the secure URL: `https://hosting.haumdaucher.de/family/...`.
|
||||
|
||||
---
|
||||
|
||||
### 5. Automated Continuous Leak Audit Guard
|
||||
|
||||
An automated audit check runs continuously (via OpenClaw periodic audit and datastore maintenance):
|
||||
- Inspects the MinIO `hosting` bucket root (`prefix=""`).
|
||||
- Detects any forbidden files matching patterns: `*mila*`, `*moritz*`, `*nutrition*`, `*sleep*`, `*schlaf*`, `*health*`, `*diaper*`.
|
||||
- If detected:
|
||||
1. Immediately deletes/quarantines the offending object from the public bucket root.
|
||||
2. Raises a critical notification/event to the system log.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Guaranteed Confidentiality:** Family and personal health data is physically impossible to access without passing Authentik ForwardAuth.
|
||||
- **Reproducible Infrastructure:** The entire authentication chain is codified in Terraform and Kubernetes manifests.
|
||||
- **Persistent Mobile PWA:** Authentik's 30-day session lifetime ensures seamless standalone mobile app operation without repetitive login prompts.
|
||||
- **Automated Safety Net:** Even if a developer or agent script mistakenly attempts to mirror a file to root, the automated audit deletes it.
|
||||
|
||||
### Negative & Mitigations
|
||||
- **Authentication Step on First Mobile Launch:**
|
||||
- *Mitigation*: When installing the PWA on iOS/Android, the user authenticates once via Authentik. The persistent session cookie maintains login across app launches.
|
||||
@@ -14,6 +14,7 @@ We follow the [Markdown Architectural Decision Records (MADR)](https://adr.githu
|
||||
| [**0002**](0002-openclaw-model-selection-tiering-and-economics.md) | **OpenClaw Multi-Tier Model Selection, Latency Optimization, and Inference Economics** | `proposed` | 2026-09-13 | OpenClaw / AI / Models |
|
||||
| [**0003**](0003-everything-as-code-and-reproducible-infrastructure.md) | **Everything as Code (EaC) and Reproducible Declarative Infrastructure** | `accepted` | 2026-09-17 | Architecture / Monorepo / EaC |
|
||||
| [**0004**](0004-structured-personal-data-datastore.md) | **Structured Personal Data Layer (DuckDB + JSONL in Knowledge Wiki)** | `accepted` | 2026-10-06 | OpenClaw / Data / Storage |
|
||||
| [**0005**](0005-personal-data-isolation-and-authentik-boundary.md) | **Personal Data Isolation, Authentik Authentication Boundaries, and Hosting Segregation** | `accepted` | 2026-10-11 | Security / Storage / Authentik |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# AGENTS.md (ESPHome Workspace)
|
||||
|
||||
This directory is a self-contained ESPHome project space utilizing dynamic configuration loaders and an interactive flashing CLI.
|
||||
|
||||
## Rules & Design Standards
|
||||
|
||||
### Secret Management
|
||||
* **NEVER** write plaintext Wi-Fi passwords, tokens, or API keys directly in the YAML configurations.
|
||||
* **DO NOT** modify the `.gitignore` to allow checking in `.env` files.
|
||||
* Use ESPHome's `!env_var VAR_NAME` to inject environment variables at compile-time. Any new secrets must be declared in `.env.template` and configured locally in `.env`.
|
||||
|
||||
### Configuration Standards
|
||||
* Default board architecture is ESP32 (`nodemcu-32s` with `arduino` framework) unless specified.
|
||||
* Naming conventions for configurations: `esdisplay_<name>.yaml` or `sensor_<name>.yaml`.
|
||||
* All displays should use the `time` component with SNTP sync for accurate time rendering.
|
||||
* Prefer importing sensors from Home Assistant over Native API client integrations, as it decouples rendering layouts from automation scripts.
|
||||
|
||||
### Build and Run Tools
|
||||
* Use `uv run flash.py` to compile, validate, clean, or flash ESPHome configurations.
|
||||
* Avoid triggering direct `esphome` cli calls unless doing isolated validation tests.
|
||||
@@ -11,8 +11,9 @@ This directory contains the Kubernetes manifests and Helm charts for a single-no
|
||||
* **SSL**: `cert-manager` (LetsEncrypt)
|
||||
|
||||
## Directory Structure
|
||||
* **Top-level folders**: Each folder corresponds to a Kubernetes **namespace**.
|
||||
* Example: `mailu/` contains resources for the `mailu` namespace.
|
||||
* **Strict Namespace Mapping**: **Every top-level subdirectory in `k8s/` MUST correspond strictly to a Kubernetes namespace.**
|
||||
* Example: `mailu/` contains resources for the `mailu` namespace, `grabengasse/` for `grabengasse`, `haumdaucher/` for `haumdaucher`, etc.
|
||||
* **NO non-namespace folders**: Loose utility scripts belong in the root `scripts/` directory, application source code belongs in `projects/`, and general documentation belongs in namespace READMEs or `docs/`.
|
||||
* **Documentation**: `README.md` is the **authoritative source** for deployment commands. Always check it before running commands.
|
||||
|
||||
## Code Style & Conventions
|
||||
|
||||
@@ -873,13 +873,13 @@ Grabengasse 7 home website and backend service deployed in the `grabengasse` nam
|
||||
# Login to local registry (if not already logged in)
|
||||
docker login registry.haumdaucher.de -u moritz -p "PPf10musSh!!"
|
||||
|
||||
# Build and push backend image
|
||||
cd website/grabengasse_backend
|
||||
make all
|
||||
# Build and push images via root projects/grabengasse Makefile
|
||||
cd projects/grabengasse
|
||||
make build push
|
||||
|
||||
# Build and push frontend image
|
||||
cd ../grabengasse_frontend
|
||||
make all
|
||||
# Or build individual components:
|
||||
cd projects/grabengasse/frontend && make all
|
||||
cd ../backend && make all
|
||||
```
|
||||
|
||||
### Deploy to Kubernetes
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: family
|
||||
labels:
|
||||
name: family
|
||||
@@ -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
|
||||
@@ -0,0 +1,71 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: haumdaucher-website
|
||||
namespace: haumdaucher
|
||||
labels:
|
||||
app: haumdaucher
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: haumdaucher
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: haumdaucher
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: registry-haumdaucher-de
|
||||
containers:
|
||||
- name: haumdaucher
|
||||
image: registry.haumdaucher.de/haumdaucher-website:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: haumdaucher-service
|
||||
namespace: haumdaucher
|
||||
spec:
|
||||
selector:
|
||||
app: haumdaucher
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: haumdaucher-ingress
|
||||
namespace: haumdaucher
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
kubernetes.io/ingress.class: nginx
|
||||
kubernetes.io/tls-acme: "true"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: haumdaucher.de
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: haumdaucher-service
|
||||
port:
|
||||
number: 80
|
||||
tls:
|
||||
- hosts:
|
||||
- haumdaucher.de
|
||||
secretName: haumdaucher-de-tls
|
||||
@@ -7,7 +7,40 @@ spec:
|
||||
addPrefix:
|
||||
prefix: /hosting
|
||||
---
|
||||
# Public Ingress for hosting.haumdaucher.de (Recipes, Public Assets)
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: hosting-redirect-mila
|
||||
namespace: minio
|
||||
spec:
|
||||
redirectRegex:
|
||||
regex: "^https?://([^/]+)/(?:family/)?(mila|sleep)\\.html.*"
|
||||
replacement: "https://family.haumdaucher.de/tracking/mila/"
|
||||
permanent: true
|
||||
---
|
||||
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
|
||||
kind: Ingress
|
||||
metadata:
|
||||
@@ -18,7 +51,7 @@ metadata:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
kubernetes.io/tls-acme: "true"
|
||||
traefik.ingress.kubernetes.io/router.priority: "10"
|
||||
traefik.ingress.kubernetes.io/router.middlewares: 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:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
@@ -37,8 +70,7 @@ spec:
|
||||
- hosting.haumdaucher.de
|
||||
secretName: hosting-haumdaucher-de-tls
|
||||
---
|
||||
# Protected Ingress for /family (Family Dashboards, Nutrition & Sleep Analytics)
|
||||
# Protected by Authentik ForwardAuth
|
||||
# Protected Ingress for /family (Redirects and Authentik Outpost)
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
@@ -49,7 +81,7 @@ metadata:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
kubernetes.io/tls-acme: "true"
|
||||
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:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
@@ -63,6 +95,13 @@ spec:
|
||||
name: minio
|
||||
port:
|
||||
number: 9000
|
||||
- path: /outpost.goauthentik.io
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: minio
|
||||
port:
|
||||
number: 9000
|
||||
tls:
|
||||
- hosts:
|
||||
- hosting.haumdaucher.de
|
||||
|
||||
@@ -57,7 +57,7 @@ The dashboard is exposed on your custom domain with TLS and basic authentication
|
||||
* **Username:** `admin`
|
||||
* **Password:** `moritz-z2m-2026`
|
||||
|
||||
*Note: You can change the basic auth password by running the `k8s/htpasswd.py` script on your machine to generate a new bcrypt hash, then updating the `users` value in `k8s/monitoring/zigbee2mqtt.secret.yaml`.*
|
||||
*Note: You can change the basic auth password by running the `scripts/htpasswd.py` script on your machine to generate a new bcrypt hash, then updating the `users` value in `k8s/monitoring/zigbee2mqtt.secret.yaml`.*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -57,10 +57,27 @@ To perform modifications or rotate api keys:
|
||||
```
|
||||
4. **Register / Sync Automations**: Run the automation setup scripts to register OpenClaw Cron tasks:
|
||||
```bash
|
||||
./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-itguy-cron.sh # Weekly health audit (Sun 03:00)
|
||||
./k8s/openclaw/setup-compaction-cron.sh # Nightly multi-agent session compaction (Daily 03:30)
|
||||
# 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
|
||||
@@ -88,6 +105,52 @@ OpenClaw connects to three foundational Git repositories:
|
||||
> [!IMPORTANT]
|
||||
> **Mandatory Bug & Incident Documentation**: Whenever a bug, outage, schema error, or operational regression is fixed in OpenClaw, **always document the incident in [TROUBLESHOOTING.md](./TROUBLESHOOTING.md)** with symptom, root cause, resolution, and how to avoid recurrence.
|
||||
|
||||
### 🛡️ Operational & Diagnostic Invariants (Learned Guidelines)
|
||||
|
||||
Whenever troubleshooting or maintaining OpenClaw, agents MUST adhere to these rules:
|
||||
|
||||
0. **Rapid Incident Triaging & Information Gathering (< 2s)**:
|
||||
- When any issue, silence, or strange bot behavior is reported, **run the one-shot triage tool first**:
|
||||
```bash
|
||||
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 /home/node/.openclaw/scripts/triage.py --topic <topicId>
|
||||
```
|
||||
- Do NOT run piecemeal SQLite queries, guess table/column names, or scan the container filesystem.
|
||||
- **Path Anchors (Never run `find /` or broad `grep -r` on rotational disk):**
|
||||
- Knowledge Wiki: `/home/node/.openclaw/wiki/main`
|
||||
- Agent Databases: `/home/node/.openclaw/agents/<agent>/agent/openclaw-agent.sqlite`
|
||||
- Gateway State DB: `/home/node/.openclaw/state/openclaw.sqlite`
|
||||
- Standalone Triage Script: `/home/node/.openclaw/scripts/triage.py` (Repo: `k8s/openclaw/scripts/openclaw-triage.py`)
|
||||
|
||||
1. **Verify Before Declaring Dead (Turn In-Flight Diagnostics)**:
|
||||
- When a user reports that a Telegram topic is "not answering" or "silent", **NEVER assume the container or gateway has crashed**.
|
||||
- Always inspect live pod logs (`kubectl logs openclaw-0 -n openclaw -c openclaw --tail=100`) and the agent's SQLite database (`session_nodes` and `transcript_events`) first.
|
||||
- Check whether a turn is currently executing: multi-turn tool loops or inline safeguard compaction (`compact:start`) can introduce multi-minute turn latency without any pod restart.
|
||||
|
||||
2. **Datastore Ground-Truth for Maintenance**:
|
||||
- High-level CLI summaries (`openclaw sessions --all-agents --json`) report `totalTokens: null` (`totalTokensFresh: false`) for stale sessions or after restarts.
|
||||
- Automated health checks and compaction scripts MUST query the SQLite database directly (`transcript_events` event count and byte size), triggering compaction when `bytes >= 300KB` or `events >= 40`, regardless of cached token values.
|
||||
|
||||
3. **Compaction User Visibility**:
|
||||
- Always ensure `agents.defaults.compaction.notifyUser: true` remains set in OpenClaw configuration. Live inline compactions must send visible status feedback (`🧹 Compacting context...`) to avoid perceived bot stalls.
|
||||
|
||||
4. **In-Pod Container Restart Self-Healing**:
|
||||
- Critical database unlocking, lockfile cleanup, and state recovery MUST run via runtime preflight (`NODE_OPTIONS="--import=..."`) or application startup hooks.
|
||||
- Never place critical self-healing logic exclusively in `initContainers`, as Kubelet container restarts inside an existing Pod completely bypass `initContainers`.
|
||||
|
||||
5. **Rotational Storage (HDD) Page-Cache Pre-Warming**:
|
||||
- Host `haumdaucher` uses rotational mechanical disks (`/dev/sda2`, `rotational: 1`).
|
||||
- Whenever the pod or container restarts, SQLite databases MUST be sequentially streamed to `/dev/null` (`cat ... > /dev/null`) to pre-warm the Linux page cache, avoiding single-threaded event loop starvation on cold seeks.
|
||||
|
||||
6. **Zero-Downtime Wiki Prompt Updates vs. Heavy Pod Rollouts**:
|
||||
- Subagent prompt instructions, tone, and tool guidelines live in `knowledge-wiki/agents/<name>.md`. Edits made there are read live by OpenClaw on git sync without any pod restart.
|
||||
- Avoid modifying `openclaw-instance.secret.yaml` unless modifying container limits, environment variables, or global operator configuration. A CR edit triggers a 7-init-container rolling update (~3 minutes).
|
||||
|
||||
7. **Continuous Troubleshooting Evolution & The Simplicity Invariant (The 98% Rule)**:
|
||||
- **Post-Fix Dual Reflection**: At the end of every troubleshooting cycle and fix, the AI MUST proactively evaluate:
|
||||
1. *Troubleshooting Optimization*: How can the diagnostic process be made faster or codified into `triage.py` / `openclaw-ops`?
|
||||
2. *System Maturity*: How can the root cause be hardened permanently to prevent recurrence?
|
||||
- **Simplicity First**: Simplicity and straightforward architecture are the central design principles. A simple, pragmatic solution that cleanly reaches **98% of the goal** is strictly preferred over an over-engineered abstraction attempting 100%. Never introduce background workers, multi-stage state machines, or silent fallbacks when a transparent single-step tool or direct alert suffices.
|
||||
|
||||
For known issues, resolved bugs, and specific failure playbooks, consult [TROUBLESHOOTING.md](./TROUBLESHOOTING.md).
|
||||
|
||||
### 1. Verify Deployment Health
|
||||
|
||||
@@ -27,6 +27,13 @@ This document captures fixed issues, operational quirks, and incident root-cause
|
||||
| **2026-10-05** | **State Deadlock & Session Corruption** | Telegram Topic 117 (`#Recherche`) wedged with `restart recovery claim changed` and `entry_valid = 0` after crash | `RESOLVED` | Repaired `session_nodes` and `session_windows` (restored `entry_valid = 1`, synced `updated_at`, cleared dead claim), purged 20 obsolete cron sessions (>498k tokens), and added `--max-lines 200` compaction fallback. |
|
||||
| **2026-10-06** | **Message Formatting & UX** | Subagents emitted raw HTML/XML comments (`<!-- DOMAIN_STATE: { ... } -->`) at the end of Telegram messages | `RESOLVED` | Removed obsolete `DOMAIN_STATE` State Bridge schema from all subagent prompts and Conductor config; replaced with clean Markdown output guardrails. |
|
||||
| **2026-10-06** | **Database Integrity & Reasoning Drop** | Pod stuck in `CrashLoopBackOff` (`quarantined_databases` on IT Guy DB FK check); Kochen topic dropped user message silently without text/tools | `RESOLVED` | Cleared 46 orphaned FK references across child tables, purged quarantine entry, unlocked wedged Topic 164 session, and hardened initContainer and nightly compaction script with automated SQLite self-healing. |
|
||||
| **2026-10-07** | **Disk I/O & In-Pod Restart Bypass** | Koch agent unresponsive after interrupted turn; container crash loop / freeze on in-pod restart due to bypassed initContainers | `RESOLVED` | Pre-warmed SQLite page cache, unlocked wedged session, recorded nutrition intake directly into DuckDB/Markdown, configured persistent preflight self-healing via `NODE_OPTIONS`, and relaxed probe deadlines. |
|
||||
| **2026-10-07** | **Compaction Latency & Live Stall** | Telegram `#Sport` (Topic 20) appeared unresponsive for >3 minutes after Moritz's message | `RESOLVED` | Identified 54s inline safeguard compaction + 35s prompt prep + 85s tool execution (181s total) caused by stale token metadata (`totalTokens: null`) slipping past nightly cron. Hardened `compact_sessions.py` with SQLite transcript inspection, lowered compaction threshold to 20k / 300KB, enabled `agents.defaults.compaction.notifyUser: true` for visible compaction notices on Telegram, and proactively compacted bloated sessions. |
|
||||
| **2026-10-09** | **Domain State & Reasoning Loop** | Telegram `#Mila` (Topic 22) messages stopped updating database; reasoning loops and timeouts on unclosed night session | `RESOLVED` | Identified unclosed 08.10 night session causing `z-ai/glm-5.3-flash` 4,000-word thinking loop. Closed session, backfilled naps, updated `datastore.py` to warn on open sessions without silent capping, supported stop by ID, and added prompt guardrails. |
|
||||
| **2026-10-10** | **Disk I/O & In-Pod Recovery Amnesia** | Telegram `#Recherche` (Topic 117) silent after voice note; probe killed pod; recovery marked wedged session done without replying | `RESOLVED` | Delivered research synthesis, decoupled `recherche.md` into Modus A/B, raised memory limit to 6Gi and probe timeout to 25s, scoped extraPaths to main agent, purged 1.8GB duplicate vector DBs (94% drop), and built automated turn-replay daemon with Telegram notice. |
|
||||
| **2026-10-11** | **Automation & Garmin Sync** | Garmin Connect sync stopped updating overnight; cron job missing from OpenClaw; untracked activities folder | `RESOLVED` | Identified `garmin-nightly-mirror` was never registered in OpenClaw; created missing `activities` directory, hardened `setup-garmin-cron.sh` with JSON declaration lookup and `--announce`, registered automation (Daily 03:30 Berlin), and verified full sync through 2026-10-11. |
|
||||
| **2026-10-12** | **Compaction Tuning & History Retention** | Session bloat during the day (~58k tokens) in Topic 21; tuning compaction strength to avoid binary choice between 0-token reset and bloat | `RESOLVED` | Configured `keepRecentTokens: 3000`, `recentTurnsPreserve: 2`, `maxActiveTranscriptBytes: 180kb`. Converted Tier A to deep-compaction with fallback, preserving conversational history while shrinking sessions from 30k-127k down to 1k-4k tokens. |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -510,3 +517,537 @@ This document captures fixed issues, operational quirks, and incident root-cause
|
||||
- Configure multi-tier model fallbacks per subagent so provider stalls or reasoning drops fail over seamlessly.
|
||||
- Rely on Prometheus alerting rules for instant notification of pod readiness and restart anomalies.
|
||||
|
||||
---
|
||||
|
||||
### Issue 17: Vision Multimodal Dropout in Kochen Agent & Image Model Modality Conflation
|
||||
|
||||
- **Date**: 2026-10-06
|
||||
- **Severity**: High (Dropped meal intake turn; user received: *„The tool run finished, but no final summary was produced...“*)
|
||||
- **Symptom**:
|
||||
Moritz sent a text meal report in Telegram Topic 21 (`#Kochen`):
|
||||
*„Ich habe gegessen einen Tomatensalat mit 300g Tomaten, 150g körniger frischkäse, 30g balsamico Essig, 5g öl“*.
|
||||
The bot executed multiple tool runs and finally answered:
|
||||
*„The tool run finished, but no final summary was produced. I did not repeat any completed actions.“*
|
||||
The intake event was never written to DuckDB or rendered in the nutrition log.
|
||||
- **Root Cause**:
|
||||
1. **Unresolved In-Context Turn**: In the active session transcript, the earlier message containing 4 packaging photos of *körniger Frischkäse* had ended without an assistant completion. When the user sent the Tomatensalat text, the agent attempted to fulfill both requests, invoking `view_image` to inspect the 4 cached images.
|
||||
2. **Image Model Modality Conflation**: In `agents.defaults.imageModel`, the fallback was declared as `openrouter/google/gemini-3.1-flash-image`. While named `flash-image`, this is Google's text-to-image raster generation model (`modality: text+image->text+image`), not an image understanding/vision model (`text+image->text`).
|
||||
3. **Empty Text Completion in GLM-5.3-Flash**: When 4 high-resolution images were loaded into context, `z-ai/glm-5.3-flash` produced reasoning tokens but emitted empty whitespace text (`" "`). OpenClaw treated this as an incomplete turn and attempted failover, but the configured image fallback model could not process chat completions.
|
||||
- **Resolution**:
|
||||
1. **Logged Intake Event**: Manually recorded the Tomatensalat into DuckDB via `datastore.py add` (485 g, 274.0 kcal, 19.3 g Eiweiß, 17.3 g KH, 12.4 g Fett) with timestamp `2026-10-06T20:43:00+02:00`.
|
||||
2. **Re-rendered Views**: Ran `render_views.py` to update `entities/nutrition-log/moritz/2026-10.md` and HTML dashboards, then committed and pushed to `knowledge-wiki`.
|
||||
3. **Telegram Confirmation**: Dispatched the exact confirmation message with meal macros and updated daily totals directly to Telegram Topic 21 using `openclaw message send --channel telegram --target "-1004326921510" --thread-id 21`.
|
||||
4. **Configured Dedicated Multimodal Vision Models**: Corrected `agents.defaults.imageModel` in `k8s/openclaw/openclaw-instance.secret.yaml` and ADR 0002:
|
||||
- Primary: `openrouter/google/gemini-3.8-flash`
|
||||
- Fallbacks: `openrouter/z-ai/glm-5.3-flash`.
|
||||
- **How to Avoid Recurrence**:
|
||||
- Never configure diffusion or raster generation models (`*-image` without chat/text output) as `imageModel` for visual input understanding.
|
||||
- Employ dedicated multimodal frontier models (Gemini Flash) with proven OCR and label comprehension for vision tasks.
|
||||
|
||||
---
|
||||
|
||||
### Issue 18: Spinning Disk I/O Bottlenecks, In-Pod Restart Bypass, and Gateway Preflight Self-Healing
|
||||
|
||||
- **Date**: 2026-10-07
|
||||
- **Severity**: Critical (Koch agent unresponsive after interrupted turn; container crash loop / freeze on in-pod restart)
|
||||
- **Symptom**:
|
||||
Moritz sent an audio message in Telegram Topic 21 (`#Kochen`):
|
||||
*„Ich frühstücke wieder meine Oats, bestehend aus 50 Gramm meiner eigenen Mischung, 160 Milliliter Oatley Hafermilch und 50 Gramm Früchte gefroren.“*
|
||||
The bot never responded. Subsequent messages in Topic 21 were completely ignored.
|
||||
- **Root Cause**:
|
||||
1. **Spinning Disk I/O Latency**: Host `haumdaucher` uses mechanical HDD (`/dev/sda2`, `rotational: 1`). Under heavy background disk operations (nightly backups, index generation), random 4KB read operations are throttled to ~70–100 IOPS, blocking Node.js synchronous operations.
|
||||
2. **Aggressive Liveness Probe Failure**: OpenClaw's liveness probe had a 120s tolerance window (`failureThreshold: 12, periodSeconds: 10`). An event-loop pause during disk I/O exceeded the deadline 12 consecutive times, causing Kubelet to kill the `openclaw` container mid-turn.
|
||||
3. **The InitContainer Bypass Trap**: When Kubelet restarts an application container inside an existing Pod, **`initContainers` do NOT re-run**. The session repair and quarantine clearing scripts situated exclusively in `initContainers` were completely bypassed!
|
||||
4. **Session Deadlock & Stale Lock**: Because container 1 was killed mid-turn:
|
||||
- The session node `agent:koch:telegram:group:-1004326921510:topic:21` was left with `status: "running"` and `activeWriterRunId: "..."`. OpenClaw's `reply-admission-ticket.js` rejected all subsequent incoming Telegram messages on that topic as locked.
|
||||
- Stale `/tmp/openclaw-1000/gateway.state.lock` caused container 2 to crash with `gateway.state.lock.reclaim: ENOENT`.
|
||||
5. **Cold Database Opening Delays**: Container 3 started cold and was forced to open all 12 agent SQLite databases (~1.9 GB total) directly from HDD seeks, blocking the event loop for up to 50 minutes.
|
||||
- **Resolution**:
|
||||
1. **Immediate State Recovery**:
|
||||
- Pre-warmed all SQLite database files into the Linux OS page cache via sequential streaming (`cat ... > /dev/null`), dropping read latency from 50 minutes to 312 milliseconds.
|
||||
- Unlocked the Koch session node and session window in SQLite (`status: "done"`, `activeWriterRunId: null`, `entry_valid = 1`).
|
||||
2. **Intake Logged & Views Rendered**:
|
||||
- Recorded the Oats breakfast into DuckDB via `datastore.py add --kind nutrition.intake` (260 g, ~311 kcal, 13.9 g Eiweiß, 37.9 g KH, 9.2 g Fett, 7.9 g Ballaststoffe, 0.24 g Salz) with `food_id = null` (composite dish) with timestamp `2026-10-07T07:55:00+02:00`.
|
||||
- Regenerated Markdown views (`entities/nutrition-log/moritz/2026-10.md`) and HTML dashboards with `render_views.py`, committed and pushed to `knowledge-wiki`.
|
||||
- Sent confirmation with complete macro totals directly to Telegram Topic 21 via `openclaw message send`.
|
||||
3. **Permanent Gateway Preflight Self-Healing (`NODE_OPTIONS`)**:
|
||||
- Configured `NODE_OPTIONS="--max-old-space-size=3072 --import=/home/node/.openclaw/scripts/gateway-preflight.mjs"`.
|
||||
- Because Node.js evaluates `NODE_OPTIONS` on **every single process launch**, `gateway-preflight.mjs` executes before OpenClaw gateway starts—even during in-pod container restarts where `initContainers` are bypassed.
|
||||
- Performs automatic lock cleanup, quarantine clearing, SQLite page cache pre-warming, and un-wedges any orphaned `running` sessions in <600ms.
|
||||
4. **Probe Hardening**:
|
||||
- Increased liveness tolerance window in `k8s/openclaw/openclaw-instance.secret.yaml` to 450s (`failureThreshold: 30, periodSeconds: 15, timeoutSeconds: 15`).
|
||||
- Increased startup probe timeout to 15s to tolerate transient event-loop spikes without false kills.
|
||||
5. **Nightly Compaction Cron Enhancement**:
|
||||
- Updated `setup-compaction-cron.sh` and `compact_sessions.py` to run session recovery, `PRAGMA optimize;`, and `VACUUM;` nightly to prevent database bloat and optimize query plans.
|
||||
- **How to Avoid Recurrence**:
|
||||
- Never place critical recovery logic solely in `initContainers`; use application runtime hooks or `NODE_OPTIONS` for in-pod restart resilience.
|
||||
- Set probe failure thresholds and timeouts with high tolerance on rotational HDD nodes.
|
||||
- Maintain page cache pre-warming for SQLite databases to convert slow random seek I/O into fast sequential streaming.
|
||||
|
||||
---
|
||||
|
||||
### Issue 19: Telegram Topic Latency & Inline Compaction Delays (Topic #Sport / Topic 20)
|
||||
|
||||
- **Date**: 2026-10-07
|
||||
- **Severity**: Medium (User-facing perceived stall of 3m03s in Telegram topic `#Sport`)
|
||||
- **Symptom**:
|
||||
Moritz sent a message in Telegram Topic 20 (`#Sport`) asking to adapt an existing Sprint Interval Training (SIT) workout:
|
||||
*„Hier ein Training dass wir vor einiger Zeit erstellt haben ich würde gerne die Intervalle etwas anpassen...“*
|
||||
The bot appeared completely unresponsive, leading Moritz to report that the bot was again not answering.
|
||||
- **Root Cause Analysis & Turn Breakdown**:
|
||||
1. **The Bot Was NOT Dead, But Executing with 181.8s Latency**:
|
||||
- Message received at **07:15:11 UTC** (`messageId=2195`).
|
||||
- Response delivered at **07:18:14 UTC** (`messageId=2197`, threadId=20) with the complete adapted Zwift `.zwo` workout file and direct download link.
|
||||
- Total turn time: **181.8 seconds (3 minutes 3 seconds)** without any typing indicator or intermediate update.
|
||||
2. **Latency Component 1: Inline Safeguard Compaction (54s)**:
|
||||
- The active session had accumulated 53,519 tokens (and >1.5 MB in `transcript_events`).
|
||||
- Because this exceeded OpenClaw's internal `maxActiveTranscriptBytes: 400kb` limit, OpenClaw paused the turn from 07:15:17 to 07:16:12 (`durationMs=54168`) to run an inline LLM compaction via `openrouter/z-ai/glm-5.3-flash`, reducing the prompt window from 161 messages to 12.
|
||||
- Because `agents.defaults.compaction.notifyUser` was `false`, Telegram showed zero typing activity or indication that compaction was underway.
|
||||
3. **Latency Component 2: System Prompt & Workspace Compilation (35s)**:
|
||||
- Following a pod restart 40 minutes prior, the Linux page cache for the mechanical HDD was cold. Compiling the 56,912-character system prompt, bootstrap notes (`DREAMS.md`, `MEMORY.md`), and scanning tool search directories took 34.7s (07:16:13 to 07:16:48).
|
||||
4. **Latency Component 3: Multi-Step Tool Execution (85s)**:
|
||||
- The Fitness agent executed 4 sequential tool turns:
|
||||
1. `write`: Wrote the updated Zwift XML file.
|
||||
2. `exec`: Located `publish_file.py` in mop-skills.
|
||||
3. `exec`: Hosted the workout on `hosting.haumdaucher.de`.
|
||||
4. `exec`: Corrected the URL slug to remove duplicate date and re-hosted.
|
||||
- Each step involved sequential OpenRouter network round-trips to `z-ai/glm-5.3-flash`.
|
||||
5. **Why Nightly Compaction (03:30 CEST) Missed Topic 20**:
|
||||
- `compact_sessions.py` retrieved session lists via `openclaw sessions --all-agents --json`.
|
||||
- In OpenClaw, for sessions that have been inactive for several days (Topic 20 was last messaged on Sep 27), `totalTokens` is returned as `null` (`totalTokensFresh: false`).
|
||||
- `compact_sessions.py` checked `tokens = s.get("totalTokens") or 0`. For inactive sessions, this evaluated to `0`, causing the cron to skip compaction (`skipped += 1`).
|
||||
- Consequently, bloated transcripts sat uncompacted in SQLite until a user sent a message, triggering an unexpected 1-minute inline compaction delay during live chat.
|
||||
- **Resolution**:
|
||||
1. **Direct SQLite Transcript Inspection in `compact_sessions.py`**:
|
||||
- Enhanced `compact_sessions.py` with `get_session_metrics(agent, key, sid)` which queries `transcript_events` directly from each agent's SQLite database (`openclaw-agent.sqlite`).
|
||||
- Compaction now triggers whenever `tokens >= 20000`, OR `transcript_bytes >= 300000`, OR `(tokens == 0 and ev_cnt >= 40)`. Stale sessions with `totalTokens: null` are guaranteed to be compacted overnight.
|
||||
2. **Compaction RPC Timeout & Fallback Tuning**:
|
||||
- Increased gateway RPC timeout from 120s to 180s (`RPC_TIMEOUT_MS = 180000`) to prevent premature gateway timeouts on large transcripts.
|
||||
- Changed the truncation fallback from `--max-lines 200` to `--max-lines 80`, ensuring that even fallback compactions effectively reduce the transcript.
|
||||
- Handled `"Already compacted"` responses gracefully to prevent redundant retries.
|
||||
3. **Compaction User Notification Enabled**:
|
||||
- Configured `agents.defaults.compaction.notifyUser: true` in `openclaw.json`. If inline compaction ever triggers during a live turn, OpenClaw now sends a status notice (`🧹 Compacting context...`) so the user is immediately aware of background maintenance rather than suspecting a crash.
|
||||
4. **Proactive Manual Compaction**:
|
||||
- Ran the updated compaction workflow across all agent databases, reducing bloated sessions (Main: 49.7k → 3.2k, Koch: 47k → 4.5k, Hebamme: 43k → 8.4k, Fitness: 12.5k → 7.5k).
|
||||
- **How to Avoid Recurrence**:
|
||||
- Never rely solely on high-level CLI summary caches (`totalTokens`) for health/maintenance triggers when underlying datastores contain ground-truth metrics.
|
||||
- Keep `notifyUser: true` enabled for long-running inline agent maintenance.
|
||||
- Retain SQLite page cache pre-warming to minimize startup latency on spinning disk infrastructure.
|
||||
|
||||
### Issue 20: Mila Datastore Dropping Turns & Reasoning Loops on Open Sleep Sessions
|
||||
|
||||
- **Date**: 2026-10-09
|
||||
- **Severity**: High (Mila Telegram Topic 22 dropped updates, turns stalled for 28+ minutes until killed, data missing in DuckDB/Markdown views)
|
||||
- **Symptom**:
|
||||
Parent messages reporting sleep intervals (e.g. "1520 bis 1535 geschlafen" or "Geschlafen von 1710 bis 1740") produced no replies or updates in `entities/people/mila-schlafprotokoll.md`. One turn remained in `status: running` for 28 minutes before being killed by the gateway (`status: killed`).
|
||||
- **Root Cause**:
|
||||
1. **Unclosed Night Session**: The night sleep session from 2026-10-08 19:15 was never closed (`end: null`).
|
||||
2. **Conflict on Open Live Session**: At 12:35, an "Eingeschlafen" message failed with `ValueError: already has an open sleep session`.
|
||||
3. **Reasoning Model Thinking Loops**: At 15:38, when given "1520 bis 1535 geschlafen", `z-ai/glm-5.3-flash` observed an unclosed 17-hour session alongside an afternoon nap in its context. Without explicit instructions, the model entered an internal reasoning loop (>4,000 thinking tokens) attempting to philosophize and resolve the chronological paradox, hitting the turn token limit without invoking any tool. A later turn (17:50) got completely stuck for 28 minutes until killed.
|
||||
4. **Silent Auto-Capping / Cronjob Rejected**: Auto-closing or fabricating artificial end times (e.g. 07:00 morning caps) was explicitly rejected as "stilles Deckeln" and data corruption. Similarly, wake-up cron reminders were deemed overengineered.
|
||||
- **Resolution**:
|
||||
1. **Data Backfill & View Render**:
|
||||
- Manually closed the 08.10 19:15 night session at 07:00 (11h45m).
|
||||
- Backfilled Nap 2 (15:20–15:35) and Nap 3 (17:10–17:40) into `data/events/sleep/2026-10.jsonl`.
|
||||
- Regenerated views via `render_views.py` (total 1.08h across 3 naps).
|
||||
- Recovered wedged SQLite session from `killed` to `done` and compacted transcript from 74.5k to 20.6k tokens.
|
||||
2. **Datastore Transparent Alerting**:
|
||||
- In `skills/datastore/scripts/datastore.py`:
|
||||
- Removed any silent auto-closing/capping.
|
||||
- Implemented `_get_open_sleep_sessions()`. When a new sleep event is added while a prior session is open, `add_event()` now successfully logs the new event and appends an `open_session_warning` and updated `day_total_formatted` alert to the CLI output.
|
||||
- Added support for closing specific events by ID via `--id` in `datastore.py stop`.
|
||||
- Added strict CLI event kind validation (`.` required, boolean keywords like `true`/`false` rejected).
|
||||
3. **Prompt Hardening**:
|
||||
- Updated `agents/hebamme.md` and Topic 22 system prompt in `openclaw-instance.secret.yaml`:
|
||||
- Mandated immediate CLI tool execution (`datastore.py add`) on reported times without overthinking past gaps (no reasoning loops).
|
||||
- Explicit rule: **Never silently cap open sessions in the background**; instead, directly notify parents in the Telegram response whenever `open_session_warning` is present in the tool output:
|
||||
`😴 Schläfchen geloggt (...). ⚠️ Hinweis: Das vorherige Schlaf-Event (ab HH:MM Uhr) ist noch offen – wann ist Mila aufgewacht?`
|
||||
- Instructed Hebamme to close the prior event with `--id <event_id>` once parents supply the wake-up time.
|
||||
- **How to Avoid Recurrence**:
|
||||
- Never allow reasoning models to silently swallow or internally debate inconsistent domain state: enforce immediate tool calls and transparent user-facing notices.
|
||||
- Never fabricate data or silently auto-cap timestamps behind parents' backs.
|
||||
- Require positional arguments in CLI tools to follow strict schema (e.g. dotted kind `domain.event`) to prevent argparse consuming flag values as positional parameters.
|
||||
|
||||
### Issue 21: Cold Tool Loading & In-Pod Recovery Amnesia Leading to Silent Turns on Topic 117 (Recherche)
|
||||
|
||||
- **Date**: 2026-10-10
|
||||
- **Severity**: High (User received no response to voice note 2492 in Telegram Topic 117 `#Recherche`, pod restart, silent turn drop)
|
||||
- **Symptom**:
|
||||
Moritz sent a Telegram voice note inquiring about healthy bread spreads and alternatives to butter ("gesunde Brotbeläge / Alternativen zu Butter"). While the audio was transcribed successfully (`transcript_events seq=372`), the user never received a response on Telegram.
|
||||
- **Root Cause**:
|
||||
1. **Cold Tool Discovery & Vector Index Scan on Rotational HDD**:
|
||||
When the turn started on Topic 117, OpenClaw initialized its tool registry (392 tools across plugins/skills) and scanned SQLite databases containing duplicated `memory_embedding_cache` (90MB) and `memory_index_chunks` (53MB). On Hetzner rotational SATA HDD (`136.243.23.215`), heavy random reads pegged disk I/O and lagged the Node.js event loop, preventing timely `/healthz` HTTP probe responses.
|
||||
2. **Kubelet Liveness Probe Timeout & Container Kill**:
|
||||
Kubelet's liveness probe timed out during the event loop freeze and killed container `openclaw` (SIGKILL / container restart).
|
||||
3. **In-Pod Recovery Amnesia**:
|
||||
During preflight initialization upon in-pod restart, `/home/node/.openclaw/scripts/recover_sessions.py` inspected SQLite databases and marked all wedged `status: "running"` sessions as `status: "done"` to clear session locks. However, Telegram ingress had already acknowledged the webhook update offset (`985533361`). Because `recover_sessions.py` transitioned the session to `done` without scheduling a re-run or emitting a fallback reply for un-answered user messages, the turn was silently abandoned.
|
||||
4. **Rigid Prompting / Template Mismatch**:
|
||||
`agents/recherche.md` enforced a mandatory 7-part municipal infrastructure framework (Stadtrat ALLRIS, Vorlagen-Nummer, Bürgerbeteiligung, etc.) even for general lifestyle, dietary, or consumer science queries.
|
||||
- **Resolution**:
|
||||
1. **Dispatched Research Answer**:
|
||||
Executed agent synthesis for session `9658ba38-50db-482d-9cc6-19a956838894` using `deepseek/deepseek-v4.1-flash` and delivered the comprehensive analysis (Ernährungsphysiologische Hebelanalyse: Butter vs. Olivenöl, Frischkäse/Quark, Hummus/Hülsenfrüchte, Nussmus, pflanzliche Margarinen) directly to Telegram Topic 117 (Message ID 2494).
|
||||
2. **Decoupled Prompting (Modus A / Modus B)**:
|
||||
Updated `knowledge-wiki/agents/recherche.md` and `openclaw-instance.secret.yaml` with dual-mode structure:
|
||||
- **Modus A (Stadtrat / Kommunalpolitik / Infrastruktur)**: Retains the strict 7-part investigative dossier format.
|
||||
- **Modus B (Allgemeine Recherche / Ernährung / Tech / Konsum)**: Uses an action-oriented schema (BLUF, Systematik, Hebelanalyse, konkrete Handlungsempfehlungen).
|
||||
3. **Automated Turn-Replay & Telegram Recovery Notice**:
|
||||
- Hardened `/home/node/.openclaw/scripts/recover_sessions.py` to inspect `transcript_events` on wedged sessions. If the last event is an un-answered user prompt (`role == 'user'`), it extracts the prompt text and routing context, cascades deletion of the dangling user event, marks the session `done`, and queues the turn to `/tmp/openclaw-interrupted-turns.json`.
|
||||
- Implemented `/home/node/.openclaw/scripts/replay_interrupted_turns.py`: waits for `/healthz`, dispatches a Telegram notification (`⚠️ OpenClaw wurde während der Bearbeitung neu gestartet – deine Anfrage wird jetzt nachgeholt...`), and replays the agent turn via `openclaw agent --session-key "<sk>" -m "<prompt>" --deliver`.
|
||||
- Integrated into `gateway-preflight.mjs` as a background detached daemon (`child.unref()`).
|
||||
4. **Vector Database Scoping & 94% Deduplication**:
|
||||
- Scoped `memory.search.extraPaths` strictly to `agents.entries.main` (the personal concierge), removing it from global `memory.search`.
|
||||
- Created `/home/node/.openclaw/scripts/dedup_subagent_vectors.py` in `gateway-preflight.mjs` to purge duplicate wiki vector tables (`memory_index_chunks_vec`, `memory_embedding_cache`, `memory_index_chunks`) and vacuum subagents.
|
||||
- Reclaimed ~1.8 GB disk space across 10 subagent databases (shrinking them from ~185 MB each down to 5–15 MB), slashing random I/O and cold start times.
|
||||
5. **Memory Limits & Probe Resilience**:
|
||||
- Increased container memory limits in `openclaw-instance.secret.yaml` from 4Gi to 6Gi (requests 2Gi) and `NODE_OPTIONS` heap to `--max-old-space-size=4096`, eliminating Linux kernel cgroup OOMKills.
|
||||
- Increased probe timeouts from 15s to 25s across `startup`, `liveness`, and `readiness` probes to accommodate mechanical HDD latency bursts without premature kills.
|
||||
- **How to Avoid Recurrence**:
|
||||
- Never configure broad vector embedding sync paths (`extraPaths`) at the global level in multi-agent environments; scope them strictly to the agents requiring them.
|
||||
- Never let recovery scripts silently discard unfulfilled user turns without re-queuing and notifying the user.
|
||||
- Generously tune probe timeouts and memory cgroup ceilings on nodes with ample headroom (32GB RAM) to survive temporary multi-process activity.
|
||||
|
||||
|
||||
### Issue 22: Split-Turn Context Drift, `wiki_get` Schema Trap & Unanswered Turn Bleed-Through on Koch Agent (Topic 21)
|
||||
|
||||
- **Date**: 2026-10-10
|
||||
- **Severity**: High (User voice note about new porridge recipe was answered with cooking wishlist; recipe was not saved; 70k context token drift; stalled turns bleed into future turns)
|
||||
- **Symptom**:
|
||||
Moritz sent a voice note to Topic 21 (`#Kochen` / `agentId: koch`) specifying a new breakfast porridge batch recipe:
|
||||
_"Ich habe eine neue Porridge-Mischung gemacht. Bitte speichere diese als Rezept auf. Es ist 400 Gramm Haferflocken, 200 Gramm Haferkleie, 50 Gramm Chiasamen, 30 Gramm getrocknete Mango, also gefriergetrocknete Mango, 5 Gramm Zimt und 160 Gramm Eiweißpulver. Speichere das bitte so als Rezept auf: Moritz Porridge-Mischung Oktober 2026. und speichere bitte, merkt ihr quasi, wenn ich sage, ich habe in der Früh meine Porridge-Mischung gemacht, dann meine ich ab sofort diese Mischung. Bitte merkt dir das."_
|
||||
Instead of saving the recipe and calculating macros, the Koch Agent replied with the open "To-Cook" wishlist (`lists/to-cook.md`), appending `NO_REPLY`.
|
||||
- **Root Cause**:
|
||||
1. **Unanswered / Stalled Turn Bleed-Through**:
|
||||
In a previous turn (Seq 51), Moritz had asked: _"Wir haben eine Liste im Wiki, was wir noch unbedingt kochen wollen. Gib mir doch mal diese Liste aus."_ The agent failed tool executions and timed out at Seq 60 with: _"The tool run finished, but no final summary was produced. I did not repeat any completed actions."_
|
||||
Because this prior turn never delivered a completed user response, the unfulfilled instruction remained hanging in the conversation history.
|
||||
2. **Long-Context Attention Drift on GLM-5.3-Flash (>70k Tokens)**:
|
||||
The session had accumulated **70,974 tokens**. When Moritz sent the new porridge voice note (Seq 61), the agent started processing it (Seq 62), but after one tool read at Seq 63, the LLM drifted back to the unresolved prompt from Seq 51 (Seq 64 Thinking: _"Moritz wants the 'To-Cook' list from the wiki. Search wiki."_). The agent abandoned the porridge recipe completely and spent 30 tool iterations fetching `lists/to-cook.md`.
|
||||
3. **`wiki_get` Parameter Schema Trap**:
|
||||
The system prompt did not specify the required argument name for `wiki_get`. The model guessed `path: "lists/to-cook.md"` repeatedly, failing with:
|
||||
`Invalid arguments for tool "openclaw:memory-wiki:wiki_get": lookup: must have required property 'lookup'; <root>: must not have additional properties: "path".`
|
||||
- **Resolution**:
|
||||
1. **Delivered Porridge Recipe & Confirmation**:
|
||||
- Created `knowledge-wiki/entities/recipes/moritz-porridge-mischung-oktober-2026.md` with full ingredients (400g Haferflocken, 200g Haferkleie, 50g Chia, 30g Mango gefriergetrocknet, 5g Zimt, 160g Eiweißpulver = 845g Gesamtmasse).
|
||||
- Calculated exact macros: Batch ~3.120 kcal / 219g P / 359g KH / 66g F / 98g Ballaststoffe; Pro 100g ~369 kcal / 25,9g P / 42,5g KH / 7,8g F / 11,6g Ballaststoffe; Pro 65g Portion ~240 kcal / 16,8g P / 27,6g KH / 5,1g F / 7,5g Ballaststoffe.
|
||||
- Registered food entity `moritz-porridge-mischung-oktober-2026` in `data/entities/foods.jsonl` and recipe in `data/entities/recipes.jsonl`.
|
||||
- Updated alias in `entities/standard-foods.md` and marked `entities/recipes/muller-protein-porridge.md` as superseded.
|
||||
- Sent full confirmation directly to Telegram Topic 21 via `openclaw message send` (Message ID 2519).
|
||||
2. **Prompt Hardening for Topic 21 in `openclaw-instance.secret.yaml`**:
|
||||
- Fastpath schema clarification:
|
||||
`- wiki_get: lookup ("lists/to-cook.md" oder "entities/recipes/<slug>.md") – WICHTIG: Das Argument heißt 'lookup', NIEMALS 'path'! Die Wunschliste offener Rezepte liegt unter 'lists/to-cook.md'.`
|
||||
`- wiki_search: query`
|
||||
- Strict Turn-Isolation Rule:
|
||||
`STRIKTE REGEL: IMMER NUR DIE AKTUELLSTE BENUTZERNACHRICHT BEARBEITEN! Wenn ein vorheriger Turn unvollständig war, Tool-Fehler hatte oder abgebrochen wurde, ignoriere ihn komplett. Beantworte und erfülle ausnahmslos und fokussiert den aktuellen Prompt der neuesten Nachricht. Versuche NIEMALS, alte unerledigte Anfragen aus früheren Turns eigenmächtig aufzugreifen oder mit neuen Prompts zu vermischen!`
|
||||
- Standard breakfast mapping:
|
||||
`STANDARD-FRÜHSTÜCK MORITZ: Wenn Moritz 'meine Porridge-Mischung' oder 'meinen Porridge' erwähnt, ist ab Oktober 2026 immer 'moritz-porridge-mischung-oktober-2026' gemeint.`
|
||||
3. **Hard-Reset of Bloated Session**:
|
||||
- Executed `openclaw sessions delete "agent:koch:telegram:group:-1004326921510:topic:21" --agent koch --yes`, safely archiving the bloated 70k context transcript and resetting the session to 0 tokens for subsequent messages.
|
||||
- **How to Avoid Recurrence**:
|
||||
- Always provide exact argument property names in direct tool fastpaths so LLMs do not guess invalid keys like `path` instead of `lookup`.
|
||||
- Mandate strict turn isolation in agent system prompts to prevent stalled turns from bleeding into new user prompts during context spikes.
|
||||
- Rely on Tier A daily resets (04:45 CET) for logging/stateful agents whose canonical history is fully externalized in DuckDB, JSONL, and Git.
|
||||
|
||||
### Issue 23: Empty Completion Whitespace Stall & Repeated Turn Bleed on Koch Agent (Topic 21)
|
||||
|
||||
- **Date**: 2026-10-10
|
||||
- **Severity**: High (User prompt asking for nutrition plan and 2-week intake vs. expenditure was answered with duplicate Dörrfleisch marinade recipe; earlier Skyr intake voice note was skipped; missing Garmin sync dependencies)
|
||||
- **Symptom**:
|
||||
Moritz sent a text message in Topic 21: _"Gib mir die aktuelle Ausgabe meines Ernährungsplans und erstelle eine 'Kalorien intakde' vs 'Kalorienverrauch' der letzten 2 Wochen."_
|
||||
Instead of querying Datastore DuckDB, the Koch Agent replied for the third time with tips on beef jerky kiwi-teriyaki marinade (_"Meine Empfehlung für deine Kiwi-Teriyaki-Marinade (800 g Dörrfleisch)..."_). Earlier at 12:14, Moritz's voice note logging Skyr Natur (300g), Erdbeerjoghurt (125g), and Knuspermüsli (44g) was never recorded.
|
||||
- **Root Cause Isolated via New One-Shot Triage Workflow**:
|
||||
1. Running `openclaw-triage.py --topic 21` instantly revealed the sequence of events (SEQ 13, 18, 20, 22):
|
||||
`z-ai/glm-5.3-flash` emitted reasoning (`THINKING: Two tasks: 1) current nutrition summary... Query datastore... Use bash`) along with an empty single-space string (`TEXT: " "`) and **0 tool calls**.
|
||||
2. Because the model emitted whitespace instead of invoking `exec`, OpenClaw triggered an internal retry: _"the runtime says the previous turn recorded reasoning but no visible answer"_.
|
||||
3. On retry, the model failed to understand why it was re-prompted without new user input. Seeing the previous Dörrfleisch recipe in conversational context, it concluded that the previous turn had failed to render and repeated the Dörrfleisch text.
|
||||
4. In addition, `garmin_sync.py` failed to mirror daily expenditure stats for the last 14 days (`No module named 'garminconnect'`) because it was missing a PEP 723 dependency declaration.
|
||||
- **Resolution**:
|
||||
1. **Delivered Correct Nutrition Summary & 14-Day Balance**:
|
||||
- Logged missing Skyr bowl (300g Skyr, 125g Erdbeerjoghurt, 44g Knuspermüsli $\rightarrow$ ~502 kcal, 40.7g Protein) into datastore via `datastore.py add`.
|
||||
- Added PEP 723 `# /// script dependencies = ["garminconnect>=0.2.14", "pytz>=2024.1"] ///` to `mop-skills/skills/datastore/scripts/garmin_sync.py` and synced all 14 days of Garmin metrics.
|
||||
- Delivered complete, formatted 14-day balance and today's nutrition status directly to Topic 21 (Message ID 2532).
|
||||
2. **Hardened `knowledge-wiki/agents/koch.md`**:
|
||||
- Explicitly mandated CLI execution via `exec(command="uv run ...")`.
|
||||
- Added runtime retry protection forbidding repetition of earlier completed topics.
|
||||
3. **Reset Topic 21 Session**:
|
||||
- Reset session `feeb6826` to 0 tokens via `openclaw sessions delete`.
|
||||
- **How to Avoid Recurrence**:
|
||||
- Always declare PEP 723 script metadata on all Python utilities invoked via `uv run`.
|
||||
- Provide explicit tool invocations (`exec(command=...)`) in agent prompts so reasoning models never stall in thinking-only loops.
|
||||
|
||||
### Issue 24: Missing Garmin Sync Cron Automation & Missing Activities Directory
|
||||
|
||||
- **Date**: 2026-10-11
|
||||
- **Severity**: Medium (Automated nightly mirror of Garmin Connect metrics did not run; user suspected Garmin sync was broken)
|
||||
- **Symptom**:
|
||||
Moritz noticed that Garmin Connect metrics (daily total expenditure, active calories, sleep scores, and activities) were not appearing in the newly deployed health dashboard (`moritz.html`) and nutrition log for recent days, suspecting the sync cron job was broken.
|
||||
- **Root Cause Isolated**:
|
||||
1. **Unregistered Cron Job**: The setup script `k8s/openclaw/setup-garmin-cron.sh` had been authored during datastore implementation but was never registered in OpenClaw's cron scheduler (`openclaw cron list` showed 22 jobs, none for Garmin).
|
||||
2. **Grep Truncation Failure**: In `setup-garmin-cron.sh`, the check `openclaw cron list | grep "$JOB_NAME"` was susceptible to CLI table truncation (e.g. `garmin-nightly-m...`), preventing reliable idempotent management.
|
||||
3. **Missing Announce Flag**: The job lacked `--announce`, preventing fallback delivery to Telegram Topic 20 if authentication errors occurred.
|
||||
4. **Missing Directory on PVC**: `garmin_sync.py` failed when saving activities because `/home/node/.openclaw/wiki/main/data/garmin/activities` was missing on the PVC.
|
||||
5. **Outdated Dashboard Pipeline Step**: `setup-garmin-cron.sh` only called `render_views.py` and omitted `update_moritz_website.py` and staging `public/`, leaving the web dashboard unrefreshed after nightly runs.
|
||||
- **Resolution**:
|
||||
1. **Hardened `setup-garmin-cron.sh`**:
|
||||
- Switched idempotency lookup to `openclaw cron list --json` with `--declaration-key`.
|
||||
- Added `--announce` for delivery of 2FA re-auth notices to Telegram Topic 20 (`fitness`).
|
||||
- Added `update_moritz_website.py` and `git add public/` to the nightly automation pipeline.
|
||||
2. **Created Activities Store**:
|
||||
- Created `data/garmin/activities/` on the PVC and verified activity ingestion.
|
||||
3. **Executed Setup Script**:
|
||||
- Successfully registered `garmin-nightly-mirror` (`id: ea1f35b0-9b04-4433-bd1f-7266e60292e3`, cron: `30 3 * * * @ Europe/Berlin`).
|
||||
4. **Backfilled Metrics**:
|
||||
- Ran full sync for 2026-10-09, 2026-10-10, and 2026-10-11. All daily summaries, sleep logs, and activities were written to JSONL, views were re-rendered, and commits pushed to `knowledge-wiki`.
|
||||
- **How to Avoid Recurrence**:
|
||||
- Always register cron jobs with `--declaration-key` and check existing jobs via JSON rather than table text grep.
|
||||
- Document all declarative setup scripts in `AGENTS.md` under `Register / Sync Automations`.
|
||||
- Ensure all data directories (`daily`, `sleep`, `activities`) are safely created in `GarminMirrorWriter`.
|
||||
|
||||
### Issue 25: Self-Quoting Duplicate Meal Logging & Turn Bleed on High-Context Session (Topic 21)
|
||||
|
||||
- **Date**: 2026-10-12
|
||||
- **Severity**: Medium (Morning porridge was logged twice on 2026-10-12, inflating daily intake to 1,906 kcal instead of 1,611 kcal; redundant pudding correction was appended to 2026-10-11)
|
||||
- **Symptom**:
|
||||
Moritz noticed on the personal health dashboard (`moritz.html`) that his morning porridge had been recorded twice.
|
||||
- **Root Cause Isolated via Rapid Triage**:
|
||||
1. **Extreme Context Bloat**: Session `aff9fd77` in Topic 21 had accumulated **58,856 tokens** across 103 events due to heavy tool results and previous turns.
|
||||
2. **External Tool Attention Disorientation**:
|
||||
- At 08:41 (SEQ 32–37), the morning porridge was properly logged in 4 individual items (`01M4QDGVY...` to `01M4QDH51...`, ~295 kcal total).
|
||||
- At 11:35 (SEQ 39), Moritz sent an audio message reporting 1.5 Brezen mit Butter, Weißwürste, and Händlmaier sweet mustard.
|
||||
- The assistant performed an external Perplexity web search for mustard calories, which returned ~16 kB of text.
|
||||
- Upon receiving the search result at SEQ 49, `z-ai/glm-5.3-flash` suffered severe attention disorientation: seeing `replyToId: 21` (standard Telegram forum topic metadata) and earlier messages in context, it hallucinated that earlier completed turns were unhandled requests.
|
||||
3. **Self-Quoting Duplicate Execution**:
|
||||
- In SEQ 51, the model literally copied its own earlier assistant confirmation text from SEQ 37 (`✅ *Morgenporridge (60 g Mischung · 60 ml Hafermilch · 120 ml Wasser · 50 g Kiwi)* geloggt`) and executed `datastore.py add` to log a duplicate 295 kcal composite portion (`01M4QQH2KJ85TB5N2MBM230ZYN`).
|
||||
- In SEQ 53, it did the same for yesterday's pudding correction (`01M4QQHMG8S30B43241B10ND3J`, +260 kcal).
|
||||
- **Resolution**:
|
||||
1. **Deleted Duplicate Events via Datastore CLI**:
|
||||
- Deleted duplicate porridge event `01M4QQH2KJ85TB5N2MBM230ZYN` (295 kcal) and duplicate pudding event `01M4QQHMG8S30B43241B10ND3J` (260 kcal) via `datastore.py delete`.
|
||||
- Re-rendered all views and published updated dashboards (`render_views.py` & `update_moritz_website.py`). Today's total restored to exact **1.611 kcal** (**75,1 g Protein**).
|
||||
2. **Notified Telegram Topic 21**:
|
||||
- Sent confirmation message (Message ID `2700`) to Topic 21 detailing the correction and showing the cleaned-up macros.
|
||||
3. **Hardened `knowledge-wiki/agents/koch.md`**:
|
||||
- Added strict rule: *„KEINE DUPLIKATE ODER SELBST-ZITATE LOGGEN“*. Explicitly forbidden from parsing previous assistant confirmation messages as intake items and forbidden from re-logging earlier completed meals.
|
||||
4. **Session Reset**:
|
||||
- Safely deleted and archived the bloated 58k-token session via `openclaw sessions delete`.
|
||||
5. **Triage Script Optimization**:
|
||||
- Updated `openclaw-triage.py` to automatically exclude background `:heartbeat` sessions when querying by topic ID.
|
||||
- **How to Avoid Recurrence**:
|
||||
- System prompts for transactional logging agents must explicitly forbid treating assistant confirmation messages as intake events.
|
||||
- Agents must enforce strict focus on the very last user message at the tail of the transcript.
|
||||
- Reset sessions periodically when context grows beyond 30k tokens for simple transactional logging tasks.
|
||||
|
||||
### Issue 26: Compaction Parameter Tuning & Shift to Deep-Compaction with History Retention
|
||||
|
||||
- **Date**: 2026-10-12
|
||||
- **Severity**: Medium (Evaluating why sessions grew to ~58k tokens despite nightly maintenance; eliminating the binary choice between complete 0-token reset and bloated 58k-token sessions)
|
||||
- **Symptom**:
|
||||
Moritz inquired why Topic 21 had reached ~58k tokens and asked whether nightly maintenance was functioning properly. He requested investigating if compaction could be tuned to aggressively compact while preserving a few recent tokens/history for all agents instead of performing hard 0-token resets.
|
||||
- **Root Cause Isolated**:
|
||||
1. **Nightly Job Execution Verified**: The nightly maintenance cron (`ce39bbf8-b83f-453e-be53-04e27db27b78`, schedule `45 4 * * * @ Europe/Berlin`) had indeed run successfully at 04:45:00 Berlin (`1791773100019 ms`).
|
||||
2. **Intraday Context Inflation**: The 58k tokens were NOT carried over from previous days; they were accumulated within the same day between 07:20 and 11:55 due to base prompt/tools (~18.2k baseline), 10 queries in Turn 1, multiple Perplexity web searches (~16 kB each), and `cat foods.jsonl`.
|
||||
3. **Overly Lenient Auto-Compaction Settings**:
|
||||
- `keepRecentTokens: 15000`: Left 15k tokens verbatim in memory after compaction.
|
||||
- `maxActiveTranscriptBytes: "400kb"`: Did not trigger inline safeguard compaction during the day until files reached 400 kB (~50k+ tokens).
|
||||
- `recentTurnsPreserve: 3` (default): Preserved 3 full turns with potentially huge tool outputs verbatim.
|
||||
4. **Binary Tier A Trade-off**: Tier A agents (`koch`, `hebamme`, `shopping`) were previously hard-reset to 0 (`openclaw sessions delete`) because state is externalized in DuckDB. While preventing hallucinations, this discarded recent conversational continuity.
|
||||
- **Resolution**:
|
||||
1. **Tuned Compaction Parameters**:
|
||||
- Updated both live configuration (`/home/node/.openclaw/openclaw.json`) and declarative manifest (`k8s/openclaw/openclaw-instance.secret.yaml`):
|
||||
- `keepRecentTokens: 3000` (down from 15,000; forces dense LLM summarization).
|
||||
- `recentTurnsPreserve: 2` (preserves only the last 2 turns verbatim).
|
||||
- `maxActiveTranscriptBytes: "180kb"` (down from 400kb; triggers proactive compaction before intraday sessions exceed ~25k tokens).
|
||||
2. **Switched Tier A from Hard-Reset to Deep-Compaction**:
|
||||
- Updated `k8s/openclaw/setup-compaction-cron.sh` and `k8s/openclaw/scripts/compact_sessions.py`.
|
||||
- Tier A now calls `openclaw sessions compact <key> --agent <agent>` with fallback to `--max-lines 40` if built-in safeguard summarization quality checks fail.
|
||||
- Lowered Tier B thresholds: `THRESHOLD_TOKENS = 15000`, `THRESHOLD_TRANSCRIPT_BYTES = 200000` (200 KB), `THRESHOLD_EVENT_COUNT = 30`.
|
||||
- Re-registered cron job in OpenClaw (`setup-compaction-cron.sh`).
|
||||
3. **Verified Live Reduction**:
|
||||
- `koch`: 29,783 $\rightarrow$ **2,098 tokens** (Tier A deep-compacted).
|
||||
- `hebamme`: Truncated to 40 lines (Tier A fallback).
|
||||
- `fitness`: 101,950 $\rightarrow$ **5,318 tokens**.
|
||||
- `recherche`: 127,356 $\rightarrow$ **4,323 tokens**.
|
||||
- `grabengasse_media`: 49,447 $\rightarrow$ **3,356 tokens**.
|
||||
- `doktor`: 20,259 $\rightarrow$ **1,079 tokens**.
|
||||
- **How to Avoid Recurrence**:
|
||||
- Keep `keepRecentTokens: 3000` and `maxActiveTranscriptBytes: 180kb` in declarative manifests.
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 ==="
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/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
|
||||
|
||||
# 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).
|
||||
# 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 and Knowledge Wiki 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; "
|
||||
"find /home/node/.openclaw/wiki/main /home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /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_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():
|
||||
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():
|
||||
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) ===")
|
||||
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 and Tracking agents as they have dedicated routines
|
||||
if key in excluded_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_tracking_sessions()
|
||||
compact_tier_a_sessions()
|
||||
compact_active_sessions()
|
||||
checkpoint_wal_journals()
|
||||
vacuum_and_optimize()
|
||||
prewarm_page_cache()
|
||||
|
||||
if __name__ == "__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)
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
openclaw-triage.py: Rapid zero-dependency incident triage for OpenClaw multi-agent sessions.
|
||||
Directly inspects ground-truth SQLite session state and transcript events in < 1 second.
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
|
||||
TOPIC_TO_AGENT = {
|
||||
"20": "fitness",
|
||||
"21": "koch",
|
||||
"22": "hebamme",
|
||||
"51": "travel",
|
||||
"115": "shopping",
|
||||
"116": "regensburg",
|
||||
"117": "recherche",
|
||||
"118": "doktor",
|
||||
"119": "itguy",
|
||||
"164": "grabengasse_media",
|
||||
"2796": "mila_tracker",
|
||||
"2797": "nutrition_tracker",
|
||||
}
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Rapid OpenClaw multi-agent session triage.")
|
||||
parser.add_argument("--topic", "-t", help="Telegram forum topic ID (e.g. 21, 22, 117)")
|
||||
parser.add_argument("--agent", "-a", help="Agent identifier (e.g. koch, hebamme, recherche)")
|
||||
parser.add_argument("--limit", "-n", type=int, default=10, help="Number of recent events to display (default: 10)")
|
||||
parser.add_argument("--db-root", default="/home/node/.openclaw/agents", help="Root path of agent SQLite databases")
|
||||
return parser.parse_args()
|
||||
|
||||
def triage_agent_session(agent, topic_id=None, limit=10, db_root="/home/node/.openclaw/agents"):
|
||||
db_path = os.path.join(db_root, agent, "agent", "openclaw-agent.sqlite")
|
||||
if not os.path.exists(db_path):
|
||||
print(f"[-] Database not found for agent '{agent}': {db_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
con = sqlite3.connect(db_path, timeout=5.0)
|
||||
except Exception as e:
|
||||
print(f"[-] Failed to connect to SQLite DB for agent '{agent}': {e}")
|
||||
return False
|
||||
|
||||
cur = con.cursor()
|
||||
# Find matching session
|
||||
if topic_id:
|
||||
target_pattern = f"%topic:{topic_id}%"
|
||||
cur.execute(
|
||||
"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",
|
||||
(target_pattern,)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"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"
|
||||
)
|
||||
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
print(f"[-] No session found in {agent} DB (filter: topic={topic_id})")
|
||||
con.close()
|
||||
return False
|
||||
|
||||
skey, sid, status, updated_at_ms, entry_json = row
|
||||
now_ms = time.time() * 1000
|
||||
age_min = (now_ms - updated_at_ms) / 60000.0 if updated_at_ms else 0.0
|
||||
|
||||
entry = {}
|
||||
if entry_json:
|
||||
try:
|
||||
entry = json.loads(entry_json)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tokens = entry.get("totalTokens", "?")
|
||||
model = entry.get("model", "default")
|
||||
last_run_id = entry.get("activeWriterRunId") or entry.get("activeRunId", None)
|
||||
|
||||
# Detect anomalies
|
||||
badges = []
|
||||
if isinstance(tokens, int):
|
||||
if tokens > 60000:
|
||||
badges.append("🚨 [CRITICAL_CONTEXT_BLOAT: >60k tokens]")
|
||||
elif tokens > 20000:
|
||||
badges.append("⚠️ [HIGH_CONTEXT_TOKENS: >20k tokens]")
|
||||
if status == "running" and age_min > 5.0:
|
||||
badges.append("🔒 [POSSIBLY_WEDGED_RUNNING: updated >5m ago]")
|
||||
elif status == "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(f"🎯 AGENT: {agent.upper():<12} | TOPIC: {topic_id or 'N/A':<6} | STATUS: {status.upper()}")
|
||||
print(f" SESSION KEY: {skey}")
|
||||
print(f" SESSION ID: {sid}")
|
||||
print(f" MODEL: {model}")
|
||||
print(f" TOKENS: {tokens} | LAST UPDATED: {age_min:.1f}m ago")
|
||||
if badges:
|
||||
print(f" ANOMALIES: {' '.join(badges)}")
|
||||
print("=" * 72)
|
||||
|
||||
# Query transcript events
|
||||
cur.execute(
|
||||
"SELECT seq, created_at, event_json FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT ?",
|
||||
(sid, limit)
|
||||
)
|
||||
events = cur.fetchall()
|
||||
con.close()
|
||||
|
||||
if not events:
|
||||
print(" (No transcript events found)")
|
||||
return True
|
||||
|
||||
events.reverse()
|
||||
print("\n📜 RECENT TRANSCRIPT EVENTS (Chronological):")
|
||||
|
||||
last_role = None
|
||||
for seq, ca_ms, ej_str in events:
|
||||
try:
|
||||
ev = json.loads(ej_str)
|
||||
except Exception:
|
||||
print(f"[{seq}] <unparseable event json>")
|
||||
continue
|
||||
|
||||
etype = ev.get("type")
|
||||
if etype != "message":
|
||||
continue
|
||||
|
||||
msg = ev.get("message", {})
|
||||
role = msg.get("role")
|
||||
last_role = role
|
||||
content = msg.get("content", [])
|
||||
|
||||
if role == "user":
|
||||
txt = content if isinstance(content, str) else json.dumps(content)
|
||||
# Check for audio transcript
|
||||
if isinstance(content, str) and "[Audio]" in content:
|
||||
print(f"\n👉 [SEQ {seq}] 🎤 USER AUDIO PROMPT:")
|
||||
for line in content.splitlines()[:6]:
|
||||
print(f" {line}")
|
||||
if len(content.splitlines()) > 6:
|
||||
print(" ...")
|
||||
else:
|
||||
txt_preview = txt.replace("\n", " ")[:240]
|
||||
print(f"\n👉 [SEQ {seq}] 👤 USER: {txt_preview}")
|
||||
|
||||
elif role == "assistant":
|
||||
print(f"\n🤖 [SEQ {seq}] ASSISTANT TURN:")
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
itype = item.get("type")
|
||||
if itype == "thinking":
|
||||
th = item.get("thinking", "").replace("\n", " ")
|
||||
print(f" 🧠 THINKING: {th[:280]}...")
|
||||
elif itype == "toolCall":
|
||||
tname = item.get("name", "unknown")
|
||||
targs = json.dumps(item.get("arguments", {}))
|
||||
print(f" ⚙️ TOOL CALL: {tname}({targs[:160]})")
|
||||
elif itype == "text":
|
||||
tx = item.get("text", "")
|
||||
lines = tx.splitlines()
|
||||
prev = lines[0][:150] if lines else ""
|
||||
print(f" 💬 TEXT OUTPUT: {prev}...")
|
||||
elif isinstance(content, str):
|
||||
print(f" 💬 TEXT OUTPUT: {content[:200]}...")
|
||||
|
||||
elif role == "toolResult":
|
||||
is_err = ev.get("isError", False)
|
||||
tname = ev.get("details", {}).get("tool") or msg.get("toolName", "tool")
|
||||
res_txt = json.dumps(content) if not isinstance(content, str) else content
|
||||
if is_err or "error" in res_txt.lower() or "must have required property" in res_txt:
|
||||
print(f" ❌ [SEQ {seq}] TOOL ERROR ({tname}): {res_txt[:260]}")
|
||||
else:
|
||||
print(f" ✅ [SEQ {seq}] TOOL RESULT ({tname}): {res_txt[:180]}")
|
||||
|
||||
if last_role == "user":
|
||||
print("\n⚠️ WARNING: The last event in the transcript is an UNANSWERED USER MESSAGE!")
|
||||
print(" The agent turn either crashed, timed out, or was killed before producing a response.")
|
||||
|
||||
print("\n" + "=" * 72)
|
||||
return True
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
if not args.topic and not args.agent:
|
||||
print("Usage: openclaw-triage.py (--topic <ID> | --agent <NAME>) [--limit <N>]")
|
||||
sys.exit(1)
|
||||
|
||||
agent = args.agent
|
||||
topic = args.topic
|
||||
|
||||
if topic and not agent:
|
||||
agent = TOPIC_TO_AGENT.get(str(topic))
|
||||
if not agent:
|
||||
# Search all DBs
|
||||
for a, t in TOPIC_TO_AGENT.items():
|
||||
if t == topic:
|
||||
agent = a
|
||||
break
|
||||
|
||||
if not agent:
|
||||
# Scan all DBs for the topic
|
||||
for db in sorted(glob.glob(os.path.join(args.db_root, "*/agent/openclaw-agent.sqlite"))):
|
||||
cand_agent = db.split(os.sep)[-3]
|
||||
try:
|
||||
con = sqlite3.connect(db, timeout=2.0)
|
||||
cur = con.cursor()
|
||||
cur.execute(
|
||||
"SELECT session_key FROM session_nodes WHERE session_key LIKE ? LIMIT 1",
|
||||
(f"%topic:{topic}%",)
|
||||
)
|
||||
if cur.fetchone():
|
||||
agent = cand_agent
|
||||
con.close()
|
||||
break
|
||||
con.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not agent:
|
||||
print(f"[-] Could not map topic '{topic}' to any known OpenClaw agent.")
|
||||
sys.exit(1)
|
||||
|
||||
success = triage_agent_session(agent, topic_id=topic, limit=args.limit, db_root=args.db_root)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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!")
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -1,8 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# setup-compaction-cron.sh
|
||||
# Declaratively registers the Multi-Agent Nightly Session Compaction in OpenClaw.
|
||||
# Schedule: Every night at 03:30 Europe/Berlin (cron: 30 3 * * *)
|
||||
# Declaratively registers OpenClaw session maintenance automations:
|
||||
# 1. Tracking Sliding Window (every 15m): Truncates mila_tracker & nutrition_tracker
|
||||
# to <= 8 events (~5 messages / 2 turns) via instant native gateway truncation.
|
||||
# 2. Nightly Deep Compaction (every night at 04:45 Europe/Berlin):
|
||||
# - Pre-warms SQLite page cache on rotational storage
|
||||
# - Recovers wedged/quarantined sessions
|
||||
# - Prunes abandoned isolated cron sessions (>48h)
|
||||
# - 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
|
||||
@@ -10,211 +18,68 @@ set -euo pipefail
|
||||
NAMESPACE="${NAMESPACE:-openclaw}"
|
||||
POD_NAME="${POD_NAME:-openclaw-0}"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-openclaw}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
JOB_NAME="nightly-session-compaction"
|
||||
CRON_SCHEDULE="30 3 * * *"
|
||||
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 * * *"
|
||||
TIMEZONE="Europe/Berlin"
|
||||
|
||||
echo "=== Registering Multi-Agent Nightly Session Compaction 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 compaction script is present on PVC
|
||||
echo "Ensuring /home/node/.openclaw/scripts/compact_sessions.py exists..."
|
||||
kubectl exec -i -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- /bin/sh -c 'cat << "EOF" > /home/node/.openclaw/scripts/compact_sessions.py
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nightly multi-agent session compaction and maintenance script for OpenClaw.
|
||||
1. Pre-warms OS page cache for SQLite databases on spinning disks.
|
||||
2. Prunes abandoned isolated cron sessions (>48h old) to prevent token/state bloat.
|
||||
3. Compacts all active forum topics and main sessions exceeding the token threshold.
|
||||
4. Checkpoints SQLite WAL journals across all agent databases to disk.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
THRESHOLD_TOKENS = 25000
|
||||
CRON_MAX_AGE_HOURS = 48
|
||||
RPC_TIMEOUT_MS = 120000
|
||||
|
||||
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_active_sessions():
|
||||
print(f"=== Running OpenClaw Multi-Agent Compaction (threshold: {THRESHOLD_TOKENS} tokens, 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
|
||||
|
||||
# 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"):
|
||||
if tokens >= THRESHOLD_TOKENS:
|
||||
print(f"Compacting {agent} ({tokens} tokens) -> {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
|
||||
else:
|
||||
print(f" ✗ {c_res.stderr.strip()}; retrying with --max-lines 200 fallback...")
|
||||
fb_res = subprocess.run(
|
||||
["openclaw", "sessions", "compact", key, "--agent", agent, "--max-lines", "200", "--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(2)
|
||||
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 main():
|
||||
prewarm_page_cache()
|
||||
prune_stale_cron_sessions()
|
||||
compact_active_sessions()
|
||||
checkpoint_wal_journals()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
EOF
|
||||
chmod +x /home/node/.openclaw/scripts/compact_sessions.py
|
||||
'
|
||||
|
||||
# 2. Register / Update the OpenClaw cron job
|
||||
echo "Registering OpenClaw Cron automation '$JOB_NAME' ($CRON_SCHEDULE @ $TIMEZONE)..."
|
||||
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
|
||||
if [ -n "$EXISTING_NIGHTLY_ID" ]; then
|
||||
echo "Updating existing nightly compaction automation '$EXISTING_NIGHTLY_ID' ($CRON_SCHEDULE @ $TIMEZONE)..."
|
||||
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron edit "$EXISTING_NIGHTLY_ID" \
|
||||
--cron "$CRON_SCHEDULE" \
|
||||
--tz "$TIMEZONE" \
|
||||
--command "python3 /home/node/.openclaw/scripts/compact_sessions.py"
|
||||
else
|
||||
echo "Registering new nightly compaction automation '$NIGHTLY_JOB' ($CRON_SCHEDULE @ $TIMEZONE)..."
|
||||
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
|
||||
--name "Nightly Session Compaction" \
|
||||
--declaration-key "$JOB_NAME" \
|
||||
--declaration-key "$NIGHTLY_JOB" \
|
||||
--description "Nightly multi-agent session compaction and SQLite WAL checkpointing" \
|
||||
--cron "$CRON_SCHEDULE" \
|
||||
--tz "$TIMEZONE" \
|
||||
--session "isolated" \
|
||||
--no-deliver \
|
||||
--command "python3 /home/node/.openclaw/scripts/compact_sessions.py"
|
||||
fi
|
||||
|
||||
echo "=== Compaction Cron automation successfully registered! ==="
|
||||
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron show "$JOB_NAME" 2>/dev/null || true
|
||||
echo "=== All session maintenance automations successfully registered/updated! ==="
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# setup-dashboards-cron.sh
|
||||
# Declaratively registers periodic background publishing for Mila & Moritz dashboards in OpenClaw.
|
||||
# Schedule: Every 10 minutes (pure shell command execution, zero LLM token cost)
|
||||
# ==============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NAMESPACE="${NAMESPACE:-openclaw}"
|
||||
POD_NAME="${POD_NAME:-openclaw-0}"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-openclaw}"
|
||||
|
||||
JOB_NAME="dashboards-periodic-publish"
|
||||
SCHEDULE_EVERY="10m"
|
||||
|
||||
echo "=== Registering Dashboards Periodic Publish in OpenClaw ($POD_NAME) ==="
|
||||
|
||||
# 1. Check if job already exists
|
||||
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"'" or j.get("name") == "'"$JOB_NAME"'"), ""))' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
echo "Found existing job with ID $EXISTING_ID. Removing for idempotent recreation..."
|
||||
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron rm "$EXISTING_ID" || true
|
||||
fi
|
||||
|
||||
# 2. Add the job as direct shell command execution with --no-deliver
|
||||
echo "Creating automation $JOB_NAME (every $SCHEDULE_EVERY)..."
|
||||
|
||||
CMD="sh -lc 'cd /home/node/mop-skills/mop-skills/skills/datastore && uv run scripts/update_website.py --data-dir /home/node/.openclaw/wiki/main/data && uv run scripts/update_moritz_website.py --data-dir /home/node/.openclaw/wiki/main/data'"
|
||||
|
||||
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
|
||||
--name "$JOB_NAME" \
|
||||
--description "Periodically regenerates and uploads Mila & Moritz family HTML dashboards to S3" \
|
||||
--command "$CMD" \
|
||||
--every "$SCHEDULE_EVERY" \
|
||||
--no-deliver
|
||||
|
||||
echo "=== Automation successfully registered! ==="
|
||||
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list | grep "$JOB_NAME" || true
|
||||
@@ -20,7 +20,7 @@ TELEGRAM_TOPIC="20"
|
||||
echo "=== Registering Garmin Connect Nightly Sync in OpenClaw ($POD_NAME) ==="
|
||||
|
||||
# 1. Check if job already exists
|
||||
EXISTING_ID=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list 2>/dev/null | grep "$JOB_NAME" | awk '{print $1}' || true)
|
||||
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"'" or j.get("name") == "'"$JOB_NAME"'"), ""))' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
echo "Found existing job with ID $EXISTING_ID. Removing for idempotent recreation..."
|
||||
@@ -35,27 +35,25 @@ 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. Aktualisiere die gerenderten Markdown-Ansichten:
|
||||
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. Git Commit & Push im Knowledge Wiki:
|
||||
cd /home/node/.openclaw/wiki/main && git add data/garmin/ entities/nutrition-log/ && git diff --staged --quiet || (git commit -m "chore(garmin): automated nightly mirror [skip ci]" && git push origin main)
|
||||
4. Finale Antwort:
|
||||
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, dass ein Garmin-Re-Auth nötig ist.
|
||||
- Nur bei Authentifizierungsfehlern: Informiere kurz via Telegram (Thema #Sport), dass ein Garmin-Re-Auth nötig ist, oder starte den Re-Auth-Flow.
|
||||
EOF
|
||||
)
|
||||
|
||||
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
|
||||
--name "$JOB_NAME" \
|
||||
--declaration-key "$JOB_NAME" \
|
||||
--description "Nächtlicher deterministischer Spiegel der Garmin Connect Daten (Aktivitäten, Daily Summary, Schlaf) in Git JSONL" \
|
||||
--cron "$CRON_SCHEDULE" \
|
||||
--tz "$TIMEZONE" \
|
||||
--agent "fitness" \
|
||||
--model "openrouter/deepseek/deepseek-v4.1-flash" \
|
||||
--model "openrouter/deepseek/deepseek-v4.1-flash-1017" \
|
||||
--session "isolated" \
|
||||
--channel "telegram" \
|
||||
--to "$TELEGRAM_GROUP" \
|
||||
--thread-id "$TELEGRAM_TOPIC" \
|
||||
--announce \
|
||||
--message "$MESSAGE"
|
||||
|
||||
echo "=== Automation successfully registered! ==="
|
||||
|
||||
@@ -48,7 +48,7 @@ kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron a
|
||||
--cron "$CRON_SCHEDULE" \
|
||||
--tz "$TIMEZONE" \
|
||||
--agent "itguy" \
|
||||
--model "openrouter/deepseek/deepseek-v4.1-flash" \
|
||||
--model "openrouter/deepseek/deepseek-v4.1-flash-1017" \
|
||||
--session "isolated" \
|
||||
--channel "telegram" \
|
||||
--to "$TELEGRAM_GROUP" \
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
# app package
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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" },
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# AGENTS.md — Kitchen e-Paper Display (`esphome_display_kitchen`)
|
||||
|
||||
> [!NOTE]
|
||||
> **Domain**: ESP32 microcontroller driving the e-Paper display in the kitchen.
|
||||
> **Source Location**: `projects/esphome_display_kitchen/`
|
||||
> **Integrations**: Home Assistant Native API (`https://hass.moritzgraf.de`), Prometheus metrics client.
|
||||
> **Hardware**: Waveshare e-Paper display on ESP32 (`nodemcu-32s`).
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Build, Flash & Messaging Tooling
|
||||
|
||||
- **Interactive Flashing Wizard**:
|
||||
```bash
|
||||
cd projects/esphome_display_kitchen
|
||||
uv run flash.py
|
||||
```
|
||||
Provides an interactive CLI to Validate, Compile, Upload, and view Logs for `esdisplay_kitchen.yaml`.
|
||||
- **Direct Terminal Messaging**:
|
||||
```bash
|
||||
cd projects/esphome_display_kitchen
|
||||
./send_message.sh "Your custom display message"
|
||||
```
|
||||
Uses the Home Assistant long-lived token from `k8s/home-assistant/` to set the `input_text.epaper_message` helper in Home Assistant, which instantly pushes updates to the screen.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Rules & Standards
|
||||
|
||||
### 1. Secret Management
|
||||
* **NEVER** write plaintext Wi-Fi passwords, tokens, or API keys directly into YAML configurations.
|
||||
* **DO NOT** modify `.gitignore` to allow committing `.env` files.
|
||||
* Use ESPHome's `!env_var VAR_NAME` to inject environment variables at compile-time. Any new secrets must be declared in `.env.template` and configured locally in `.env`.
|
||||
|
||||
### 2. Configuration Standards
|
||||
* Board architecture: ESP32 (`nodemcu-32s` with `arduino` framework).
|
||||
* SNTP time synchronization configured via `time` component for clock rendering.
|
||||
* Sensor values (outdoor temperature, indoor climate) are dynamically pulled from Home Assistant entities.
|
||||
@@ -5,7 +5,7 @@ set -e
|
||||
CDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
|
||||
# Read the token from the home-assistant secrets folder
|
||||
TOKEN_FILE="${CDIR}/../k8s/home-assistant/tmp_long_lived_token.secret.yml"
|
||||
TOKEN_FILE="${CDIR}/../../k8s/home-assistant/tmp_long_lived_token.secret.yml"
|
||||
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo "Error: Home Assistant token file not found at $TOKEN_FILE."
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -0,0 +1,76 @@
|
||||
# AGENTS.md — Grabengasse 7 Home Portal (`grabengasse`)
|
||||
|
||||
> [!NOTE]
|
||||
> **Domain**: Local climate, environment, and smart home portal for Grabengasse 7.
|
||||
> **Live Production URL**: `https://grabengasse7.de`
|
||||
> **Kubernetes Manifests**: `k8s/grabengasse/` (Namespace: `grabengasse`)
|
||||
> **Images**:
|
||||
> - Frontend: `registry.haumdaucher.de/website/grabengasse_frontend:latest`
|
||||
> - Backend: `registry.haumdaucher.de/website/grabengasse_backend:latest`
|
||||
> **Source Location**: `projects/grabengasse/`
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Architecture & Tech Stack
|
||||
|
||||
1. **Frontend (`frontend/`)**:
|
||||
- **Framework**: Vue 3 (Composition API) + Vite + TypeScript.
|
||||
- **Styling**: Retro-chic aesthetic with pixel art accents, glassmorphic cards, and an optional CRT scanline toggle.
|
||||
- **Data Visualization**: Chart.js / Vue-Chartjs / Apexcharts for 24h temperature, humidity, wind, solar, and rain telemetry.
|
||||
- **Serving Runtime**: Nginx (`nginx:alpine`) in Docker.
|
||||
|
||||
2. **Backend (`backend/`)**:
|
||||
- **Framework**: FastAPI (Python 3.11).
|
||||
- **Telemetry Source**: Direct HTTP queries to cluster Prometheus (`http://prometheus-operator-kube-p-prometheus.monitoring.svc.cluster.local:9090`) aggregating metrics from the Shelly WS90 weather station.
|
||||
- **Endpoints**:
|
||||
- `GET /healthz`: Liveness and readiness probe.
|
||||
- `GET /api/v1/weather/current`: Live weather telemetry (temperature, humidity, wind speed, solar radiation, rain).
|
||||
- `GET /api/v1/weather/history`: Historical timeseries for 24h graphing.
|
||||
|
||||
3. **Kubernetes Ingress & Routing (`k8s/grabengasse/`)**:
|
||||
- Ingress Host: `grabengasse7.de` with Traefik ingress controller and `cert-manager` TLS.
|
||||
- `/` → `grabengasse-frontend` (Port 80)
|
||||
- `/api` → `grabengasse-backend` (Port 8000)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Build & Deployment Commands
|
||||
|
||||
```bash
|
||||
cd projects/grabengasse
|
||||
make compile # Build frontend static assets (npm run build)
|
||||
make build # Build both frontend and backend Docker images (linux/amd64)
|
||||
make push # Push images to registry.haumdaucher.de
|
||||
make deploy # Apply k8s manifests and rollout restart pods
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Repository Structure
|
||||
|
||||
```
|
||||
projects/grabengasse/
|
||||
├── Makefile # Coordinated multi-service build and deploy
|
||||
├── AGENTS.md # This handbook
|
||||
├── README.md # Project overview
|
||||
├── frontend/ # Vue 3 SPA
|
||||
│ ├── Dockerfile
|
||||
│ ├── Makefile
|
||||
│ ├── nginx.conf
|
||||
│ ├── package.json
|
||||
│ ├── src/
|
||||
│ │ ├── App.vue # Weather dashboard layout & state
|
||||
│ │ ├── components/ # Weather gauges, telemetry cards & retro CRT overlay
|
||||
│ │ └── scripts/ # generate_icons.py (Pixel art icon generator)
|
||||
│ └── public/ # Static icons, manifest, and service worker
|
||||
└── backend/ # FastAPI microservice
|
||||
├── Dockerfile
|
||||
├── Makefile
|
||||
├── requirements.txt
|
||||
├── main.py # FastAPI application entrypoint
|
||||
├── config.py # CORS & environment configuration
|
||||
├── routers/
|
||||
│ └── weather.py # /api/v1/weather routes
|
||||
└── services/
|
||||
└── prometheus.py # Prometheus query client
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
REGISTRY ?= registry.haumdaucher.de
|
||||
FRONTEND_IMAGE ?= $(REGISTRY)/website/grabengasse_frontend:latest
|
||||
BACKEND_IMAGE ?= $(REGISTRY)/website/grabengasse_backend:latest
|
||||
NAMESPACE ?= grabengasse
|
||||
|
||||
.PHONY: all compile dev build push deploy rollout build-frontend build-backend push-frontend push-backend
|
||||
|
||||
all: compile build push deploy
|
||||
|
||||
compile:
|
||||
cd frontend && npm run build
|
||||
|
||||
dev-frontend:
|
||||
cd frontend && npm run dev
|
||||
|
||||
dev-backend:
|
||||
cd backend && uvicorn main:app --reload
|
||||
|
||||
build: build-frontend build-backend
|
||||
|
||||
build-frontend:
|
||||
cd frontend && docker build --platform linux/amd64 -t $(FRONTEND_IMAGE) .
|
||||
|
||||
build-backend:
|
||||
cd backend && docker build --platform linux/amd64 -t $(BACKEND_IMAGE) .
|
||||
|
||||
push: push-frontend push-backend
|
||||
|
||||
push-frontend:
|
||||
docker push $(FRONTEND_IMAGE)
|
||||
|
||||
push-backend:
|
||||
docker push $(BACKEND_IMAGE)
|
||||
|
||||
deploy:
|
||||
kubectl apply -f ../../k8s/grabengasse/backend.yaml -f ../../k8s/grabengasse/frontend.yaml -f ../../k8s/grabengasse/ingress.yaml
|
||||
$(MAKE) rollout
|
||||
|
||||
rollout:
|
||||
kubectl rollout restart deployment/grabengasse-frontend -n $(NAMESPACE)
|
||||
kubectl rollout restart deployment/grabengasse-backend -n $(NAMESPACE)
|
||||
kubectl rollout status deployment/grabengasse-frontend -n $(NAMESPACE)
|
||||
kubectl rollout status deployment/grabengasse-backend -n $(NAMESPACE)
|
||||
@@ -0,0 +1,12 @@
|
||||
# Grabengasse 7 Home Portal 🌦️
|
||||
|
||||
Climate and environmental monitoring web dashboard for Grabengasse 7 (`https://grabengasse7.de`).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`frontend/`**: Vue 3 + TypeScript SPA displaying real-time telemetry from the Shelly WS90 outdoor weather station.
|
||||
- **`backend/`**: FastAPI backend fetching Prometheus metric aggregations for temperature, humidity, rain, wind, and solar irradiance.
|
||||
|
||||
## Development & Deployment
|
||||
|
||||
See [AGENTS.md](AGENTS.md) for architecture details and the [Makefile](Makefile) for standard build commands (`make compile`, `make build`, `make deploy`).
|
||||
|
Before Width: | Height: | Size: 947 B After Width: | Height: | Size: 947 B |
|
Before Width: | Height: | Size: 436 B After Width: | Height: | Size: 436 B |
|
Before Width: | Height: | Size: 1019 B After Width: | Height: | Size: 1019 B |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |