feat: complete phases 2–4 (verified compaction through production)

Add verified compaction, memory backends (file/mem0/hybrid), OpenAI-
compatible agent, adaptive calibration, metrics export, HITL signals,
structured logging, cost model, memory backup/restore, A/B memory
experiments, namespaced multi-agent handoffs, and decision policy
plugins. Bump package to 0.4.0a1 with docs and full test suite.
This commit is contained in:
grok-bot committed 2026-08-04 22:12:25 +02:00
1 parent faf00dfaae
commit 7c788bdbf1
67 files changed
+5632 -188

No files matched your search

+125 -92
View File
@@ -2,34 +2,47 @@
| Feld | Wert |
|------|------|
| **Stand** | 2026-08-04 |
| **Version** | `0.1.0a1` (Phase-1 MVP) |
| **Branch** | `main` (tip: `git rev-parse HEAD` / origin) |
| **Stand** | 2026-08-04 (P4 complete) |
| **Version** | `0.4.0a1` (Phase 4 complete) |
| **Branch** | `main` (lokal ahead of origin nur durch **uncommitted** Work) |
| **HEAD (committed)** | `faf00df` — entspricht `origin/main`; P2+P3+P4 liegen **nur working tree** |
| **Remote** | `http://10.27.27.139:3000/admin/Context-Lifecycle-Management-f-r-Agents.git` |
| **Tracking** | `main` → `origin/main` |
| **P1 quality gate** | `c48b837` — `chore: phase1 mvp quality gate` |
| **Status** | `partial_complete` — P0+P1 erledigt und gepusht; P2+ offen |
| **Status** | `ready_for_commit` — P4 lokal fertig + quality-gated; **Commit/Push User-Entscheidung** |
---
## Goal
Automatisiertes Context-Lifecycle-Management für LLM-Agents: Outer Control Loop entscheidet **CONTINUE / COMPACT / HANDOFF**, hält Context kontrolliert, schreibt datei-basiertes Memory und erzeugt auditierbare Handoff-Manifeste — framework-agnostisch, zuerst mit SimulatedAgent.
Automatisiertes Context-Lifecycle-Management für LLM-Agents: Outer Control Loop entscheidet **CONTINUE / COMPACT / HANDOFF**, hält Context kontrolliert (inkl. **Verified Compaction**), schreibt datei-basiertes (oder Mem0/Hybrid) Memory und erzeugt auditierbare Handoff-Manifeste — framework-agnostisch, SimulatedAgent + OpenAI-compatible PoC.
**Phasenstand:** P0–P4 implementiert. Roadmap-Meilensteine des Plans sind abgedeckt; weitere Iteration = Production polish / Research-Policies nach Bedarf.
---
## Next safe action
1. Session starten: Repo klonen/pullen, `HANDOFF.md` + `README.md` lesen.
2. Optional: Quality-Gate kurz bestätigen:
### 1. Optional: P2+P3+P4 committen + pushen (User muss freigeben)
Working tree enthält **alle** P2-, P3- und P4-Änderungen uncommitted (modified + untracked). Vor Commit Quality Gate erneut laufen lassen:
```bash
cd /Volumes/SSD2TB/books/clm-agents # oder Clone-Pfad
source .venv/bin/activate # oder: uv venv && uv pip install -e ".[dev]"
cd /Volumes/SSD2TB/books/clm-agents
source .venv/bin/activate
pytest -q && ruff check src tests && mypy src/clm
# dann nach User-OK: git add … && git commit && git push origin main
```
3. **P2 starten** (empfohlen nächste Phase) — siehe [NEXT](#next--phase-2).
**Hinweis:** Nicht force-pushen. `uv.lock` ist untracked — optional mitcommitten.
Empfehlung Commit-Split: (1) P2, (2) P3, (3) P4 — oder ein Squash „phase 2–4“.
### 2. Danach / alternativ: Ops-Nutzung / Erweiterungen
- Live Mem0 / Live OpenAI gegen echte Keys smoke-testen
- Eigene `DecisionPolicy`-Plugins (ARC/ACM-Heuristiken) hinter `DecisionEngine`
- Cost `price_table` pro Provider in Deploy-Config pflegen
- Optional: `clm experiment ab-memory` in CI als Regression
---
@@ -38,76 +51,102 @@ pytest -q && ruff check src tests && mypy src/clm
| Artefakt | Pfad |
|----------|------|
| Design-Spec | `docs/superpowers/specs/2026-08-04-clm-design.md` |
| Umsetzungsplan (P0–P1 detailliert) | `docs/superpowers/plans/2026-08-04-clm-mvp.md` |
| Umsetzungsplan | `docs/superpowers/plans/2026-08-04-clm-mvp.md` |
| Architektur | `docs/architecture.md` |
| Metriken | `docs/metrics.md` |
| Manifest-Intent + Schema | `docs/manifest-schema.md`, `schemas/handoff_manifest.schema.json` |
| Manifest | `docs/manifest-schema.md`, `schemas/handoff_manifest.schema.json` |
| README / Demos | `README.md` |
| Package | `src/clm/` |
| Tests | `tests/unit/`, `tests/integration/` |
---
## Done (Phase 0 + Phase 1)
## Done (Phase 0–4)
### Deliverables
### P0–P1 (committed / quality gate)
- [x] Package-Scaffold `clm` (Python ≥3.12, hatchling, typer, pydantic, pytest/ruff/mypy)
- [x] Docs: Design, Architecture, Metrics, Manifest, Plan
- [x] `HandoffManifest` (Pydantic + JSON Schema)
- [x] Config-Profile + Schwellen (CODING soft 250k / hard 350k, …)
- [x] `MetricsMonitor` + `AuditLogger` (`metrics.jsonl`)
- [x] `FileMemoryStore` (MEMORY.md, topics, logs)
- [x] `DecisionEngine` (phase → hard → soft → continue)
- [x] `CompactionPipeline` (deterministisch, **ohne** Verify)
- [x] `HandoffPipeline` + Seed-Context
- [x] `SimulatedAgent` + `WorkingSession`
- [x] `OuterControlLoop` (CONTINUE / COMPACT / HANDOFF)
- [x] CLI: `init`, `run`, `decide`, `compact`, `handoff`, `status`
- [x] Quality Gate: 69 Tests grün, Core-Coverage ~93%, ruff/mypy clean
- [x] Remote: `main` gepusht
- Package-Scaffold, Docs, Manifest-Schema, Config, Metrics, File Memory, Decision Engine, Compaction (structured), Handoff, SimulatedAgent, Outer Loop, CLI (`init/status/decide/compact/handoff/run`)
### Commits (Auszug, `main`)
### P2 (implementiert, **uncommitted**)
```text
c48b837 chore: phase1 mvp quality gate
c3a72a4 feat: CLI for init, run, decide, compact, handoff, status
2a0a70d feat: outer control loop with compact and handoff paths
… (P0–P1 Feature-Commits)
91e03d0 chore: initial empty repository
```
- Verified Compaction (`compaction/verify.py` + Pipeline)
- Memory backends: factory, Mem0 store, Hybrid store
- OpenAI-compatible Agent PoC
- Resume-Run, Loop-Polish, Integrationstests (verify-fail → handoff, resume)
### Commands & results (letztes Quality Gate)
### P3 (implementiert, **uncommitted**)
- Adaptive Thresholds (`clm calibrate`)
- Observability Export (`clm export metrics`)
- Phase-Orchestration Hooks (`clm signal --phase-done`)
- HITL Policy (`require_confirm_on` / `--yes`)
### P4 Deliverables (implementiert, **uncommitted**, quality-gated)
- [x] **M4.1 Production**
- Structured JSON logging (`clm.logging.structured`)
- Manifest versioning (`schema_version=1.1` + JSON Schema)
- Memory backup/restore (`clm backup memory` / `clm backup restore`)
- Cost model: `ProviderPrices`, `price_table`, `provider`, `metrics.cost`; Monitor loggt `*_cost_usd`
- [x] **M4.2 A/B Memory**
- `clm experiment ab-memory` + `clm.experiments.ab_memory`
- Report: completion, tokens, recall_score, interventions, cost (JSON + Markdown)
- Offline mem0/hybrid via `InMemoryMem0Client`
- [x] **M4.3 Multi-Agent Handoffs**
- `NamespacedMemoryStore` + `ClmConfig.memory_namespace`
- Manifest/Session `agent_id`; CLI `--agent-id` / `--memory-namespace`
- Keine Framework-Annahmen — nur Artefakt-Konventionen
- [x] **M4.4 Policy-Plugins**
- `DecisionPolicy` Protocol + `DecisionEngine(policies=…)`
- Beispiel: `BlockersForceHandoffPolicy`, `PreferHandoffOverCompactPolicy`
- [x] Docs/README/architecture/metrics/manifest; Version `0.4.0a1`
### Wichtige neue Pfade (P4)
| Pfad | Rolle |
|------|--------|
| `src/clm/logging/structured.py` | JSON structured logging |
| `src/clm/metrics/cost.py` | Cost model helpers |
| `src/clm/memory/backup.py` | tar.gz backup/restore |
| `src/clm/memory/namespaced.py` | Multi-agent key prefix |
| `src/clm/experiments/ab_memory.py` | A/B experiment runner |
| `src/clm/decision/policy.py` | DecisionPolicy plugins |
| `src/clm/cli/backup_cmd.py`, `experiment.py` | CLI |
| `tests/unit/test_production_p4.py`, `test_ab_memory.py`, `test_multi_agent.py`, `test_decision_policy.py`, `test_cli_p4.py` | P4 tests |
### Commands & results (Quality Gate, 2026-08-04 P4)
| Command | Result |
|---------|--------|
| `pytest` | **69 passed** |
| Coverage (Core decision/loop/handoff/memory) | **≥ 90%**, total ~**93%** |
| `pytest -q` | **126 passed** |
| `ruff check src tests` | clean |
| `mypy src/clm` | clean |
| `git push origin main` | OK (`c48b837` → origin) |
| `git push` | **pending** — nichts committed seit `faf00df` |
---
## Verified
- Outer Loop steuert SimulatedAgent für A/B/C (CONTINUE / COMPACT / HANDOFF) — Integrationstests + CLI-Smoke.
- File Memory: write / read / search; Key-Sanitization.
- Handoff-Manifest: Pydantic-Validierung vor Write; JSON Schema vorhanden.
- Decision-Priorität und Coding-Schwellen wie spezifiziert.
- CLI-End-to-End inkl. `state.json`-Persistenz nach `run`.
- Kein externer LLM-/Memory-Service für MVP nötig.
- Cost: global prices + provider table; metrics.jsonl cost fields
- Structured logging: single-line JSON with extra fields
- Backup/restore round-trip of FileMemoryStore topics
- Manifest: default `schema_version=1.1`, optional `agent_id`; legacy fixture still validates
- A/B: file-only and file+mem0+hybrid offline arms; markdown/json report
- Namespaced memory prefixes keys; factory honors `memory_namespace`
- Policy plugins override base DecisionEngine decisions
- CLI: `backup memory`, `experiment ab-memory`, help lists new commands
- P1–P3 suite still green (126 total)
---
## Not verified / bewusst out of scope (P1)
## Not verified / bewusst out of scope
- Verified Compaction (Recall-Score, Fallback HANDOFF) — **P2**
- Mem0 / Letta / andere Memory-Backends — **P2**
- Real-Agent-Adapter (OpenAI-compatible, Grok/Claude Hooks) — **P2**
- Adaptive Schwellen, Dashboard, Traycer — **P3**
- Multi-Agent-Handoffs, A/B Memory — **P4**
- Manuelle Demo-Szenarien A/B/C aus README auf diesem Host nicht zwingend nachgefahren (Tests decken Verhalten ab)
- Live Mem0 / Live OpenAI (Mock/Unit only)
- LLM-Summarizer (deterministische Summary bleibt default)
- Auto-Apply von Calibrate in `clm run` (manuell; Config-Overrides führend)
- Traycer Vendor-Integration
- ARC/ACM research papers hardcodiert (nur Plugin-Fläche + Beispiel-Policies)
- Dashboard-Framework / multi-session continue_after_handoff production UX
---
@@ -115,19 +154,17 @@ c3a72a4 feat: CLI for init, run, decide, compact, handoff, status
| Thema | Hinweis |
|-------|---------|
| Token-Semantik P1 | `DecisionEngine` nutzt **session-kumulative** Tokens, nicht post-compact Window-Größe. Für reale Agents später `context_tokens` einführen. |
| Terminal-Steps | Nach `COMPLETED`/`FAILED` kann die Engine noch entscheiden (edge: high tokens → `stopped_reason=handoff`). |
| `max_steps` vs completed | Loop prüft `max_steps` vor Terminal-Status am Loop-Kopf (bekannter Race). |
| Sparse Manifests | Sim-Agent füllt `commands_and_results`, `blockers`, … kaum; Schema-valid, inhaltlich dünn. |
| `decisions.jsonl` | Nur `action`/`trigger`/`rationale` — ohne step/tokens/ts. |
| `inject_context` | SimulatedAgent speichert Addons nur; echte Adapter brauchen klare Contract-Doku. |
| Remote first commit | Gitea war beim ersten Push ohne Refs; `main` wurde mit voller History etabliert. |
| Uncommitted bulk | P2+P3+P4 gemeinsam im Working Tree — Commit-Split oder Squash; User entscheidet |
| A/B mem0 arms | Nutzen `InMemoryMem0Client` ohne API-Key — misst Integration, nicht Live-Vector-Qualität |
| Namespace | Präfix gilt für alle Keys des Stores; Agents teilen Namespace bewusst |
| Policy order | Policies laufen sequentiell; spätere können frühere Overrides weiter ersetzen |
| Cost | Ohne price config: keine cost-Felder (unavailable), Tokens bleiben |
---
## Blockers
Keine. Arbeit ist merge-/push-fähig und remote erreichbar.
Keine technischen Blocker für P4. **Commit/Push wartet auf explizite User-Freigabe.**
---
@@ -135,44 +172,40 @@ Keine. Arbeit ist merge-/push-fähig und remote erreichbar.
| Pfad | Zweck |
|------|--------|
| `.clm/` | Runtime (init via `clm init`) — sessions, memory, manifests |
| `.superpowers/sdd/plan/` | SDD-Ledger/Briefs (gitignored) |
| `uv.lock` | lokal untracked; optional committen |
---
## NEXT — Phase 2
Priorisierte nächste Schritte (aus Plan M2.x):
1. **Verified Compaction** — `VerificationSuite`, Recall-Score (Default 0.8), Fallback HANDOFF
2. **Session Persistence** (teilweise da: `state.json`) — `clm run --resume`
3. **Memory Adapter** — `memory.backend: file | mem0` (optional)
4. **Erster Real-Agent Adapter** — z. B. OpenAI-compatible oder Grok/Claude Protocol
5. **Loop-Korrektur-Polish** (klein, vor Real-Agent sinnvoll):
- terminal status vor Pipelines / max_steps
- Decision-Audit um `step_index`, `total_tokens`, `ts` anreichern
- Token-Semantik dokumentieren oder `context_tokens` einführen
Einstieg nächste Session:
```text
Repo: Context-Lifecycle-Management-f-r-Agents (oder /Volumes/SSD2TB/books/clm-agents)
Branch: main · HANDOFF.md lesen · nächste Aufgabe: P2 Verified Compaction (TDD)
```
| `.clm/` | Runtime (init via `clm init`) |
| `.clm/calibrations/` | P3 `calibrate --write` |
| `.clm/exports/` | P3 export metrics; P4 A/B reports |
| `.clm/backups/` | P4 memory tar.gz |
| `uv.lock` | untracked; optional committen |
---
## Decision rationale (dieser Handoff)
P0–P1 sind implementiert, reviewt, quality-gated und auf Gitea `main` gepusht. Nächster sinnvoller Phasenübergang ist **P2 (Verified Compaction + Adapter)**; dieser Handoff hält Stand, SoT und offene Risiken für eine frische Session fest.
- P4 M4.1–M4.4 aus Plan-Meilensteinen umgesetzt und quality-gated (126 / ruff / mypy).
- Offline-Default intakt; Backup/A/B/Policies additiv; Multi-Agent nur Konventionen.
- Version `0.4.0a1`; Handoff-Status **`ready_for_commit`**.
- Nächste Session: committen/pushen oder Ops/Research-Plugins — **nicht** P4 neu bauen.
---
## Quick resume checklist
- [ ] `git pull origin main`
- [ ] `source .venv/bin/activate` / `uv pip install -e ".[dev]"`
- [ ] `pytest -q`
- [ ] Dieses `HANDOFF.md` + Plan `docs/superpowers/plans/2026-08-04-clm-mvp.md` (Abschnitt Phase 2)
- [ ] Task starten: Verified Compaction **oder** Loop-Polish **oder** Real-Agent-Adapter
- [ ] `git status` — erwarten: viele M + ?? unter `src/clm/`, `tests/`, docs, `pyproject.toml`
- [ ] Optional: commit + `git push origin main` (nur mit User-OK)
- [ ] `source .venv/bin/activate` / bei Bedarf `uv pip install -e ".[dev]"`
- [ ] `pytest -q && ruff check src tests && mypy src/clm` → 126 / clean / clean
- [ ] Dieses `HANDOFF.md` + `docs/architecture.md` (P4)
- [ ] **Nicht** P4 erneut implementieren
- [ ] Nächster Task: **Commit** oder Ops/Policy-Erweiterungen
---
## Einstieg nächste Session
```text
Repo: /Volumes/SSD2TB/books/clm-agents
Branch: main @ faf00df (origin) + uncommitted P2+P3+P4 working tree
HANDOFF.md lesen · P4 complete (0.4.0a1, 126 tests) · nicht neu bauen
User-Wahl: (A) commit+push P2/P3/P4 | (B) Ops smoke / custom DecisionPolicy
```
+65 -4
View File
@@ -2,7 +2,7 @@
Automated Context Lifecycle Management for LLM Agents.
**Phase-1 MVP** (`0.1.0a1`): Outer control loop + SimulatedAgent, file memory, decision engine (CONTINUE / COMPACT / HANDOFF), schema-valid handoff manifests, CLI, and audit logs — no external LLM or memory service required.
**Phase 4** (`0.4.0a1`): Production hardening (structured JSON logging, manifest `schema_version`, memory backup/restore, cost model per provider), A/B memory experiments (`clm experiment ab-memory`), multi-agent handoffs (`agent_id`, shared `memory_namespace`), and DecisionPolicy plugins. Builds on P3 calibrate/export/signal/HITL and P2 verified compaction — defaults still work offline.
## Requirements
@@ -13,6 +13,8 @@ Automated Context Lifecycle Management for LLM Agents.
```bash
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"
# optional Mem0 backend:
# uv pip install -e ".[dev,mem0]"
```
## CLI Quickstart
@@ -31,25 +33,84 @@ clm decide --tokens 360000 --profile coding
clm run --goal "demo" --script tests/fixtures/sample_transcript.json \
--profile coding --soft-compact 1000 --hard-handoff 50000 --max-steps 20
# Resume a stopped (non-terminal) session from state.json
clm run --resume <session-id> --script tests/fixtures/sample_transcript.json --path .clm
# Inspect last metrics / decisions
clm status --session-id <id>
# Manual compact / handoff on persisted sessions/<id>/state.json
clm compact --session-id <id>
clm handoff --session-id <id>
# P3: recommend soft/hard from past metrics.jsonl
clm calibrate --profile coding --path .clm --write
# P3: export observability artifacts
clm export metrics --format json --session-id <id> --path .clm
clm export metrics --format html --session-id <id> --path .clm
# P3: external phase boundary → next decide/run prefers handoff
clm signal --phase-done --session-id <id> --path .clm
# P3: HITL — require confirm on handoff (auto-approve with --yes)
clm run --goal "…" --script tests/fixtures/sample_transcript.json \
--require-confirm handoff --yes --path .clm
# P4: backup file memory
clm backup memory --path .clm --label pre-deploy
# P4: A/B memory backends on the same Simulated script (offline mem0 via in-memory client)
clm experiment ab-memory --goal "ab" --script tests/fixtures/sample_transcript.json \
--backends file,mem0,hybrid --soft-compact 1000 --hard-handoff 500000 --path .clm
# P4: multi-agent identity + shared memory namespace
clm run --goal "coord" --script tests/fixtures/sample_transcript.json \
--agent-id worker-a --memory-namespace team/shared --path .clm
```
`clm run` writes under `.clm/sessions/<id>/`:
- `metrics.jsonl`, `decisions.jsonl`
- `state.json` (enables later `compact` / `handoff`)
- `metrics.jsonl`, `decisions.jsonl` (decisions include `step_index`, `total_tokens`, `ts`; optional cost fields)
- `state.json` (enables `compact` / `handoff` / `--resume` / `signal`)
Handoff manifests land in `.clm/manifests/`.
Handoff manifests land in `.clm/manifests/` (`schema_version=1.1`, optional `agent_id`). Exports under `.clm/exports/<id>/`. Backups under `.clm/backups/`.
### P2–P4 highlights
| Feature | Notes |
|---------|--------|
| Verified Compaction | After soft compact, recall score on goal/verified/constraints/blockers; fail → `trigger=verify_fail` handoff |
| Resume | `clm run --resume <id> --script …` continues SimulatedAgent at `step_index` |
| Memory backends | `--memory-backend file\|mem0\|hybrid` (file default; mem0 needs API key + optional extra) |
| Real agent PoC | `OpenAICompatibleAgent` (library; stdlib HTTP + injectable transport for tests) |
| Calibrate | Peak-percentile + decision-token blend → recommended soft/hard; `--write` → `.clm/calibrations/` |
| Export | `clm export metrics --format csv\|json\|html` |
| Phase signal | `clm signal --phase-done` sets `status=phase_boundary` |
| HITL | `ClmConfig.require_confirm_on` / `--require-confirm`; denied → continue + exit 2 without `--yes` |
| Cost model | `--provider` / `--price-per-1k-*` or `price_table`; metrics log `*_cost_usd` |
| Memory backup | `clm backup memory` / `clm backup restore --archive …` |
| A/B Memory | `clm experiment ab-memory` → completion, tokens, recall, interventions report |
| Multi-Agent | `--agent-id`, `--memory-namespace` (key prefix); manifest carries both conventions |
| Policy plugins | `DecisionEngine(config, policies=[BlockersForceHandoffPolicy()])` |
```bash
clm --help
```
### OpenAI-compatible adapter (library)
```python
from clm.session.openai_compatible import OpenAICompatibleAgent
agent = OpenAICompatibleAgent(
api_key="sk-…",
model="gpt-4o-mini",
base_url="https://api.openai.com/v1", # or any compatible endpoint
)
# Wire into WorkingSession + OuterControlLoop like SimulatedAgent
```
## Demo scenarios (Phase-1 quality gate)
Use the bundled SimulatedAgent script
+67 -22
View File
@@ -49,10 +49,11 @@ CLM ist ein **framework-agnostischer Outer Control Loop** über einem ausführen
|---------|---------------|---------------|
| Outer Control Loop | `clm.loop.control`, `clm.decision.*`, `clm.metrics.*`, `clm.compaction.*`, `clm.handoff.*` | Orchestrierung, Metriken, Entscheidung, Pipelines |
| Working Session | `clm.session.working`, `clm.session.agent`, `clm.session.simulated` | Agent-Ausführung, Context Window, inject/reset |
| Long-Term Memory | `clm.memory.base`, `clm.memory.file_store` | Persistente Keys/Inhalte; Adapter-fähig |
| Phase-Orchestration (optional, P3+) | Events / CLI signal | `PHASE_BOUNDARY`, externe Phasensysteme |
| Long-Term Memory | `clm.memory.base`, `clm.memory.file_store`, `namespaced`, `backup` | Persistente Keys/Inhalte; Multi-Agent-Namespace; Backup |
| Phase-Orchestration (P3) | `clm signal`, HITL confirm | `PHASE_BOUNDARY`, Confirm-Policy, Export/Calibrate |
| Production & Experiments (P4) | `logging.structured`, `metrics.cost`, `experiments`, `decision.policy` | Structured logs, Cost model, A/B Memory, Policy-Plugins |
Runtime-Artefakte liegen unter `.clm/` (sessions, memory, manifests).
Runtime-Artefakte liegen unter `.clm/` (sessions, memory, manifests, backups, exports).
---
@@ -92,15 +93,17 @@ Regelbasiert (P1); optional LLM hinter Flag (nicht MVP-Must).
Ausgabe: `Decision(action, rationale, trigger)`.
### `CompactionPipeline` (P1)
### `CompactionPipeline` (P2: verified)
1. Strukturierte Summary aus `SessionState` (kein LLM nötig).
2. Snapshot in Memory: `sessions/{id}/compact-{step}`.
3. Compact-Hint (Markdown): Goal, Constraints, Next action, Memory keys, Verified/Not verified.
4. Messages ersetzen: system-hint + letzte `retain_recent_messages` (Default 6).
5. `last_compact_step` setzen.
**Keine Verify-Suite in P1** (Zielbild P2).
4. **`VerificationSuite`** (deterministisch): Fragen aus goal / verified / constraints / blockers;
`RecallScore = passed / total` gegen Summary+Hint; Default-Schwellwert `0.8`.
5. **Verify vor Message-Mutation:**
- Score ≥ threshold → Messages ersetzen (system-hint + `retain_recent_messages`), `last_compact_step`.
- Score &lt; threshold → Messages unangetastet; `fell_back_to_handoff=True` → Loop erzwingt HANDOFF (`trigger=verify_fail`).
6. Config: `verify_compaction` (default True), `recall_score_threshold` (default 0.8).
### `HandoffPipeline`
@@ -119,7 +122,15 @@ search(query, *, limit=10) → list[MemoryEntry]
list_keys() → list[str]
```
MVP: `FileMemoryStore` unter `.clm/memory/` (`MEMORY.md`, topics, logs).
**P2 Memory backends** (`ClmConfig.memory_backend` / factory):
| Backend | Klasse | Hinweis |
|---------|--------|---------|
| `file` (default) | `FileMemoryStore` | `.clm/memory/` |
| `mem0` | `Mem0Store` | optional extra `clm[mem0]`; API-Key nötig |
| `hybrid` | `HybridMemoryStore` | write file+mem0; read prefer file |
**P4 Multi-Agent:** `ClmConfig.memory_namespace` wrappt den Store als `NamespacedMemoryStore` (Key-Prefix). `agent_id` landet im Manifest (`schema_version=1.1`). Shared namespace = gemeinsame Keys über Agents ohne Framework-Annahme.
### `OuterControlLoop`
@@ -130,6 +141,35 @@ on_step_end(step) → Decision
Bindet Monitor, Engine, Compaction, Handoff und Session zusammen.
**P2 Loop-Verhalten:**
- Nach Step: Decision auditiert mit `step_index`, `total_tokens`, `ts` (+ optional `context_tokens`).
- Terminal-Status (`completed`/`failed`) nach Step → **keine** Compact/Handoff-Pipeline.
- COMPACT mit Verify-Fail → Override-Decision `trigger=verify_fail` → HandoffPipeline.
- Token-Basis: default session-kumulativ; optional `use_context_tokens_for_thresholds` (Window-Größe).
**P3 Loop- / CLI-Erweiterungen:**
- **HITL:** `require_confirm_on: ["compact"\|"handoff"]` → vor Pipeline `confirm_callback`; Denied → CONTINUE, `human_interventions++`, Audit `event=confirm` / `trigger=confirm_denied`.
- **Phase signal:** `clm signal --phase-done` setzt `SessionState.status=phase_boundary` (ohne Pipeline); DecisionEngine → HANDOFF wenn `prefer_handoff_on_phase`.
- **Calibrate:** `clm calibrate` liest `metrics.jsonl`/`decisions.jsonl`, empfiehlt soft/hard (Perzentile + optional Decision-Blend).
- **Export:** `clm export metrics --format csv|json|html` → `.clm/exports/<id>/`.
**P4 Loop- / CLI-Erweiterungen:**
- **Structured logging:** `clm.logging.structured` (JSON lines; optional in Ops-Wiring).
- **Cost model:** `provider` + `price_table` / globale `price_per_1k_*`; MetricsMonitor loggt `step_cost_usd` / `session_cost_usd`.
- **Memory backup:** `clm backup memory` → `.clm/backups/memory_*.tar.gz`; `clm backup restore --archive …`.
- **A/B Memory:** `clm experiment ab-memory --script … --backends file,mem0,hybrid` → Report (completion, tokens, recall, interventions).
- **Policy plugins:** `DecisionEngine(..., policies=[…])` — z. B. `BlockersForceHandoffPolicy`.
### Agent-Adapter
| Adapter | Zweck |
|---------|--------|
| `SimulatedAgent` | Deterministische Scripts; `script_offset` für Resume |
| `OpenAICompatibleAgent` | PoC: `/v1/chat/completions` (stdlib HTTP, inject `http_post` für Tests) |
---
## Datenfluss (Schritt)
@@ -150,10 +190,9 @@ Bindet Monitor, Engine, Compaction, Handoff und Session zusammen.
→ nächster Step mit unverändertem Window (ggf. neue Messages)
4b. COMPACT
→ CompactionPipeline.run(state)
→ MemoryStore.write(snapshot)
→ state.messages ← hint + recent tail
→ Agent.inject_context / nächster Step mit verdichtetem Context
→ CompactionPipeline.run(state) # summary → memory → verify → maybe mutate
→ if verify fail: Decision(verify_fail) → HandoffPipeline (wie 4c)
→ else: state.messages ← hint + recent tail; Agent.inject_context
4c. HANDOFF
→ HandoffPipeline.run(state, decision)
@@ -169,9 +208,13 @@ Bindet Monitor, Engine, Compaction, Handoff und Session zusammen.
```text
.clm/
├── config.yaml # optional local override
├── calibrations/<profile>.json # P3 calibrate --write
├── exports/<id>/ # P3 export metrics; P4 A/B reports
├── backups/ # P4 memory_*.tar.gz
├── sessions/<id>/
│ ├── metrics.jsonl
│ ├── decisions.jsonl
│ ├── state.json
│ ├── compact-hints/
│ └── handoffs/
├── memory/
@@ -179,7 +222,7 @@ Bindet Monitor, Engine, Compaction, Handoff und Session zusammen.
│ ├── topics/
│ └── logs/
└── manifests/
└── {timestamp}_{session_id}.json
└── {timestamp}_{session_id}.json # schema_version 1.1 + optional agent_id
```
---
@@ -192,18 +235,20 @@ Bindet Monitor, Engine, Compaction, Handoff und Session zusammen.
| B – Handoff + neue Session | `handoff` | `HandoffPipeline` |
| C – Weitermachen | `continue` | — |
Schwellen und Task-Profile: siehe Design-Spec §6 und `ClmConfig` (Task 0.4).
Schwellen und Task-Profile: siehe Design-Spec §6 und `ClmConfig` (Task 0.4).
P3: Adaptive Vorschläge via `clm calibrate` — **manuelle Overrides bleiben führend**.
---
## MVP-Grenzen (Architektur)
## Phasen-Grenzen (Architektur)
| In P1 | Nicht in P1 |
|-------|-------------|
| Regel-Engine, File Memory, SimAgent | Mem0/Letta SDK |
| Compact ohne Verify | VerificationSuite / Recall-Score |
| Manifest + Seed | Multi-Agent shared handoffs |
| metrics/decisions jsonl | Adaptive thresholds, Dashboard (P3) |
| In P4 | Bewusst out of scope / später |
|-------|-------------------------------|
| Structured logging, Manifest `schema_version`, Memory backup/restore | Live Mem0/OpenAI (weiter Mock/Unit) |
| Cost model pro Provider (`price_table`) | LLM-Summarizer als Default |
| A/B Memory experiment runner + Report | Dashboard-Framework |
| Multi-Agent: `agent_id`, `memory_namespace`, shared keys | Spezielles Multi-Agent-Framework |
| DecisionPolicy plugins (Beispiel-Policies) | Traycer vendor lock-in; ARC/ACM research hardcode |
---
+5 -1
View File
@@ -35,6 +35,8 @@ Jedes **HANDOFF** erzeugt ein strukturiertes, validierbares Manifest. Es ist die
| `token_snapshot` | int ≥ 0 | Token-Stand zum Handoff-Zeitpunkt |
| `timestamp` | string | ISO-8601 |
| `decision_rationale` | string (non-empty) | Begründung der Handoff-Decision |
| `schema_version` | string (optional, default `1.1`) | Manifest-Versionierung (P4); Legacy-Reads ohne Feld ok |
| `agent_id` | string \| null (optional) | Multi-Agent-Identität der Quell-Session (P4) |
---
@@ -55,7 +57,9 @@ Jedes **HANDOFF** erzeugt ein strukturiertes, validierbares Manifest. Es ist die
"memory_keys": ["sessions/s1/compact-12", "topics/auth"],
"token_snapshot": 287450,
"timestamp": "2026-08-04T12:00:00Z",
"decision_rationale": "tokens >= hard_handoff"
"decision_rationale": "tokens >= hard_handoff",
"schema_version": "1.1",
"agent_id": null
}
```
+44 -8
View File
@@ -34,18 +34,26 @@ Design-Kontext: [`docs/superpowers/specs/2026-08-04-clm-design.md`](superpowers/
- Pro Step: `input_tokens + output_tokens` (oder `total_tokens` aus `TokenSnapshot`).
- Summe über alle Steps einer erfolgreichen Task; Mittel über erfolgreiche Tasks.
- Fallback-Schätzung: `estimate_tokens(text) ≈ len(chars) / 4` wenn Provider-Counts fehlen (`estimated=true`).
- **P2:** `TokenSnapshot.context_tokens` = geschätzte aktuelle Message-Window-Größe (chars//4).
Decision-Schwellen nutzen standardmäßig **session-kumulativ** (`total_tokens`); optional
`ClmConfig.use_context_tokens_for_thresholds=True` für Window-basierte Soft/Hard-Checks.
### `human_interventions_per_session`
- Inkrement bei: manuellem Override der Decision Engine, explizitem User-Prompt, HITL-Confirm (P3 Policy).
- Feld: `SessionState.human_interventions` (Counter); pro Session und gemittelt.
### `compaction_recall_score` (P2)
### `compaction_recall_score` (P2 — live)
- Nach Compact: `VerificationSuite` stellt Fragen aus Summary/Decisions/Constraints.
- `RecallScore = passed / total` (Default-Schwellwert 0.8).
- Score &lt; threshold → automatischer Fallback HANDOFF.
- **P1:** Metrik wird nicht berechnet; Platzhalter in Doku und späteren Log-Feldern.
- Nach Compact: `VerificationSuite` stellt Fragen aus **goal**, **verified**, **assumptions_risks** (constraints), **blockers**.
- Corpus = structured summary + compact hint (deterministisch, kein LLM).
- `RecallScore = passed / total` (0 Fragen → 1.0).
- Default-Schwellwert `ClmConfig.recall_score_threshold = 0.8`.
- Score &lt; threshold → Fallback HANDOFF (`trigger=verify_fail`); Messages werden **nicht** geshunkt.
- **Logging:**
- Erfolg: `decisions.jsonl` Event `compaction_verify` mit `recall_score`, `trigger=verify_ok`
- Fail: Decision-Zeile mit `trigger=verify_fail`, `recall_score`
- `LoopResult.last_recall_score`
### `session_length_until_handoff`
@@ -55,8 +63,10 @@ Design-Kontext: [`docs/superpowers/specs/2026-08-04-clm-design.md`](superpowers/
### `cost_per_task`
- `cost = (input_tokens / 1000) * price_per_1k_input + (output_tokens / 1000) * price_per_1k_output`
- Preise aus Config (`price_per_1k_input` / `price_per_1k_output`); optional pro Provider (P4).
- Ohne Price-Table: Cost-Metrik als „unavailable“ markieren, Tokens trotzdem loggen.
- **P4:** Preise aus `ClmConfig.resolve_prices()` — Preferenz `price_table[provider]`, sonst globale `price_per_1k_input` / `price_per_1k_output`.
- `MetricsMonitor` (mit config) schreibt optional `step_cost_usd`, `session_cost_usd`, `provider` in `metrics.jsonl`.
- Ohne Prices: Cost-Felder fehlen (unavailable); Tokens weiterhin loggen.
- Helper: `clm.metrics.cost.compute_cost_usd` / `cost_for_config`.
### `e2e_latency_s`
@@ -86,7 +96,33 @@ Append-only unter `.clm/sessions/<id>/`:
| `metrics.jsonl` | pro Step: `step_index`, `input_tokens`, `output_tokens`, `total_tokens`, `tool_calls`, `task_status`, `estimated`, Timestamp |
| `decisions.jsonl` | pro Decision: `action`, `trigger`, `rationale`, Token-Snapshot, `step_index`, Timestamp |
**P1 loggt Rohdaten auch ohne Dashboard.** Aggregation/Export: `clm export metrics` (P3).
**P1 loggt Rohdaten auch ohne Dashboard.** Aggregation/Export ist **P3 live**:
```bash
clm export metrics --format json|csv|html --session-id <id> --path .clm
clm calibrate --profile coding --path .clm --write
```
### P3 Export-Felder
| Format | Artefakte |
|--------|-----------|
| `json` | `metrics_export.json` (`metrics` + `decisions`) |
| `csv` | `metrics.csv`, `decisions.csv` |
| `html` | `report.html` (Token-over-steps SVG, Decision-Timeline, Tabellen) |
### P3 Calibration
- Input: kumulierte Step-Totals aus `metrics.jsonl` (Peaks pro Session) + Token-Werte an Compact/Handoff in `decisions.jsonl`.
- soft ≈ p60 der Peaks (optional gemittelt mit Decision-Median); hard ≈ p90, floor `soft * 1.3`.
- Keine Daten → Profil-Defaults (`used_defaults=true`).
- Schreiben: `.clm/calibrations/<profile>.json` — **nicht** auto-applied; CLI-Overrides bleiben autoritativ.
### P3 HITL / Interventions
- `require_confirm_on` → vor COMPACT/HANDOFF Callback; Audit `event=confirm`.
- Denied: `trigger=confirm_denied`, Action wird übersprungen (CONTINUE), Counter `human_interventions`.
- CLI non-interactive ohne `--yes`: Exit-Code **2** wenn Confirms denied und keine Pipeline lief.
Empfohlene JSONL-Felder (Orientierung für Task 1.1):
+4 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "clm"
version = "0.1.0a1"
version = "0.4.0a1"
description = "Automated Context Lifecycle Management for LLM Agents"
readme = "README.md"
requires-python = ">=3.12"
@@ -20,6 +20,9 @@ dev = [
"mypy>=1.10",
"types-PyYAML>=6.0",
]
mem0 = [
"mem0ai>=0.1.0",
]
[project.scripts]
clm = "clm.cli.app:app"
+9
View File
@@ -91,6 +91,15 @@
"type": "string",
"minLength": 1,
"description": "Rationale for the handoff decision"
},
"schema_version": {
"type": "string",
"minLength": 1,
"description": "Manifest schema version (default 1.1); optional on legacy reads"
},
"agent_id": {
"type": ["string", "null"],
"description": "Optional multi-agent identity that produced this handoff"
}
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
"""CLM — Context Lifecycle Management for LLM Agents."""
__version__ = "0.1.0a1"
__version__ = "0.4.0a1"
+13
View File
@@ -0,0 +1,13 @@
"""Adaptive threshold calibration from session metrics (P3)."""
from clm.calibration.thresholds import (
CalibrationResult,
calibrate_thresholds,
write_calibration,
)
__all__ = [
"CalibrationResult",
"calibrate_thresholds",
"write_calibration",
]
+256
View File
@@ -0,0 +1,256 @@
"""Derive recommended soft/hard thresholds from session metrics (P3 M3.1)."""
from __future__ import annotations
import json
import math
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from clm.models.config import ClmConfig, TaskType, Thresholds
def _list_session_ids(home: Path) -> list[str]:
root = home / "sessions"
if not root.is_dir():
return []
return sorted(p.name for p in root.iterdir() if p.is_dir())
def _metrics_path(home: Path, session_id: str) -> Path:
return home / "sessions" / session_id / "metrics.jsonl"
def _decisions_path(home: Path, session_id: str) -> Path:
return home / "sessions" / session_id / "decisions.jsonl"
@dataclass(frozen=True)
class CalibrationResult:
"""Recommended thresholds plus diagnostic metadata."""
profile: TaskType
soft_compact: int
hard_handoff: int
sessions_analyzed: int
samples_peak: int
method: str
notes: str
used_defaults: bool
peak_totals: list[int]
decision_soft_tokens: list[int]
decision_hard_tokens: list[int]
def as_thresholds(self) -> Thresholds:
return Thresholds(soft_compact=self.soft_compact, hard_handoff=self.hard_handoff)
def to_dict(self) -> dict[str, Any]:
return {
"profile": self.profile.value,
"soft_compact": self.soft_compact,
"hard_handoff": self.hard_handoff,
"sessions_analyzed": self.sessions_analyzed,
"samples_peak": self.samples_peak,
"method": self.method,
"notes": self.notes,
"used_defaults": self.used_defaults,
"ts": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
}
def _percentile(sorted_values: list[int], p: float) -> int:
"""Nearest-rank percentile for non-empty sorted ascending list."""
if not sorted_values:
raise ValueError("percentile requires non-empty data")
if p <= 0:
return sorted_values[0]
if p >= 100:
return sorted_values[-1]
# nearest rank (1-indexed): ceil(p/100 * n)
rank = max(1, min(len(sorted_values), math.ceil(p / 100.0 * len(sorted_values))))
return sorted_values[rank - 1]
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
if not path.is_file():
return []
rows: list[dict[str, Any]] = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
rows.append(json.loads(line))
return rows
def _session_peak_cumulative(metrics_rows: list[dict[str, Any]]) -> int | None:
"""Reconstruct session cumulative total from per-step total_tokens."""
if not metrics_rows:
return None
cum = 0
peak = 0
for row in metrics_rows:
step_total = int(row.get("total_tokens") or 0)
cum += step_total
peak = max(peak, cum)
return peak if peak > 0 else None
def _collect_decision_tokens(
decision_rows: list[dict[str, Any]],
) -> tuple[list[int], list[int]]:
soft: list[int] = []
hard: list[int] = []
for row in decision_rows:
trigger = str(row.get("trigger") or "")
total = row.get("total_tokens")
if total is None:
continue
try:
n = int(total)
except (TypeError, ValueError):
continue
if n <= 0:
continue
if trigger == "soft_compact" or row.get("action") == "compact":
soft.append(n)
if trigger in {"hard_handoff", "phase_boundary", "verify_fail"} or (
row.get("action") == "handoff" and trigger != "manual"
):
hard.append(n)
return soft, hard
def calibrate_thresholds(
home: Path,
profile: TaskType | str,
*,
session_ids: list[str] | None = None,
soft_percentile: float = 60.0,
hard_percentile: float = 90.0,
min_hard_ratio: float = 1.3,
) -> CalibrationResult:
"""Recommend soft/hard thresholds from metrics.jsonl / decisions.jsonl.
Strategy:
1. Per session: peak cumulative tokens (sum of step totals).
2. soft = p{soft_percentile} of peaks (default p60).
3. hard = p{hard_percentile} of peaks (default p90), floored to soft * min_hard_ratio.
4. If decision soft/hard token samples exist, blend toward those medians.
5. No data → profile mid-band defaults (used_defaults=True).
Manual CLI overrides remain authoritative at run time.
"""
task = profile if isinstance(profile, TaskType) else TaskType(str(profile).lower())
defaults = ClmConfig.default_thresholds(task)
sids = session_ids if session_ids is not None else _list_session_ids(home)
peaks: list[int] = []
soft_tokens: list[int] = []
hard_tokens: list[int] = []
analyzed = 0
for sid in sids:
m_path = _metrics_path(home, sid)
d_path = _decisions_path(home, sid)
if not m_path.is_file() and not d_path.is_file():
continue
analyzed += 1
metrics_rows = _read_jsonl(m_path)
peak = _session_peak_cumulative(metrics_rows)
if peak is not None:
peaks.append(peak)
s_tok, h_tok = _collect_decision_tokens(_read_jsonl(d_path))
soft_tokens.extend(s_tok)
hard_tokens.extend(h_tok)
if not peaks and not soft_tokens and not hard_tokens:
return CalibrationResult(
profile=task,
soft_compact=defaults.soft_compact,
hard_handoff=defaults.hard_handoff,
sessions_analyzed=analyzed,
samples_peak=0,
method="profile_defaults",
notes="No metrics/decision samples found; returning profile mid-band defaults.",
used_defaults=True,
peak_totals=[],
decision_soft_tokens=[],
decision_hard_tokens=[],
)
peaks_sorted = sorted(peaks)
notes_parts: list[str] = []
if peaks_sorted:
soft = _percentile(peaks_sorted, soft_percentile)
hard = _percentile(peaks_sorted, hard_percentile)
method = f"peak_p{soft_percentile:g}_p{hard_percentile:g}"
notes_parts.append(
f"peaks n={len(peaks_sorted)} p{soft_percentile:g}={soft} "
f"p{hard_percentile:g}={hard}"
)
else:
soft = defaults.soft_compact
hard = defaults.hard_handoff
method = "decision_tokens"
notes_parts.append("no peak samples; basing on decision totals / defaults")
# Blend with decision-triggered tokens when available (simple average of sources).
if soft_tokens:
soft_med = _percentile(sorted(soft_tokens), 50)
soft = round((soft + soft_med) / 2)
notes_parts.append(f"soft blended with decision median={soft_med}")
if hard_tokens:
hard_med = _percentile(sorted(hard_tokens), 50)
hard = round((hard + hard_med) / 2)
notes_parts.append(f"hard blended with decision median={hard_med}")
# Enforce soft < hard with ratio floor.
min_hard = max(soft + 1, math.ceil(soft * min_hard_ratio))
if hard < min_hard:
notes_parts.append(f"hard raised to soft*{min_hard_ratio:g} floor={min_hard}")
hard = min_hard
# Keep integers non-negative and sensible minimums.
soft = max(1, soft)
hard = max(soft + 1, hard)
return CalibrationResult(
profile=task,
soft_compact=soft,
hard_handoff=hard,
sessions_analyzed=analyzed,
samples_peak=len(peaks_sorted),
method=method,
notes="; ".join(notes_parts),
used_defaults=False,
peak_totals=peaks_sorted,
decision_soft_tokens=sorted(soft_tokens),
decision_hard_tokens=sorted(hard_tokens),
)
def write_calibration(home: Path, result: CalibrationResult) -> Path:
"""Persist calibration suggestion under ``.clm/calibrations/<profile>.json``."""
out_dir = home / "calibrations"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{result.profile.value}.json"
payload = result.to_dict()
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return path
def load_calibration(home: Path, profile: TaskType | str) -> dict[str, Any] | None:
"""Load last written calibration for profile, if present."""
task = profile if isinstance(profile, TaskType) else TaskType(str(profile).lower())
path = home / "calibrations" / f"{task.value}.json"
if not path.is_file():
return None
data: object = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return None
return {str(k): v for k, v in data.items()}
+301 -5
View File
@@ -23,6 +23,27 @@ app = typer.Typer(
no_args_is_help=True,
)
export_app = typer.Typer(
name="export",
help="Export session observability artifacts",
no_args_is_help=True,
)
app.add_typer(export_app, name="export")
backup_app = typer.Typer(
name="backup",
help="Backup and restore CLM memory artifacts",
no_args_is_help=True,
)
app.add_typer(backup_app, name="backup")
experiment_app = typer.Typer(
name="experiment",
help="Run offline experiments (A/B memory, …)",
no_args_is_help=True,
)
app.add_typer(experiment_app, name="experiment")
_DEFAULT_PATH = Path(".clm")
@@ -47,6 +68,9 @@ def init_cmd(
typer.echo(" memory/logs/")
typer.echo(" sessions/")
typer.echo(" manifests/")
typer.echo(" backups/")
typer.echo(" exports/")
typer.echo(" calibrations/")
@app.command("decide")
@@ -67,6 +91,13 @@ def decide_cmd(
int | None,
typer.Option("--hard-handoff", help="Override hard_handoff threshold"),
] = None,
phase_boundary: Annotated[
bool,
typer.Option(
"--phase-boundary",
help="Simulate task status phase_boundary (P3 signal dry-run)",
),
] = False,
) -> None:
"""Dry-run DecisionEngine for a token count and profile."""
try:
@@ -81,7 +112,10 @@ def decide_cmd(
soft_compact=soft_compact,
hard_handoff=hard_handoff,
)
state = SessionState(session_id="decide-dry-run", goal="dry-run")
from clm.models.session import TaskStatus
status = TaskStatus.PHASE_BOUNDARY if phase_boundary else TaskStatus.RUNNING
state = SessionState(session_id="decide-dry-run", goal="dry-run", status=status)
# total_tokens drives the engine; input/output kept consistent.
snap = TokenSnapshot(
input_tokens=tokens,
@@ -100,10 +134,6 @@ def decide_cmd(
@app.command("run")
def run_cmd(
goal: Annotated[
str,
typer.Option("--goal", help="Session goal / user prompt seed"),
],
script: Annotated[
Path,
typer.Option(
@@ -111,11 +141,66 @@ def run_cmd(
help="JSON fixture: list of AgentStepResult objects",
),
],
goal: Annotated[
str | None,
typer.Option("--goal", help="Session goal / user prompt seed (required unless --resume)"),
] = None,
profile: Annotated[str, typer.Option("--profile")] = "coding",
soft_compact: Annotated[int | None, typer.Option("--soft-compact")] = None,
hard_handoff: Annotated[int | None, typer.Option("--hard-handoff")] = None,
max_steps: Annotated[int | None, typer.Option("--max-steps")] = None,
session_id: Annotated[str | None, typer.Option("--session-id")] = None,
resume: Annotated[
str | None,
typer.Option(
"--resume",
help="Resume session id from state.json (script continues at step_index)",
),
] = None,
memory_backend: Annotated[
str | None,
typer.Option(
"--memory-backend",
help="Memory backend: file | mem0 | hybrid (default: file)",
),
] = None,
require_confirm: Annotated[
str | None,
typer.Option(
"--require-confirm",
help="HITL: comma-separated actions needing confirm (compact,handoff)",
),
] = None,
yes: Annotated[
bool,
typer.Option(
"--yes",
help="Auto-approve HITL confirms (for non-interactive / CI)",
),
] = False,
agent_id: Annotated[
str | None,
typer.Option("--agent-id", help="Multi-agent identity written into handoffs"),
] = None,
memory_namespace: Annotated[
str | None,
typer.Option(
"--memory-namespace",
help="Shared memory key prefix for multi-agent handoffs",
),
] = None,
provider: Annotated[
str | None,
typer.Option("--provider", help="Cost-model provider key (optional)"),
] = None,
price_per_1k_input: Annotated[
float | None,
typer.Option("--price-per-1k-input", help="USD per 1k input tokens"),
] = None,
price_per_1k_output: Annotated[
float | None,
typer.Option("--price-per-1k-output", help="USD per 1k output tokens"),
] = None,
path: Annotated[
Path,
typer.Option("--path", help="CLM home directory"),
@@ -133,7 +218,16 @@ def run_cmd(
hard_handoff=hard_handoff,
max_steps=max_steps,
session_id=session_id,
resume=resume,
path=path,
memory_backend=memory_backend,
require_confirm=require_confirm,
yes=yes,
agent_id=agent_id,
memory_namespace=memory_namespace,
provider=provider,
price_per_1k_input=price_per_1k_input,
price_per_1k_output=price_per_1k_output,
)
except (FileNotFoundError, ValueError, TypeError) as exc:
typer.echo(str(exc), err=True)
@@ -208,5 +302,207 @@ def status_cmd(
raise typer.Exit(code=code)
@app.command("calibrate")
def calibrate_cmd(
profile: Annotated[
str,
typer.Option("--profile", help="Task profile to calibrate"),
] = "coding",
path: Annotated[
Path,
typer.Option("--path", help="CLM home directory"),
] = _DEFAULT_PATH,
write: Annotated[
bool,
typer.Option(
"--write",
help="Write recommendation to .clm/calibrations/<profile>.json",
),
] = False,
session_id: Annotated[
str | None,
typer.Option("--session-id", help="Limit calibration to one session"),
] = None,
) -> None:
"""Recommend soft/hard thresholds from metrics.jsonl (adaptive, P3)."""
from clm.cli.calibrate import run_calibrate
code = run_calibrate(
profile=profile,
path=path,
write=write,
session_id=session_id,
)
if code != 0:
raise typer.Exit(code=code)
@export_app.command("metrics")
def export_metrics_cmd(
format: Annotated[
str,
typer.Option("--format", help="Export format: csv | json | html"),
] = "json",
session_id: Annotated[
str | None,
typer.Option("--session-id", help="Session id (default: latest)"),
] = None,
path: Annotated[
Path,
typer.Option("--path", help="CLM home directory"),
] = _DEFAULT_PATH,
output: Annotated[
Path | None,
typer.Option("--output", help="Output directory (default: .clm/exports/<id>)"),
] = None,
) -> None:
"""Export metrics + decisions as CSV, JSON, or simple HTML report."""
from clm.cli.export_cmd import run_export_metrics
code = run_export_metrics(
format=format,
session_id=session_id,
path=path,
output=output,
)
if code != 0:
raise typer.Exit(code=code)
@app.command("signal")
def signal_cmd(
session_id: Annotated[
str,
typer.Option("--session-id", help="Session to signal"),
],
phase_done: Annotated[
bool,
typer.Option(
"--phase-done",
help="Mark session status as phase_boundary (external orchestration)",
),
] = False,
path: Annotated[
Path,
typer.Option("--path", help="CLM home directory"),
] = _DEFAULT_PATH,
) -> None:
"""External phase-orchestration signals (P3)."""
if not phase_done:
typer.echo(
"No signal flag provided. Use --phase-done to set PHASE_BOUNDARY.",
err=True,
)
raise typer.Exit(code=1)
from clm.cli.signal import run_signal_phase_done
code = run_signal_phase_done(session_id=session_id, path=path)
if code != 0:
raise typer.Exit(code=code)
@backup_app.command("memory")
def backup_memory_cmd(
path: Annotated[
Path,
typer.Option("--path", help="CLM home directory"),
] = _DEFAULT_PATH,
label: Annotated[
str | None,
typer.Option("--label", help="Optional label suffix for archive name"),
] = None,
) -> None:
"""Create a gzipped tarball of ``.clm/memory`` under ``.clm/backups/``."""
from clm.cli.backup_cmd import run_backup_memory
code = run_backup_memory(path=path, label=label)
if code != 0:
raise typer.Exit(code=code)
@backup_app.command("restore")
def backup_restore_cmd(
archive: Annotated[
Path,
typer.Option("--archive", help="Path to memory_*.tar.gz backup"),
],
path: Annotated[
Path,
typer.Option("--path", help="CLM home directory"),
] = _DEFAULT_PATH,
overwrite: Annotated[
bool,
typer.Option("--overwrite", help="Replace existing memory contents"),
] = False,
) -> None:
"""Restore file-backed memory from a backup archive."""
from clm.cli.backup_cmd import run_restore_memory
code = run_restore_memory(archive=archive, path=path, overwrite=overwrite)
if code != 0:
raise typer.Exit(code=code)
@experiment_app.command("ab-memory")
def experiment_ab_memory_cmd(
script: Annotated[
Path,
typer.Option("--script", help="JSON SimulatedAgent script"),
],
goal: Annotated[str, typer.Option("--goal", help="Shared goal for all arms")],
backends: Annotated[
str,
typer.Option(
"--backends",
help="Comma-separated backends: file,mem0,hybrid",
),
] = "file,mem0,hybrid",
profile: Annotated[str, typer.Option("--profile")] = "coding",
soft_compact: Annotated[int | None, typer.Option("--soft-compact")] = None,
hard_handoff: Annotated[int | None, typer.Option("--hard-handoff")] = None,
max_steps: Annotated[int | None, typer.Option("--max-steps")] = None,
experiment_id: Annotated[
str | None,
typer.Option("--experiment-id", help="Stable id for this experiment run"),
] = None,
agent_id: Annotated[
str | None,
typer.Option("--agent-id", help="Optional multi-agent identity"),
] = None,
memory_namespace: Annotated[
str | None,
typer.Option("--memory-namespace", help="Shared memory namespace prefix"),
] = None,
output: Annotated[
Path | None,
typer.Option("--output", help="Report directory (default: .clm/exports/ab-*)"),
] = None,
path: Annotated[
Path,
typer.Option("--path", help="CLM home directory"),
] = _DEFAULT_PATH,
) -> None:
"""A/B: run the same script against file / mem0 / hybrid and write a report."""
from clm.cli.experiment import run_ab_memory_cli
code = run_ab_memory_cli(
goal=goal,
script=script,
path=path,
backends=backends,
profile=profile,
soft_compact=soft_compact,
hard_handoff=hard_handoff,
max_steps=max_steps,
experiment_id=experiment_id,
output=output,
agent_id=agent_id,
memory_namespace=memory_namespace,
)
if code != 0:
raise typer.Exit(code=code)
if __name__ == "__main__":
app()
+45
View File
@@ -0,0 +1,45 @@
"""``clm backup`` — snapshot / restore file-backed memory."""
from __future__ import annotations
from pathlib import Path
import typer
from clm.cli.common import ensure_clm_layout, resolve_clm_home
from clm.memory.backup import backup_memory, restore_memory
def run_backup_memory(
*,
path: Path,
label: str | None = None,
) -> int:
"""Create a tar.gz backup of ``.clm/memory``. Returns exit code."""
home = resolve_clm_home(path)
ensure_clm_layout(home)
try:
archive = backup_memory(home, label=label)
except FileNotFoundError as exc:
typer.echo(str(exc), err=True)
return 1
typer.echo(f"backup={archive}")
return 0
def run_restore_memory(
*,
archive: Path,
path: Path,
overwrite: bool = False,
) -> int:
"""Restore memory from a backup archive. Returns exit code."""
home = resolve_clm_home(path)
ensure_clm_layout(home)
try:
root = restore_memory(Path(archive), home, overwrite=overwrite)
except (FileNotFoundError, FileExistsError, RuntimeError) as exc:
typer.echo(str(exc), err=True)
return 1
typer.echo(f"restored={root}")
return 0
+53
View File
@@ -0,0 +1,53 @@
"""``clm calibrate`` — recommend soft/hard thresholds from session metrics."""
from __future__ import annotations
from pathlib import Path
import typer
from clm.calibration.thresholds import calibrate_thresholds, write_calibration
from clm.cli.common import parse_profile, resolve_clm_home
def run_calibrate(
*,
profile: str = "coding",
path: Path | None = None,
write: bool = False,
session_id: str | None = None,
) -> int:
"""Analyze metrics and print recommended thresholds. Exit 0/1."""
home = resolve_clm_home(path)
if not home.is_dir():
typer.echo(f"CLM home not found: {home}. Run `clm init` first.", err=True)
return 1
try:
task = parse_profile(profile)
except ValueError as exc:
typer.echo(str(exc), err=True)
return 1
sids = [session_id] if session_id else None
result = calibrate_thresholds(home, task, session_ids=sids)
typer.echo(f"profile={result.profile.value}")
typer.echo(f"sessions_analyzed={result.sessions_analyzed}")
typer.echo(f"samples_peak={result.samples_peak}")
typer.echo(f"method={result.method}")
typer.echo(f"used_defaults={result.used_defaults}")
typer.echo(f"recommended soft_compact={result.soft_compact}")
typer.echo(f"recommended hard_handoff={result.hard_handoff}")
typer.echo(f"notes={result.notes}")
typer.echo(
"hint: apply via `clm run --soft-compact "
f"{result.soft_compact} --hard-handoff {result.hard_handoff}` "
"(manual overrides remain authoritative)"
)
if write:
out = write_calibration(home, result)
typer.echo(f"written={out}")
return 0
+55
View File
@@ -29,6 +29,9 @@ def ensure_clm_layout(home: Path) -> None:
(home / "memory" / "logs").mkdir(parents=True, exist_ok=True)
(home / "sessions").mkdir(parents=True, exist_ok=True)
(home / "manifests").mkdir(parents=True, exist_ok=True)
(home / "backups").mkdir(parents=True, exist_ok=True)
(home / "exports").mkdir(parents=True, exist_ok=True)
(home / "calibrations").mkdir(parents=True, exist_ok=True)
memory_md = home / "memory" / "MEMORY.md"
if not memory_md.is_file():
memory_md.write_text(DEFAULT_MEMORY_MD, encoding="utf-8")
@@ -82,6 +85,26 @@ def parse_profile(name: str) -> TaskType:
raise ValueError(f"Unknown profile {name!r}. Valid: {valid}") from exc
def parse_require_confirm(value: str | None) -> list[str]:
"""Parse comma-separated confirm actions: ``handoff``, ``compact``, or empty."""
if value is None or not str(value).strip():
return []
allowed = {"compact", "handoff"}
out: list[str] = []
for part in str(value).split(","):
key = part.strip().lower()
if not key:
continue
if key not in allowed:
raise ValueError(
f"Unknown confirm action {part!r}. Valid: compact, handoff "
"(comma-separated)"
)
if key not in out:
out.append(key)
return out
def build_config(
profile: str,
*,
@@ -89,6 +112,15 @@ def build_config(
soft_compact: int | None = None,
hard_handoff: int | None = None,
min_steps_between_compacts: int | None = None,
memory_backend: str | None = None,
verify_compaction: bool | None = None,
recall_score_threshold: float | None = None,
require_confirm_on: list[str] | None = None,
agent_id: str | None = None,
memory_namespace: str | None = None,
provider: str | None = None,
price_per_1k_input: float | None = None,
price_per_1k_output: float | None = None,
) -> ClmConfig:
"""Build ClmConfig from profile with optional threshold overrides."""
task = parse_profile(profile)
@@ -100,6 +132,29 @@ def build_config(
}
if min_steps_between_compacts is not None:
updates["min_steps_between_compacts"] = min_steps_between_compacts
if memory_backend is not None:
backend = memory_backend.strip().lower()
if backend not in ("file", "mem0", "hybrid"):
raise ValueError(
f"Unknown memory backend {memory_backend!r}. Valid: file, mem0, hybrid"
)
updates["memory_backend"] = backend
if verify_compaction is not None:
updates["verify_compaction"] = verify_compaction
if recall_score_threshold is not None:
updates["recall_score_threshold"] = recall_score_threshold
if require_confirm_on is not None:
updates["require_confirm_on"] = require_confirm_on
if agent_id is not None:
updates["agent_id"] = agent_id
if memory_namespace is not None:
updates["memory_namespace"] = memory_namespace
if provider is not None:
updates["provider"] = provider
if price_per_1k_input is not None:
updates["price_per_1k_input"] = price_per_1k_input
if price_per_1k_output is not None:
updates["price_per_1k_output"] = price_per_1k_output
return cfg.model_copy(update=updates)
+10 -3
View File
@@ -14,7 +14,7 @@ from clm.cli.common import (
save_session_state,
)
from clm.compaction.pipeline import CompactionPipeline
from clm.memory.file_store import FileMemoryStore
from clm.memory.factory import build_memory_store
def run_compact(
@@ -34,15 +34,22 @@ def run_compact(
return 1
cfg = build_config(profile, clm_home=home)
store = FileMemoryStore(home / "memory")
store = build_memory_store(cfg)
pipeline = CompactionPipeline(store=store, config=cfg)
result = pipeline.run(state)
state_file = save_session_state(home, state)
typer.echo(f"session_id={session_id}")
typer.echo("action=compact")
if result.fell_back_to_handoff:
typer.echo("action=compact_verify_fail")
else:
typer.echo("action=compact")
typer.echo(f"memory_keys_written={','.join(result.memory_keys_written)}")
typer.echo(f"messages_after={len(result.new_messages)}")
typer.echo(f"last_compact_step={state.last_compact_step}")
if result.recall_score is not None:
typer.echo(f"recall_score={result.recall_score}")
if result.verify_passed is not None:
typer.echo(f"verify_passed={result.verify_passed}")
typer.echo(f"state={state_file}")
return 0
+80
View File
@@ -0,0 +1,80 @@
"""``clm experiment`` — A/B and other experiment runners."""
from __future__ import annotations
from pathlib import Path
import typer
from clm.cli.common import ensure_clm_layout, resolve_clm_home
from clm.experiments.ab_memory import run_ab_memory_experiment, write_ab_report
def run_ab_memory_cli(
*,
goal: str,
script: Path,
path: Path,
backends: str = "file,mem0,hybrid",
profile: str = "coding",
soft_compact: int | None = None,
hard_handoff: int | None = None,
max_steps: int | None = None,
experiment_id: str | None = None,
output: Path | None = None,
agent_id: str | None = None,
memory_namespace: str | None = None,
) -> int:
"""Run A/B memory experiment and write report. Returns exit code."""
home = resolve_clm_home(path)
ensure_clm_layout(home)
backend_list = [b.strip().lower() for b in backends.split(",") if b.strip()]
if not backend_list:
typer.echo("No backends specified.", err=True)
return 1
try:
report = run_ab_memory_experiment(
goal=goal,
script=script,
path=home,
backends=backend_list,
profile=profile,
soft_compact=soft_compact,
hard_handoff=hard_handoff,
max_steps=max_steps,
experiment_id=experiment_id,
agent_id=agent_id,
memory_namespace=memory_namespace,
)
except (FileNotFoundError, ValueError, TypeError) as exc:
typer.echo(str(exc), err=True)
return 1
out_dir = (
Path(output)
if output is not None
else home / "exports" / f"ab-{report.experiment_id}"
)
paths = write_ab_report(report, out_dir)
typer.echo(f"experiment_id={report.experiment_id}")
for arm in report.arms:
recall = (
f"{arm.last_recall_score:.3f}"
if arm.last_recall_score is not None
else "n/a"
)
typer.echo(
f"arm backend={arm.backend} completed={arm.completed} "
f"stopped={arm.stopped_reason} tokens={arm.total_tokens} "
f"compact={arm.compact_count} recall={recall} "
f"interventions={arm.human_interventions}"
+ (f" error={arm.error}" if arm.error else "")
)
typer.echo(f"report_json={paths['json']}")
typer.echo(f"report_md={paths['md']}")
typer.echo("")
typer.echo(report.to_markdown())
return 0 if all(a.error is None for a in report.arms) else 1
+55
View File
@@ -0,0 +1,55 @@
"""``clm export metrics`` — CSV / JSON / HTML observability export."""
from __future__ import annotations
from pathlib import Path
import typer
from clm.cli.common import resolve_clm_home
from clm.export.metrics_export import ExportFormat, export_metrics
def run_export_metrics(
*,
format: str = "json",
session_id: str | None = None,
path: Path | None = None,
output: Path | None = None,
) -> int:
"""Export metrics/decisions. Exit 0/1."""
home = resolve_clm_home(path)
if not home.is_dir():
typer.echo(f"CLM home not found: {home}. Run `clm init` first.", err=True)
return 1
fmt = format.strip().lower()
if fmt not in ("csv", "json", "html"):
typer.echo(f"Unknown format {format!r}. Valid: csv, json, html", err=True)
return 1
try:
result = export_metrics(
home,
session_id=session_id,
fmt=fmt, # type: ignore[arg-type]
output_dir=output,
)
except FileNotFoundError as exc:
typer.echo(str(exc), err=True)
return 1
except ValueError as exc:
typer.echo(str(exc), err=True)
return 1
typer.echo(f"session_id={result.session_id}")
typer.echo(f"format={result.format}")
typer.echo(f"metrics_rows={result.metrics_rows}")
typer.echo(f"decision_rows={result.decision_rows}")
for p in result.paths:
typer.echo(f"written={p}")
return 0
# Re-export for type checkers
__all__ = ["ExportFormat", "run_export_metrics"]
+2 -2
View File
@@ -14,7 +14,7 @@ from clm.cli.common import (
save_session_state,
)
from clm.handoff.pipeline import HandoffPipeline
from clm.memory.file_store import FileMemoryStore
from clm.memory.factory import build_memory_store
from clm.models.decision import Decision, DecisionAction
@@ -35,7 +35,7 @@ def run_handoff(
return 1
cfg = build_config(profile, clm_home=home)
store = FileMemoryStore(home / "memory")
store = build_memory_store(cfg)
pipeline = HandoffPipeline(
store=store,
config=cfg,
+110 -12
View File
@@ -12,7 +12,9 @@ from clm.cli.common import (
build_config,
decisions_path,
ensure_clm_layout,
load_session_state,
metrics_path,
parse_require_confirm,
resolve_clm_home,
save_session_state,
session_dir,
@@ -21,18 +23,51 @@ from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
from clm.logging.audit import AuditLogger
from clm.loop.control import OuterControlLoop
from clm.memory.file_store import FileMemoryStore
from clm.loop.control import ConfirmCallback, OuterControlLoop
from clm.memory.factory import build_memory_store
from clm.metrics.monitor import MetricsMonitor
from clm.models.decision import Decision
from clm.models.session import (
AgentStepResult,
Message,
MessageRole,
SessionState,
TaskStatus,
)
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
_TERMINAL = frozenset({TaskStatus.COMPLETED, TaskStatus.FAILED})
# Exit code when HITL confirm is required but no interactive approval is possible.
EXIT_CONFIRM_REQUIRED = 2
def make_confirm_callback(*, yes: bool, interactive: bool) -> ConfirmCallback:
"""Build HITL callback: --yes always approves; else prompt if interactive."""
def _confirm(decision: Decision, state: SessionState) -> bool:
if yes:
return True
if not interactive:
# Non-interactive without --yes: deny (safe). Callers may map to exit 2.
typer.echo(
f"confirm required for action={decision.action.value} "
f"trigger={decision.trigger} session={state.session_id} "
"(pass --yes to auto-approve, or run interactively)",
err=True,
)
return False
answer = typer.prompt(
f"Confirm {decision.action.value} "
f"(trigger={decision.trigger}, step={state.step_index})? [y/N]",
default="n",
)
return str(answer).strip().lower() in {"y", "yes"}
return _confirm
def load_script(script_path: Path) -> list[AgentStepResult]:
"""Load a JSON array of AgentStepResult objects from ``script_path``."""
@@ -47,34 +82,78 @@ def load_script(script_path: Path) -> list[AgentStepResult]:
def run_session(
*,
goal: str,
goal: str | None = None,
script: Path,
profile: str = "coding",
soft_compact: int | None = None,
hard_handoff: int | None = None,
max_steps: int | None = None,
session_id: str | None = None,
resume: str | None = None,
path: Path | None = None,
min_steps_between_compacts: int | None = None,
memory_backend: str | None = None,
require_confirm: str | None = None,
yes: bool = False,
agent_id: str | None = None,
memory_namespace: str | None = None,
provider: str | None = None,
price_per_1k_input: float | None = None,
price_per_1k_output: float | None = None,
) -> int:
"""Execute OuterControlLoop; write metrics/decisions/state. Returns exit code."""
home = resolve_clm_home(path)
ensure_clm_layout(home)
sid = session_id or f"s-{uuid.uuid4().hex[:12]}"
steps = load_script(script)
if resume is not None:
sid = resume
state = load_session_state(home, sid)
if state.status in _TERMINAL:
raise ValueError(
f"Session {sid!r} already terminal (status={state.status.value}); "
"cannot resume."
)
if goal is not None and goal != state.goal:
typer.echo(
f"warning: --goal differs from persisted goal; keeping state.goal={state.goal!r}",
err=True,
)
script_offset = state.step_index
if agent_id is not None and state.agent_id is None:
state.agent_id = agent_id
else:
sid = session_id or f"s-{uuid.uuid4().hex[:12]}"
if goal is None or not str(goal).strip():
raise ValueError("--goal is required unless --resume is set")
script_offset = 0
state = SessionState(
session_id=sid,
goal=goal,
messages=[Message(role=MessageRole.USER, content=goal)],
agent_id=agent_id,
)
# Low soft thresholds in demos need compact allowed every step.
min_steps = min_steps_between_compacts
if min_steps is None and soft_compact is not None and soft_compact < 10_000:
min_steps = 1
confirm_on = parse_require_confirm(require_confirm)
cfg = build_config(
profile,
clm_home=home,
soft_compact=soft_compact,
hard_handoff=hard_handoff,
min_steps_between_compacts=min_steps,
memory_backend=memory_backend,
require_confirm_on=confirm_on if confirm_on else None,
agent_id=agent_id,
memory_namespace=memory_namespace,
provider=provider,
price_per_1k_input=price_per_1k_input,
price_per_1k_output=price_per_1k_output,
)
sdir = session_dir(home, sid)
@@ -82,17 +161,20 @@ def run_session(
dec_path = decisions_path(home, sid)
met_path = metrics_path(home, sid)
store = FileMemoryStore(home / "memory")
agent = SimulatedAgent(steps)
state = SessionState(
session_id=sid,
goal=goal,
messages=[Message(role=MessageRole.USER, content=goal)],
)
confirm_cb: ConfirmCallback | None = None
if cfg.require_confirm_on:
# --yes auto-approves; interactive prompt only on TTY without --yes.
import sys
interactive = sys.stdin.isatty() and not yes
confirm_cb = make_confirm_callback(yes=yes, interactive=interactive)
store = build_memory_store(cfg)
agent = SimulatedAgent(steps, script_offset=script_offset)
session = WorkingSession(agent, state)
loop = OuterControlLoop(
session=session,
monitor=MetricsMonitor(met_path),
monitor=MetricsMonitor(met_path, config=cfg),
engine=DecisionEngine(cfg),
compaction=CompactionPipeline(store=store, config=cfg),
handoff=HandoffPipeline(
@@ -102,6 +184,7 @@ def run_session(
),
audit=AuditLogger(dec_path),
config=cfg,
confirm_callback=confirm_cb,
)
result = loop.run(max_steps=max_steps)
@@ -114,10 +197,25 @@ def run_session(
typer.echo(f"tokens.total={result.final_state.tokens.total_tokens}")
typer.echo(f"compact_count={result.compact_count}")
typer.echo(f"decisions={len(result.decisions)}")
typer.echo(f"human_interventions={result.final_state.human_interventions}")
if result.confirm_denied:
typer.echo(f"confirm_denied={result.confirm_denied}")
if result.last_recall_score is not None:
typer.echo(f"last_recall_score={result.last_recall_score}")
if result.handoffs:
for hp in result.handoffs:
typer.echo(f"handoff_manifest={hp}")
typer.echo(f"state={state_file}")
typer.echo(f"metrics={met_path}")
typer.echo(f"decisions_log={dec_path}")
# External orchestrators: confirm required but denied without --yes → exit 2.
if (
result.confirm_denied > 0
and cfg.require_confirm_on
and not yes
and result.compact_count == 0
and not result.handoffs
):
return EXIT_CONFIRM_REQUIRED
return 0
+80
View File
@@ -0,0 +1,80 @@
"""``clm signal`` — external phase-orchestration hooks (P3 M3.3)."""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
import typer
from clm.cli.common import (
decisions_path,
load_session_state,
resolve_clm_home,
save_session_state,
)
from clm.logging.audit import AuditLogger
from clm.models.session import TaskStatus
def run_signal_phase_done(
*,
session_id: str,
path: Path | None = None,
) -> int:
"""Mark session as PHASE_BOUNDARY so next decide/run prefers handoff.
Writes state.json and appends a signal event to decisions.jsonl.
Does **not** run the handoff pipeline itself — orchestrators may call
``clm handoff`` or resume ``clm run`` afterwards.
"""
home = resolve_clm_home(path)
if not home.is_dir():
typer.echo(f"CLM home not found: {home}. Run `clm init` first.", err=True)
return 1
try:
state = load_session_state(home, session_id)
except FileNotFoundError as exc:
typer.echo(str(exc), err=True)
return 1
if state.status in {TaskStatus.COMPLETED, TaskStatus.FAILED}:
typer.echo(
f"Session {session_id!r} is terminal (status={state.status.value}); "
"cannot set phase_boundary.",
err=True,
)
return 1
prev = state.status
state.status = TaskStatus.PHASE_BOUNDARY
state_file = save_session_state(home, state)
ts = datetime.now(UTC).isoformat().replace("+00:00", "Z")
AuditLogger(decisions_path(home, session_id)).append(
{
"ts": ts,
"event": "signal",
"signal": "phase_done",
"action": "phase_boundary",
"trigger": "phase_boundary",
"rationale": (
f"external phase-done signal; status {prev.value} → phase_boundary"
),
"step_index": state.step_index,
"total_tokens": state.tokens.total_tokens,
"session_id": session_id,
}
)
typer.echo(f"session_id={session_id}")
typer.echo("signal=phase_done")
typer.echo(f"status={state.status.value}")
typer.echo(f"previous_status={prev.value}")
typer.echo(f"state={state_file}")
typer.echo(
"hint: prefer_handoff_on_phase=True → next DecisionEngine call / "
"`clm run --resume` will HANDOFF (trigger=phase_boundary)"
)
return 0
+4 -1
View File
@@ -1,11 +1,14 @@
"""Deterministic compaction pipeline (structured summary + compact hint)."""
"""Deterministic compaction pipeline (structured summary + compact hint + verify)."""
from clm.compaction.pipeline import CompactionPipeline, CompactResult
from clm.compaction.summary import build_compact_hint, build_structured_summary
from clm.compaction.verify import VerificationSuite, VerifyResult
__all__ = [
"CompactResult",
"CompactionPipeline",
"VerificationSuite",
"VerifyResult",
"build_compact_hint",
"build_structured_summary",
]
+68 -8
View File
@@ -1,10 +1,11 @@
"""CompactionPipeline: deterministic summary + hint, no verify suite (MVP/P1)."""
"""CompactionPipeline: deterministic summary + optional verify-before-mutate (P2)."""
from __future__ import annotations
from pydantic import BaseModel, Field
from clm.compaction.summary import build_compact_hint, build_structured_summary
from clm.compaction.verify import VerificationSuite, VerifyQuestionResult, VerifyResult
from clm.memory.base import MemoryStore
from clm.models.config import ClmConfig
from clm.models.session import Message, MessageRole, SessionState
@@ -17,6 +18,11 @@ class CompactResult(BaseModel):
summary: str
memory_keys_written: list[str] = Field(default_factory=list)
new_messages: list[Message] = Field(default_factory=list)
# P2 verification
recall_score: float | None = None
verify_passed: bool | None = None
verify_details: list[VerifyQuestionResult] = Field(default_factory=list)
fell_back_to_handoff: bool = False
def _retain_recent_user_assistant(
@@ -35,15 +41,23 @@ def _retain_recent_user_assistant(
class CompactionPipeline:
"""Deterministic compaction: summary → memory snapshot → hint seed.
"""Deterministic compaction: summary → memory → verify → optional message shrink.
Does **not** run a verification suite (P2). Mutates ``state`` in place:
``messages``, ``last_compact_step``, and ``memory_keys``.
When ``config.verify_compaction`` is True, verification runs **before**
mutating ``state.messages``. On verify failure, messages and
``last_compact_step`` are left unchanged; memory snapshot may still exist.
"""
def __init__(self, store: MemoryStore, config: ClmConfig) -> None:
def __init__(
self,
store: MemoryStore,
config: ClmConfig,
*,
verifier: VerificationSuite | None = None,
) -> None:
self._store = store
self._config = config
self._verifier = verifier if verifier is not None else VerificationSuite()
def run(self, state: SessionState) -> CompactResult:
summary = build_structured_summary(state)
@@ -55,10 +69,41 @@ class CompactionPipeline:
tags=["compact", "session", state.session_id],
)
if memory_key not in state.memory_keys:
state.memory_keys = [*state.memory_keys, memory_key]
# Provisional keys for hint (include snapshot even if verify fails).
memory_keys = list(state.memory_keys)
if memory_key not in memory_keys:
memory_keys = [*memory_keys, memory_key]
hint = build_compact_hint(state, memory_keys=memory_keys)
verify_result: VerifyResult | None = None
if self._config.verify_compaction:
verify_result = self._verifier.verify(
state=state,
summary=summary,
hint=hint,
threshold=self._config.recall_score_threshold,
)
if verify_result is not None and not verify_result.passed:
# Keep messages intact; still record memory key so handoff can find snapshot.
if memory_key not in state.memory_keys:
state.memory_keys = memory_keys
return CompactResult(
hint=hint,
summary=summary,
memory_keys_written=[memory_key],
new_messages=list(state.messages),
recall_score=verify_result.recall_score,
verify_passed=False,
verify_details=list(verify_result.questions),
fell_back_to_handoff=True,
)
# Verify passed or disabled → mutate window.
if memory_key not in state.memory_keys:
state.memory_keys = memory_keys
hint = build_compact_hint(state, memory_keys=state.memory_keys)
system_msg = Message(role=MessageRole.SYSTEM, content=hint)
retained = _retain_recent_user_assistant(
state.messages,
@@ -69,9 +114,24 @@ class CompactionPipeline:
state.messages = new_messages
state.last_compact_step = state.step_index
recall_score: float | None = None
verify_passed: bool | None = None
details: list[VerifyQuestionResult] = []
if verify_result is not None:
recall_score = verify_result.recall_score
verify_passed = verify_result.passed
details = list(verify_result.questions)
elif not self._config.verify_compaction:
verify_passed = None
recall_score = None
return CompactResult(
hint=hint,
summary=summary,
memory_keys_written=[memory_key],
new_messages=list(new_messages),
recall_score=recall_score,
verify_passed=verify_passed,
verify_details=details,
fell_back_to_handoff=False,
)
+188
View File
@@ -0,0 +1,188 @@
"""Deterministic VerificationSuite for post-compact recall scoring (P2)."""
from __future__ import annotations
from pydantic import BaseModel, Field
from clm.models.session import SessionState
class VerifyQuestion(BaseModel):
"""One recall question derived from session state facts."""
id: str
question: str
expected_phrases: list[str] = Field(default_factory=list)
source: str = "" # e.g. verified, constraint, blocker, goal
class VerifyQuestionResult(BaseModel):
"""Outcome of checking one verification question against compact context."""
question: VerifyQuestion
passed: bool
matched_phrases: list[str] = Field(default_factory=list)
missing_phrases: list[str] = Field(default_factory=list)
class VerifyResult(BaseModel):
"""Aggregate recall score for a compaction attempt."""
questions: list[VerifyQuestionResult] = Field(default_factory=list)
passed_count: int = 0
total_count: int = 0
recall_score: float = 1.0
passed: bool = True
@classmethod
def empty_pass(cls) -> VerifyResult:
"""No questions → nothing to lose; score 1.0 and pass."""
return cls(
questions=[],
passed_count=0,
total_count=0,
recall_score=1.0,
passed=True,
)
def _normalize(text: str) -> str:
return " ".join(text.casefold().split())
def _phrases_from_fact(fact: str) -> list[str]:
"""Derive keyphrases from a fact string (full fact + significant tokens)."""
cleaned = fact.strip()
if not cleaned:
return []
phrases = [cleaned]
# Also require the full normalized form as the primary phrase only;
# multi-word facts are checked as a whole substring.
return phrases
def build_questions(state: SessionState) -> list[VerifyQuestion]:
"""Build verification questions from goal, verified, constraints, blockers."""
questions: list[VerifyQuestion] = []
idx = 0
goal = state.goal.strip()
if goal:
questions.append(
VerifyQuestion(
id=f"q{idx}",
question=f"What is the session goal? ({goal[:80]})",
expected_phrases=_phrases_from_fact(goal),
source="goal",
)
)
idx += 1
for item in state.verified:
text = item.strip()
if not text:
continue
questions.append(
VerifyQuestion(
id=f"q{idx}",
question=f"Can you still recall verified fact: {text[:80]}?",
expected_phrases=_phrases_from_fact(text),
source="verified",
)
)
idx += 1
for item in state.assumptions_risks:
text = item.strip()
if not text:
continue
questions.append(
VerifyQuestion(
id=f"q{idx}",
question=f"Can you reconstruct constraint/assumption: {text[:80]}?",
expected_phrases=_phrases_from_fact(text),
source="constraint",
)
)
idx += 1
for item in state.blockers:
text = item.strip()
if not text:
continue
questions.append(
VerifyQuestion(
id=f"q{idx}",
question=f"Is blocker still known: {text[:80]}?",
expected_phrases=_phrases_from_fact(text),
source="blocker",
)
)
idx += 1
return questions
def check_answer(corpus: str, expected_phrases: list[str]) -> tuple[bool, list[str], list[str]]:
"""Return (passed, matched, missing) if all expected phrases appear in corpus."""
hay = _normalize(corpus)
matched: list[str] = []
missing: list[str] = []
for phrase in expected_phrases:
needle = _normalize(phrase)
if not needle:
continue
if needle in hay:
matched.append(phrase)
else:
missing.append(phrase)
# Pass only if every non-empty phrase matched.
non_empty = [p for p in expected_phrases if _normalize(p)]
passed = bool(non_empty) and len(missing) == 0
if not non_empty:
passed = True
return passed, matched, missing
class VerificationSuite:
"""Generate and score recall questions against compact summary/hint text."""
def verify(
self,
*,
state: SessionState,
summary: str,
hint: str,
extra_corpus: str = "",
threshold: float = 0.8,
) -> VerifyResult:
"""Score how well summary+hint retain critical facts from ``state``."""
questions = build_questions(state)
if not questions:
return VerifyResult.empty_pass()
corpus = "\n".join(part for part in (summary, hint, extra_corpus) if part)
results: list[VerifyQuestionResult] = []
passed_count = 0
for q in questions:
ok, matched, missing = check_answer(corpus, q.expected_phrases)
if ok:
passed_count += 1
results.append(
VerifyQuestionResult(
question=q,
passed=ok,
matched_phrases=matched,
missing_phrases=missing,
)
)
total = len(results)
score = passed_count / total if total else 1.0
return VerifyResult(
questions=results,
passed_count=passed_count,
total_count=total,
recall_score=score,
passed=score >= threshold,
)
+8
View File
@@ -1,7 +1,15 @@
"""Rule-based decision engine for compact / handoff / continue."""
from clm.decision.engine import DecisionEngine
from clm.decision.policy import (
BlockersForceHandoffPolicy,
DecisionPolicy,
PreferHandoffOverCompactPolicy,
)
__all__ = [
"BlockersForceHandoffPolicy",
"DecisionEngine",
"DecisionPolicy",
"PreferHandoffOverCompactPolicy",
]
+33 -4
View File
@@ -2,6 +2,9 @@
from __future__ import annotations
from collections.abc import Sequence
from clm.decision.policy import DecisionPolicy
from clm.decision.rules import may_compact
from clm.models.config import ClmConfig
from clm.models.decision import Decision, DecisionAction
@@ -18,14 +21,30 @@ class DecisionEngine:
3. total_tokens >= soft_compact and enough steps since last compact
→ COMPACT (soft_compact); last_compact_step=None counts as ok
4. else CONTINUE
Optional ``policies`` run after the base rules; first non-None override wins
(policies are applied in order; later policies may further override).
"""
def __init__(self, config: ClmConfig) -> None:
def __init__(
self,
config: ClmConfig,
policies: Sequence[DecisionPolicy] | None = None,
) -> None:
self._config = config
self._policies: list[DecisionPolicy] = list(policies) if policies else []
def decide(self, state: SessionState, metrics: TokenSnapshot) -> Decision:
decision = self._base_decide(state, metrics)
for policy in self._policies:
override = policy.maybe_override(state, metrics, decision, self._config)
if override is not None:
decision = override
return decision
def _base_decide(self, state: SessionState, metrics: TokenSnapshot) -> Decision:
cfg = self._config
total = metrics.total_tokens
total, token_label = self._token_basis(metrics)
soft = cfg.thresholds.soft_compact
hard = cfg.thresholds.hard_handoff
@@ -44,7 +63,7 @@ class DecisionEngine:
if total >= hard:
return Decision(
action=DecisionAction.HANDOFF,
rationale=f"tokens.total_tokens ({total}) >= hard_handoff ({hard})",
rationale=f"{token_label} ({total}) >= hard_handoff ({hard})",
trigger="hard_handoff",
)
@@ -52,7 +71,7 @@ class DecisionEngine:
if total >= soft and may_compact(state, cfg):
return Decision(
action=DecisionAction.COMPACT,
rationale=f"tokens.total_tokens ({total}) >= soft_compact ({soft})",
rationale=f"{token_label} ({total}) >= soft_compact ({soft})",
trigger="soft_compact",
)
@@ -62,3 +81,13 @@ class DecisionEngine:
rationale="no handoff or compact rule matched",
trigger="none",
)
def _token_basis(self, metrics: TokenSnapshot) -> tuple[int, str]:
"""Return (token_count, label) for threshold comparison."""
cfg = self._config
if (
cfg.use_context_tokens_for_thresholds
and metrics.context_tokens is not None
):
return metrics.context_tokens, "tokens.context_tokens"
return metrics.total_tokens, "tokens.total_tokens"
+75
View File
@@ -0,0 +1,75 @@
"""Decision policy plugins — optional overrides behind DecisionEngine."""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from clm.models.config import ClmConfig
from clm.models.decision import Decision, DecisionAction
from clm.models.metrics import TokenSnapshot
from clm.models.session import SessionState
@runtime_checkable
class DecisionPolicy(Protocol):
"""Plugin interface: return a Decision to override, or None to keep current."""
def maybe_override(
self,
state: SessionState,
metrics: TokenSnapshot,
decision: Decision,
config: ClmConfig,
) -> Decision | None:
"""Optionally replace the rule-based decision with a policy decision."""
...
class BlockersForceHandoffPolicy:
"""If session has blockers and decision is CONTINUE, force HANDOFF.
Lightweight example of a research/ops policy plugin (ARC/ACM-style
escalation rules can plug in the same way).
"""
def maybe_override(
self,
state: SessionState,
metrics: TokenSnapshot,
decision: Decision,
config: ClmConfig,
) -> Decision | None:
del metrics, config # unused; interface-stable
if decision.action != DecisionAction.CONTINUE:
return None
if not state.blockers:
return None
joined = "; ".join(b.strip() for b in state.blockers if b.strip())
return Decision(
action=DecisionAction.HANDOFF,
rationale=f"policy blockers_force_handoff: {joined or 'blockers present'}",
trigger="policy:blockers_force_handoff",
)
class PreferHandoffOverCompactPolicy:
"""When rule engine chose COMPACT, upgrade to HANDOFF (ops switch)."""
def maybe_override(
self,
state: SessionState,
metrics: TokenSnapshot,
decision: Decision,
config: ClmConfig,
) -> Decision | None:
del state, metrics, config
if decision.action != DecisionAction.COMPACT:
return None
return Decision(
action=DecisionAction.HANDOFF,
rationale=(
f"policy prefer_handoff_over_compact: upgraded from compact "
f"(was trigger={decision.trigger})"
),
trigger="policy:prefer_handoff_over_compact",
)
+9
View File
@@ -0,0 +1,9 @@
"""Experiment runners (A/B memory strategies, etc.)."""
from clm.experiments.ab_memory import AbArmResult, AbMemoryReport, run_ab_memory_experiment
__all__ = [
"AbArmResult",
"AbMemoryReport",
"run_ab_memory_experiment",
]
+290
View File
@@ -0,0 +1,290 @@
"""A/B memory experiment: run same Simulated script across backends."""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
from clm.cli.common import (
build_config,
decisions_path,
ensure_clm_layout,
metrics_path,
save_session_state,
session_dir,
)
from clm.cli.run import load_script
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
from clm.logging.audit import AuditLogger
from clm.loop.control import OuterControlLoop
from clm.memory.factory import build_memory_store
from clm.memory.mem0_store import InMemoryMem0Client, Mem0Client
from clm.metrics.cost import cost_for_config
from clm.metrics.monitor import MetricsMonitor
from clm.models.config import ClmConfig
from clm.models.session import Message, MessageRole, SessionState, TaskStatus
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
_VALID_BACKENDS: frozenset[str] = frozenset({"file", "mem0", "hybrid"})
class AbArmResult(BaseModel):
"""Outcome of one backend arm in an A/B memory experiment."""
backend: str
session_id: str
stopped_reason: str
status: str
completed: bool
total_tokens: int
input_tokens: int = 0
output_tokens: int = 0
compact_count: int = 0
handoff_count: int = 0
last_recall_score: float | None = None
human_interventions: int = 0
steps: int = 0
cost_usd: float | None = None
error: str | None = None
class AbMemoryReport(BaseModel):
"""Aggregate report for file vs mem0 vs hybrid (or any subset)."""
goal: str
script: str
profile: str
arms: list[AbArmResult] = Field(default_factory=list)
experiment_id: str = ""
def to_markdown(self) -> str:
"""Render a compact markdown comparison table."""
lines = [
f"# A/B Memory Report (`{self.experiment_id}`)",
"",
f"- **goal:** {self.goal}",
f"- **script:** {self.script}",
f"- **profile:** {self.profile}",
"",
(
"| backend | completed | stopped | tokens | steps | compact | handoff | "
"recall | interventions | cost_usd |"
),
(
"|---------|-----------|---------|--------|-------|---------|---------|"
"--------|---------------|----------|"
),
]
for arm in self.arms:
recall = (
f"{arm.last_recall_score:.3f}"
if arm.last_recall_score is not None
else "—"
)
cost = f"{arm.cost_usd:.6f}" if arm.cost_usd is not None else "—"
err = f" ERR:{arm.error}" if arm.error else ""
lines.append(
f"| {arm.backend} | {arm.completed} | {arm.stopped_reason}{err} | "
f"{arm.total_tokens} | {arm.steps} | {arm.compact_count} | "
f"{arm.handoff_count} | {recall} | {arm.human_interventions} | {cost} |"
)
lines.append("")
return "\n".join(lines)
def _run_one_arm(
*,
backend: str,
goal: str,
steps: list[Any],
cfg_base: ClmConfig,
home: Path,
experiment_id: str,
soft_compact: int | None,
hard_handoff: int | None,
max_steps: int | None,
mem0_client: Mem0Client | None,
) -> AbArmResult:
sid = f"{experiment_id}-{backend}-{uuid.uuid4().hex[:8]}"
cfg = cfg_base.model_copy(
update={
"memory_backend": backend,
"clm_home": home,
}
)
# Ensure thresholds from caller overrides already applied via cfg_base.
sdir = session_dir(home, sid)
sdir.mkdir(parents=True, exist_ok=True)
met_path = metrics_path(home, sid)
dec_path = decisions_path(home, sid)
state = SessionState(
session_id=sid,
goal=goal,
messages=[Message(role=MessageRole.USER, content=goal)],
agent_id=cfg.agent_id,
)
# Offline A/B: inject in-memory Mem0 client when needed and none provided.
client = mem0_client
if backend in ("mem0", "hybrid") and client is None:
client = InMemoryMem0Client()
try:
store = build_memory_store(cfg, mem0_client=client)
agent = SimulatedAgent(steps, script_offset=0)
session = WorkingSession(agent, state)
loop = OuterControlLoop(
session=session,
monitor=MetricsMonitor(met_path, config=cfg),
engine=DecisionEngine(cfg),
compaction=CompactionPipeline(store=store, config=cfg),
handoff=HandoffPipeline(
store=store,
config=cfg,
manifests_dir=home / "manifests",
),
audit=AuditLogger(dec_path),
config=cfg,
)
result = loop.run(max_steps=max_steps)
save_session_state(home, result.final_state)
cost = cost_for_config(
cfg,
input_tokens=result.final_state.tokens.input_tokens,
output_tokens=result.final_state.tokens.output_tokens,
)
return AbArmResult(
backend=backend,
session_id=sid,
stopped_reason=result.stopped_reason,
status=result.final_state.status.value,
completed=result.final_state.status == TaskStatus.COMPLETED
or result.stopped_reason == "completed",
total_tokens=result.final_state.tokens.total_tokens,
input_tokens=result.final_state.tokens.input_tokens,
output_tokens=result.final_state.tokens.output_tokens,
compact_count=result.compact_count,
handoff_count=len(result.handoffs),
last_recall_score=result.last_recall_score,
human_interventions=result.final_state.human_interventions,
steps=result.final_state.step_index,
cost_usd=round(cost, 8) if cost is not None else None,
)
except Exception as exc: # noqa: BLE001 — experiment arm isolation
return AbArmResult(
backend=backend,
session_id=sid,
stopped_reason="error",
status="failed",
completed=False,
total_tokens=0,
error=f"{type(exc).__name__}: {exc}",
)
def run_ab_memory_experiment(
*,
goal: str,
script: Path,
path: Path,
backends: list[str] | None = None,
profile: str = "coding",
soft_compact: int | None = None,
hard_handoff: int | None = None,
max_steps: int | None = None,
experiment_id: str | None = None,
mem0_client: Mem0Client | None = None,
agent_id: str | None = None,
memory_namespace: str | None = None,
) -> AbMemoryReport:
"""Run the same SimulatedAgent script for each memory backend and compare."""
home = Path(path)
ensure_clm_layout(home)
chosen = backends or ["file", "mem0", "hybrid"]
for b in chosen:
if b not in _VALID_BACKENDS:
raise ValueError(
f"Unknown backend {b!r}. Valid: {', '.join(sorted(_VALID_BACKENDS))}"
)
steps = load_script(script)
exp_id = experiment_id or f"ab-{uuid.uuid4().hex[:10]}"
min_steps = None
if soft_compact is not None and soft_compact < 10_000:
min_steps = 1
cfg = build_config(
profile,
clm_home=home,
soft_compact=soft_compact,
hard_handoff=hard_handoff,
min_steps_between_compacts=min_steps,
)
updates: dict[str, object] = {}
if agent_id is not None:
updates["agent_id"] = agent_id
if memory_namespace is not None:
updates["memory_namespace"] = memory_namespace
if updates:
cfg = cfg.model_copy(update=updates)
arms: list[AbArmResult] = []
for backend in chosen:
# Fresh mem0 client per arm when not injected (isolation).
arm_client = mem0_client
if arm_client is None and backend in ("mem0", "hybrid"):
arm_client = InMemoryMem0Client()
arms.append(
_run_one_arm(
backend=backend,
goal=goal,
steps=steps,
cfg_base=cfg,
home=home,
experiment_id=exp_id,
soft_compact=soft_compact,
hard_handoff=hard_handoff,
max_steps=max_steps,
mem0_client=arm_client,
)
)
return AbMemoryReport(
goal=goal,
script=str(script),
profile=profile,
arms=arms,
experiment_id=exp_id,
)
def write_ab_report(report: AbMemoryReport, output_dir: Path) -> dict[str, Path]:
"""Write JSON + Markdown report under ``output_dir``. Returns written paths."""
output_dir.mkdir(parents=True, exist_ok=True)
json_path = output_dir / "ab_memory_report.json"
md_path = output_dir / "ab_memory_report.md"
json_path.write_text(
json.dumps(report.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
md_path.write_text(report.to_markdown(), encoding="utf-8")
return {"json": json_path, "md": md_path}
__all__ = [
"AbArmResult",
"AbMemoryReport",
"run_ab_memory_experiment",
"write_ab_report",
]
+5
View File
@@ -0,0 +1,5 @@
"""Observability export for metrics and decisions (P3)."""
from clm.export.metrics_export import ExportResult, export_metrics
__all__ = ["ExportResult", "export_metrics"]
+258
View File
@@ -0,0 +1,258 @@
"""Export session metrics/decisions to CSV, JSON, or simple HTML (P3 M3.2)."""
from __future__ import annotations
import csv
import html
import io
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
ExportFormat = Literal["csv", "json", "html"]
@dataclass(frozen=True)
class ExportResult:
"""Paths written by an export run."""
session_id: str
format: ExportFormat
paths: list[Path]
metrics_rows: int
decision_rows: int
def _list_session_ids(home: Path) -> list[str]:
root = home / "sessions"
if not root.is_dir():
return []
return sorted(p.name for p in root.iterdir() if p.is_dir())
def _latest_session_id(home: Path) -> str | None:
best_id: str | None = None
best_mtime = -1.0
for sid in _list_session_ids(home):
sdir = home / "sessions" / sid
candidates = [
sdir / "decisions.jsonl",
sdir / "metrics.jsonl",
sdir / "state.json",
]
mtimes = [p.stat().st_mtime for p in candidates if p.is_file()]
if not mtimes:
continue
m = max(mtimes)
if m > best_mtime:
best_mtime = m
best_id = sid
return best_id
def _metrics_path(home: Path, session_id: str) -> Path:
return home / "sessions" / session_id / "metrics.jsonl"
def _decisions_path(home: Path, session_id: str) -> Path:
return home / "sessions" / session_id / "decisions.jsonl"
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
if not path.is_file():
return []
rows: list[dict[str, Any]] = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
rows.append(json.loads(line))
return rows
def _write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if not rows:
path.write_text("", encoding="utf-8")
return
# Stable column order: union of keys in first-seen order.
fieldnames: list[str] = []
seen: set[str] = set()
for row in rows:
for k in row:
if k not in seen:
seen.add(k)
fieldnames.append(k)
with path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow({k: row.get(k, "") for k in fieldnames})
def _series_from_metrics(metrics: list[dict[str, Any]]) -> list[tuple[int, int]]:
"""Return (step_index, cumulative_total) points."""
cum = 0
series: list[tuple[int, int]] = []
for row in metrics:
step = int(row.get("step_index") or 0)
cum += int(row.get("total_tokens") or 0)
series.append((step, cum))
return series
def _html_report(
session_id: str,
metrics: list[dict[str, Any]],
decisions: list[dict[str, Any]],
) -> str:
series = _series_from_metrics(metrics)
# Simple SVG polyline for token-over-steps.
svg = ""
if series:
max_y = max(y for _, y in series) or 1
max_x = max(x for x, _ in series) or 1
w, h, pad = 640, 200, 20
pts: list[str] = []
for x, y in series:
px = pad + (w - 2 * pad) * (x / max_x if max_x else 0)
py = h - pad - (h - 2 * pad) * (y / max_y if max_y else 0)
pts.append(f"{px:.1f},{py:.1f}")
polyline = " ".join(pts)
svg = f"""
<svg viewBox="0 0 {w} {h}" width="100%" style="max-width:720px;background:#0f172a;border-radius:8px">
<polyline fill="none" stroke="#38bdf8" stroke-width="2" points="{polyline}" />
<text x="{pad}" y="16" fill="#94a3b8" font-size="12">tokens (cumulative)</text>
<text x="{pad}" y="{h - 4}" fill="#64748b" font-size="11">steps → peak={max_y}</text>
</svg>
"""
def rows_table(title: str, rows: list[dict[str, Any]], limit: int = 50) -> str:
if not rows:
return f"<h2>{html.escape(title)}</h2><p><em>empty</em></p>"
keys: list[str] = []
seen: set[str] = set()
for r in rows:
for k in r:
if k not in seen:
seen.add(k)
keys.append(k)
head = "".join(f"<th>{html.escape(k)}</th>" for k in keys)
body_parts: list[str] = []
for r in rows[-limit:]:
cells = "".join(
f"<td>{html.escape(str(r.get(k, '')))}</td>" for k in keys
)
body_parts.append(f"<tr>{cells}</tr>")
body = "\n".join(body_parts)
return f"""
<h2>{html.escape(title)} <small>(last {min(limit, len(rows))} of {len(rows)})</small></h2>
<table>
<thead><tr>{head}</tr></thead>
<tbody>{body}</tbody>
</table>
"""
dec_timeline = io.StringIO()
dec_timeline.write("<ol class='timeline'>")
for d in decisions:
action = html.escape(str(d.get("action", "")))
trigger = html.escape(str(d.get("trigger", "")))
step = html.escape(str(d.get("step_index", "")))
rationale = html.escape(str(d.get("rationale", "")))
dec_timeline.write(
f"<li><strong>{action}</strong> "
f"<code>{trigger}</code> step={step} — {rationale}</li>"
)
dec_timeline.write("</ol>")
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>CLM metrics — {html.escape(session_id)}</title>
<style>
body {{ font-family: ui-sans-serif, system-ui, sans-serif; margin: 1.5rem; background: #f8fafc; color: #0f172a; }}
h1 {{ margin-bottom: 0.25rem; }}
.meta {{ color: #64748b; margin-bottom: 1.5rem; }}
table {{ border-collapse: collapse; width: 100%; font-size: 12px; background: #fff; }}
th, td {{ border: 1px solid #e2e8f0; padding: 4px 8px; text-align: left; }}
th {{ background: #f1f5f9; }}
.timeline {{ line-height: 1.6; }}
</style>
</head>
<body>
<h1>CLM session report</h1>
<p class="meta">session_id={html.escape(session_id)} · metrics={len(metrics)} · decisions={len(decisions)}</p>
<h2>Token over steps</h2>
{svg or "<p><em>no metrics</em></p>"}
<h2>Decision timeline</h2>
{dec_timeline.getvalue()}
{rows_table("Metrics (raw)", metrics)}
{rows_table("Decisions (raw)", decisions)}
</body>
</html>
"""
def export_metrics(
home: Path,
*,
session_id: str | None = None,
fmt: ExportFormat = "json",
output_dir: Path | None = None,
) -> ExportResult:
"""Export metrics + decisions for one session into ``output_dir``."""
sid = session_id
if sid is None:
sid = _latest_session_id(home)
if sid is None:
sessions = _list_session_ids(home)
raise FileNotFoundError(
f"No sessions under {home / 'sessions'}"
+ (f" (found dirs: {sessions})" if sessions else "")
)
m_rows = _read_jsonl(_metrics_path(home, sid))
d_rows = _read_jsonl(_decisions_path(home, sid))
if not m_rows and not d_rows:
raise FileNotFoundError(
f"No metrics.jsonl or decisions.jsonl for session {sid!r}"
)
out = output_dir if output_dir is not None else home / "exports" / sid
out.mkdir(parents=True, exist_ok=True)
written: list[Path] = []
if fmt == "json":
payload = {
"session_id": sid,
"metrics": m_rows,
"decisions": d_rows,
}
p = out / "metrics_export.json"
p.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
written.append(p)
elif fmt == "csv":
m_path = out / "metrics.csv"
d_path = out / "decisions.csv"
_write_csv(m_path, m_rows)
_write_csv(d_path, d_rows)
written.extend([m_path, d_path])
elif fmt == "html":
p = out / "report.html"
p.write_text(_html_report(sid, m_rows, d_rows), encoding="utf-8")
written.append(p)
else:
raise ValueError(f"Unsupported format: {fmt!r}")
return ExportResult(
session_id=sid,
format=fmt,
paths=written,
metrics_rows=len(m_rows),
decision_rows=len(d_rows),
)
+3
View File
@@ -91,6 +91,7 @@ class HandoffPipeline:
if memory_key not in memory_keys:
memory_keys = [*memory_keys, memory_key]
agent_id = state.agent_id or self._config.agent_id
raw = {
"goal": state.goal,
"status": map_task_status(state.status).value,
@@ -106,6 +107,8 @@ class HandoffPipeline:
"token_snapshot": state.tokens.total_tokens,
"timestamp": _iso_z(now),
"decision_rationale": decision.rationale,
"schema_version": "1.1",
"agent_id": agent_id,
}
# Strict validate before write (raises ValidationError if invalid).
manifest = HandoffManifest.model_validate(raw)
+11 -1
View File
@@ -1,7 +1,17 @@
"""Append-only audit logging for metrics and decisions."""
"""Append-only audit logging and structured JSON logging."""
from clm.logging.audit import AuditLogger
from clm.logging.structured import (
JsonFormatter,
configure_structured_logging,
get_logger,
log_event,
)
__all__ = [
"AuditLogger",
"JsonFormatter",
"configure_structured_logging",
"get_logger",
"log_event",
]
+75
View File
@@ -0,0 +1,75 @@
"""Structured JSON logging for production CLM runs."""
from __future__ import annotations
import json
import logging
import sys
from datetime import UTC, datetime
from typing import Any, TextIO
def _utc_ts() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
class JsonFormatter(logging.Formatter):
"""Format log records as single-line JSON objects."""
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"ts": _utc_ts(),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if record.exc_info:
payload["exc_info"] = self.formatException(record.exc_info)
# Extra fields attached via logger.info("…", extra={"clm": {...}})
clm_extra = getattr(record, "clm", None)
if isinstance(clm_extra, dict):
payload.update(clm_extra)
return json.dumps(payload, ensure_ascii=False)
def configure_structured_logging(
*,
level: int = logging.INFO,
stream: TextIO | None = None,
logger_name: str = "clm",
) -> logging.Logger:
"""Configure (or reconfigure) a named logger with JSON stdout handler.
Idempotent for the named logger: existing handlers are replaced.
Does not touch the root logger.
"""
logger = logging.getLogger(logger_name)
logger.handlers.clear()
handler = logging.StreamHandler(stream if stream is not None else sys.stderr)
handler.setFormatter(JsonFormatter())
handler.setLevel(level)
logger.addHandler(handler)
logger.setLevel(level)
logger.propagate = False
return logger
def get_logger(name: str = "clm") -> logging.Logger:
"""Return a child logger under the ``clm`` namespace."""
if name == "clm" or name.startswith("clm."):
return logging.getLogger(name)
return logging.getLogger(f"clm.{name}")
def log_event(
logger: logging.Logger,
msg: str,
*,
level: int = logging.INFO,
**fields: Any,
) -> None:
"""Emit a structured log line with optional CLM fields."""
if fields:
logger.log(level, msg, extra={"clm": fields})
else:
logger.log(level, msg)
+182 -3
View File
@@ -2,7 +2,10 @@
from __future__ import annotations
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
@@ -13,11 +16,15 @@ from clm.logging.audit import AuditLogger
from clm.metrics.monitor import MetricsMonitor
from clm.models.config import ClmConfig
from clm.models.decision import Decision, DecisionAction
from clm.models.metrics import TokenSnapshot
from clm.models.session import SessionState, TaskStatus
from clm.session.working import WorkingSession
_TERMINAL = frozenset({TaskStatus.COMPLETED, TaskStatus.FAILED})
# HITL confirm callback: return True to proceed, False to skip action.
ConfirmCallback = Callable[[Decision, SessionState], bool]
class LoopResult(BaseModel):
"""Outcome of one OuterControlLoop.run() invocation."""
@@ -27,6 +34,31 @@ class LoopResult(BaseModel):
handoffs: list[Path] = Field(default_factory=list)
compact_count: int = 0
stopped_reason: str
last_recall_score: float | None = None
confirm_denied: int = 0
def _utc_ts() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _decision_record(
decision: Decision,
state: SessionState,
snap: TokenSnapshot,
*,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build enriched audit record for decisions.jsonl (P2 fields)."""
record: dict[str, Any] = decision.model_dump(mode="json")
record["step_index"] = state.step_index
record["total_tokens"] = snap.total_tokens
record["ts"] = _utc_ts()
if snap.context_tokens is not None:
record["context_tokens"] = snap.context_tokens
if extra:
record.update(extra)
return record
class OuterControlLoop:
@@ -43,6 +75,7 @@ class OuterControlLoop:
config: ClmConfig,
*,
continue_after_handoff: bool = False,
confirm_callback: ConfirmCallback | None = None,
) -> None:
self.session = session
self.monitor = monitor
@@ -53,6 +86,55 @@ class OuterControlLoop:
self.config = config
# MVP: stop after first handoff; flag reserved for multi-session runs.
self.continue_after_handoff = continue_after_handoff
self.confirm_callback = confirm_callback
def _needs_confirm(self, decision: Decision) -> bool:
action = decision.action.value
return action in self.config.require_confirm_on
def _request_confirm(
self,
decision: Decision,
snap: TokenSnapshot,
decisions: list[Decision],
) -> bool:
"""Run HITL policy. Returns True if pipeline may proceed.
Denied confirms increment ``human_interventions`` and are audited.
Missing callback when confirm is required → deny (safe default for
non-interactive orchestrators; CLI injects prompt / --yes).
"""
self.session.state.human_interventions += 1
approved = False
if self.confirm_callback is not None:
approved = bool(self.confirm_callback(decision, self.session.state))
event = {
"ts": _utc_ts(),
"event": "confirm",
"action": decision.action.value,
"trigger": decision.trigger,
"approved": approved,
"step_index": self.session.state.step_index,
"total_tokens": snap.total_tokens,
"human_interventions": self.session.state.human_interventions,
"rationale": (
f"HITL confirm {'approved' if approved else 'denied'} "
f"for {decision.action.value}"
),
}
self.audit.append(event)
if not approved:
deny = Decision(
action=DecisionAction.CONTINUE,
rationale=(
f"confirm denied for {decision.action.value} "
f"(trigger={decision.trigger}); treating as continue"
),
trigger="confirm_denied",
)
self.audit.append(_decision_record(deny, self.session.state, snap))
decisions.append(deny)
return approved
def run(self, max_steps: int | None = None) -> LoopResult:
"""Drive the session until terminal status, handoff, or max_steps."""
@@ -60,6 +142,8 @@ class OuterControlLoop:
handoffs: list[Path] = []
compact_count = 0
steps = 0
last_recall_score: float | None = None
confirm_denied = 0
while True:
if max_steps is not None and steps >= max_steps:
@@ -69,6 +153,8 @@ class OuterControlLoop:
handoffs=handoffs,
compact_count=compact_count,
stopped_reason="max_steps",
last_recall_score=last_recall_score,
confirm_denied=confirm_denied,
)
if self.session.state.status in _TERMINAL:
@@ -78,21 +164,112 @@ class OuterControlLoop:
handoffs=handoffs,
compact_count=compact_count,
stopped_reason=self.session.state.status.value,
last_recall_score=last_recall_score,
confirm_denied=confirm_denied,
)
step = self.session.step()
snap = self.monitor.update(self.session.state, step)
decision = self.engine.decide(self.session.state, snap)
self.audit.append(decision.model_dump(mode="json"))
self.audit.append(_decision_record(decision, self.session.state, snap))
decisions.append(decision)
# Terminal after step: audit decision but never run compact/handoff pipelines.
if self.session.state.status in _TERMINAL:
return LoopResult(
final_state=self.session.state,
decisions=decisions,
handoffs=handoffs,
compact_count=compact_count,
stopped_reason=self.session.state.status.value,
last_recall_score=last_recall_score,
confirm_denied=confirm_denied,
)
if decision.action == DecisionAction.CONTINUE:
pass
elif decision.action == DecisionAction.COMPACT:
if self._needs_confirm(decision) and not self._request_confirm(
decision, snap, decisions
):
confirm_denied += 1
steps += 1
continue
compact_result = self.compaction.run(self.session.state)
self.session.agent.inject_context(compact_result.hint)
compact_count += 1
last_recall_score = compact_result.recall_score
if compact_result.fell_back_to_handoff:
# Verify failed → override to HANDOFF
verify_decision = Decision(
action=DecisionAction.HANDOFF,
rationale=(
f"compaction verify failed: recall_score="
f"{compact_result.recall_score} "
f"< threshold={self.config.recall_score_threshold}"
),
trigger="verify_fail",
)
self.audit.append(
_decision_record(
verify_decision,
self.session.state,
snap,
extra={
"recall_score": compact_result.recall_score,
"verify_passed": False,
"parent_trigger": decision.trigger,
},
)
)
decisions.append(verify_decision)
# verify_fail handoff is safety-critical; still honor HITL if required
if self._needs_confirm(verify_decision) and not self._request_confirm(
verify_decision, snap, decisions
):
confirm_denied += 1
steps += 1
continue
handoff_result = self.handoff.run(self.session.state, verify_decision)
self.session.agent.reset_with_seed(handoff_result.seed_messages)
self.session.state.messages = list(handoff_result.seed_messages)
handoffs.append(handoff_result.manifest_path)
if not self.continue_after_handoff:
return LoopResult(
final_state=self.session.state,
decisions=decisions,
handoffs=handoffs,
compact_count=compact_count,
stopped_reason="handoff",
last_recall_score=last_recall_score,
confirm_denied=confirm_denied,
)
else:
self.session.agent.inject_context(compact_result.hint)
compact_count += 1
if compact_result.recall_score is not None:
# Append verify metrics on the successful compact path.
self.audit.append(
{
"ts": _utc_ts(),
"event": "compaction_verify",
"action": DecisionAction.COMPACT.value,
"trigger": "verify_ok",
"rationale": (
f"compaction verified recall_score="
f"{compact_result.recall_score}"
),
"step_index": self.session.state.step_index,
"recall_score": compact_result.recall_score,
"verify_passed": compact_result.verify_passed,
"total_tokens": snap.total_tokens,
}
)
elif decision.action == DecisionAction.HANDOFF:
if self._needs_confirm(decision) and not self._request_confirm(
decision, snap, decisions
):
confirm_denied += 1
steps += 1
continue
handoff_result = self.handoff.run(self.session.state, decision)
self.session.agent.reset_with_seed(handoff_result.seed_messages)
self.session.state.messages = list(handoff_result.seed_messages)
@@ -104,6 +281,8 @@ class OuterControlLoop:
handoffs=handoffs,
compact_count=compact_count,
stopped_reason="handoff",
last_recall_score=last_recall_score,
confirm_denied=confirm_denied,
)
steps += 1
+12 -1
View File
@@ -1,9 +1,20 @@
"""Long-term memory store protocols and file backend."""
"""Long-term memory store protocols and backends."""
from clm.memory.backup import backup_memory, restore_memory
from clm.memory.base import MemoryStore
from clm.memory.factory import build_memory_store
from clm.memory.file_store import FileMemoryStore
from clm.memory.hybrid_store import HybridMemoryStore
from clm.memory.mem0_store import Mem0Store
from clm.memory.namespaced import NamespacedMemoryStore
__all__ = [
"FileMemoryStore",
"HybridMemoryStore",
"Mem0Store",
"MemoryStore",
"NamespacedMemoryStore",
"backup_memory",
"build_memory_store",
"restore_memory",
]
+87
View File
@@ -0,0 +1,87 @@
"""Backup utilities for file-backed CLM memory."""
from __future__ import annotations
import shutil
import tarfile
from datetime import UTC, datetime
from pathlib import Path
def _utc_stamp() -> str:
return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
def backup_memory(
clm_home: Path,
*,
label: str | None = None,
backups_dir: Path | None = None,
) -> Path:
"""Create a gzipped tarball of ``{clm_home}/memory`` under backups/.
Returns the path to the created archive. Raises ``FileNotFoundError`` if
the memory directory does not exist.
"""
home = Path(clm_home)
memory_root = home / "memory"
if not memory_root.is_dir():
raise FileNotFoundError(f"Memory directory not found: {memory_root}")
dest_root = Path(backups_dir) if backups_dir is not None else home / "backups"
dest_root.mkdir(parents=True, exist_ok=True)
stamp = _utc_stamp()
suffix = f"_{label}" if label else ""
archive_name = f"memory_{stamp}{suffix}.tar.gz"
archive_path = dest_root / archive_name
# Write to a temp name then rename for atomic-ish creation.
tmp_path = Path(str(archive_path) + ".partial")
try:
with tarfile.open(tmp_path, "w:gz") as tar:
tar.add(memory_root, arcname="memory")
tmp_path.replace(archive_path)
except Exception:
if tmp_path.is_file():
tmp_path.unlink(missing_ok=True)
raise
return archive_path
def restore_memory(
archive_path: Path,
clm_home: Path,
*,
overwrite: bool = False,
) -> Path:
"""Extract a memory backup tarball into ``{clm_home}/memory``.
If ``overwrite`` is False and memory already has content (beyond empty
dirs), raises ``FileExistsError``. Returns the restored memory root.
"""
home = Path(clm_home)
memory_root = home / "memory"
archive = Path(archive_path)
if not archive.is_file():
raise FileNotFoundError(f"Backup archive not found: {archive}")
if memory_root.exists() and any(memory_root.iterdir()) and not overwrite:
raise FileExistsError(
f"Memory directory not empty: {memory_root}. Pass overwrite=True to replace."
)
if overwrite and memory_root.exists():
shutil.rmtree(memory_root)
home.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive, "r:gz") as tar:
# Python 3.12+: filter= for safer extraction when available
try:
tar.extractall(home, filter="data")
except TypeError:
tar.extractall(home)
if not memory_root.is_dir():
raise RuntimeError(f"Backup did not contain memory/ directory: {archive}")
return memory_root
+49
View File
@@ -0,0 +1,49 @@
"""Build MemoryStore instances from ClmConfig."""
from __future__ import annotations
from pathlib import Path
from clm.memory.base import MemoryStore
from clm.memory.file_store import FileMemoryStore
from clm.memory.hybrid_store import HybridMemoryStore
from clm.memory.mem0_store import Mem0Client, Mem0Store, build_default_mem0_client
from clm.memory.namespaced import NamespacedMemoryStore
from clm.models.config import ClmConfig
def build_memory_store(
config: ClmConfig,
*,
memory_root: Path | None = None,
mem0_client: Mem0Client | None = None,
apply_namespace: bool = True,
) -> MemoryStore:
"""Return a MemoryStore for ``config.memory_backend``.
Args:
config: Runtime config (backend, keys, clm_home, optional namespace).
memory_root: Override for file store root (default ``clm_home/memory``).
mem0_client: Optional injected Mem0 client (tests / custom wiring).
apply_namespace: When True and ``config.memory_namespace`` is set,
wrap the store so keys are prefixed (multi-agent shared memory).
"""
root = memory_root if memory_root is not None else Path(config.clm_home) / "memory"
backend = config.memory_backend
if backend == "file":
store: MemoryStore = FileMemoryStore(root)
elif backend == "mem0":
client = mem0_client or build_default_mem0_client(config.mem0_api_key)
store = Mem0Store(client, user_id=config.mem0_user_id)
elif backend == "hybrid":
file_store = FileMemoryStore(root)
client = mem0_client or build_default_mem0_client(config.mem0_api_key)
mem0_store = Mem0Store(client, user_id=config.mem0_user_id)
store = HybridMemoryStore(primary=file_store, secondary=mem0_store)
else:
raise ValueError(f"Unknown memory_backend: {backend!r}")
if apply_namespace and config.memory_namespace:
store = NamespacedMemoryStore(store, config.memory_namespace)
return store
+47
View File
@@ -0,0 +1,47 @@
"""Hybrid MemoryStore: parallel write to primary + secondary; read prefers primary."""
from __future__ import annotations
from clm.memory.base import MemoryStore
from clm.models.memory import MemoryEntry
class HybridMemoryStore:
"""Write to both backends; read/search prefer primary, then secondary."""
def __init__(self, primary: MemoryStore, secondary: MemoryStore) -> None:
self.primary = primary
self.secondary = secondary
def write(self, key: str, content: str, *, tags: list[str] | None = None) -> None:
self.primary.write(key, content, tags=tags)
self.secondary.write(key, content, tags=tags)
def read(self, key: str) -> str | None:
value = self.primary.read(key)
if value is not None:
return value
return self.secondary.read(key)
def search(self, query: str, *, limit: int = 10) -> list[MemoryEntry]:
hits = self.primary.search(query, limit=limit)
if len(hits) >= limit:
return hits
seen = {h.key for h in hits}
for entry in self.secondary.search(query, limit=limit):
if entry.key in seen:
continue
hits.append(entry)
seen.add(entry.key)
if len(hits) >= limit:
break
return hits
def list_keys(self) -> list[str]:
keys = list(self.primary.list_keys())
seen = set(keys)
for key in self.secondary.list_keys():
if key not in seen:
keys.append(key)
seen.add(key)
return keys
+184
View File
@@ -0,0 +1,184 @@
"""Optional Mem0-backed MemoryStore (P2).
Uses a thin client protocol so unit tests can inject a fake without the
``mem0ai`` package. Live client construction requires ``MEM0_API_KEY`` or
``config.mem0_api_key``.
"""
from __future__ import annotations
import os
from typing import Any, Protocol, runtime_checkable
from clm.models.memory import MemoryEntry
@runtime_checkable
class Mem0Client(Protocol):
"""Minimal surface used by Mem0Store (compatible with mem0 Memory client)."""
def add(self, messages: Any, *, user_id: str, metadata: dict[str, Any] | None = None) -> Any:
...
def search(self, query: str, *, user_id: str, limit: int = 10) -> Any:
...
def get_all(self, *, user_id: str) -> Any:
...
class InMemoryMem0Client:
"""Test double that mimics Mem0 add/search/get_all over a local dict."""
def __init__(self) -> None:
self._by_key: dict[str, dict[str, Any]] = {}
def add(
self,
messages: Any,
*,
user_id: str,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
meta = dict(metadata or {})
key = str(meta.get("key", f"auto-{len(self._by_key)}"))
if isinstance(messages, list) and messages:
content = str(messages[0].get("content", ""))
else:
content = str(messages)
self._by_key[key] = {
"key": key,
"content": content,
"tags": list(meta.get("tags", [])),
"user_id": user_id,
}
return {"id": key}
def search(self, query: str, *, user_id: str, limit: int = 10) -> dict[str, Any]:
needle = query.casefold()
results: list[dict[str, Any]] = []
for entry in self._by_key.values():
if entry.get("user_id") != user_id:
continue
hay = f"{entry['key']}\n{entry['content']}".casefold()
if needle in hay:
results.append(
{
"memory": entry["content"],
"metadata": {"key": entry["key"], "tags": entry["tags"]},
}
)
if len(results) >= limit:
break
return {"results": results}
def get_all(self, *, user_id: str) -> dict[str, Any]:
results = [
{
"memory": e["content"],
"metadata": {"key": e["key"], "tags": e["tags"]},
}
for e in self._by_key.values()
if e.get("user_id") == user_id
]
return {"results": results}
def build_default_mem0_client(api_key: str | None = None) -> Mem0Client:
"""Construct the official mem0 client if installed and keyed.
Raises:
ImportError: if ``mem0ai`` is not installed.
ValueError: if no API key is available.
"""
key = api_key or os.environ.get("MEM0_API_KEY")
if not key:
raise ValueError(
"Mem0 backend requires mem0_api_key or MEM0_API_KEY environment variable"
)
try:
from mem0 import Memory # type: ignore[import-not-found]
except ImportError as exc:
raise ImportError(
"Mem0 backend requires optional dependency 'mem0ai'. "
"Install with: pip install 'clm[mem0]'"
) from exc
# Memory.from_config may vary by version; use Memory() + env when possible.
os.environ.setdefault("MEM0_API_KEY", key)
client: Mem0Client = Memory()
return client
class Mem0Store:
"""MemoryStore adapter over a Mem0-compatible client."""
def __init__(
self,
client: Mem0Client,
*,
user_id: str = "clm",
) -> None:
self._client = client
self._user_id = user_id
# Local index for read-by-key (Mem0 search is query-oriented).
self._local: dict[str, MemoryEntry] = {}
def write(self, key: str, content: str, *, tags: list[str] | None = None) -> None:
tag_list = list(tags) if tags is not None else []
self._client.add(
[{"role": "user", "content": content}],
user_id=self._user_id,
metadata={"key": key, "tags": tag_list},
)
self._local[key] = MemoryEntry(
key=key,
content=content,
tags=tag_list,
updated_at="",
)
def read(self, key: str) -> str | None:
if key in self._local:
return self._local[key].content
# Fallback: scan get_all metadata
for entry in self._entries_from_get_all():
if entry.key == key:
self._local[key] = entry
return entry.content
return None
def search(self, query: str, *, limit: int = 10) -> list[MemoryEntry]:
raw = self._client.search(query, user_id=self._user_id, limit=limit)
return self._parse_results(raw, limit=limit)
def list_keys(self) -> list[str]:
keys = set(self._local.keys())
for entry in self._entries_from_get_all():
keys.add(entry.key)
return sorted(keys)
def _entries_from_get_all(self) -> list[MemoryEntry]:
raw = self._client.get_all(user_id=self._user_id)
return self._parse_results(raw, limit=10_000)
def _parse_results(self, raw: Any, *, limit: int) -> list[MemoryEntry]:
items: list[Any]
if isinstance(raw, dict):
items = list(raw.get("results") or raw.get("memories") or [])
elif isinstance(raw, list):
items = raw
else:
items = []
out: list[MemoryEntry] = []
for item in items[:limit]:
if not isinstance(item, dict):
continue
meta = item.get("metadata") or {}
key = str(meta.get("key") or item.get("id") or f"mem0-{len(out)}")
content = str(item.get("memory") or item.get("content") or "")
tags_raw = meta.get("tags") or []
tags = [str(t) for t in tags_raw] if isinstance(tags_raw, list) else []
entry = MemoryEntry(key=key, content=content, tags=tags, updated_at="")
out.append(entry)
self._local[key] = entry
return out
+69
View File
@@ -0,0 +1,69 @@
"""Namespace prefix wrapper for multi-agent shared MemoryStore keys."""
from __future__ import annotations
from clm.memory.base import MemoryStore
from clm.models.memory import MemoryEntry
def normalize_namespace(namespace: str) -> str:
"""Normalize a namespace to a clean prefix without leading/trailing slashes."""
ns = namespace.strip().strip("/")
if not ns:
raise ValueError("memory_namespace must be non-empty when set")
return ns
class NamespacedMemoryStore:
"""Prefix all keys with ``{namespace}/`` for multi-agent shared memory.
Read/list/search operate only on keys under the namespace (list_keys and
search strip the prefix in returned entries).
"""
def __init__(self, inner: MemoryStore, namespace: str) -> None:
self._inner = inner
self.namespace = normalize_namespace(namespace)
self._prefix = f"{self.namespace}/"
def _full_key(self, key: str) -> str:
key = key.lstrip("/")
if key.startswith(self._prefix) or key == self.namespace:
return key
return f"{self._prefix}{key}"
def _strip_key(self, key: str) -> str:
if key.startswith(self._prefix):
return key[len(self._prefix) :]
return key
def write(self, key: str, content: str, *, tags: list[str] | None = None) -> None:
self._inner.write(self._full_key(key), content, tags=tags)
def read(self, key: str) -> str | None:
return self._inner.read(self._full_key(key))
def search(self, query: str, *, limit: int = 10) -> list[MemoryEntry]:
hits = self._inner.search(query, limit=limit * 4)
out: list[MemoryEntry] = []
for entry in hits:
if not entry.key.startswith(self._prefix):
continue
out.append(
MemoryEntry(
key=self._strip_key(entry.key),
content=entry.content,
tags=list(entry.tags),
updated_at=entry.updated_at,
)
)
if len(out) >= limit:
break
return out
def list_keys(self) -> list[str]:
return [
self._strip_key(k)
for k in self._inner.list_keys()
if k.startswith(self._prefix)
]
+40
View File
@@ -0,0 +1,40 @@
"""Cost model: tokens × price table (global or per-provider)."""
from __future__ import annotations
from clm.models.config import ClmConfig
def compute_cost_usd(
input_tokens: int,
output_tokens: int,
*,
price_per_1k_input: float | None,
price_per_1k_output: float | None,
) -> float | None:
"""Return estimated USD cost, or None if no prices are configured.
Formula: ``(in/1000)*price_in + (out/1000)*price_out``.
Missing side prices are treated as 0.0 when the other side is set.
"""
if price_per_1k_input is None and price_per_1k_output is None:
return None
pin = 0.0 if price_per_1k_input is None else float(price_per_1k_input)
pout = 0.0 if price_per_1k_output is None else float(price_per_1k_output)
return (input_tokens / 1000.0) * pin + (output_tokens / 1000.0) * pout
def cost_for_config(
config: ClmConfig,
*,
input_tokens: int,
output_tokens: int,
) -> float | None:
"""Resolve prices from config (provider table or globals) and compute cost."""
pin, pout = config.resolve_prices()
return compute_cost_usd(
input_tokens,
output_tokens,
price_per_1k_input=pin,
price_per_1k_output=pout,
)
+42 -15
View File
@@ -6,23 +6,35 @@ from datetime import UTC, datetime
from pathlib import Path
from clm.logging.audit import AuditLogger
from clm.metrics.cost import cost_for_config
from clm.metrics.estimators import estimate_tokens
from clm.models.config import ClmConfig
from clm.models.metrics import TokenSnapshot
from clm.models.session import AgentStepResult, SessionState
def estimate_context_tokens(state: SessionState) -> int:
"""Estimate current message-window size via chars//4 over all messages."""
text = "".join(m.content for m in state.messages)
return estimate_tokens(text)
class MetricsMonitor:
"""Track session tokens and write append-only metrics.jsonl records."""
def __init__(self, log_path: Path) -> None:
def __init__(self, log_path: Path, config: ClmConfig | None = None) -> None:
self.log_path = log_path
self._audit = AuditLogger(log_path)
self._config = config
def update(self, state: SessionState, step: AgentStepResult) -> TokenSnapshot:
"""Add step tokens to ``state.tokens`` and append a metrics JSONL line.
If both ``step.input_tokens`` and ``step.output_tokens`` are 0, estimate
from ``messages_added`` contents via ``estimate_tokens`` (chars // 4).
Always sets ``context_tokens`` from the current message window size.
When prices are configured on ``config``, also logs step and session cost.
"""
if step.input_tokens == 0 and step.output_tokens == 0:
estimated = True
@@ -38,26 +50,41 @@ class MetricsMonitor:
new_input = state.tokens.input_tokens + step_input
new_output = state.tokens.output_tokens + step_output
new_total = new_input + new_output
context_tokens = estimate_context_tokens(state)
snap = TokenSnapshot(
input_tokens=new_input,
output_tokens=new_output,
total_tokens=new_total,
estimated=state.tokens.estimated or estimated,
context_tokens=context_tokens,
)
state.tokens = snap
self._audit.append(
{
"ts": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
"session_id": state.session_id,
"step_index": state.step_index,
"input_tokens": step_input,
"output_tokens": step_output,
"total_tokens": step_total,
"tool_calls": step.tool_calls,
"task_status": step.task_status.value,
"estimated": estimated,
"human_interventions": state.human_interventions,
}
)
record: dict[str, object] = {
"ts": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
"session_id": state.session_id,
"step_index": state.step_index,
"input_tokens": step_input,
"output_tokens": step_output,
"total_tokens": step_total,
"context_tokens": context_tokens,
"tool_calls": step.tool_calls,
"task_status": step.task_status.value,
"estimated": estimated,
"human_interventions": state.human_interventions,
}
if self._config is not None:
step_cost = cost_for_config(
self._config, input_tokens=step_input, output_tokens=step_output
)
session_cost = cost_for_config(
self._config, input_tokens=new_input, output_tokens=new_output
)
if step_cost is not None:
record["step_cost_usd"] = round(step_cost, 8)
if session_cost is not None:
record["session_cost_usd"] = round(session_cost, 8)
if self._config.provider:
record["provider"] = self._config.provider
self._audit.append(record)
return snap
+45 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from enum import Enum
from pathlib import Path
from typing import Self
from typing import Literal, Self
from pydantic import BaseModel, Field, model_validator
@@ -22,6 +22,9 @@ class TaskType(str, Enum):
# Plan interface name; same as TaskType.
TaskProfile = TaskType
MemoryBackend = Literal["file", "mem0", "hybrid"]
ConfirmAction = Literal["compact", "handoff"]
class Thresholds(BaseModel):
"""Token thresholds for compaction and handoff decisions."""
@@ -36,6 +39,13 @@ class Thresholds(BaseModel):
return self
class ProviderPrices(BaseModel):
"""Per-provider token prices (USD per 1k tokens)."""
price_per_1k_input: float = Field(ge=0.0, default=0.0)
price_per_1k_output: float = Field(ge=0.0, default=0.0)
# Mid-band defaults from design spec / task brief.
_DEFAULT_THRESHOLDS: dict[TaskType, Thresholds] = {
TaskType.CODING: Thresholds(soft_compact=250_000, hard_handoff=350_000),
@@ -57,8 +67,37 @@ class ClmConfig(BaseModel):
prefer_handoff_on_phase: bool = True
retain_recent_messages: int = Field(default=6, ge=0)
clm_home: Path = Field(default_factory=lambda: Path(".clm"))
# Global cost defaults (used when provider is unset or missing from price_table).
price_per_1k_input: float | None = None
price_per_1k_output: float | None = None
# P4: cost model — active provider key + optional table of provider prices.
provider: str | None = None
price_table: dict[str, ProviderPrices] = Field(default_factory=dict)
# P2: use window size (context_tokens) for soft/hard thresholds instead of session cumulative.
use_context_tokens_for_thresholds: bool = False
# P2: verified compaction
verify_compaction: bool = True
recall_score_threshold: float = Field(default=0.8, ge=0.0, le=1.0)
# P2: memory backend selection
memory_backend: MemoryBackend = "file"
mem0_api_key: str | None = None
mem0_user_id: str = "clm"
# P3: human-in-the-loop — actions that require confirm before pipelines run
require_confirm_on: list[ConfirmAction] = Field(default_factory=list)
# P4 multi-agent: identity + shared memory namespace (prefix for MemoryStore keys)
agent_id: str | None = None
memory_namespace: str | None = None
def resolve_prices(self) -> tuple[float | None, float | None]:
"""Return (price_per_1k_input, price_per_1k_output) for the active provider.
Preference: ``price_table[provider]`` when both set; else global
``price_per_1k_*`` fields.
"""
if self.provider and self.provider in self.price_table:
row = self.price_table[self.provider]
return row.price_per_1k_input, row.price_per_1k_output
return self.price_per_1k_input, self.price_per_1k_output
@classmethod
def for_task(cls, task_type: TaskType) -> ClmConfig:
@@ -67,3 +106,8 @@ class ClmConfig(BaseModel):
task_type=task_type,
thresholds=_DEFAULT_THRESHOLDS[task_type].model_copy(),
)
@classmethod
def default_thresholds(cls, task_type: TaskType) -> Thresholds:
"""Return mid-band threshold defaults for a task profile (copy)."""
return _DEFAULT_THRESHOLDS[task_type].model_copy()
+6
View File
@@ -3,6 +3,9 @@ from typing import Annotated
from pydantic import BaseModel, Field, field_validator
# Current schema version written by HandoffPipeline (semver-ish string).
MANIFEST_SCHEMA_VERSION = "1.1"
class ManifestStatus(str, Enum):
IN_PROGRESS = "in_progress"
@@ -25,6 +28,9 @@ class HandoffManifest(BaseModel):
token_snapshot: Annotated[int, Field(ge=0)]
timestamp: str # ISO-8601
decision_rationale: Annotated[str, Field(min_length=1)]
# P4: versioning + multi-agent identity (optional for backward-compatible reads)
schema_version: str = MANIFEST_SCHEMA_VERSION
agent_id: str | None = None
@field_validator("goal", "next_safe_action", "decision_rationale")
@classmethod
+2
View File
@@ -10,6 +10,8 @@ class TokenSnapshot(BaseModel):
output_tokens: int = Field(ge=0)
total_tokens: int = Field(ge=0)
estimated: bool = False # True when using char/4 heuristic
# Approximate size of the current message window (chars//4). Optional for P1 compat.
context_tokens: int | None = Field(default=None, ge=0)
class StepMetrics(BaseModel):
+2
View File
@@ -57,3 +57,5 @@ class SessionState(BaseModel):
blockers: list[str] = Field(default_factory=list)
source_of_truth: list[str] = Field(default_factory=list)
memory_keys: list[str] = Field(default_factory=list)
# P4 multi-agent: optional identity for shared-namespace handoffs
agent_id: str | None = None
+143
View File
@@ -0,0 +1,143 @@
"""OpenAI-compatible chat completions agent adapter (P2 PoC)."""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from collections.abc import Callable, Mapping, Sequence
from typing import Any
from clm.models.session import AgentStepResult, Message, MessageRole, TaskStatus
# Injectable HTTP transport for tests: (url, headers, body) -> response dict
HttpPost = Callable[[str, Mapping[str, str], dict[str, Any]], dict[str, Any]]
def _default_http_post(
url: str,
headers: Mapping[str, str],
body: dict[str, Any],
) -> dict[str, Any]:
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers=dict(headers),
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
raw = resp.read().decode("utf-8")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"OpenAI-compatible API HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"OpenAI-compatible API request failed: {exc}") from exc
parsed: dict[str, Any] = json.loads(raw)
return parsed
def messages_to_openai(messages: Sequence[Message]) -> list[dict[str, str]]:
"""Map CLM Message list to OpenAI chat message dicts."""
out: list[dict[str, str]] = []
for m in messages:
role = m.role.value
# OpenAI uses system/user/assistant/tool; map tool as user-tagged.
if role == MessageRole.TOOL.value:
content = f"[tool{(' ' + m.name) if m.name else ''}]: {m.content}"
out.append({"role": "user", "content": content})
else:
entry: dict[str, str] = {"role": role, "content": m.content}
out.append(entry)
return out
def _usage_tokens(payload: Mapping[str, Any]) -> tuple[int, int]:
usage = payload.get("usage") or {}
if not isinstance(usage, dict):
return 0, 0
inp = int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
out = int(usage.get("completion_tokens") or usage.get("output_tokens") or 0)
return max(0, inp), max(0, out)
def parse_chat_completion(payload: Mapping[str, Any]) -> AgentStepResult:
"""Parse a chat.completions response into AgentStepResult."""
choices = payload.get("choices") or []
content = ""
tool_calls = 0
if choices and isinstance(choices[0], dict):
message = choices[0].get("message") or {}
if isinstance(message, dict):
content = str(message.get("content") or "")
tc = message.get("tool_calls")
if isinstance(tc, list):
tool_calls = len(tc)
inp, out = _usage_tokens(payload)
return AgentStepResult(
messages_added=[
Message(role=MessageRole.ASSISTANT, content=content),
],
tool_calls=tool_calls,
input_tokens=inp,
output_tokens=out,
task_status=TaskStatus.RUNNING,
notes="",
)
class OpenAICompatibleAgent:
"""Agent adapter calling an OpenAI-compatible ``/v1/chat/completions`` API.
Does not require the official OpenAI SDK. Inject ``http_post`` for tests.
"""
def __init__(
self,
*,
api_key: str,
model: str,
base_url: str = "https://api.openai.com/v1",
http_post: HttpPost | None = None,
extra_headers: Mapping[str, str] | None = None,
) -> None:
self.api_key = api_key
self.model = model
self.base_url = base_url.rstrip("/")
self._http_post = http_post or _default_http_post
self._extra_headers = dict(extra_headers or {})
self.system_addons: list[str] = []
self._seed_override: list[Message] | None = None
self.reset_called: bool = False
self.seed_messages: list[Message] = []
def step(self, messages: Sequence[Message]) -> AgentStepResult:
"""POST chat completion for the given (or seed-overridden) messages."""
working = list(self._seed_override) if self._seed_override is not None else list(messages)
for addon in self.system_addons:
working.insert(0, Message(role=MessageRole.SYSTEM, content=addon))
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
**self._extra_headers,
}
body: dict[str, Any] = {
"model": self.model,
"messages": messages_to_openai(working),
}
payload = self._http_post(url, headers, body)
return parse_chat_completion(payload)
def inject_context(self, system_addon: str) -> None:
"""Queue a system-level context addon applied on subsequent steps."""
self.system_addons.append(system_addon)
def reset_with_seed(self, seed_messages: Sequence[Message]) -> None:
"""Replace conversation seed and clear system addons."""
self.system_addons = []
self.reset_called = True
self.seed_messages = list(seed_messages)
self._seed_override = list(seed_messages)
+11 -2
View File
@@ -17,7 +17,12 @@ class SimulatedAgent:
outer loop can assert inject/reset behaviour without a real LLM.
"""
def __init__(self, script: ScriptSource) -> None:
def __init__(
self,
script: ScriptSource,
*,
script_offset: int = 0,
) -> None:
self._script: list[AgentStepResult] | None
self._callable: Callable[[Sequence[Message]], AgentStepResult] | None
if isinstance(script, list):
@@ -27,7 +32,11 @@ class SimulatedAgent:
else:
self._script = None
self._callable = script
self._index = 0
# Resume support: skip already-consumed script entries (P2).
offset = max(0, script_offset)
if self._script is not None:
offset = min(offset, len(self._script))
self._index = offset
self.system_addons: list[str] = []
self.reset_called: bool = False
self.seed_messages: list[Message] = []
@@ -0,0 +1,107 @@
"""Integration: compact verify failure falls back to HANDOFF."""
from __future__ import annotations
import json
from pathlib import Path
from clm.compaction.pipeline import CompactionPipeline
from clm.compaction.verify import VerificationSuite, VerifyResult
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
from clm.logging.audit import AuditLogger
from clm.loop.control import OuterControlLoop
from clm.memory.file_store import FileMemoryStore
from clm.metrics.monitor import MetricsMonitor
from clm.models.config import ClmConfig, TaskType, Thresholds
from clm.models.decision import DecisionAction
from clm.models.session import (
AgentStepResult,
Message,
MessageRole,
SessionState,
TaskStatus,
)
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
class _FailVerifier(VerificationSuite):
def verify(self, **kwargs: object) -> VerifyResult: # type: ignore[override]
return VerifyResult(
questions=[],
passed_count=1,
total_count=5,
recall_score=0.2,
passed=False,
)
def test_verify_fail_triggers_handoff(tmp_clm_home: Path) -> None:
cfg = ClmConfig(
task_type=TaskType.CODING,
thresholds=Thresholds(soft_compact=500, hard_handoff=1_000_000),
min_steps_between_compacts=1,
retain_recent_messages=2,
clm_home=tmp_clm_home,
verify_compaction=True,
recall_score_threshold=0.8,
)
session_id = "s-verify-fail"
sdir = tmp_clm_home / "sessions" / session_id
sdir.mkdir(parents=True, exist_ok=True)
store = FileMemoryStore(tmp_clm_home / "memory")
script = [
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="fill")],
input_tokens=400,
output_tokens=200,
task_status=TaskStatus.RUNNING,
),
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="more")],
input_tokens=50,
output_tokens=10,
task_status=TaskStatus.RUNNING,
),
]
agent = SimulatedAgent(script)
state = SessionState(
session_id=session_id,
goal="verify-fail task",
messages=[Message(role=MessageRole.USER, content=f"h{i}") for i in range(10)],
verified=["must keep this fact"],
)
loop = OuterControlLoop(
session=WorkingSession(agent, state),
monitor=MetricsMonitor(sdir / "metrics.jsonl"),
engine=DecisionEngine(cfg),
compaction=CompactionPipeline(
store=store, config=cfg, verifier=_FailVerifier()
),
handoff=HandoffPipeline(
store=store,
config=cfg,
manifests_dir=tmp_clm_home / "manifests",
),
audit=AuditLogger(sdir / "decisions.jsonl"),
config=cfg,
)
result = loop.run()
assert result.stopped_reason == "handoff"
assert result.compact_count == 0
assert len(result.handoffs) == 1
assert result.handoffs[0].exists()
assert result.last_recall_score == 0.2
assert any(d.trigger == "verify_fail" for d in result.decisions)
assert any(d.action == DecisionAction.HANDOFF for d in result.decisions)
assert agent.reset_called is True
lines = [
json.loads(ln)
for ln in (sdir / "decisions.jsonl").read_text(encoding="utf-8").splitlines()
if ln.strip()
]
assert any(r.get("trigger") == "verify_fail" for r in lines)
assert any(r.get("recall_score") == 0.2 for r in lines)
+181
View File
@@ -0,0 +1,181 @@
"""Integration / CLI tests for session resume."""
from __future__ import annotations
import json
from pathlib import Path
from typer.testing import CliRunner
from clm.cli.app import app
from clm.models.session import AgentStepResult, Message, MessageRole, TaskStatus
from clm.session.simulated import SimulatedAgent
runner = CliRunner()
def _script_path(tmp: Path, steps: list[dict[str, object]]) -> Path:
path = tmp / "script.json"
path.write_text(json.dumps(steps), encoding="utf-8")
return path
def test_simulated_script_offset() -> None:
script = [
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="a")],
task_status=TaskStatus.RUNNING,
),
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="b")],
task_status=TaskStatus.RUNNING,
),
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="c")],
task_status=TaskStatus.COMPLETED,
),
]
agent = SimulatedAgent(script, script_offset=2)
r = agent.step([])
assert r.messages_added[0].content == "c"
def test_cli_resume_continues_session(tmp_path: Path) -> None:
home = tmp_path / ".clm"
runner.invoke(app, ["init", "--path", str(home)])
steps = [
{
"messages_added": [
{"role": "assistant", "content": "step-1"},
],
"input_tokens": 10,
"output_tokens": 5,
"task_status": "running",
},
{
"messages_added": [
{"role": "assistant", "content": "step-2"},
],
"input_tokens": 10,
"output_tokens": 5,
"task_status": "running",
},
{
"messages_added": [
{"role": "assistant", "content": "step-3-done"},
],
"input_tokens": 10,
"output_tokens": 5,
"task_status": "completed",
},
]
script = _script_path(tmp_path, steps)
first = runner.invoke(
app,
[
"run",
"--goal",
"resume-demo",
"--script",
str(script),
"--session-id",
"s-resume",
"--path",
str(home),
"--max-steps",
"1",
"--soft-compact",
"1000000",
"--hard-handoff",
"2000000",
],
)
assert first.exit_code == 0, first.output
assert "stopped_reason=max_steps" in first.output
state1 = json.loads(
(home / "sessions" / "s-resume" / "state.json").read_text(encoding="utf-8")
)
assert state1["step_index"] == 1
assert state1["status"] == "running"
second = runner.invoke(
app,
[
"run",
"--resume",
"s-resume",
"--script",
str(script),
"--path",
str(home),
"--soft-compact",
"1000000",
"--hard-handoff",
"2000000",
],
)
assert second.exit_code == 0, second.output
assert "session_id=s-resume" in second.output
assert "stopped_reason=completed" in second.output
state2 = json.loads(
(home / "sessions" / "s-resume" / "state.json").read_text(encoding="utf-8")
)
assert state2["step_index"] == 3
assert state2["status"] == "completed"
# Append-only logs grew
decisions = (home / "sessions" / "s-resume" / "decisions.jsonl").read_text(
encoding="utf-8"
)
assert decisions.count("\n") >= 2
def test_cli_resume_terminal_rejected(tmp_path: Path) -> None:
home = tmp_path / ".clm"
runner.invoke(app, ["init", "--path", str(home)])
steps = [
{
"messages_added": [{"role": "assistant", "content": "done"}],
"input_tokens": 1,
"output_tokens": 1,
"task_status": "completed",
},
]
script = _script_path(tmp_path, steps)
run = runner.invoke(
app,
[
"run",
"--goal",
"done-already",
"--script",
str(script),
"--session-id",
"s-term",
"--path",
str(home),
"--soft-compact",
"1000000",
"--hard-handoff",
"2000000",
],
)
assert run.exit_code == 0, run.output
resume = runner.invoke(
app,
[
"run",
"--resume",
"s-term",
"--script",
str(script),
"--path",
str(home),
],
)
assert resume.exit_code != 0
assert "terminal" in resume.output.lower() or "cannot resume" in resume.output.lower()
+62
View File
@@ -0,0 +1,62 @@
"""P4 A/B memory experiment runner."""
from __future__ import annotations
from pathlib import Path
from clm.experiments.ab_memory import run_ab_memory_experiment, write_ab_report
FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "sample_transcript.json"
def test_ab_memory_file_only(tmp_path: Path) -> None:
home = tmp_path / ".clm"
report = run_ab_memory_experiment(
goal="ab-demo",
script=FIXTURE,
path=home,
backends=["file"],
profile="coding",
soft_compact=500_000,
hard_handoff=600_000,
max_steps=20,
experiment_id="ab-test-1",
)
assert report.experiment_id == "ab-test-1"
assert len(report.arms) == 1
arm = report.arms[0]
assert arm.backend == "file"
assert arm.error is None
assert arm.completed is True
assert arm.total_tokens > 0
assert arm.compact_count == 0
out = write_ab_report(report, home / "exports" / "ab-test-1")
assert out["json"].is_file()
assert out["md"].is_file()
md = out["md"].read_text(encoding="utf-8")
assert "file" in md
assert "completed" in md.lower() or "True" in md
def test_ab_memory_all_backends_offline(tmp_path: Path) -> None:
"""file + mem0 + hybrid with InMemoryMem0Client (no API key)."""
home = tmp_path / ".clm"
report = run_ab_memory_experiment(
goal="ab-all",
script=FIXTURE,
path=home,
backends=["file", "mem0", "hybrid"],
profile="coding",
soft_compact=1000,
hard_handoff=500_000,
max_steps=20,
experiment_id="ab-all",
)
assert len(report.arms) == 3
for arm in report.arms:
assert arm.error is None, arm.error
assert arm.total_tokens > 0
# Low soft should force at least one compact on this fixture
assert arm.compact_count >= 1
assert arm.last_recall_score is not None
+74
View File
@@ -0,0 +1,74 @@
"""Unit tests for adaptive threshold calibration (P3 M3.1)."""
from __future__ import annotations
import json
from pathlib import Path
from clm.calibration.thresholds import (
calibrate_thresholds,
write_calibration,
)
from clm.models.config import TaskType
def _write_jsonl(path: Path, rows: list[dict[object, object]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"\n".join(json.dumps(r) for r in rows) + "\n",
encoding="utf-8",
)
def test_calibrate_no_data_returns_defaults(tmp_clm_home: Path) -> None:
result = calibrate_thresholds(tmp_clm_home, TaskType.CODING)
assert result.used_defaults is True
assert result.soft_compact == 250_000
assert result.hard_handoff == 350_000
assert result.method == "profile_defaults"
def test_calibrate_from_peak_metrics(tmp_clm_home: Path) -> None:
# Session A: cumulative peak 1000
s1 = tmp_clm_home / "sessions" / "a"
_write_jsonl(
s1 / "metrics.jsonl",
[
{"step_index": 1, "total_tokens": 400},
{"step_index": 2, "total_tokens": 600},
],
)
# Session B: cumulative peak 2000
s2 = tmp_clm_home / "sessions" / "b"
_write_jsonl(
s2 / "metrics.jsonl",
[
{"step_index": 1, "total_tokens": 1000},
{"step_index": 2, "total_tokens": 1000},
],
)
# Session C: peak 3000
s3 = tmp_clm_home / "sessions" / "c"
_write_jsonl(
s3 / "metrics.jsonl",
[{"step_index": 1, "total_tokens": 3000}],
)
result = calibrate_thresholds(tmp_clm_home, "coding")
assert result.used_defaults is False
assert result.sessions_analyzed == 3
assert result.samples_peak == 3
assert result.soft_compact < result.hard_handoff
assert result.soft_compact >= 1
# Peaks [1000, 2000, 3000] → p60 ≈ 2000, p90 ≈ 3000
assert result.soft_compact == 2000
assert result.hard_handoff >= int(2000 * 1.3)
def test_write_calibration(tmp_clm_home: Path) -> None:
result = calibrate_thresholds(tmp_clm_home, TaskType.CODING)
path = write_calibration(tmp_clm_home, result)
assert path.is_file()
data = json.loads(path.read_text(encoding="utf-8"))
assert data["profile"] == "coding"
assert data["soft_compact"] == result.soft_compact
+58
View File
@@ -0,0 +1,58 @@
"""CLI smoke for P4 backup + experiment commands."""
from __future__ import annotations
from pathlib import Path
from typer.testing import CliRunner
from clm.cli.app import app
from clm.memory.file_store import FileMemoryStore
runner = CliRunner()
FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "sample_transcript.json"
def test_cli_backup_memory(tmp_path: Path) -> None:
home = tmp_path / ".clm"
init = runner.invoke(app, ["init", "--path", str(home)])
assert init.exit_code == 0, init.output
FileMemoryStore(home / "memory").write("t/k", "body")
result = runner.invoke(
app,
["backup", "memory", "--path", str(home), "--label", "cli"],
)
assert result.exit_code == 0, result.output
assert "backup=" in result.output
backups = list((home / "backups").glob("memory_*.tar.gz"))
assert len(backups) == 1
def test_cli_experiment_ab_memory(tmp_path: Path) -> None:
home = tmp_path / ".clm"
result = runner.invoke(
app,
[
"experiment",
"ab-memory",
"--goal",
"cli-ab",
"--script",
str(FIXTURE),
"--backends",
"file",
"--soft-compact",
"500000",
"--hard-handoff",
"600000",
"--path",
str(home),
"--experiment-id",
"cli-ab-1",
],
)
assert result.exit_code == 0, result.output
assert "experiment_id=cli-ab-1" in result.output
assert "backend=file" in result.output
assert "report_json=" in result.output
+6
View File
@@ -22,6 +22,11 @@ def test_cli_help() -> None:
assert "compact" in result.output
assert "handoff" in result.output
assert "status" in result.output
assert "calibrate" in result.output
assert "export" in result.output
assert "signal" in result.output
assert "backup" in result.output
assert "experiment" in result.output
def test_cli_init(tmp_path: Path) -> None:
@@ -34,6 +39,7 @@ def test_cli_init(tmp_path: Path) -> None:
assert (home / "memory" / "logs").is_dir()
assert (home / "sessions").is_dir()
assert (home / "manifests").is_dir()
assert (home / "backups").is_dir()
body = (home / "memory" / "MEMORY.md").read_text(encoding="utf-8")
assert "Memory Index" in body
@@ -0,0 +1,100 @@
"""Unit tests for CompactionPipeline verify-before-mutate."""
from __future__ import annotations
from pathlib import Path
from clm.compaction.pipeline import CompactionPipeline
from clm.compaction.verify import VerificationSuite, VerifyResult
from clm.memory.file_store import FileMemoryStore
from clm.models.config import ClmConfig, TaskType, Thresholds
from clm.models.session import Message, MessageRole, SessionState
class _AlwaysFailVerifier(VerificationSuite):
def verify(self, **kwargs: object) -> VerifyResult: # type: ignore[override]
return VerifyResult(
questions=[],
passed_count=0,
total_count=5,
recall_score=0.0,
passed=False,
)
def _cfg(tmp: Path, **kwargs: object) -> ClmConfig:
base = {
"task_type": TaskType.CODING,
"thresholds": Thresholds(soft_compact=1000, hard_handoff=1_000_000),
"retain_recent_messages": 2,
"clm_home": tmp,
"verify_compaction": True,
"recall_score_threshold": 0.8,
}
base.update(kwargs)
return ClmConfig(**base) # type: ignore[arg-type]
def test_pipeline_pass_shrinks_messages(tmp_clm_home: Path) -> None:
store = FileMemoryStore(tmp_clm_home / "memory")
cfg = _cfg(tmp_clm_home)
state = SessionState(
session_id="s-pass",
goal="keep me",
messages=[
Message(role=MessageRole.USER, content=f"m{i}") for i in range(10)
],
verified=["fact-a"],
step_index=3,
)
pipe = CompactionPipeline(store=store, config=cfg)
result = pipe.run(state)
assert result.verify_passed is True
assert result.fell_back_to_handoff is False
assert result.recall_score is not None
assert result.recall_score >= 0.8
assert len(state.messages) < 10
assert state.last_compact_step == 3
assert any("compact-" in k for k in result.memory_keys_written)
def test_pipeline_fail_keeps_messages(tmp_clm_home: Path) -> None:
store = FileMemoryStore(tmp_clm_home / "memory")
cfg = _cfg(tmp_clm_home)
messages = [Message(role=MessageRole.USER, content=f"m{i}") for i in range(8)]
state = SessionState(
session_id="s-fail",
goal="goal",
messages=list(messages),
step_index=2,
)
pipe = CompactionPipeline(
store=store,
config=cfg,
verifier=_AlwaysFailVerifier(),
)
result = pipe.run(state)
assert result.fell_back_to_handoff is True
assert result.verify_passed is False
assert result.recall_score == 0.0
assert len(state.messages) == 8
assert state.last_compact_step is None
# Snapshot still written for handoff recovery
assert result.memory_keys_written
assert store.read(result.memory_keys_written[0]) is not None
def test_pipeline_verify_disabled_skips_score(tmp_clm_home: Path) -> None:
store = FileMemoryStore(tmp_clm_home / "memory")
cfg = _cfg(tmp_clm_home, verify_compaction=False)
state = SessionState(
session_id="s-off",
goal="g",
messages=[Message(role=MessageRole.USER, content="x")],
step_index=1,
)
result = CompactionPipeline(store=store, config=cfg).run(state)
assert result.verify_passed is None
assert result.recall_score is None
assert result.fell_back_to_handoff is False
assert state.last_compact_step == 1
+12
View File
@@ -49,6 +49,18 @@ def test_config_policy_defaults():
assert cfg.retain_recent_messages == 6
assert cfg.price_per_1k_input is None
assert cfg.price_per_1k_output is None
assert cfg.require_confirm_on == []
assert cfg.provider is None
assert cfg.price_table == {}
assert cfg.agent_id is None
assert cfg.memory_namespace is None
def test_require_confirm_on_accepts_actions():
cfg = ClmConfig.for_task(TaskType.CODING).model_copy(
update={"require_confirm_on": ["handoff", "compact"]}
)
assert cfg.require_confirm_on == ["handoff", "compact"]
def test_equal_thresholds_rejected():
+53
View File
@@ -0,0 +1,53 @@
"""P4 DecisionPolicy plugins behind DecisionEngine."""
from __future__ import annotations
from clm.decision.engine import DecisionEngine
from clm.decision.policy import (
BlockersForceHandoffPolicy,
PreferHandoffOverCompactPolicy,
)
from clm.models.config import ClmConfig, TaskType, Thresholds
from clm.models.decision import DecisionAction
from clm.models.metrics import TokenSnapshot
from clm.models.session import SessionState
def _cfg(**kwargs: object) -> ClmConfig:
base = {
"task_type": TaskType.CODING,
"thresholds": Thresholds(soft_compact=1000, hard_handoff=10_000),
"min_steps_between_compacts": 1,
}
base.update(kwargs)
return ClmConfig(**base) # type: ignore[arg-type]
def test_blockers_policy_forces_handoff_on_continue() -> None:
engine = DecisionEngine(_cfg(), policies=[BlockersForceHandoffPolicy()])
state = SessionState(
session_id="s1",
goal="g",
blockers=["missing API key"],
)
snap = TokenSnapshot(input_tokens=10, output_tokens=0, total_tokens=10)
d = engine.decide(state, snap)
assert d.action == DecisionAction.HANDOFF
assert d.trigger == "policy:blockers_force_handoff"
def test_blockers_policy_noop_without_blockers() -> None:
engine = DecisionEngine(_cfg(), policies=[BlockersForceHandoffPolicy()])
state = SessionState(session_id="s1", goal="g")
snap = TokenSnapshot(input_tokens=10, output_tokens=0, total_tokens=10)
d = engine.decide(state, snap)
assert d.action == DecisionAction.CONTINUE
def test_prefer_handoff_over_compact_policy() -> None:
engine = DecisionEngine(_cfg(), policies=[PreferHandoffOverCompactPolicy()])
state = SessionState(session_id="s1", goal="g")
snap = TokenSnapshot(input_tokens=2000, output_tokens=0, total_tokens=2000)
d = engine.decide(state, snap)
assert d.action == DecisionAction.HANDOFF
assert d.trigger == "policy:prefer_handoff_over_compact"
+93
View File
@@ -0,0 +1,93 @@
"""Unit tests for metrics export (P3 M3.2)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from clm.export.metrics_export import export_metrics
def _seed_session(home: Path, sid: str = "s-export") -> None:
sdir = home / "sessions" / sid
sdir.mkdir(parents=True, exist_ok=True)
metrics = [
{
"step_index": 1,
"total_tokens": 10,
"input_tokens": 5,
"output_tokens": 5,
"task_status": "running",
},
{
"step_index": 2,
"total_tokens": 20,
"input_tokens": 10,
"output_tokens": 10,
"task_status": "completed",
},
]
decisions = [
{
"action": "continue",
"trigger": "none",
"rationale": "ok",
"step_index": 1,
"total_tokens": 10,
},
{
"action": "handoff",
"trigger": "hard_handoff",
"rationale": "full",
"step_index": 2,
"total_tokens": 30,
},
]
(sdir / "metrics.jsonl").write_text(
"\n".join(json.dumps(r) for r in metrics) + "\n",
encoding="utf-8",
)
(sdir / "decisions.jsonl").write_text(
"\n".join(json.dumps(r) for r in decisions) + "\n",
encoding="utf-8",
)
def test_export_json(tmp_clm_home: Path) -> None:
_seed_session(tmp_clm_home)
result = export_metrics(tmp_clm_home, session_id="s-export", fmt="json")
assert result.metrics_rows == 2
assert result.decision_rows == 2
assert len(result.paths) == 1
payload = json.loads(result.paths[0].read_text(encoding="utf-8"))
assert payload["session_id"] == "s-export"
assert len(payload["metrics"]) == 2
def test_export_csv(tmp_clm_home: Path) -> None:
_seed_session(tmp_clm_home)
result = export_metrics(tmp_clm_home, session_id="s-export", fmt="csv")
assert len(result.paths) == 2
names = {p.name for p in result.paths}
assert "metrics.csv" in names
assert "decisions.csv" in names
metrics_csv = next(p for p in result.paths if p.name == "metrics.csv")
text = metrics_csv.read_text(encoding="utf-8")
assert "step_index" in text
assert "total_tokens" in text
def test_export_html(tmp_clm_home: Path) -> None:
_seed_session(tmp_clm_home)
result = export_metrics(tmp_clm_home, session_id="s-export", fmt="html")
html = result.paths[0].read_text(encoding="utf-8")
assert "CLM session report" in html
assert "s-export" in html
assert "Decision timeline" in html
def test_export_missing_session(tmp_clm_home: Path) -> None:
with pytest.raises(FileNotFoundError):
export_metrics(tmp_clm_home, session_id="nope", fmt="json")
+277
View File
@@ -0,0 +1,277 @@
"""Tests for HITL confirm policy and phase signal (P3 M3.3–M3.4)."""
from __future__ import annotations
import json
from pathlib import Path
from typer.testing import CliRunner
from clm.cli.app import app
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
from clm.logging.audit import AuditLogger
from clm.loop.control import OuterControlLoop
from clm.memory.file_store import FileMemoryStore
from clm.metrics.monitor import MetricsMonitor
from clm.models.config import ClmConfig, TaskType, Thresholds
from clm.models.decision import Decision
from clm.models.session import (
AgentStepResult,
Message,
MessageRole,
SessionState,
TaskStatus,
)
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
runner = CliRunner()
FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "sample_transcript.json"
def test_confirm_denied_skips_handoff(tmp_clm_home: Path) -> None:
cfg = ClmConfig(
task_type=TaskType.CODING,
thresholds=Thresholds(soft_compact=50, hard_handoff=100),
min_steps_between_compacts=1,
clm_home=tmp_clm_home,
verify_compaction=False,
require_confirm_on=["handoff"],
)
sdir = tmp_clm_home / "sessions" / "s-hitl"
sdir.mkdir(parents=True, exist_ok=True)
script = [
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="x" * 200)],
input_tokens=80,
output_tokens=40,
task_status=TaskStatus.RUNNING,
),
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="done")],
input_tokens=5,
output_tokens=5,
task_status=TaskStatus.COMPLETED,
),
]
store = FileMemoryStore(tmp_clm_home / "memory")
state = SessionState(
session_id="s-hitl",
goal="hitl",
messages=[Message(role=MessageRole.USER, content="start")],
)
denials = {"n": 0}
def deny(_d: Decision, _s: SessionState) -> bool:
denials["n"] += 1
return False
loop = OuterControlLoop(
session=WorkingSession(SimulatedAgent(script), state),
monitor=MetricsMonitor(sdir / "metrics.jsonl"),
engine=DecisionEngine(cfg),
compaction=CompactionPipeline(store=store, config=cfg),
handoff=HandoffPipeline(
store=store, config=cfg, manifests_dir=tmp_clm_home / "manifests"
),
audit=AuditLogger(sdir / "decisions.jsonl"),
config=cfg,
confirm_callback=deny,
)
result = loop.run()
assert result.handoffs == []
assert result.confirm_denied >= 1
assert denials["n"] >= 1
assert result.final_state.human_interventions >= 1
# Eventually completes via script (or max tokens continues)
assert result.stopped_reason in {"completed", "max_steps"}
def test_confirm_approved_runs_handoff(tmp_clm_home: Path) -> None:
cfg = ClmConfig(
task_type=TaskType.CODING,
thresholds=Thresholds(soft_compact=50, hard_handoff=100),
min_steps_between_compacts=1,
clm_home=tmp_clm_home,
verify_compaction=False,
require_confirm_on=["handoff"],
)
sdir = tmp_clm_home / "sessions" / "s-hitl-ok"
sdir.mkdir(parents=True, exist_ok=True)
script = [
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="big")],
input_tokens=80,
output_tokens=40,
task_status=TaskStatus.RUNNING,
),
]
store = FileMemoryStore(tmp_clm_home / "memory")
state = SessionState(
session_id="s-hitl-ok",
goal="hitl-ok",
messages=[Message(role=MessageRole.USER, content="start")],
)
loop = OuterControlLoop(
session=WorkingSession(SimulatedAgent(script), state),
monitor=MetricsMonitor(sdir / "metrics.jsonl"),
engine=DecisionEngine(cfg),
compaction=CompactionPipeline(store=store, config=cfg),
handoff=HandoffPipeline(
store=store, config=cfg, manifests_dir=tmp_clm_home / "manifests"
),
audit=AuditLogger(sdir / "decisions.jsonl"),
config=cfg,
confirm_callback=lambda _d, _s: True,
)
result = loop.run()
assert result.stopped_reason == "handoff"
assert result.handoffs
assert result.final_state.human_interventions >= 1
def test_cli_signal_phase_done(tmp_path: Path) -> None:
home = tmp_path / ".clm"
init = runner.invoke(app, ["init", "--path", str(home)])
assert init.exit_code == 0
run = runner.invoke(
app,
[
"run",
"--goal",
"signal-demo",
"--script",
str(FIXTURE),
"--session-id",
"s-signal",
"--path",
str(home),
"--soft-compact",
"1000000",
"--hard-handoff",
"2000000",
"--max-steps",
"1",
],
)
assert run.exit_code == 0, run.output
sig = runner.invoke(
app,
["signal", "--phase-done", "--session-id", "s-signal", "--path", str(home)],
)
assert sig.exit_code == 0, sig.output
assert "signal=phase_done" in sig.output
assert "status=phase_boundary" in sig.output
state = json.loads(
(home / "sessions" / "s-signal" / "state.json").read_text(encoding="utf-8")
)
assert state["status"] == "phase_boundary"
# decide dry-run with phase boundary
dec = runner.invoke(
app,
["decide", "--tokens", "100", "--phase-boundary"],
)
assert dec.exit_code == 0
assert "action=handoff" in dec.output
assert "trigger=phase_boundary" in dec.output
def test_cli_calibrate_and_export(tmp_path: Path) -> None:
home = tmp_path / ".clm"
runner.invoke(app, ["init", "--path", str(home)])
run = runner.invoke(
app,
[
"run",
"--goal",
"export-demo",
"--script",
str(FIXTURE),
"--session-id",
"s-exp",
"--path",
str(home),
"--soft-compact",
"1000000",
"--hard-handoff",
"2000000",
],
)
assert run.exit_code == 0, run.output
cal = runner.invoke(
app,
["calibrate", "--profile", "coding", "--path", str(home), "--write"],
)
assert cal.exit_code == 0, cal.output
assert "recommended soft_compact=" in cal.output
assert (home / "calibrations" / "coding.json").is_file()
exp = runner.invoke(
app,
[
"export",
"metrics",
"--format",
"json",
"--session-id",
"s-exp",
"--path",
str(home),
],
)
assert exp.exit_code == 0, exp.output
assert "written=" in exp.output
exp_html = runner.invoke(
app,
[
"export",
"metrics",
"--format",
"html",
"--session-id",
"s-exp",
"--path",
str(home),
],
)
assert exp_html.exit_code == 0, exp_html.output
def test_cli_run_require_confirm_yes(tmp_path: Path) -> None:
home = tmp_path / ".clm"
runner.invoke(app, ["init", "--path", str(home)])
# Force handoff quickly with low hard threshold + --yes
result = runner.invoke(
app,
[
"run",
"--goal",
"confirm-yes",
"--script",
str(FIXTURE),
"--session-id",
"s-yes",
"--path",
str(home),
"--soft-compact",
"1",
"--hard-handoff",
"5",
"--require-confirm",
"handoff",
"--yes",
"--max-steps",
"5",
],
)
assert result.exit_code == 0, result.output
# With hard=5 should handoff after confirm approved
assert "human_interventions=" in result.output
+162
View File
@@ -0,0 +1,162 @@
"""Unit/integration tests for outer-loop polish (audit, terminal, context_tokens)."""
from __future__ import annotations
import json
from pathlib import Path
from clm.compaction.pipeline import CompactionPipeline
from clm.decision.engine import DecisionEngine
from clm.handoff.pipeline import HandoffPipeline
from clm.logging.audit import AuditLogger
from clm.loop.control import OuterControlLoop
from clm.memory.file_store import FileMemoryStore
from clm.metrics.monitor import MetricsMonitor, estimate_context_tokens
from clm.models.config import ClmConfig, TaskType, Thresholds
from clm.models.decision import DecisionAction
from clm.models.metrics import TokenSnapshot
from clm.models.session import (
AgentStepResult,
Message,
MessageRole,
SessionState,
TaskStatus,
)
from clm.session.simulated import SimulatedAgent
from clm.session.working import WorkingSession
def _build(
tmp: Path,
script: list[AgentStepResult],
*,
cfg: ClmConfig,
session_id: str = "s-polish",
) -> tuple[OuterControlLoop, Path]:
cfg = cfg.model_copy(update={"clm_home": tmp})
sdir = tmp / "sessions" / session_id
sdir.mkdir(parents=True, exist_ok=True)
store = FileMemoryStore(tmp / "memory")
agent = SimulatedAgent(script)
state = SessionState(
session_id=session_id,
goal="polish",
messages=[Message(role=MessageRole.USER, content="start")],
)
loop = OuterControlLoop(
session=WorkingSession(agent, state),
monitor=MetricsMonitor(sdir / "metrics.jsonl"),
engine=DecisionEngine(cfg),
compaction=CompactionPipeline(store=store, config=cfg),
handoff=HandoffPipeline(
store=store, config=cfg, manifests_dir=tmp / "manifests"
),
audit=AuditLogger(sdir / "decisions.jsonl"),
config=cfg,
)
return loop, sdir / "decisions.jsonl"
def test_audit_includes_step_tokens_ts(tmp_clm_home: Path) -> None:
cfg = ClmConfig(
task_type=TaskType.CODING,
thresholds=Thresholds(soft_compact=1_000_000, hard_handoff=2_000_000),
clm_home=tmp_clm_home,
)
script = [
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="done")],
input_tokens=10,
output_tokens=5,
task_status=TaskStatus.COMPLETED,
),
]
loop, dec_path = _build(tmp_clm_home, script, cfg=cfg)
loop.run()
rows = [
json.loads(line)
for line in dec_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
assert rows
row = rows[0]
assert "step_index" in row
assert "total_tokens" in row
assert "ts" in row
assert row["total_tokens"] == 15
def test_terminal_step_does_not_handoff_pipeline(tmp_clm_home: Path) -> None:
"""High tokens on COMPLETED step must not run handoff pipeline."""
cfg = ClmConfig(
task_type=TaskType.CODING,
thresholds=Thresholds(soft_compact=50, hard_handoff=100),
min_steps_between_compacts=1,
clm_home=tmp_clm_home,
verify_compaction=False,
)
script = [
AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="bye")],
input_tokens=200,
output_tokens=50,
task_status=TaskStatus.COMPLETED,
),
]
loop, _ = _build(tmp_clm_home, script, cfg=cfg)
result = loop.run()
assert result.stopped_reason == "completed"
assert result.handoffs == []
assert result.compact_count == 0
# Decision may still be handoff/compact in the log, but no pipeline side effects
assert any(d.action in (DecisionAction.HANDOFF, DecisionAction.COMPACT, DecisionAction.CONTINUE) for d in result.decisions)
def test_context_tokens_estimated(tmp_clm_home: Path) -> None:
state = SessionState(
session_id="s1",
goal="g",
messages=[Message(role=MessageRole.USER, content="abcd" * 10)],
)
assert estimate_context_tokens(state) == 10
mon = MetricsMonitor(tmp_clm_home / "sessions" / "s1" / "metrics.jsonl")
step = AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="xyzw")],
input_tokens=1,
output_tokens=1,
task_status=TaskStatus.RUNNING,
)
# After step merge in real loop messages grow; here simulate update after extend
state.messages.append(step.messages_added[0])
snap = mon.update(state, step)
assert snap.context_tokens is not None
assert snap.context_tokens == estimate_context_tokens(state)
def test_engine_can_use_context_tokens() -> None:
cfg = ClmConfig(
task_type=TaskType.CODING,
thresholds=Thresholds(soft_compact=100, hard_handoff=1000),
use_context_tokens_for_thresholds=True,
)
engine = DecisionEngine(cfg)
state = SessionState(session_id="s", goal="g")
# cumulative high but context low → CONTINUE
snap = TokenSnapshot(
input_tokens=5000,
output_tokens=0,
total_tokens=5000,
context_tokens=10,
)
d = engine.decide(state, snap)
assert d.action == DecisionAction.CONTINUE
snap2 = TokenSnapshot(
input_tokens=10,
output_tokens=0,
total_tokens=10,
context_tokens=150,
)
d2 = engine.decide(state, snap2)
assert d2.action == DecisionAction.COMPACT
+65
View File
@@ -0,0 +1,65 @@
"""Tests for memory backend factory and hybrid/mem0 stores."""
from __future__ import annotations
from pathlib import Path
import pytest
from clm.memory.factory import build_memory_store
from clm.memory.file_store import FileMemoryStore
from clm.memory.hybrid_store import HybridMemoryStore
from clm.memory.mem0_store import InMemoryMem0Client, Mem0Store
from clm.models.config import ClmConfig, TaskType, Thresholds
def _cfg(tmp: Path, backend: str = "file") -> ClmConfig:
return ClmConfig(
task_type=TaskType.CODING,
thresholds=Thresholds(soft_compact=100, hard_handoff=200),
clm_home=tmp,
memory_backend=backend, # type: ignore[arg-type]
)
def test_factory_file_default(tmp_clm_home: Path) -> None:
store = build_memory_store(_cfg(tmp_clm_home, "file"))
assert isinstance(store, FileMemoryStore)
store.write("k1", "hello")
assert store.read("k1") == "hello"
def test_factory_mem0_with_injected_client(tmp_clm_home: Path) -> None:
client = InMemoryMem0Client()
store = build_memory_store(
_cfg(tmp_clm_home, "mem0"),
mem0_client=client,
)
assert isinstance(store, Mem0Store)
store.write("topic/a", "content-a", tags=["t"])
assert store.read("topic/a") == "content-a"
hits = store.search("content-a")
assert hits and hits[0].key == "topic/a"
def test_factory_hybrid_writes_both(tmp_clm_home: Path) -> None:
client = InMemoryMem0Client()
store = build_memory_store(
_cfg(tmp_clm_home, "hybrid"),
mem0_client=client,
)
assert isinstance(store, HybridMemoryStore)
store.write("h-key", "hybrid body", tags=["h"])
assert store.read("h-key") == "hybrid body"
# Primary (file) has it
assert isinstance(store.primary, FileMemoryStore)
assert store.primary.read("h-key") == "hybrid body"
# Secondary (mem0) has it
assert isinstance(store.secondary, Mem0Store)
assert store.secondary.read("h-key") == "hybrid body"
def test_factory_mem0_without_key_raises(tmp_clm_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("MEM0_API_KEY", raising=False)
with pytest.raises(ValueError, match="MEM0_API_KEY"):
build_memory_store(_cfg(tmp_clm_home, "mem0"))
+70
View File
@@ -0,0 +1,70 @@
"""P4 multi-agent: namespaced memory + agent_id on handoff manifests."""
from __future__ import annotations
from pathlib import Path
from clm.handoff.pipeline import HandoffPipeline
from clm.memory.factory import build_memory_store
from clm.memory.file_store import FileMemoryStore
from clm.memory.namespaced import NamespacedMemoryStore
from clm.models.config import ClmConfig, TaskType, Thresholds
from clm.models.decision import Decision, DecisionAction
from clm.models.metrics import TokenSnapshot
from clm.models.session import SessionState
def test_namespaced_store_prefixes_keys(tmp_path: Path) -> None:
inner = FileMemoryStore(tmp_path / "memory")
ns = NamespacedMemoryStore(inner, "team/shared")
ns.write("facts/x", "hello-x", tags=["f"])
assert ns.read("facts/x") == "hello-x"
# Physical key under namespace
assert inner.read("team/shared/facts/x") == "hello-x"
assert "facts/x" in ns.list_keys()
assert "team/shared/facts/x" in inner.list_keys()
def test_factory_applies_memory_namespace(tmp_path: Path) -> None:
cfg = ClmConfig(
task_type=TaskType.MULTI_AGENT,
thresholds=Thresholds(soft_compact=100, hard_handoff=200),
clm_home=tmp_path,
memory_backend="file",
memory_namespace="agents/shared",
agent_id="planner",
)
store = build_memory_store(cfg)
assert isinstance(store, NamespacedMemoryStore)
store.write("k", "v")
# Un-namespaced view of same root
raw = FileMemoryStore(tmp_path / "memory")
assert raw.read("agents/shared/k") == "v"
def test_handoff_includes_agent_id_and_schema_version(tmp_path: Path) -> None:
cfg = ClmConfig.for_task(TaskType.CODING).model_copy(
update={"agent_id": "worker-a", "clm_home": tmp_path}
)
store = FileMemoryStore(tmp_path / "memory")
pipe = HandoffPipeline(
store=store,
config=cfg,
manifests_dir=tmp_path / "manifests",
)
state = SessionState(
session_id="s-ma",
goal="coordinate",
agent_id="worker-a",
tokens=TokenSnapshot(input_tokens=10, output_tokens=5, total_tokens=15),
memory_keys=["shared/note-1"],
)
decision = Decision(
action=DecisionAction.HANDOFF,
rationale="phase done",
trigger="phase_boundary",
)
result = pipe.run(state, decision)
assert result.manifest.agent_id == "worker-a"
assert result.manifest.schema_version == "1.1"
assert "shared/note-1" in result.manifest.memory_keys
+105
View File
@@ -0,0 +1,105 @@
"""Tests for OpenAI-compatible agent adapter."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from clm.models.session import AgentStepResult, Message, MessageRole, TaskStatus
from clm.session.agent import Agent
from clm.session.openai_compatible import (
OpenAICompatibleAgent,
messages_to_openai,
parse_chat_completion,
)
from clm.session.simulated import SimulatedAgent
def test_messages_to_openai_roles() -> None:
msgs = [
Message(role=MessageRole.SYSTEM, content="sys"),
Message(role=MessageRole.USER, content="hi"),
Message(role=MessageRole.ASSISTANT, content="yo"),
Message(role=MessageRole.TOOL, content="out", name="bash"),
]
out = messages_to_openai(msgs)
assert out[0] == {"role": "system", "content": "sys"}
assert out[1]["role"] == "user"
assert out[3]["role"] == "user"
assert "tool" in out[3]["content"]
def test_parse_chat_completion() -> None:
payload = {
"choices": [{"message": {"content": "hello", "tool_calls": [{}, {}]}}],
"usage": {"prompt_tokens": 11, "completion_tokens": 3},
}
result = parse_chat_completion(payload)
assert result.messages_added[0].content == "hello"
assert result.tool_calls == 2
assert result.input_tokens == 11
assert result.output_tokens == 3
assert result.task_status == TaskStatus.RUNNING
def test_agent_step_with_mock_http() -> None:
captured: dict[str, Any] = {}
def fake_post(
url: str,
headers: Mapping[str, str],
body: dict[str, Any],
) -> dict[str, Any]:
captured["url"] = url
captured["headers"] = dict(headers)
captured["body"] = body
return {
"choices": [{"message": {"content": "mocked reply"}}],
"usage": {"prompt_tokens": 5, "completion_tokens": 2},
}
agent = OpenAICompatibleAgent(
api_key="sk-test",
model="gpt-test",
base_url="https://example.test/v1",
http_post=fake_post,
)
result = agent.step([Message(role=MessageRole.USER, content="ping")])
assert result.messages_added[0].content == "mocked reply"
assert captured["url"] == "https://example.test/v1/chat/completions"
assert "Bearer sk-test" in captured["headers"]["Authorization"]
assert captured["body"]["model"] == "gpt-test"
def test_inject_and_reset() -> None:
def fake_post(
url: str,
headers: Mapping[str, str],
body: dict[str, Any],
) -> dict[str, Any]:
return {
"choices": [{"message": {"content": "ok"}}],
"usage": {},
}
agent = OpenAICompatibleAgent(
api_key="k",
model="m",
http_post=fake_post,
)
agent.inject_context("hint-1")
assert agent.system_addons == ["hint-1"]
seed = [Message(role=MessageRole.SYSTEM, content="seed")]
agent.reset_with_seed(seed)
assert agent.reset_called is True
assert agent.system_addons == []
assert agent.seed_messages == seed
def test_conformance_isinstance_protocol() -> None:
sim = SimulatedAgent(
[AgentStepResult(messages_added=[], task_status=TaskStatus.COMPLETED)]
)
oai = OpenAICompatibleAgent(api_key="k", model="m", http_post=lambda *a, **k: {})
assert isinstance(sim, Agent)
assert isinstance(oai, Agent)
+152
View File
@@ -0,0 +1,152 @@
"""P4 production: cost model, structured logging, memory backup, manifest version."""
from __future__ import annotations
import io
import json
import logging
from pathlib import Path
import pytest
from clm.logging.structured import configure_structured_logging, log_event
from clm.memory.backup import backup_memory, restore_memory
from clm.memory.file_store import FileMemoryStore
from clm.metrics.cost import compute_cost_usd, cost_for_config
from clm.metrics.monitor import MetricsMonitor
from clm.models.config import ClmConfig, ProviderPrices, TaskType, Thresholds
from clm.models.manifest import MANIFEST_SCHEMA_VERSION, HandoffManifest, ManifestStatus
from clm.models.session import AgentStepResult, Message, MessageRole, SessionState
def test_compute_cost_none_without_prices() -> None:
assert compute_cost_usd(1000, 500, price_per_1k_input=None, price_per_1k_output=None) is None
def test_compute_cost_global() -> None:
cost = compute_cost_usd(
1000, 2000, price_per_1k_input=0.001, price_per_1k_output=0.002
)
assert cost == pytest.approx(0.001 + 0.004)
def test_resolve_prices_from_provider_table() -> None:
cfg = ClmConfig.for_task(TaskType.CODING).model_copy(
update={
"provider": "openai:gpt-4o-mini",
"price_per_1k_input": 9.0,
"price_per_1k_output": 9.0,
"price_table": {
"openai:gpt-4o-mini": ProviderPrices(
price_per_1k_input=0.00015, price_per_1k_output=0.0006
)
},
}
)
pin, pout = cfg.resolve_prices()
assert pin == 0.00015
assert pout == 0.0006
cost = cost_for_config(cfg, input_tokens=1_000_000, output_tokens=1_000_000)
assert cost == pytest.approx(0.00015 * 1000 + 0.0006 * 1000)
def test_metrics_monitor_logs_cost(tmp_path: Path) -> None:
cfg = ClmConfig(
task_type=TaskType.CODING,
thresholds=Thresholds(soft_compact=100, hard_handoff=200),
clm_home=tmp_path,
price_per_1k_input=1.0,
price_per_1k_output=2.0,
provider="test-provider",
)
mon = MetricsMonitor(tmp_path / "metrics.jsonl", config=cfg)
state = SessionState(session_id="s1", goal="g")
step = AgentStepResult(
messages_added=[Message(role=MessageRole.ASSISTANT, content="hi")],
input_tokens=1000,
output_tokens=500,
)
mon.update(state, step)
line = json.loads((tmp_path / "metrics.jsonl").read_text(encoding="utf-8").strip())
assert line["provider"] == "test-provider"
assert line["step_cost_usd"] == pytest.approx(1.0 + 1.0)
assert line["session_cost_usd"] == pytest.approx(1.0 + 1.0)
def test_structured_json_logging() -> None:
buf = io.StringIO()
logger = configure_structured_logging(level=logging.INFO, stream=buf, logger_name="clm.test")
log_event(logger, "hello", session_id="s1", action="compact")
raw = buf.getvalue().strip()
payload = json.loads(raw)
assert payload["msg"] == "hello"
assert payload["level"] == "INFO"
assert payload["session_id"] == "s1"
assert payload["action"] == "compact"
def test_backup_and_restore_memory(tmp_path: Path) -> None:
home = tmp_path / ".clm"
store = FileMemoryStore(home / "memory")
store.write("topics/alpha", "payload-alpha", tags=["t"])
archive = backup_memory(home, label="pre")
assert archive.is_file()
assert "memory_" in archive.name
assert archive.name.endswith(".tar.gz")
# Wipe and restore
import shutil
shutil.rmtree(home / "memory")
restored = restore_memory(archive, home)
assert restored.is_dir()
store2 = FileMemoryStore(restored)
assert store2.read("topics/alpha") == "payload-alpha"
def test_backup_missing_memory_raises(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
backup_memory(tmp_path / "nope")
def test_manifest_schema_version_default() -> None:
m = HandoffManifest(
goal="g",
status=ManifestStatus.IN_PROGRESS,
source_of_truth=[],
changed_artifacts=[],
commands_and_results=[],
verified=[],
not_verified=[],
assumptions_risks=[],
blockers=[],
next_safe_action="next",
memory_keys=["k1"],
token_snapshot=1,
timestamp="2026-08-04T12:00:00Z",
decision_rationale="why",
)
assert m.schema_version == MANIFEST_SCHEMA_VERSION
assert m.agent_id is None
def test_manifest_accepts_agent_id() -> None:
m = HandoffManifest(
goal="g",
status=ManifestStatus.IN_PROGRESS,
source_of_truth=[],
changed_artifacts=[],
commands_and_results=[],
verified=[],
not_verified=[],
assumptions_risks=[],
blockers=[],
next_safe_action="next",
memory_keys=[],
token_snapshot=0,
timestamp="2026-08-04T12:00:00Z",
decision_rationale="why",
agent_id="coder-1",
schema_version="1.1",
)
assert m.agent_id == "coder-1"
+83
View File
@@ -0,0 +1,83 @@
"""Unit tests for deterministic VerificationSuite."""
from __future__ import annotations
from clm.compaction.verify import (
VerificationSuite,
build_questions,
check_answer,
)
from clm.models.session import SessionState
def test_build_questions_from_state_fields() -> None:
state = SessionState(
session_id="s1",
goal="Ship feature X",
verified=["tests green"],
assumptions_risks=["no network in CI"],
blockers=["missing API key"],
)
qs = build_questions(state)
sources = {q.source for q in qs}
assert sources == {"goal", "verified", "constraint", "blocker"}
assert len(qs) == 4
def test_empty_questions_pass_with_score_one() -> None:
state = SessionState(session_id="s1", goal="") # empty goal → no questions
result = VerificationSuite().verify(
state=state,
summary="",
hint="",
threshold=0.8,
)
assert result.total_count == 0
assert result.recall_score == 1.0
assert result.passed is True
def test_verify_passes_when_facts_in_corpus() -> None:
state = SessionState(
session_id="s1",
goal="Ship feature X",
verified=["pytest green"],
assumptions_risks=["offline only"],
)
corpus = "Ship feature X\npytest green\noffline only"
result = VerificationSuite().verify(
state=state,
summary=corpus,
hint="",
threshold=0.8,
)
assert result.passed is True
assert result.recall_score == 1.0
assert result.passed_count == result.total_count
def test_verify_fails_when_facts_missing() -> None:
state = SessionState(
session_id="s1",
goal="Ship feature X",
verified=["critical decision ABC"],
assumptions_risks=["must keep constraint ZZZ"],
blockers=["blocker unique 999"],
)
# Summary only has goal — other facts missing → score < 0.8
result = VerificationSuite().verify(
state=state,
summary="Ship feature X",
hint="Ship feature X",
threshold=0.8,
)
assert result.passed is False
assert result.recall_score < 0.8
assert result.passed_count < result.total_count
def test_check_answer_case_insensitive() -> None:
ok, matched, missing = check_answer("Hello WORLD", ["hello world"])
assert ok is True
assert matched == ["hello world"]
assert missing == []
+1 -1
View File
@@ -2,4 +2,4 @@ import clm
def test_version():
assert clm.__version__ == "0.1.0a1"
assert clm.__version__ == "0.4.0a1"
Generated
+643
View File
@@ -0,0 +1,643 @@
version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
]
[[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 = "ast-serialize"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" },
{ url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" },
{ url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" },
{ url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" },
{ url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" },
{ url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" },
{ url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" },
{ url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" },
{ url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" },
{ url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" },
{ url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" },
{ url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" },
{ url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" },
{ url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" },
{ url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" },
{ url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" },
{ url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" },
{ url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" },
{ url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" },
{ url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" },
{ url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" },
{ url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" },
{ url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" },
]
[[package]]
name = "clm"
version = "0.1.0a1"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyyaml" },
{ name = "rich" },
{ name = "typer" },
]
[package.optional-dependencies]
dev = [
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "types-pyyaml" },
]
[package.metadata]
requires-dist = [
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
{ name = "pydantic", specifier = ">=2.7" },
{ name = "pydantic-settings", specifier = ">=2.3" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "rich", specifier = ">=13.7" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" },
{ name = "typer", specifier = ">=0.12" },
{ name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" },
]
provides-extras = ["dev"]
[[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 = "coverage"
version = "7.15.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592, upload-time = "2026-08-02T18:50:17.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/6c/bac99d9d4c6abe856e93bf3f5212982ac0bfac126dd4a042753bd53bc5af/coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f", size = 222499, upload-time = "2026-08-02T18:48:15.018Z" },
{ url = "https://files.pythonhosted.org/packages/aa/bc/cb9a39b083bc1aa70586482dab25c9be20bab0ec6c155340e50d9066bb1e/coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60", size = 222866, upload-time = "2026-08-02T18:48:16.884Z" },
{ url = "https://files.pythonhosted.org/packages/58/fb/beaa453d62000a0a5b39838bee2a137afe609a50a71f55e83c73461e513b/coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f", size = 254367, upload-time = "2026-08-02T18:48:18.507Z" },
{ url = "https://files.pythonhosted.org/packages/66/64/43e72500ed6815cef189f9193f29d7af4b078830337c95ea976cd0c0d427/coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088", size = 257103, upload-time = "2026-08-02T18:48:20.172Z" },
{ url = "https://files.pythonhosted.org/packages/66/3a/2893e2937adfe02f45fd38e4a8a0a0d8b7a02ff9e012ac3d009bee3c4f16/coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c", size = 258220, upload-time = "2026-08-02T18:48:21.963Z" },
{ url = "https://files.pythonhosted.org/packages/30/b4/d5e6e2eb1a62961083734291304b1f85df72e2abe95c76eb88a7f472afd0/coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851", size = 260481, upload-time = "2026-08-02T18:48:23.682Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c9/9b72c5c6a9798a9a12cf65f66e077cc1fdd396e61915c862688f9afe1cae/coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9", size = 254749, upload-time = "2026-08-02T18:48:25.32Z" },
{ url = "https://files.pythonhosted.org/packages/92/20/e1c2f759e2dbce559ba85c40c0e4acfecc6cff4b740c294c88e41ccc6111/coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e", size = 256138, upload-time = "2026-08-02T18:48:27.064Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ab/48cc7e760f769e86ae290a125ea6e7209dfbdbbbb7ff4f5d9d1ee7a45d57/coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866", size = 254283, upload-time = "2026-08-02T18:48:29.082Z" },
{ url = "https://files.pythonhosted.org/packages/15/26/39529a68154f99b3a1829debd8b25eac384effeec890a293b5bbdcb49186/coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb", size = 258352, upload-time = "2026-08-02T18:48:30.892Z" },
{ url = "https://files.pythonhosted.org/packages/91/2f/55b82aa3d8d7dd8023a56e7c5c2a70e39a3c44b3353c6cf3faec9ad51566/coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646", size = 253852, upload-time = "2026-08-02T18:48:32.934Z" },
{ url = "https://files.pythonhosted.org/packages/6a/6d/839f4045124cd3518ecf2c58967e58a911202834e7c5a03cfdf2ab0b29f6/coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0", size = 255725, upload-time = "2026-08-02T18:48:34.848Z" },
{ url = "https://files.pythonhosted.org/packages/75/21/d25e3e2a9e327798078c877f469dfb6def860bf6e25036529046227d3e15/coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d", size = 224566, upload-time = "2026-08-02T18:48:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/b1/0f/df90cc1e8d095ce263968a93e04829821b2afb31ac2752c06a2e0a8e3c13/coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a", size = 225098, upload-time = "2026-08-02T18:48:38.941Z" },
{ url = "https://files.pythonhosted.org/packages/65/c7/ec49e43c58967a07163e2d1c6bbd58112b825b2772ab66784afd6a5400ba/coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235", size = 224485, upload-time = "2026-08-02T18:48:40.682Z" },
{ url = "https://files.pythonhosted.org/packages/68/6e/62ae61e1fc434956bec38ed1d5b1c494f58cf579dbd998e77abffe7b3e6b/coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de", size = 222522, upload-time = "2026-08-02T18:48:42.476Z" },
{ url = "https://files.pythonhosted.org/packages/13/ff/c74c673d81e0e77b6608c3d21331e3db42e30daeb3c8a0a8860d4c9e2e14/coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c", size = 222894, upload-time = "2026-08-02T18:48:44.274Z" },
{ url = "https://files.pythonhosted.org/packages/a1/91/ccb30f5ffafd7d69d0b18e5162f9b711a5654e807b7b0c13497f0826b33f/coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3", size = 253890, upload-time = "2026-08-02T18:48:46.097Z" },
{ url = "https://files.pythonhosted.org/packages/29/c6/e92a66cda49a2751b09826d51258f199b92aa0cb005bc5f34e9729a52a9c/coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30", size = 256484, upload-time = "2026-08-02T18:48:47.846Z" },
{ url = "https://files.pythonhosted.org/packages/96/7a/730929164b457cf25cf76c23898b90f9039a104a647890801b6586797b14/coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10", size = 257723, upload-time = "2026-08-02T18:48:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/9e/be/04cb5672cb19f5c389eda81ba22d89807699a949653d3625b0e0fda169da/coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049", size = 259854, upload-time = "2026-08-02T18:48:51.413Z" },
{ url = "https://files.pythonhosted.org/packages/96/25/5e7fd6af39f6507071455944b8906dd1fe5b7b6bffb6a163ceb20afa0d13/coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e", size = 254085, upload-time = "2026-08-02T18:48:53.158Z" },
{ url = "https://files.pythonhosted.org/packages/23/c8/55e58a853f1e61163a6e755897bd14a059d78411e86560f39d9951c019b5/coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040", size = 255850, upload-time = "2026-08-02T18:48:55.031Z" },
{ url = "https://files.pythonhosted.org/packages/be/74/8bcec66dbcf3d22bea2a0b2b77ee2fa6f766a647d0023d4eabbc4f2b2756/coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21", size = 253818, upload-time = "2026-08-02T18:48:57.163Z" },
{ url = "https://files.pythonhosted.org/packages/ce/06/450b673fdfece0997b4e16a31d6bde6b18889c578f1013ddd34c962ac6f9/coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c", size = 257973, upload-time = "2026-08-02T18:48:59.098Z" },
{ url = "https://files.pythonhosted.org/packages/56/fd/3ec7409aec0ddc943132452b65672f065f043b844f1830e1fe173c98b3ab/coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f", size = 253638, upload-time = "2026-08-02T18:49:01.199Z" },
{ url = "https://files.pythonhosted.org/packages/75/20/30a8dabb194123631c93f860fdd86401ad405d56cfb1841873afbfe4e92b/coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c", size = 255407, upload-time = "2026-08-02T18:49:03.143Z" },
{ url = "https://files.pythonhosted.org/packages/13/4d/e14365b1953b43653341412f9088b0d752614c626a73a705ff9af400f3a3/coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93", size = 224575, upload-time = "2026-08-02T18:49:05.011Z" },
{ url = "https://files.pythonhosted.org/packages/1c/64/88f762ea80de2070207246faef514513be874486b2773528f2cc2b4b515c/coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3", size = 225116, upload-time = "2026-08-02T18:49:06.894Z" },
{ url = "https://files.pythonhosted.org/packages/ab/66/03c34c53a319f522554cd29d4f2e16c5eab61aa4cdcf55753129fd7d926c/coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767", size = 224509, upload-time = "2026-08-02T18:49:09.129Z" },
{ url = "https://files.pythonhosted.org/packages/35/6f/8c2dc014357618b3226c90f731b8282766c3685786f422558991dc49fbf2/coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d", size = 222571, upload-time = "2026-08-02T18:49:11.242Z" },
{ url = "https://files.pythonhosted.org/packages/07/50/d867c7ceae9d56b7e74ee61ea834f1aa4f9a1e1c7f0ce39393ba573b1c12/coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef", size = 222902, upload-time = "2026-08-02T18:49:13.448Z" },
{ url = "https://files.pythonhosted.org/packages/62/77/4f6dfc490c5f2bcacb2d296d9aa4d1e128c43b48e94ad313fec7f49f09ad/coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd", size = 253947, upload-time = "2026-08-02T18:49:15.304Z" },
{ url = "https://files.pythonhosted.org/packages/16/8a/6777f192af264165103e2a3d3768dbadb9894a0a2359a16877141d9ae8f5/coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731", size = 256452, upload-time = "2026-08-02T18:49:17.801Z" },
{ url = "https://files.pythonhosted.org/packages/7d/7b/3d7ac46a0234bc684f41ee42be95e29b2b6525695adb04083609d5ac2149/coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e", size = 257798, upload-time = "2026-08-02T18:49:19.878Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1e/c6ee59c29afcb5fdb35f936381340d1a06429a07c48f20e809646647acbe/coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c", size = 260112, upload-time = "2026-08-02T18:49:21.858Z" },
{ url = "https://files.pythonhosted.org/packages/c1/e1/e8ea39a46e89e3a143312ee5f80336e992e3ae8fe44bf9c76b83fefeed42/coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764", size = 253944, upload-time = "2026-08-02T18:49:23.926Z" },
{ url = "https://files.pythonhosted.org/packages/95/67/31ab5f6a37fd887d1386f81f0da9306851ad2264e9baaa9c7f606e0b3e17/coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e", size = 255805, upload-time = "2026-08-02T18:49:25.973Z" },
{ url = "https://files.pythonhosted.org/packages/fb/6a/ee505a80c8fd89620fb337c0596daecff87f33171fbb4ee3015fc3d7331f/coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f", size = 253769, upload-time = "2026-08-02T18:49:27.883Z" },
{ url = "https://files.pythonhosted.org/packages/b0/41/6ab0f81c9e89660230d8f3f581d4732e5ddb75a885b0a5dfc73d315dc94f/coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd", size = 258045, upload-time = "2026-08-02T18:49:30.201Z" },
{ url = "https://files.pythonhosted.org/packages/bc/62/c995e91cae28cf31d6defab3bfb553dda5ac83ac7381b0f2b121264c307a/coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a", size = 253587, upload-time = "2026-08-02T18:49:32.349Z" },
{ url = "https://files.pythonhosted.org/packages/84/df/f2049980f82d6890321f2065f9e66216eabbf4b2001815db958bc543f40a/coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6", size = 255243, upload-time = "2026-08-02T18:49:34.324Z" },
{ url = "https://files.pythonhosted.org/packages/1d/82/2c841b67a978c0eb9c3707630b68f93f9e7585d78bb906bc8823ec6b07a5/coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8", size = 224759, upload-time = "2026-08-02T18:49:36.326Z" },
{ url = "https://files.pythonhosted.org/packages/b3/78/5c93ec43784fd3e404ca23cd0584ae24bc1732de4a3fc194b68c3be88db0/coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2", size = 225246, upload-time = "2026-08-02T18:49:38.366Z" },
{ url = "https://files.pythonhosted.org/packages/9d/77/813a054371f3b018cc63c6bdb46a3c35d5e95d4e3ed4f1449d4196106db5/coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926", size = 224673, upload-time = "2026-08-02T18:49:40.552Z" },
{ url = "https://files.pythonhosted.org/packages/8f/63/8c9f36cc71178d26db930baa03a4494abcc516d8d41bf820d0d85ef1d80b/coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b", size = 223298, upload-time = "2026-08-02T18:49:42.634Z" },
{ url = "https://files.pythonhosted.org/packages/54/66/211f24d058ce9f56ebf1420d55b7574fdae924f6da3836f83c8bd4793e38/coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95", size = 223568, upload-time = "2026-08-02T18:49:44.706Z" },
{ url = "https://files.pythonhosted.org/packages/dd/bb/9c2ad5574a0d6420a96c6cade4f8a683931b9e79fe609f8924d7b6964616/coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43", size = 264932, upload-time = "2026-08-02T18:49:47.153Z" },
{ url = "https://files.pythonhosted.org/packages/ba/91/938c39e77bdd5a0a440412f975609ce3702dabbda6ac715719d93ca45a7b/coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8", size = 267052, upload-time = "2026-08-02T18:49:49.324Z" },
{ url = "https://files.pythonhosted.org/packages/b0/a3/7b431a98af35d9cc6394e54cde9435b33b8591672fbece6a4931267d7a8e/coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779", size = 269473, upload-time = "2026-08-02T18:49:51.599Z" },
{ url = "https://files.pythonhosted.org/packages/32/58/dbc9951dce46be47a732823a1c571f62bcabdd54a68d8c281489a1a55cfb/coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77", size = 270591, upload-time = "2026-08-02T18:49:53.865Z" },
{ url = "https://files.pythonhosted.org/packages/71/bd/1d610772c7c0889bfe477a59c46ee66ea53e271f3f06951e9d55b317f7c6/coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005", size = 264007, upload-time = "2026-08-02T18:49:55.875Z" },
{ url = "https://files.pythonhosted.org/packages/69/97/852eb3dcdba156b1a9078503f098499916bf889f964b61ad4a08223ac169/coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57", size = 266926, upload-time = "2026-08-02T18:49:57.944Z" },
{ url = "https://files.pythonhosted.org/packages/52/f8/b72cd238757fba2b587fc7dee047efe6e10b0c18343509faaaf502dd4680/coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91", size = 264529, upload-time = "2026-08-02T18:50:00.035Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0a/6c52ec4b7fb007cb6433d1fcfda4080cb15d75ad37ef9c31025f3427293e/coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2", size = 268263, upload-time = "2026-08-02T18:50:02.161Z" },
{ url = "https://files.pythonhosted.org/packages/c0/4e/f1f9aa3efd109a04353563a43fb5155340c1fdcdeaa6296ebed3b6f510ea/coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef", size = 263377, upload-time = "2026-08-02T18:50:04.243Z" },
{ url = "https://files.pythonhosted.org/packages/dd/fb/6b268a0b2728ef1c379ad656b899274477a5f6bed1bf6765b4b387fb0601/coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c", size = 265688, upload-time = "2026-08-02T18:50:06.428Z" },
{ url = "https://files.pythonhosted.org/packages/29/54/1a3ea96e5d5e7cd41dc432597bfc60692910e635d05e1cc25a8ccc243581/coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42", size = 225066, upload-time = "2026-08-02T18:50:08.533Z" },
{ url = "https://files.pythonhosted.org/packages/31/9d/a7b0d9afd18ed5274dd00651a78e7810a931c70d94b79996f150bec1a30f/coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429", size = 225897, upload-time = "2026-08-02T18:50:10.572Z" },
{ url = "https://files.pythonhosted.org/packages/ca/11/34c5ae40b945e69aa72b87dc268135b7049905f3824af573b7073acbb946/coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e", size = 225212, upload-time = "2026-08-02T18:50:12.63Z" },
{ url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297, upload-time = "2026-08-02T18:50:14.709Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "librt"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" },
{ url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" },
{ url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" },
{ url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" },
{ url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" },
{ url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" },
{ url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" },
{ url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" },
{ url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" },
{ url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" },
{ url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" },
{ url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" },
{ url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" },
{ url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" },
{ url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" },
{ url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" },
{ url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" },
{ url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" },
{ url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" },
{ url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" },
{ url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" },
{ url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" },
{ url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" },
{ url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" },
{ url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" },
{ url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" },
{ url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" },
{ url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" },
{ url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" },
{ url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" },
{ url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" },
{ url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" },
{ url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" },
{ url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" },
{ url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" },
{ url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" },
{ url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" },
{ url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" },
{ url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" },
{ url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mypy"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" },
{ url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" },
{ url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" },
{ url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" },
{ url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" },
{ url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" },
{ url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" },
{ url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" },
{ url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" },
{ url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" },
{ url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" },
{ url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" },
{ url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" },
{ url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" },
{ url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" },
{ url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" },
{ url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" },
{ url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" },
{ url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" },
{ url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" },
{ url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" },
{ url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" },
{ url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[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 = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[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.13.4"
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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[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-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[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 = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "ruff"
version = "0.16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" },
{ url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" },
{ url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" },
{ url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" },
{ url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" },
{ url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" },
{ url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" },
{ url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" },
{ url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" },
{ url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" },
{ url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" },
{ url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" },
{ url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" },
{ url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "typer"
version = "0.27.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20260724"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" },
]
[[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.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]