Compare commits

...
143 Commits
Author SHA1 Message Date
Moritz Graf 9903194316 feat(shelly): optimize dew point ventilation setpoints, duty cycle, and night-window gating
- Raise target humidity setpoints from 40% to 55% with a 2% hysteresis shut-off
- Eliminate 9999-min unlimited runtime bypass and strictly cap bursts at 35 min
- Rebalance duty cycle to 1:1 ratio (35 min run, 35 min cooldown rest)
- Implement Shelly internal RTC time-of-day check to restrict routine drying to 02:00-07:00
- Add delta-T guard requiring spread >= 3.5°C and max 20 min bursts when outside is >8°C colder
- Update AGENTS.md documentation to reflect optimized gates and thresholds
2026-10-20 15:46:30 +02:00
Moritz Graf 28eedea372 refactor(terraform): move shelly to projects/shelly_taupunktluefter and flatten core infra 2026-10-20 09:39:26 +02:00
Moritz Graf a34fa7ee08 fix(cron): resolve Garmin sync payload mismatch, harden cronjobs, and update wiki submodule 2026-10-18 20:33:26 +02:00
Moritz Graf c33b6e94fb fix(openclaw): configure 64k completion headroom for deepseek-v4.1-flash, promote cloudflare, and onboard fireworks 2026-10-18 11:06:50 +02:00
Moritz Graf dd60a095c6 feat(openclaw): upgrade fleet to DeepSeek V4.1 Flash 1017 and harden operations
- Upgrade OpenClaw primary model from deepseek-v4.1-flash to deepseek-v4.1-flash-1017 across all interactive agents, heartbeats, compaction, and cron jobs.
- Configure hardened OpenRouter provider routing for 1017 (deepinfra, inference-net, parasail, cloudflare) with strict fp8/bf16 quantization and tool_choice verification.
- Retain deepseek-v4.1-flash (0910), z-ai/glm-5.3-flash, and qwen3.8-flash in the automated fallback chain.
- Update ADR 0002 with 1017 economics (30% price reduction on prompt/cache, +60% intelligence benchmark increase) and a 7-point provider qualification playbook.
- Add operational scripts and tooling for session compaction, subagent vector deduplication, gateway preflight, and triage diagnostics in k8s/openclaw.
- Update DuckDB backend engine, git-sync logic, and test suites.
- Enhance Mila tracking UI components and add container build configuration.
- Sync Terraform infrastructure state for Authentik.
2026-10-18 10:54:59 +02:00
Moritz Graf 7ce1da18ab fix(mila): fix last feed display, hide diaper tile when empty today, and add resilient git-init 2026-10-17 08:01:39 +02:00
Moritz Graf 5b8432caf4 feat(family): deploy Single-Writer DuckDB backend, Mila parent cockpit & feeding analytics, and Moritz macro tracker 2026-10-17 07:21:49 +02:00
Moritz Graf ef50d87bd7 fix(openclaw): add automatic mcp-venv fallback to sync_crons.py 2026-10-16 21:28:15 +02:00
Moritz Graf ed3529a9d7 feat(openclaw): modularize scripts, implement crons-as-code and EaC governance 2026-10-16 21:25:33 +02:00
Moritz Graf f4d8210cc4 fix(openclaw): fix tracking tool allowlist, resolve research fastpath and cap bootstrap bloat 2026-10-14 15:34:45 +02:00
Moritz Graf 02fcbd3174 feat(openclaw): strip tracking agent prompt bloat and add 15m deterministic sliding-window compaction
- Disable bootstrap context injection (contextInjection: never) and skill catalog injection (skills: []) on mila_tracker and nutrition_tracker, dropping baseline prompt by 95% (<350 tokens)
- Disable reasoning loops on tracking agents (thinkingDefault: off) for instantaneous sub-second execution
- Add compact_tracking_sessions.py with deterministic 8-line sliding window (--max-lines 8) requiring 0 LLM tokens
- Register automated 15-minute cron job (tracking-session-sliding-window) in OpenClaw gateway
- Streamline setup-compaction-cron.sh to sync python scripts directly and manage both 15m and nightly maintenance
2026-10-14 14:14:35 +02:00
Moritz Graf a0c8c3828c fix(openclaw): resolve tracking compaction hang, restrict tracking tools and enforce lifecycle management 2026-10-14 13:39:38 +02:00
Moritz Graf cab8dd290f feat(openclaw): switch mila_tracker primary model to DeepSeek V4.1 Flash and add koch recipe delegation 2026-10-13 09:49:04 +02:00
Moritz Graf c85a60d920 feat(openclaw): update agent instance configuration and sync knowledge-wiki submodule 2026-10-13 09:41:25 +02:00
Moritz Graf a2cf3bfe39 fix(openclaw): decouple Gemini 3.8 Flash vision perception while preserving GLM-5.3-flash as primary chat model 2026-10-12 22:21:06 +02:00
Moritz Graf d84d7b0bec fix(openclaw): upgrade fitness agent model to gemini-3.8-flash and document issue 28 2026-10-12 22:04:22 +02:00
Moritz Graf 6acceec701 docs(openclaw): document lasagna logging recovery and compaction in issue 27 2026-10-12 21:05:22 +02:00
Moritz Graf 972d629df8 chore(wiki): update knowledge-wiki submodule pointer 2026-10-12 21:04:06 +02:00
Moritz Graf a12b596053 fix(openclaw): allocate Gemini 3.8 Flash to koch agent, harden vision and tool protocols 2026-10-12 19:52:11 +02:00
Moritz Graf 393d928b19 feat(openclaw): tune compaction to keepRecentTokens 3k and convert Tier A to deep-compaction 2026-10-12 17:10:00 +02:00
Moritz Graf 8f01b75bd6 refactor(structure): reorganize monorepo with projects, terraform hierarchy, and strict k8s namespace rules 2026-10-12 13:49:41 +02:00
Moritz Graf 4831147087 docs(openclaw): document issue 25 duplicate porridge logging & update triage filter 2026-10-12 12:09:57 +02:00
Moritz Graf 3b190988be feat(projects): migrate haumdaucherde website into monorepo and document in AGENTS.md 2026-10-12 10:42:30 +02:00
Moritz Graf f225d4fe4e chore(submodule): update knowledge-wiki pointer with public icons 2026-10-12 08:49:28 +02:00
Moritz Graf ebade04e81 chore(submodule): update knowledge-wiki pointer 2026-10-12 08:36:25 +02:00
Moritz Graf 0c48b2e9c1 chore(submodule): update knowledge-wiki with growth tracking schema and views 2026-10-12 08:34:10 +02:00
Moritz Graf 978fdae051 chore(submodule): update knowledge-wiki pointer 2026-10-12 08:25:43 +02:00
Moritz Graf 9e52e279dd chore(submodule): update knowledge-wiki pointer 2026-10-12 08:16:32 +02:00
Moritz Graf 71188a5117 chore(submodule): update knowledge-wiki pointer 2026-10-12 07:36:52 +02:00
Moritz Graf 692bcc2084 feat(openclaw): add setup-dashboards-cron.sh and update knowledge-wiki submodule 2026-10-11 22:25:46 +02:00
Moritz Graf 6b3b024726 chore(submodule): sync knowledge-wiki with latest live S3 rendering 2026-10-11 22:16:22 +02:00
Moritz Graf b45ca01e8e chore(submodule): update knowledge-wiki pointer with corrected sleep data and dashboard 2026-10-11 22:14:33 +02:00
Moritz Graf 86030cf474 fix(openclaw): harden setup-garmin-cron.sh, register nightly sync cron, and document Issue 24 2026-10-11 22:04:57 +02:00
Moritz Graf 4705de9602 chore(submodule): update knowledge-wiki pointer with corrected nutrition and garmin data 2026-10-11 22:01:03 +02:00
Moritz Graf 4d30c654e9 feat(ingress): add legacy redirect middleware for root dashboard URLs 2026-10-11 21:43:34 +02:00
Moritz Graf e59297a404 docs(adr): document /outpost.goauthentik.io callback path in ADR 0005 2026-10-11 21:41:27 +02:00
Moritz Graf dc96a1a547 fix(ingress): route /outpost.goauthentik.io through ForwardAuth on hosting.haumdaucher.de 2026-10-11 21:41:09 +02:00
Moritz Graf ccfce0fa5d feat(security): implement ADR 0005 Authentik boundary and hosting isolation 2026-10-11 21:25:12 +02:00
Moritz Graf fbc04f8892 chore(wiki): update knowledge-wiki submodule with Mila PWA assets 2026-10-11 20:58:58 +02:00
Moritz Graf 68c8dd91b9 feat(openclaw): update Koch topic 21 with auto-updating moritz.html dashboard and anti-lag rules 2026-10-11 20:41:57 +02:00
Moritz Graf d778883fe3 fix(openclaw): eliminate penultimate turn lag and harden Hebamme topic 22 prompt 2026-10-11 20:09:11 +02:00
Moritz Graf 6ae70d8ade fix(openclaw): clarify datastore stop end/since parameters and enforce multi-event logging for hebamme 2026-10-11 15:08:21 +02:00
Moritz Graf ac6adcd0f4 fix(openclaw): update hebamme topic 22 systemPrompt, datastore protocol, and knowledge-wiki submodule 2026-10-11 12:17:41 +02:00
Moritz Graf 4f51fb6a83 docs(openclaw): add issue 23 troubleshooting for koch agent tool stall & update knowledge-wiki 2026-10-10 15:53:02 +02:00
Moritz Graf bb5ca769db feat(ops): add openclaw-triage tool, instant diagnostic playbook, and simplicity invariant 2026-10-10 14:25:16 +02:00
Moritz Graf 5b3dfe2a50 fix(openclaw): harden koch agent prompts, clarify wiki_get schema, and document Issue 22 2026-10-10 13:06:28 +02:00
Moritz Graf 6ea08ea497 fix(openclaw): implement turn-replay daemon, vector dedup, 6Gi memory limit, and 25s probe timeouts 2026-10-10 10:16:30 +02:00
Moritz Graf 0cbd39d0d8 fix(openclaw): handle interrupted turns in preflight recovery and decouple recherche prompts 2026-10-10 09:01:13 +02:00
Moritz Graf 299343798f docs(troubleshooting): document issue 20 on mila datastore reasoning loops 2026-10-09 19:25:54 +02:00
Moritz Graf abba4d6da1 fix(openclaw): update hebamme topic prompt with open session notice rule 2026-10-09 19:19:20 +02:00
Moritz Graf 2d2dfd94c6 fix(hebamme): add anti-reasoning loop prompt guardrail and sync knowledge-wiki 2026-10-09 18:48:14 +02:00
Moritz Graf a590d190bd fix(openclaw): add calendar tool fastpaths for regensburg, forbid markets, and enforce anti-hallucination rules 2026-10-09 12:37:21 +02:00
Moritz Graf 45562c23d9 feat(openclaw): implement two-tier session maintenance with nightly resets for state-externalized agents 2026-10-09 10:36:43 +02:00
Moritz Graf 4a63e273fc chore(knowledge-wiki): update submodule pointer to 10d024d (hebamme guardrails) 2026-10-09 09:26:44 +02:00
Moritz Graf 36e0887c73 feat(openclaw): persist operational invariants in AGENTS.md and add openclaw-ops skill runbook 2026-10-09 07:54:20 +02:00
Moritz Graf aeea7a8fea fix(openclaw): harden session compaction cron against stale token metadata and enable user compaction notices 2026-10-07 09:38:39 +02:00
Moritz Graf e0ee1dc7b4 chore(wiki): update knowledge-wiki submodule 2026-10-07 09:02:00 +02:00
Moritz Graf 53f132d446 fix(openclaw): harden probes, runtime self-healing preflight and session recovery 2026-10-07 08:43:28 +02:00
Moritz Graf a0e461a0b7 fix(openclaw): remove gemini-2.5-flash from ocr and vision fallbacks 2026-10-06 21:41:19 +02:00
Moritz Graf 4e2a7120d6 fix(openclaw): configure gemini-3.8-flash vision model, document Issue 17, and update wiki submodule 2026-10-06 21:13:35 +02:00
Moritz Graf bfa8ee19b7 chore(knowledge-wiki): update submodule to include completed task 2026-10-06 20:42:51 +02:00
Moritz Graf 91bc5a8bf8 docs(openclaw): update Issue 16 with fallbacks, koch grounding, and prometheus rules 2026-10-06 20:36:14 +02:00
Moritz Graf 744f8e243b feat(openclaw): add PrometheusRule for pod readiness, crashlooping, and PVC storage 2026-10-06 20:35:47 +02:00
Moritz Graf 0f606e3ddf chore(knowledge-wiki): update submodule to include latest standard foods and koch prompt 2026-10-06 20:32:35 +02:00
Moritz Graf 9a7d554183 feat(openclaw): add structured model fallbacks for all specialist subagents 2026-10-06 20:24:00 +02:00
Moritz Graf 5aa3b99316 fix(openclaw): add pre-flight self-healing init hook, repair FK cascade, and document Issue 16 2026-10-06 20:05:44 +02:00
Moritz Graf 1ffbf3d6b7 fix(openclaw): eliminate DOMAIN_STATE HTML comment tags and add clean Telegram output guardrails 2026-10-06 19:38:47 +02:00
Moritz Graf f717a9a2f7 feat(datastore): implement structured personal data layer (ADR 0004, ForwardAuth /family, and OpenClaw protocol) 2026-10-06 18:18:27 +02:00
Moritz Graf 9d890a290f feat(skills): add marktguru supermarket offer and price comparison skill
Introduce the marktguru agent skill and MCP integration for searching supermarket deals, comparing product prices, and querying supported retail chains:
- Add Marktguru API client and MCP server script
- Add skill definition (SKILL.md) and supported supermarkets reference
- Add test suite covering Marktguru API integration and offer queries
2026-10-05 21:20:16 +02:00
Moritz Graf c3c7a4c8cf fix(openclaw): resolve restart deadlock, foreign keys, cold-start latency, and wiki audit cron 2026-10-05 09:36:11 +02:00
Moritz Graf b6ed5fd00d fix(openclaw): harden 4Gi memory ceiling, increase liveness tolerance, add WAL checkpointing and cron delivery fix 2026-10-03 08:03:18 +02:00
Moritz Graf 4b85dd0eea chore(wiki): update knowledge-wiki submodule pointer to dbae215 2026-09-30 21:31:50 +02:00
Moritz Graf fa6779e87d fix(openclaw): switch recherche agent to deepseek-v4.1-flash and document STT quota failure 2026-09-29 20:04:01 +02:00
Moritz Graf 61ac5baa6f fix(openclaw): switch multi-agent compaction to true LLM summarization and document split-turn drift 2026-09-29 08:48:15 +02:00
Moritz Graf 5bf1a29f4a docs(openclaw): document issue 7 root cause and resolution in troubleshooting guide 2026-09-29 08:08:42 +02:00
Moritz Graf 74e32ed6bf fix(openclaw): replace hardlinks with copies in wiki-backup cronjob to satisfy bootstrap file security constraints 2026-09-29 08:06:29 +02:00
Moritz Graf bdcf6a8d18 feat(openclaw): automate nightly multi-agent session compaction and document incident 2026-09-28 20:18:02 +02:00
Moritz Graf 613a19bcca feat(openclaw): enable create-website skill, update system prompt, and document troubleshooting findings 2026-09-28 07:39:26 +02:00
Moritz Graf 8d17a7b8d7 feat(openclaw): add bidirectional subagent AGENTS.md sync in backup cronjob and update knowledge-wiki submodule 2026-09-27 22:06:56 +02:00
Moritz Graf d15337088f fix(authentik): extend silverbullet access_token_validity to 7 days to prevent 10m session re-auth popups 2026-09-27 21:54:07 +02:00
Moritz Graf bd275bc7f2 chore(wiki): update submodule to f043d43 2026-09-27 21:35:39 +02:00
Moritz Graf 222cf1a8ad chore(wiki): update submodule to 3018bcf with clean lint state 2026-09-27 21:34:50 +02:00
Moritz Graf caf2c07565 build(moritzgrafde): simplify Dockerfile to serve pre-built static assets
Remove multi-stage Node.js build from the Dockerfile and copy pre-built dist assets directly into the nginx:alpine runtime image.
2026-09-27 21:26:54 +02:00
Moritz Graf c9e4ba519a fix(silverbullet): bypass forwardauth for service_worker.js and static assets to eliminate redirect error 2026-09-27 21:24:50 +02:00
Moritz Graf 41df233840 feat(wiki): isolate silverbullet pvc, add pre-commit wall, family group, and audit alert 2026-09-27 21:15:10 +02:00
Moritz Graf b54b9fdb53 chore(infra): update knowledge-wiki submodule and authentik terraform state 2026-09-27 11:41:10 +02:00
Moritz Graf 5d3d958ca1 feat(wiki): implement Google OKF v0.2 migration, decoupled audit CronJob, and SilverBullet web portal
- Update OpenClaw memory-wiki renderMode to 'native'
- Add decoupled Kubernetes CronJob openclaw-wiki-audit for OKF health checks
- Deploy SilverBullet PWA at wiki.moritzgraf.de with shared volume and Git auto-commit
- Add SilverBullet forward-auth proxy provider and admin policy in Authentik Terraform
- Update knowledge-wiki submodule to OKF v0.2 schema migration commit
2026-09-27 11:34:04 +02:00
Moritz Graf d2dac3470f feat(moritzgrafde): rewrite personal website with Vue 3, Vite, and Tailwind CSS
- Replace legacy Hugo static site generator and hugo-goa theme with a modern Vue 3 + Vite + TypeScript application
- Implement Clean Enterprise design system using Tailwind CSS with light/dark mode support
- Position website around Cloud Platform & Infrastructure Leadership (GCP & AWS central platforms, Kubernetes at scale, enterprise spin-off governance, and systems depth) without chronological resumes
- Add 6 comprehensive capability dossiers and verified credential badges (CKA, PSM II, M.Sc.)
- Implement interactive skill matrix with real-time text search and category filtering
- Add engineering philosophy section, contact channels with one-click email copying, and Impressum modal
- Configure multi-stage Dockerfile (Node builder to Nginx runtime) with custom gzip, caching, and security headers
- Update build scripts (Makefile, build.sh), .gitignore, and AGENTS.md documentation across the monorepo
2026-09-26 20:37:53 +02:00
Moritz Graf afb80bbba8 feat(projects): migrate moritzgrafde website into monorepo and document in AGENTS.md 2026-09-26 11:58:30 +02:00
Moritz Graf 945d80b232 chore(k8s,dns): decommission corona workloads and prune DNS records 2026-09-26 11:50:46 +02:00
Moritz Graf c91ab16b9b feat(sasch40): add printable medieval retro A4 proclamation flyer PDF with story and QR code 2026-09-25 10:07:03 +02:00
Moritz Graf bf15c2e9cb feat(sasch40): deliver schankraum v6 and zero-occlusion hotspot positions
- Replace schankraum with v6: exactly one Queen Verena with tiara on upper gallery, Lutz in hot tub, Armend at forge, rustic beer taps, no crowns at patron tables, seamless secret oak trapdoor without text
- Rename Papa Markus button to 'Markus (Vater)' and place above his head in stall window to avoid facial/body occlusion
- Relocate character hotspot badges across all tavern scenes (Verena, Lutz, Armend, Amy, Mane, Viktor, Pü) onto railings, table rims, and lower body levels to guarantee zero occlusion of faces or bodies
2026-09-25 09:46:45 +02:00
Moritz Graf 109d47d8d3 feat(sasch40): authentic 3 sisters portraits, 9:16 responsive stage with medieval ornaments, and hotspot fixes
- Recreate 16-bit LucasArts portraits for Saskia, Maja, and Miriam matching real-life source photos
- Add 3 sisters showcase in Act II intro modal and golden portrait badges on goal platforms
- Fix Act III background scaling: lock scene to 9:16 aspect ratio with medieval ornamental side borders and corner brackets
- Ensure 100% full background visibility with zero cropping and lock hotspot buttons to image features across all screen sizes
- Fix cache issue by referencing schankraum_v5.jpg with cache buster query
- Re-position NAT button to feet (top: 70%) and Mario button to knees (top: 79%) to eliminate face occlusion
2026-09-25 09:23:31 +02:00
Moritz Graf 07fef4547c feat(sasch40): overhaul Act III tavern schankraum and canonical papa markus scene
- Generate and integrate authentic 16-bit LucasArts taproom scene with Theke, brass taps, bartender, drinking patrons, and bunker trapdoor
- Realign Schankraum hotspots for Verena, Lutz in hot tub, Armend at forge, room transitions, and centered bunker trapdoor
- Update Papa Markus canonical portrait and Taiwan street dining scene to be bald and clean-shaven with glasses and modern attire
- Enforce hotspot zero-occlusion rules for signposts, characters, and mobile room navigation bars
- Document Papa Markus specifications, tavern invariants, and layout guidelines in AGENTS.md
- Add automated sister capture and playthrough recording scripts
2026-09-25 08:36:39 +02:00
Moritz Graf b61cf84082 fix(sasch40): bald Markus portrait & scene, authentic Schankraum taproom, and cleaned up Hinterland navigation 2026-09-24 22:15:08 +02:00
Moritz Graf fbc4158016 feat(sasch40): v4.0 full-screen mobile conversion, Hinter den Bergen room with Liars Dice, beat-only concert audio, and updated art 2026-09-24 21:11:12 +02:00
Moritz Graf 8a0b65c3fa feat(sasch40): major overhaul with doodle jump, party bunker, and guitar hero
- Add global settings & pause modal (gear icon) with mute and reset, removing obsolete bag button
- Extend Prologue with Flo Maier (castle fire game over & Löschzug restart) and Viktor (vampire bite game over & Knoblauch restart)
- Rebuild Akt II as 3-level vertical Doodle Jump climber to rescue sisters Saskia, Maja, and Miriam with custom pixel portraits
- Update Akt III Schankraum with seamless oak floor, double-tap 'Luke zum Party Bunker', duel intro modals, and Hansi's departure in Hinterland
- Rebuild Akt IV as 3-lane medieval Guitar Hero rock concert with live crowd meter, combo multipliers, and Hansi duo
- Update AGENTS.md architectural documentation and playtest test suite
2026-09-24 18:22:28 +02:00
Moritz Graf 6374ba9e8a feat(sasch40): add Nebenraum, Marktplatz minigame & trapdoor to Hinterzimmer
- Split GameBoy chassis brand header: 'AKT III' on clean dedicated second line
- Add 5-room tavern architecture: Schankraum, Nebenraum, Marktplatz, Hinterland, Hinterzimmer
- Introduce Nebenraum (Kemenate/Atelier) with Amy at her easel painting the neon banner and Zuluman magic mirror
- Update Marktplatz scene with Mario juggling chainsaws/hops and NAT on the festival stage
- Implement Wildschwein-Schnick-Schnack-Schnuck minigame against NAT (Best of 3, Keiler > Dickicht > Lanze > Keiler)
- Replace secret tap with cellar floor trapdoor ('Luke zum Hinterraum') initially invisible
- Update automated playtest covering all 25 screens with zero console/network errors
- Document all architectural decisions in projects/sasch40/AGENTS.md
2026-09-24 12:37:56 +02:00
Moritz Graf bbae8a8c78 docs(sasch40): document cache busting, easter egg invisibility, and localStorage persistence 2026-09-24 11:10:09 +02:00
Moritz Graf e45dbc2e19 feat(sasch40): improve Act III, add state persistence and fix portraits
- Fix character portrait crops and version assets to portrait_v2, ensuring correct identities (resolving Nat/Verena mismatch)
- Update Schankraum scene image and configure Nginx cache-control to prevent stale asset caching
- Make secret beer tap completely invisible in tavern until clicked
- Reposition room hotspots in Act III to eliminate overlapping click targets
- Add exit button [✕] to Bavarian Gstanzl duel minigame
- Implement localStorage persistence in gameStore to maintain progress across page reloads
- Enable clean game state and storage reset when clicking "Nochmal spielen"
- Expand automated playtest script to cover Nat dialogue, duel exit, reload persistence, and game restart
2026-09-24 11:03:22 +02:00
Moritz Graf 7a7038e2c2 feat(sasch40): multi-room tavern, authentic character portraits, pre-level start screens & bollerwagen redesign 2026-09-24 10:02:57 +02:00
Moritz Graf 4488749d6f docs(sasch40): document architecture decisions, agent rules, and character system in AGENTS.md 2026-09-24 07:59:01 +02:00
Moritz Graf f6d39f01da feat(sasch40): polish retro game layout, clean tavern scene, and improve mobile HUD
- Hide GameBoy D-Pad and action buttons during RPG and dialogue modes
- Add prequel intro landing screen for QR code arrivals with Haumdaucher lore
- Clean baked-in text from tavern scene background and streamline navigation bar
- Add safe-zone margins and HTML HUD in platformer and party express acts
- Optimize Certificate modal layout and scoring breakdown for mobile screens
- Add automated end-to-end browser playtest script with Puppeteer
- Accelerate Docker build via native pre-compilation
2026-09-24 07:55:42 +02:00
Moritz Graf 5852479c21 feat(sasch40): add retro birthday game subproject and k8s deployment
- Add Vue 3 + Vite + TypeScript retro game: 'Saschas Kronkorken Kreuzzug: 40 Jahre Bass & Gerstensaft'
- Add dynamic GameBoy layout: controls visible only during arcade modes, fullscreen during dialogue and tavern
- Add QR code welcome & prequel screen with lore, feature preview, and audio hints
- Add competitive party highscore and ranking breakdown on royal certificate
- Add dedicated individual 1:1 character portraits for Sascha, Verena, NAT, Hansi, Mop, Lutz, Armend, Amy, Mane, Pü, and Mario
- Add Dockerfile, Makefile, and K8s deployment at sasch40.haumdaucher.de
- Update openclaw timeout configuration
2026-09-23 21:56:34 +02:00
Moritz Graf d11a84a0d3 fix(openclaw): use base64 patch for garmin_mcp workouts normalization in init script 2026-09-20 08:09:58 +02:00
Moritz Graf f2ef5a8834 fix(openclaw): enforce sportType integrity on Garmin workout uploads 2026-09-20 08:06:06 +02:00
Moritz Graf 80a056f720 docs(bootstrap): document single-node cluster upgrade procedures and modernize init.sh
- Update init.sh with smart checkout, patch application, sync-inventory alias, and runbook output
- Rewrite bootstrap/README.md with step-by-step upgrade guide, architectural invariants, and troubleshooting
- Update bootstrap/AGENTS.md with single-node operational rules and OS constraints
2026-09-19 22:58:22 +02:00
Moritz Graf 87649e6e22 chore(bootstrap): upgrade kubespray to release-2.28 and kubernetes to v1.32.13
- Bump Kubespray baseline to release-2.28 (last release supporting Ubuntu 20.04)
- Upgrade Kubernetes control plane and node to v1.32.13
- Configure single-node cluster settings (drain_nodes: false)
- Add patch for Kubespray v1beta4 kubeadm preflight error handling
- Update init.sh, AGENTS.md, and README.md with two-phase upgrade instructions
2026-09-19 22:57:19 +02:00
Moritz Graf c9e21c082d chore(wiki): update knowledge-wiki submodule pointer 2026-09-19 21:06:42 +02:00
Moritz Graf a7b93ca34a feat(openclaw): add chart-to-chat skill to prompt and update PATH for dct 2026-09-19 21:06:07 +02:00
Moritz Graf 18a7292844 fix(dns,k8s): add moritzgraf.de apex A record, modernize ingress, and resolve cluster alerts
- terraform: add ovh_domain_zone_record.moritzgraf_domain pointing to cluster IP
- k8s/web: modernize Ingress to networking.k8s.io/v1 with traefik class and auto-renew TLS certificate
- k8s/backup: set activeDeadlineSeconds and backoffLimit: 3 on gdrive-to-gphotos, clean dead pods
- k8s/openclaw: set failedJobsHistoryLimit: 1 on wiki backup cronjob and purge stale failed job
- k8s/coder: increase postgresql CPU limit to 500m to eliminate CFS CPU throttling
2026-09-19 18:19:54 +02:00
Moritz Graf 79fa817480 feat(openclaw): enable itguy autonomous weekly health audit and 3-repo architecture 2026-09-19 16:58:23 +02:00
Moritz Graf 474af29c4d feat(openclaw): integrate YouTube and Google Maps Platform MCP skills and GCP infrastructure 2026-09-18 11:46:10 +02:00
Moritz Graf bdfd5f5057 feat(calendar): update OpenClaw calendar prompt with RRULE, transparency, reminders, free slots, and move capabilities 2026-09-18 11:29:49 +02:00
Moritz Graf 0282b82c7a fix(openclaw): increase timeout, bind heartbeat to main, add shared hardlinks, cleanup cron, and enrich subagent topic prompts 2026-09-18 07:57:46 +02:00
Moritz Graf 87b09ed55d feat(openclaw): add doktor, itguy, merged grabengasse_media and pin 10 telegram topics 2026-09-17 14:12:49 +02:00
Moritz Graf e9376c6552 fix(openclaw): configure timeout and provider order for glm-5.3-flash 2026-09-17 13:38:54 +02:00
Moritz Graf 10a94c3512 feat(openclaw): enable grill-me skill in skills.entries 2026-09-17 13:34:53 +02:00
Moritz Graf c1cd638039 feat(openclaw): add read-only github mcp server with tool search 2026-09-17 11:09:08 +02:00
Moritz Graf a93c1e3510 chore(wiki): update knowledge-wiki pointer to 20e8556 2026-09-17 09:47:39 +02:00
Moritz Graf e5fa7c6ff3 feat(openclaw): optimize Garmin tool calling with parameter signatures and fast-path 2026-09-17 09:42:20 +02:00
Moritz Graf ea8ce29574 chore(wiki): update knowledge-wiki pointer to dc273a3 2026-09-17 09:30:34 +02:00
Moritz Graf 2747e07148 fix(openclaw): preserve live garmin tokens on PVC and add garmin-auth MFA instructions 2026-09-17 09:28:13 +02:00
Moritz Graf 3e0922c5fa fix(openclaw): enforce dynamic chat/thread routing and single ask_user prompt for brother printer 2026-09-17 09:05:23 +02:00
Moritz Graf aad2dc874d feat(openclaw): integrate Brother HL-L8240CDW duplex laser printer with interactive Telegram options 2026-09-17 08:14:26 +02:00
Moritz Graf d8c95940a1 chore(openclaw): update garmin oauth tokens 2026-09-16 11:23:55 +02:00
Moritz Graf fd0633dace feat: add public hosting ingress, minio credentials, and skills for openclaw 2026-09-16 09:06:16 +02:00
Moritz Graf 707a1b02ef feat(openclaw): implement 9-agent specialist architecture and agents-as-code (ADR 0001) 2026-09-16 08:29:29 +02:00
Moritz Graf bcff777228 feat(openclaw): configure calendar-id-regensburg for HaumdaucherEvent Calendar 2026-09-15 22:42:33 +02:00
Moritz Graf ce025f4842 fix(openclaw): set bootstrapMaxChars to 60000 for agent defaults 2026-09-15 22:35:02 +02:00
Moritz Graf 5c3ff03124 feat(openclaw): update prompt with calendar color coding, conflict guard, and prep checklists 2026-09-15 22:28:30 +02:00
Moritz Graf 115ca3c80e feat(openclaw): add Bring! shopping lists MCP integration and multi-list support 2026-09-15 22:16:58 +02:00
Moritz Graf c7d0d471b6 feat(openclaw): add GrafFamily calendar ID to openclaw-secrets 2026-09-15 22:05:09 +02:00
Moritz Graf 668f8da112 chore: update knowledge-wiki submodule pointer 2026-09-15 21:47:38 +02:00
Moritz Graf 0184fc9527 feat(openclaw): provision google calendar service account and configure calendar MCP server 2026-09-15 21:46:48 +02:00
Moritz Graf 97ead7943c feat(openclaw): allow FamilyHub supergroup without mention gating 2026-09-15 21:11:17 +02:00
Moritz Graf 4df5c2c3d6 fix(openclaw): bypass local attachment allowlist to support all files (.zwo, .json, etc.) 2026-09-15 13:23:40 +02:00
Moritz Graf feca5ccd3c fix(openclaw): ensure .config sentinel exists on PVC during init to guard subPath mounts 2026-09-14 08:28:34 +02:00
Moritz Graf 6bf3bb1348 feat(openclaw): assign glm-5.3-flash as primary conductor with prompt guardrail in adr 0002 2026-09-14 08:12:54 +02:00
Moritz Graf 4ae16b2640 docs(adr): update ADR 0002 open questions following DeepSeek conductor promotion 2026-09-14 07:35:32 +02:00
Moritz Graf c4297d42aa fix(openclaw,docs): promote deepseek v4.1 flash to primary conductor to resolve tool_call XML leakage 2026-09-14 07:28:49 +02:00
Moritz Graf edf124e88c feat(openclaw,docs): expand ADR 0002 to full multimodal spectrum and upgrade to Gemini 3.1 2026-09-13 22:46:33 +02:00
Moritz Graf c4c00416af feat(openclaw,docs): restore gemini flash image generation and propose ADR 0002 for model selection 2026-09-13 22:45:13 +02:00
5125 changed files with 58448 additions and 565 deletions
+142
View File
@@ -0,0 +1,142 @@
---
name: marktguru
license: Apache-2.0
description: Query weekly supermarket offers, flyer discounts, and price comparisons across German supermarkets (ALDI Süd, ALDI Nord, Lidl, EDEKA, REWE, Kaufland, PENNY, Netto, dm, ROSSMANN, etc.) via the Marktguru API and local MCP server. Use when the user asks to check supermarket discounts, find deals in brochures/prospekte, compare grocery prices for a postal code (PLZ), or create shopping lists from current promotions without needing fragile PDF OCR.
---
# Marktguru Supermarket Offers & MCP Server
> Query live promotional flyers and circular offers from German supermarkets and drugstores without brittle PDF scraping or layout OCR.
This skill is inspired by and expands upon the Home Assistant integration pattern (`thorschtn/ha-marktguru`), leveraging Marktguru's backend API (`api.marktguru.de`) to retrieve structured deal data for any German postal code.
---
## Supported Supermarkets & Retailers
Marktguru covers **over 450 retail chains** across Germany. The primary supported chains are:
* **Discounter**:
* **ALDI SÜD** (`127153`) & **ALDI NORD** (`127139`)
* **Lidl** (`126679`)
* **PENNY** (`126765`)
* **Netto Marken-Discount** (`126735`) & **Netto (mit Hund)** (`126734`)
* **NORMA** (`126745`)
* **Supermärkte & Vollsortimenter**:
* **EDEKA** (`127093`) & **E center** (`126699`) + regionale Kaufleute (Simmel, Zurheide, etc.)
* **REWE** (`126802`) & **REWE Center** (`126804`)
* **nahkauf** (`137320`), **HIT** (`126598`), **tegut** (`126866`)
* **Großfläche / Hypermarket**:
* **Kaufland** (`126654`)
* **Globus** (`127177`)
* **Drogerien & Reform**:
* **dm-drogerie markt** (`127057`)
* **ROSSMANN** (`126811`)
* **Müller Drogeriemarkt** (`126720`)
Full details and ID mappings are documented in [`references/supermarkets.md`](references/supermarkets.md).
---
## Components
The skill provides two primary components:
1. **CLI & Python Client (`scripts/marktguru_client.py`)**: Zero external dependencies (uses standard library `urllib` and `json`).
2. **MCP Server (`scripts/marktguru_mcp.py`)**: Standard JSON-RPC 2.0 stdio server providing AI tools for search, comparison, and catalog retrieval.
---
## 1. CLI Usage
Run directly with standard `python3` (uses default postal code **`93059` Regensburg** if `--zip` is omitted):
### A. Search for Deals
```bash
# Search for butter in Regensburg (93059 default) across Aldi, Lidl, and Edeka
python3 scripts/marktguru_client.py search "Butter" --supermarket "Aldi,Lidl,Edeka"
# Search for coffee with raw JSON output
python3 scripts/marktguru_client.py search "Kaffee" --json
```
### B. Price Comparison (Sorted Ascending)
```bash
# Compare chocolate prices across all supermarkets in 93059
python3 scripts/marktguru_client.py compare "Schokolade"
# Compare beer prices between Lidl and Rewe in another postal code (e.g. 80331)
python3 scripts/marktguru_client.py compare "Bier" --zip 80331 --supermarket "Lidl,Rewe"
```
### C. List Supported Chains
```bash
python3 scripts/marktguru_client.py supermarkets
```
---
## 2. Model Context Protocol (MCP) Integration
The MCP server exposes three tools to any MCP-compatible AI agent:
* `search_supermarket_offers(query, zip_code="93059", supermarkets=None, limit=20)`
* `compare_supermarket_prices(query, zip_code="93059", supermarkets=None)`
* `list_supported_supermarkets()`
### MCP Server Configuration (`mcp_config.json`)
To enable the server in Antigravity or other MCP hosts:
```json
{
"mcpServers": {
"marktguru": {
"command": "python3",
"args": [
"/Users/moritz/src/infrapuzzle/.agents/skills/marktguru/scripts/marktguru_mcp.py"
]
}
}
}
```
---
## 3. Python Integration
You can import the module directly in Python scripts or agent sidecars:
```python
import sys
sys.path.append("/Users/moritz/src/infrapuzzle/.agents/skills/marktguru/scripts")
from marktguru_client import search_offers, compare_prices, DEFAULT_ZIP_CODE
# Search deals (defaults to 93059)
results = search_offers("Kaffee", supermarkets=["Aldi", "Lidl"])
for offer in results["offers"]:
print(f"[{offer['supermarket']}] {offer['product_name'] or offer['description']}: {offer['price']} €")
# Compare prices
comparison = compare_prices("Milch")
cheapest = comparison["cheapest_offer"]
print(f"Cheapest milk: {cheapest['price']} € at {cheapest['supermarket']}")
```
---
## Data Extracted Per Offer
Each item returned from the Marktguru API includes:
* `id`: Internal Marktguru offer ID.
* `supermarket`: Retailer name (e.g. "ALDI SÜD", "Lidl", "EDEKA").
* `product_name`: Generic product name (e.g. "Butter", "Filterkaffee").
* `brand`: Brand name (e.g. "Kerrygold", "Melitta", "Milka").
* `description`: Promotional description / pack size (e.g. "je 250g Packung").
* `price`: Promotional sale price in Euros (€).
* `old_price`: Regular price or crossed-out price (UVP).
* `discount_percent`: Calculated percentage savings (e.g. 28%).
* `reference_price`: Unit price (e.g. 5.16 € / kg).
* `loyalty_app_required`: Boolean flag indicating if a digital store app (Lidl Plus, Edeka App, Penny App) is required to receive the discount.
* `valid_from` & `valid_to`: Start and end dates of the promotion.
* `image_url`: High-resolution product image hosted on CDN.
@@ -0,0 +1,94 @@
# Unterstützte Supermärkte & Händler (Marktguru)
Über die Marktguru-Schnittstelle können Werbeprospekte, Wochenangebote und Rabattaktionen von über 450 Handelsketten und Einzelhändlern in Deutschland abgefragt werden.
---
## 1. Discounter
Discounter machen einen Großteil der wöchentlich wechselnden Prospektaktionen aus. Marktguru unterscheidet sauber zwischen den Ketten:
| Kette | Marktguru ID | Typischer Aktionsrhythmus | App-Vorteile erfasst? |
| :--- | :--- | :--- | :--- |
| **ALDI SÜD** | `127153` | Mo. & Do. / Fr. & Sa. | Ja (ALDI App Angebote) |
| **ALDI NORD** | `127139` | Mo. & Do. / Fr. & Sa. | Ja |
| **Lidl** | `126679` | Mo.–Sa. / Do.–Sa. / Fr.–Sa. | Ja (`loyalty_app_required` für Lidl Plus) |
| **PENNY** | `126765` | Mo.–Sa. / Do.–Sa. | Ja (Penny App / Payback) |
| **Netto Marken-Discount** | `126735` | Mo.–Sa. / Do.–Sa. | Ja (Netto App / DeutschlandCard) |
| **Netto (mit Hund)** | `126734` | Mo.–Sa. (primär Ost-/Norddeutschland) | Ja |
| **NORMA** | `126745` | Mo. & Mi. / Fr. & Sa. | Ja |
---
## 2. Vollsortimenter & Supermärkte
Hier gibt es oft filialgenaue oder regionale Prospekte. Die API filtert automatisch anhand der übergebenen Postleitzahl (PLZ):
| Kette | Marktguru ID | Besonderheiten |
| :--- | :--- | :--- |
| **EDEKA** | `127093` | Nationale & regionale Angebote; Marktguru aggregiert auch selbstständige Kaufleute (z.B. EDEKA Simmel, Zurheide, Niemerszein). |
| **E center** | `126699` | Großformatige EDEKA-Märkte mit erweitertem Non-Food- und Frischeangebot. |
| **REWE** | `126802` | Bundesweite Angebote sowie regionale Aktionen. |
| **REWE Center** | `126804` | Großflächenmärkte von REWE. |
| **nahkauf** | `137320` | Nachbarschaftsmärkte der REWE Group. |
| **HIT** | `126598` | Große Frische-Supermärkte (Dohle Handelsgruppe). |
| **tegut** | `126866` | Fokus auf Bio, Nachhaltigkeit und Regionales (Hessen, Bayern, Thüringen). |
---
## 3. Großfläche & SB-Warenhäuser (Hypermarkets)
| Kette | Marktguru ID | Besonderheiten |
| :--- | :--- | :--- |
| **Kaufland** | `126654` | Riesiges wöchentliches Prospektangebot (Lebensmittel + Non-Food, Kaufland Card). |
| **Globus** | `127177` | SB-Warenhäuser mit eigener Metzgerei und Bäckerei. |
---
## 4. Drogeriemärkte
Drogerieprospekte enthalten neben Pflege- und Haushaltsartikeln auch viele Bio-Lebensmittel, Snacks, Kaffee und Babynahrung:
| Kette | Marktguru ID | Typische Angebote |
| :--- | :--- | :--- |
| **dm-drogerie markt** | `127057` | dmBio, Drogeriewaren, Tiernahrung. |
| **ROSSMANN** | `126811` | EnerBio, Kaffeeangebote, wöchentliche 10%-Coupons. |
| **Müller Drogeriemarkt** | `126720` | Drogerie, Parfümerie, Spielwaren, Naturshop. |
| **budni** | `126944` | Primär im Norden (Metropolregion Hamburg). |
---
## 5. Getränkemärkte & Bio-Fachmärkte
| Kette | Marktguru ID | Sortiment |
| :--- | :--- | :--- |
| **Netto Getränke-Discount** | `134009` | Bier, Mineralwasser, Softdrinks. |
| **Denns BioMarkt / Alnatura** | Diverse | Bio-Vollsortiment, Wochenaktionen. |
---
## 6. Unterstützte Aliase im Client & MCP Server
Wenn im Tool-Aufruf der Parameter `supermarkets` übergeben wird, werden folgende umgangssprachliche Bezeichnungen automatisch auf die korrekten Retailer-IDs gemappt:
```json
{
"aldi": [127153, 127139], // Findet automatisch ALDI SÜD und ALDI NORD
"aldi süd" / "aldi sued": [127153],
"aldi nord": [127139],
"lidl": [126679],
"edeka": [127093, 126699], // EDEKA und E center
"rewe": [126802, 126804], // REWE und REWE Center
"kaufland": [126654],
"penny": [126765],
"netto": [126735, 126734], // Netto Marken-Discount und Netto (mit Hund)
"norma": [126745],
"globus": [127177],
"dm": [127057],
"rossmann": [126811],
"müller" / "mueller": [126720],
"hit": [126598],
"tegut": [126866],
"nahkauf": [137320]
}
```
+577
View File
@@ -0,0 +1,577 @@
#!/usr/bin/env python3
"""
Marktguru API Client (Production Grade)
Fetches promotional offers and discounts from German supermarkets via the Marktguru API.
Key Design Principles:
- Zero external dependencies (uses standard library only: urllib, json, os, time, datetime).
- Resilient: Retries with exponential backoff on transient errors.
- Low Maintenance: Configurable keys and postal codes via environment variables.
- Fail-Safe: Caching with stale fallback if upstream API is temporarily unreachable.
- Defensive: Robust parsing protecting against upstream schema shifts and malformed payloads.
"""
import os
import sys
import time
import json
import logging
import hashlib
import tempfile
import urllib.request
import urllib.parse
import urllib.error
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Set, Tuple
# Logger writing to stderr to keep stdout pure for JSON / CLI
logger = logging.getLogger("marktguru_client")
if not logger.handlers:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
API_BASE = "https://api.marktguru.de/api/v1"
# Environment variable overrides for zero-code configuration changes
DEFAULT_ZIP_CODE = os.environ.get("MARKTGURU_ZIP_CODE", "93059")
CLIENT_KEY = os.environ.get("MARKTGURU_CLIENT_KEY", "WU/RH+PMGDi+gkZer3WbMelt6zcYHSTytNB7VpTia90=")
API_KEY = os.environ.get("MARKTGURU_API_KEY", "8Kk+pmbf7TgJ9nVj2cXeA7P5zBGv8iuutVVMRfOfvNE=")
CACHE_TTL = int(os.environ.get("MARKTGURU_CACHE_TTL", "1800")) # 30 minutes
CACHE_ENABLED = os.environ.get("MARKTGURU_CACHE_ENABLED", "true").lower() in ("true", "1", "yes")
HEADERS = {
"x-clientkey": CLIENT_KEY,
"x-apikey": API_KEY,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)",
"Accept": "application/json",
}
# Major supermarket chains and their Marktguru retailer IDs
MAJOR_RETAILERS: Dict[str, Dict[str, Any]] = {
"ALDI SÜD": {"id": 127153, "type": "Discounter", "major": True},
"ALDI NORD": {"id": 127139, "type": "Discounter", "major": True},
"Lidl": {"id": 126679, "type": "Discounter", "major": True},
"EDEKA": {"id": 127093, "type": "Supermarkt", "major": True},
"E center": {"id": 126699, "type": "Supermarkt", "major": True},
"REWE": {"id": 126802, "type": "Supermarkt", "major": True},
"REWE Center": {"id": 126804, "type": "Supermarkt", "major": True},
"Kaufland": {"id": 126654, "type": "Großfläche/Hypermarkt", "major": True},
"PENNY": {"id": 126765, "type": "Discounter", "major": True},
"Netto Marken-Discount": {"id": 126735, "type": "Discounter", "major": True},
"Netto (mit Hund)": {"id": 126734, "type": "Discounter", "major": False},
"NORMA": {"id": 126745, "type": "Discounter", "major": True},
"Globus": {"id": 127177, "type": "Großfläche/Hypermarkt", "major": True},
"HIT": {"id": 126598, "type": "Supermarkt", "major": False},
"tegut": {"id": 126866, "type": "Supermarkt", "major": False},
"nahkauf": {"id": 137320, "type": "Supermarkt", "major": False},
"dm-drogerie markt": {"id": 127057, "type": "Drogerie", "major": True},
"ROSSMANN": {"id": 126811, "type": "Drogerie", "major": True},
"Müller Drogeriemarkt": {"id": 126720, "type": "Drogerie", "major": True},
}
# Aliases mapping user query strings to Marktguru retailer IDs
RETAILER_ALIASES: Dict[str, List[int]] = {
"aldi": [127153, 127139],
"aldi süd": [127153],
"aldi sued": [127153],
"aldi nord": [127139],
"lidl": [126679],
"edeka": [127093, 126699],
"e center": [126699],
"ecenter": [126699],
"rewe": [126802, 126804],
"kaufland": [126654],
"penny": [126765],
"netto": [126735, 126734],
"norma": [126745],
"globus": [127177],
"hit": [126598],
"tegut": [126866],
"nahkauf": [137320],
"dm": [127057],
"rossmann": [126811],
"müller": [126720],
"mueller": [126720],
}
def _get_cache_dir() -> str:
"""Returns directory path for local cached responses."""
cache_dir = os.path.join(tempfile.gettempdir(), "marktguru_cache")
try:
os.makedirs(cache_dir, exist_ok=True)
except Exception:
pass
return cache_dir
def _read_cache(cache_key: str, max_age_seconds: int = CACHE_TTL) -> Tuple[Optional[Dict[str, Any]], bool]:
"""
Reads cache file. Returns (data, is_fresh).
If cache is expired, returns (data, False) so caller can use it as emergency fallback.
"""
if not CACHE_ENABLED:
return None, False
cache_file = os.path.join(_get_cache_dir(), f"{cache_key}.json")
if not os.path.isfile(cache_file):
return None, False
try:
with open(cache_file, "r", encoding="utf-8") as f:
payload = json.load(f)
cached_time = payload.get("_cached_at", 0)
age = time.time() - cached_time
data = payload.get("data")
is_fresh = age < max_age_seconds
return data, is_fresh
except Exception as e:
logger.debug(f"Failed to read cache {cache_key}: {e}")
return None, False
def _write_cache(cache_key: str, data: Dict[str, Any]) -> None:
"""Writes data to cache file atomically."""
if not CACHE_ENABLED:
return
cache_file = os.path.join(_get_cache_dir(), f"{cache_key}.json")
temp_file = f"{cache_file}.tmp.{os.getpid()}"
try:
payload = {
"_cached_at": time.time(),
"data": data,
}
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
os.replace(temp_file, cache_file)
except Exception as e:
logger.debug(f"Failed to write cache {cache_key}: {e}")
if os.path.exists(temp_file):
try:
os.remove(temp_file)
except Exception:
pass
def resolve_supermarket_ids(supermarkets: Optional[List[str]]) -> Optional[str]:
"""Resolves human supermarket names/aliases to comma-separated Marktguru retailer IDs."""
if not supermarkets:
return None
# Handle string passed instead of list (e.g. "Aldi, Lidl")
if isinstance(supermarkets, str):
supermarkets = [s.strip() for s in supermarkets.split(",") if s.strip()]
resolved_ids: Set[int] = set()
for name in supermarkets:
if not isinstance(name, str):
continue
cleaned = name.strip().lower()
if not cleaned:
continue
if cleaned in RETAILER_ALIASES:
resolved_ids.update(RETAILER_ALIASES[cleaned])
elif cleaned.isdigit():
resolved_ids.add(int(cleaned))
else:
found = False
for r_name, r_info in MAJOR_RETAILERS.items():
if cleaned in r_name.lower():
resolved_ids.add(r_info["id"])
found = True
if not found:
logger.warning(f"Unknown supermarket name '{name}' skipped.")
return ",".join(str(i) for i in sorted(resolved_ids)) if resolved_ids else None
def format_iso_date(iso_str: Optional[str]) -> str:
"""Formats an ISO-8601 date string to human-readable DD.MM.YYYY format."""
if not iso_str or not isinstance(iso_str, str):
return "-"
try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
return dt.strftime("%d.%m.%Y")
except Exception:
return iso_str[:10] if len(iso_str) >= 10 else iso_str
def parse_offer(raw: Any, zip_code: str) -> Optional[Dict[str, Any]]:
"""
Defensively parses and standardizes an offer payload from Marktguru.
Protects against null fields, malformed dates, and missing keys.
"""
if not isinstance(raw, dict):
return None
try:
offer_id = raw.get("id")
price = raw.get("price")
if isinstance(price, (int, float)):
price = round(float(price), 2)
else:
price = None
old_price = raw.get("oldPrice")
if isinstance(old_price, (int, float)):
old_price = round(float(old_price), 2)
else:
old_price = None
ref_price = raw.get("referencePrice")
if isinstance(ref_price, (int, float)):
ref_price = round(float(ref_price), 2)
else:
ref_price = None
discount_pct = None
if old_price and price and old_price > price:
discount_pct = round(((old_price - price) / old_price) * 100)
# Validity dates
validity = raw.get("validityDates")
valid_from_raw = None
valid_to_raw = None
is_valid_now = True
days_remaining = None
if isinstance(validity, list) and len(validity) > 0 and isinstance(validity[0], dict):
valid_from_raw = validity[0].get("from")
valid_to_raw = validity[0].get("to")
try:
now = datetime.now(timezone.utc)
if valid_to_raw and isinstance(valid_to_raw, str):
dt_to = datetime.fromisoformat(valid_to_raw.replace("Z", "+00:00"))
diff = (dt_to - now).days
days_remaining = max(0, diff)
if dt_to < now:
is_valid_now = False
if valid_from_raw and isinstance(valid_from_raw, str):
dt_from = datetime.fromisoformat(valid_from_raw.replace("Z", "+00:00"))
if dt_from > now:
is_valid_now = False
except Exception:
pass
# Supermarket / advertiser extraction
supermarket = "Unbekannt"
supermarket_id = None
advertisers = raw.get("advertisers")
if isinstance(advertisers, list) and len(advertisers) > 0 and isinstance(advertisers[0], dict):
supermarket = advertisers[0].get("name") or "Unbekannt"
supermarket_id = advertisers[0].get("id")
# High resolution CDN image
image_url = None
if offer_id:
image_url = f"https://mg2de.b-cdn.net/api/v1/offers/{offer_id}/images/default/0/medium.webp"
# Unit & volume
unit_info = raw.get("unit")
unit_name = None
if isinstance(unit_info, dict):
unit_name = unit_info.get("shortName") or unit_info.get("name")
product_info = raw.get("product")
product_name = None
if isinstance(product_info, dict):
product_name = product_info.get("name")
brand_info = raw.get("brand")
brand_name = None
if isinstance(brand_info, dict):
brand_name = brand_info.get("name")
return {
"id": offer_id,
"supermarket": supermarket,
"supermarket_id": supermarket_id,
"product_name": product_name,
"brand": brand_name,
"description": raw.get("description"),
"price": price,
"old_price": old_price,
"reference_price": ref_price,
"discount_percent": discount_pct,
"unit": unit_name,
"volume": raw.get("volume"),
"loyalty_app_required": bool(raw.get("requiresLoyalityMembership", False)),
"is_valid_now": is_valid_now,
"days_remaining": days_remaining,
"valid_from": format_iso_date(valid_from_raw),
"valid_to": format_iso_date(valid_to_raw),
"image_url": image_url,
"zip_code": str(zip_code),
}
except Exception as e:
logger.debug(f"Error parsing single offer {raw.get('id') if isinstance(raw, dict) else raw}: {e}")
return None
def _fetch_with_retry(url: str, max_retries: int = 2) -> Dict[str, Any]:
"""
Executes HTTP request with exponential backoff on transient errors (5xx / network blips).
"""
last_err: Optional[Exception] = None
for attempt in range(max_retries + 1):
req = urllib.request.Request(url, headers=HEADERS)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw_bytes = resp.read()
return json.loads(raw_bytes.decode("utf-8"))
except urllib.error.HTTPError as e:
last_err = e
# Retry only on transient server errors (500, 502, 503, 504) or rate limits (429)
if e.code in (429, 500, 502, 503, 504) and attempt < max_retries:
backoff = 0.5 * (2 ** attempt)
logger.warning(f"Marktguru HTTP {e.code}. Retrying in {backoff:.1f}s (attempt {attempt+1}/{max_retries})...")
time.sleep(backoff)
continue
raise
except (urllib.error.URLError, TimeoutError, OSError) as e:
last_err = e
if attempt < max_retries:
backoff = 0.5 * (2 ** attempt)
logger.warning(f"Connection issue ({e}). Retrying in {backoff:.1f}s...")
time.sleep(backoff)
continue
raise
raise last_err or RuntimeError("Max retries exceeded")
def search_offers(
query: str,
zip_code: str = DEFAULT_ZIP_CODE,
supermarkets: Optional[List[str]] = None,
limit: int = 24,
offset: int = 0,
force_refresh: bool = False,
) -> Dict[str, Any]:
"""
Searches current supermarket offers in Germany via the Marktguru API.
Args:
query: Search term (e.g. 'Butter', 'Kaffee', 'Bier', 'Milch')
zip_code: German postal code (default: '93059' Regensburg)
supermarkets: List of supermarket names or aliases (e.g. ['Aldi', 'Lidl', 'Edeka'])
limit: Max number of items to return (default: 24, max: 50)
offset: Result pagination offset
force_refresh: If True, bypasses local TTL cache
"""
cleaned_query = (query or "").strip()
cleaned_zip = str(zip_code or DEFAULT_ZIP_CODE).strip()
limit = max(1, min(int(limit), 50))
offset = max(0, int(offset))
retailer_ids = resolve_supermarket_ids(supermarkets)
# Compute deterministic cache key
cache_raw = f"{cleaned_query.lower()}:{cleaned_zip}:{retailer_ids or ''}:{limit}:{offset}"
cache_key = hashlib.sha256(cache_raw.encode("utf-8")).hexdigest()[:16]
if not force_refresh:
cached_data, is_fresh = _read_cache(cache_key)
if is_fresh and cached_data:
return cached_data
params: Dict[str, Any] = {
"as": "web",
"limit": limit,
"offset": offset,
"q": cleaned_query,
"zipCode": cleaned_zip,
}
if retailer_ids:
params["retailers"] = retailer_ids
url = f"{API_BASE}/offers/search?{urllib.parse.urlencode(params)}"
try:
data = _fetch_with_retry(url)
except Exception as e:
logger.error(f"Marktguru API query failed: {e}")
# Emergency fail-safe: Check if stale cache is available
cached_data, _ = _read_cache(cache_key, max_age_seconds=86400 * 7)
if cached_data:
logger.warning("Serving stale cached data due to API failure.")
cached_data["warning"] = "Stale cache returned due to temporary upstream API issue."
return cached_data
return {
"error": f"API request failed: {str(e)}",
"query": cleaned_query,
"zip_code": cleaned_zip,
"total_results": 0,
"returned_count": 0,
"offers": [],
"available_retailers": [],
}
raw_results = data.get("results") or []
parsed_offers: List[Dict[str, Any]] = []
for item in raw_results:
parsed = parse_offer(item, cleaned_zip)
if parsed is not None:
parsed_offers.append(parsed)
filters = data.get("filters") or {}
raw_retailers = filters.get("retailers") or []
available_retailers = [
{"name": r.get("name"), "id": r.get("id"), "count": r.get("resultsCount")}
for r in raw_retailers if isinstance(r, dict) and r.get("name")
]
result = {
"query": cleaned_query,
"zip_code": cleaned_zip,
"total_results": data.get("totalResults", len(parsed_offers)),
"returned_count": len(parsed_offers),
"available_retailers": available_retailers,
"offers": parsed_offers,
}
_write_cache(cache_key, result)
return result
def compare_prices(
query: str,
zip_code: str = DEFAULT_ZIP_CODE,
supermarkets: Optional[List[str]] = None,
force_refresh: bool = False,
) -> Dict[str, Any]:
"""
Compares prices for a given product query across supermarkets, sorted from cheapest to most expensive.
"""
data = search_offers(query, zip_code=zip_code, supermarkets=supermarkets, limit=50, force_refresh=force_refresh)
offers = data.get("offers", [])
# Filter out items without price and sort ascending
priced_offers = [o for o in offers if o.get("price") is not None]
priced_offers.sort(key=lambda x: x["price"])
return {
"query": query,
"zip_code": str(zip_code),
"total_compared": len(priced_offers),
"cheapest_offer": priced_offers[0] if priced_offers else None,
"all_offers_sorted": priced_offers,
}
def list_supported_supermarkets() -> Dict[str, Any]:
"""Returns a structured catalog of all major supermarket chains supported by Marktguru."""
categories: Dict[str, List[Dict[str, Any]]] = {
"Discounter": [],
"Supermarkt": [],
"Großfläche / Hypermarkt": [],
"Drogerie": [],
}
for name, info in MAJOR_RETAILERS.items():
cat = info.get("type", "Sonstige")
if cat not in categories:
categories[cat] = []
categories[cat].append({
"name": name,
"id": info["id"],
"major": info.get("major", False)
})
return {
"default_zip_code": DEFAULT_ZIP_CODE,
"cache_ttl_seconds": CACHE_TTL,
"supported_chains_count": len(MAJOR_RETAILERS),
"categories": categories,
"supported_aliases": list(RETAILER_ALIASES.keys()),
}
def main():
import argparse
parser = argparse.ArgumentParser(description="Query German supermarket flyers/discounts via Marktguru")
subparsers = parser.add_subparsers(dest="command", required=True)
# Search
search_p = subparsers.add_parser("search", help="Search offers for a product")
search_p.add_argument("query", help="Product to search (e.g. Butter, Kaffee)")
search_p.add_argument("--zip", default=DEFAULT_ZIP_CODE, help=f"Postal code (default: {DEFAULT_ZIP_CODE})")
search_p.add_argument("--supermarket", help="Comma-separated supermarkets (e.g. 'Aldi,Lidl,Edeka')")
search_p.add_argument("--limit", type=int, default=15, help="Max results (default 15)")
search_p.add_argument("--refresh", action="store_true", help="Bypass local cache")
search_p.add_argument("--json", action="store_true", help="Output raw JSON")
# Compare
comp_p = subparsers.add_parser("compare", help="Compare prices across supermarkets")
comp_p.add_argument("query", help="Product to compare")
comp_p.add_argument("--zip", default=DEFAULT_ZIP_CODE, help=f"Postal code (default: {DEFAULT_ZIP_CODE})")
comp_p.add_argument("--supermarket", help="Comma-separated supermarkets")
comp_p.add_argument("--refresh", action="store_true", help="Bypass local cache")
comp_p.add_argument("--json", action="store_true", help="Output raw JSON")
# Supermarkets
subparsers.add_parser("supermarkets", help="List all supported supermarkets and chains")
args = parser.parse_args()
if args.command == "supermarkets":
data = list_supported_supermarkets()
print(json.dumps(data, indent=2, ensure_ascii=False))
return
sm_list = [s.strip() for s in args.supermarket.split(",")] if getattr(args, "supermarket", None) else None
if args.command == "search":
result = search_offers(
args.query,
zip_code=args.zip,
supermarkets=sm_list,
limit=args.limit,
force_refresh=args.refresh
)
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
if "error" in result and not result.get("offers"):
print(f"❌ Fehler: {result['error']}")
return
print(f"\n🔍 Angebote für '{args.query}' in PLZ {args.zip} (Gefunden: {result['total_results']}):\n")
for o in result["offers"]:
disc_str = f" (-{o['discount_percent']}%)" if o.get("discount_percent") else ""
loyalty_str = " 📱 [App Coupon]" if o.get("loyalty_app_required") else ""
timing_str = f"noch {o['days_remaining']} Tage" if o.get("days_remaining") is not None else f"bis {o['valid_to']}"
print(f" • [{o['supermarket']}] {o['description'] or o['product_name'] or 'Angebot'}")
print(f" Preis: {o['price']:.2f} €{disc_str}{loyalty_str} | Gültig: {timing_str}")
if o.get("reference_price"):
print(f" Grundpreis: {o['reference_price']:.2f} € / {o.get('unit') or 'Einheit'}")
print()
elif args.command == "compare":
result = compare_prices(
args.query,
zip_code=args.zip,
supermarkets=sm_list,
force_refresh=args.refresh
)
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print(f"\n🏷️ Preisvergleich für '{args.query}' in PLZ {args.zip}:\n")
for idx, o in enumerate(result["all_offers_sorted"], 1):
disc_str = f" (UVP {o['old_price']:.2f}€)" if o.get("old_price") else ""
loyalty_str = " [App erforderlich]" if o.get("loyalty_app_required") else ""
print(f" {idx:2d}. {o['price']:.2f} € - [{o['supermarket']}] {o['description'] or o['product_name']}{disc_str}{loyalty_str}")
print()
if __name__ == "__main__":
main()
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""
Marktguru MCP (Model Context Protocol) Server (Production Grade)
Exposes German supermarket flyers, discounts, and price comparison tools over standard JSON-RPC 2.0 stdio.
Key Design Principles:
- Zero external dependencies: Uses standard library only.
- Strict Protocol Compliance: Conforms to MCP (2024-11-05) and JSON-RPC 2.0 specifications.
- Defensive Argument Coercion: Tolerates string/number variations commonly output by LLMs.
- Graceful Signal Handling: Clean exit on SIGINT, SIGTERM, and EOF.
- Stream Isolation: All logs and diagnostics route to stderr, ensuring stdout is 100% clean JSON-RPC.
"""
import sys
import json
import signal
import logging
from typing import Dict, Any, List, Optional
# Import business logic from hardened client script
from marktguru_client import (
search_offers,
compare_prices,
list_supported_supermarkets,
DEFAULT_ZIP_CODE,
MAJOR_RETAILERS,
RETAILER_ALIASES,
)
# Configure logging strictly to stderr
logging.basicConfig(
stream=sys.stderr,
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("marktguru-mcp")
PROTOCOL_VERSION = "2024-11-05"
SERVER_NAME = "marktguru-mcp-server"
SERVER_VERSION = "1.1.0"
TOOLS = [
{
"name": "search_supermarket_offers",
"description": (
"Search current weekly supermarket offers and promotional discounts in Germany via the Marktguru API. "
"Supports filtering by postal code (PLZ) and specific supermarket chains (e.g. Aldi Süd, Aldi Nord, Lidl, Edeka, Rewe, Kaufland, Penny, Netto)."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The product or keyword to search for (e.g. 'Butter', 'Kaffee', 'Bier', 'Milch', 'Nutella', 'Schokolade')."
},
"zip_code": {
"type": "string",
"description": f"5-digit German postal code (PLZ). Defaults to '{DEFAULT_ZIP_CODE}' (Regensburg)."
},
"supermarkets": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of supermarket names to filter by (e.g. ['Aldi', 'Lidl', 'Edeka', 'Rewe', 'Kaufland', 'Penny', 'Netto', 'dm', 'Rossmann']). If omitted, all available retailers are included."
},
"limit": {
"type": "integer",
"description": "Maximum number of offers to return (default: 20, max: 50)."
}
},
"required": ["query"]
}
},
{
"name": "compare_supermarket_prices",
"description": (
"Compare current prices for a product across German supermarkets in a given postal code. "
"Returns results sorted from lowest to highest price, highlighting the cheapest deal, regular prices (UVP), and required loyalty app memberships."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The product or grocery item to compare (e.g. 'Kaffee', 'Butter', 'Bier', 'Olivenöl')."
},
"zip_code": {
"type": "string",
"description": f"5-digit German postal code (PLZ). Defaults to '{DEFAULT_ZIP_CODE}' (Regensburg)."
},
"supermarkets": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of supermarket names to compare (e.g. ['Aldi', 'Lidl', 'Edeka', 'Rewe'])."
}
},
"required": ["query"]
}
},
{
"name": "list_supported_supermarkets",
"description": (
"List all supermarket, discounter, hypermarket, and drugstore chains supported by the Marktguru service in Germany, "
"including their categories (Discounter, Supermarkt, Großfläche, Drogerie) and accepted name aliases."
),
"inputSchema": {
"type": "object",
"properties": {}
}
}
]
def _sanitize_supermarkets(sm_input: Any) -> Optional[List[str]]:
"""Coerces various LLM input formats (list, comma-string, single string) into a clean list."""
if not sm_input:
return None
if isinstance(sm_input, list):
return [str(s).strip() for s in sm_input if str(s).strip()]
if isinstance(sm_input, str):
return [s.strip() for s in sm_input.split(",") if s.strip()]
return None
def handle_initialize(req_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {
"tools": {
"listChanged": False
}
},
"serverInfo": {
"name": SERVER_NAME,
"version": SERVER_VERSION
}
}
}
def handle_tools_list(req_id: Any) -> Dict[str, Any]:
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"tools": TOOLS
}
}
def handle_tools_call(req_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
tool_name = params.get("name")
args = params.get("arguments", {})
logger.info(f"Tool call requested: {tool_name} with args={args}")
try:
if tool_name == "search_supermarket_offers":
query = str(args.get("query") or "").strip()
if not query:
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"content": [{"type": "text", "text": "Error: 'query' argument is required and cannot be empty."}],
"isError": True
}
}
zip_code = str(args.get("zip_code") or DEFAULT_ZIP_CODE).strip()
supermarkets = _sanitize_supermarkets(args.get("supermarkets"))
try:
limit = int(args.get("limit") or 20)
except (ValueError, TypeError):
limit = 20
data = search_offers(query, zip_code=zip_code, supermarkets=supermarkets, limit=limit)
text_result = json.dumps(data, indent=2, ensure_ascii=False)
elif tool_name == "compare_supermarket_prices":
query = str(args.get("query") or "").strip()
if not query:
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"content": [{"type": "text", "text": "Error: 'query' argument is required and cannot be empty."}],
"isError": True
}
}
zip_code = str(args.get("zip_code") or DEFAULT_ZIP_CODE).strip()
supermarkets = _sanitize_supermarkets(args.get("supermarkets"))
data = compare_prices(query, zip_code=zip_code, supermarkets=supermarkets)
text_result = json.dumps(data, indent=2, ensure_ascii=False)
elif tool_name == "list_supported_supermarkets":
data = list_supported_supermarkets()
text_result = json.dumps(data, indent=2, ensure_ascii=False)
else:
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32601,
"message": f"Method or tool '{tool_name}' not found."
}
}
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"content": [
{
"type": "text",
"text": text_result
}
],
"isError": False
}
}
except Exception as e:
logger.exception(f"Exception during tool execution '{tool_name}': {e}")
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"content": [
{
"type": "text",
"text": f"Tool execution failed: {type(e).__name__}: {str(e)}"
}
],
"isError": True
}
}
def process_message(line: str) -> Optional[Dict[str, Any]]:
if not line.strip():
return None
try:
msg = json.loads(line)
except json.JSONDecodeError as e:
logger.error(f"Malformed JSON-RPC received: {e}")
return {
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32700, "message": f"Parse error: {str(e)}"}
}
method = msg.get("method")
req_id = msg.get("id")
params = msg.get("params", {})
# Notifications do not return a response
if req_id is None:
if method == "notifications/initialized":
logger.info("Client acknowledged initialized notification.")
elif method == "notifications/cancelled":
logger.info(f"Client cancelled operation: {params}")
return None
# Standard Requests
if method == "initialize":
return handle_initialize(req_id, params)
elif method == "tools/list":
return handle_tools_list(req_id)
elif method == "tools/call":
return handle_tools_call(req_id, params)
elif method == "ping":
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
else:
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32601,
"message": f"Method '{method}' is not supported."
}
}
def _sig_handler(sig, frame):
logger.info(f"Received termination signal {sig}. Exiting gracefully.")
sys.exit(0)
def main():
signal.signal(signal.SIGINT, _sig_handler)
signal.signal(signal.SIGTERM, _sig_handler)
logger.info(f"Starting {SERVER_NAME} v{SERVER_VERSION} (stdio mode)...")
try:
for raw_line in sys.stdin:
response = process_message(raw_line)
if response is not None:
output = json.dumps(response, ensure_ascii=False) + "\n"
sys.stdout.write(output)
sys.stdout.flush()
except (KeyboardInterrupt, BrokenPipeError):
pass
except Exception as e:
logger.exception(f"Fatal error in stdio loop: {e}")
sys.exit(1)
logger.info("Marktguru MCP server shutdown complete.")
if __name__ == "__main__":
main()
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
Unit tests for the Marktguru skill & MCP Server.
Runs completely offline using standard library unittest.
"""
import sys
import os
import json
import unittest
from datetime import datetime, timezone, timedelta
# Add scripts directory to path
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
if SCRIPTS_DIR not in sys.path:
sys.path.insert(0, SCRIPTS_DIR)
from marktguru_client import (
resolve_supermarket_ids,
format_iso_date,
parse_offer,
list_supported_supermarkets,
DEFAULT_ZIP_CODE,
_read_cache,
_write_cache,
_get_cache_dir,
)
from marktguru_mcp import process_message, SERVER_NAME, PROTOCOL_VERSION
class TestMarktguruClient(unittest.TestCase):
def test_default_zip_code(self):
self.assertEqual(DEFAULT_ZIP_CODE, "93059")
def test_resolve_supermarket_ids_aliases(self):
# Aldi maps to Aldi Nord and Aldi Süd
aldi_ids = resolve_supermarket_ids(["aldi"])
self.assertIn("127153", aldi_ids)
self.assertIn("127139", aldi_ids)
# Edeka maps to Edeka and E center
edeka_ids = resolve_supermarket_ids(["edeka"])
self.assertIn("127093", edeka_ids)
self.assertIn("126699", edeka_ids)
# Lidl
lidl_ids = resolve_supermarket_ids(["lidl"])
self.assertEqual(lidl_ids, "126679")
# Comma-separated string support
combined = resolve_supermarket_ids("Lidl, Kaufland")
self.assertIn("126679", combined)
self.assertIn("126654", combined)
# Unknown supermarket is ignored gracefully
unknown = resolve_supermarket_ids(["NonExistentMarket123"])
self.assertIsNone(unknown)
def test_format_iso_date(self):
self.assertEqual(format_iso_date("2026-10-15T12:00:00Z"), "15.10.2026")
self.assertEqual(format_iso_date(None), "-")
self.assertEqual(format_iso_date(""), "-")
self.assertEqual(format_iso_date("invalid-date-format"), "invalid-da")
def test_parse_offer_defensive(self):
# 1. Complete payload
now = datetime.now(timezone.utc)
from_date = (now - timedelta(days=1)).isoformat()
to_date = (now + timedelta(days=4)).isoformat()
raw_complete = {
"id": 99999,
"price": 1.49,
"oldPrice": 2.29,
"referencePrice": 5.96,
"requiresLoyalityMembership": True,
"description": "250g Packung",
"product": {"name": "Süßrahmbutter"},
"brand": {"name": "Landliebe"},
"advertisers": [{"name": "EDEKA", "id": "retailers/127093"}],
"unit": {"shortName": "kg"},
"volume": 0.25,
"validityDates": [{"from": from_date, "to": to_date}]
}
parsed = parse_offer(raw_complete, "93059")
self.assertIsNotNone(parsed)
self.assertEqual(parsed["id"], 99999)
self.assertEqual(parsed["supermarket"], "EDEKA")
self.assertEqual(parsed["price"], 1.49)
self.assertEqual(parsed["old_price"], 2.29)
self.assertEqual(parsed["discount_percent"], 35) # (2.29 - 1.49) / 2.29 = 34.9% -> 35%
self.assertTrue(parsed["loyalty_app_required"])
self.assertTrue(parsed["is_valid_now"])
self.assertGreaterEqual(parsed["days_remaining"], 3)
self.assertIn("https://mg2de.b-cdn.net/api/v1/offers/99999", parsed["image_url"])
# 2. Minimal / empty payload
raw_empty = {}
parsed_min = parse_offer(raw_empty, "93059")
self.assertIsNotNone(parsed_min)
self.assertEqual(parsed_min["supermarket"], "Unbekannt")
self.assertIsNone(parsed_min["price"])
self.assertIsNone(parsed_min["discount_percent"])
# 3. Invalid types (not a dict)
self.assertIsNone(parse_offer("not a dict", "93059"))
self.assertIsNone(parse_offer(None, "93059"))
def test_cache_mechanism(self):
test_key = "test_unit_key_123"
test_data = {"hello": "world", "timestamp": 12345}
_write_cache(test_key, test_data)
data, is_fresh = _read_cache(test_key, max_age_seconds=60)
self.assertTrue(is_fresh)
self.assertEqual(data, test_data)
# Stale cache test
data_stale, is_fresh_stale = _read_cache(test_key, max_age_seconds=-1)
self.assertFalse(is_fresh_stale)
self.assertEqual(data_stale, test_data) # still returned as fallback!
def test_list_supported_supermarkets(self):
catalog = list_supported_supermarkets()
self.assertEqual(catalog["default_zip_code"], "93059")
self.assertIn("Discounter", catalog["categories"])
self.assertIn("Supermarkt", catalog["categories"])
self.assertIn("aldi", catalog["supported_aliases"])
self.assertIn("edeka", catalog["supported_aliases"])
class TestMarktguruMCP(unittest.TestCase):
def test_mcp_initialize(self):
req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
resp = process_message(req)
self.assertEqual(resp["id"], 1)
self.assertEqual(resp["result"]["protocolVersion"], PROTOCOL_VERSION)
self.assertEqual(resp["result"]["serverInfo"]["name"], SERVER_NAME)
def test_mcp_tools_list(self):
req = json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
resp = process_message(req)
tools = [t["name"] for t in resp["result"]["tools"]]
self.assertIn("search_supermarket_offers", tools)
self.assertIn("compare_supermarket_prices", tools)
self.assertIn("list_supported_supermarkets", tools)
def test_mcp_tools_call_supermarket_catalog(self):
req = json.dumps({
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {"name": "list_supported_supermarkets", "arguments": {}}
})
resp = process_message(req)
self.assertFalse(resp["result"]["isError"])
content_text = resp["result"]["content"][0]["text"]
data = json.loads(content_text)
self.assertEqual(data["default_zip_code"], "93059")
def test_mcp_tools_call_missing_query(self):
req = json.dumps({
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {"name": "search_supermarket_offers", "arguments": {"query": ""}}
})
resp = process_message(req)
self.assertTrue(resp["result"]["isError"])
self.assertIn("required", resp["result"]["content"][0]["text"])
def test_mcp_notification_silence(self):
req = json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"})
resp = process_message(req)
self.assertIsNone(resp) # Notifications must not produce responses
if __name__ == "__main__":
unittest.main()
+231
View File
@@ -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.
+7
View File
@@ -2,3 +2,10 @@ k8s/gitea-chart/
.vagrant
k8s/tekton-pipelines/auth
.DS_Store
node_modules/
**/node_modules/
dist/
**/dist/
*.tsbuildinfo
.venv/
**/__pycache__/
+81 -20
View File
@@ -14,35 +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).
@@ -55,13 +48,81 @@ 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).
* **Consulting ADRs**: AI agents MUST consult `docs/adr/` before designing or altering system architecture, agent topologies, or tool integration patterns.
* **Key Records**:
* [ADR 0001: Hybrid MCP Tool Search and Subagent Delegation for AI Agents](docs/adr/0001-hybrid-mcp-tool-search-and-subagents.md)
* [ADR 0002: OpenClaw Multi-Tier Model Selection, Latency Optimization, and Inference Economics](docs/adr/0002-openclaw-model-selection-tiering-and-economics.md)
* [ADR 0003: Everything as Code (EaC) and Reproducible Declarative Infrastructure](docs/adr/0003-everything-as-code-and-reproducible-infrastructure.md)
### 7. [Saschas Kronkorken Kreuzzug](./projects/sasch40/AGENTS.md) (`projects/sasch40/`)
* **Purpose**: Mobile-first 2.5D retro birthday adventure game for King Sascha's 40th jubilee (`https://sasch40.haumdaucher.de`).
* **Key Rules**: Prequel landing screen for QR codes, dynamic GameBoy arcade vs RPG layouts, real-photo character portraits, pure Web Audio synthesizer, competitive certificate scoring.
* **Read**: [projects/sasch40/AGENTS.md](./projects/sasch40/AGENTS.md)
### 8. [Moritz Graf Personal Website](./projects/moritzgrafde/AGENTS.md) (`projects/moritzgrafde/`)
* **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.
* **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.
+22 -23
View File
@@ -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).
+2
View File
@@ -1,2 +1,4 @@
kubespray
venv
credentials/
inventory/**/credentials/
+71 -37
View File
@@ -1,49 +1,83 @@
# AGENTS.md
> [!NOTE]
> This directory handles the **bootstrapping and provisioning** of the Haumdaucher Kubernetes cluster using **Kubespray**.
> This directory handles the **bootstrapping, provisioning, and in-place upgrades** of the Haumdaucher Kubernetes cluster using **Kubespray**.
## Project Overview
* **Tool**: [Kubespray](https://github.com/kubernetes-sigs/kubespray) (Ansible-based).
* **Method**: The local `inventory/` is the source of truth, which is synced into a checked-out Kubespray repository.
* **Idempotency**: The process is designed to be repeatable. The `kubespray` folder is treated as ephemeral and is re-created by `init.sh`.
* **Topology**: **Single-node cluster** (`haumdaucher`, `136.243.23.215`). The single node acts simultaneously as control plane, etcd member, and worker.
* **Host OS**: Ubuntu 20.04.6 LTS.
* **Method**: The local `inventory/` is the source of truth, synced into the checked-out Kubespray repository.
* **Idempotency & Patching**: The `kubespray/` directory is ephemeral and managed by `init.sh`. Any essential Kubespray fixes are persisted in `patches/` and applied automatically.
---
## Architectural Invariants for Single-Node Upgrades
> [!IMPORTANT]
> The single-node topology imposes strict operational constraints that must be observed by all agents and operators:
1. **NO DRAIN / NO CORDON**:
- Draining or cordoning a single-node cluster evicts all workloads with nowhere to schedule them, causing cascading failures and pending pods.
- `drain_nodes: false`, `upgrade_node_always_cordon: false`, and `upgrade_node_fail_if_drain_fails: false` MUST remain configured in [k8s-cluster.yml](file:///Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml).
- Use the in-place upgrade mechanism: `ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml`. This invokes `kubeadm-upgrade.yml` without cordon/drain overhead.
2. **VERSION STRING FORMAT**:
- In `inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml`, `kube_version` must be specified **without** the leading `v` (e.g., `1.32.13`, NOT `v1.32.13`), as Kubespray 2.28 interpolates `v{{ kube_version }}` in download URLs and package names.
3. **UBUNTU 20.04 OS CONSTRAINT**:
- Kubespray `release-2.28` is the **final release line** supporting Ubuntu 20.04 LTS. Upstream `release-2.29`+ drops Ubuntu 20.04 support. Do NOT bump `VERSION` in `init.sh` beyond `release-2.28` until the host operating system has been upgraded to Ubuntu 22.04 or 24.04.
4. **ETCD BACKUPS**:
- Always take a manual etcd snapshot before initiating any cluster upgrade.
---
## Workflow & Scripts
The core workflow is encapsulated in `init.sh`.
### `init.sh`
**Purpose**: Prepares the environment and Kubespray for deployment.
**Actions**:
1. **Clean Slate**: Deletes existing `kubespray/` directory.
2. **Clone**: Clones Kubespray (version defined in variable `VERSION`, e.g., `release-2.27`).
3. **Environment**: Sets up Python virtualenv via `pyenv` and installs `requirements.txt`.
4. **Sync**: Copies local `./inventory/` configurations into `./kubespray/inventory/`.
### 1. Source `init.sh`
```bash
source init.sh
```
**Actions performed**:
- Clones / checks out the Kubespray branch defined by `VERSION` (`release-2.28`).
- Applies monorepo patches in `patches/*.patch` (e.g., propagating `--ignore-preflight-errors` for `v1beta4` kubeadm configs).
- Activates the `infrapuzzle-bootstrap` pyenv virtualenv and verifies Python requirements.
- Syncs `./inventory/` to `./kubespray/inventory/`.
- Exports the `sync-inventory` alias.
- Changes working directory to `./kubespray`.
### Usage
1. **Source the script**:
```bash
source init.sh
```
2. **Deploy / Upgrade**:
After sourcing, go to the `kubespray` directory and run the Ansible playbooks as instructed by the script output.
* **Standard Run**:
```bash
cd kubespray
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
```
* **Forced Upgrade**:
```bash
cd kubespray
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
```
### 2. Upgrading the Single-Node Cluster (Two-Phase Decoupled Workflow)
#### Phase 1: Reconcile Kubespray Tooling Baseline (Keep existing k8s version)
```bash
cd kubespray
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
```
*Verification*: Check node readiness and that no application pods crashed.
#### Phase 2: Upgrade Kubernetes Version (e.g. to 1.32.13)
1. Edit `bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml`:
```yaml
kube_version: 1.32.13
```
2. Re-sync inventory from `bootstrap/` or `bootstrap/kubespray/`:
```bash
sync-inventory
```
3. Run the in-place upgrade:
```bash
cd kubespray
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
```
4. Verify the cluster:
```bash
kubectl version
kubectl get nodes -o wide
kubectl get pods -A | grep -v -E "Running|Completed"
```
---
## Directory Structure
* `init.sh`: The entry point script. **Source of truth for Kubespray version.**
* `inventory/`: Contains cluster inventory configurations (hosts, variables). **Edit this, not the one in `kubespray/`**.
* `kubespray/`: (Ignored/Ephemeral) The checked-out Kubespray repository. **Do not edit files here directly**; they will be overwritten.
## Configuration Updates
To upgrade Kubespray or change cluster config:
1. **Version Upgrade**: Update `VERSION` in `init.sh` (e.g., to `release-2.28`).
2. **Config Changes**: Modify files in `./inventory/`.
3. **Apply**: Run `source init.sh` then execute the Ansible playbook.
* `init.sh`: The entrypoint script (sources environment, applies patches, sets up inventory).
* `patches/`: Monorepo patches applied automatically to ephemeral `kubespray/`.
* `inventory/`: Source of truth for cluster inventory and variables.
* `kubespray/`: (Ephemeral / Gitignored) Cloned Kubespray repository.
+132 -81
View File
@@ -1,114 +1,165 @@
# Bootstrap
The following lines document how to initalize a fresh cluster. On a real cluster, or using Vagrant. It therefore assumes to clone kubespray to this folder. It will be excluded in *.gitignore* and all files are in this folder.
This directory contains the automation, configurations, and playbooks used to provision and upgrade the single-node Kubernetes cluster **`haumdaucher`** (`136.243.23.215`) using **[Kubespray](https://github.com/kubernetes-sigs/kubespray)**.
The version to be initialized in configured in the `init.sh` script.
---
## Architecture & Single-Node Cluster Constraints
`haumdaucher` is a **single-node cluster** (control plane, etcd, and worker all run on the same machine). This topology requires specific configuration choices during cluster maintenance:
> [!IMPORTANT]
> ### Crucial Single-Node Invariants
> 1. **Never Cordon or Drain the Node**:
> Draining a single-node cluster evicts all workloads with nowhere to schedule them, causing pods to stay in `Pending` state and breaking critical cluster services (such as DNS, CNI, ingress, and local storage).
> In [inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml](file:///Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml), the following settings are enforced:
> ```yaml
> drain_nodes: false
> upgrade_node_always_cordon: false
> upgrade_node_fail_if_drain_fails: false
> ```
> 2. **In-Place Upgrades via `cluster.yml`**:
> Instead of `upgrade-cluster.yml` (which defaults to draining nodes), single-node upgrades should use the in-place upgrade mechanism:
> ```sh
> ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
> ```
> 3. **Version Number Formatting**:
> Kubespray 2.28 interpolates `v{{ kube_version }}` across many internal tasks. Therefore, `kube_version` in `k8s-cluster.yml` MUST NOT include a leading `v`:
> ```yaml
> kube_version: 1.32.13 # Correct
> # kube_version: v1.32.13 # INCORRECT (leads to v1.32.13 -> vv1.32.13 errors)
> ```
> 4. **Host OS Lifecycle Constraint (Ubuntu 20.04 LTS)**:
> Kubespray `release-2.28` is the **final release line supporting Ubuntu 20.04**. Kubespray `release-2.29`+ drops Ubuntu 20.04 support and requires Ubuntu 22.04 or 24.04. Do not bump `VERSION` in `init.sh` beyond `release-2.28` until the host OS is upgraded.
---
## Quickstart
### 1. Environment Initialization
Source `init.sh` from this directory. It clones Kubespray, applies patches from `patches/`, configures the Python virtualenv, and syncs `./inventory/` into `./kubespray/inventory/`:
```sh
. ./init.sh
source ./init.sh
```
See [kubespray.io](https://kubespray.io/) on detailed information about kubespray. Though it seems to be a littlebit outdated.
*(Note: `init.sh` also exports the `sync-inventory` alias to quickly sync local `./inventory/` modifications into `./kubespray/inventory/` without re-cloning).*
## Vagrant
---
```sh
cd kubespray
vagrant up
# up and abkle to ssh
vagrant ssh k8s-1
```
## Step-by-Step Upgrade Runbook
## Prod
Upgrades are conducted in two decoupled phases:
1. **Phase 1**: Upgrade the Kubespray tooling baseline while keeping the Kubernetes version unchanged $\rightarrow$ Apply $\rightarrow$ Verify.
2. **Phase 2**: Upgrade Kubernetes components (API server, controller manager, scheduler, kubelet, kube-proxy) $\rightarrow$ Apply $\rightarrow$ Verify.
Prepare server:
* deactivate swap!
* `moritz username ALL=(ALL) NOPASSWD:ALL`
* `
### Step 0: Pre-Flight Checks & Backups
```sh
ssh centos@<ip>
# auth via pw
sudo su - root
adduser moritz
visudo # add as sudo user
su - moritz
sudo yum -y install vim python3
ssh-keygen
vim .ssh/authorized_users # paste key
chmod 644 .ssh/authorized_keys
# check whether login works with ssh key
sudo vim /etc/ssh/sshd_config # remove pw auth & root login
sudo yum upgrade -y && sudo reboot
```
1. **Verify SSH and sudo access**:
```sh
ssh moritz@136.243.23.215 "sudo kubectl get nodes -o wide"
```
Install Kubernetes:
2. **Check Pod Health**:
```sh
ssh moritz@136.243.23.215 "sudo kubectl get pods -A | grep -v -E 'Running|Completed'"
```
```sh
. ./init.sh
# follow instructions from output, sth like:
cd kubespray
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
```
3. **Take a Manual etcd Snapshot**:
```sh
ssh moritz@136.243.23.215 "sudo etcdctl \
--cacert=/etc/ssl/etcd/ssl/ca.pem \
--cert=/etc/ssl/etcd/ssl/node-haumdaucher.pem \
--key=/etc/ssl/etcd/ssl/node-haumdaucher-key.pem \
snapshot save /var/backups/etcd/etcd-pre-upgrade-\$(date +%Y%m%d%H%M%S).db"
```
And get credentials:
---
```sh
ssh <ip>
sudo su - root
cd
cp -r .kube /home/moritz/
chown -R moritz. /home/moritz/.kube
#ctrl + d
kubectl get ns # test connection
#ctrl + d
scp haumdaucher.de:/home/moritz/.kube/config .kube/config
```
### Step 1: Reconcile Kubespray Baseline (Phase 1)
Foreward in k8s-directory.
1. Verify `VERSION` in `init.sh` matches the target Kubespray branch (e.g. `release-2.28`).
2. Run `source ./init.sh` to initialize the environment.
3. Apply the baseline configuration:
```sh
cd kubespray
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
```
4. Verify cluster health:
```sh
kubectl get nodes -o wide
kubectl get pods -n kube-system
```
## Upgrade cluster
---
Check the current default value of `kube_version` in cloned repository.
### Step 2: Upgrade Kubernetes Version (Phase 2)
**FOR K8S VERSION - Edit /Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s-cluster/k8s-cluster.yml**
1. In `bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml`, update `kube_version`:
```yaml
kube_version: 1.32.13
```
```sh
cd kubespray
ansible-playbook -i inventory/prod/inventory.ini -e kube_version=v1.22.10 -e upgrade_cluster_setup=true cluster.yml
# or just the newest version
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
# upgrade to specific calico version (did not trigger/ failed)
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true -e calico_version=v3.15.2 cluster.yml --tags=network
```
2. Sync the configuration into the `kubespray/` workspace:
```sh
sync-inventory
```
History:
3. Run the in-place upgrade playbook:
```sh
cd kubespray
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
```
* 2020-04-18 kube_version=v1.16.8 kubespray_branch=release-2.12
---
## Add node
### Step 3: Post-Upgrade Verification
See [documentation](https://github.com/kubernetes-sigs/kubespray/blob/master/docs/nodes.md).
1. **Check component versions**:
```sh
kubectl version
```
Ensure both `Client Version` and `Server Version` report `v1.32.13`.
Note: This was more or less a trial and error approach. Running different playbooks over and over again got it right at some point.
2. **Verify Node status**:
```sh
kubectl get nodes -o wide
```
Ensure status is `Ready` and version matches `v1.32.13`.
```sh
ansible-playbook -i inventory/prod/inventory.ini --limit=ns3088070.ip-37-59-40.eu,ns3100058.ip-37-59-61.eu scale.yml
ansible-playbook -i inventory/prod/inventory.ini --limit=etcd,kube-master -e ignore_assert_errors=yes cluster.yml
```
3. **Verify System Pods**:
```sh
kubectl get pods -n kube-system
```
All control plane components (`kube-apiserver`, `kube-controller-manager`, `kube-scheduler`, `coredns`, `kube-flannel`, `kube-proxy`, `traefik`) should be `1/1 Running`.
This runs everything and is kind of idempotent:
4. **Verify Workloads**:
```sh
kubectl get pods -A | grep -v -E "Running|Completed"
```
```sh
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
```
---
## Upgrade to 2.31.3
## Troubleshooting & Edge Cases
Required to execute:
### Kubeadm Preflight `CreateJob` Failure
Kubeadm 1.31+ preflight executes an ephemeral `upgrade-health-check-...` batch job with a 15-second timeout. On single-node clusters or under load, this may exceed 15s. To prevent the upgrade from halting:
- `CreateJob` is included in `kubeadm_ignore_preflight_errors` in [k8s-cluster.yml](file:///Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml).
- The patch `patches/kubeadm-upgrade-ignore-preflight.patch` ensures Kubespray's `v1beta4` kubeadm configuration propagates this flag during `upgrade apply`.
```
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true -e drain_nodes=false upgrade-cluster.yml
```
### Kubelet Housekeeping Stalls / Static Pod Timeout
If `kubeadm upgrade apply` times out waiting for static pod hash changes:
- Check `journalctl -u kubelet -n 100` for `Housekeeping took longer than expected` or `orphaned pod ... failed to rmdir()`.
- If an old container unmount left a stale bind-mount busy, lazily detach it:
```sh
sudo umount -l /var/lib/kubelet/pods/<orphaned-pod-uid>/volumes/...
sudo systemctl restart kubelet
```
+ set a feature flag: https://github.com/kubernetes-sigs/kubespray/issues/11887
---
## Directory Layout
* `init.sh`: Environment bootstrapping script and upgrade instructions.
* `inventory/`: Source-of-truth inventory and variable files.
* `patches/`: Git patches applied to upstream Kubespray on checkout.
* `kubespray/`: (Ephemeral) Cloned Kubespray repository.
+67 -49
View File
@@ -1,60 +1,78 @@
#!/bin/sh
# check whether script is sourced
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && echo "script is executed, but needs to be sourced" && exit 1
# Ensure script is sourced, not executed directly
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && echo "Error: init.sh must be sourced: source ./init.sh (or . ./init.sh)" && exit 1
echo "######################################################################################"
echo "## Reinit repository"
rm -rf kubespray
VERSION="release-2.27"
git clone --branch $VERSION https://github.com/kubernetes-sigs/kubespray.git
BOOTSTRAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERSION="release-2.28" # Note: release-2.28 is the final release line supporting Ubuntu 20.04 LTS
echo "######################################################################################"
echo "## Activating pyenv venv"
echo "======================================================================================"
echo "## [1/4] Checking / Cloning Kubespray ($VERSION)"
echo "======================================================================================"
if [ ! -d "$BOOTSTRAP_DIR/kubespray/.git" ]; then
rm -rf "$BOOTSTRAP_DIR/kubespray"
git clone --branch "$VERSION" --depth 1 https://github.com/kubernetes-sigs/kubespray.git "$BOOTSTRAP_DIR/kubespray"
else
echo "Existing kubespray checkout detected. Ensuring correct branch..."
git -C "$BOOTSTRAP_DIR/kubespray" fetch origin "$VERSION" --depth 1
git -C "$BOOTSTRAP_DIR/kubespray" checkout "$VERSION"
fi
if [ -d "$BOOTSTRAP_DIR/patches" ]; then
for p in "$BOOTSTRAP_DIR"/patches/*.patch; do
if [ -f "$p" ]; then
echo "## Applying patch $(basename "$p")..."
git -C "$BOOTSTRAP_DIR/kubespray" apply --check "$p" 2>/dev/null && git -C "$BOOTSTRAP_DIR/kubespray" apply "$p" || echo "Patch $(basename "$p") already applied or skipped."
fi
done
fi
echo "======================================================================================"
echo "## [2/4] Activating Python Virtualenv"
echo "======================================================================================"
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)"
pyenv virtualenv 3.10.8 infrapuzzle-bootstrap
pyenv virtualenvs --bare | grep -q "^infrapuzzle-bootstrap$" || pyenv virtualenv 3.10.8 infrapuzzle-bootstrap
pyenv activate infrapuzzle-bootstrap
python -m pip install -r kubespray/requirements.txt
python -m pip install -q -r "$BOOTSTRAP_DIR/kubespray/requirements.txt"
echo "======================================================================================"
echo "## [3/4] Syncing Inventory Configuration"
echo "======================================================================================"
rsync -a "$BOOTSTRAP_DIR/inventory/" "$BOOTSTRAP_DIR/kubespray/inventory/"
echo "######################################################################################"
echo "## Customizing vagrant dev env"
mkdir -p kubespray/vagrant
cat << EOF > kubespray/vagrant/config.rb
\$instance_name_prefix = "k8s"
\$vm_cpus = 4
\$num_instances = 1
\$os = "centos"
\$subnet = "10.0.20"
\$network_plugin = "calico"
\$shared_folders = { 'temp/docker_rpms' => "/var/cache/yum/x86_64/7/docker-ce/packages" }
\$kube_node_instances_with_disks_number = 0
EOF
# Helper function for re-syncing changes made in ./inventory to ephemeral ./kubespray/inventory
sync-inventory() {
rsync -av "$BOOTSTRAP_DIR/inventory/" "$BOOTSTRAP_DIR/kubespray/inventory/"
}
export -f sync-inventory 2>/dev/null || true
alias sync-inventory="rsync -av '$BOOTSTRAP_DIR/inventory/' '$BOOTSTRAP_DIR/kubespray/inventory/'"
# make the rpm cache
mkdir -p kubespray/temp/docker_rpms
echo "###############"
echo "Now cd to kubespray and 'vagrant up'"
echo "======================================================================================"
echo "## [4/4] Environment Ready! Single-Node Cluster Upgrade Workflow"
echo "======================================================================================"
echo ""
echo "export KUBECONFIG=\"$( pwd )/kubespray/inventory/sample/artifacts/admin.conf\""
echo "######################################################################################"
echo "## * syncing config in kubespray dir"
rsync -a ./inventory/ ./kubespray/inventory/
echo "## * changing into kubespray dir"
cd kubespray
echo "## * execute the following command to force new settings"
echo "ansible-playbook -i inventory/prod/inventory.ini cluster.yml"
echo "## * execute the following command to force new settings with forced cluster upgrade"
echo "ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml"
# cp -r kubespray/inventory/sample kubespray/inventory/prod
# rm kubespray/inventory/prod/inventory.ini
# cp ./prod.ini kubespray/inventory/prod/inventory.ini
#gsed -i "s/kube_network_plugin: .*/kube_network_plugin: flannel/" ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-cluster.yml
# gsed -i "s/# calico_iptables_backend: \"Legacy\"/calico_iptables_backend: \"NFT\"/" ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-net-calico.yml
#echo 'calico_iptables_backend: "NFT"' >> ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-net-cluster.yml
#gsed -i "s/metrics_server_enabled: .*/metrics_server_enabled: true/" ./kubespray/inventory/prod/group_vars/k8s-cluster/addons.yml
echo "Current directory changed to: $BOOTSTRAP_DIR/kubespray"
cd "$BOOTSTRAP_DIR/kubespray"
echo ""
echo "=== PRE-UPGRADE CHECKLIST ==="
echo "1. Verify SSH connection and sudo access:"
echo " ssh moritz@136.243.23.215 'sudo kubectl get nodes -o wide'"
echo "2. Take manual etcd snapshot before upgrading:"
echo " ssh moritz@136.243.23.215 'sudo etcdctl --cacert=/etc/ssl/etcd/ssl/ca.pem --cert=/etc/ssl/etcd/ssl/node-haumdaucher.pem --key=/etc/ssl/etcd/ssl/node-haumdaucher-key.pem snapshot save /var/backups/etcd/etcd-pre-upgrade-\$(date +%Y%m%d%H%M%S).db'"
echo ""
echo "=== TWO-PHASE UPGRADE WORKFLOW ==="
echo "Phase 1: Reconcile Kubespray baseline (keeps current k8s version):"
echo " ansible-playbook -i inventory/prod/inventory.ini cluster.yml"
echo ""
echo "Phase 2: Upgrade Kubernetes version (e.g. to 1.32.13):"
echo " a) Update 'kube_version: <target>' in ../inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml"
echo " b) Sync inventory: sync-inventory"
echo " c) Run in-place single-node upgrade:"
echo " ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml"
echo ""
echo "=== POST-UPGRADE VERIFICATION ==="
echo " kubectl version"
echo " kubectl get nodes -o wide"
echo " kubectl get pods -A | grep -v -E 'Running|Completed'"
echo "======================================================================================"
@@ -27,7 +27,17 @@ kube_kubeadm_apiserver_extra_args:
kube_api_anonymous_auth: true
## Change this to use another Kubernetes version, e.g. a current beta release
kube_version: v1.31.9
kube_version: 1.32.13
# Single-node cluster settings: prevent draining or cordoning the only node
drain_nodes: false
upgrade_node_always_cordon: false
upgrade_node_fail_if_drain_fails: false
kubeadm_ignore_preflight_errors:
- ImagePull
- SystemVerification
- FileContent--proc-sys-net-bridge-bridge-nf-call-iptables
- CreateJob
# kubernetes image repo define
#kube_image_repo: "k8s.gcr.io"
@@ -0,0 +1,20 @@
diff --git a/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml b/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
index 523956d1d..75723a6e0 100644
--- a/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
+++ b/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
@@ -17,6 +17,7 @@
--force
{%- else %}
--config={{ kube_config_dir }}/kubeadm-config.yaml
+ --ignore-preflight-errors={{ kubeadm_ignore_preflight_errors | join(',') }}
{%- endif %}
{%- if kube_version is version('1.32.0', '>=') %}
--skip-phases={{ kubeadm_init_phases_skip | join(',') }}
@@ -38,6 +39,7 @@
{% if kubeadm_patches | length > 0 %}--patches={{ kubeadm_patches_dir }}{% endif %}
{%- else %}
--config={{ kube_config_dir }}/kubeadm-config.yaml
+ --ignore-preflight-errors={{ kubeadm_ignore_preflight_errors | join(',') }}
{%- endif %}
--skip-phases={{ kubeadm_upgrade_node_phases_skip | join(',') }}
register: kubeadm_upgrade
@@ -119,7 +119,30 @@ The main conductor is guided by explicit system prompt rules:
## Future Roadmap: Monorepo Refactoring Blueprint
This Hybrid MCP architecture serves as the official template for future refactorings in `infrapuzzle`:
1. **Shopping & Supermarket Deals**: Migrate current static MCP tools behind Tool Search; delegate multi-store price comparisons and recipe cart generation to a shopping subagent.
2. **Jellyfin & Media Management**: Keep playback/resume queries direct; delegate complex media recommendations and library reorganizations to a media subagent.
3. **Local News & Stadtrat Research**: Provide direct search for council dates; delegate deep legislative research, PDF document extraction, and meeting summarization to an isolated Stadtrat research subagent.
This Hybrid MCP architecture serves as the official template for all agentic workflows in `infrapuzzle`. On 2026-09-16, this architecture was formally expanded into the **Full-Spectrum Multi-Agent Architecture** with 9 specialized subagents:
### 1. Agents-as-Code in the Knowledge Wiki (`knowledge-wiki/agents/`)
Subagent specifications, system prompts, tool assignments, and domain guidelines are decoupled from Kubernetes deployment manifests and stored as versioned Markdown files in `knowledge-wiki/agents/<name>.md`.
* **Zero-Deployment Tuning**: Adding or refining an agent requires no Kubernetes pod restarts; changes synced via Git are immediately effective.
* **Extensibility Blueprint**: Adding a 10th agent requires simply adding `agents/<new-agent>.md` with YAML frontmatter specifying model, tools, and owned concepts.
### 2. The 9 Specialized Subagents Roster
1. 🩺 **Digitale Hebamme Agent** (`agents/hebamme.md`): Baby milestones, sleep windows, BLW, pediatrician safety boundaries.
2. 🍳 **Koch Agent** (`agents/koch.md`): Ninja CREAMi Deluxe, Airfryer, KitchenAid, leftover utilization, exact recipes.
3. 🏡 **Grabengasse Agent** (`agents/grabengasse.md`): Home Assistant, Philips Hue, Sonos multiroom, house systems.
4. 🏰 **Regensburg Agent** (`agents/regensburg.md`): Regensburg & Stadtamhof insider, local places, RVV transit, city notices.
5. ✈️ **Travel Agent** (`agents/travel.md`): Vacation planning, itineraries, packing checklists, on-the-road travel concierge.
6. 🎬 **Media Agent** (`agents/media.md`): Jellyfin streaming/requests, Jellyseerr, Spotify audiobooks & playlists.
7. 🔍 **Recherche Agent** (`agents/recherche.md`): Deep research engine (iterative multi-hop), ALLRIS Stadtrat, Friday bridge updates.
8. 🛒 **Shopping Agent** (`agents/shopping.md`): Deal hunter, Geizhals all-time lows, supermarket deals, multi-tier webshop extraction.
9. 🏋️ **Fitness Coach Agent** (`agents/fitness.md`): Garmin Connect (148 tools), Tacx Flux 2 (FTP 275 W), home gym supersets.
### 3. Multi-Turn Continuity: Domain Stickiness & Rich State Bridge
To prevent context loss during ongoing step-by-step dialogues (e.g. interactive cooking or workout design):
* **Persistent Context Continuity**: Follow-up turns are routed within the dedicated subagent topic session using native conversational memory, keeping user-facing messages clean without embedding raw JSON/HTML metadata tags.
### 4. 3-Tier Session Lifecycle (Preventing Unbounded Session Growth)
1. **Conversational Close**: Topic shifts or explicit user completion (*"Danke, das war's"*) reset the active domain pointer.
2. **Ephemeral Inactivity Cleanup**: Subagent child sessions inactive for > 60 minutes (`subagents.archiveAfterMinutes: 60`) are automatically archived.
3. **Deterministic Nightly Compaction**: Nightly 03:30 AM CET compaction vacuums expired child sessions from the SQLite store.
@@ -0,0 +1,258 @@
---
status: "accepted (fleet-wide primary: DeepSeek-V4.1-Flash-1017; updated 2026-10-17)"
date: 2026-09-13
decision-makers: ["Moritz Graf", "Antigravity"]
consulted: []
informed: []
---
# ADR 0002: OpenClaw Full-Spectrum Model Selection, Multimodal Tiering, and Cost Governance
## Context and Problem Statement
OpenClaw serves as the 24/7 personal AI concierge, family assistant, and operational orchestrator for Moritz Graf and his family. It interacts across Telegram, voice notes, webhooks, and background automations, bridging to personal knowledge (Obsidian wiki), smart home systems (Home Assistant, Sonos), fitness tracking (Garmin Connect), and cluster monitoring (Prometheus, Gitea).
During early September 2026, OpenClaw consumed **143.26 million tokens** over 7 days, accumulating **\$12.55+** in raw OpenRouter API costs. Forensic analysis revealed that over **80% of this spend was architectural waste**:
1. **Unbounded Context Re-transmission**: An uncompacted session (`agent:main:main`) reached 381k tokens (2,650 events, 5.7 MB JSON). Every minor turn re-ingested this entire history, costing \$12.20 (97.2% of total spend).
2. **Heartbeat Amplification Loop**: Background heartbeats firing every 30 minutes targeted the active user session (`target: last`), re-evaluating the 381k context 48 times/day simply to output `NO_REPLY` (\$5.16 wasted).
3. **Expensive & Deprecated Model Allocations**: Frontier models (`claude-3.7-sonnet` and `deepseek-v4-pro`) were configured without clear cost-benefit boundaries, with subagents frequently timing out (>300s). In addition, legacy model generations (e.g., Gemini 2.5) lingered in configuration despite superior, newer generations being available.
4. **Modality Conflation (Image Input vs. Image Generation)**: Vision perception (analyzing an uploaded photo) was conflated with text-to-image synthesis (drawing an image). Configuring chat models like `z-ai/glm-5.3-flash` for image synthesis caused runtime HTTP 404 failures because pure LLMs lack diffusion/raster generation heads.
Moritz established two foundational requirements for the repository:
> 1. *"I want high quality, but it should not be too expensive."*
> 2. *"We always want to use the newest models (as they usually are the best)."*
How do we architect the full-spectrum model selection across **all modalities** (text, subagents, heartbeats, vision, image generation, audio transcription, text-to-speech, web search, and vector embeddings) to deliver premium quality, sub-10s latency, and a total operating budget of **<\$2.00 / week**?
---
## Decision Drivers
* **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
- Native Multimodal Vision (reading user photos)
- Text-to-Image Generation (synthesizing new pictures)
- Speech-to-Text / Audio Input (transcribing voice messages)
- 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), 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 and strict quantization/provider pins (`fp8`/`bf16`, `tool_choice` support) across independent providers on OpenRouter.
---
## Full-Spectrum Model Architecture & Concrete Allocations
```
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ OpenClaw Ingress Gateway │
└──────────────────────────────────────────────────┬─────────────────────────────────────────────────────┘
│
┌───────────────────┬─────────────────────┼─────────────────────┬───────────────────┐
▼ ▼ ▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Text Conductor │ │ Subagent Engine │ │ Background & │ │ Image Synthesis │ │ Voice & Audio │
│ & Vision Input │ │ (sessions_spawn)│ │ Heartbeat │ │ (Text-to-Image) │ │ (STT & TTS) │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ 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 │
│ • Decoupled OCR │ │ • Isolated Run │ │ • 30m Polling │ │ Raster Head │ │ Flash TTS │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 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 │ │ │ │ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
```
---
## Modality Specifications & Cost-Benefit Analysis
### 1. Interactive Conductor & Agentic Reasoning (Tier 1)
* **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` (`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)
* **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/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` 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).
- **Cost**: \$0.00006 per generated image + \$0.50/1M prompt.
- **Latency**: ~4.5 seconds.
- **Fallbacks**:
1. `openrouter/google/gemini-3.1-flash-image-preview`
2. `openrouter/google/gemini-3-pro-image` (Nano Banana Pro)
- **Decommissioned**: Gemini 2.5 is completely removed from all image configurations.
### 5. Voice, Audio & Podcast Synthesis (Tier 5)
* **Speech-to-Text (Audio Input)**:
- **Engine**: `openrouter/openai/whisper-large-v3-turbo`
- **Capabilities**: Fast, highly accurate transcription of German and English Telegram voice notes, with local gateway fallback.
* **Text-to-Speech (Audio Dossiers & Podcasts)**:
- **Engine**: `google/gemini-3.1-flash-tts-preview` via `text-to-audio` skill.
- **Voices**:
- German Solo/Host: `Puck` (warm, natural, dynamic).
- German Deep/Calm: `Charon`.
- German Co-Host / Expert: `Aoede`.
- English: `Zephyr`.
- **Fallback**: `minimax/speech-2.8-hd` for studio-grade multi-speaker podcasts when Google TTS is throttled.
### 6. Grounded Web Search & Vector Embeddings
* **Live Search**: `perplexity/sonar-pro` via OpenRouter (replacing raw web scraping with grounded citations).
* **Memory Embeddings**: `text-embedding-3-small` via OpenRouter for the Obsidian personal knowledge wiki.
---
## Context Lifecycle & Daytime Headroom Policy
To balance the user's desire to retain full conversation context across the entire day while avoiding the 70-second latency penalty that uncompacted transcripts impose on OpenRouter providers:
1. **Daytime Headroom (`maxActiveTranscriptBytes: "3mb"`)**:
The active transcript is permitted to retain the day's back-and-forth interactions so OpenClaw recalls earlier topics and instructions naturally.
2. **Deterministic Nightly Compaction**:
Scheduled daily at **03:30 AM Europe/Berlin** (`ce39bbf8-b83f-453e-be53-04e27db27b78`):
```bash
openclaw sessions compact "agent:main:main" --max-lines 250
```
Executes in **2.1 seconds** using **0 LLM tokens**, ensuring every morning begins with a lean, fast ~15k token transcript.
---
## Open Clarifying Questions for Moritz
To align on remaining edge cases before final sign-off:
1. **Active Trial with GLM-5.3-Flash as Primary Conductor**:
- As requested, `z-ai/glm-5.3-flash` is now deployed as the Primary Conductor for interactive chat and autonomous subagents, equipped with the prompt guardrails against direct tool calls and XML leakage.
- `deepseek/deepseek-v4.1-flash` stands ready as the zero-latency primary fallback.
- We will observe real-world performance, tool adherence, and cost over the next several days.
2. **Thinking / Reasoning Mode on Telegram**:
- Currently, `thinking: medium` is enabled, emitting 100–300 reasoning tokens before generating conversational text.
- *Question*: Would you like to disable thinking mode (`thinking: none`) for standard Telegram chat turns (for near-instant replies) and keep reasoning active only for subagents?
3. **On-Demand Frontier Model**:
- With all standard workloads handled by Flash-tier models, do you ever want an explicit escape hatch to a frontier model (e.g. `claude-3.7-sonnet` or `gemini-3.1-pro-preview` triggered exclusively via `/expert <query>`)?
---
## 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 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 ~\$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 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`).
@@ -0,0 +1,153 @@
---
status: "accepted"
date: 2026-09-17
decision-makers: ["Moritz Graf", "Antigravity"]
consulted: []
informed: []
---
# ADR 0003: Everything as Code (EaC) and Reproducible Declarative Infrastructure
## Context and Problem Statement
The **Infrapuzzle** monorepo governs the complete digital infrastructure for Moritz Graf and family: bare-metal server provisioning (Haumdaucher node), Kubernetes workloads, DNS management, mail routing, smart home/IoT edge devices, and AI concierge workflows (OpenClaw and specialized domain subagents).
Historically, home labs and personal infrastructure suffer from the "snowflake" problem:
1. **Undocumented ClickOps**: Settings altered manually in cloud provider consoles (e.g. OVH DNS, router web UIs) or via web dashboards (Home Assistant, Grafana) become unversioned, undocumented, and fragile.
2. **Ephemeral Terminal Interventions**: Ad-hoc `kubectl edit`, hot-patched container environments, or manual secret injections deviate from repository state, rendering disaster recovery nearly impossible.
3. **Agent Prompt Fragmentation**: As AI agents proliferate (e.g. OpenClaw conductor, domain subagents like Hebamme, Koch, Grabengasse, Fitness, Recherche), defining prompts or tool configs across disparate Helm values, ConfigMaps, and local files leads to inconsistencies, stale behavior, and duplicate definitions.
How do we establish a strict, maintainable architectural standard that ensures **everything is defined as code as far as feasible and reproducible**, while accommodating dynamic runtime state and secrets safely?
---
## Decision Drivers
* **Determinism & Disaster Recovery**: The entire single-node cluster (`haumdaucher.de`), along with all services, DNS records, and edge devices, must be rebuildable from scratch purely from git repositories.
* **Single Source of Truth**: Eliminate configuration drift across all operational domains.
* **Exclusive Agent Storage**: Establish an unambiguous, single home for all AI agent personas, system prompts, role boundaries, and tool guidance.
* **Declarative Secret Management**: Store all secrets in version control without risking plain-text credential leaks.
* **Pragmatic Feasibility Boundary**: Clearly separate declarative desired state (in Git) from legitimate mutable runtime data (databases, dynamic chat histories, live OAuth session tokens) without breaking stateful applications.
---
## Considered Options
1. **Option 1: Ad-Hoc / Best-Effort Setup (Status Quo Antipattern)**
* Store major K8s manifests in git, but allow manual tweaks in web consoles (OVH, Home Assistant, Shelly) and ad-hoc `kubectl` patches.
* *Drawbacks*: Severe drift, snowflake server syndrome, impossible disaster recovery, zero audit trail.
2. **Option 2: Pure GitOps Immutability with Zero Runtime Mutation**
* Force every state mutation (including session compacting, OAuth token refreshes, and dynamic settings) to generate a Git commit.
* *Drawbacks*: Impractical for stateful workloads. High friction, commit noise, rate limits on git providers, and potential race conditions when multi-agent systems rapidly update local session caches.
3. **Option 3: Comprehensive Everything-as-Code with Pragmatic State Governance (Selected)**
* Define 100% of infrastructure, workloads, DNS, IoT edge devices, and AI agents declaratively in Git.
* Enforce `git-crypt` for declarative secrets.
* Isolate mutable runtime state (databases, chat histories, active OAuth tokens) to persistent volumes (PVCs) governed by zero-drift merge mechanics (`mergeMode: merge`).
* Mandate `knowledge-wiki/agents/` as the **exclusive** home for all AI agent prompt definitions.
---
## Decision Outcome
Chosen Option: **Option 3: Comprehensive Everything-as-Code with Pragmatic State Governance**.
### 1. The Five Tiers of Everything as Code (EaC)
```
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Infrapuzzle Monorepo (Git) │
└────────────────────────────────────────────────┬─────────────────────────────────────────────────┘
│
┌──────────────────┬───────────────────────┼───────────────────────┬──────────────────┐
▼ ▼ ▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ Tier 1 │ │ Tier 2 │ │ Tier 3 │ │ Tier 4 │ │ Tier 5 │
│Bare-Metal │ │Kubernetes │ │Cloud & DNS│ │Smart Home │ │Agents as │
│Bootstrap │ │Workloads │ │ │ │& IoT Edge │ │Code (Wiki)│
├───────────┤ ├───────────┤ ├───────────┤ ├───────────┤ ├───────────┤
│• Kubespray│ │• Helm │ │• Terraform│ │• ESPHome │ │• Exclusive│
│• Ansible │ │• Manifests│ │ (OVH DNS)│ │• Shelly │ │ prompts │
│• init.sh │ │• Operators│ │• Mail DNS │ │ Terraform│ │• Tool │
│ │ │ (OpenClaw│ │ (MX/SPF/ │ │• BLE & │ │ bindings │
│ │ │ CRDs) │ │ DKIM) │ │ Sensors │ │• git-sync │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Hetzner Node Haumdaucher K8s Public DNS & Grabengasse 7 OpenClaw
Base OS Setup Cluster Engine Domain Records Physical Devices Conductor
```
#### Tier 1: Bare-Metal Server Provisioning (`bootstrap/`)
* **Tooling**: Kubespray, Ansible, Bash (`init.sh`).
* **Scope**: Operating system hardening, kernel modules, firewall rules, container runtime (containerd), and Kubernetes control plane setup on the single bare-metal node (Haumdaucher, `136.243.23.215`).
* **Source of Truth**: `bootstrap/init.sh` and version-pinned Kubespray playbooks.
#### Tier 2: Kubernetes Workloads & Operators (`k8s/`)
* **Tooling**: Raw Kubernetes manifests, Helm 3 values, and Kubernetes Custom Resource Definitions (CRDs).
* **Conventions**:
- Directory structure enforces namespace partitioning (`k8s/<namespace>/`).
- Workloads managed via declarative specifications (e.g. OpenClaw Operator `OpenClawInstance` CRD in `k8s/openclaw/openclaw-instance.secret.yaml`).
- Standardized ingress controller (`ingress-nginx` / `traefik`) with automated Let's Encrypt certificates (`cert-manager`).
#### Tier 3: External Infrastructure, DNS & Mail (`terraform/`)
* **Tooling**: Terraform with `ovh/ovh` provider.
* **Scope**:
- Authoritative DNS zones (`haumdaucher.de`, `moritzgraf.de`, `ameliegraf.de`, and others).
- Google Workspace mail routing (MX, SPF TXT, DKIM, DMARC records).
- 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 (`projects/shelly_taupunktluefter/`, `esphome/`, `monitoring/`)
* **Tooling**: ESPHome, Shelly Terraform Provider, YAML configs.
* **Scope**:
- Shelly relays and smart switches provisioned declaratively via Terraform.
- ESPHome firmware configurations for ESP32 Bluetooth proxies, Xiaomi BLE temperature/hygrometer sensors, and plant moisture monitors.
- All device configurations, pinouts, and network parameters committed to git; flashing performed deterministically from code.
#### Tier 5: Agents-as-Code & Knowledge Systems (`knowledge-wiki/agents/`)
* **Core Rule — Exclusive Single Source of Truth**:
> **All AI agent definitions (prompts, personas, operational boundaries, and tool calling instructions) MUST reside exclusively in [`knowledge-wiki/agents/<name>.md`](../../knowledge-wiki/agents/).**
* **Strict Architecture**:
- Agent behavior is never hardcoded inside container images, ad-hoc Helm values, or untracked local directories.
- Adding or modifying an agent is done purely by creating or editing a markdown specification in the `knowledge-wiki/agents/` directory (e.g. `hebamme.md`, `koch.md`, `grabengasse.md`, `fitness.md`, `recherche.md`).
- Workloads (such as OpenClaw) ingest these definitions dynamically via `git-sync` sidecars or submodules, enabling instantaneous updates without requiring pod restarts or image rebuilds.
- If agent prompts or specifications are discovered outside of `knowledge-wiki/agents/`, they must be refactored and consolidated into this directory.
---
## The Feasibility Boundary & State Governance
The user requirement explicitly dictates: *"we want to do everything as code as far as it is feasible and reproducible"*. To balance pure declarative code with stateful services, the boundary is defined as follows:
| Dimension | Managed as Code (Git) | Managed on Persistent Storage (PVC) |
| :--- | :--- | :--- |
| **Workload Topology** | Pod specs, replicas, resource limits, CRDs, Helm releases. | — |
| **Secrets & Keys** | Encrypted via `git-crypt` (`*.secret`, `*.secret.yaml`, `*.secret.values`). | Mounted into runtime memory/containers. |
| **Agent Behavior** | System prompts, personas, roles, tool manifests in `knowledge-wiki/agents/`. | Dynamic short-term conversation context, user memory (`MEMORY.md`). |
| **External Auth Tokens** | Initial static API keys and client credentials. | Volatile session tokens refreshed dynamically at runtime (e.g. Garmin OAuth tokens on PVC). |
| **Data Storage** | Schema migrations, DB connection configs, backup cronjobs. | Raw databases (PostgreSQL, SQLite), media files, Nextcloud files. |
| **Operator Integration** | Declarative base configurations (`spec.config.raw`). | Runtime mutations deep-merged safely via `mergeMode: merge` without overwriting runtime state. |
### Operational Governance Rules
1. **No Ad-Hoc `kubectl edit`**: Never modify cluster objects directly with `kubectl edit` or unrecorded commands. All modifications must be committed to git and applied via `kubectl apply -f <manifest>` or Helm.
2. **Git-Crypt Security Enforcement**: Any file containing API keys, private passwords, or private identities must match the `.gitattributes` pattern (`*.secret*`) and be locked with `git-crypt`.
3. **Reproducibility Guarantee**: In the event of total hardware loss, cloning `infrapuzzle` and unlocking `git-crypt` must be sufficient to run `bootstrap/init.sh`, apply Terraform, restore PVC backups from Velero/MinIO, and restore full operational state.
---
## Consequences
### Positive
* **100% Deterministic Disaster Recovery**: Eliminates mysterious configuration drift and allows full rebuilds of bare-metal, Kubernetes, DNS, and IoT systems.
* **Unified AI Agent Lifecycle**: Developers and AI assistants edit agent personalities, prompts, and skills in a single, version-controlled markdown directory (`knowledge-wiki/agents/`).
* **High Operational Velocity**: Changes to agent logic apply instantly via `git-sync` without container restarts or downtime.
* **Audit Trail**: Every change to infrastructure, DNS, smart home switches, or agent system prompts has an author, timestamp, and commit message.
### Negative & Mitigations
* **Discipline Overhead**: Even minor configuration changes require a git commit and deployment workflow rather than a quick UI click.
* *Mitigation*: Tooling like OpenClaw Operator automated rolling updates and `git-sync` minimizes deployment friction to a single `git push`.
* **Git-Crypt Dependency**: Operators and agents require the repository encryption key to modify secrets.
* *Mitigation*: Standardized symmetric key distribution and clear documentation in `k8s/AGENTS.md`.
@@ -0,0 +1,141 @@
---
status: "accepted"
date: 2026-10-06
decision-makers: ["Moritz Graf", "Antigravity"]
consulted: []
informed: []
---
# ADR 0004: Structured Personal Data Layer (DuckDB + JSONL in Knowledge Wiki)
## Context and Problem Statement
OpenClaw manages personal logging and family assistance for the Graf family across several daily domains:
1. **Personal Nutrition:** Daily food intake, macro tracking (protein, carbs, sugar, fat, fiber, salt, calories) in `entities/nutrition-log/moritz/*.md`.
2. **Baby Sleep & Routines:** Sleep logs, naps, night sleep, bottle feeds, and developmental milestones for Mila in `entities/people/mila-schlafprotokoll.md`.
3. **Sports & Physiological Balance:** Activities, daily calorie expenditure (RMR + active), and net caloric surplus/deficit synced with Garmin Connect.
4. **Culinary Knowledge:** Recipes with ingredient proportions, cooking steps, and hardware settings.
5. **Points of Interest (POIs):** Local stores, farm stands (Hofläden), beer gardens, and playgrounds in Regensburg.
Historically, this data was logged directly into Markdown tables and bulleted lists in the Knowledge Wiki (`knowledge-wiki/`). An analysis of existing log files revealed severe systemic failure modes:
- **LLM Arithmetic Drift:** In `2026-09.md` and `2026-10.md`, the LLM consistently failed at daily macro sums (errors up to 75 kcal and 7.5 g protein per day). The derived "Vortags-Bilanz" (caloric deficit/surplus against Garmin) was calculated from incorrect totals.
- **Structural and Chronological Degradation:** In `mila-schlafprotokoll.md`, dates drifted out of order (`29.09 → Sep Summary → 30.09 → 01.10 → 03.10 → 02.10 → 28.09`), and every night sleep was duplicated (once in the evening, once the next morning).
- **Inability to Perform Spatial & Aggregate Queries:** Questions like *"What was Mila's average night sleep duration over the last 14 days?"* or *"Which beer gardens are within 500m of Grabengasse 7?"* required either multi-turn LLM hallucination or reading hundreds of lines of raw text.
How do we establish a structured, reliable, and queryable data layer for OpenClaw that guarantees mathematical precision while upholding our Everything-as-Code (ADR 0003) and Git-as-Source-of-Truth architecture?
---
## Decision Drivers
* **Mathematical Precision:** Zero LLM arithmetic. All summations, caloric balances, sleep durations, and nutrient rollups must be computed by a deterministic SQL engine.
* **Git as the Single Source of Truth (ADR 0003):** No external database silos (PostgreSQL, standalone SQLite PVCs) that can drift from the git-backed wiki. All state must remain git-versioned, diffable, and merge-friendly.
* **Separation of Concerns (Math vs. Narrative):** Delegate structured math, joins, and coordinates to DuckDB. Delegate natural-language understanding, nuance, packaging photo interpretation, and cooking advice to the LLM.
* **Flexible Composability:** Foods can become ingredients in recipes, which can yield composite foods (e.g. wheat → baking mix → bread; tomatoes → sauce → pizza).
* **Multi-Channel Presentation:** Auto-updated Markdown files for SilverBullet (`wiki.moritzgraf.de`), Telegram inline text with `chart-to-chat` PNGs, and mobile-friendly HTML dashboards on `hosting.haumdaucher.de/family/`.
* **Zero-Tax Execution:** In-memory query execution in Python via `uv` without adding heavy runtime containers or pod memory overhead.
---
## Considered Options
1. **Option 1: Stricter Markdown Prompts + Python Lint Rules**
* Keep Markdown tables; add linter checks in `okf_lint.py` to flag wrong sums.
* *Drawback*: Reactive detection rather than proactive prevention. Cannot do spatial searches or aggregate SQL queries.
2. **Option 2: External Relational Database (PostgreSQL / PVC SQLite)**
* Deploy PostgreSQL or write to a binary SQLite database on the Kubernetes PVC.
* *Drawback*: Violates ADR 0003. Creates a separate data silo outside git, introduces backup synchronization risks, and binary files cannot be cleanly merged or diffed in Git.
3. **Option 3: Pure Event Sourcing for All Data**
* Force every piece of data (including stores, recipes, and food labels) into an immutable event log.
* *Drawback*: Over-engineers static catalogs. Catalog entities (like places or recipes) become awkward to manage and query when forced into point-in-time event streams.
4. **Option 4: Three-Tier Archetype Architecture via DuckDB & JSONL in Git (Selected)**
* Store structured records in line-delimited JSON (`.jsonl`) inside the Knowledge Wiki repository (`data/`), using in-memory DuckDB as the calculation and query engine.
* Divide data into three natural archetypes: **Events**, **Entities**, and **Documents**.
---
## Decision Outcome
Chosen Option: **Option 4: Three-Tier Archetype Architecture via DuckDB & JSONL in Git**.
### 1. The Three Data Archetypes
```
┌────────────────────────────────────────────────────────────────────────┐
│ Three Data Archetypes │
├─────────────────────────┬─────────────────────────┬────────────────────┤
│ 1. EVENTS (Time-Series) │ 2. ENTITIES (Catalogs) │ 3. DOCUMENTS (Prose│
├─────────────────────────┼─────────────────────────┼────────────────────┤
│ • data/events/nutrition/│ • data/entities/foods/ │ • entities/recipes/│
│ • data/events/sleep/ │ • data/entities/recipes/│ • syntheses/places/│
│ • data/events/feeding/ │ • data/entities/places/ │ • sources/*.pdf │
│ • data/events/body/ │ • data/entities/people/ │ │
│ [Point & Interval Logs] │ [Durable Slug Identifiers] [Markdown / Media]│
└────────────┬────────────┴────────────┬────────────┴──────────┬─────────┘
│ │ │
▼ ▼ │
┌──────────────────────────────────────────────┐ │
│ In-Memory DuckDB SQL Engine │ │
│ • Schema Validation (STRUCT & CHECK) │ │
│ • SQL Aggregations & Daily Deficit Joins │ │
│ • Spatial Queries (ST_DWithin / Haversine) │ │
└──────────────────────┬───────────────────────┘ │
│ │
┌────────────┴────────────┐ │
▼ ▼ │
┌──────────────────┐ ┌──────────────────┐ │
│ Telegram Output │ │ Generated Views │ │
│ • Text Summaries │ │ • Markdown Views ◄────────────┘
│ • chart-to-chat │ │ • HTML Dashboards│ (Frontmatter:
└──────────────────┘ │ (/family/...) │ datastore_ref)
└──────────────────┘
```
1. **Events (Time-Series Logs):**
- Stored in `data/events/<domain>/<YYYY-MM>.jsonl`, sorted by `(start, id)`.
- Unified envelope: `id` (ULID), `kind`, `subject`, `actor`, `start`, `end` (supports open intervals where `end=NULL`), `estimated`, `meal`, `note`, `payload` (JSON cast to typed STRUCT), `source_ref`.
2. **Entities (Structured Catalogs):**
- Stored in `data/entities/<collection>.jsonl`, sorted by slug `id`.
- `foods`: EU LMIV per-100g nutritional profile, ingredient lists, barcodes, density.
- `recipes`: Ingredients list (linking `foods` with exact grams or raw text), servings, calculated macros.
- `places`: Points of interest with `lat`, `lon`, category, address, tags, and linked documents.
- `people`: Family profile registry.
3. **Documents (Prose & Media):**
- Stored as standard Markdown in `knowledge-wiki/`.
- Deterministic two-way link: Document YAML frontmatter contains `datastore_ref: "<collection>:<id>"`, while the entity JSONL record lists `documents: ["path/to/doc.md"]`.
### 2. Dedicated Raw Garmin Mirror (`data/garmin/`)
- Nightly zero-LLM Python sync script fetches raw Garmin API responses directly into:
- `data/garmin/activities/<YYYY-MM>.jsonl`
- `data/garmin/daily/<YYYY-MM>.jsonl`
- `data/garmin/sleep/<YYYY-MM>.jsonl`
- DuckDB queries raw JSON directly with `read_json_auto()` and computes the daily caloric deficit/surplus by joining nutrition intake with Garmin total daily energy expenditure.
### 3. Spatial & Geospatial Radius Lookups
- DuckDB attempts to initialize the official `spatial` extension (`LOAD spatial;`).
- If offline or binary installation fails, it transparently falls back to an internal pure-SQL Haversine macro `distance_m(lat1, lon1, lat2, lon2)`, ensuring zero-failure execution across any environment.
### 4. Privacy & Hosting Routes (`hosting.haumdaucher.de`)
- **Public (`/recipes/*`, `/places/*`, `/public/*`):** Publicly accessible recipe cards and shareable dossiers.
- **Family (`/family/*`):** Protected by Traefik ForwardAuth linked to Authentik group `family` (nutrition dashboards, baby sleep charts).
- **Private (`/private/*`):** Protected by Authentik for personal vital logs.
---
## Consequences
### Positive
- **Guaranteed Calculation Accuracy:** All calories, macronutrient totals, sleep statistics, and energy balances are computed in SQL. Zero LLM math errors.
- **Git-Native & Mergable:** Text-based JSONL files partition data cleanly and produce clean 1-line git diffs.
- **Offline & Low-Latency:** In-memory DuckDB reads months of JSONL in single-digit milliseconds.
- **Preserved Human Readability:** SilverBullet (`wiki.moritzgraf.de`) and Obsidian continue to display updated Markdown tables generated directly from the database.
### Negative & Mitigations
- **Generated Markdown Views Must Not Be Hand-Edited:**
- *Mitigation*: Generated files include a prominent `<!-- DO NOT EDIT: GENERATED BY DATASTORE -->` warning comment.
- *Mitigation*: Git pre-commit hook and the weekly IT Guy audit verify that generated views match the underlying JSONL data.
- **Dual-Step Agent Retrieval:** Agents execute `datastore query` for structured data, then `wiki_get` on linked documents if full narrative text is needed.
- *Mitigation*: Clear instructions and query helpers in the `datastore` skill prompt.
@@ -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.
+4
View File
@@ -11,6 +11,10 @@ We follow the [Markdown Architectural Decision Records (MADR)](https://adr.githu
| ADR | Title | Status | Date | Area |
| :--- | :--- | :---: | :---: | :--- |
| [**0001**](0001-hybrid-mcp-tool-search-and-subagents.md) | **Hybrid MCP Tool Search and Subagent Delegation for AI Agents** | `accepted` | 2026-09-12 | OpenClaw / AI / MCP |
| [**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 |
---
-20
View File
@@ -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.
+3 -2
View File
@@ -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
+6 -6
View File
@@ -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
+2 -1
View File
@@ -25,4 +25,5 @@ spec:
configMap:
name: rclone-gdrive-config
restartPolicy: Never
backoffLimit: 100
activeDeadlineSeconds: 86400
backoffLimit: 3
Binary file not shown.
-86
View File
@@ -1,86 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: corona-api
labels:
app: corona-api
namespace: datalab
spec:
selector:
matchLabels:
app: corona-api
replicas: 1
template:
metadata:
labels:
app: corona-api
spec:
imagePullSecrets:
- name: "registry-haumdaucher-de"
containers:
- image: registry.haumdaucher.de/corona_plumber:latest
imagePullPolicy: Always
name: corona-api
ports:
- containerPort: 8000
---
apiVersion: v1
kind: Service
metadata:
name: corona-api
namespace: datalab
spec:
ports:
- port: 8000
targetPort: 8000
protocol: TCP
selector:
app: corona-api
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: corona-api-haumdaucher
namespace: datalab
annotations:
kubernetes.io/ingress.class: "nginx"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
tls:
- hosts:
- "corona-api.haumdaucher.de"
secretName: corona-api-haumdaucher-de
rules:
- host: corona-api.haumdaucher.de
http:
paths:
- path: /
backend:
serviceName: corona-api
servicePort: 8000
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: corona-api-moritzgraf
namespace: datalab
annotations:
kubernetes.io/ingress.class: "nginx"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
tls:
- hosts:
- "corona-api.moritzgraf.de"
secretName: corona-api-moritzgraf-de
rules:
- host: corona-api.moritzgraf.de
http:
paths:
- path: /
backend:
serviceName: corona-api
servicePort: 8000
-86
View File
@@ -1,86 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: corona
labels:
app: corona
namespace: datalab
spec:
selector:
matchLabels:
app: corona
replicas: 1
template:
metadata:
labels:
app: corona
spec:
imagePullSecrets:
- name: "registry-haumdaucher-de"
containers:
- image: registry.haumdaucher.de/corona:latest
imagePullPolicy: Always
name: corona
ports:
- containerPort: 3838
---
apiVersion: v1
kind: Service
metadata:
name: corona
namespace: datalab
spec:
ports:
- port: 3838
targetPort: 3838
protocol: TCP
selector:
app: corona
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: corona-haumdaucher
namespace: datalab
annotations:
kubernetes.io/ingress.class: "nginx"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
tls:
- hosts:
- "corona.haumdaucher.de"
secretName: corona-haumdaucher-de
rules:
- host: corona.haumdaucher.de
http:
paths:
- path: /
backend:
serviceName: corona
servicePort: 3838
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: corona-moritzgraf
namespace: datalab
annotations:
kubernetes.io/ingress.class: "nginx"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
tls:
- hosts:
- "corona.moritzgraf.de"
secretName: corona-moritzgraf-de
rules:
- host: corona.moritzgraf.de
http:
paths:
- path: /
backend:
serviceName: corona
servicePort: 3838
-79
View File
@@ -1,79 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
creationTimestamp: null
name: redeploy
namespace: datalab
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: datalab
name: redeploy
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get",list","patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
# This role binding allows "jane" to read pods in the "default" namespace.
# You need to already have a Role named "pod-reader" in that namespace.
kind: RoleBinding
metadata:
name: redeploy-pods
namespace: datalab
subjects:
# You can specify more than one "subject"
- kind: ServiceAccount
name: redeploy
roleRef:
# "roleRef" specifies the binding to a Role / ClusterRole
kind: Role #this must be Role or ClusterRole
name: redeploy
apiGroup: rbac.authorization.k8s.io
---
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: redeploy-corona-api
namespace: datalab
spec:
schedule: "0 5/12 * * *"
jobTemplate:
spec:
template:
spec:
serviceAccountName: redeploy
imagePullSecrets:
- name: "registry-haumdaucher-de"
containers:
- name: redeploy-corona-api
image: registry.haumdaucher.de/redeploy:latest
env:
- name: DEPLOYMENT
value: "corona-api"
restartPolicy: Never
---
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: redeploy-corona
namespace: datalab
spec:
schedule: "15 5/12 * * *"
jobTemplate:
spec:
template:
spec:
serviceAccountName: redeploy
imagePullSecrets:
- name: "registry-haumdaucher-de"
containers:
- name: redeploy-corona
image: registry.haumdaucher.de/redeploy:latest
env:
- name: DEPLOYMENT
value: "corona"
restartPolicy: Never
+154
View File
@@ -0,0 +1,154 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: duckdb-backend
namespace: family
labels:
app.kubernetes.io/name: duckdb-backend
app.kubernetes.io/part-of: family-portal
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: duckdb-backend
template:
metadata:
labels:
app.kubernetes.io/name: duckdb-backend
app.kubernetes.io/part-of: family-portal
spec:
imagePullSecrets:
- name: registry-haumdaucher-de
initContainers:
- name: git-init
image: alpine/git:latest
command: ["/bin/sh", "-c"]
args:
- |
set -e
export HOME=/tmp
cd /wiki
if [ ! -d ".git" ]; then
echo "Initializing wiki repository on dedicated PVC..."
REPO_URL="https://${GIT_SYNC_USERNAME}:${GIT_SYNC_PASSWORD}@git.moritzgraf.de/moritz/knowledge-wiki.git"
git config --global http.sslVerify false
git config --global --add safe.directory /wiki
git config --global --add safe.directory '*'
git init
git remote add origin "$REPO_URL" || git remote set-url origin "$REPO_URL"
git fetch origin main
git checkout -f -B main origin/main
echo "Clone completed successfully."
else
echo "Repository already initialized on PVC, updating..."
REPO_URL="https://${GIT_SYNC_USERNAME}:${GIT_SYNC_PASSWORD}@git.moritzgraf.de/moritz/knowledge-wiki.git"
git config --global http.sslVerify false
git config --global --add safe.directory /wiki
git config --global --add safe.directory '*'
git remote set-url origin "$REPO_URL"
if [ -f .git/shallow ]; then
echo "Unshallowing existing repository..."
git fetch --unshallow origin main || git fetch origin main
else
git fetch origin main
fi
git rebase --abort 2>/dev/null || true
git merge --abort 2>/dev/null || true
git reset --hard origin/main
git clean -fd
fi
env:
- name: GIT_SYNC_USERNAME
valueFrom:
secretKeyRef:
name: wiki-git-credentials
key: git-sync-username
- name: GIT_SYNC_PASSWORD
valueFrom:
secretKeyRef:
name: wiki-git-credentials
key: wiki-sync-token
volumeMounts:
- name: duckdb-wiki-data
mountPath: /wiki
containers:
- name: duckdb-backend
image: registry.haumdaucher.de/family/duckdb-backend:latest
imagePullPolicy: Always
env:
- name: DATA_DIR
value: "/wiki/data"
- name: DUCKDB_DATA_DIR
value: "/wiki/data"
- name: WIKI_DIR
value: "/wiki"
- name: GIT_REPO_DIR
value: "/wiki"
- name: GIT_REPO_URL
value: "https://git.moritzgraf.de/moritz/knowledge-wiki.git"
- name: GIT_REMOTE_URL
value: "https://git.moritzgraf.de/moritz/knowledge-wiki.git"
- name: GIT_SYNC_USERNAME
valueFrom:
secretKeyRef:
name: wiki-git-credentials
key: git-sync-username
- name: GIT_SYNC_PASSWORD
valueFrom:
secretKeyRef:
name: wiki-git-credentials
key: wiki-sync-token
- name: GIT_SYNC_INTERVAL_SECONDS
value: "60"
- name: PORT
value: "8000"
- name: HOST
value: "0.0.0.0"
ports:
- name: http
containerPort: 8000
volumeMounts:
- name: duckdb-wiki-data
mountPath: /wiki
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 1000m
memory: 1Gi
readinessProbe:
httpGet:
path: /duckdb-api/v1/healthz
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /duckdb-api/v1/healthz
port: 8000
initialDelaySeconds: 15
periodSeconds: 20
volumes:
- name: duckdb-wiki-data
persistentVolumeClaim:
claimName: duckdb-wiki-data
---
apiVersion: v1
kind: Service
metadata:
name: duckdb-backend
namespace: family
labels:
app.kubernetes.io/name: duckdb-backend
app.kubernetes.io/part-of: family-portal
spec:
type: ClusterIP
ports:
- port: 8000
targetPort: 8000
name: http
selector:
app.kubernetes.io/name: duckdb-backend
Binary file not shown.
+55
View File
@@ -0,0 +1,55 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: family-portal
namespace: family
annotations:
kubernetes.io/ingress.class: "traefik"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
kubernetes.io/tls-acme: "true"
traefik.ingress.kubernetes.io/router.middlewares: auth-authentik-forwardauth@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- family.haumdaucher.de
secretName: family-haumdaucher-de-tls
rules:
- host: family.haumdaucher.de
http:
paths:
- path: /duckdb-api
pathType: Prefix
backend:
service:
name: duckdb-backend
port:
number: 8000
- path: /tracking/mila
pathType: Prefix
backend:
service:
name: mila
port:
number: 80
- path: /tracking/moritz
pathType: Prefix
backend:
service:
name: moritz
port:
number: 80
- path: /outpost.goauthentik.io
pathType: Prefix
backend:
service:
name: duckdb-backend
port:
number: 8000
- path: /
pathType: Prefix
backend:
service:
name: mila
port:
number: 80
+64
View File
@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mila
namespace: family
labels:
app.kubernetes.io/name: mila
app.kubernetes.io/part-of: family-portal
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: mila
template:
metadata:
labels:
app.kubernetes.io/name: mila
app.kubernetes.io/part-of: family-portal
spec:
imagePullSecrets:
- name: registry-haumdaucher-de
containers:
- name: mila
image: registry.haumdaucher.de/family/mila:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 80
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
readinessProbe:
httpGet:
path: /tracking/mila/
port: 80
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /tracking/mila/
port: 80
initialDelaySeconds: 10
periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
name: mila
namespace: family
labels:
app.kubernetes.io/name: mila
app.kubernetes.io/part-of: family-portal
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 80
name: http
selector:
app.kubernetes.io/name: mila
+64
View File
@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: moritz
namespace: family
labels:
app.kubernetes.io/name: moritz
app.kubernetes.io/part-of: family-portal
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: moritz
template:
metadata:
labels:
app.kubernetes.io/name: moritz
app.kubernetes.io/part-of: family-portal
spec:
imagePullSecrets:
- name: registry-haumdaucher-de
containers:
- name: moritz
image: registry.haumdaucher.de/family/moritz:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 80
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
readinessProbe:
httpGet:
path: /tracking/moritz/
port: 80
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /tracking/moritz/
port: 80
initialDelaySeconds: 10
periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
name: moritz
namespace: family
labels:
app.kubernetes.io/name: moritz
app.kubernetes.io/part-of: family-portal
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 80
name: http
selector:
app.kubernetes.io/name: moritz
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: family
labels:
name: family
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: duckdb-wiki-data
namespace: family
labels:
app.kubernetes.io/name: duckdb-backend
app.kubernetes.io/part-of: family-portal
spec:
accessModes:
- ReadWriteOnce
storageClassName: openebs-hostpath
resources:
requests:
storage: 5Gi
Binary file not shown.
+71
View File
@@ -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
+108
View File
@@ -0,0 +1,108 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: hosting-add-prefix
namespace: minio
spec:
addPrefix:
prefix: /hosting
---
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:
name: minio-hosting
namespace: minio
annotations:
kubernetes.io/ingress.class: "traefik"
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-redirect-mila@kubernetescrd,minio-hosting-redirect-moritz@kubernetescrd,minio-hosting-redirect-family-root@kubernetescrd,minio-hosting-add-prefix@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: hosting.haumdaucher.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minio
port:
number: 9000
tls:
- hosts:
- hosting.haumdaucher.de
secretName: hosting-haumdaucher-de-tls
---
# Protected Ingress for /family (Redirects and Authentik Outpost)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: minio-hosting-family
namespace: minio
annotations:
kubernetes.io/ingress.class: "traefik"
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: 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:
- host: hosting.haumdaucher.de
http:
paths:
- path: /family
pathType: Prefix
backend:
service:
name: minio
port:
number: 9000
- path: /outpost.goauthentik.io
pathType: Prefix
backend:
service:
name: minio
port:
number: 9000
tls:
- hosts:
- hosting.haumdaucher.de
secretName: hosting-haumdaucher-de-tls
Binary file not shown.
+1 -1
View File
@@ -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`.*
---
+110 -2
View File
@@ -25,6 +25,23 @@ The multi-container pod structure is managed natively by the operator controller
| `skill-stabilizer` | Retained under `spec.sidecars` to copy skills to the flat PVC structure |
| `install-uv-python` (init) | Managed natively by the operator under `spec.runtimeDeps.python: true` |
### 3. Multi-Agent Architecture & Agents-as-Code (ADR 0001)
* **Conductor**: The main session runs as the lean front-of-house coordinator (`SOUL.md`), handling fast actions (< 2s) directly with MCP tools and delegating complex domain tasks via `sessions_spawn`.
* **Specialist Subagents & Telegram Forum Topics**: 10 specialized subagents defined as code in the knowledge wiki and pinned to Telegram forum topics:
- 🏋️ `agents/fitness.md` (Topic 20 "Sport" — Garmin Connect 148 tools, Tacx Flux 2, home gym supersets)
- 🍳 `agents/koch.md` (Topic 21 "Kochen" — Ninja CREAMi Deluxe, Airfryer, KitchenAid, leftover utilization)
- 🩺 `agents/hebamme.md` (Topic 22 "Mila" — Baby development & sleep, pediatrician safety boundaries)
- ✈️ `agents/travel.md` (Topic 51 "Travel" — Vacation itineraries, packing lists, travel concierge)
- 🛒 `agents/shopping.md` (Topic 115 "Shopping" — Supermarket deals, Geizhals best prices, Bring! lists)
- 🏰 `agents/regensburg.md` (Topic 116 "Regensburg" — Stadtamhof insider, RVV, playgrounds, local dining)
- 🔍 `agents/recherche.md` (Topic 117 "Research" — Deep research, ALLRIS Stadtrat, Friday bridge tracking)
- 👨‍⚕️ `agents/doktor.md` (Topic 118 "Medizin" — Evidenzbasierte ärztliche Beratung, Laborwerte, Red-Flag Triage)
- 🧑‍💻 `agents/itguy.md` (Topic 119 "IT Guy" — Nerdy CS Hacker, K8s Ops via `kubectl` & RBAC, Git PRs, Prometheus Alert Responder)
- 🏡🎬 `agents/grabengasse_media.md` (Topic 164 "Grabengasse & Media" — Home Assistant, Hue, Sonos, Brother Drucker, Jellyfin & Spotify)
* **Inter-Agent Peer Escalation (A2A)**: Every subagent topic is configured with peer escalation authority via `sessions_spawn(agentId="<target>", task="...")` to consult sibling agents when cross-domain expertise is needed.
* **Modifying & Adding Agents**: To update prompt logic, tone, or tool instructions for any agent, edit its markdown file directly in `knowledge-wiki/agents/<name>.md` and commit to git. No Kubernetes pod restarts required!
* **Multi-Turn Domain Stickiness**: When a conversation spans multiple turns in a subagent domain (e.g. refining a recipe or planning a trip), the Conductor detects domain continuity and routes to the same agent with clean conversational context.
---
## 🔧 Applying Configuration Changes
@@ -38,12 +55,103 @@ To perform modifications or rotate api keys:
kubectl apply -f k8s/openclaw/openclaw-instance.secret.yaml
kubectl apply -f k8s/openclaw/cronjob-wiki-backup.secret.yaml
```
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)
# 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.
---
## 🚨 Troubleshooting & Live Verification
### 4. 🗂️ The Three Core Repositories Architecture & Weekly Self-Audit
If the agent is offline or failing:
OpenClaw connects to three foundational Git repositories:
1. **`infrapuzzle`** (`/home/node/.openclaw/repos/infrapuzzle` on PVC):
- **Scope**: Source of truth for cluster infrastructure (`haumdaucher`), Kubespray bootstrap, K8s manifests, Terraform DNS, ESPHome IoT, and Architecture Decision Records (`docs/adr/`).
2. **`mop-skills`** (`/home/node/mop-skills/mop-skills` via `git-sync` sidecar):
- **Scope**: Custom Python skills, CLI utilities, and MCP bridges (`openclaw-audit`, `garmin-auth`, `brother-printer`, `google-calendar`, etc.).
3. **`knowledge-wiki`** (`/home/node/.openclaw/wiki/main`):
- **Scope**: Agents-as-code (`agents/*.md`), active task dispatch queue (`tasks/antigravity.md`), family knowledge vault, and audit archives (`operations/audits/`).
#### 🩺 Autonomous Weekly Health Audit (`itguy-weekly-health-audit`)
- Scheduled via OpenClaw Cron on **Sundays at 03:00 Europe/Berlin** (`0 3 * * 0`).
- Executes `uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 7d --write-report --add-task --git-push`.
- Correlates errors (pod stdout & session transcripts) with recent git commits across the 3 repositories.
- Archives diagnostic reports into `operations/audits/YYYY-Wxx.md`.
- Appends newly detected, actionable tasks into `tasks/antigravity.md` under `## 📥 Pending Tasks`.
- **Telegram Notification Policy**: Alerts Topic `#IT Guy` (thread `119`) if new issues are filed; returns `NO_REPLY` for intentional silence when clean.
---
## 🚨 Troubleshooting & Incident Playbook
> [!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
Ensure that the `OpenClawInstance` is successfully reconciled:
+8
View File
@@ -20,6 +20,11 @@ The deployment is managed by the official **OpenClaw Operator** (`openclaw-opera
* **Vector Search**: Semantic memory search via OpenRouter `/v1/embeddings` (`text-embedding-3-small`).
* **Memory Wiki**: Bundled `memory-wiki` compiles structured entities, concepts, and dashboards into an Obsidian-compatible vault (`wiki/main`).
* **Automated Backup**: `openclaw-wiki-backup` CronJob runs daily at 03:30 AM UTC to commit and push the knowledge vault to `git.moritzgraf.de/moritz/knowledge-wiki`.
* **Multi-Agent Specialist Delegation (ADR 0001)**:
* **Conductor + 9 Subagents**: Lean front-of-house coordinator orchestrates 9 specialized domain subagents defined as code in the knowledge wiki (`agents/*.md`).
* **Direct Fast Path (< 2s)**: Simple status, home automation toggles, and single calendar entries run directly in-place.
* **Domain Stickiness**: Multi-turn dialogue (e.g. step-by-step cooking or vacation planning) stays pinned to the active subagent with persistent session continuity.
* **Active MCP Servers**: `stadtrat` (ALLRIS Net), `supermarket` (Marktguru), `geizhals` (price histories), `ebay`, `garmin` (148 health tools), `bring` (4 lists), `spotify`, `calendar` (family + city), `jelly` (Jellyfin/Jellyseerr), `homeassistant`, `prometheus`, `gitea`, `weather`.
---
@@ -49,6 +54,9 @@ kubectl apply -f k8s/openclaw/openclaw-instance.secret.yaml
# Apply the automated daily knowledge-wiki backup CronJob
kubectl apply -f k8s/openclaw/cronjob-wiki-backup.secret.yaml
# Register the IT Guy weekly health audit automation
./k8s/openclaw/setup-itguy-cron.sh
```
---
File diff suppressed because it is too large Load Diff
Binary file not shown.
+633
View File
@@ -0,0 +1,633 @@
apiVersion: v1
data:
compact_sessions.py: "#!/usr/bin/env python3\n\"\"\"\nNightly multi-agent session
maintenance and compaction script for OpenClaw.\n1. Pre-warms OS page cache for
SQLite databases on spinning disks.\n2. Recovers any wedged or locked sessions
via recover_sessions.py.\n3. Prunes abandoned isolated cron sessions (>48h old)
to prevent token/state bloat.\n4. Tier A Hard-Reset: Resets koch, hebamme, and
shopping topics to 0 tokens (clean archive).\n5. Tier B Smart-Compaction: Compacts
active sessions exceeding token thresholds (5s pacing).\n6. Checkpoints SQLite
WAL journals across all agent databases to disk.\n7. Optimizes SQLite query planner
without blocking full VACUUM.\n\"\"\"\nimport glob\nimport json\nimport os\nimport
sqlite3\nimport subprocess\nimport sys\nimport time\n\nTHRESHOLD_TOKENS = 15000\nTHRESHOLD_TRANSCRIPT_BYTES
= 200000\nTHRESHOLD_EVENT_COUNT = 30\nCRON_MAX_AGE_HOURS = 48\nRPC_TIMEOUT_MS
= 180000\n\n# Tracking Channels: Deterministic sliding-window (mila_tracker, nutrition_tracker).\n#
Strict sliding window of ~5-8 events (2-3 turns max), 0 LLM tokens, instant truncation.\nTRACKING_TARGETS
= [\n (\"mila_tracker\", \"agent:mila_tracker:telegram:group:-1004326921510:topic:2796\"),\n
\ (\"nutrition_tracker\", \"agent:nutrition_tracker:telegram:group:-1004326921510:topic:2797\"),\n]\nMAX_TRACKING_EVENTS
= 8\nMAX_TRACKING_LINES = 8\n\n# Tier A: High-frequency interaction topics (koch,
hebamme, shopping).\n# Deep-compaction keeps a compact summary + latest 2 turns
without completely wiping conversational memory.\nTIER_A_TARGETS = [\n (\"koch\",
\"agent:koch:telegram:group:-1004326921510:topic:21\"),\n (\"hebamme\", \"agent:hebamme:telegram:group:-1004326921510:topic:22\"),\n
\ (\"shopping\", \"agent:shopping:telegram:group:-1004326921510:topic:115\"),\n]\n\ndef
prewarm_page_cache():\n print(\"=== Pre-warming SQLite databases and Knowledge
Wiki into OS page cache ===\")\n t0 = time.time()\n try:\n subprocess.run(\n
\ \"cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite
> /dev/null 2>&1 || true; \"\n \"find /home/node/.openclaw/wiki/main
/home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null
2>&1 || true\",\n shell=True,\n timeout=90.0\n )\n
\ print(f\"Page cache pre-warmed in {time.time() - t0:.2f}s\")\n except
Exception as e:\n print(f\"Page cache pre-warming warning: {e}\")\n\ndef
prune_stale_cron_sessions():\n print(f\"=== Pruning isolated cron sessions
older than {CRON_MAX_AGE_HOURS}h ===\")\n cutoff_ms = int(time.time() * 1000)
- (CRON_MAX_AGE_HOURS * 3600 * 1000)\n total_pruned = 0\n total_tokens_reclaimed
= 0\n\n for db in sorted(glob.glob(\"/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite\")):\n
\ agent = db.split(\"/\")[5]\n try:\n conn = sqlite3.connect(db,
timeout=30.0)\n conn.execute(\"PRAGMA foreign_keys = ON;\")\n cursor
= conn.cursor()\n cursor.execute(\"SELECT name FROM sqlite_master WHERE
type=\\\"table\\\"\")\n all_tables = set(r[0] for r in cursor.fetchall())\n\n
\ cursor.execute(\"SELECT session_key, current_session_id, updated_at,
entry_json FROM session_nodes WHERE session_key LIKE \\\"%:cron:%\\\"\")\n stale
= []\n for k, sid, up, ej in cursor.fetchall():\n if
up and up < cutoff_ms:\n tok = 0\n if ej:\n
\ try:\n tok = json.loads(ej).get(\"totalTokens\",
0) or 0\n except Exception:\n pass\n
\ stale.append((k, sid, tok))\n\n if stale:\n tokens_reclaimed
= sum(t[2] for t in stale)\n for k, sid, _ in stale:\n for
t in [\"trajectory_runtime_events\", \"transcript_events\", \"transcript_event_identities\",
\"session_transcript_active_events\", \"transcript_rewrite_watermarks\", \"session_transcript_index_state\",
\"session_transcript_fts\", \"session_conversations\"]:\n if
t in all_tables and sid:\n cursor.execute(f\"DELETE
FROM {t} WHERE session_id = ?\", (sid,))\n for t in [\"session_participants\",
\"heartbeat_outcomes\", \"session_progress_cards\", \"board_widgets\", \"board_tabs\"]:\n
\ if t in all_tables:\n cursor.execute(f\"DELETE
FROM {t} WHERE session_key = ?\", (k,))\n if \"memory_entry_origins\"
in all_tables:\n cursor.execute(\"DELETE FROM memory_entry_origins
WHERE session_key = ? OR session_id = ?\", (k, sid or \"\"))\n if
\"session_windows\" in all_tables:\n cursor.execute(\"DELETE
FROM session_windows WHERE session_key = ? OR session_id = ?\", (k, sid or \"\"))\n
\ cursor.execute(\"DELETE FROM session_nodes WHERE session_key
= ?\", (k,))\n conn.commit()\n total_pruned += len(stale)\n
\ total_tokens_reclaimed += tokens_reclaimed\n print(f\"
\ [{agent}] Pruned {len(stale)} dead cron sessions ({tokens_reclaimed} tokens
reclaimed)\")\n\n # Unconditionally resolve any dangling foreign key
references across all tables\n while True:\n cursor.execute(\"PRAGMA
foreign_key_check;\")\n violations = cursor.fetchall()\n if
not violations:\n break\n for table, rowid,
parent, fkid in violations:\n cursor.execute(f\"DELETE FROM
{table} WHERE rowid = ?\", (rowid,))\n conn.commit()\n conn.close()\n
\ except Exception as e:\n print(f\" [{agent}] Pruning error:
{e}\")\n\n # Clear resolved quarantine entries if all databases are healthy\n
\ try:\n q_path = \"/home/node/.openclaw/state/openclaw-quarantine.sqlite\"\n
\ if os.path.exists(q_path):\n q_conn = sqlite3.connect(q_path)\n
\ q_cur = q_conn.cursor()\n q_cur.execute(\"DELETE FROM quarantined_databases\")\n
\ q_conn.commit()\n q_conn.close()\n except Exception
as e:\n print(f\"Quarantine cleanup error: {e}\")\n\n print(f\"Cron
pruning complete: {total_pruned} sessions pruned, {total_tokens_reclaimed} tokens
reclaimed.\")\n\ndef compact_tracking_sessions():\n print(\"=== Tracking Channels
Sliding-Window Maintenance (mila_tracker, nutrition_tracker) ===\")\n for agent,
key in TRACKING_TARGETS:\n db_path = f\"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite\"\n
\ if not os.path.exists(db_path):\n continue\n try:\n
\ con = sqlite3.connect(db_path, timeout=5.0)\n row = con.execute(\"SELECT
current_session_id FROM session_nodes WHERE session_key=?\", (key,)).fetchone()\n
\ if not row or not row[0]:\n con.close()\n print(f\"
\ [{agent}] No active session node found for {key}.\")\n continue\n
\ sid = row[0]\n cnt = con.execute(\"SELECT count(*) FROM
transcript_events WHERE session_id=?\", (sid,)).fetchone()[0]\n con.close()\n\n
\ if cnt > MAX_TRACKING_EVENTS:\n print(f\" [{agent}]
Session {key} has {cnt} events (> {MAX_TRACKING_EVENTS}). Truncating to last {MAX_TRACKING_LINES}
lines...\")\n cmd = [\n \"openclaw\", \"sessions\",
\"compact\", key,\n \"--agent\", agent,\n \"--max-lines\",
str(MAX_TRACKING_LINES),\n \"--timeout\", \"30000\"\n ]\n
\ r = subprocess.run(cmd, capture_output=True, text=True)\n if
r.returncode == 0:\n print(f\" ✓ Truncated successfully:
{r.stdout.strip()}\")\n else:\n print(f\" ✗
Truncation failed: {r.stderr.strip()}\")\n else:\n print(f\"
\ [{agent}] Session {key} has {cnt} events (<= {MAX_TRACKING_EVENTS}). Clean.\")\n
\ except Exception as e:\n print(f\" ✗ [{agent}] Error during
tracking compaction: {e}\")\n\ndef compact_tier_a_sessions():\n print(\"===
Tier A: Deep-Compaction (koch, hebamme, shopping) ===\")\n for agent, key in
TIER_A_TARGETS:\n db_path = f\"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite\"\n
\ if not os.path.exists(db_path):\n continue\n try:\n
\ con = sqlite3.connect(db_path, timeout=10.0)\n row = con.execute(\"SELECT
current_session_id FROM session_nodes WHERE session_key=?\", (key,)).fetchone()\n
\ con.close()\n if not row or not row[0]:\n print(f\"
\ [Tier A] {agent} ({key}) already at 0 (no active session).\")\n continue\n
\ ev_cnt, ev_bytes = get_session_metrics(agent, key, row[0])\n if
ev_cnt <= 2 and ev_bytes < 20000:\n print(f\" [Tier A] {agent}
({key}) has only {ev_cnt} events ({ev_bytes} bytes), skipping.\")\n continue\n
\ print(f\" [Tier A] Deep-compacting {agent} ({key}) [{ev_cnt} events,
{ev_bytes//1024}KB]...\")\n r = subprocess.run(\n [\"openclaw\",
\"sessions\", \"compact\", key, \"--agent\", agent, \"--timeout\", str(RPC_TIMEOUT_MS)],\n
\ capture_output=True, text=True\n )\n if
r.returncode == 0:\n print(f\" ✓ Deep-compacted: {r.stdout.strip()}\")\n
\ elif \"Already compacted\" in r.stderr or \"Already compacted\" in
r.stdout:\n print(f\" ✓ Already compacted.\")\n else:\n
\ print(f\" ✗ Standard compact error: {r.stderr.strip()}; retrying
with --max-lines 40...\")\n fb = subprocess.run(\n [\"openclaw\",
\"sessions\", \"compact\", key, \"--agent\", agent, \"--max-lines\", \"40\", \"--timeout\",
str(RPC_TIMEOUT_MS)],\n capture_output=True, text=True\n )\n
\ if fb.returncode == 0:\n print(f\" ✓ Truncation
fallback success: {fb.stdout.strip()}\")\n else:\n print(f\"
\ ✗ Truncation fallback failed: {fb.stderr.strip()}\")\n except Exception
as e:\n print(f\" ✗ Exception deep-compacting {agent}: {e}\")\n
\ time.sleep(2)\n\ndef get_session_metrics(agent, key, sid=None):\n db_path
= f\"/home/node/.openclaw/agents/{agent}/agent/openclaw-agent.sqlite\"\n if
not os.path.exists(db_path):\n return 0, 0\n try:\n con = sqlite3.connect(db_path,
timeout=5.0)\n cur = con.cursor()\n if not sid:\n row
= cur.execute(\"SELECT current_session_id FROM session_nodes WHERE session_key=?\",
(key,)).fetchone()\n sid = row[0] if row else None\n if not
sid:\n con.close()\n return 0, 0\n ev_cnt, ev_bytes
= cur.execute(\n \"SELECT count(*), coalesce(sum(length(event_json)),
0) FROM transcript_events WHERE session_id=?\", \n (sid,)\n ).fetchone()\n
\ con.close()\n return ev_cnt, ev_bytes\n except Exception:\n
\ return 0, 0\n\ndef compact_active_sessions():\n excluded_keys = set(t[1]
for t in TIER_A_TARGETS) | set(t[1] for t in TRACKING_TARGETS)\n print(f\"===
Tier B: Running Multi-Agent Compaction (threshold: {THRESHOLD_TOKENS} tokens /
{THRESHOLD_TRANSCRIPT_BYTES//1024}KB, LLM summarization) ===\")\n res = subprocess.run([\"openclaw\",
\"sessions\", \"--all-agents\", \"--json\"], capture_output=True, text=True)\n
\ if res.returncode != 0:\n print(f\"Failed to list sessions: {res.stderr}\")\n
\ return\n\n sessions = json.loads(res.stdout).get(\"sessions\", [])\n
\ compacted = 0\n skipped = 0\n\n for s in sessions:\n key = s.get(\"key\",
\"\")\n agent = s.get(\"agentId\") or \"main\"\n tokens = s.get(\"totalTokens\")
or 0\n sid = s.get(\"sessionId\")\n\n # Skip Tier A and Tracking
agents as they have dedicated routines\n if key in excluded_keys:\n continue\n\n
\ # Target main sessions and all telegram sessions (topics and direct)\n
\ is_target = (\n key == \"agent:main:main\" or \n \":telegram:\"
in key or\n (key.startswith(\"agent:\") and key.endswith(\":main\"))\n
\ )\n if is_target and not key.endswith(\":heartbeat\"):\n ev_cnt,
ev_bytes = get_session_metrics(agent, key, sid)\n needs_compaction
= (\n tokens >= THRESHOLD_TOKENS or\n ev_bytes >=
THRESHOLD_TRANSCRIPT_BYTES or\n (tokens == 0 and ev_cnt >= THRESHOLD_EVENT_COUNT)\n
\ )\n if needs_compaction:\n stat_str = f\"{tokens}
tokens\" if tokens else f\"{ev_bytes//1024}KB / {ev_cnt} events\"\n print(f\"Compacting
{agent} ({stat_str}) -> {key}...\")\n c_res = subprocess.run(\n
\ [\"openclaw\", \"sessions\", \"compact\", key, \"--agent\",
agent, \"--timeout\", str(RPC_TIMEOUT_MS)],\n capture_output=True,
text=True\n )\n if c_res.returncode == 0:\n print(f\"
\ ✓ {c_res.stdout.strip()}\")\n compacted += 1\n elif
\"Already compacted\" in c_res.stderr or \"Already compacted\" in c_res.stdout:\n
\ print(f\" ✓ Already compacted.\")\n skipped
+= 1\n else:\n print(f\" ✗ {c_res.stderr.strip()};
retrying with --max-lines 80 fallback...\")\n fb_res = subprocess.run(\n
\ [\"openclaw\", \"sessions\", \"compact\", key, \"--agent\",
agent, \"--max-lines\", \"80\", \"--timeout\", str(RPC_TIMEOUT_MS)],\n capture_output=True,
text=True\n )\n if fb_res.returncode ==
0:\n print(f\" ✓ (fallback) {fb_res.stdout.strip()}\")\n
\ compacted += 1\n else:\n print(f\"
\ ✗ Fallback failed: {fb_res.stderr.strip()}\")\n time.sleep(5)
\ # 5s pause to avoid rotational HDD I/O saturation\n else:\n skipped
+= 1\n\n print(f\"Compaction complete: {compacted} compacted, {skipped} skipped.\")\n\ndef
checkpoint_wal_journals():\n print(\"=== Checkpointing SQLite WAL journals
across all agent databases ===\")\n dbs = [\"/home/node/.openclaw/state/openclaw.sqlite\"]
+ glob.glob(\"/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite\")\n wal_freed
= 0\n for db in dbs:\n wal = db + \"-wal\"\n if os.path.exists(wal):\n
\ sz = os.path.getsize(wal)\n try:\n con =
sqlite3.connect(db, timeout=30.0)\n con.execute(\"PRAGMA wal_checkpoint(TRUNCATE);\")\n
\ con.close()\n wal_freed += sz\n except
Exception as e:\n print(f\" ✗ Error checkpointing {db}: {e}\")\n
\ print(f\"WAL Checkpoint complete: {wal_freed / (1024*1024):.2f} MB merged
to disk.\")\n\ndef vacuum_and_optimize():\n print(\"=== Running SQLite OPTIMIZE
across all databases ===\")\n dbs = [\"/home/node/.openclaw/state/openclaw.sqlite\"]
+ glob.glob(\"/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite\")\n for
db in dbs:\n try:\n con = sqlite3.connect(db, timeout=30.0)\n
\ con.execute(\"PRAGMA optimize;\")\n con.close()\n except
Exception as e:\n print(f\" ✗ Error optimizing {db}: {e}\")\n print(\"SQLite
optimization complete.\")\n\ndef main():\n prewarm_page_cache()\n try:\n
\ subprocess.run([\"python3\", \"/home/node/.openclaw/scripts/recover_sessions.py\"],
check=False)\n except Exception:\n pass\n prune_stale_cron_sessions()\n
\ compact_tracking_sessions()\n compact_tier_a_sessions()\n compact_active_sessions()\n
\ checkpoint_wal_journals()\n vacuum_and_optimize()\n prewarm_page_cache()\n\nif
__name__ == \"__main__\":\n main()\n"
dedup_subagent_vectors.py: |
#!/usr/bin/env python3
import glob, json, os, sqlite3, sys
cfg_path = "/home/node/.openclaw/openclaw.json"
if os.path.exists(cfg_path):
try:
with open(cfg_path, "r") as f:
cfg = json.load(f)
mem_search = cfg.get("memory", {}).get("search", {})
if mem_search.get("extraPaths"):
mem_search["extraPaths"] = []
with open(cfg_path, "w") as f:
json.dump(cfg, f, indent=2)
print("[dedup_subagents] Cleared top-level memory.search.extraPaths from openclaw.json (scoped to main only)")
except Exception as e:
print(f"[dedup_subagents] Warning checking openclaw.json: {e}", file=sys.stderr)
vec_path = "/app/node_modules/sqlite-vec-linux-x64/vec0.so"
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
agent = os.path.basename(os.path.dirname(os.path.dirname(db)))
if agent == "main":
continue
try:
conn = sqlite3.connect(db, timeout=10.0)
if os.path.exists(vec_path):
conn.enable_load_extension(True)
conn.load_extension(vec_path)
cur = conn.cursor()
chk = cur.execute("SELECT count(*) FROM memory_index_chunks").fetchone()[0]
if chk > 0:
print(f"[dedup_subagents] Purging {chk} duplicated vector chunks from agent {agent}...")
for t in [
"memory_index_sources",
"memory_index_chunks",
"memory_index_chunk_recall_metadata",
"memory_index_chunk_provenance",
"memory_entry_origins",
"memory_embedding_cache",
"memory_index_chunks_vec"
]:
try: cur.execute(f"DELETE FROM {t}")
except Exception: pass
conn.commit()
cur.execute("VACUUM")
print(f"[dedup_subagents] Reclaimed disk space for agent {agent}")
conn.close()
except Exception as e:
print(f"[dedup_subagents] Notice for {agent}: {e}", file=sys.stderr)
gateway-preflight.mjs: |
import fs from "node:fs";
import path from "node:path";
import { execSync, spawn } from "node:child_process";
if (!process.env.__OPENCLAW_PREFLIGHT_DONE) {
process.env.__OPENCLAW_PREFLIGHT_DONE = "1";
const isGateway = process.argv.some(arg => arg.endsWith("openclaw.mjs") || arg.endsWith("openclaw")) && process.argv.includes("gateway");
if (isGateway) {
const t0 = Date.now();
console.log("[gateway-preflight] Initializing container self-healing preflight...");
// 1. Clean up stale gateway lock files from ungraceful kills
for (const dir of ["/tmp/openclaw-1000", "/tmp"]) {
if (fs.existsSync(dir)) {
try {
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith("gateway") && file.endsWith(".lock")) {
const fullPath = path.join(dir, file);
fs.unlinkSync(fullPath);
console.log(`[gateway-preflight] Removed stale lock: ${fullPath}`);
}
}
} catch (e) {
console.warn(`[gateway-preflight] Lock cleanup warning in ${dir}:`, e.message);
}
}
}
// 2. Clear false database quarantines
const qPath = "/home/node/.openclaw/state/openclaw-quarantine.sqlite";
if (fs.existsSync(qPath)) {
try {
execSync(`/home/node/.openclaw/.local/bin/python3 -c "import sqlite3; c=sqlite3.connect(\x27${qPath}\x27); c.execute(\x27DELETE FROM quarantined_databases\x27); c.commit(); c.close()"`, { stdio: "ignore" });
console.log("[gateway-preflight] Cleared database quarantine.");
} catch (_) {}
}
// 3. Deduplicate subagent vector databases (keeps non-main agent DBs < 15MB)
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/dedup_subagent_vectors.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Vector deduplication warning:", e.message);
}
// 4. Pre-warm page cache sequentially (transforms random 70 IOPS disk reads into sequential streaming)
try {
const pw0 = Date.now();
execSync("cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true; find /home/node/.openclaw/wiki/main /home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null 2>&1 || true");
console.log(`[gateway-preflight] Pre-warmed SQLite databases and Wiki markdown in ${Date.now() - pw0}ms`);
} catch (_) {}
// 5. Recover any wedged running sessions left over from ungraceful container restart
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/recover_sessions.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Session recovery warning:", e.message);
}
// 6. Spawn background turn replay daemon if interrupted turns were detected
try {
if (fs.existsSync("/tmp/openclaw-interrupted-turns.json")) {
console.log("[gateway-preflight] Spawning background turn replay daemon...");
const child = spawn("/home/node/.openclaw/.local/bin/python3", ["/home/node/.openclaw/scripts/replay_interrupted_turns.py"], {
detached: true,
stdio: "ignore"
});
child.unref();
}
} catch (e) {
console.warn("[gateway-preflight] Could not spawn turn replay daemon:", e.message);
}
console.log(`[gateway-preflight] Self-healing completed in ${Date.now() - t0}ms.`);
}
}
openclaw-triage.py: "#!/usr/bin/env python3\n\"\"\"\nopenclaw-triage.py: Rapid zero-dependency
incident triage for OpenClaw multi-agent sessions.\nDirectly inspects ground-truth
SQLite session state and transcript events in < 1 second.\n\"\"\"\nimport argparse\nimport
glob\nimport json\nimport os\nimport sqlite3\nimport sys\nimport time\n\nTOPIC_TO_AGENT
= {\n \"20\": \"fitness\",\n \"21\": \"koch\",\n \"22\": \"hebamme\",\n
\ \"51\": \"travel\",\n \"115\": \"shopping\",\n \"116\": \"regensburg\",\n
\ \"117\": \"recherche\",\n \"118\": \"doktor\",\n \"119\": \"itguy\",\n
\ \"164\": \"grabengasse_media\",\n \"2796\": \"mila_tracker\",\n \"2797\":
\"nutrition_tracker\",\n}\n\ndef parse_args():\n parser = argparse.ArgumentParser(description=\"Rapid
OpenClaw multi-agent session triage.\")\n parser.add_argument(\"--topic\",
\"-t\", help=\"Telegram forum topic ID (e.g. 21, 22, 117)\")\n parser.add_argument(\"--agent\",
\"-a\", help=\"Agent identifier (e.g. koch, hebamme, recherche)\")\n parser.add_argument(\"--limit\",
\"-n\", type=int, default=10, help=\"Number of recent events to display (default:
10)\")\n parser.add_argument(\"--db-root\", default=\"/home/node/.openclaw/agents\",
help=\"Root path of agent SQLite databases\")\n return parser.parse_args()\n\ndef
triage_agent_session(agent, topic_id=None, limit=10, db_root=\"/home/node/.openclaw/agents\"):\n
\ db_path = os.path.join(db_root, agent, \"agent\", \"openclaw-agent.sqlite\")\n
\ if not os.path.exists(db_path):\n print(f\"[-] Database not found for
agent '{agent}': {db_path}\")\n return False\n\n try:\n con =
sqlite3.connect(db_path, timeout=5.0)\n except Exception as e:\n print(f\"[-]
Failed to connect to SQLite DB for agent '{agent}': {e}\")\n return False\n\n
\ cur = con.cursor()\n # Find matching session\n if topic_id:\n target_pattern
= f\"%topic:{topic_id}%\"\n cur.execute(\n \"SELECT session_key,
current_session_id, status, updated_at, entry_json FROM session_nodes WHERE session_key
LIKE ? AND session_key NOT LIKE '%:heartbeat' ORDER BY updated_at DESC LIMIT 1\",\n
\ (target_pattern,)\n )\n else:\n cur.execute(\n \"SELECT
session_key, current_session_id, status, updated_at, entry_json FROM session_nodes
WHERE session_key NOT LIKE '%:heartbeat' ORDER BY updated_at DESC LIMIT 1\"\n
\ )\n\n row = cur.fetchone()\n if not row:\n print(f\"[-] No
session found in {agent} DB (filter: topic={topic_id})\")\n con.close()\n
\ return False\n\n skey, sid, status, updated_at_ms, entry_json = row\n
\ now_ms = time.time() * 1000\n age_min = (now_ms - updated_at_ms) / 60000.0
if updated_at_ms else 0.0\n\n entry = {}\n if entry_json:\n try:\n
\ entry = json.loads(entry_json)\n except Exception:\n pass\n\n
\ tokens = entry.get(\"totalTokens\", \"?\")\n model = entry.get(\"model\",
\"default\")\n last_run_id = entry.get(\"activeWriterRunId\") or entry.get(\"activeRunId\",
None)\n\n # Detect anomalies\n badges = []\n if isinstance(tokens, int):\n
\ if tokens > 60000:\n badges.append(\"\U0001F6A8 [CRITICAL_CONTEXT_BLOAT:
>60k tokens]\")\n elif tokens > 20000:\n badges.append(\"⚠️
[HIGH_CONTEXT_TOKENS: >20k tokens]\")\n if status == \"running\" and age_min
> 5.0:\n badges.append(\"\U0001F512 [POSSIBLY_WEDGED_RUNNING: updated >5m
ago]\")\n elif status == \"processing\":\n badges.append(\"⚡ [ACTIVE_PROCESSING]\")\n\n
\ # Check ingress queue status\n state_db = \"/home/node/.openclaw/state/openclaw.sqlite\"\n
\ if topic_id and os.path.exists(state_db):\n try:\n s_con
= sqlite3.connect(state_db, timeout=2.0)\n lane_pat = f\"%topic:{topic_id}%\"\n
\ pending_cnt = s_con.execute(\n \"SELECT count(*) FROM
channel_ingress_events WHERE lane_key LIKE ? AND status IN ('pending', 'claimed')\",\n
\ (lane_pat,)\n ).fetchone()[0]\n s_con.close()\n
\ if pending_cnt > 0:\n badges.append(f\"\U0001F4EC [INGRESS_STALLED:
{pending_cnt} message(s) pending]\")\n except Exception:\n pass\n\n
\ print(\"=\" * 72)\n print(f\"\U0001F3AF AGENT: {agent.upper():<12} | TOPIC:
{topic_id or 'N/A':<6} | STATUS: {status.upper()}\")\n print(f\" SESSION
KEY: {skey}\")\n print(f\" SESSION ID: {sid}\")\n print(f\" MODEL:
\ {model}\")\n print(f\" TOKENS: {tokens} | LAST UPDATED: {age_min:.1f}m
ago\")\n if badges:\n print(f\" ANOMALIES: {' '.join(badges)}\")\n
\ print(\"=\" * 72)\n\n # Query transcript events\n cur.execute(\n \"SELECT
seq, created_at, event_json FROM transcript_events WHERE session_id = ? ORDER
BY seq DESC LIMIT ?\",\n (sid, limit)\n )\n events = cur.fetchall()\n
\ con.close()\n\n if not events:\n print(\" (No transcript events
found)\")\n return True\n\n events.reverse()\n print(\"\\n\U0001F4DC
RECENT TRANSCRIPT EVENTS (Chronological):\")\n\n last_role = None\n for
seq, ca_ms, ej_str in events:\n try:\n ev = json.loads(ej_str)\n
\ except Exception:\n print(f\"[{seq}] <unparseable event json>\")\n
\ continue\n\n etype = ev.get(\"type\")\n if etype !=
\"message\":\n continue\n\n msg = ev.get(\"message\", {})\n
\ role = msg.get(\"role\")\n last_role = role\n content =
msg.get(\"content\", [])\n\n if role == \"user\":\n txt = content
if isinstance(content, str) else json.dumps(content)\n # Check for
audio transcript\n if isinstance(content, str) and \"[Audio]\" in content:\n
\ print(f\"\\n\U0001F449 [SEQ {seq}] \U0001F3A4 USER AUDIO PROMPT:\")\n
\ for line in content.splitlines()[:6]:\n print(f\"
\ {line}\")\n if len(content.splitlines()) > 6:\n print(\"
\ ...\")\n else:\n txt_preview = txt.replace(\"\\n\",
\" \")[:240]\n print(f\"\\n\U0001F449 [SEQ {seq}] \U0001F464 USER:
{txt_preview}\")\n\n elif role == \"assistant\":\n print(f\"\\n\U0001F916
[SEQ {seq}] ASSISTANT TURN:\")\n if isinstance(content, list):\n for
item in content:\n if not isinstance(item, dict):\n continue\n
\ itype = item.get(\"type\")\n if itype ==
\"thinking\":\n th = item.get(\"thinking\", \"\").replace(\"\\n\",
\" \")\n print(f\" \U0001F9E0 THINKING: {th[:280]}...\")\n
\ elif itype == \"toolCall\":\n tname
= item.get(\"name\", \"unknown\")\n targs = json.dumps(item.get(\"arguments\",
{}))\n print(f\" ⚙️ TOOL CALL: {tname}({targs[:160]})\")\n
\ elif itype == \"text\":\n tx = item.get(\"text\",
\"\")\n lines = tx.splitlines()\n prev
= lines[0][:150] if lines else \"\"\n print(f\" \U0001F4AC
TEXT OUTPUT: {prev}...\")\n elif isinstance(content, str):\n print(f\"
\ \U0001F4AC TEXT OUTPUT: {content[:200]}...\")\n\n elif role == \"toolResult\":\n
\ is_err = ev.get(\"isError\", False)\n tname = ev.get(\"details\",
{}).get(\"tool\") or msg.get(\"toolName\", \"tool\")\n res_txt = json.dumps(content)
if not isinstance(content, str) else content\n if is_err or \"error\"
in res_txt.lower() or \"must have required property\" in res_txt:\n print(f\"
\ ❌ [SEQ {seq}] TOOL ERROR ({tname}): {res_txt[:260]}\")\n else:\n
\ print(f\" ✅ [SEQ {seq}] TOOL RESULT ({tname}): {res_txt[:180]}\")\n\n
\ if last_role == \"user\":\n print(\"\\n⚠️ WARNING: The last event in
the transcript is an UNANSWERED USER MESSAGE!\")\n print(\" The agent
turn either crashed, timed out, or was killed before producing a response.\")\n\n
\ print(\"\\n\" + \"=\" * 72)\n return True\n\ndef main():\n args = parse_args()\n
\ if not args.topic and not args.agent:\n print(\"Usage: openclaw-triage.py
(--topic <ID> | --agent <NAME>) [--limit <N>]\")\n sys.exit(1)\n\n agent
= args.agent\n topic = args.topic\n\n if topic and not agent:\n agent
= TOPIC_TO_AGENT.get(str(topic))\n if not agent:\n # Search
all DBs\n for a, t in TOPIC_TO_AGENT.items():\n if t
== topic:\n agent = a\n break\n\n if
not agent:\n # Scan all DBs for the topic\n for db in sorted(glob.glob(os.path.join(args.db_root,
\"*/agent/openclaw-agent.sqlite\"))):\n cand_agent = db.split(os.sep)[-3]\n
\ try:\n con = sqlite3.connect(db, timeout=2.0)\n cur
= con.cursor()\n cur.execute(\n \"SELECT session_key
FROM session_nodes WHERE session_key LIKE ? LIMIT 1\",\n (f\"%topic:{topic}%\",)\n
\ )\n if cur.fetchone():\n agent
= cand_agent\n con.close()\n break\n con.close()\n
\ except Exception:\n pass\n\n if not agent:\n print(f\"[-]
Could not map topic '{topic}' to any known OpenClaw agent.\")\n sys.exit(1)\n\n
\ success = triage_agent_session(agent, topic_id=topic, limit=args.limit, db_root=args.db_root)\n
\ sys.exit(0 if success else 1)\n\nif __name__ == \"__main__\":\n main()\n"
recover_sessions.py: |+
#!/usr/bin/env python3
import glob, sqlite3, json, os, sys, time
t0 = time.time()
recovered = 0
interrupted_turns = []
def extract_prompt(msg):
if not msg: return ""
content = msg.get("content")
if isinstance(content, str): return content
if isinstance(content, list):
parts = []
for c in content:
if isinstance(c, dict): parts.append(c.get("text") or c.get("content") or "")
elif isinstance(c, str): parts.append(c)
return "\n".join(filter(None, parts))
return str(content or "")
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
try:
conn = sqlite3.connect(db, timeout=10.0)
cursor = conn.cursor()
while True:
cursor.execute("PRAGMA foreign_key_check;")
violations = cursor.fetchall()
if not violations: break
for table, rowid, parent, fkid in violations:
cursor.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
cursor.execute("SELECT session_key, entry_json, current_session_id, status FROM session_nodes WHERE status IN (\"running\", \"failed\", \"error\")")
rows = cursor.fetchall()
agent_id = os.path.basename(os.path.dirname(os.path.dirname(db)))
for sk, ej, csid, st in rows:
try:
p = json.loads(ej) if ej else {}
sid = csid or p.get("sessionId")
if sid:
ev = cursor.execute("SELECT seq, event_json FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT 1", (sid,)).fetchone()
if ev:
ev_seq, ev_raw = ev
try:
ev_data = json.loads(ev_raw)
msg = ev_data.get("message") or ev_data
if st == "running" and msg.get("role") == "user":
prompt_text = extract_prompt(msg)
ctx = p.get("restartRecoveryDeliveryContext") or p.get("delivery", {}).get("context") or p.get("delivery", {}).get("route", {}).get("context") or {}
interrupted_turns.append({
"agent": agent_id,
"session_key": sk,
"session_id": sid,
"context": ctx,
"prompt": prompt_text
})
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
elif msg.get("role") == "assistant" and msg.get("stopReason") == "error":
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
except Exception as ex:
print(f"[recover_sessions] Error parsing event: {ex}", file=sys.stderr)
p["status"] = "done"
p["activeWriterRunId"] = None
p["activeClaimRunId"] = None
p["restartRecoveryDeliveryRunId"] = None
cursor.execute("UPDATE session_nodes SET status = \"done\", entry_json = ? WHERE session_key = ?", (json.dumps(p), sk))
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE session_key = ?", (sk,))
recovered += 1
except Exception: pass
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE entry_valid != 1")
cursor.execute("UPDATE session_windows SET status = \"done\" WHERE status IN (\"running\", \"failed\", \"error\")")
conn.commit()
conn.close()
except Exception as e:
print(f"[recover_sessions] Warning for {db}: {e}", file=sys.stderr)
state_db = "/home/node/.openclaw/state/openclaw.sqlite"
if os.path.exists(state_db):
try:
sconn = sqlite3.connect(state_db, timeout=10.0)
scur = sconn.cursor()
scur.execute("UPDATE channel_ingress_events SET attempts = 0, last_attempt_at = NULL, last_error = NULL WHERE status = 'pending' AND attempts > 0")
sconn.commit()
sconn.close()
except Exception as e:
print(f"[recover_sessions] Warning resetting ingress backoff: {e}", file=sys.stderr)
if interrupted_turns:
try:
with open("/tmp/openclaw-interrupted-turns.json", "w") as f:
json.dump(interrupted_turns, f, indent=2)
print(f"[recover_sessions] Detected {len(interrupted_turns)} interrupted user turn(s) saved to /tmp/openclaw-interrupted-turns.json")
except Exception as e:
print(f"[recover_sessions] Error writing interrupted turns: {e}", file=sys.stderr)
print(f"[recover_sessions] Completed in {time.time() - t0:.2f}s ({recovered} wedged sessions recovered)")
replay_interrupted_turns.py: |
#!/usr/bin/env python3
import json, os, subprocess, sys, time, urllib.request
INTERRUPTED_FILE = "/tmp/openclaw-interrupted-turns.json"
if not os.path.exists(INTERRUPTED_FILE):
sys.exit(0)
try:
with open(INTERRUPTED_FILE, "r") as f:
turns = json.load(f)
except Exception as e:
print(f"[replay_interrupted_turns] Failed to read {INTERRUPTED_FILE}: {e}", file=sys.stderr)
sys.exit(0)
if not turns:
sys.exit(0)
print(f"[replay_interrupted_turns] Found {len(turns)} interrupted turn(s). Waiting for gateway /healthz...")
gateway_ready = False
for _ in range(60):
try:
with urllib.request.urlopen("http://127.0.0.1:18790/healthz", timeout=3) as resp:
if resp.status == 200:
gateway_ready = True
break
except Exception:
pass
time.sleep(2)
if not gateway_ready:
print("[replay_interrupted_turns] Gateway did not become healthy in 120s. Aborting.", file=sys.stderr)
sys.exit(1)
# Allow 5s for Telegram ingress to bind
time.sleep(5)
for turn in turns:
sk = turn.get("session_key")
sid = turn.get("session_id")
agent_id = turn.get("agent")
ctx = turn.get("context") or {}
prompt = turn.get("prompt") or ""
channel = ctx.get("channel") or "telegram"
to_target = ctx.get("to") or ""
thread_id = ctx.get("threadId")
target = to_target
if target.startswith("telegram:"):
parts = target.split(":")
target = parts[1] if len(parts) > 1 else target
print(f"[replay_interrupted_turns] Processing agent '{agent_id}' on session '{sk}' target='{target}' thread='{thread_id}'")
# 1. Send status notification to Telegram if target exists
if target:
notice = "⚠️ OpenClaw wurde während der Bearbeitung neu gestartet – deine Anfrage wird jetzt nachgeholt..."
notify_cmd = [
"openclaw", "message", "send",
"--channel", channel,
"--target", str(target),
"-m", notice
]
if thread_id is not None:
notify_cmd.extend(["--thread-id", str(thread_id)])
try:
subprocess.run(notify_cmd, check=False, timeout=30)
print(f"[replay_interrupted_turns] Sent recovery notice to {target}")
except Exception as e:
print(f"[replay_interrupted_turns] Notice failed: {e}", file=sys.stderr)
# 2. Replay agent turn
if sk and prompt:
p_short = (prompt[:60] + "...") if len(prompt) > 60 else prompt
print(f"[replay_interrupted_turns] Replaying agent turn: {p_short}")
agent_cmd = [
"openclaw", "agent",
"--session-key", sk,
"-m", prompt,
"--deliver"
]
try:
res = subprocess.run(agent_cmd, capture_output=True, text=True, timeout=300)
if res.returncode == 0:
print(f"[replay_interrupted_turns] Turn replay succeeded for {sk}")
else:
print(f"[replay_interrupted_turns] Turn replay exited with code {res.returncode}: {res.stderr[:200]}", file=sys.stderr)
except Exception as e:
print(f"[replay_interrupted_turns] Replay execution error: {e}", file=sys.stderr)
try:
os.remove(INTERRUPTED_FILE)
print(f"[replay_interrupted_turns] Cleaned up {INTERRUPTED_FILE}")
except Exception:
pass
kind: ConfigMap
metadata:
name: openclaw-scripts
namespace: openclaw
Binary file not shown.
Binary file not shown.
+137
View File
@@ -0,0 +1,137 @@
# Declarative Crons-as-Code for OpenClaw (Haumdaucher)
# Reconciled idempotently via k8s/openclaw/scripts/sync_crons.py
version: 1
jobs:
# 1. Maintenance & State Truncation
- name: "tracking-session-sliding-window"
declarationKey: "tracking-session-sliding-window"
description: "Lightweight sliding-window truncation (max 8 events) for mila_tracker and nutrition_tracker"
schedule:
kind: "every"
every: "15m"
session: "isolated"
delivery: "none"
command: "python3 /home/node/.openclaw/scripts/compact_tracking_sessions.py"
- name: "nightly-session-compaction"
declarationKey: "nightly-session-compaction"
description: "Nightly multi-agent session compaction and SQLite WAL checkpointing"
schedule:
kind: "cron"
expr: "45 4 * * *"
tz: "Europe/Berlin"
session: "isolated"
delivery: "none"
command: "python3 /home/node/.openclaw/scripts/compact_sessions.py"
# 2. Nightly Garmin Connect Mirror
- name: "garmin-nightly-mirror"
declarationKey: "garmin-nightly-mirror"
description: "Nächtlicher deterministischer Spiegel der Garmin Connect Daten (Aktivitäten, Daily Summary, Schlaf) in Git JSONL"
schedule:
kind: "cron"
expr: "30 3 * * *"
tz: "Europe/Berlin"
agent: "fitness"
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
session: "isolated"
channel: "telegram"
to: "-1004326921510"
threadId: "20"
announce: true
message: |
Führe den nächtlichen Garmin Connect Sync durch.
Vorgehen:
1. Führe den Garmin-Sync-Befehl aus:
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/garmin_sync.py --days 2 --data-dir /home/node/.openclaw/wiki/main/data
2. Finale Antwort:
- Bei Erfolg: Gib exakt 'NO_REPLY' aus (intentional silence, kein Telegram-Spam in der Nacht).
- Nur bei Authentifizierungsfehlern: Informiere kurz via Telegram (Thema #Sport), dass ein Garmin-Re-Auth nötig ist, oder starte den Re-Auth-Flow.
# 4. Weekly Autonomous System & Error Audit
- name: "itguy-weekly-health-audit"
declarationKey: "itguy-weekly-health-audit"
description: "Wöchentlicher Self-Audit der OpenClaw-Logs, Tool-Failures und Session-Transkripte mit Git-Commit-Korrelation"
schedule:
kind: "cron"
expr: "0 3 * * 0"
tz: "Europe/Berlin"
agent: "itguy"
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
session: "isolated"
channel: "telegram"
to: "-1004326921510"
threadId: "119"
announce: true
message: |
Führe den wöchentlichen OpenClaw System- und Log-Audit durch.
Vorgehen:
1. Führe den Audit-Befehl aus:
uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 7d --write-report --add-task --git-push
2. Analysiere die Ausgabe und korreliere eventuelle Fehler-Cluster mit den jüngsten Commits in infrapuzzle, mop-skills und knowledge-wiki.
3. Finale Antwort:
- Wenn NEUE Probleme gefunden oder Tasks in tasks/antigravity.md eingetragen wurden: Fasse die Ergebnisse kurz, präzise und lösungsorientiert zusammen (betroffene Tools, vermutete Ursache, Link zur Task-Queue) und gib diese Zusammenfassung als finale Antwort aus.
- Wenn das System gesund ist und KEINE neuen Tasks erzeugt wurden: Gib als finale Antwort exakt 'NO_REPLY' aus (intentional silence, kein Telegram-Spam).
# 5. Weekly AI Roundup (Recherche)
- name: "weekly-ai-roundup"
declarationKey: "weekly-ai-roundup:recherche"
description: "Wöchentlicher AI-Roundup und Tech-Scanning für Topic #Research"
schedule:
kind: "cron"
expr: "30 6 * * 5"
tz: "Europe/Berlin"
agent: "recherche"
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
session: "isolated"
channel: "telegram"
to: "-1004326921510"
threadId: "117"
announce: true
message: |
🗓️ WÖCHENTLICHER AI-ROUNDUP (Freitag). Führe den in sources.md im Workspace definierten Ablauf aus und poste das Ergebnis als EINE kompakte Telegram-Nachricht in diesen Topic (#Research).
1) Hacker News: Algolia API – https://hn.algolia.com/api/v1/search_by_date?tags=story&numericFilters=points>150,created_at_i>{Unix-Timestamp von vor 7 Tagen}&hitsPerPage=100 → daraus die KI-relevanten Top-Stories nach Punkten ziehen.
2) Frontier-Labs prüfen: OpenAI, Anthropic, Google DeepMind, Meta AI, Mistral, xAI.
3) China-Labs prüfen: GLM/Z.ai, Qwen, DeepSeek, Moonshot/Kimi, MiniMax.
4) Tracker: Artificial Analysis Releases, Hugging Face Trending, OpenRouter Rankings, LMArena.
5) Newsletter/Blogs: The Batch, Import AI, Interconnects, Ahead of AI, Simon Willison, TLDR AI.
6) GitHub: Trending (weekly) + neue Releases der Kern-Orgs (openai, anthropics, google-deepmind, QwenLM, deepseek-ai, zai-org, meta-llama, mistralai).
7) YouTube nur als Hype-Indikator (Titel/Snippets), KEINE Transkripte.
Ausgabe (Deutsch, Markdown, KEINE Tabellen):
- Top-3-Highlights der Woche (je 1 Satz + Link)
- Danach Bullets je Kategorie: ① Frontier-Modelle · ② China-Modelle · ③ Tools/Infra/Procedures · ④ Trends (HN/GitHub/YouTube), je Eintrag genau 1 Quell-Link
- Scope 60/40 (Modelle/Tools). Wenn eine Kategorie nichts Neues hat: kurz 'nichts Neues'.
- Am Ende NUR falls nötig eine Zeile: welche Quelle in sources.md ergänzt/entfernt werden sollte.
Nutze web_search/web_fetch. Effort: medium.
# 6. Weekly Bridge Tracker (Holzgartensteg)
- name: "Recherche: Holzgartensteg (wöchentlich)"
declarationKey: "recherche:holzgartensteg"
description: "Wöchentlicher Status-Check zum Holzgartensteg Regensburg via ALLRIS & Web"
schedule:
kind: "cron"
expr: "0 16 * * 5"
tz: "Europe/Berlin"
agent: "main"
session: "isolated"
channel: "telegram"
to: "306373425"
announce: true
message: |
Wöchentlicher Recherche-Task (Holzgartensteg, Stadt Regensburg).
AUFGABE: Prüfe, ob es NEUIGKEITEN zum geplanten Holzgartensteg (geplante Fuß-/Radbrücke zwischen Grieser Spitz und Maria-Beer-Platz / Weichs) gibt — seit dem letzten Check bzw. den letzten ~7 Tagen.
QUELLEN:
1) ALLRIS via Skill 'regensburg-stadtrat': Script /home/node/mop-skills/mop-skills/skills/regensburg-stadtrat/scripts/regensburg_ris.py — Befehle: search "Holzgartensteg" --limit 30 ; calendar --days 30 ; vorlage <ID> ; session <ID>. Achte auf NEUE Vorlagen, Beschlüsse, Anfragen und Sitzungstermine (Ausschuss für Stadtplanung, Verkehr und Wohnungsfragen / Stadtrat).
2) Lokale Medien via web_search: regensburg-digital.de, idowa.de, mittelbayerische.de, tvaktuell.com, regensburger-nachrichten.de.
FOKUS: neue Vorlagen/Beschlüsse, Ausschuss-/Stadtrattermine, Mittel/Budget, Planungsstand (Variante, Kosten, Termin), politische Positionen.
BEKANNTER STAND (nicht wiederholen, nur Abweichungen melden): 2020 aus Investitionsprogramm gestrichen ('beerdigt'); Comeback ab 2025 (Grüne/Verbände); Kommunalwahl 2026 mehrere Parteien dafür; Haushalt 2026: ~100.000 EUR Planungsmittel im Investitionsprogramm, Stadtrat-Beschluss 26.02.2026, Genehmigung Regierung der Oberpfalz 06.03.2026; Koalitionsvereinbarung 2026 mit Wiederaufnahme der Planungen.
AUSGABE: Kurz auf Deutsch an Moritz. Falls echte Neuigkeiten: 3-6 Bullets mit Quelle+Link. Falls NICHTS Neues: eine kurze Zeile, z.B. 'Holzgartensteg: keine neuen Entwicklungen diese Woche.' Kein Hintergrund-Recycling.
+36
View File
@@ -0,0 +1,36 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: openclaw-alerts
namespace: openclaw
labels:
release: prometheus-operator
app.kubernetes.io/name: openclaw
spec:
groups:
- name: openclaw.rules
rules:
- alert: OpenClawPodNotReady
expr: kube_pod_status_ready{namespace="openclaw", pod="openclaw-0", condition="true"} == 0
for: 5m
labels:
severity: critical
annotations:
summary: "OpenClaw pod openclaw-0 is not ready"
description: "Pod openclaw-0 in namespace openclaw has been unready for more than 5 minutes. Telegram and background agents may be offline."
- alert: OpenClawCrashLooping
expr: increase(kube_pod_container_status_restarts_total{namespace="openclaw", pod="openclaw-0"}[15m]) >= 2
for: 2m
labels:
severity: warning
annotations:
summary: "OpenClaw container restart detected"
description: "Container {{ $labels.container }} in pod openclaw-0 has restarted {{ $value }} times in the last 15 minutes."
- alert: OpenClawDataVolumeLow
expr: kubelet_volume_stats_available_bytes{namespace="openclaw", persistentvolumeclaim="openclaw-data"} / kubelet_volume_stats_capacity_bytes{namespace="openclaw", persistentvolumeclaim="openclaw-data"} < 0.15
for: 15m
labels:
severity: warning
annotations:
summary: "OpenClaw PVC data volume is low on space"
description: "PersistentVolumeClaim openclaw-data has less than 15% space available."
Binary file not shown.
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: openclaw-cluster-ops
subjects:
- kind: ServiceAccount
name: openclaw
namespace: openclaw
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
+205
View File
@@ -0,0 +1,205 @@
#!/bin/sh
set -e
echo "=== OpenClaw Dependency & Runtime Bootstrap ==="
export PATH="/home/node/.openclaw/bin:/home/node/.openclaw/.local/bin:$PATH"
SCRIPTS_DIR="/home/node/.openclaw/scripts"
mkdir -p /home/node/.openclaw/bin "$SCRIPTS_DIR" /home/node/.openclaw/.config
touch /home/node/.openclaw/.config/keep
# 1. Sanitize legacy config keys in openclaw.json
node -e '
const fs = require("fs");
for (const file of ["/home/node/.openclaw/openclaw.json", "/home/openclaw/.openclaw/openclaw.json"]) {
if (fs.existsSync(file)) {
try {
const cfg = JSON.parse(fs.readFileSync(file, "utf8"));
if (cfg.browser) {
delete cfg.browser.remoteCdpTimeoutMs;
if (cfg.browser.profiles) {
if (cfg.browser.profiles.default) delete cfg.browser.profiles.default.color;
if (cfg.browser.profiles.chrome) delete cfg.browser.profiles.chrome;
}
}
if (cfg.gateway && cfg.gateway.controlUi) {
delete cfg.gateway.controlUi.dangerouslyDisableDeviceAuth;
}
if (!cfg.agents) cfg.agents = {};
cfg.agents.ownership = "explicit";
if (cfg.agents.entries && cfg.agents.entries.main) {
delete cfg.agents.entries.main.default;
}
fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
console.log("Sanitized config file:", file);
} catch (e) {
console.error("Failed to sanitize", file, e.message);
}
}
}
' || true
# 2. Run pre-flight session recovery
echo "Running pre-flight self-healing..."
python3 "$SCRIPTS_DIR/recover_sessions.py" || true
# 3. Doctor fix
echo "Running openclaw doctor --fix to migrate runtime config on PVC..."
openclaw doctor --fix || true
# 4. Garmin OAuth tokens bootstrap / preserve
if [ ! -s /home/node/.openclaw/garmin_tokens/garmin_tokens.json ] && [ -n "$GARMIN_TOKENS_BASE64" ]; then
echo "Bootstrapping initial Garmin OAuth tokens from secret..."
mkdir -p /home/node/.openclaw/garmin_tokens
echo "$GARMIN_TOKENS_BASE64" | base64 -d > /home/node/.openclaw/garmin_tokens/garmin_tokens.json
chmod 700 /home/node/.openclaw/garmin_tokens
chmod 600 /home/node/.openclaw/garmin_tokens/garmin_tokens.json
elif [ -s /home/node/.openclaw/garmin_tokens/garmin_tokens.json ]; then
echo "Preserving live Garmin OAuth tokens on PVC..."
chmod 700 /home/node/.openclaw/garmin_tokens
chmod 600 /home/node/.openclaw/garmin_tokens/garmin_tokens.json
fi
# 5. Cleanup legacy binaries and superseded local skills
rm -rf /home/node/.openclaw/bin/whisper* /home/node/.openclaw/bin/.whisper* /home/node/.openclaw/scripts/whisper* /home/node/.openclaw/models/whisper || true
rm -rf /home/node/.openclaw/skills/memobird /home/node/.openclaw/skills/nerd-report /home/node/.openclaw/skills/rvv-regensburg /home/node/.openclaw/skills/skill-creator || true
rm -rf /home/node/.openclaw/workspace/skills /home/node/.openclaw/workspace-koch/skills /home/node/.openclaw/workspace-recherche/skills || true
# 6. Ensure tools.allow and models.providers.openrouter.apiKey unset
openclaw config unset tools.allow || true
openclaw config unset models.providers.openrouter.apiKey || true
openclaw config unset agents.entries.mila_tracker.tools.profile || true
openclaw config unset agents.entries.nutrition_tracker.tools.profile || true
# 7. Decouple hardlinked bootstrap files
for dir in /home/node/.openclaw/workspace-*; do
if [ -d "$dir" ]; then
for file in USER.md MEMORY.md; do
if [ -f "/home/node/.openclaw/workspace/$file" ]; then
cp --remove-destination "/home/node/.openclaw/workspace/$file" "$dir/$file"
chown node:node "$dir/$file" 2>/dev/null || true
chmod 664 "$dir/$file" 2>/dev/null || true
fi
done
fi
done
# 8. Install CLI binaries
if [ ! -f "/home/node/.openclaw/bin/openhue.real" ]; then
echo "Installing openhue CLI binary..."
curl -sL https://github.com/openhue/openhue-cli/releases/download/0.24/openhue_Linux_x86_64.tar.gz | tar -xz -C /home/node/.openclaw/bin openhue
mv /home/node/.openclaw/bin/openhue /home/node/.openclaw/bin/openhue.real
printf '#!/bin/sh\nexport HOME=/home/node/.openclaw\nexec /home/node/.openclaw/bin/openhue.real "$@"\n' > /home/node/.openclaw/bin/openhue
chmod +x /home/node/.openclaw/bin/openhue /home/node/.openclaw/bin/openhue.real
fi
if [ ! -f "/home/node/.openclaw/bin/summarize" ]; then
echo "Installing summarize CLI via npm..."
npm install -g --prefix /home/node/.openclaw @steipete/summarize || true
fi
if [ ! -f "/home/node/.openclaw/bin/mcp-server-github" ]; then
echo "Installing @modelcontextprotocol/server-github via npm..."
npm install -g --prefix /home/node/.openclaw @modelcontextprotocol/server-github || true
fi
if [ ! -f "/home/node/.openclaw/bin/kubectl" ]; then
echo "Installing kubectl CLI binary..."
curl -fsSL https://dl.k8s.io/release/v1.31.0/bin/linux/amd64/kubectl -o /home/node/.openclaw/bin/kubectl
chmod +x /home/node/.openclaw/bin/kubectl
fi
if [ ! -f "/home/node/.openclaw/bin/jq" ]; then
echo "Installing jq CLI binary..."
curl -fsSL https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64 -o /home/node/.openclaw/bin/jq
chmod +x /home/node/.openclaw/bin/jq
fi
if [ ! -f "/home/node/.openclaw/bin/file" ]; then
echo "Setting up file CLI helper..."
cp "$SCRIPTS_DIR/file_cli.py" /home/node/.openclaw/bin/file
chmod +x /home/node/.openclaw/bin/file
fi
if [ -x "/home/node/.openclaw/.local/bin/python3" ]; then
/home/node/.openclaw/bin/uv pip install --break-system-packages --python /home/node/.openclaw/.local/bin/python3 pillow google-api-python-client pypdf || true
fi
# 9. Clone or pull infrapuzzle repository to PVC
mkdir -p /home/node/.openclaw/repos
if [ ! -d "/home/node/.openclaw/repos/infrapuzzle/.git" ]; then
echo "Cloning infrapuzzle repository to PVC..."
git clone --depth 1 "https://${GIT_SYNC_USERNAME}:${GIT_SYNC_PASSWORD}@git.moritzgraf.de/moritz/infrapuzzle.git" /home/node/.openclaw/repos/infrapuzzle || true
else
echo "Pulling latest infrapuzzle commits on PVC..."
git -C /home/node/.openclaw/repos/infrapuzzle pull origin master || git -C /home/node/.openclaw/repos/infrapuzzle pull origin main || true
fi
# 10. Seed and maintain persistent mcp-venv
if [ ! -d "/home/node/.openclaw/mcp-venv" ]; then
echo "Seeding persistent mcp-venv..."
uv venv --python 3.12 /home/node/.openclaw/mcp-venv
uv pip install --python /home/node/.openclaw/mcp-venv \
prometheus-mcp-server \
ha-mcp \
chuk-mcp-open-meteo \
gitea-mcp \
git+https://github.com/Taxuspt/garmin_mcp \
"mcp>=2.0" \
filetype \
google-api-python-client \
google-auth \
bring-api \
requests \
beautifulsoup4 \
youtube-transcript-api
fi
if [ -d "/home/node/.openclaw/mcp-venv" ]; then
python3 -c 'import os; p="/home/node/.openclaw/mcp-venv/lib/python3.12/site-packages/mcp/server/fastmcp.py"; open(p, "w").write("import os\nfrom fastmcp import FastMCP as _FastMCP, Context\n\nclass FastMCP(_FastMCP):\n def __init__(self, name=\"MCP Server\", host=None, port=None, **kwargs):\n if host is not None: os.environ[\"FASTMCP_HOST\"] = str(host)\n if port is not None: os.environ[\"FASTMCP_PORT\"] = str(port)\n super().__init__(name=name, **kwargs)\n")' || true
python3 "$SCRIPTS_DIR/patch_garmin_mcp.py" || true
fi
# 11. Extensions & Plugins
if [ ! -d "/home/node/.openclaw/extensions/perplexity" ]; then
echo "Installing perplexity plugin..."
openclaw plugins install clawhub:@openclaw/perplexity-plugin --accept-capabilities || true
fi
echo "Enabling memory-wiki plugin..."
openclaw plugins enable memory-wiki || true
echo "Patching perplexity web search runtime for chat completions compatibility..."
node "$SCRIPTS_DIR/patch_perplexity.mjs" || true
# 12. Ensure OpenRouter auth profile in state DB
echo "Ensuring OpenRouter auth profile in state DB..."
node --input-type=module -e '
import { DatabaseSync } from "node:sqlite";
const key = process.env.OPENROUTER_API_KEY;
if (key) {
try {
const dbPath = "/home/node/.openclaw/state/openclaw.sqlite";
const db = new DatabaseSync(dbPath);
db.prepare("CREATE TABLE IF NOT EXISTS config_machine_state (state_key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL)").run();
const row = db.prepare("SELECT value_json FROM config_machine_state WHERE state_key = \x27authProfiles.store\x27").get();
let store = row?.value_json ? JSON.parse(row.value_json) : { version: 1, profiles: {} };
if (!store.profiles) store.profiles = {};
store.profiles["openrouter:default"] = {
type: "api_key",
provider: "openrouter",
key: key
};
db.prepare("INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (\x27authProfiles.store\x27, ?, ?) ON CONFLICT(state_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms")
.run(JSON.stringify(store), Date.now());
console.log("Synchronized openrouter auth profile in state DB.");
} catch (e) {
console.error("Could not sync auth profile:", e.message);
}
}
' || true
# 13. Pre-warm SQLite page cache
echo "Pre-warming SQLite database files into OS page cache for instant cold start..."
cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true
echo "=== Bootstrap Complete ==="
+336
View File
@@ -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)
+50
View File
@@ -0,0 +1,50 @@
#!/home/node/.openclaw/mcp-venv/bin/python3
import sys, os, mimetypes, filetype
def get_file_info(filepath, mime_only=False):
if not os.path.exists(filepath):
return "cannot open (No such file or directory)", "cannot open (No such file or directory)"
if os.path.isdir(filepath):
return "directory", "inode/directory"
kind = filetype.guess(filepath)
if kind is not None:
return f"{kind.extension.upper()} image/media data ({kind.mime})", kind.mime
guessed_mime, _ = mimetypes.guess_type(filepath)
try:
with open(filepath, "rb") as f:
sample = f.read(4096)
if len(sample) == 0:
return "empty", "application/x-empty"
try:
sample.decode("utf-8")
if guessed_mime:
return f"{guessed_mime} text", guessed_mime
return "ASCII text", "text/plain"
except UnicodeDecodeError:
return "data", "application/octet-stream"
except Exception as e:
return f"cannot read ({e})", "application/octet-stream"
def main():
args = sys.argv[1:]
brief, mime_only, files = False, False, []
for arg in args:
if arg in ("-b", "--brief"):
brief = True
elif arg in ("-i", "--mime", "--mime-type"):
mime_only = True
elif arg.startswith("-") and len(arg) > 1 and not arg.startswith("--"):
if "b" in arg: brief = True
if "i" in arg: mime_only = True
else:
files.append(arg)
if not files:
sys.stderr.write("Usage: file [-b] [-i|--mime-type] <file>...\n")
sys.exit(1)
for f in files:
desc, mime = get_file_info(f, mime_only)
res = mime if mime_only else desc
print(res if brief else f"{f}: {res}")
if __name__ == "__main__":
main()
@@ -0,0 +1,78 @@
import fs from "node:fs";
import path from "node:path";
import { execSync, spawn } from "node:child_process";
if (!process.env.__OPENCLAW_PREFLIGHT_DONE) {
process.env.__OPENCLAW_PREFLIGHT_DONE = "1";
const isGateway = process.argv.some(arg => arg.endsWith("openclaw.mjs") || arg.endsWith("openclaw")) && process.argv.includes("gateway");
if (isGateway) {
const t0 = Date.now();
console.log("[gateway-preflight] Initializing container self-healing preflight...");
// 1. Clean up stale gateway lock files from ungraceful kills
for (const dir of ["/tmp/openclaw-1000", "/tmp"]) {
if (fs.existsSync(dir)) {
try {
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith("gateway") && file.endsWith(".lock")) {
const fullPath = path.join(dir, file);
fs.unlinkSync(fullPath);
console.log(`[gateway-preflight] Removed stale lock: ${fullPath}`);
}
}
} catch (e) {
console.warn(`[gateway-preflight] Lock cleanup warning in ${dir}:`, e.message);
}
}
}
// 2. Clear false database quarantines
const qPath = "/home/node/.openclaw/state/openclaw-quarantine.sqlite";
if (fs.existsSync(qPath)) {
try {
execSync(`/home/node/.openclaw/.local/bin/python3 -c "import sqlite3; c=sqlite3.connect(\x27${qPath}\x27); c.execute(\x27DELETE FROM quarantined_databases\x27); c.commit(); c.close()"`, { stdio: "ignore" });
console.log("[gateway-preflight] Cleared database quarantine.");
} catch (_) {}
}
// 3. Deduplicate subagent vector databases (keeps non-main agent DBs < 15MB)
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/dedup_subagent_vectors.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Vector deduplication warning:", e.message);
}
// 4. Pre-warm page cache sequentially (transforms random 70 IOPS disk reads into sequential streaming)
try {
const pw0 = Date.now();
execSync("cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true; find /home/node/.openclaw/wiki/main /home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null 2>&1 || true");
console.log(`[gateway-preflight] Pre-warmed SQLite databases and Wiki markdown in ${Date.now() - pw0}ms`);
} catch (_) {}
// 5. Recover any wedged running sessions left over from ungraceful container restart
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/recover_sessions.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Session recovery warning:", e.message);
}
// 6. Spawn background turn replay daemon if interrupted turns were detected
try {
if (fs.existsSync("/tmp/openclaw-interrupted-turns.json")) {
console.log("[gateway-preflight] Spawning background turn replay daemon...");
const child = spawn("/home/node/.openclaw/.local/bin/python3", ["/home/node/.openclaw/scripts/replay_interrupted_turns.py"], {
detached: true,
stdio: "ignore"
});
child.unref();
}
} catch (e) {
console.warn("[gateway-preflight] Could not spawn turn replay daemon:", e.message);
}
console.log(`[gateway-preflight] Self-healing completed in ${Date.now() - t0}ms.`);
}
}
+249
View File
@@ -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()
+64
View File
@@ -0,0 +1,64 @@
import os
p = "/home/node/.openclaw/mcp-venv/lib/python3.12/site-packages/garmin_mcp/workouts.py"
if os.path.exists(p):
with open(p, "r") as f:
c = f.read()
if "_normalize_sport_type" not in c:
patch = '''
SPORT_TYPE_MAPPINGS = {
"running": {"sportTypeId": 1, "sportTypeKey": "running", "displayOrder": 1},
"cycling": {"sportTypeId": 2, "sportTypeKey": "cycling", "displayOrder": 2},
"other": {"sportTypeId": 3, "sportTypeKey": "other", "displayOrder": 3},
"lap_swimming": {"sportTypeId": 4, "sportTypeKey": "lap_swimming", "displayOrder": 4},
"swimming": {"sportTypeId": 4, "sportTypeKey": "lap_swimming", "displayOrder": 4},
"strength_training": {"sportTypeId": 5, "sportTypeKey": "strength_training", "displayOrder": 5},
"cardio_training": {"sportTypeId": 6, "sportTypeKey": "cardio_training", "displayOrder": 6},
"cardio": {"sportTypeId": 6, "sportTypeKey": "cardio_training", "displayOrder": 6},
"yoga": {"sportTypeId": 7, "sportTypeKey": "yoga", "displayOrder": 8},
"pilates": {"sportTypeId": 8, "sportTypeKey": "pilates", "displayOrder": 9},
"hiit": {"sportTypeId": 9, "sportTypeKey": "hiit", "displayOrder": 7},
"mobility": {"sportTypeId": 11, "sportTypeKey": "mobility", "displayOrder": 11},
"walking": {"sportTypeId": 12, "sportTypeKey": "walking", "displayOrder": 10},
"rucking": {"sportTypeId": 13, "sportTypeKey": "rucking", "displayOrder": 12},
}
SPORT_ID_MAPPINGS = {v["sportTypeId"]: v for v in SPORT_TYPE_MAPPINGS.values()}
def _resolve_sport_block(st):
if isinstance(st, str):
k = st.strip().lower()
if k in SPORT_TYPE_MAPPINGS:
return dict(SPORT_TYPE_MAPPINGS[k])
elif isinstance(st, dict):
key = st.get("sportTypeKey")
sid = st.get("sportTypeId")
if key and str(key).strip().lower() in SPORT_TYPE_MAPPINGS:
base = dict(SPORT_TYPE_MAPPINGS[str(key).strip().lower()])
if sid is not None and sid != 0 and sid != base["sportTypeId"]:
raise ValueError(f"sportType mismatch: {key} vs {sid}")
return base
if sid is not None and sid in SPORT_ID_MAPPINGS:
return dict(SPORT_ID_MAPPINGS[sid])
return None
def _normalize_sport_type(workout_data: dict) -> None:
resolved = _resolve_sport_block(workout_data.get("sportType"))
segments = workout_data.get("workoutSegments") or []
if not resolved and segments:
for seg in segments:
resolved = _resolve_sport_block(seg.get("sportType"))
if resolved:
break
if not resolved:
raise ValueError("Missing or invalid sportType. Both sportTypeId and sportTypeKey are required.")
workout_data["sportType"] = resolved
for seg in segments:
st = _resolve_sport_block(seg.get("sportType"))
seg["sportType"] = st if st else dict(resolved)
'''
t = "def _normalize_workout_steps(workout_data: dict) -> None:"
if t in c:
with open(p, "w") as f:
f.write(c.replace(t, patch + t + "\n _normalize_sport_type(workout_data)", 1))
print("Successfully patched garmin_mcp workouts.py!")
+61
View File
@@ -0,0 +1,61 @@
import fs from "node:fs";
import path from "node:path";
import { globSync } from "node:fs";
const baseDir = "/home/node/.openclaw/extensions/perplexity/dist";
if (fs.existsSync(baseDir)) {
const files = fs.readdirSync(baseDir).filter(f => f.startsWith("perplexity-web-search-provider.runtime-") && f.endsWith(".js"));
for (const filename of files) {
const file = path.join(baseDir, filename);
let code = fs.readFileSync(file, "utf8");
if (!code.includes("/* PATCHED_CHAT_COMPLETIONS */")) {
const target = "const structured = runtime.transport === \"search_api\";";
const fastPath = `const structured = runtime.transport === "search_api"; /* PATCHED_CHAT_COMPLETIONS */
if (!structured) {
const cacheKey = buildSearchCacheKey([
"perplexity",
runtime.transport,
runtime.baseUrl,
runtime.model,
query,
freshness
]);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
const cached = readCachedSearchPayload(cacheKey, cacheTtlMs);
if (cached) return cached;
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const result = await runPerplexitySearch({
query,
apiKey: runtime.apiKey,
baseUrl: runtime.baseUrl,
model: runtime.model,
timeoutSeconds,
signal,
freshness
});
const payload = {
query,
provider: "perplexity",
model: runtime.model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true
},
content: wrapWebContent(result.content, "web_search"),
citations: result.citations
};
writeCachedSearchPayload(cacheKey, payload);
return payload;
}`;
if (code.includes(target)) {
code = code.replace(target, fastPath);
fs.writeFileSync(file, code, "utf8");
console.log("Patched perplexity runtime for chat completions:", file);
}
}
}
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
import glob, sqlite3, json, os, sys, time
t0 = time.time()
recovered = 0
interrupted_turns = []
def extract_prompt(msg):
if not msg: return ""
content = msg.get("content")
if isinstance(content, str): return content
if isinstance(content, list):
parts = []
for c in content:
if isinstance(c, dict): parts.append(c.get("text") or c.get("content") or "")
elif isinstance(c, str): parts.append(c)
return "\n".join(filter(None, parts))
return str(content or "")
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
try:
conn = sqlite3.connect(db, timeout=10.0)
cursor = conn.cursor()
while True:
cursor.execute("PRAGMA foreign_key_check;")
violations = cursor.fetchall()
if not violations: break
for table, rowid, parent, fkid in violations:
cursor.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
cursor.execute("SELECT session_key, entry_json, current_session_id, status FROM session_nodes WHERE status IN (\"running\", \"failed\", \"error\")")
rows = cursor.fetchall()
agent_id = os.path.basename(os.path.dirname(os.path.dirname(db)))
for sk, ej, csid, st in rows:
try:
p = json.loads(ej) if ej else {}
sid = csid or p.get("sessionId")
if sid:
ev = cursor.execute("SELECT seq, event_json FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT 1", (sid,)).fetchone()
if ev:
ev_seq, ev_raw = ev
try:
ev_data = json.loads(ev_raw)
msg = ev_data.get("message") or ev_data
if st == "running" and msg.get("role") == "user":
prompt_text = extract_prompt(msg)
ctx = p.get("restartRecoveryDeliveryContext") or p.get("delivery", {}).get("context") or p.get("delivery", {}).get("route", {}).get("context") or {}
interrupted_turns.append({
"agent": agent_id,
"session_key": sk,
"session_id": sid,
"context": ctx,
"prompt": prompt_text
})
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
elif msg.get("role") == "assistant" and msg.get("stopReason") == "error":
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
except Exception as ex:
print(f"[recover_sessions] Error parsing event: {ex}", file=sys.stderr)
p["status"] = "done"
p["activeWriterRunId"] = None
p["activeClaimRunId"] = None
p["restartRecoveryDeliveryRunId"] = None
cursor.execute("UPDATE session_nodes SET status = \"done\", entry_json = ? WHERE session_key = ?", (json.dumps(p), sk))
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE session_key = ?", (sk,))
recovered += 1
except Exception: pass
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE entry_valid != 1")
cursor.execute("UPDATE session_windows SET status = \"done\" WHERE status IN (\"running\", \"failed\", \"error\")")
conn.commit()
conn.close()
except Exception as e:
print(f"[recover_sessions] Warning for {db}: {e}", file=sys.stderr)
state_db = "/home/node/.openclaw/state/openclaw.sqlite"
if os.path.exists(state_db):
try:
sconn = sqlite3.connect(state_db, timeout=10.0)
scur = sconn.cursor()
scur.execute("UPDATE channel_ingress_events SET attempts = 0, last_attempt_at = NULL, last_error = NULL WHERE status = 'pending' AND attempts > 0")
sconn.commit()
sconn.close()
except Exception as e:
print(f"[recover_sessions] Warning resetting ingress backoff: {e}", file=sys.stderr)
if interrupted_turns:
try:
with open("/tmp/openclaw-interrupted-turns.json", "w") as f:
json.dump(interrupted_turns, f, indent=2)
print(f"[recover_sessions] Detected {len(interrupted_turns)} interrupted user turn(s) saved to /tmp/openclaw-interrupted-turns.json")
except Exception as e:
print(f"[recover_sessions] Error writing interrupted turns: {e}", file=sys.stderr)
print(f"[recover_sessions] Completed in {time.time() - t0:.2f}s ({recovered} wedged sessions recovered)")
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
import json, os, subprocess, sys, time, urllib.request
INTERRUPTED_FILE = "/tmp/openclaw-interrupted-turns.json"
if not os.path.exists(INTERRUPTED_FILE):
sys.exit(0)
try:
with open(INTERRUPTED_FILE, "r") as f:
turns = json.load(f)
except Exception as e:
print(f"[replay_interrupted_turns] Failed to read {INTERRUPTED_FILE}: {e}", file=sys.stderr)
sys.exit(0)
if not turns:
sys.exit(0)
print(f"[replay_interrupted_turns] Found {len(turns)} interrupted turn(s). Waiting for gateway /healthz...")
gateway_ready = False
for _ in range(60):
try:
with urllib.request.urlopen("http://127.0.0.1:18790/healthz", timeout=3) as resp:
if resp.status == 200:
gateway_ready = True
break
except Exception:
pass
time.sleep(2)
if not gateway_ready:
print("[replay_interrupted_turns] Gateway did not become healthy in 120s. Aborting.", file=sys.stderr)
sys.exit(1)
# Allow 5s for Telegram ingress to bind
time.sleep(5)
for turn in turns:
sk = turn.get("session_key")
sid = turn.get("session_id")
agent_id = turn.get("agent")
ctx = turn.get("context") or {}
prompt = turn.get("prompt") or ""
channel = ctx.get("channel") or "telegram"
to_target = ctx.get("to") or ""
thread_id = ctx.get("threadId")
target = to_target
if target.startswith("telegram:"):
parts = target.split(":")
target = parts[1] if len(parts) > 1 else target
print(f"[replay_interrupted_turns] Processing agent '{agent_id}' on session '{sk}' target='{target}' thread='{thread_id}'")
# 1. Send status notification to Telegram if target exists
if target:
notice = "⚠️ OpenClaw wurde während der Bearbeitung neu gestartet – deine Anfrage wird jetzt nachgeholt..."
notify_cmd = [
"openclaw", "message", "send",
"--channel", channel,
"--target", str(target),
"-m", notice
]
if thread_id is not None:
notify_cmd.extend(["--thread-id", str(thread_id)])
try:
subprocess.run(notify_cmd, check=False, timeout=30)
print(f"[replay_interrupted_turns] Sent recovery notice to {target}")
except Exception as e:
print(f"[replay_interrupted_turns] Notice failed: {e}", file=sys.stderr)
# 2. Replay agent turn
if sk and prompt:
p_short = (prompt[:60] + "...") if len(prompt) > 60 else prompt
print(f"[replay_interrupted_turns] Replaying agent turn: {p_short}")
agent_cmd = [
"openclaw", "agent",
"--session-key", sk,
"-m", prompt,
"--deliver"
]
try:
res = subprocess.run(agent_cmd, capture_output=True, text=True, timeout=300)
if res.returncode == 0:
print(f"[replay_interrupted_turns] Turn replay succeeded for {sk}")
else:
print(f"[replay_interrupted_turns] Turn replay exited with code {res.returncode}: {res.stderr[:200]}", file=sys.stderr)
except Exception as e:
print(f"[replay_interrupted_turns] Replay execution error: {e}", file=sys.stderr)
try:
os.remove(INTERRUPTED_FILE)
print(f"[replay_interrupted_turns] Cleaned up {INTERRUPTED_FILE}")
except Exception:
pass
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
sync_crons.py
Idempotently reconciles declarative OpenClaw automations defined in crons.yaml
with the running OpenClaw gateway state.
Can run directly inside the container or remotely via kubectl exec.
"""
import json
import os
import subprocess
import sys
try:
import yaml
except ImportError:
venv_py = "/home/node/.openclaw/mcp-venv/bin/python3"
if os.path.exists(venv_py) and sys.executable != venv_py:
os.execv(venv_py, [venv_py] + sys.argv)
raise
def find_crons_file():
candidates = [
os.environ.get("CRONS_FILE"),
"crons.yaml",
"k8s/openclaw/crons.yaml",
"/home/node/.openclaw/scripts/crons.yaml",
os.path.join(os.path.dirname(__file__), "../crons.yaml"),
os.path.join(os.path.dirname(__file__), "crons.yaml"),
]
for c in candidates:
if c and os.path.exists(c):
return os.path.abspath(c)
raise FileNotFoundError("Could not find crons.yaml. Pass via CRONS_FILE environment variable.")
def is_inside_container():
return os.path.exists("/home/node/.openclaw") or os.environ.get("OPENCLAW_HOME") is not None
def run_openclaw_cmd(args):
if is_inside_container():
cmd = ["openclaw"] + args
else:
namespace = os.environ.get("NAMESPACE", "openclaw")
pod = os.environ.get("POD_NAME", "openclaw-0")
container = os.environ.get("CONTAINER_NAME", "openclaw")
cmd = ["kubectl", "exec", "-n", namespace, pod, "-c", container, "--", "openclaw"] + args
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode != 0:
raise RuntimeError(f"OpenClaw CLI command failed ({' '.join(cmd)}):\n{res.stderr}\n{res.stdout}")
return res.stdout
def list_existing_jobs():
raw = run_openclaw_cmd(["cron", "list", "--json"])
try:
data = json.loads(raw)
return data.get("jobs", [])
except Exception as e:
print(f"Warning: could not parse cron list json: {e}\nRaw output: {raw[:300]}", file=sys.stderr)
return []
def reconcile_jobs():
file_path = find_crons_file()
print(f"=== Syncing OpenClaw Crons-as-Code from {file_path} ===")
with open(file_path, "r", encoding="utf-8") as f:
spec = yaml.safe_load(f)
declarations = spec.get("jobs", [])
existing_jobs = list_existing_jobs()
print(f"Found {len(declarations)} declared jobs in YAML, {len(existing_jobs)} active jobs on gateway.")
for job in declarations:
name = job.get("name")
decl_key = job.get("declarationKey") or name
desc = job.get("description", "")
sched = job.get("schedule", {})
# Match existing job by declarationKey or exact name
match = next((j for j in existing_jobs if j.get("declarationKey") == decl_key or j.get("name") == name), None)
if match:
existing_id = match.get("id")
print(f"Removing existing job '{name}' (ID {existing_id}) for idempotent recreation...")
try:
run_openclaw_cmd(["cron", "rm", existing_id])
except Exception as e:
print(f"Warning removing {existing_id}: {e}")
# Build add command
cmd = [
"cron", "add",
"--name", name,
"--declaration-key", decl_key,
"--description", desc,
]
if sched.get("kind") == "every":
cmd.extend(["--every", sched.get("every", "15m")])
elif sched.get("kind") == "cron":
cmd.extend(["--cron", sched.get("expr", "* * * * *")])
if sched.get("tz"):
cmd.extend(["--tz", sched.get("tz")])
if job.get("session"):
cmd.extend(["--session", job.get("session")])
if job.get("delivery") == "none":
cmd.append("--no-deliver")
if job.get("command"):
cmd.extend(["--command", job.get("command")])
if job.get("agent"):
cmd.extend(["--agent", job.get("agent")])
if job.get("model"):
cmd.extend(["--model", job.get("model")])
if job.get("channel"):
cmd.extend(["--channel", job.get("channel")])
if job.get("to"):
cmd.extend(["--to", job.get("to")])
if job.get("threadId"):
cmd.extend(["--thread-id", str(job.get("threadId"))])
if job.get("announce"):
cmd.append("--announce")
if job.get("message"):
cmd.extend(["--message", job.get("message")])
print(f"Registering job '{name}'...")
run_openclaw_cmd(cmd)
print(f"Successfully registered '{name}'.")
print("=== OpenClaw Crons-as-Code reconciliation complete! ===")
if __name__ == "__main__":
reconcile_jobs()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# ==============================================================================
# setup-compaction-cron.sh
# 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
NAMESPACE="${NAMESPACE:-openclaw}"
POD_NAME="${POD_NAME:-openclaw-0}"
CONTAINER_NAME="${CONTAINER_NAME:-openclaw}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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 "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)
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 "$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 "=== All session maintenance automations successfully registered/updated! ==="
+40
View File
@@ -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
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# ==============================================================================
# setup-garmin-cron.sh
# Declaratively registers the Garmin Connect Nightly Sync in OpenClaw.
# Schedule: Daily at 03:30 Europe/Berlin (cron: 30 3 * * *)
# ==============================================================================
set -euo pipefail
NAMESPACE="${NAMESPACE:-openclaw}"
POD_NAME="${POD_NAME:-openclaw-0}"
CONTAINER_NAME="${CONTAINER_NAME:-openclaw}"
JOB_NAME="garmin-nightly-mirror"
CRON_SCHEDULE="30 3 * * *"
TIMEZONE="Europe/Berlin"
TELEGRAM_GROUP="-1004326921510"
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 --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
echo "Creating automation $JOB_NAME ($CRON_SCHEDULE @ $TIMEZONE)..."
MESSAGE=$(cat << 'EOF'
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.
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-1017" \
--session "isolated" \
--channel "telegram" \
--to "$TELEGRAM_GROUP" \
--thread-id "$TELEGRAM_TOPIC" \
--announce \
--message "$MESSAGE"
echo "=== Automation successfully registered! ==="
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list | grep "$JOB_NAME" || true
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# ==============================================================================
# setup-itguy-cron.sh
# Declaratively registers the IT Guy weekly health audit in OpenClaw.
# Schedule: Every Sunday at 03:00 Europe/Berlin (cron: 0 3 * * 0)
# ==============================================================================
set -euo pipefail
NAMESPACE="${NAMESPACE:-openclaw}"
POD_NAME="${POD_NAME:-openclaw-0}"
CONTAINER_NAME="${CONTAINER_NAME:-openclaw}"
JOB_NAME="itguy-weekly-health-audit"
CRON_SCHEDULE="0 3 * * 0"
TIMEZONE="Europe/Berlin"
TELEGRAM_GROUP="-1004326921510"
TELEGRAM_TOPIC="119"
echo "=== Registering IT Guy Weekly Health Audit 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)
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
echo "Creating automation $JOB_NAME ($CRON_SCHEDULE @ $TIMEZONE)..."
MESSAGE=$(cat << 'EOF'
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).
EOF
)
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron add \
--name "$JOB_NAME" \
--description "Wöchentlicher Self-Audit der OpenClaw-Logs, Tool-Failures und Session-Transkripte mit Git-Commit-Korrelation" \
--cron "$CRON_SCHEDULE" \
--tz "$TIMEZONE" \
--agent "itguy" \
--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! ==="
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron list | grep "$JOB_NAME" || true
Binary file not shown.
+16 -13
View File
@@ -41,25 +41,28 @@ spec:
selector:
app: moritzgrafde
---
apiVersion: extensions/v1beta1
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: moritzgrafde
namespace: web
annotations:
kubernetes.io/ingress.class: "nginx"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- "moritzgraf.de"
secretName: moritzgraf-de
- hosts:
- "moritzgraf.de"
secretName: moritzgraf-de
rules:
- host: moritzgraf.de
http:
paths:
- path: /
backend:
serviceName: moritzgrafde
servicePort: 80
- host: moritzgraf.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: moritzgrafde
port:
number: 80
+74
View File
@@ -0,0 +1,74 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sasch40
labels:
app: sasch40
namespace: web
spec:
selector:
matchLabels:
app: sasch40
replicas: 1
template:
metadata:
labels:
app: sasch40
spec:
imagePullSecrets:
- name: "registry-haumdaucher-de"
containers:
- image: registry.haumdaucher.de/sasch40:latest
imagePullPolicy: Always
name: sasch40
ports:
- containerPort: 80
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: sasch40
namespace: web
spec:
ports:
- port: 80
targetPort: 80
protocol: TCP
selector:
app: sasch40
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sasch40
namespace: web
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- "sasch40.haumdaucher.de"
secretName: sasch40-haumdaucher-de-tls
rules:
- host: sasch40.haumdaucher.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sasch40
port:
number: 80
+13
View File
@@ -0,0 +1,13 @@
# AGENTS.md — DuckDB Backend
## Purpose
FastAPI HTTP backend executing in-memory DuckDB queries against Git-backed JSONL data in the Knowledge Wiki.
## Development & Testing
```bash
# Run tests
uv run --project projects/duckdb-backend pytest -v projects/duckdb-backend/tests
# Run locally
uv run --project projects/duckdb-backend uvicorn app.main:app --port 8000
```
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.12-slim
# Install system dependencies (git for wiki sync)
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install uv for fast dependency resolution
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
# Install dependencies
COPY pyproject.toml .
RUN uv pip install --system --no-cache -r pyproject.toml
# Copy application source
COPY app/ ./app/
ENV PYTHONUNBUFFERED=1
ENV PORT=8000
ENV HOST=0.0.0.0
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+26
View File
@@ -0,0 +1,26 @@
# DuckDB Backend
HTTP-based analytical and transactional backend for personal logging data stored in the Knowledge Wiki.
Powered by Python 3.12, FastAPI, and in-memory DuckDB querying JSONL files directly.
## Architecture
- **Single Source of Truth**: Knowledge Wiki (`/wiki` git repository with data in `/wiki/data/**/*.jsonl`).
- **Engine**: In-memory DuckDB reading `sleep`, `nutrition`, `mila`, `entities`, and `garmin` partitions.
- **Git Synchronization**:
- Background periodic `git pull --rebase origin main` (every 60s).
- Mutating operations (`POST /duckdb-api/v1/events`) append to typed JSONL partition files, sort them, commit, rebase, and push to origin main.
- Conflicts trigger automatic rebase abort and flag `git_conflict: true` on `/status` and `/healthz`.
## Endpoints
- `GET /duckdb-api/v1/mila/dashboard`: Curated aggregated dashboard for Mila (sleep, wake timer, feedings, diapers, WHO percentiles).
- `GET /duckdb-api/v1/moritz/dashboard`: Curated aggregated dashboard for Moritz (today's intake, yesterday's 24h deficit/surplus, Garmin recovery & sleep, 7d/30d trends).
- `POST /duckdb-api/v1/query`: Generic read-only SQL queries against DuckDB tables and views.
- `POST /duckdb-api/v1/events`: Record new events (sleep, nutrition, diaper, feeding, growth).
- `POST /duckdb-api/v1/events/stop`: Close active open sleep session or interval.
- `DELETE /duckdb-api/v1/events/{id}`: Soft-delete event.
- `POST /duckdb-api/v1/entities`: Upsert entities (`foods`, `recipes`, `places`, `people`).
- `GET /duckdb-api/v1/healthz`: Liveness and readiness probe.
- `GET /duckdb-api/v1/status`: Git synchronization status and table row counts.
- `POST /duckdb-api/v1/git/sync`: Manual pull or webhook sync trigger.
+1
View File
@@ -0,0 +1 @@
# app package
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
from pathlib import Path
def find_default_wiki_dir() -> Path:
env_wiki = os.environ.get("WIKI_DIR") or os.environ.get("GIT_REPO_DIR")
if env_wiki:
return Path(env_wiki).resolve()
candidates = [
Path("/wiki"),
Path.cwd() / "knowledge-wiki",
Path.cwd().parent.parent / "knowledge-wiki",
Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"),
Path.home() / "src" / "infrapuzzle" / "knowledge-wiki",
Path("/home/node/.openclaw/wiki/main"),
]
for c in candidates:
if c.is_dir() and (c / "data").is_dir():
return c.resolve()
return (Path.cwd() / "knowledge-wiki").resolve()
WIKI_DIR = find_default_wiki_dir()
DATA_DIR = Path(
os.environ.get("DATA_DIR")
or os.environ.get("DUCKDB_DATA_DIR")
or (WIKI_DIR / "data")
).resolve()
GIT_REPO_URL = os.environ.get(
"GIT_REPO_URL",
os.environ.get("GIT_REMOTE_URL", "https://git.moritzgraf.de/moritz/knowledge-wiki.git"),
)
GIT_USERNAME = os.environ.get("GIT_SYNC_USERNAME", "")
GIT_PASSWORD = os.environ.get("GIT_SYNC_PASSWORD", os.environ.get("WIKI_SYNC_TOKEN", ""))
GIT_BRANCH = os.environ.get("GIT_BRANCH", "main")
GIT_SYNC_INTERVAL_SECONDS = int(os.environ.get("GIT_SYNC_INTERVAL_SECONDS", "60"))
AUTO_GIT_SYNC = os.environ.get("AUTO_GIT_SYNC", "true").lower() in ("1", "true", "yes")
GIT_AUTHOR_NAME = os.environ.get("GIT_AUTHOR_NAME", "DuckDB Backend")
GIT_AUTHOR_EMAIL = os.environ.get("GIT_AUTHOR_EMAIL", "duckdb-backend@haumdaucher.de")
PORT = int(os.environ.get("PORT", "8000"))
HOST = os.environ.get("HOST", "0.0.0.0")
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
from __future__ import annotations
import asyncio
import datetime
import logging
import os
import subprocess
import threading
import urllib.parse
from pathlib import Path
from typing import Callable, Optional
from app.config import (
AUTO_GIT_SYNC,
GIT_AUTHOR_EMAIL,
GIT_AUTHOR_NAME,
GIT_BRANCH,
GIT_PASSWORD,
GIT_REPO_URL,
GIT_SYNC_INTERVAL_SECONDS,
GIT_USERNAME,
WIKI_DIR,
)
logger = logging.getLogger("duckdb_backend.git_sync")
class GitSyncManager:
def __init__(self, wiki_dir: Optional[Path] = None, on_data_pulled: Optional[Callable[[], None]] = None):
self.wiki_dir = wiki_dir or WIKI_DIR
self.on_data_pulled = on_data_pulled
self._lock = threading.Lock()
self.status = "ok"
self.is_conflict = False
self.last_sync_time: Optional[str] = None
self.last_commit_hash: Optional[str] = None
self.last_error: Optional[str] = None
self._bg_task: Optional[asyncio.Task] = None
self._stop_event = asyncio.Event()
def _configure_git_client(self):
"""Configures git client with safe.directory and author identity."""
try:
subprocess.run(["git", "config", "--global", "http.sslVerify", "false"], check=False)
subprocess.run(["git", "config", "--global", "--add", "safe.directory", "*"], check=False)
if (self.wiki_dir / ".git").exists():
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.name", GIT_AUTHOR_NAME], check=False)
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.email", GIT_AUTHOR_EMAIL], check=False)
except Exception as e:
logger.warning(f"Failed to configure git client options: {e}")
def get_auth_repo_url(self) -> str:
if GIT_USERNAME and GIT_PASSWORD and "://" in GIT_REPO_URL:
proto, rest = GIT_REPO_URL.split("://", 1)
# Remove existing credentials in URL if any
if "@" in rest:
rest = rest.split("@", 1)[1]
user_enc = urllib.parse.quote(GIT_USERNAME, safe="")
pass_enc = urllib.parse.quote(GIT_PASSWORD, safe="")
return f"{proto}://{user_enc}:{pass_enc}@{rest}"
return GIT_REPO_URL
def ensure_repo(self) -> bool:
"""Ensures repository exists and is initialized."""
with self._lock:
self._configure_git_client()
if (self.wiki_dir / ".git").exists():
try:
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
self.last_commit_hash = res.stdout.strip()
return True
except Exception as e:
logger.warning(f"Failed to check git rev: {e}")
return True
if not GIT_REPO_URL:
logger.info(f"Wiki directory {self.wiki_dir} is not a git repo, but no GIT_REPO_URL configured.")
return False
logger.info(f"Initializing repository {GIT_REPO_URL} in {self.wiki_dir}...")
self.wiki_dir.mkdir(parents=True, exist_ok=True)
auth_url = self.get_auth_repo_url()
try:
# Use git init + remote + fetch + checkout to safely support non-empty directories (e.g. lost+found on PVC)
subprocess.run(["git", "-C", str(self.wiki_dir), "init"], capture_output=True, check=False)
self._configure_git_client()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "add", "origin", auth_url],
capture_output=True,
check=False,
)
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "fetch", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=60,
)
if res.returncode == 0:
if (self.wiki_dir / ".git" / "shallow").exists():
subprocess.run(
["git", "-C", str(self.wiki_dir), "fetch", "--unshallow", "origin", GIT_BRANCH],
capture_output=True,
check=False,
)
checkout_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "checkout", "-f", "-B", GIT_BRANCH, f"origin/{GIT_BRANCH}"],
capture_output=True,
text=True,
check=False,
)
if checkout_res.returncode == 0:
logger.info("Successfully initialized and checked out wiki repository.")
self.status = "ok"
self.is_conflict = False
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
return True
else:
self.status = "error"
self.last_error = f"Checkout failed: {checkout_res.stderr.strip()}"
logger.error(self.last_error)
return False
else:
self.status = "error"
self.last_error = f"Fetch failed: {res.stderr.strip()}"
logger.error(self.last_error)
return False
except Exception as e:
self.status = "error"
self.last_error = f"Clone exception: {e}"
logger.error(self.last_error)
return False
def pull_rebase(self) -> bool:
"""Pulls latest changes via rebase. Handles conflicts cleanly."""
if not (self.wiki_dir / ".git").exists():
return False
with self._lock:
try:
# Update remote url with credentials if provided
auth_url = self.get_auth_repo_url()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
# Fetch and rebase with autostash
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "pull", "--rebase", "--autostash", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if res.returncode == 0:
self.status = "ok"
self.is_conflict = False
self.last_error = None
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
# Update commit hash
rev = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if rev.returncode == 0:
self.last_commit_hash = rev.stdout.strip()
if self.on_data_pulled:
try:
self.on_data_pulled()
except Exception as ex:
logger.error(f"Error in on_data_pulled callback: {ex}")
return True
else:
# Check if conflict occurred
stderr = res.stderr or res.stdout
logger.warning(f"Git pull --rebase failed: {stderr}")
# Abort any in-progress rebase to keep working tree stable
subprocess.run(
["git", "-C", str(self.wiki_dir), "rebase", "--abort"],
capture_output=True,
check=False,
)
self.is_conflict = True
self.status = "conflict"
self.last_error = f"Rebase conflict: {stderr.strip()}"
return False
except Exception as e:
self.status = "error"
self.last_error = f"Pull error: {e}"
logger.error(self.last_error)
return False
def commit_and_push(self, commit_msg: str) -> bool:
"""Stages data/, commits with descriptive message, rebases and pushes."""
if not (self.wiki_dir / ".git").exists():
return False
with self._lock:
try:
self._configure_git_client()
data_rel = "data"
subprocess.run(
["git", "-C", str(self.wiki_dir), "add", data_rel],
capture_output=True,
check=False,
)
commit_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "commit", "-m", f"{commit_msg} [skip ci]"],
capture_output=True,
text=True,
check=False,
)
if commit_res.returncode != 0:
# Nothing to commit or already committed
logger.debug(f"Commit returncode {commit_res.returncode}: {commit_res.stdout.strip()}")
return True
# Pull rebase with autostash before push
auth_url = self.get_auth_repo_url()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
rebase_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "pull", "--rebase", "--autostash", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if rebase_res.returncode != 0:
stderr = rebase_res.stderr or rebase_res.stdout
logger.warning(f"Rebase failed during commit_and_push: {stderr}")
subprocess.run(
["git", "-C", str(self.wiki_dir), "rebase", "--abort"],
capture_output=True,
check=False,
)
self.is_conflict = True
self.status = "conflict"
self.last_error = f"Rebase conflict on push: {stderr.strip()}"
return False
# Push to remote
push_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "push", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if push_res.returncode == 0:
self.status = "ok"
self.is_conflict = False
self.last_error = None
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
rev = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if rev.returncode == 0:
self.last_commit_hash = rev.stdout.strip()
return True
else:
self.status = "error"
self.last_error = f"Push error: {push_res.stderr.strip()}"
logger.error(self.last_error)
return False
except Exception as e:
self.status = "error"
self.last_error = f"Commit/push error: {e}"
logger.error(self.last_error)
return False
async def start_periodic_sync(self):
"""Starts background periodic git pull loop."""
if not AUTO_GIT_SYNC:
logger.info("Automatic git sync is disabled by configuration.")
return
self._stop_event.clear()
async def _loop():
logger.info(f"Starting periodic git sync every {GIT_SYNC_INTERVAL_SECONDS}s")
while not self._stop_event.is_set():
try:
await asyncio.to_thread(self.pull_rebase)
except Exception as ex:
logger.error(f"Error during periodic git pull: {ex}")
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=GIT_SYNC_INTERVAL_SECONDS)
except asyncio.TimeoutError:
pass
self._bg_task = asyncio.create_task(_loop())
async def stop_periodic_sync(self):
"""Stops background sync loop."""
self._stop_event.set()
if self._bg_task:
self._bg_task.cancel()
try:
await self._bg_task
except asyncio.CancelledError:
pass
self._bg_task = None
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import DATA_DIR, WIKI_DIR
from app.engine import DuckDBEngine
from app.git_sync import GitSyncManager
from app.routes.dashboard import router as dashboard_router
from app.routes.entities import router as entities_router
from app.routes.events import router as events_router
from app.routes.query import router as query_router
from app.routes.system import router as system_router
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("duckdb_backend")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Initializing DuckDB Backend...")
if getattr(app.state, "git_sync", None) is None or getattr(app.state, "engine", None) is None:
git_sync = GitSyncManager(
wiki_dir=WIKI_DIR,
on_data_pulled=lambda: app.state.engine.reload_all() if hasattr(app.state, "engine") else None,
)
git_sync.ensure_repo()
app.state.git_sync = git_sync
engine = DuckDBEngine(data_dir=DATA_DIR, git_sync_manager=git_sync)
app.state.engine = engine
await git_sync.start_periodic_sync()
logger.info("DuckDB Backend is ready to serve queries.")
try:
yield
finally:
logger.info("Shutting down DuckDB Backend...")
await git_sync.stop_periodic_sync()
else:
yield
app = FastAPI(
title="DuckDB Backend for Knowledge Wiki",
version="1.0.0",
description="HTTP-based analytical and transactional backend for personal logging data powered by DuckDB",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API v1 prefix
v1_router = FastAPI()
app.include_router(dashboard_router, prefix="/duckdb-api/v1")
app.include_router(query_router, prefix="/duckdb-api/v1")
app.include_router(events_router, prefix="/duckdb-api/v1")
app.include_router(entities_router, prefix="/duckdb-api/v1")
app.include_router(system_router, prefix="/duckdb-api/v1")
# Direct root health probes for Kubernetes
app.include_router(system_router)
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, model_validator
class QueryRequest(BaseModel):
query: str = Field(..., description="Read-only SQL query to execute in DuckDB")
params: Optional[List[Any]] = Field(default=None, description="Positional query parameters")
class QueryResponse(BaseModel):
columns: List[str]
rows: List[Dict[str, Any]]
count: int
execution_time_ms: float
class EventCreateRequest(BaseModel):
kind: str = Field(..., description="Event kind, e.g. sleep.session, nutrition.intake, diaper.change, feeding.bottle, growth.measurement")
subject: str = Field(..., description="Event subject, e.g. mila or moritz")
payload: Dict[str, Any] = Field(default_factory=dict, description="Event-specific payload")
actor: Optional[str] = Field(default="web-app", description="Actor who logged the event")
date: Optional[str] = Field(default=None, description="Explicit log date override YYYY-MM-DD")
start: Optional[str] = Field(default=None, description="ISO-8601 start timestamp")
end: Optional[str] = Field(default=None, description="ISO-8601 end timestamp (optional)")
since: Optional[str] = Field(default=None, description="Relative start duration like '15m', '1h'")
estimated: bool = Field(default=False, description="Whether intake is estimated")
meal: Optional[str] = Field(default=None, description="Meal category, e.g. fruehstueck, mittagessen, abendessen, snack")
note: Optional[str] = Field(default=None, description="Free-text note")
source_ref: Optional[str] = Field(default=None, description="Source reference")
allow_duplicate: bool = Field(default=False, description="Allow event within 15min of duplicate")
class EventStopRequest(BaseModel):
kind: str = Field(default="sleep.session", description="Event kind to stop")
subject: str = Field(..., description="Event subject, e.g. mila")
end_time: Optional[str] = Field(default=None, description="ISO-8601 end timestamp")
since: Optional[str] = Field(default=None, description="Relative end duration like '5m'")
event_id: Optional[str] = Field(default=None, description="Specific event ID to close")
actor: Optional[str] = Field(default="web-app", description="Actor who stopped the event")
class EntityPutRequest(BaseModel):
collection: str = Field(..., description="Target collection: foods, recipes, places, people")
record: Dict[str, Any] = Field(..., description="Entity record with 'id'")
class GarminSyncRequest(BaseModel):
daily: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin daily summary records")
sleep: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin sleep records")
activities: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin activity records")
daily_summary: Optional[Dict[str, Any]] = None
sleep_data: Optional[Dict[str, Any]] = None
@model_validator(mode="before")
@classmethod
def normalize_aliases(cls, data: Any) -> Any:
if isinstance(data, dict):
# Normalize daily_summary dict to daily list if daily is missing/empty
if "daily_summary" in data and data["daily_summary"] and not data.get("daily"):
data["daily"] = [data["daily_summary"]]
# Normalize sleep_data dict to sleep list if sleep is missing/empty
if "sleep_data" in data and data["sleep_data"] and not data.get("sleep"):
data["sleep"] = [data["sleep_data"]]
return data
class StatusResponse(BaseModel):
status: str
git_conflict: bool
last_sync_time: Optional[str]
last_commit_hash: Optional[str]
last_error: Optional[str]
data_dir: str
table_counts: Dict[str, int]
@@ -0,0 +1 @@
# routes package
@@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.engine import DuckDBEngine
router = APIRouter(tags=["Dashboards"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.get("/mila/dashboard")
async def get_mila_dashboard(
as_of: Optional[str] = Query(default=None, description="Optional target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_mila_dashboard(as_of_date=as_of)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/moritz/dashboard")
async def get_moritz_dashboard(
as_of: Optional[str] = Query(default=None, description="Optional target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_moritz_dashboard(as_of_date=as_of)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/mila/day/{date}")
async def get_mila_day_detail(
date: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_mila_day_detail(target_date=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/moritz/day/{date}")
async def get_moritz_day_detail(
date: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_moritz_day_detail(target_date=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/summary/{subject}")
async def get_summary(
subject: str,
date: Optional[str] = Query(default=None, description="Target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_summary(subject=subject, date_str=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,51 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.engine import DuckDBEngine
from app.models import EntityPutRequest
router = APIRouter(tags=["Entities"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/entities")
async def put_entity(
req: EntityPutRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.put_entity(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/entities/{collection}")
async def list_entities(
collection: str,
engine: DuckDBEngine = Depends(get_engine),
) -> List[Dict[str, Any]]:
try:
return engine.list_entities(collection=collection)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/places/near")
async def find_places_near(
lat: float = Query(..., description="Latitude"),
lon: float = Query(..., description="Longitude"),
radius_m: float = Query(default=1000.0, description="Radius in meters"),
category: Optional[str] = Query(default=None, description="Category filter"),
engine: DuckDBEngine = Depends(get_engine),
) -> List[Dict[str, Any]]:
try:
return engine.find_places_near(lat=lat, lon=lon, radius_m=radius_m, category=category)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Request
from app.engine import DuckDBEngine
from app.models import EventCreateRequest, EventStopRequest, GarminSyncRequest
router = APIRouter(tags=["Events"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/events")
async def add_event(
req: EventCreateRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.add_event(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/events/stop")
async def stop_event(
req: EventStopRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.stop_event(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/events/{event_id}")
async def delete_event(
event_id: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.delete_event(event_id)
except ValueError as ve:
raise HTTPException(status_code=404, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/garmin/sync")
async def sync_garmin(
req: GarminSyncRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.sync_garmin(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,35 @@
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from app.engine import DuckDBEngine
from app.models import QueryRequest, QueryResponse
router = APIRouter(tags=["Generic Queries"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/query", response_model=QueryResponse)
async def execute_query(
req: QueryRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> QueryResponse:
t0 = time.perf_counter()
try:
rows = engine.query(req.query, req.params)
duration_ms = round((time.perf_counter() - t0) * 1000, 2)
cols = list(rows[0].keys()) if rows else []
return QueryResponse(
columns=cols,
rows=rows,
count=len(rows),
execution_time_ms=duration_ms,
)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,77 @@
from __future__ import annotations
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Request
from app.config import DATA_DIR
from app.engine import DuckDBEngine
from app.git_sync import GitSyncManager
from app.models import StatusResponse
router = APIRouter(tags=["System"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
def get_git_sync(request: Request) -> Optional[GitSyncManager]:
return getattr(request.app.state, "git_sync", None)
@router.get("/healthz")
async def healthz(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
conflict = git_sync.is_conflict if git_sync else False
status_str = "conflict" if conflict else "ok"
return {
"status": status_str,
"conflict": conflict,
"engine_ready": True,
}
@router.get("/status", response_model=StatusResponse)
async def status(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> StatusResponse:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
counts = engine.get_table_counts()
return StatusResponse(
status=git_sync.status if git_sync else "ok",
git_conflict=git_sync.is_conflict if git_sync else False,
last_sync_time=git_sync.last_sync_time if git_sync else None,
last_commit_hash=git_sync.last_commit_hash if git_sync else None,
last_error=git_sync.last_error if git_sync else None,
data_dir=str(engine.data_dir),
table_counts=counts,
)
@router.post("/git/sync")
async def trigger_sync(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
if not git_sync:
raise HTTPException(status_code=400, detail="Git sync is not configured")
success = git_sync.pull_rebase()
if success:
engine.reload_all()
return {
"status": "ok",
"message": "Git pull --rebase completed and DuckDB reloaded.",
"commit": git_sync.last_commit_hash,
}
else:
return {
"status": git_sync.status,
"conflict": git_sync.is_conflict,
"error": git_sync.last_error,
}
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import math
from typing import Any, Dict, List, Optional, Tuple
WHO_GIRLS_WEIGHT = [
{"month": 0, "L": 0.3809, "M": 3.2322, "S": 0.14171, "p3": 2.4, "p50": 3.2, "p97": 4.2},
{"month": 1, "L": 0.1714, "M": 4.1873, "S": 0.13724, "p3": 3.2, "p50": 4.2, "p97": 5.4},
{"month": 2, "L": 0.0962, "M": 5.1282, "S": 0.13000, "p3": 4.0, "p50": 5.1, "p97": 6.5},
{"month": 3, "L": 0.0402, "M": 5.8458, "S": 0.12619, "p3": 4.6, "p50": 5.8, "p97": 7.4},
{"month": 4, "L": -0.0050, "M": 6.4237, "S": 0.12402, "p3": 5.1, "p50": 6.4, "p97": 8.1},
{"month": 5, "L": -0.0430, "M": 6.8985, "S": 0.12274, "p3": 5.5, "p50": 6.9, "p97": 8.7},
{"month": 6, "L": -0.0756, "M": 7.2970, "S": 0.12204, "p3": 5.8, "p50": 7.3, "p97": 9.2},
{"month": 7, "L": -0.1039, "M": 7.6422, "S": 0.12178, "p3": 6.1, "p50": 7.6, "p97": 9.6},
{"month": 8, "L": -0.1288, "M": 7.9487, "S": 0.12181, "p3": 6.3, "p50": 7.9, "p97": 10.0},
{"month": 9, "L": -0.1507, "M": 8.2254, "S": 0.12199, "p3": 6.6, "p50": 8.2, "p97": 10.4},
{"month": 10, "L": -0.1700, "M": 8.4800, "S": 0.12223, "p3": 6.8, "p50": 8.5, "p97": 10.7},
{"month": 11, "L": -0.1872, "M": 8.7192, "S": 0.12247, "p3": 7.0, "p50": 8.7, "p97": 11.0},
{"month": 12, "L": -0.2024, "M": 8.9481, "S": 0.12268, "p3": 7.1, "p50": 8.9, "p97": 11.3},
{"month": 13, "L": -0.2158, "M": 9.1699, "S": 0.12283, "p3": 7.3, "p50": 9.2, "p97": 11.6},
{"month": 14, "L": -0.2278, "M": 9.3870, "S": 0.12294, "p3": 7.5, "p50": 9.4, "p97": 11.9},
{"month": 15, "L": -0.2384, "M": 9.6008, "S": 0.12299, "p3": 7.7, "p50": 9.6, "p97": 12.2},
{"month": 16, "L": -0.2478, "M": 9.8124, "S": 0.12303, "p3": 7.8, "p50": 9.8, "p97": 12.5},
{"month": 17, "L": -0.2562, "M": 10.0226, "S": 0.12306, "p3": 8.0, "p50": 10.0, "p97": 12.7},
{"month": 18, "L": -0.2637, "M": 10.2315, "S": 0.12309, "p3": 8.2, "p50": 10.2, "p97": 13.0},
{"month": 19, "L": -0.2703, "M": 10.4393, "S": 0.12315, "p3": 8.3, "p50": 10.4, "p97": 13.3},
{"month": 20, "L": -0.2762, "M": 10.6464, "S": 0.12323, "p3": 8.5, "p50": 10.6, "p97": 13.5},
{"month": 21, "L": -0.2815, "M": 10.8534, "S": 0.12335, "p3": 8.7, "p50": 10.9, "p97": 13.8},
{"month": 22, "L": -0.2862, "M": 11.0608, "S": 0.12350, "p3": 8.8, "p50": 11.1, "p97": 14.1},
{"month": 23, "L": -0.2903, "M": 11.2688, "S": 0.12369, "p3": 9.0, "p50": 11.3, "p97": 14.3},
{"month": 24, "L": -0.2941, "M": 11.4775, "S": 0.12390, "p3": 9.2, "p50": 11.5, "p97": 14.6},
]
WHO_GIRLS_HEIGHT = [
{"month": 0, "L": 1.0, "M": 49.1477, "S": 0.03790, "p3": 45.6, "p50": 49.1, "p97": 52.7},
{"month": 1, "L": 1.0, "M": 53.6872, "S": 0.03640, "p3": 50.0, "p50": 53.7, "p97": 57.4},
{"month": 2, "L": 1.0, "M": 57.0673, "S": 0.03568, "p3": 53.2, "p50": 57.1, "p97": 60.9},
{"month": 3, "L": 1.0, "M": 59.8029, "S": 0.03520, "p3": 55.8, "p50": 59.8, "p97": 63.8},
{"month": 4, "L": 1.0, "M": 62.0899, "S": 0.03486, "p3": 58.0, "p50": 62.1, "p97": 66.2},
{"month": 5, "L": 1.0, "M": 64.0301, "S": 0.03463, "p3": 59.9, "p50": 64.0, "p97": 68.2},
{"month": 6, "L": 1.0, "M": 65.7311, "S": 0.03448, "p3": 61.5, "p50": 65.7, "p97": 70.0},
{"month": 7, "L": 1.0, "M": 67.2873, "S": 0.03441, "p3": 62.9, "p50": 67.3, "p97": 71.6},
{"month": 8, "L": 1.0, "M": 68.7498, "S": 0.03440, "p3": 64.3, "p50": 68.7, "p97": 73.2},
{"month": 9, "L": 1.0, "M": 70.1435, "S": 0.03444, "p3": 65.6, "p50": 70.1, "p97": 74.7},
{"month": 10, "L": 1.0, "M": 71.4818, "S": 0.03452, "p3": 66.8, "p50": 71.5, "p97": 76.1},
{"month": 11, "L": 1.0, "M": 72.7710, "S": 0.03464, "p3": 68.0, "p50": 72.8, "p97": 77.5},
{"month": 12, "L": 1.0, "M": 74.0150, "S": 0.03479, "p3": 69.2, "p50": 74.0, "p97": 78.9},
{"month": 13, "L": 1.0, "M": 75.2176, "S": 0.03496, "p3": 70.3, "p50": 75.2, "p97": 80.2},
{"month": 14, "L": 1.0, "M": 76.3817, "S": 0.03514, "p3": 71.3, "p50": 76.4, "p97": 81.4},
{"month": 15, "L": 1.0, "M": 77.5099, "S": 0.03534, "p3": 72.4, "p50": 77.5, "p97": 82.7},
{"month": 16, "L": 1.0, "M": 78.6055, "S": 0.03555, "p3": 73.3, "p50": 78.6, "p97": 83.9},
{"month": 17, "L": 1.0, "M": 79.6710, "S": 0.03576, "p3": 74.3, "p50": 79.7, "p97": 85.0},
{"month": 18, "L": 1.0, "M": 80.7079, "S": 0.03598, "p3": 75.2, "p50": 80.7, "p97": 86.2},
{"month": 19, "L": 1.0, "M": 81.7182, "S": 0.03620, "p3": 76.2, "p50": 81.7, "p97": 87.3},
{"month": 20, "L": 1.0, "M": 82.7036, "S": 0.03643, "p3": 77.0, "p50": 82.7, "p97": 88.4},
{"month": 21, "L": 1.0, "M": 83.6654, "S": 0.03666, "p3": 77.9, "p50": 83.7, "p97": 89.4},
{"month": 22, "L": 1.0, "M": 84.6040, "S": 0.03688, "p3": 78.7, "p50": 84.6, "p97": 90.5},
{"month": 23, "L": 1.0, "M": 85.5202, "S": 0.03711, "p3": 79.6, "p50": 85.5, "p97": 91.5},
{"month": 24, "L": 1.0, "M": 86.4153, "S": 0.03734, "p3": 80.3, "p50": 86.4, "p97": 92.5},
]
def _interpolate_lms(table: List[Dict[str, float]], age_months: float) -> Tuple[float, float, float, float, float, float]:
m_clamped = max(0.0, min(float(table[-1]["month"]), float(age_months)))
m_low = int(math.floor(m_clamped))
m_high = int(math.ceil(m_clamped))
r_low = table[m_low]
if m_low == m_high:
return r_low["L"], r_low["M"], r_low["S"], r_low["p3"], r_low["p50"], r_low["p97"]
r_high = table[m_high]
frac = m_clamped - m_low
L = r_low["L"] + frac * (r_high["L"] - r_low["L"])
M = r_low["M"] + frac * (r_high["M"] - r_low["M"])
S = r_low["S"] + frac * (r_high["S"] - r_low["S"])
p3 = r_low["p3"] + frac * (r_high["p3"] - r_low["p3"])
p50 = r_low["p50"] + frac * (r_high["p50"] - r_low["p50"])
p97 = r_low["p97"] + frac * (r_high["p97"] - r_low["p97"])
return L, M, S, p3, p50, p97
def calculate_percentile(metric: str, value: float, age_months: float) -> Dict[str, Any]:
table = WHO_GIRLS_WEIGHT if metric == "weight" else WHO_GIRLS_HEIGHT
L, M, S, p3, p50, p97 = _interpolate_lms(table, age_months)
if value <= 0 or M <= 0 or S <= 0:
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": 0.0,
"percentile": 50.0,
"percentile_str": "P50",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": True,
"status": "normal",
}
if abs(L) > 1e-6:
z = ((value / M) ** L - 1.0) / (L * S)
else:
z = math.log(value / M) / S
p = 0.5 * (1.0 + math.erf(z / math.sqrt(2.0))) * 100.0
p_clamped = max(0.1, min(99.9, p))
in_normal = 3.0 <= p_clamped <= 97.0
if p_clamped < 3.0:
status = "unter P3"
elif p_clamped > 97.0:
status = "über P97"
else:
status = "im WHO-Normbereich"
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": round(z, 2),
"percentile": round(p_clamped, 1),
"percentile_str": f"P{int(round(p_clamped))}",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": in_normal,
"status": status,
}
+28
View File
@@ -0,0 +1,28 @@
[project]
name = "duckdb-backend"
version = "1.0.0"
description = "FastAPI HTTP DuckDB Backend for Knowledge Wiki Personal Data"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"duckdb>=1.1.0",
"python-ulid>=3.0.0",
"pytz>=2024.1",
"pydantic>=2.8.0",
"httpx>=0.27.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
+36
View File
@@ -0,0 +1,36 @@
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import DATA_DIR
from app.engine import DuckDBEngine
from app.git_sync import GitSyncManager
from app.main import app
@pytest.fixture(scope="session")
def test_data_dir():
temp_root = Path(tempfile.mkdtemp())
temp_data = temp_root / "data"
if DATA_DIR.is_dir():
shutil.copytree(DATA_DIR, temp_data)
else:
temp_data.mkdir(parents=True, exist_ok=True)
yield temp_data
shutil.rmtree(temp_root, ignore_errors=True)
@pytest.fixture
def engine(test_data_dir):
return DuckDBEngine(data_dir=test_data_dir)
@pytest.fixture
def client(engine):
app.state.engine = engine
app.state.git_sync = GitSyncManager(wiki_dir=engine.data_dir.parent)
with TestClient(app) as c:
yield c
@@ -0,0 +1,205 @@
import pytest
from fastapi.testclient import TestClient
def test_mila_dashboard_endpoint(client: TestClient):
resp = client.get("/duckdb-api/v1/mila/dashboard")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "mila"
assert "live_status" in data
assert "today" in data
assert "growth" in data
def test_moritz_dashboard_endpoint(client: TestClient):
resp = client.get("/duckdb-api/v1/moritz/dashboard")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "moritz"
assert "today" in data
assert "yesterday" in data
assert "seven_days" in data
assert "thirty_days" in data
def test_healthz_and_status_endpoints(client: TestClient):
resp_h = client.get("/healthz")
assert resp_h.status_code == 200
assert resp_h.json()["engine_ready"] is True
resp_s = client.get("/duckdb-api/v1/status")
assert resp_s.status_code == 200
data_s = resp_s.json()
assert "status" in data_s
assert "table_counts" in data_s
assert data_s["table_counts"]["sleep"] > 0
def test_mila_history_includes_diaper_only_days(client: TestClient):
# Log a diaper change for a specific day without sleep
client.post("/duckdb-api/v1/events", json={
"kind": "diaper.change",
"subject": "mila",
"start": "2026-10-12T12:00:00+02:00",
"payload": {"pee": True, "poo": True},
})
resp = client.get("/duckdb-api/v1/mila/dashboard")
assert resp.status_code == 200
data = resp.json()
history = data["history"]
dates = [h["date"] for h in history]
assert "2026-10-12" in dates
def test_moritz_seven_days_includes_garmin_days(client: TestClient):
resp = client.get("/duckdb-api/v1/moritz/dashboard")
assert resp.status_code == 200
data = resp.json()
days = data["seven_days"]["days"]
assert len(days) > 0
# Every returned day has a valid date and steps or expenditure
for d in days:
assert d["date"] is not None
assert "expenditure_kcal" in d
def test_mila_dashboard_parent_cockpit_and_norms(client: TestClient):
resp = client.get("/duckdb-api/v1/mila/dashboard")
assert resp.status_code == 200
data = resp.json()
# Parent cockpit live fields
live = data["live_status"]
assert "is_sleeping" in live
assert "active_sleep_duration_str" in live
assert "wake_window_minutes" in live
assert "last_feed_since_str" in live
assert "last_diaper_since_str" in live
assert "last_stool_str" in live
# Science age norms
assert "age_norms" in data
norms = data["age_norms"]
assert norms["age_months"] >= 6.0
assert norms["wake_window_min_minutes"] == 135
assert norms["wake_window_max_minutes"] == 180
assert norms["feed_interval_min_minutes"] == 180
# Ensure IDs on items
today = data["today"]
for f in today["feedings"]:
assert "id" in f
for d in today["diapers"]:
assert "id" in d
def test_day_detail_and_summary_endpoints(client: TestClient):
# Test mila day detail
resp_mila = client.get("/duckdb-api/v1/mila/day/2026-10-12")
assert resp_mila.status_code == 200
assert resp_mila.json()["metadata"]["today_date"] == "2026-10-12"
# Test moritz day detail
resp_moritz = client.get("/duckdb-api/v1/moritz/day/2026-10-12")
assert resp_moritz.status_code == 200
assert "today" in resp_moritz.json()
# Test summary
resp_sum = client.get("/duckdb-api/v1/summary/mila?date=2026-10-12")
assert resp_sum.status_code == 200
sum_data = resp_sum.json()
assert sum_data["subject"] == "mila"
assert sum_data["date"] == "2026-10-12"
assert "nutrition" in sum_data
def test_entities_and_places_endpoints(client: TestClient):
# Put and list entity
client.post("/duckdb-api/v1/entities", json={
"collection": "places",
"record": {
"id": "test-biergarten",
"name": "Test Biergarten",
"category": "biergarten",
"lat": 49.02,
"lon": 12.09,
}
})
resp_places = client.get("/duckdb-api/v1/entities/places")
assert resp_places.status_code == 200
places = resp_places.json()
assert any(p["id"] == "test-biergarten" for p in places)
# Places near search
resp_near = client.get("/duckdb-api/v1/places/near?lat=49.02&lon=12.09&radius_m=500")
assert resp_near.status_code == 200
near = resp_near.json()
assert len(near) > 0
assert near[0]["id"] == "test-biergarten"
def test_garmin_sync_and_duplicate_guard(client: TestClient):
# Duplicate guard test
post_data = {
"kind": "feeding.bottle",
"subject": "mila",
"start": "2026-10-12T10:00:00+02:00",
"payload": {"consumed_ml": 180, "milk_type": "pre"},
}
r1 = client.post("/duckdb-api/v1/events", json=post_data)
assert r1.status_code == 200
assert r1.json()["status"] == "success"
assert "feedback" in r1.json()
# Second post within 15min should be duplicate_skipped
r2 = client.post("/duckdb-api/v1/events", json=post_data)
assert r2.status_code == 200
assert r2.json()["status"] == "duplicate_skipped"
# Garmin sync test
garmin_payload = {
"daily": [{
"calendarDate": "2026-10-15",
"totalKilocalories": 2400,
"activeKilocalories": 600,
"bmrKilocalories": 1800,
"totalSteps": 11000,
"restingHeartRate": 52,
}],
"sleep": [],
"activities": [],
}
r_sync = client.post("/duckdb-api/v1/garmin/sync", json=garmin_payload)
assert r_sync.status_code == 200
assert r_sync.json()["status"] == "success"
assert r_sync.json()["synced_daily"] == 1
# Test alias support (daily_summary and sleep_data dicts)
garmin_alias_payload = {
"date": "2026-10-16",
"daily_summary": {
"calendarDate": "2026-10-16",
"totalKilocalories": 2550,
"activeKilocalories": 650,
"bmrKilocalories": 1900,
"totalSteps": 12000,
"restingHeartRate": 50,
},
"sleep_data": {
"dailySleepDTO": {
"calendarDate": "2026-10-16",
"sleepTimeSeconds": 28800,
}
},
"activities": [],
}
r_alias = client.post("/duckdb-api/v1/garmin/sync", json=garmin_alias_payload)
assert r_alias.status_code == 200
assert r_alias.json()["status"] == "success"
assert r_alias.json()["synced_daily"] == 1
assert r_alias.json()["synced_sleep"] == 1
@@ -0,0 +1,176 @@
import tempfile
import pytest
from pathlib import Path
from fastapi.testclient import TestClient
from app.engine import DuckDBEngine
from app.models import EventCreateRequest, EventStopRequest
def test_add_and_stop_sleep_event(tmp_path: Path):
# Use isolated temp directory for write tests so we do not pollute wiki git repo
engine = DuckDBEngine(data_dir=tmp_path)
# 1. Add active sleep event (end=None)
add_req = EventCreateRequest(
kind="sleep.session",
subject="mila",
payload={"type": "nap", "location": "kinderwagen"},
actor="test-runner",
note="Mittagsschlaf Test",
)
res_add = engine.add_event(add_req)
assert res_add["status"] == "success"
event_id = res_add["id"]
assert event_id is not None
# Check that it appears in open sleep
open_rows = engine.query(f"SELECT * FROM sleep WHERE id = '{event_id}' AND (isOpen = true OR endIso IS NULL)")
assert len(open_rows) == 1
# 2. Stop the active sleep event
stop_req = EventStopRequest(
kind="sleep.session",
subject="mila",
event_id=event_id,
actor="test-runner",
)
res_stop = engine.stop_event(stop_req)
assert res_stop["status"] == "success"
assert res_stop["id"] == event_id
# Check that it is now closed
closed_rows = engine.query(f"SELECT isOpen, endIso FROM sleep WHERE id = '{event_id}'")
assert len(closed_rows) == 1
assert closed_rows[0]["isOpen"] is False
assert closed_rows[0]["endIso"] is not None
def test_add_nutrition_and_diaper_event(tmp_path: Path):
engine = DuckDBEngine(data_dir=tmp_path)
# Add nutrition event
nut_req = EventCreateRequest(
kind="nutrition.intake",
subject="moritz",
payload={"item": "Apfel", "qty": 150.0, "kcal": 78.0, "protein_g": 0.5},
meal="snack",
actor="test-runner",
)
res_nut = engine.add_event(nut_req)
assert res_nut["status"] == "success"
nut_id = res_nut["id"]
nut_rows = engine.query(f"SELECT * FROM nutrition WHERE id = '{nut_id}'")
assert len(nut_rows) == 1
# Add diaper event
diaper_req = EventCreateRequest(
kind="diaper.change",
subject="mila",
payload={"pee": True, "poo": False},
actor="test-runner",
)
res_diaper = engine.add_event(diaper_req)
assert res_diaper["status"] == "success"
diaper_id = res_diaper["id"]
diaper_rows = engine.query(f"SELECT * FROM mila WHERE id = '{diaper_id}'")
assert len(diaper_rows) == 1
def test_duplicate_open_sleep_rejected(tmp_path: Path):
engine = DuckDBEngine(data_dir=tmp_path)
# 1. Start first open sleep session
req1 = EventCreateRequest(
kind="sleep.session",
subject="mila",
payload={"type": "nap", "location": "bett"},
actor="parent",
)
res1 = engine.add_event(req1)
assert res1["status"] == "success"
# 2. Attempt to start second open sleep session without stopping the first
req2 = EventCreateRequest(
kind="sleep.session",
subject="mila",
payload={"type": "nap", "location": "kinderwagen"},
actor="parent",
)
with pytest.raises(ValueError, match="already active for subject 'mila'"):
engine.add_event(req2)
def test_duplicate_open_sleep_api_endpoint(client: TestClient):
# 1. Start first session via API
res1 = client.post("/duckdb-api/v1/events", json={
"kind": "sleep.session",
"subject": "test_baby",
"payload": {"type": "nap"},
})
assert res1.status_code == 200
# 2. Start second session via API -> must return 400 Bad Request
res2 = client.post("/duckdb-api/v1/events", json={
"kind": "sleep.session",
"subject": "test_baby",
"payload": {"type": "nap"},
})
assert res2.status_code == 400
assert "already active for subject 'test_baby'" in res2.json()["detail"]
# 3. Clean up by stopping
stop_res = client.post("/duckdb-api/v1/events/stop", json={
"kind": "sleep.session",
"subject": "test_baby",
})
assert stop_res.status_code == 200
def test_agent_utc_wallclock_guard_and_mila_recipe_defaults(tmp_path: Path):
engine = DuckDBEngine(data_dir=tmp_path)
# 1. Agent passes +00:00 on local wall-clock times (e.g. 08:30 to 10:20)
nap_req = EventCreateRequest(
kind="sleep.session",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-17T08:30:00+00:00",
end="2026-10-17T10:20:00+00:00",
payload={"type": "nap", "location": "bett"},
)
res_nap = engine.add_event(nap_req)
assert res_nap["status"] == "success"
assert res_nap["record"]["startIso"] == "2026-10-17T08:30:00+02:00"
assert res_nap["record"]["endIso"] == "2026-10-17T10:20:00+02:00"
assert res_nap["record"]["sleepTimeSeconds"] == 6600
# 2. Solid food without amount_g gets standard recipe defaults
brei_req = EventCreateRequest(
kind="feeding.solid",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-17T12:20:00+00:00",
meal="lunch",
payload={"item": "mittagsbrei"},
)
res_brei = engine.add_event(brei_req)
assert res_brei["record"]["start"] == "2026-10-17T12:20:00+02:00"
assert res_brei["record"]["payload"]["amount_g"] == 190
assert res_brei["record"]["payload"]["kcal"] == 135
abend_req = EventCreateRequest(
kind="feeding.solid",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-17T16:40:00+00:00",
meal="dinner",
payload={"item": "abendbrei"},
)
res_abend = engine.add_event(abend_req)
assert res_abend["record"]["start"] == "2026-10-17T16:40:00+02:00"
assert res_abend["record"]["payload"]["amount_g"] == 200
assert res_abend["record"]["payload"]["kcal"] == 110
@@ -0,0 +1,28 @@
import pytest
from fastapi.testclient import TestClient
def test_generic_query_select(client: TestClient):
payload = {
"query": "SELECT count(*) AS cnt FROM v_sleep WHERE subject = 'mila'"
}
resp = client.post("/duckdb-api/v1/query", json=payload)
assert resp.status_code == 200
data = resp.json()
assert "rows" in data
assert len(data["rows"]) == 1
assert data["rows"][0]["cnt"] > 0
assert data["execution_time_ms"] < 50.0 # verifies sub-50ms execution
def test_generic_query_disallowed_drop(client: TestClient):
payload = {"query": "DROP TABLE sleep;"}
resp = client.post("/duckdb-api/v1/query", json=payload)
assert resp.status_code == 400
assert "not permitted" in resp.json()["detail"]
def test_generic_query_disallowed_insert(client: TestClient):
payload = {"query": "INSERT INTO sleep VALUES ('x', 'y')"}
resp = client.post("/duckdb-api/v1/query", json=payload)
assert resp.status_code == 400
@@ -0,0 +1,36 @@
import pytest
from app.engine import DuckDBEngine
def test_engine_init_and_counts(engine: DuckDBEngine):
counts = engine.get_table_counts()
assert "sleep" in counts
assert "nutrition" in counts
assert "mila" in counts
assert "garmin_daily" in counts
assert counts["sleep"] > 0
assert counts["nutrition"] > 0
def test_mila_dashboard_payload(engine: DuckDBEngine):
data = engine.get_mila_dashboard()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "mila"
assert "live_status" in data
assert "today" in data
assert "averages" in data
assert "history" in data
assert "growth" in data
assert len(data["history"]) > 0
def test_moritz_dashboard_payload(engine: DuckDBEngine):
data = engine.get_moritz_dashboard()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "moritz"
assert "today" in data
assert "yesterday" in data
assert "garmin_recovery" in data
assert "seven_days" in data
assert "thirty_days" in data
assert len(data["thirty_days"]["days"]) > 0
@@ -0,0 +1,68 @@
import subprocess
from pathlib import Path
from app.git_sync import GitSyncManager
def test_git_sync_conflict_handling(tmp_path: Path):
repo_dir = tmp_path / "fake_repo"
repo_dir.mkdir()
subprocess.run(["git", "init", "-b", "main", str(repo_dir)], check=True)
subprocess.run(["git", "-C", str(repo_dir), "config", "user.email", "test@test.local"], check=True)
subprocess.run(["git", "-C", str(repo_dir), "config", "user.name", "Test User"], check=True)
test_file = repo_dir / "test.txt"
test_file.write_text("initial")
subprocess.run(["git", "-C", str(repo_dir), "add", "."], check=True)
subprocess.run(["git", "-C", str(repo_dir), "commit", "-m", "initial commit"], check=True)
mgr = GitSyncManager(wiki_dir=repo_dir)
assert mgr.ensure_repo() is True
assert mgr.status == "ok"
assert mgr.is_conflict is False
def test_git_url_encoding(monkeypatch):
monkeypatch.setattr("app.git_sync.GIT_USERNAME", "moritz@graf")
monkeypatch.setattr("app.git_sync.GIT_PASSWORD", "p@ss:w/ord%123")
monkeypatch.setattr("app.git_sync.GIT_REPO_URL", "https://git.moritzgraf.de/moritz/knowledge-wiki.git")
mgr = GitSyncManager()
auth_url = mgr.get_auth_repo_url()
assert auth_url == "https://moritz%40graf:p%40ss%3Aw%2Ford%25123@git.moritzgraf.de/moritz/knowledge-wiki.git"
def test_ensure_repo_handles_non_empty_dir(tmp_path: Path):
# Simulate a PVC containing lost+found before repo initialization
wiki_pvc = tmp_path / "wiki_pvc"
wiki_pvc.mkdir()
(wiki_pvc / "lost+found").mkdir()
# Create upstream origin to fetch from
origin = tmp_path / "origin.git"
subprocess.run(["git", "init", "--bare", str(origin)], check=True)
# Push initial commit to bare origin
work = tmp_path / "work"
work.mkdir()
subprocess.run(["git", "init", "-b", "main", str(work)], check=True)
subprocess.run(["git", "-C", str(work), "config", "user.name", "Admin"], check=True)
subprocess.run(["git", "-C", str(work), "config", "user.email", "admin@test.local"], check=True)
(work / "README.md").write_text("# Knowledge Wiki")
subprocess.run(["git", "-C", str(work), "add", "."], check=True)
subprocess.run(["git", "-C", str(work), "commit", "-m", "init"], check=True)
subprocess.run(["git", "-C", str(work), "remote", "add", "origin", str(origin)], check=True)
subprocess.run(["git", "-C", str(work), "push", "origin", "main"], check=True)
# Now verify GitSyncManager initializes cleanly into non-empty directory
mgr = GitSyncManager(wiki_dir=wiki_pvc)
# Point URL to file repo
import app.git_sync
app.git_sync.GIT_REPO_URL = str(origin)
app.git_sync.GIT_USERNAME = ""
app.git_sync.GIT_PASSWORD = ""
success = mgr.ensure_repo()
assert success is True
assert (wiki_pvc / ".git").is_dir()
assert (wiki_pvc / "README.md").is_file()
assert (wiki_pvc / "lost+found").is_dir()
+813
View File
@@ -0,0 +1,813 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "duckdb"
version = "1.5.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/59/0b/d65ea3be00ea79aa276a8388bec588a9cbf409ce637c6d306e5316210d15/duckdb-1.5.6.tar.gz", hash = "sha256:166a91dbfacfc0c9f08cc76c0243cb6d3d4296bfab5bad72a3cfb63140a5b7c8", size = 18032957, upload-time = "2026-09-28T13:38:37.978Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/d5/d0ab77a0a1702a43171c93874f44c1f6481e30038bd3987df0d77a16a5c6/duckdb-1.5.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:48d07d0651aaeac2c3974afd37599970154b7b79b54c18f27c319c14ccf98d9d", size = 32810486, upload-time = "2026-09-28T13:37:47.254Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cd/b22201de5377faa3be6c38d5f3eaa504cb480392a448bed6a4d2239469b4/duckdb-1.5.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79de3dfa8705b1ba0d59e7e3252e40ff399e0afd12f485502a6c7bf7c2fd809a", size = 17405278, upload-time = "2026-09-28T13:37:50.135Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6d/f9cfb1493bbdc2f095693a402e42dce1192077f9e11573f00baed6a748de/duckdb-1.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcccce20965e6986cd083fdf192c461685ad0b93cd1ccd0b2a8207f1185f078b", size = 15532943, upload-time = "2026-09-28T13:37:52.927Z" },
{ url = "https://files.pythonhosted.org/packages/53/04/f65ccfaa5a833f2e570c4a140f03c8f95da416da9fe8ed08401f81f8242a/duckdb-1.5.6-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce89a1025a5317ebe9c520876c48032b5247ac574865486648b1a004f6009875", size = 19454940, upload-time = "2026-09-28T13:37:55.732Z" },
{ url = "https://files.pythonhosted.org/packages/4c/99/be75c788a492f8d77b7a1cdc1b19939ae7be0007f2028691ad371a1a33ee/duckdb-1.5.6-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc9619ed7d4ffa117b5155d84b44794366bb6635178d78ed5e13a6024845c757", size = 21568087, upload-time = "2026-09-28T13:37:58.191Z" },
{ url = "https://files.pythonhosted.org/packages/b5/95/889f8508960e47c0a7c75cc5bf57cde8512fc24f8db7b3129cca5388da42/duckdb-1.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:09ff51b230219f0d8b47fc8a1e17fb595ba9fab0c3d96a6de4d00b8ff86b3cf1", size = 13190189, upload-time = "2026-09-28T13:38:00.407Z" },
{ url = "https://files.pythonhosted.org/packages/a4/c9/baab503364a68309f8368c88e77f5341e7d94927bdf3e6d703f0e5035f3e/duckdb-1.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:b8d795c8b2d5634b3269f974aa97f1fdf878f62f032317a52252a151b693fb1e", size = 14021977, upload-time = "2026-09-28T13:38:02.682Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/a476197fcba557738a588ec844747a19bc0a24b0e6f1809e308f29d68c0e/duckdb-1.5.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ae352646374cacf48e9981cf031191c494865192fc436d13667a2531fc5d1da3", size = 32810376, upload-time = "2026-09-28T13:38:05.148Z" },
{ url = "https://files.pythonhosted.org/packages/0c/6d/5466a2b53ddd557644dfa47a763f68748efccdf282e6ae7c4f1bcfb3da69/duckdb-1.5.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a1261e90785e9d29953293e44f60fa073bd1137098924e8de21a037a861b051", size = 17405385, upload-time = "2026-09-28T13:38:07.363Z" },
{ url = "https://files.pythonhosted.org/packages/d4/a0/bf87071170835ee4a34fe764fc11c1c6e7040a0e021b36c1b6f834a4c22f/duckdb-1.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97dd7a555b8f5298b76bc7d48a11cb2c64336e8de9bfde783cffb86ea9f54807", size = 15533132, upload-time = "2026-09-28T13:38:09.681Z" },
{ url = "https://files.pythonhosted.org/packages/31/e0/38095c8e140ecfbe847519ac07bcba94301b8fbb76b2870015e33e07f179/duckdb-1.5.6-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:364992ba1089a2b327391cfcb68fd0bd0ce9090cf293baef861a0ba6847abfee", size = 19454994, upload-time = "2026-09-28T13:38:11.836Z" },
{ url = "https://files.pythonhosted.org/packages/70/21/61dd2876bbaa69cf77d7b5c620e52e8b25faae7096f4d2e4a812b52095d7/duckdb-1.5.6-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:644f54ce99b3b61844bc9a3fe80e0aecb1ea4084b1fffc4396d1569db6111679", size = 21568700, upload-time = "2026-09-28T13:38:14.258Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4a/100730e7785e85268be4d4d5bd62cfc8314e261d2f42efa208243eef35cb/duckdb-1.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:ced693d33ddcee2e5345f077d342c87d2aaa80e41c514e64c9ff2d4e5963c251", size = 13190707, upload-time = "2026-09-28T13:38:16.875Z" },
{ url = "https://files.pythonhosted.org/packages/f3/2e/bc7f44eab4e89ee5c1cb427bb1168ad021d985042e6841ec0694c3d3d501/duckdb-1.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:41ecc75bb9328d72d154a705c1a653d2c5c60f686a5c0c6578aa80020753c884", size = 14020962, upload-time = "2026-09-28T13:38:19.007Z" },
{ url = "https://files.pythonhosted.org/packages/fb/62/a8a30a4c6b94c0861d348ed5633b963f6745a5525527530f02f3c1a7c931/duckdb-1.5.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:aa21d2ad803b2524326e8622d7d96b2bb1ff1d5b60368e1978ee805df9c21fb3", size = 32828003, upload-time = "2026-09-28T13:38:21.414Z" },
{ url = "https://files.pythonhosted.org/packages/71/b7/1dcca0005eb8c67adf9fc06bf0cbb1d2bf4ea1974cc89e7a7c2ad66aac28/duckdb-1.5.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a1b2ad27d414068cbca06c55cfa802eece10f86ea4812ff082f8ab4cb25fc85", size = 17413912, upload-time = "2026-09-28T13:38:23.915Z" },
{ url = "https://files.pythonhosted.org/packages/93/b0/e3ac175443550f3464f2d95731a8b0aae9b4dc3875c3a186c352262b43c2/duckdb-1.5.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c79c6d222b1d015cde73b5139087186b00db65357fb4e2c94c2308fbbf465a72", size = 15543122, upload-time = "2026-09-28T13:38:26.317Z" },
{ url = "https://files.pythonhosted.org/packages/9d/08/cc510a7952aba69d5cdca17f3ef61c95713d86143f2ee9aa3e097d38f50b/duckdb-1.5.6-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1052b8050ef5696e2c0d8c836949c72f3dd11f0690466acbea739613e8e2750b", size = 19457946, upload-time = "2026-09-28T13:38:28.877Z" },
{ url = "https://files.pythonhosted.org/packages/ef/a5/6f8099d9a5a02ddff89e5c85875df3465054845b0920fb0703fbdf8dd2ec/duckdb-1.5.6-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c5e485e59613b8878d1670bcaa7a010f53c5a4da5ae8e08863e5e529ca6182", size = 21575132, upload-time = "2026-09-28T13:38:31.231Z" },
{ url = "https://files.pythonhosted.org/packages/9f/58/762f7159662d7859e201fa05ca29f306795daeabf84f3e087215a966b001/duckdb-1.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:ebcbd09cd8578ab1093393e9b16289cda0e8f1791ac595bf00eb5bad75c3cf00", size = 13713963, upload-time = "2026-09-28T13:38:33.543Z" },
{ url = "https://files.pythonhosted.org/packages/46/69/64d165db322de13f5c3e75d377b6b9694df1821155ad1fa4b14b04601abc/duckdb-1.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:820a8384faef11cd86068ea48c5da57ce2d8f1c7b3d2bdb9be3398317a7c3728", size = 14514368, upload-time = "2026-09-28T13:38:35.676Z" },
]
[[package]]
name = "duckdb-backend"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "duckdb" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "python-ulid" },
{ name = "pytz" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "duckdb", specifier = ">=1.1.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "pydantic", specifier = ">=2.8.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "python-ulid", specifier = ">=3.0.0" },
{ name = "pytz", specifier = ">=2024.1" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
]
provides-extras = ["dev"]
[[package]]
name = "fastapi"
version = "0.143.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" },
{ url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" },
{ url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" },
{ url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" },
{ url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" },
{ url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" },
{ url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" },
{ url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" },
{ url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" },
{ url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" },
{ url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" },
{ url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" },
{ url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" },
{ url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" },
{ url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" },
{ url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" },
{ url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" },
{ url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" },
{ url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" },
{ url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" },
{ url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" },
{ url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" },
{ url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" },
{ url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" },
{ url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" },
{ url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" },
{ url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" },
{ url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" },
{ url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" },
{ url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" },
{ url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" },
{ url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" },
{ url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" },
{ url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" },
{ url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" },
{ url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" },
{ url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" },
{ url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" },
{ url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" },
{ url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" },
{ url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" },
{ url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" },
{ url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" },
{ url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" },
{ url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" },
{ url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" },
{ url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" },
{ url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" },
{ url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" },
{ url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" },
{ url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" },
{ url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" },
{ url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" },
{ url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" },
{ url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" },
{ url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" },
{ url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.45.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" },
]
[[package]]
name = "pydantic-core"
version = "2.50.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" },
{ url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" },
{ url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" },
{ url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" },
{ url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" },
{ url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" },
{ url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" },
{ url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" },
{ url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" },
{ url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" },
{ url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" },
{ url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" },
{ url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" },
{ url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" },
{ url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" },
{ url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" },
{ url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" },
{ url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" },
{ url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" },
{ url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" },
{ url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" },
{ url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" },
{ url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" },
{ url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" },
{ url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" },
{ url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" },
{ url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" },
{ url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" },
{ url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" },
{ url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" },
{ url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" },
{ url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" },
{ url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" },
{ url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" },
{ url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" },
{ url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" },
{ url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" },
{ url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" },
{ url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" },
{ url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" },
{ url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" },
{ url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" },
{ url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" },
{ url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" },
{ url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" },
{ url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" },
{ url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" },
{ url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" },
{ url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" },
{ url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" },
{ url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" },
{ url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" },
{ url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" },
{ url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" },
{ url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" },
{ url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" },
{ url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" },
{ url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" },
{ url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" },
{ url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" },
{ url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" },
{ url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" },
{ url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" },
{ url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" },
{ url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" },
{ url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" },
{ url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" },
{ url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" },
{ url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" },
{ url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "python-ulid"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/41/65079023c81491a21799c0120bce5925366b6913596bf797806f19973290/python_ulid-4.0.1.tar.gz", hash = "sha256:bbeec02556190bb9dc3401faa7268696acbfbe7b6db9908c155dc3548629f20c", size = 101683, upload-time = "2026-07-20T15:21:41.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/15/8b39b36f55b6618ec4ca9b55134dcfd9c04cecbc72709f5d8e6bdebed9cd/python_ulid-4.0.1-py3-none-any.whl", hash = "sha256:6f1d69ceb97e99fe542df8476ebcd7a668284bf53ee14b3106bcc6a341a95ed9", size = 14609, upload-time = "2026-07-20T15:21:40.214Z" },
]
[[package]]
name = "pytz"
version = "2026.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "starlette"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/0c/6efb252d091ecccd7d62048ae11f0ea35cd75a4fbaeea5e30f9c3bf91d10/starlette-1.8.0.tar.gz", hash = "sha256:1565dc0b35d5737a271ed1e0e04e949f4e81198799f216d2667b0a0fb9cf9522", size = 2730457, upload-time = "2026-10-13T07:54:39.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b0/5742e4ac7af5eb58ec3470a537a49d7aa507e5539413e504b3a65ef50ba8/starlette-1.8.0-py3-none-any.whl", hash = "sha256:dfdd6b29c26483288088d990eee59631dedadd66ce20d203402a7ca8e3c4656f", size = 79612, upload-time = "2026-10-13T07:54:38.019Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
{ url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
{ url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
{ url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
{ url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
{ url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
{ url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
{ url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
{ url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
{ url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
{ url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
{ url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
{ url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
{ url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
{ url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
{ url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
{ url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
{ url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
{ url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
{ url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
{ url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
{ url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
{ url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
{ url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
]
[[package]]
name = "watchfiles"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" },
{ url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" },
{ url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" },
{ url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" },
{ url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" },
{ url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" },
{ url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" },
{ url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" },
{ url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" },
{ url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" },
]
[[package]]
name = "websockets"
version = "17.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" },
{ url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" },
{ url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" },
{ url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" },
{ url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" },
{ url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" },
{ url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" },
{ url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" },
{ url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" },
{ url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" },
{ url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" },
{ url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" },
{ url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" },
{ url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" },
{ url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" },
{ url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" },
{ url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" },
{ url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" },
{ url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" },
{ url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" },
{ url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" },
{ url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" },
{ url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" },
{ url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" },
{ url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" },
{ url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" },
{ url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" },
{ url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" },
{ url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" },
{ url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" },
{ url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" },
{ url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" },
{ url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" },
{ url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" },
{ url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" },
{ url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" },
{ url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" },
{ url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" },
{ url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" },
{ url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" },
{ url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" },
{ url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" },
{ url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" },
{ url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" },
{ url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" },
{ url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" },
{ url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" },
{ url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" },
{ url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" },
{ url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" },
{ url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" },
{ url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" },
{ url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" },
{ url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" },
{ url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" },
{ url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" },
{ url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" },
{ url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" },
{ url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" },
{ url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" },
{ url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" },
{ url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" },
{ url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" },
{ url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" },
{ url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" },
{ url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" },
{ url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" },
{ url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" },
{ url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" },
{ url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" },
{ url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" },
{ url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" },
{ url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" },
{ url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" },
{ url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" },
{ url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" },
{ url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" },
]

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