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:
1 parent
faf00dfaae
commit
7c788bdbf1
67 files changed
+5632
-188
No files matched your search
+125
-92
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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 < 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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 < 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 < 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
@@ -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"
|
||||
|
||||
@@ -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
@@ -1,3 +1,3 @@
|
||||
"""CLM — Context Lifecycle Management for LLM Agents."""
|
||||
|
||||
__version__ = "0.1.0a1"
|
||||
__version__ = "0.4.0a1"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Observability export for metrics and decisions (P3)."""
|
||||
|
||||
from clm.export.metrics_export import ExportResult, export_metrics
|
||||
|
||||
__all__ = ["ExportResult", "export_metrics"]
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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 == []
|
||||
@@ -2,4 +2,4 @@ import clm
|
||||
|
||||
|
||||
def test_version():
|
||||
assert clm.__version__ == "0.1.0a1"
|
||||
assert clm.__version__ == "0.4.0a1"
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user