From 5c98e126173cf66d2de5e9540dfbedbcb369df24 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Wed, 23 Sep 2026 09:02:05 +0200 Subject: [PATCH 01/39] retain works --- .env.example | 21 +- codespy.yaml | 36 +- docs/configuration.md | 17 +- docs/memory.md | 4 +- poetry.lock | 5900 +++++++++++++---- pyproject.toml | 1 + .../agents/memory/cerebral/__init__.py | 3 + .../agents/memory/cerebral/cerebral.py | 197 + src/codespy/agents/memory/postgres.py | 13 + src/codespy/agents/review/audit/agent.py | 9 +- .../agents/review/code_review/agent.py | 10 +- src/codespy/agents/review/doc/agent.py | 10 +- src/codespy/agents/review/scope/agent.py | 9 +- src/codespy/agents/review/summary/agent.py | 9 +- .../agents/review/supply_chain/agent.py | 10 +- src/codespy/config.py | 4 +- src/codespy/config_dspy.py | 2 +- src/codespy/config_memory.py | 198 +- tests/test_cerebral.py | 447 ++ tests/test_config_memory.py | 8 +- 20 files changed, 5720 insertions(+), 1188 deletions(-) create mode 100644 src/codespy/agents/memory/cerebral/__init__.py create mode 100644 src/codespy/agents/memory/cerebral/cerebral.py create mode 100644 tests/test_cerebral.py diff --git a/.env.example b/.env.example index 9dab44b..4f8cbf2 100644 --- a/.env.example +++ b/.env.example @@ -210,8 +210,8 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # MEMORY_PG0_PORT=5432 # MEMORY_PG0_DATA_DIR=~/.cache/codespy/pg0 # default; persists on Docker-mounted codespy-cache volume -# Memory default — overridable per-signature via _MEMORY_* -# MEMORY_DEFAULT_ENABLED=false +# Memory master switch — overridable per-signature via _MEMORY_ENABLED +# MEMORY_ENABLED=false # Whether to compact trajectories (head+tail bounding) before distillation. # When false, the full trajectory is sent to the Distiller and ContextSafe @@ -259,6 +259,21 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # MEMORY_CARTOGRAPHER_MAX_LLM_CALLS=2 +# ============================================================================= +# Cerebral LLM overrides (same pattern as distiller/cartographer) +# ============================================================================= +# Cerebral (semantic memory) activates automatically when MEMORY_ENABLED=true. +# LLM provider and credentials are derived from the model string and existing +# LLM config (e.g. bedrock uses AWS env vars, openai uses OPENAI_API_KEY). +# MEMORY_CEREBRAL_MODEL= # Falls back to DEFAULT_MODEL +# MEMORY_CEREBRAL_TEMPERATURE= +# MEMORY_CEREBRAL_MAX_TOKENS= + +# Hindsight embedding model (for semantic memory). +# Auto-derived from LLM provider if unset. +# MEMORY_HINDSIGHT_EMBEDDINGS_MODEL= + + # ============================================================================= # Output Settings # ============================================================================= @@ -296,7 +311,7 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # - REASONING_EFFORT (minimal|low|medium|high) - Provider reasoning budget # - TEMPERATURE (float) - Must be 1 while reasoning is enabled # - MAX_TOKENS (integer) - Output token budget (unset -> DEFAULT_MAX_TOKENS) -# - MEMORY_ENABLED (true/false) - Hippocampus memory (unset -> MEMORY_DEFAULT_ENABLED) +# - MEMORY_ENABLED (true/false) - Hippocampus memory (unset -> MEMORY_ENABLED) # Examples: # CODE_REVIEW_ENABLED=true diff --git a/codespy.yaml b/codespy.yaml index eafe6e0..874497d 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -98,8 +98,8 @@ memory: # or agent name. When unset, auto-generated from hostname. bank_id: null # MEMORY_BANK_ID - # Memory default — overridable per-signature via signatures..memory - default_enabled: false # MEMORY_DEFAULT_ENABLED + # Master switch — overridable per-signature via signatures..memory + enabled: false # MEMORY_ENABLED # Four independent token budgets, from most to least cost-sensitive: # @@ -143,6 +143,22 @@ memory: max_tokens: null # MEMORY_CARTOGRAPHER_MAX_TOKENS max_iters: 1 # MEMORY_CARTOGRAPHER_MAX_ITERS max_llm_calls: 2 # MEMORY_CARTOGRAPHER_MAX_LLM_CALLS + cerebral: + model: null # MEMORY_CEREBRAL_MODEL (falls back to default_model) + reasoning_effort: null # MEMORY_CEREBRAL_REASONING_EFFORT + temperature: null # MEMORY_CEREBRAL_TEMPERATURE + max_tokens: null # MEMORY_CEREBRAL_MAX_TOKENS + max_iters: 1 # MEMORY_CEREBRAL_MAX_ITERS + max_llm_calls: 2 # MEMORY_CEREBRAL_MAX_LLM_CALLS + + # Hindsight MemoryEngine settings (semantic memory). + # The LLM for fact extraction is configured via cerebral: above. + # embeddings_model is auto-derived from the LLM provider when null: + # bedrock → bedrock/cohere.embed-multilingual-v3 + # openai → openai/text-embedding-3-small + # gemini → gemini/text-embedding-004 + hindsight: + embeddings_model: null # MEMORY_HINDSIGHT_EMBEDDINGS_MODEL # ============================================================================ @@ -193,6 +209,10 @@ rlm_fallback: # Mid-tier (memory.distiller / memory.cartographer): Memory reflection — # summarizing a trajectory and curating the context memory. Requires nuance. # Recommended: anthropic/claude-sonnet-4-5-20250929 or equivalent. + # + # Mid-tier (memory.cerebral): Hindsight fact extraction — distils episode + # observations and artifacts into structured facts, entities, and knowledge. + # Recommended: anthropic/claude-sonnet-4-5-20250929 or equivalent. # # By default, all models fall back to default_model. Override extraction_model, # the summary model, and the reflection models for cost optimization: @@ -248,7 +268,7 @@ signatures: # When true: scans ALL artifacts (Dockerfiles, etc.) and manifests # When false (default): only scans artifacts/manifests that were modified in the MR memory: - enabled: null # SUPPLY_CHAIN_MEMORY_ENABLED (null -> memory.default_enabled) + enabled: null # SUPPLY_CHAIN_MEMORY_ENABLED (null -> memory.enabled) # Code Reviewer signature (bugs, security, removed defensive code, code smells) # Unified code review: bugs, security vulnerabilities, and code smells in a single agent pass per scope @@ -261,7 +281,7 @@ signatures: temperature: null # CODE_REVIEW_TEMPERATURE max_tokens: null # CODE_REVIEW_MAX_TOKENS memory: - enabled: null # CODE_REVIEW_MEMORY_ENABLED (null -> memory.default_enabled) + enabled: null # CODE_REVIEW_MEMORY_ENABLED (null -> memory.enabled) # Documentation Reviewer signature (compares patches against extracted documentation) # Note: doc extraction is now deterministic (no LLM) — see doc_extractor.py @@ -274,7 +294,7 @@ signatures: temperature: null # DOC_TEMPERATURE max_tokens: null # DOC_MAX_TOKENS memory: - enabled: null # DOC_MEMORY_ENABLED (null -> memory.default_enabled) + enabled: null # DOC_MEMORY_ENABLED (null -> memory.enabled) # Scope Identifier signature scope: @@ -286,7 +306,7 @@ signatures: temperature: null # SCOPE_TEMPERATURE max_tokens: null # SCOPE_MAX_TOKENS memory: - enabled: null # SCOPE_MEMORY_ENABLED (null -> memory.default_enabled) + enabled: null # SCOPE_MEMORY_ENABLED (null -> memory.enabled) # Summarizer signature summary: @@ -298,7 +318,7 @@ signatures: temperature: null # SUMMARY_TEMPERATURE max_tokens: null # SUMMARY_MAX_TOKENS memory: - enabled: true # SUMMARY_MEMORY_ENABLED (null -> memory.default_enabled) + enabled: null # SUMMARY_MEMORY_ENABLED (null -> memory.enabled) # Auditor signature (quality assessment + recommendation after all reviews) audit: @@ -310,7 +330,7 @@ signatures: temperature: null # AUDIT_TEMPERATURE max_tokens: null # AUDIT_MAX_TOKENS memory: - enabled: null # AUDIT_MEMORY_ENABLED (null -> memory.default_enabled) + enabled: null # AUDIT_MEMORY_ENABLED (null -> memory.enabled) # ============================================================================ # OUTPUT diff --git a/docs/configuration.md b/docs/configuration.md index 7bf818e..360b4ca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,6 +126,7 @@ AUTO_DISCOVER_GEMINI=false | Mid-tier | Field extraction | `EXTRACTION_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | | Cheap | PR summary | `SUMMARY_MODEL` | Falls back to DEFAULT_MODEL | Claude Haiku | | Mid-tier | Memory reflection | `MEMORY_DISTILLER_MODEL` / `MEMORY_CARTOGRAPHER_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | +| Mid-tier | Semantic memory (fact extraction) | `MEMORY_CEREBRAL_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | ## Per-Signature Configuration @@ -182,7 +183,7 @@ Brief overview: | pg0 name | `MEMORY_PG0_NAME` | `codespy` | pg0-embedded database name (local dev) | | pg0 port | `MEMORY_PG0_PORT` | auto | pg0-embedded port (local dev) | | pg0 data dir | `MEMORY_PG0_DATA_DIR` | — | pg0-embedded data directory (local dev) | -| Default enabled | `MEMORY_DEFAULT_ENABLED` | `false` | Enable memory globally | +| Enabled | `MEMORY_ENABLED` | `false` | Enable memory globally (episodic + semantic) | | Context memory tokens | `MEMORY_MAX_CONTEXT_MEMORY_TOKENS` | `16384` | Ceiling on persisted context memory | | Observation tokens | `MEMORY_MAX_CONTEXT_ITEM_TOKENS` | `512` | Soft per-observation token limit | | Trajectory tokens | `MEMORY_MAX_TRAJECTORY_TOKENS` | `16384` | Cap on trajectory fed to Distiller | @@ -190,6 +191,20 @@ Brief overview: See [Memory System](memory.md) for full memory configuration details. +## Cerebral Semantic Memory + +Cerebral retains episode observations and artifacts into Hindsight AI semantic memory, creating a knowledge layer alongside the episodic store. It activates automatically when `MEMORY_ENABLED=true`. + +LLM provider and credentials are auto-derived from the model string and existing LLM config (e.g., `bedrock/*` uses AWS env vars, `openai/*` uses `OPENAI_API_KEY`). + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| Cerebral Model | `MEMORY_CEREBRAL_MODEL` | `DEFAULT_MODEL` | Model for fact extraction (ReflectionModuleConfig) | +| Cerebral Temperature | `MEMORY_CEREBRAL_TEMPERATURE` | `DEFAULT_TEMPERATURE` | Temperature for fact extraction | +| Cerebral Max Tokens | `MEMORY_CEREBRAL_MAX_TOKENS` | `DEFAULT_MAX_TOKENS` | Output token budget | + +Hindsight MemoryEngine uses the same PostgreSQL instance under the `semantic` schema (created automatically alongside `episodic`). + ## Output Settings | Setting | Env Var | Default | Description | diff --git a/docs/memory.md b/docs/memory.md index 5fca36f..cac3a11 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -212,7 +212,7 @@ Observation capacity ≈ context_memory_tokens / observation_tokens (16384/512 = | `MEMORY_PG0_PORT` | `memory.pg0.port` | auto | pg0-embedded port | | `MEMORY_PG0_DATA_DIR` | `memory.pg0.data_dir` | — | Custom data directory for pg0-embedded | | `MEMORY_BANK_ID` | `memory.bank_id` | `codespy` | Scopes all memory data | -| `MEMORY_DEFAULT_ENABLED` | `memory.default_enabled` | `false` | Enable memory globally | +| `MEMORY_ENABLED` | `memory.enabled` | `false` | Enable memory globally (episodic + semantic) | | `MEMORY_COMPACT_TRAJECTORY` | `memory.compact_trajectory` | `true` | Apply head+tail trajectory bounding before distillation | ### Reflection Module LLM Overrides @@ -238,7 +238,7 @@ See [Configuration](configuration.md#recommended-model-strategy) for recommended Enable memory for code review: ```bash -MEMORY_DEFAULT_ENABLED=true +MEMORY_ENABLED=true # Or per-signature: CODE_REVIEW_MEMORY_ENABLED=true SUMMARY_MEMORY_ENABLED=true diff --git a/poetry.lock b/poetry.lock index b5d7760..631b68e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,30 @@ # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +[[package]] +name = "agent-detector" +version = "2.0.0" +description = "Detect AI coding agents from their execution environment or User-Agent" +optional = false +python-versions = ">=3.9" +files = [ + {file = "agent_detector-2.0.0-py3-none-any.whl", hash = "sha256:22c6a1c9c23894a12f55aea625f060b8d3330fba774de72d02d5442bb580417f"}, + {file = "agent_detector-2.0.0.tar.gz", hash = "sha256:19c8ae185dbdca23c24abdcd8ac2cf58b8e84dc284c6f6db536656601f9ffc3f"}, +] + +[[package]] +name = "aiofile" +version = "3.12.3" +description = "Asynchronous file operations." +optional = false +python-versions = ">=3.11" +files = [ + {file = "aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017"}, + {file = "aiofile-3.12.3.tar.gz", hash = "sha256:caa6aa746b5e47e2165f7abd741b6415e49cf4d44fddc0f61844612cc3924d41"}, +] + +[package.dependencies] +caio = ">=0.12.0,<0.13.0" + [[package]] name = "aiohappyeyeballs" version = "2.7.1" @@ -167,6 +192,25 @@ files = [ frozenlist = ">=1.1.0" typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} +[[package]] +name = "alembic" +version = "1.20.0" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.10" +files = [ + {file = "alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d"}, + {file = "alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=2.0" +typing-extensions = ">=4.12" + +[package.extras] +tz = ["tzdata"] + [[package]] name = "annotated-doc" version = "0.0.5" @@ -189,90 +233,203 @@ files = [ {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, ] +[[package]] +name = "anthropic" +version = "1.7.0" +description = "The official Python library for the anthropic API" +optional = false +python-versions = ">=3.10" +files = [ + {file = "anthropic-1.7.0-py3-none-any.whl", hash = "sha256:6b681b6ee00f232bb54f50f9a0d6d30e7be368c1b6f754bfd470c8385b8b6058"}, + {file = "anthropic-1.7.0.tar.gz", hash = "sha256:0ab1b04668606ba1ae93f6d9e8dcc2e0c4f0debebd4eea4773a3a595f0836db1"}, +] + +[package.dependencies] +anyio = ">=4.1.0,<5" +docstring-parser = ">=0.15,<1" +httpx2 = ">=2.0.0,<3" +jiter = ">=0.4.0,<1" +pydantic = ">=1.10.0,<3" +sniffio = ">=1,<2" +typing-extensions = ">=4.14,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.10.0,<4)"] +aws = ["boto3 (>=1.28.57,<2)", "botocore (>=1.31.57,<2)"] +bedrock = ["boto3 (>=1.28.57,<2)", "botocore (>=1.31.57,<2)"] +google-cloud = ["google-auth[requests] (>=2,<3)"] +mcp = ["mcp (>=1.0,<3)"] +vertex = ["google-auth[requests] (>=2,<3)"] +webhooks = ["standardwebhooks (>=1.0.1,<2)"] + [[package]] name = "anyio" -version = "4.14.2" +version = "4.15.1" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.10" files = [ - {file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"}, - {file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"}, + {file = "anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101"}, + {file = "anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94"}, ] [package.dependencies] idna = ">=2.8" -typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} +typing_extensions = {version = ">=4.16.0", markers = "python_version < \"3.15\""} [package.extras] trio = ["trio (>=0.32.0)"] +[[package]] +name = "asgiref" +version = "3.12.1" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.10" +files = [ + {file = "asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094"}, + {file = "asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340"}, +] + +[package.extras] +mypy = ["mypy (>=1.14.0)"] +tests = ["pytest", "pytest-asyncio"] + [[package]] name = "ast-serialize" -version = "0.8.0" +version = "0.11.2" description = "Python bindings for mypy AST serialization" optional = false python-versions = ">=3.7" files = [ - {file = "ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6"}, - {file = "ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16"}, - {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc"}, - {file = "ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5"}, - {file = "ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87"}, - {file = "ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06"}, - {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6"}, - {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405"}, - {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab"}, - {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3"}, - {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331"}, - {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6"}, - {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06"}, - {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3"}, - {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8"}, - {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3"}, - {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7"}, - {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe"}, - {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a"}, - {file = "ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed"}, - {file = "ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51"}, - {file = "ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0"}, - {file = "ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010"}, + {file = "ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b"}, + {file = "ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f"}, + {file = "ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d"}, + {file = "ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1"}, + {file = "ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4"}, + {file = "ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365"}, + {file = "ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07"}, + {file = "ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e"}, + {file = "ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99"}, + {file = "ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484"}, + {file = "ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27"}, + {file = "ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450"}, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +description = "An asyncio PostgreSQL driver" +optional = false +python-versions = ">=3.9.0" +files = [ + {file = "asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61"}, + {file = "asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be"}, + {file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8"}, + {file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1"}, + {file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3"}, + {file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8"}, + {file = "asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095"}, + {file = "asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540"}, + {file = "asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d"}, + {file = "asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab"}, + {file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c"}, + {file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109"}, + {file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da"}, + {file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9"}, + {file = "asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24"}, + {file = "asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047"}, + {file = "asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad"}, + {file = "asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d"}, + {file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a"}, + {file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671"}, + {file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec"}, + {file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20"}, + {file = "asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8"}, + {file = "asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186"}, + {file = "asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b"}, + {file = "asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e"}, + {file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403"}, + {file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4"}, + {file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2"}, + {file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602"}, + {file = "asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696"}, + {file = "asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab"}, + {file = "asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44"}, + {file = "asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5"}, + {file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2"}, + {file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2"}, + {file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218"}, + {file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d"}, + {file = "asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b"}, + {file = "asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be"}, + {file = "asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2"}, + {file = "asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31"}, + {file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7"}, + {file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e"}, + {file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c"}, + {file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a"}, + {file = "asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d"}, + {file = "asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3"}, + {file = "asyncpg-0.31.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ebb3cde58321a1f89ce41812be3f2a98dddedc1e76d0838aba1d724f1e4e1a95"}, + {file = "asyncpg-0.31.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6974f36eb9a224d8fb428bcf66bd411aa12cf57c2967463178149e73d4de366"}, + {file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2b685f400ceae428f79f78b58110470d7b4466929a7f78d455964b17ad1008"}, + {file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb223567dea5f47c45d347f2bde5486be8d9f40339f27217adb3fb1c3be51298"}, + {file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:22be6e02381bab3101cd502d9297ac71e2f966c86e20e78caead9934c98a8af6"}, + {file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37a58919cfef2448a920df00d1b2f821762d17194d0dbf355d6dde8d952c04f9"}, + {file = "asyncpg-0.31.0-cp39-cp39-win32.whl", hash = "sha256:c1a9c5b71d2371a2290bc93336cd05ba4ec781683cab292adbddc084f89443c6"}, + {file = "asyncpg-0.31.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1e1ab5bc65373d92dd749d7308c5b26fb2dc0fbe5d3bf68a32b676aa3bcd24a"}, + {file = "asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735"}, ] +[package.extras] +gssauth = ["gssapi", "sspilib"] + [[package]] name = "attrs" version = "26.1.0" @@ -284,6 +441,55 @@ files = [ {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] +[[package]] +name = "authlib" +version = "1.8.0" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = false +python-versions = ">=3.10" +files = [ + {file = "authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121"}, + {file = "authlib-1.8.0.tar.gz", hash = "sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0"}, +] + +[package.dependencies] +cryptography = ">=45.0.1" +joserfc = ">=1.6.1" + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +description = "Backport of CPython tarfile module" +optional = false +python-versions = ">=3.8" +files = [ + {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, + {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"] + +[[package]] +name = "beartype" +version = "0.22.9" +description = "Unbearably fast near-real-time pure-Python runtime-static type-checker." +optional = false +python-versions = ">=3.10" +files = [ + {file = "beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2"}, + {file = "beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f"}, +] + +[package.extras] +dev = ["autoapi (>=0.9.0)", "celery", "click", "coverage (>=5.5)", "docutils (>=0.22.0)", "equinox", "fastmcp", "jax[cpu]", "jaxtyping", "langchain", "mypy (>=0.800)", "nuitka (>=1.2.6)", "numba", "numpy", "pandera (>=0.26.0)", "poetry", "polars", "pydata-sphinx-theme (<=0.7.2)", "pygments", "pyinstaller", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "setuptools", "sphinx", "sphinx (>=4.2.0,<6.0.0)", "sphinxext-opengraph (>=0.7.5)", "sqlalchemy", "torch", "tox (>=3.20.1)", "typer", "typing-extensions (>=3.10.0.0)", "xarray"] +doc-ghp = ["mkdocs-material[imaging] (>=9.6.0)", "mkdocstrings-python (>=1.16.0)", "mkdocstrings-python-xref (>=1.16.0)"] +doc-rtd = ["autoapi (>=0.9.0)", "pydata-sphinx-theme (<=0.7.2)", "setuptools", "sphinx (>=4.2.0,<6.0.0)", "sphinxext-opengraph (>=0.7.5)"] +test = ["celery", "click", "coverage (>=5.5)", "docutils (>=0.22.0)", "equinox", "fastmcp", "jax[cpu]", "jaxtyping", "langchain", "mypy (>=0.800)", "nuitka (>=1.2.6)", "numba", "numpy", "pandera (>=0.26.0)", "poetry", "polars", "pygments", "pyinstaller", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "sphinx", "sqlalchemy", "torch", "tox (>=3.20.1)", "typer", "typing-extensions (>=3.10.0.0)", "xarray"] +test-tox = ["celery", "click", "docutils (>=0.22.0)", "equinox", "fastmcp", "jax[cpu]", "jaxtyping", "langchain", "mypy (>=0.800)", "nuitka (>=1.2.6)", "numba", "numpy", "pandera (>=0.26.0)", "poetry", "polars", "pygments", "pyinstaller", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "sphinx", "sqlalchemy", "torch", "typer", "typing-extensions (>=3.10.0.0)", "xarray"] +test-tox-coverage = ["coverage (>=5.5)"] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -308,17 +514,17 @@ lxml = ["lxml"] [[package]] name = "boto3" -version = "1.43.86" -description = "The AWS SDK for Python" +version = "1.43.98" +description = "The AWS SDK for Python (Boto3)" optional = false python-versions = ">=3.10" files = [ - {file = "boto3-1.43.86-py3-none-any.whl", hash = "sha256:94543a5ce482df8bb6a0bf8a944bf5ccf6625889b1df0d7886dfc3311ebd4169"}, - {file = "boto3-1.43.86.tar.gz", hash = "sha256:aca7b5d7f31a90ad37bec773552ec9bfb57e0029c9aa0f0f4d51d5bf9607b1c4"}, + {file = "boto3-1.43.98-py3-none-any.whl", hash = "sha256:7454f666a9e852a56db0a7fa23be33a899f64e27828930eac43c3edba7b34e17"}, + {file = "boto3-1.43.98.tar.gz", hash = "sha256:1ec732e023fb29c12dc8520f925b5bbbed29eeb36b5764b5a5c26052d7c721f7"}, ] [package.dependencies] -botocore = ">=1.43.86,<1.44.0" +botocore = ">=1.43.98,<1.44.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.19.0,<0.20.0" @@ -327,13 +533,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.43.86" +version = "1.43.98" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.10" files = [ - {file = "botocore-1.43.86-py3-none-any.whl", hash = "sha256:4efc7fbd6e7616edbd55623bb585a044906149b9a5d3d8558420506526e5a5ac"}, - {file = "botocore-1.43.86.tar.gz", hash = "sha256:0e943c77ab6a54aaaf4d57e6026ede5c3dca341af71ce91f71849a6eae9d5dbf"}, + {file = "botocore-1.43.98-py3-none-any.whl", hash = "sha256:84b35b10402c2fc0c265f634fbf86336eecc6489ee23f55074b329924b2cfd6f"}, + {file = "botocore-1.43.98.tar.gz", hash = "sha256:6135dd639ea6d1b3b49381bc253c8a139d61f7d44cb5f7dae8e7cd1791758572"}, ] [package.dependencies] @@ -346,15 +552,60 @@ crt = ["awscrt (==0.36.0)"] [[package]] name = "cachetools" -version = "7.1.8" +version = "7.2.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.10" files = [ - {file = "cachetools-7.1.8-py3-none-any.whl", hash = "sha256:a81e3844acaa7355b6567f97bd67a94a14ec3a9bc2cbbdae45b9592cc036775b"}, - {file = "cachetools-7.1.8.tar.gz", hash = "sha256:1221d547a0b24b7f26fa891d40d488b5258beab9aebd8ed68c729be3af849c43"}, + {file = "cachetools-7.2.0-py3-none-any.whl", hash = "sha256:3045213f186b89fdd95d94441354c4bd87c570b7a38d8c3fd1d9dd37f6dc90d8"}, + {file = "cachetools-7.2.0.tar.gz", hash = "sha256:bcac1a1b8da6909994a2957238a57b8140dab7c5c5c69a43669654fe87a33c1d"}, +] + +[[package]] +name = "caio" +version = "0.12.4" +description = "Asynchronous file IO for Linux MacOS or Windows." +optional = false +python-versions = ">=3.10" +files = [ + {file = "caio-0.12.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e9eda6a88f309a0a53bcba844471638da01a92758015e2df099fa361891dc72"}, + {file = "caio-0.12.4-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:51b8600eadc8756751dfc152514852040c6c28deb6bdcd6bfbdddb2a38e424e1"}, + {file = "caio-0.12.4-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:97bff8f7da696321552b42629d1869983a8ced2f010bac9731b2719c1195a20c"}, + {file = "caio-0.12.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:759b952df48a5bbdb16e90631c748c877fb0ec7ca9d64eef1066569925ccdd30"}, + {file = "caio-0.12.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20f1d3fe05be285d82413b00bd8061eeb62d3027d3f565adaf7db10bebc594dc"}, + {file = "caio-0.12.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b0444d20067642bb272e423de3cc8ef05bf134d335d57fa5bddcfcc98e04313e"}, + {file = "caio-0.12.4-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:780465251a0680039b29c990bca73b40b843e39d06505c62e353ed56490e4576"}, + {file = "caio-0.12.4-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:67f1bddc997bef281db36600e3e58d6b16d6486b3540eb62c9e54c6584f137fd"}, + {file = "caio-0.12.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:20acd9a8df90d25b63bc0b7dc264e2a09b99082a420b5d640c76056027ee6b52"}, + {file = "caio-0.12.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d8d2826bf622644fc374a9f53ac00918b489d03599783953fcbe79af19123f5"}, + {file = "caio-0.12.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:67b725641fea682a2e9b1d3b2a150d21f1a25383e3ec8351bd7677ff857fa982"}, + {file = "caio-0.12.4-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4854a029e359e5ddfb5589ab66157a34af6a0dda0acdaa43f5e640d60182c1ea"}, + {file = "caio-0.12.4-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:f063624a98a64bab387430c5eaea61ef3825a98399104a297b8da4741c15139f"}, + {file = "caio-0.12.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a926d562f8c06767774a91239770ebaa93c10a24074e21ea741db208df9cc1d"}, + {file = "caio-0.12.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53605636e3b1eaeca368b475f1471ad9774736c6502a4b58b8b6b8d74b531770"}, + {file = "caio-0.12.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:134d9d145d75f454de5ece9e87595bad433639b51061b693bafc369f689f8742"}, + {file = "caio-0.12.4-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:27ec5671ac05650abc7ac1fb12e95ad09417ae495ce9be565927786688edd6c5"}, + {file = "caio-0.12.4-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:1e1d5570fd0ec75b1b2c2877c5545ed9f0738b5d23b000e2b6a8fc830dc8b310"}, + {file = "caio-0.12.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e22ce2e69d94e4c80e0c11c871e547b3c2d147f977632894cb2527ddb6e87a8d"}, + {file = "caio-0.12.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9dc0fd6f09ef72d18d3f43ca3d1140295222d925c583114ab9b1e8843a109a6e"}, + {file = "caio-0.12.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:413565d77dfdf2dd841ac100571ef1cc710f9c56137312f3ec51356350b4f3a5"}, + {file = "caio-0.12.4-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:cd1d00ed1867a3b7a4cffb64b1bd8644de4b6f88cb25240cf82a9cc160ace975"}, + {file = "caio-0.12.4-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:4189104e5579553031340a677762398c130b81ddb7d7c9b69eebffddaecc4f58"}, + {file = "caio-0.12.4-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:4f2e0b6d393dc0ea78cfd13a9a4321fc35fd6a2db802e5f64651317e859fc929"}, + {file = "caio-0.12.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:710ffe4a6f3d69dbfe98e9f856c24a544e4d639c7bd210f9f5503a26e62502f4"}, + {file = "caio-0.12.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddf8d946b795ebd7c75b331b6dcbab4808fdd2c9f16ca76012b4757512861133"}, + {file = "caio-0.12.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:17a41de51203fc4d787d8d577bd56f169167aa9822f14fada015504688791aa4"}, + {file = "caio-0.12.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ee9de68d6ede7ed69e55a1c09073ce146d9f9470088796d55238c485a93fe203"}, + {file = "caio-0.12.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:1c1a387f8784a86a56a8912ca42e1ad67599f16406f497b5b655b461a1b89426"}, + {file = "caio-0.12.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ceee64265a55b9aba9c38c1d01a159b99f1e36eb56938022018199d2e7fe1742"}, + {file = "caio-0.12.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:456a93868ff007cea65d916d96d192cda8b9992c6eee046cb0159040f9446ab5"}, + {file = "caio-0.12.4-py3-none-any.whl", hash = "sha256:7a5e231bbf81eaed269f99afa9ef46457f51f9407952a1795c0795b3a62c23c0"}, + {file = "caio-0.12.4.tar.gz", hash = "sha256:32d8e9f3e2099c8db29446679252766c9bcd806eb88b4fb60ad274f73df2a5e9"}, ] +[package.extras] +develop = ["aiomisc-pytest", "coveralls", "pytest", "pytest-cov", "pytest-rerunfailures", "setuptools"] + [[package]] name = "certifi" version = "2026.7.22" @@ -659,6 +910,31 @@ files = [ {file = "charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3"}, ] +[[package]] +name = "claude-agent-sdk" +version = "0.2.157" +description = "Python SDK for Claude Code" +optional = false +python-versions = ">=3.10" +files = [ + {file = "claude_agent_sdk-0.2.157-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6a2c7aa1901e43b827411d62f2327aed82f53ed8ca6b076eaa5be4a1fc681f"}, + {file = "claude_agent_sdk-0.2.157-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ebf3f514c61db9be01bbf6d1acb0d993247340583b3559d2df75e3822f618bbb"}, + {file = "claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:a58089e0c11364db2ec7a139e09b36a4fb9c95817a758bee7b28e0434f55c0b6"}, + {file = "claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:bd126b2edee3a247b2c7c4cee1f5a3ea68ef748288ec0650db88dd3918e5471a"}, + {file = "claude_agent_sdk-0.2.157.tar.gz", hash = "sha256:4ce6f41bc965fcb4a9d11bd7a3cac266975c75b632f3d74a26f55f0fffb3160a"}, +] + +[package.dependencies] +anyio = ">=4.0.0" +jsonschema = ">=4.20.0" +mcp = ">=1.23.0,<3.0.0" +sniffio = ">=1.0.0" + +[package.extras] +dev = ["anyio[trio] (>=4.0.0)", "mypy (>=1.0.0)", "pytest (>=7.0.0)", "pytest-cov (>=4.0.0)", "ruff (>=0.1.0)"] +examples = ["asyncpg (>=0.27.0)", "boto3 (>=1.28.0)", "fakeredis (>=2.20.0)", "moto[s3] (>=5.0.0)", "redis (>=4.2.0)"] +otel = ["opentelemetry-api (>=1.20.0)"] + [[package]] name = "click" version = "8.5.0" @@ -681,6 +957,42 @@ files = [ {file = "cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414"}, ] +[[package]] +name = "cobble" +version = "0.1.4" +description = "Create data objects" +optional = false +python-versions = ">=3.5" +files = [ + {file = "cobble-0.1.4-py3-none-any.whl", hash = "sha256:36c91b1655e599fd428e2b95fdd5f0da1ca2e9f1abb0bc871dec21a0e78a2b44"}, + {file = "cobble-0.1.4.tar.gz", hash = "sha256:de38be1539992c8a06e569630717c485a5f91be2192c461ea2b220607dfa78aa"}, +] + +[[package]] +name = "cohere" +version = "7.1.1" +description = "" +optional = false +python-versions = "<4.0,>=3.10" +files = [ + {file = "cohere-7.1.1-py3-none-any.whl", hash = "sha256:5b5dd9034f8eb5a32c988edad7d0aea120bc3076dffbec9e84858d7b0f0361a0"}, + {file = "cohere-7.1.1.tar.gz", hash = "sha256:3b0fe2e4ab255db4939783c085d935d1f694f09fd625d60ce14c94fd92792889"}, +] + +[package.dependencies] +fastavro = ">=1.9.4,<2.0.0" +httpx = ">=0.25.0" +pydantic = ">=1.9.2" +pydantic-core = ">=2.18.2,<3.0.0" +requests = ">=2.0.0,<3.0.0" +tokenizers = ">=0.15,<1" +types-requests = ">=2.0.0,<3.0.0" +typing_extensions = ">=4.0.0" + +[package.extras] +aiohttp = ["aiohttp (>=3.14.1,<4)", "httpx-aiohttp (>=0.1.8,<0.2.0)"] +oci = ["oci (>=2.165.0,<3.0.0)"] + [[package]] name = "colorama" version = "0.4.6" @@ -692,6 +1004,37 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coloredlogs" +version = "15.0.1" +description = "Colored terminal output for Python's logging module" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"}, + {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"}, +] + +[package.dependencies] +humanfriendly = ">=9.1" + +[package.extras] +cron = ["capturer (>=2.4)"] + +[[package]] +name = "croniter" +version = "6.2.4" +description = "croniter provides iteration for datetime object with cron like format" +optional = false +python-versions = ">=3.9" +files = [ + {file = "croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d"}, + {file = "croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189"}, +] + +[package.dependencies] +python-dateutil = "*" + [[package]] name = "cryptography" version = "50.0.1" @@ -753,6 +1096,53 @@ cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy [package.extras] ssh = ["bcrypt (>=3.1.5)"] +[[package]] +name = "cyclopts" +version = "4.25.3" +description = "Intuitive, easy CLIs based on type hints." +optional = false +python-versions = ">=3.10" +files = [ + {file = "cyclopts-4.25.3-py3-none-any.whl", hash = "sha256:40e76b3153ea90e885abd8d3a4d8184db5a5eec2fde8540025aae8d72fc973fc"}, + {file = "cyclopts-4.25.3.tar.gz", hash = "sha256:7d3a067bc7699a1a65fe2ea0b092a925855bb711a05adc1e65c5b9ba307acea4"}, +] + +[package.dependencies] +attrs = ">=23.1.0" +docstring-parser = ">=0.15,<4.0" +rich = ">=13.6.0" +rich-rst = ">=1.3.1,<3.0.0" + +[package.extras] +debug = ["ipdb (>=0.13.9)", "line-profiler (>=3.5.1)"] +dev = ["coverage[toml] (>=5.1)", "mkdocs (>=1.4.0)", "pexpect (>=4.9.0)", "pre-commit (>=2.16.0)", "pydantic (>=2.11.2,<3.0.0)", "pytest (>=8.2.0)", "pytest-cov (>=3.0.0)", "pytest-mock (>=3.7.0)", "pytest-timeout (>=2.3.0)", "pyyaml (>=6.0.1)", "syrupy (>=4.0.0)", "toml (>=0.10.2,<1.0.0)", "trio (>=0.10.0)"] +docs = ["gitpython (>=3.1.31)", "myst-parser[linkify] (>=3.0.1,<5.0.0)", "sphinx (>=7.4.7,<8.2.0)", "sphinx-autodoc-typehints (>=1.25.2,<4.0.0)", "sphinx-copybutton (>=0.5,<1.0)", "sphinx-rtd-dark-mode (>=1.3.0,<2.0.0)", "sphinx-rtd-theme (>=3.0.0,<4.0.0)"] +mkdocs = ["markdown (>=3.3)", "mkdocs (>=1.4.0)", "pymdown-extensions (>=10.0)"] +toml = ["tomli (>=2.0.0)"] +trio = ["trio (>=0.10.0)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "dateparser" +version = "1.4.3" +description = "Date parsing library designed to parse dates from HTML pages" +optional = false +python-versions = ">=3.10" +files = [ + {file = "dateparser-1.4.3-py3-none-any.whl", hash = "sha256:cbce86c64e0cea5c54c84c015d34c903d46c51bfad50632bf2f05a6141d10c05"}, + {file = "dateparser-1.4.3.tar.gz", hash = "sha256:bab8c43a746266e68142f4926e69438ce551441aa88e54e78bb6410bf3ee7000"}, +] + +[package.dependencies] +python-dateutil = ">=2.7.0" +pytz = ">=2024.2" +regex = ">=2024.9.11" +tzlocal = ">=0.2" + +[package.extras] +calendars = ["convertdate (>=2.2.1)", "hijridate"] +langdetect = ["langdetect (>=1.0.0)"] + [[package]] name = "ddgs" version = "9.16.0" @@ -774,6 +1164,28 @@ api = ["fastapi (>=0.135.1)", "uvicorn[standard] (>=0.41.0)"] dev = ["lxml-stubs", "mypy (>=1.17.1)", "prek", "pytest (>=8.4.1)", "pytest-trio", "ruff (>=0.13.0)", "types-PyYAML", "types-Pygments", "types-pexpect", "types-ujson"] mcp = ["mcp (>=2.0)"] +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + +[[package]] +name = "detect-installer" +version = "0.2.1" +description = "Detect how a Python package was installed and get the correct upgrade command" +optional = false +python-versions = ">=3.10" +files = [ + {file = "detect_installer-0.2.1-py3-none-any.whl", hash = "sha256:87e2ce7d05cb108b178f8e45bb80d3d88c0cb4b397983345c18ad6562e02e620"}, + {file = "detect_installer-0.2.1.tar.gz", hash = "sha256:85f889d4d19c1caf5bef89ef389eb920cd8c6c2a868e1eeceec24527b93021e7"}, +] + [[package]] name = "diskcache" version = "5.6.3" @@ -796,6 +1208,42 @@ files = [ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "dnspython" +version = "2.8.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.10" +files = [ + {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, + {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, +] + +[package.extras] +dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] +dnssec = ["cryptography (>=45)"] +doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] +doq = ["aioquic (>=1.2.0)"] +idna = ["idna (>=3.10)"] +trio = ["trio (>=0.30)"] +wmi = ["wmi (>=1.5.1)"] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +description = "Parse Python docstrings in reST, Google and Numpydoc format" +optional = false +python-versions = ">=3.8" +files = [ + {file = "docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b"}, + {file = "docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015"}, +] + +[package.extras] +dev = ["pre-commit (>=2.16.0)", "pydoctor (>=25.4.0)", "pytest"] +docs = ["pydoctor (>=25.4.0)"] +test = ["pytest"] + [[package]] name = "dspy" version = "3.3.1" @@ -837,85 +1285,532 @@ test-extras = ["datasets (>=2.14.6)", "langchain_core (>=0.3.0)", "mcp", "numpy weaviate = ["weaviate-client (>=4.5.4,<4.22.0)"] [[package]] -name = "fastuuid" -version = "0.14.0" -description = "Python bindings to Rust's UUID library." +name = "email-validator" +version = "2.3.0" +description = "A robust email address syntax and deliverability validation library." optional = false python-versions = ">=3.8" files = [ - {file = "fastuuid-0.14.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6e6243d40f6c793c3e2ee14c13769e341b90be5ef0c23c82fa6515a96145181a"}, - {file = "fastuuid-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:13ec4f2c3b04271f62be2e1ce7e95ad2dd1cf97e94503a3760db739afbd48f00"}, - {file = "fastuuid-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b2fdd48b5e4236df145a149d7125badb28e0a383372add3fbaac9a6b7a394470"}, - {file = "fastuuid-0.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f74631b8322d2780ebcf2d2d75d58045c3e9378625ec51865fe0b5620800c39d"}, - {file = "fastuuid-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83cffc144dc93eb604b87b179837f2ce2af44871a7b323f2bfed40e8acb40ba8"}, - {file = "fastuuid-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a771f135ab4523eb786e95493803942a5d1fc1610915f131b363f55af53b219"}, - {file = "fastuuid-0.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4edc56b877d960b4eda2c4232f953a61490c3134da94f3c28af129fb9c62a4f6"}, - {file = "fastuuid-0.14.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bcc96ee819c282e7c09b2eed2b9bd13084e3b749fdb2faf58c318d498df2efbe"}, - {file = "fastuuid-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7a3c0bca61eacc1843ea97b288d6789fbad7400d16db24e36a66c28c268cfe3d"}, - {file = "fastuuid-0.14.0-cp310-cp310-win32.whl", hash = "sha256:7f2f3efade4937fae4e77efae1af571902263de7b78a0aee1a1653795a093b2a"}, - {file = "fastuuid-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:ae64ba730d179f439b0736208b4c279b8bc9c089b102aec23f86512ea458c8a4"}, - {file = "fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34"}, - {file = "fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7"}, - {file = "fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1"}, - {file = "fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc"}, - {file = "fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8"}, - {file = "fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7"}, - {file = "fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73"}, - {file = "fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36"}, - {file = "fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94"}, - {file = "fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24"}, - {file = "fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa"}, - {file = "fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a"}, - {file = "fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d"}, - {file = "fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070"}, - {file = "fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796"}, - {file = "fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09"}, - {file = "fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8"}, - {file = "fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741"}, - {file = "fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057"}, - {file = "fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8"}, - {file = "fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176"}, - {file = "fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397"}, - {file = "fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021"}, - {file = "fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc"}, - {file = "fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5"}, - {file = "fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f"}, - {file = "fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87"}, - {file = "fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b"}, - {file = "fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022"}, - {file = "fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995"}, - {file = "fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab"}, - {file = "fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad"}, - {file = "fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed"}, - {file = "fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad"}, - {file = "fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b"}, - {file = "fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714"}, - {file = "fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f"}, - {file = "fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f"}, - {file = "fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75"}, - {file = "fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4"}, - {file = "fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad"}, - {file = "fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8"}, - {file = "fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06"}, - {file = "fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a"}, - {file = "fastuuid-0.14.0-cp38-cp38-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:47c821f2dfe95909ead0085d4cb18d5149bca704a2b03e03fb3f81a5202d8cea"}, - {file = "fastuuid-0.14.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:3964bab460c528692c70ab6b2e469dd7a7b152fbe8c18616c58d34c93a6cf8d4"}, - {file = "fastuuid-0.14.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c501561e025b7aea3508719c5801c360c711d5218fc4ad5d77bf1c37c1a75779"}, - {file = "fastuuid-0.14.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dce5d0756f046fa792a40763f36accd7e466525c5710d2195a038f93ff96346"}, - {file = "fastuuid-0.14.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193ca10ff553cf3cc461572da83b5780fc0e3eea28659c16f89ae5202f3958d4"}, - {file = "fastuuid-0.14.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0737606764b29785566f968bd8005eace73d3666bd0862f33a760796e26d1ede"}, - {file = "fastuuid-0.14.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e0976c0dff7e222513d206e06341503f07423aceb1db0b83ff6851c008ceee06"}, - {file = "fastuuid-0.14.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6fbc49a86173e7f074b1a9ec8cf12ca0d54d8070a85a06ebf0e76c309b84f0d0"}, - {file = "fastuuid-0.14.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:de01280eabcd82f7542828ecd67ebf1551d37203ecdfd7ab1f2e534edb78d505"}, - {file = "fastuuid-0.14.0-cp38-cp38-win32.whl", hash = "sha256:af5967c666b7d6a377098849b07f83462c4fedbafcf8eb8bc8ff05dcbe8aa209"}, - {file = "fastuuid-0.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3091e63acf42f56a6f74dc65cfdb6f99bfc79b5913c8a9ac498eb7ca09770a8"}, - {file = "fastuuid-0.14.0-cp39-cp39-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2ec3d94e13712a133137b2805073b65ecef4a47217d5bac15d8ac62376cefdb4"}, - {file = "fastuuid-0.14.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:139d7ff12bb400b4a0c76be64c28cbe2e2edf60b09826cbfd85f33ed3d0bbe8b"}, - {file = "fastuuid-0.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d55b7e96531216fc4f071909e33e35e5bfa47962ae67d9e84b00a04d6e8b7173"}, - {file = "fastuuid-0.14.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0eb25f0fd935e376ac4334927a59e7c823b36062080e2e13acbaf2af15db836"}, - {file = "fastuuid-0.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:089c18018fdbdda88a6dafd7d139f8703a1e7c799618e33ea25eb52503d28a11"}, - {file = "fastuuid-0.14.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fc37479517d4d70c08696960fad85494a8a7a0af4e93e9a00af04d74c59f9e3"}, - {file = "fastuuid-0.14.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:73657c9f778aba530bc96a943d30e1a7c80edb8278df77894fe9457540df4f85"}, + {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, + {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +description = "An implementation of lxml.xmlfile for the standard library" +optional = false +python-versions = ">=3.8" +files = [ + {file = "et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa"}, + {file = "et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fastapi" +version = "0.141.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.10" +files = [ + {file = "fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3"}, + {file = "fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"standard\""} +fastapi-cli = {version = ">=0.0.32", extras = ["standard"], optional = true, markers = "extra == \"standard\""} +fastar = {version = ">=0.9.0", optional = true, markers = "extra == \"standard\""} +httpx = {version = ">=0.23.0,<1.0.0", optional = true, markers = "extra == \"standard\""} +jinja2 = {version = ">=3.1.5", optional = true, markers = "extra == \"standard\""} +pydantic = ">=2.9.0" +pydantic-extra-types = {version = ">=2.0.0", optional = true, markers = "extra == \"standard\""} +pydantic-settings = {version = ">=2.0.0", optional = true, markers = "extra == \"standard\""} +python-multipart = {version = ">=0.0.18", optional = true, markers = "extra == \"standard\""} +starlette = ">=0.46.0" +typing-extensions = ">=4.8.0" +typing-inspection = ">=0.4.2" +uvicorn = {version = ">=0.12.0", extras = ["standard"], optional = true, markers = "extra == \"standard\""} + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.32)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.32)", "fastar (>=0.9.0)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.32)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "fastapi-cli" +version = "0.0.32" +description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀" +optional = false +python-versions = ">=3.10" +files = [ + {file = "fastapi_cli-0.0.32-py3-none-any.whl", hash = "sha256:8dcc286fa32f01bbd3f65dd09cfd5a2540ed5f2230b77db7fd30978d6165f3c4"}, + {file = "fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424"}, +] + +[package.dependencies] +fastapi-cloud-cli = {version = ">=0.1.1", optional = true, markers = "extra == \"standard\""} +rich-toolkit = ">=0.14.8" +typer = ">=0.16.0" +uvicorn = {version = ">=0.15.0", extras = ["standard"]} + +[package.extras] +new = ["fastapi-new (>=0.0.2)"] +standard = ["fastapi-cloud-cli (>=0.1.1)", "uvicorn[standard] (>=0.15.0)"] +standard-no-fastapi-cloud-cli = ["uvicorn[standard] (>=0.15.0)"] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.26.0" +description = "Deploy and manage FastAPI Cloud apps from the command line 🚀" +optional = false +python-versions = ">=3.10" +files = [ + {file = "fastapi_cloud_cli-0.26.0-py3-none-any.whl", hash = "sha256:94029f92f3dc4289b376e1213f0d6cc7c30d4f32de7e47b5effc6dc754ab9ce9"}, + {file = "fastapi_cloud_cli-0.26.0.tar.gz", hash = "sha256:5fd64c26228c8ead803461e8b58ae64ffd586e960d97ab1fc848efac2d9539be"}, +] + +[package.dependencies] +agent-detector = ">=1.1.0" +detect-installer = ">=0.1.0" +fastar = ">=0.10.0" +httpx = ">=0.27.0" +pydantic = [ + {version = ">=2.7.4", extras = ["email"], markers = "python_version < \"3.13\""}, + {version = ">=2.8.0", extras = ["email"], markers = "python_version == \"3.13\""}, +] +rich-toolkit = ">=0.20.5" +rignore = ">=0.5.1" +sentry-sdk = ">=2.20.0" +typer = ">=0.26.1" +uvicorn = {version = ">=0.17.6", extras = ["standard"]} + +[package.extras] +standard = ["uvicorn[standard] (>=0.15.0)"] + +[[package]] +name = "fastar" +version = "0.12.0" +description = "High-level bindings for the Rust tar crate" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastar-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:310c99a33f83f83f49917cee153cdd49ac0a3e42f78ad685a84a700ede3aece5"}, + {file = "fastar-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a57a4b3ea38937f95c8ac76432992dc29f672b265e021a4c8c7191a767188297"}, + {file = "fastar-0.12.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:00104297bee6bec0c20aae27e82e62b33db4329575e4cfd3e824745128b9933a"}, + {file = "fastar-0.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12484b5f87529e9c0af4299a5db779c92c0c393b95867977e3ea02d22024dc96"}, + {file = "fastar-0.12.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e54319e21ea309e8f2141ed88ace0cb34af72bbd5865eb6a819773a170230e27"}, + {file = "fastar-0.12.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2eef75f01b874968ac340be98d44a583ded3b50f29483fd3ec5fd86cce41db7d"}, + {file = "fastar-0.12.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69391964bbf1b170eeed382fd05d490ba8f6b00fb0b72627ba00cf7d037b9d75"}, + {file = "fastar-0.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7a28985d86a3d8582d6ce69d356fc498035d717938b5029df057d599718888"}, + {file = "fastar-0.12.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3060521ae526301acadd84fbc3bdf50faa63e3f06f7faecdb286c7e4e4d8b22d"}, + {file = "fastar-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:868259b82a03d90eb7b618478a5f5721f293f477b9a1f2037546e9edec21792a"}, + {file = "fastar-0.12.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:09e6a5d4b111c5d40f7dc0813357260ee060b74b4e14bfdc66f5c3ae37f9832c"}, + {file = "fastar-0.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:682a8c91d3ee82179e44ffd7673f4a6a08e7465970b75d19c38df41c39e2dbc2"}, + {file = "fastar-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0a30eced7e4c711a272336e8c6214ca23f30eaf57b06c85eb6f67cbf50e207b7"}, + {file = "fastar-0.12.0-cp310-cp310-win32.whl", hash = "sha256:f5e9cec09cf46ba60ae78bdaf22548b6128d4142c274632faf6c8d2a7a6abf0f"}, + {file = "fastar-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:46feb733e23f2a85f097f5bab710e49ad3e4d9ad63d6a6c5b4c6d470239c6217"}, + {file = "fastar-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:474fa88aab2aa2ea32682edfe46cd4d24a5f75acaa2021b40d1e247e33e21b7f"}, + {file = "fastar-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2dc6149218fa5e34526714aefabc148b25fc7d27c2f25c8b942f1567875511a9"}, + {file = "fastar-0.12.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0e16b0d029411989b9459adc65b9b553a7051909a96e4432e4a151490e42f0de"}, + {file = "fastar-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f0432d1138e7f3b65a3afc324ac373706751d1b8554541fe33d5e62ab08ff11"}, + {file = "fastar-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:930d355b98304984b70b30b8a678c782ecfa958a9c710134c71ea54db995a21f"}, + {file = "fastar-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e33b9b67d9fbcda49559c653390698a3e4d89c4d9cb9974fbbdd8a8aa0bfe3b5"}, + {file = "fastar-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc98215d7cdd32b70d2ab8123448071f33e5cc4a4af2ddef67af37beb420dae2"}, + {file = "fastar-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:471a2f3c7295c296252463ae2eadd87f258dec33a7fec053a553c90930e03392"}, + {file = "fastar-0.12.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:6bc9c294f2aca5590b8188ab09e04099b727444cee7e56bd89d73c57adcdb2b8"}, + {file = "fastar-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1c2926264f000e88e214a23c1a308a4851325ee72569c27b4188d8d44262fa56"}, + {file = "fastar-0.12.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:adfc7981f3b3aaafcebc555437e66a37bafbe48d4e3e2129b782b52ed96b6c06"}, + {file = "fastar-0.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:75ea567751a7eebbc5cee86e3df5f0e699afe984e6b76eed91fc304485b0808a"}, + {file = "fastar-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c19e0d943e68ee9748610e510639e438c07d4dda13743cbb2d928689aad15599"}, + {file = "fastar-0.12.0-cp311-cp311-win32.whl", hash = "sha256:423d2b0ff5a43405c9776f1ef0779502c547bdb2d7fa7857b4f7434c95035556"}, + {file = "fastar-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:b2fcbdc9b49fb240965941a21fd45ac9f7f75bf87acc33106e1ee70ab912cfdd"}, + {file = "fastar-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:e8fe9f8e8b6b4df05f1a3561ff9a5348553b93fa6fabc7e31334d1ee720c00af"}, + {file = "fastar-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d7a37d672233031bd41b000852e2520d34a1dd362c2e3b9000b6400a16b3bba4"}, + {file = "fastar-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:787e3c8018848b56b7e47918f9a9ec0e13a68e39af6e4d386cf083b2973d254b"}, + {file = "fastar-0.12.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8fa10e692ca23aafb88ab441bb280724328da7b8cebddf3296c751b4e06324ed"}, + {file = "fastar-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76115050e8f076fb96f1aa7662dc502b5232d21b610aec08035957a93a830cb1"}, + {file = "fastar-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0df33b22848b96beb8b229869abcc6592592d654f25b6cb1856c08717aaf1544"}, + {file = "fastar-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:27142e096eb5765b4dca66e4dbd29a9c61a0a4bc4c0ca649f6e661475da2bf8d"}, + {file = "fastar-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1eaf9306c98e22aa7650c6ec02522d77b61c539bc81583897ca351de125ade14"}, + {file = "fastar-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deb0b3882626832361dd0fcee1f4ef1c6e2a7aaa4165e51c86ae0bab95caccd6"}, + {file = "fastar-0.12.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:47739deed9e4ccf6514821d1c7a79c9188e4a5029d80b618078739d91e7ed9f0"}, + {file = "fastar-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7ec2100cea7274ec4b1ec77cd5613318a2d9fc9f7a291a6acf642a26e3856a93"}, + {file = "fastar-0.12.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f95a59a11ee59701a6c0048c90601063894102343c4bb09462eed5df0fab0077"}, + {file = "fastar-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8383404eebbff9832652ddcd32f91bb4acd8e038b27597dc12aef33c4e72f7b7"}, + {file = "fastar-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60de8b81a04cda78e5281d72ff6d0c50e4925621cc5eb55d99c2e344d1b291f4"}, + {file = "fastar-0.12.0-cp312-cp312-win32.whl", hash = "sha256:f62411993976d5c5a0d1090f8c809f96e0671d824ee0d844e5b5256bf31fe7a9"}, + {file = "fastar-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ed84522ddffb4c41c247f3e6823c763faf45af2ba1bf887eaab7f907e74e0bbe"}, + {file = "fastar-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:c03a61a149eaa857a4af8bf6c0bc0895bf668b8d5685130337a1ca42e60f9828"}, + {file = "fastar-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:654165090cdcac7ff13d43ee4012c366f0f2061ddf46658bc0ad248c8aa3960a"}, + {file = "fastar-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:685d3d45943b43c32c71c8470552a615c90e06ca532db1b7a5633f01aa108f0d"}, + {file = "fastar-0.12.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f8da75b5eca0d3b540e50ab6b7dfe4b148d7ae8a0b444a9781fd1219392859a0"}, + {file = "fastar-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13c2df8db1b7a4d783429ffa1125c53f7dd9534baae7218eb49273797691e2a8"}, + {file = "fastar-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:042fd43c4e0c3f3ba3f7b8a083694d1c4bd77d60ce266090d4eb96cb8a8021c2"}, + {file = "fastar-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6958a332c55052dd8090b03de238ca59190d625f4ba9c292b34e938ac64105ea"}, + {file = "fastar-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b05c0c739c43b9228e57fc59eb68c38660c62030bcdea3a032b269df71f5bb8"}, + {file = "fastar-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2bfad69679111e4567d4bad41fd795071c9335cd94bc0f26e24b7d19e95c9b1"}, + {file = "fastar-0.12.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:1155e1dd9c60cf636b6b3d35edfe242348f47286724fa84b5b4055c03d7fdbf6"}, + {file = "fastar-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:29b87474b2e7c9e64549b87aeb2c1d68a94e78c887a3a8d88bf7b804ddbcc0dc"}, + {file = "fastar-0.12.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4587a08d6de2e62611278fc4cd36186a3ebbf6609d9e49df34d79a407d66f599"}, + {file = "fastar-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e7d7512b5c747edfce129448a72c8f6223323748ca3e98cc54401241bff70ee5"}, + {file = "fastar-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ce9a7e7757074a3d0920a8bc3936cf4164f63786d8e1b64e425d2957ddcceace"}, + {file = "fastar-0.12.0-cp313-cp313-win32.whl", hash = "sha256:61f1eec258b328182c6b6258641d33264ac3080fb7fcbf40ea1e326fc855d917"}, + {file = "fastar-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:84caa362865cac75807c51afbeff2e9b313fc45f89e0865f7c8bf627ea721f4b"}, + {file = "fastar-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:a3de985d942247fa924e185ff2744a6da0005dcedbb39ae1b811bec11710e572"}, + {file = "fastar-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:7116a770a4e47262734fafe06d3e835a23b833e81b45b3054558301385a0f2a7"}, + {file = "fastar-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ebe324ffcb3e8efb2308255f60de911bfa4ccf10f087b6e60c62606f091f1807"}, + {file = "fastar-0.12.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4af6be78f7ec6ef8e6da7d162361e4198be10fe81bfc95112f635c2c14e12922"}, + {file = "fastar-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad38b27e93ba9c7de076ad694d224153bf8d66695acc27a114cba8087078fd54"}, + {file = "fastar-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:968d64c203d10d257c2f2268cfc97d94f273fdfc64b37a4739fdb6e2cf2c3f03"}, + {file = "fastar-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21256ccb3946730e3d601b6a9c9de61a127855957a7f0852cb14b0dbe15f8aeb"}, + {file = "fastar-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d89116b102fb7d4c47b6b723dc32d5c12122e7ffeb41b7a8b22cc777c9eca5"}, + {file = "fastar-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1450e0325897e87594c29837fdd9e338b1d281f6c81d14d7a75b6776285b5b6e"}, + {file = "fastar-0.12.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ca63fee43f07408efec09e1c0ae34a1b29ae52b8c1adc31bd6434ccc9e1741cb"}, + {file = "fastar-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3fa5fd057b4f4537f04dd4e4f13be92433bf47bc479309335755531c5af34560"}, + {file = "fastar-0.12.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4b395c3d4375809d0d55b5ae297f6bc037b90b382f35f056e453732e4f6f523c"}, + {file = "fastar-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:20d5e52c45e75a55ed27e7952487506269e9a64559d6cbfaf6977529db81298f"}, + {file = "fastar-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4497098ebdf3c1f89dbeafe44ebcd7f143f8a774641bbfa13d1d793104464c41"}, + {file = "fastar-0.12.0-cp314-cp314-win32.whl", hash = "sha256:56cb3b3c46edf26f054f2420573c7c69c03997203a7a867ca835bc305b4a0f30"}, + {file = "fastar-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:81534df96e775ccaa37fcd1f45e06f48c245ff77e30d3bcb0d3c1101da9399e8"}, + {file = "fastar-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:fce60bd91fd982bf52e9a4c87820a44f92ac0d896bd64544891d6995fa6b8b98"}, + {file = "fastar-0.12.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:22c882f1096199d5e63f3ea4d9313e10799fcbc70166d315aa07576148601326"}, + {file = "fastar-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:74c928183d7ca19056bc0eb24d8c1907c115cfb2382ad4a7c32ccbfb04ca0a0b"}, + {file = "fastar-0.12.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f1f3e70447f45e54b488eea8bd401cd3774b7b688d5b507915ae415058d8ac86"}, + {file = "fastar-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96ae27bbb807e39e05097bdc2a6cba6820f31dd053aaf8cf0a045e5969041778"}, + {file = "fastar-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0d60682ed24fc6063b18e76a947f1cc0fcd0777709357b801b3e3458a87c2e5"}, + {file = "fastar-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:644f6d06e312bc47dd315aa36fba723854859601e184113de7b887613ed9a5ad"}, + {file = "fastar-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea6c5addb01f206bd75f784540a8a11bbddc451293318a823e3fecaf0d62cc3c"}, + {file = "fastar-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc892486eb242ac55dde185d547d2723289da50c6cdc06614868416576ba5c4f"}, + {file = "fastar-0.12.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a47a68d1b9bd59062af41d9809a340739ab1ba13cb5b4beb23466a621d6479f2"}, + {file = "fastar-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a7d9fd762e7eab2262ec004aa9c714b53d303e11cca7c814b7b634d9d2424691"}, + {file = "fastar-0.12.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3766bf0aeeb6a03d114b185472593a2a3f0ff43f1b56c40fe0cda4283f9f4351"}, + {file = "fastar-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:860516a52a3bdcaa746be42fc1bdbbbd48e05c7579f9f61e30d78a7e065835bd"}, + {file = "fastar-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb75898c166ff6d232bceb68a5a7dc4a8f17239fb40925d169649a3967b76c4a"}, + {file = "fastar-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:c5755332572756061b29766a9ceff2c837d52d96828e58f908cb46cc49123bd7"}, + {file = "fastar-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:07d861c7ddf31bccb9615a0ba4c9f06987d1373a6b357183526d68cf9c3f5552"}, + {file = "fastar-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1d56e2a52bebd3e379d0cacc2b018b819a0b99f0dc19b4453f304c4e2fce5b3"}, + {file = "fastar-0.12.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:6109ec55528a975ab3644dc1c9ccccb2c2315daa66ca34f54e1e3dca60afa757"}, + {file = "fastar-0.12.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6e94e2881c3aa53da5d9161e2e64c0d698c66506a62024d5900943098220ebc7"}, + {file = "fastar-0.12.0-cp315-cp315-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:682c531ac174d63919374eaab6fca91f92432cbf6a2262acb72719ba2e2a694d"}, + {file = "fastar-0.12.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbf3d5bfd73516b506a916f6809b90a4ad73ff5840c0af6cbe0142417a03b014"}, + {file = "fastar-0.12.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fbeb5fce858248d6b9b1fbef12c3e14d174432728c6e6eb1e2a63447432571c2"}, + {file = "fastar-0.12.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45164f7138613b76918101ea28842cb69c10ff441c1ba2d56d7c6b28053f28e2"}, + {file = "fastar-0.12.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e71715c64695bc80fd8fed2a82af30acb49b6bf085c06876cbfc2116b53cc7c"}, + {file = "fastar-0.12.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8056fb0f3ff2213eb00234d32b2d701cd288b763d3430b67033a393b8d33b47"}, + {file = "fastar-0.12.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a843704912dc3b20e152743bd5fa3e225bf9cc23c34fea0debeceefead477e78"}, + {file = "fastar-0.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:b4a7b6ca4a04e269aa26533ca8bfd0c674e4ee7328b0d3d80d45ab979a7e613c"}, + {file = "fastar-0.12.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:883f1e06c0d9649a2e54b767b3384b47306098ea73be3ca288d562c4d73dbcaa"}, + {file = "fastar-0.12.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:74858c4648bdc4450a66f3d6ec16a4e61ba48c16c9898a88a061d3272f82c65b"}, + {file = "fastar-0.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b16451d5b50579e4eb7dc1761946bc6f6186df44fa84310a06f455c26eb4442e"}, + {file = "fastar-0.12.0-cp315-cp315-win32.whl", hash = "sha256:ad8185a7b379e5cd81ef65209d21db4c63e8a62bcaa2d17fe40a8e50fcb28427"}, + {file = "fastar-0.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:a2819b9061cee89da560156b77230d4ffe81e75b8f64b9732ca34d7bc546e49e"}, + {file = "fastar-0.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:a8a8130f236a5dc2ceab88486f77bbdd516d08dc949d0f04194305845cf44c19"}, + {file = "fastar-0.12.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:6f25c1aa6d55a457d95dc2163bbc27942e1541ca6792d4bf323a922688b8597e"}, + {file = "fastar-0.12.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:aeb69fe64537deec4902f45ad9634b85d44ebb42ee1a33725d6584e8d9b33927"}, + {file = "fastar-0.12.0-cp315-cp315t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a83ae278bcc718dd155219fbbd552a16bd8c178effc5021600c3be2806a01cf"}, + {file = "fastar-0.12.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c048c732e3ca28a132732f83130ccdab58d9b27dd36bb26bdeb42c2d48827da9"}, + {file = "fastar-0.12.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:613466f628667af03de8f914de58a07bfb3ee1bd0347e3532ec9790df92a1e72"}, + {file = "fastar-0.12.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f59d3243d8913db385ab822be8f111f188218ea73f9f14f5d70c869a33ff4d1"}, + {file = "fastar-0.12.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b362e5404dab262e85f0d93bd950933a0935dac6a9f5f0516bba6c703c440ec"}, + {file = "fastar-0.12.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d1e50c423cd064f29f11c98f6d995b8fa7df7bbc19f3fdb9f081859afd8e00b"}, + {file = "fastar-0.12.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:6857a79691c5c033a31d76c62ad02f2c92d173a0e1fb2fac7fcb7ac686108bd3"}, + {file = "fastar-0.12.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3053bb800b5375fbca8f96d256654ae3489c439f1b55766896d1c703d8281804"}, + {file = "fastar-0.12.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:067094312cbea82ef2efa3999dc31318ac539805965c9505d99654f01775cac1"}, + {file = "fastar-0.12.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f69400ebb83a8d754aa7735165c12f8029ac577c3c08eb6d174eedc5901b7cb2"}, + {file = "fastar-0.12.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7d875d99946a11538f7ecb183f0a885d1d0a0495a6f1d6d2aa1de9b5fe6e5e9d"}, + {file = "fastar-0.12.0-cp315-cp315t-win32.whl", hash = "sha256:39dad3351f1399cd28e2e649b6651299ef857df6c745e598b00b4167dcf93dbc"}, + {file = "fastar-0.12.0-cp315-cp315t-win_amd64.whl", hash = "sha256:00cda9a3f11871261a4e77a3b8f0eede85c9730fb7516811bcbcf96a2bb3b75b"}, + {file = "fastar-0.12.0-cp315-cp315t-win_arm64.whl", hash = "sha256:e8e0fb057b5c271f46f3300b539b0d3dab8c8cb2515205a37c818c2f68d16806"}, + {file = "fastar-0.12.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:c745781d4836c794800fdda4641f9d685b14e1981ed9d898c7ac152ac62cd1dd"}, + {file = "fastar-0.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:01d90bb64a83843a3ae07e33da300723de7ce50864d6bb47a58bc1112d68ecbf"}, + {file = "fastar-0.12.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4468813c739aaf498afc6f553fe082b485d39c3ddd4fa4b1ca130ce6d82057fd"}, + {file = "fastar-0.12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4851974bf24c1d993617f5c045a323315b632ad48358dfc95f126d822937292a"}, + {file = "fastar-0.12.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41d7d26a09ccc0e6a939194a640aa934ae5e263581fb0127c44b0b1dd720f071"}, + {file = "fastar-0.12.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dfc8a2bfb1b52ea812eee3bf34f7c76ecc72b74e3346b46fc308212fa27c1f4a"}, + {file = "fastar-0.12.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5aee7a7354425a9841991b8032d94509c022b509cb0e47f484c7fb56df5874e3"}, + {file = "fastar-0.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70fb44c26eb993bd676ccec24ce45b70354e4d1c0e35750a8a49233934049d7d"}, + {file = "fastar-0.12.0-cp38-cp38-manylinux_2_31_riscv64.whl", hash = "sha256:d9e403be00e17d340131327f97789c6b640ac516926e1217cfbe522c24b5120e"}, + {file = "fastar-0.12.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b3a2b7ec287bfe7110a1c047698d681a0a1c4b888c7f4aa93862de9b541928d9"}, + {file = "fastar-0.12.0-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6c068844bbfbc26b42c4142c53d994df6b6bb71146f7f860a7135d49d916e01a"}, + {file = "fastar-0.12.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:304152fb06608208581e3340f51e5f8d9658fd1ea66f204647ab931eb3525b24"}, + {file = "fastar-0.12.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b21937917620122291e65ed1644e8b8acb519fc7df44d2ea3b5b9963a5d804e9"}, + {file = "fastar-0.12.0-cp38-cp38-win32.whl", hash = "sha256:6aac6e5b0c9f140a3ee0bfd1037b3c178cf5b6f7f1ac37c002006bb34d8c90d8"}, + {file = "fastar-0.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:d03e32e47a98f02c6a99d894a07e54efea0b9c3eb6fa8aed4f81b075530b2c7f"}, + {file = "fastar-0.12.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:7c9cad467ca85bb2629a5e4d52bc60997221fe91d19e93ca8c05735d23f72de9"}, + {file = "fastar-0.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e2b8394d4c417dbc05c3a5c45e6c9742d5e78ce07e6eaf18894218c0a9dae5d0"}, + {file = "fastar-0.12.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bfa2891ee4bd86b71721b0afa6c410c2917c86522dffc13a5aba2038d147e81f"}, + {file = "fastar-0.12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc34a941607a44563ec4b4f0f5c7af7f237bceab73c372f1987d505ecd6165c"}, + {file = "fastar-0.12.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc7f09c635194e5e673703cffbd1eda80773edb8000faa42da158b8ea1ce5018"}, + {file = "fastar-0.12.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d6929f821729abc10b754180675dcd2e5328ac12d28abef4409afebd1a8c59"}, + {file = "fastar-0.12.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bec95651f31caa9f505bbeb2019187e2f0ec561548ae0cd33cfa753ef59492ac"}, + {file = "fastar-0.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aef3e8f913fcf2150e664bfbdca9462c6472ad1c1c553f4b58c1b5d2b34807f5"}, + {file = "fastar-0.12.0-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:37e0d61c64eaf5eae1970c0e73a56c596b3487b14bd6cede3c6426d981cb2bf5"}, + {file = "fastar-0.12.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b79e69b2c9d0df293cda11881a1b27ab2893460a2b68229b20f7757fb5f5fe2d"}, + {file = "fastar-0.12.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:31196ca910579d0317fdbcefd7bbe82a08b5c933bd0a61269b66ca1feeb5c82d"}, + {file = "fastar-0.12.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:b3f28f54bde75366cc1b9357ff739593fa61cd81736f0f31883ea026623108b2"}, + {file = "fastar-0.12.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9612513fbf7fa906bb186c3e30f51edb4b6baf27088a7d4b4ef0c6e8732b16b8"}, + {file = "fastar-0.12.0-cp39-cp39-win32.whl", hash = "sha256:1664bad53ed42a657d1c9b8a939d3a1b2716ec9feb00a65b12c5c9668e3128f2"}, + {file = "fastar-0.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:c46cf6767d71506b6917cddcc903b0b110169d5254717c95ea00e2920a85df3c"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5fa463374dde07fbb7a46fddc75288554ce9b89f5d0129fa073028e2e13d6db3"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4da24effffd801bfa5d39c7b68c59a1124df8ce8ddb4048e1a5bbff4a915248b"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d3f780bd6d4e39df5b21862d23df636ad03fea45d73e105bba2493f467fe2b86"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172ab2b39a9307b0fc56e603b57d01d98f92bcd19cc78c1d3451142d00b3eee3"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d47478fc6ac75e6a2f7a0adf7d5d0636f36fbd2b1269bf87530c5bc404af1c1"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a84c2c04e77559363e7da7b3a99f94a48d938043de09bfc13042f4437ae1f86f"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b57afcac03ef59dc3b26b0669b8da7f61d0d8961896b5a809c3d6b31f664ec0"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb38929383fefe38909421eb0bf9d0ae058c541b93e80383b678755eede190"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:7f00ba54a5b44eeb6075005915a8a11c93ff04cf82ac620c7d33105cf4f7ad3e"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:7fd86318da844a9553e02d0d1dfdeb459b4d516c49c8ed66264ae59a40e3b7aa"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:b9dbdd0d9a7082d924489411bf84d8dd392f18bbfb4379a60fee97275e6d8c1f"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:689fed84ab320e428fb7ee1ee7ad5dae856f8d75c12df6860258fa94497eae7e"}, + {file = "fastar-0.12.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:70077a6d327ffa5d54f9d7bad6e6583c010530a5b120f5ad71a83b0a14d26ad9"}, + {file = "fastar-0.12.0.tar.gz", hash = "sha256:bba71522eae6a7627a5514ffdd4ac9645ef27d82e23931d79fd974bb49c3f2ad"}, +] + +[[package]] +name = "fastavro" +version = "1.12.2" +description = "Fast read/write of AVRO files" +optional = false +python-versions = ">=3.9" +files = [ + {file = "fastavro-1.12.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7c6d26c731a0e1e8e7d4ae8f13ae524eb6ec0e90d99c8147a19fdbae14eb807"}, + {file = "fastavro-1.12.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7caeecf519eff50f007ca4bee16b6e0a8252e5fe682c94432192a20867239888"}, + {file = "fastavro-1.12.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:731aefe6c4bf2bafa0798ef83927676d06e44d1d18202cfb56d63b40422ab900"}, + {file = "fastavro-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f089f24225a28ddafa5cfad7c41cfa84db1a55f2d473370769a95c0e3bac60c9"}, + {file = "fastavro-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:653c4f90dd21d8a1e74309919e08934e420d9aef51d051d14bf5a1c0e8293c22"}, + {file = "fastavro-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:030f17eb4c7978538a31b55dea451ceace851a88dc9816b1923f8fb8a260db4c"}, + {file = "fastavro-1.12.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d48cd7094598a7e9d4297e8bf4bbe0dc9dc2ba4367d83dbb603e3b3c6aa35566"}, + {file = "fastavro-1.12.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:070c6134604bd7b6fd44409406ac50445339682b2e872885db2e859f92d22e93"}, + {file = "fastavro-1.12.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b73d50978d5e57416fa68461f9f3c8f39ea39e761cb1e12f919745adefe26a7"}, + {file = "fastavro-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c57a9920400166398695d92580eca21fd7a79f3c67d691ac7e20a7d1b5300735"}, + {file = "fastavro-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:81f6108f3ac292fb6cd05758c9e531389d8fc5e94e8c949b9298f4fb0a239662"}, + {file = "fastavro-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:eec44256856fd59d29d1f1d0950ace18a58e4228e7d49de5d5e1b1875b227dde"}, + {file = "fastavro-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:ecd1b23ea7f9af09c865ac8503d07afd7e6bf782d76bb83cbbdba15b7a0db807"}, + {file = "fastavro-1.12.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0e331896e8efffc72fa03e63b87ebfc37960113127da8e0f5152d91664ffed68"}, + {file = "fastavro-1.12.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f01ebaada59d74fdf6d28e5031a961a413b3752e9edb0c03866fa18480cf4c8"}, + {file = "fastavro-1.12.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25ef6855935f67582740ffa6bb978e40ec51be876117a3555c36fa2488dcdf25"}, + {file = "fastavro-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:84a4f76a0aece0aa72b5ed8162ba2ff8c78908b8361b5a5d92ddd161977ccb74"}, + {file = "fastavro-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e8da77d201916f6771fc357fda8267c2a256d7aa11923d43bc5f2fc155878b"}, + {file = "fastavro-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:1924349c74666c89417bd5cc2749f598e2f15f1d56ee81428b2317ab02c88aae"}, + {file = "fastavro-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:4c346cf449baf3b113e997c34151ad205e7135bc429469b005b180ade7e65e28"}, + {file = "fastavro-1.12.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:57bb6b908cb2e05baab63b04c3a31be3b4545a10bfab9748b8763016b5256704"}, + {file = "fastavro-1.12.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a007f95cc682f56e6d83f1d17c29c00bf719d6fe8e003282b535af3a1ba09c0"}, + {file = "fastavro-1.12.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e90460b0cd21f62be3cb26087e706e2cebb7b3fcef9e05b4473b61bb0415b5e"}, + {file = "fastavro-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ccd15966b8218d41b06ec3e7c2556be89a8a693026c771e6564d2e40bbaf8ea"}, + {file = "fastavro-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06b6971d3dae10cb34353b857d16ad21ebd6f0ea394e86c96abdcad109005d6e"}, + {file = "fastavro-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:98dfcdfaf1498ae2f0e2fafe900a82e8320cc81d8ae5a95b8b8879eaa3298c39"}, + {file = "fastavro-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:3888ef7a51adc77cdf07251bc762566a1be36211e1cff689f13980f3776a2f36"}, + {file = "fastavro-1.12.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:283dcd3129b632021894425974bedd0eb6db3bbf5994e448ccad10db4d803d31"}, + {file = "fastavro-1.12.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d125e210d5a0a1f701f12c0ecad9a03f1b04b5eddbce6ca36a1fc217da977ef"}, + {file = "fastavro-1.12.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d4d66afad78e8f47feaa307728a6b71fe3effc63ba2b9eeb109ee687c9bd397"}, + {file = "fastavro-1.12.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2328ec07925c04c89719e3971c9068a165c7fd474ea87675b1204de0440e71ff"}, + {file = "fastavro-1.12.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:55dea7e74b834d4b70467fc19c5b9ccb5509fe39abc4d26891187c1b22176423"}, + {file = "fastavro-1.12.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8d37c87826ae7195cfbd20fcd448801f2f563bb38f2691ec6574e39cb9eca6c8"}, + {file = "fastavro-1.12.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c463a3701f293e30d3d62e71e1989f112028d07f87432baf4507eeb57ec3831"}, + {file = "fastavro-1.12.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f604ba83498e209fff4c7ecc5063a39421dc538dace694bc592f9f338254f3dc"}, + {file = "fastavro-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bfac2dada8ddc002e8b7d8289d6fad4f070bc1fec20371cec684a7d10d932e96"}, + {file = "fastavro-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bc44ba6289fb1f5ee318335958dde6ad6d742dcb4bb8930de843e9024c64b68c"}, + {file = "fastavro-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:a475418f71c5aed69899813ecccf392429c08c3a63df3030129db71760b0db8f"}, + {file = "fastavro-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:daec9f9655a1d4636613c47d6d3343f6e039150d66cdce62543e20ca36612a8a"}, + {file = "fastavro-1.12.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:57594b72cf663bbd0f3ad8a319a999fc3d7c71065a6799b2c1d1a6a137894c5b"}, + {file = "fastavro-1.12.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74412132bbfb153cbf704517f2c89f7d3e170feb681b13bceace690f66f8d5fa"}, + {file = "fastavro-1.12.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e367a84c9133018e0a3bc822abe78d7f1f9a6092991a0ec409468cf4ef260282"}, + {file = "fastavro-1.12.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:044fafca0853e9ae14009de7763ac9e8e8f8b96f8a4e90bd58b695443266a370"}, + {file = "fastavro-1.12.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afede7324822800e4f90e96b9514188a237a60f35e8e7a10b2129c10c78f6e4d"}, + {file = "fastavro-1.12.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b5539711dfa1ec8f3eca57482b93a48a165af4a99e9d5f41e3af3fb913aadf92"}, + {file = "fastavro-1.12.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c01b0f0ce030a7b89263c0236ca77923eae352c5f35ecf214b04d3aaea8eb2c3"}, + {file = "fastavro-1.12.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:742d93f2ca835e4fa83a3ae9ed2bce8b28029ed62ac730f339a37685c23075cd"}, + {file = "fastavro-1.12.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ae60df21cc7059e2f3b1928ad2c0b75c6b26f9ada79d992f87b6fc3f50d3877e"}, + {file = "fastavro-1.12.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:03614131093a32c90c8fd95ff356c316752b8850cb8a770bc96cef17a003e2fc"}, + {file = "fastavro-1.12.2-cp39-cp39-win_amd64.whl", hash = "sha256:e235dfdabb51993bcd4a8f45c3a54f21a782f7f92b3def0648b0ace45a1b1ac7"}, + {file = "fastavro-1.12.2.tar.gz", hash = "sha256:3c79502d56cf6b76210032e1c53494ddfbc73c140bccf2ef4092b3f0825323ab"}, +] + +[package.extras] +codecs = ["backports.zstd", "cramjam", "lz4"] +lz4 = ["lz4"] +snappy = ["cramjam"] +zstandard = ["backports.zstd"] + +[[package]] +name = "fastmcp" +version = "3.4.7" +description = "The fast, Pythonic way to build MCP servers and clients." +optional = false +python-versions = ">=3.10" +files = [ + {file = "fastmcp-3.4.7-py3-none-any.whl", hash = "sha256:e4e7698cb4af5bc667b1901685261fa2f3526dc73d243a461fca42500c8dbe56"}, + {file = "fastmcp-3.4.7.tar.gz", hash = "sha256:43117aca886f5ee2f6a569bba91cef02b59c339aad04ba29950ff18d251c822a"}, +] + +[package.dependencies] +fastmcp-slim = {version = "3.4.7", extras = ["client", "server"]} + +[package.extras] +anthropic = ["fastmcp-slim[anthropic] (==3.4.7)"] +apps = ["fastmcp-slim[apps] (==3.4.7)"] +azure = ["fastmcp-slim[azure] (==3.4.7)"] +code-mode = ["fastmcp-slim[code-mode] (==3.4.7)"] +gemini = ["fastmcp-slim[gemini] (==3.4.7)"] +openai = ["fastmcp-slim[openai] (==3.4.7)"] +tasks = ["fastmcp-slim[tasks] (==3.4.7)"] + +[[package]] +name = "fastmcp-slim" +version = "3.4.7" +description = "The dependency-slim FastMCP package." +optional = false +python-versions = ">=3.10" +files = [ + {file = "fastmcp_slim-3.4.7-py3-none-any.whl", hash = "sha256:6c931a0089705f3f2935428ef9b2bc74ad94140adc64aab84d116d103e694b3a"}, + {file = "fastmcp_slim-3.4.7.tar.gz", hash = "sha256:06b32a358320a7dc2b2ee040ba89ea55ddc20763dff2949f384f7974b13b5d8f"}, +] + +[package.dependencies] +authlib = {version = ">=1.6.11", optional = true, markers = "extra == \"client\" or extra == \"server\""} +cyclopts = {version = ">=4.0.0", optional = true, markers = "extra == \"server\""} +exceptiongroup = {version = ">=1.2.2", optional = true, markers = "extra == \"client\" or extra == \"server\""} +griffelib = {version = ">=2.0.0", optional = true, markers = "extra == \"server\""} +httpx = {version = ">=0.28.1,<1.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +joserfc = {version = ">=1.1.0", optional = true, markers = "extra == \"server\""} +jsonref = {version = ">=1.1.0", optional = true, markers = "extra == \"server\""} +jsonschema-path = {version = ">=0.3.4", optional = true, markers = "extra == \"server\""} +mcp = {version = ">=1.24.0,<2.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +openapi-pydantic = {version = ">=0.5.1", optional = true, markers = "extra == \"server\""} +opentelemetry-api = {version = ">=1.20.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +packaging = {version = ">=24.0", optional = true, markers = "extra == \"server\""} +platformdirs = ">=4.0.0" +py-key-value-aio = {version = ">=0.4.4,<0.5.0", extras = ["filetree", "keyring", "memory"], optional = true, markers = "extra == \"client\" or extra == \"server\""} +pydantic = {version = ">=2.11.7", extras = ["email"]} +pydantic-settings = ">=2.0.0" +pyperclip = {version = ">=1.9.0", optional = true, markers = "extra == \"server\""} +python-dotenv = ">=1.1.0" +python-multipart = {version = ">=0.0.26", optional = true, markers = "extra == \"server\""} +pyyaml = {version = ">=6.0,<7.0", optional = true, markers = "extra == \"server\""} +rich = ">=13.9.4" +starlette = {version = ">=1.0.1", optional = true, markers = "extra == \"client\" or extra == \"server\""} +typing-extensions = ">=4.0.0" +uncalled-for = {version = ">=0.2.0", optional = true, markers = "extra == \"server\""} +uvicorn = {version = ">=0.35", optional = true, markers = "extra == \"server\""} +watchfiles = {version = ">=1.0.0", optional = true, markers = "extra == \"server\""} +websockets = {version = ">=15.0.1", optional = true, markers = "extra == \"server\""} + +[package.extras] +anthropic = ["anthropic (>=0.48.0)"] +apps = ["prefab-ui (>=0.18.0)"] +azure = ["azure-identity (>=1.16.0)", "pyjwt (>=2.12.0)"] +client = ["authlib (>=1.6.11)", "exceptiongroup (>=1.2.2)", "httpx (>=0.28.1,<1.0)", "mcp (>=1.24.0,<2.0)", "opentelemetry-api (>=1.20.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "starlette (>=1.0.1)"] +code-mode = ["pydantic-monty (==0.0.17)"] +gemini = ["google-genai (>=1.18.0)", "jsonref (>=1.1.0)"] +mcp = ["exceptiongroup (>=1.2.2)", "httpx (>=0.28.1,<1.0)", "mcp (>=1.24.0,<2.0)", "opentelemetry-api (>=1.20.0)", "starlette (>=1.0.1)"] +openai = ["openai (>=1.102.0)"] +server = ["authlib (>=1.6.11)", "cyclopts (>=4.0.0)", "exceptiongroup (>=1.2.2)", "griffelib (>=2.0.0)", "httpx (>=0.28.1,<1.0)", "joserfc (>=1.1.0)", "jsonref (>=1.1.0)", "jsonschema-path (>=0.3.4)", "mcp (>=1.24.0,<2.0)", "openapi-pydantic (>=0.5.1)", "opentelemetry-api (>=1.20.0)", "packaging (>=24.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "pyperclip (>=1.9.0)", "python-multipart (>=0.0.26)", "pyyaml (>=6.0,<7.0)", "starlette (>=1.0.1)", "uncalled-for (>=0.2.0)", "uvicorn (>=0.35)", "watchfiles (>=1.0.0)", "websockets (>=15.0.1)"] +tasks = ["pydocket (>=0.20.0)"] + +[[package]] +name = "fastuuid" +version = "0.14.0" +description = "Python bindings to Rust's UUID library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastuuid-0.14.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6e6243d40f6c793c3e2ee14c13769e341b90be5ef0c23c82fa6515a96145181a"}, + {file = "fastuuid-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:13ec4f2c3b04271f62be2e1ce7e95ad2dd1cf97e94503a3760db739afbd48f00"}, + {file = "fastuuid-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b2fdd48b5e4236df145a149d7125badb28e0a383372add3fbaac9a6b7a394470"}, + {file = "fastuuid-0.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f74631b8322d2780ebcf2d2d75d58045c3e9378625ec51865fe0b5620800c39d"}, + {file = "fastuuid-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83cffc144dc93eb604b87b179837f2ce2af44871a7b323f2bfed40e8acb40ba8"}, + {file = "fastuuid-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a771f135ab4523eb786e95493803942a5d1fc1610915f131b363f55af53b219"}, + {file = "fastuuid-0.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4edc56b877d960b4eda2c4232f953a61490c3134da94f3c28af129fb9c62a4f6"}, + {file = "fastuuid-0.14.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bcc96ee819c282e7c09b2eed2b9bd13084e3b749fdb2faf58c318d498df2efbe"}, + {file = "fastuuid-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7a3c0bca61eacc1843ea97b288d6789fbad7400d16db24e36a66c28c268cfe3d"}, + {file = "fastuuid-0.14.0-cp310-cp310-win32.whl", hash = "sha256:7f2f3efade4937fae4e77efae1af571902263de7b78a0aee1a1653795a093b2a"}, + {file = "fastuuid-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:ae64ba730d179f439b0736208b4c279b8bc9c089b102aec23f86512ea458c8a4"}, + {file = "fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34"}, + {file = "fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7"}, + {file = "fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1"}, + {file = "fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc"}, + {file = "fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8"}, + {file = "fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7"}, + {file = "fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73"}, + {file = "fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36"}, + {file = "fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94"}, + {file = "fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24"}, + {file = "fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa"}, + {file = "fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a"}, + {file = "fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d"}, + {file = "fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070"}, + {file = "fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796"}, + {file = "fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09"}, + {file = "fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8"}, + {file = "fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741"}, + {file = "fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057"}, + {file = "fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8"}, + {file = "fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176"}, + {file = "fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397"}, + {file = "fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021"}, + {file = "fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc"}, + {file = "fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5"}, + {file = "fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f"}, + {file = "fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87"}, + {file = "fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b"}, + {file = "fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022"}, + {file = "fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995"}, + {file = "fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab"}, + {file = "fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad"}, + {file = "fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed"}, + {file = "fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad"}, + {file = "fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b"}, + {file = "fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714"}, + {file = "fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f"}, + {file = "fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f"}, + {file = "fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75"}, + {file = "fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4"}, + {file = "fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad"}, + {file = "fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8"}, + {file = "fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06"}, + {file = "fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a"}, + {file = "fastuuid-0.14.0-cp38-cp38-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:47c821f2dfe95909ead0085d4cb18d5149bca704a2b03e03fb3f81a5202d8cea"}, + {file = "fastuuid-0.14.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:3964bab460c528692c70ab6b2e469dd7a7b152fbe8c18616c58d34c93a6cf8d4"}, + {file = "fastuuid-0.14.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c501561e025b7aea3508719c5801c360c711d5218fc4ad5d77bf1c37c1a75779"}, + {file = "fastuuid-0.14.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dce5d0756f046fa792a40763f36accd7e466525c5710d2195a038f93ff96346"}, + {file = "fastuuid-0.14.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193ca10ff553cf3cc461572da83b5780fc0e3eea28659c16f89ae5202f3958d4"}, + {file = "fastuuid-0.14.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0737606764b29785566f968bd8005eace73d3666bd0862f33a760796e26d1ede"}, + {file = "fastuuid-0.14.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e0976c0dff7e222513d206e06341503f07423aceb1db0b83ff6851c008ceee06"}, + {file = "fastuuid-0.14.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6fbc49a86173e7f074b1a9ec8cf12ca0d54d8070a85a06ebf0e76c309b84f0d0"}, + {file = "fastuuid-0.14.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:de01280eabcd82f7542828ecd67ebf1551d37203ecdfd7ab1f2e534edb78d505"}, + {file = "fastuuid-0.14.0-cp38-cp38-win32.whl", hash = "sha256:af5967c666b7d6a377098849b07f83462c4fedbafcf8eb8bc8ff05dcbe8aa209"}, + {file = "fastuuid-0.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3091e63acf42f56a6f74dc65cfdb6f99bfc79b5913c8a9ac498eb7ca09770a8"}, + {file = "fastuuid-0.14.0-cp39-cp39-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2ec3d94e13712a133137b2805073b65ecef4a47217d5bac15d8ac62376cefdb4"}, + {file = "fastuuid-0.14.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:139d7ff12bb400b4a0c76be64c28cbe2e2edf60b09826cbfd85f33ed3d0bbe8b"}, + {file = "fastuuid-0.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d55b7e96531216fc4f071909e33e35e5bfa47962ae67d9e84b00a04d6e8b7173"}, + {file = "fastuuid-0.14.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0eb25f0fd935e376ac4334927a59e7c823b36062080e2e13acbaf2af15db836"}, + {file = "fastuuid-0.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:089c18018fdbdda88a6dafd7d139f8703a1e7c799618e33ea25eb52503d28a11"}, + {file = "fastuuid-0.14.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fc37479517d4d70c08696960fad85494a8a7a0af4e93e9a00af04d74c59f9e3"}, + {file = "fastuuid-0.14.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:73657c9f778aba530bc96a943d30e1a7c80edb8278df77894fe9457540df4f85"}, {file = "fastuuid-0.14.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d31f8c257046b5617fc6af9c69be066d2412bdef1edaa4bdf6a214cf57806105"}, {file = "fastuuid-0.14.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5816d41f81782b209843e52fdef757a361b448d782452d96abedc53d545da722"}, {file = "fastuuid-0.14.0-cp39-cp39-win32.whl", hash = "sha256:448aa6833f7a84bfe37dd47e33df83250f404d591eb83527fa2cac8d1e57d7f3"}, @@ -925,13 +1820,23 @@ files = [ [[package]] name = "filelock" -version = "3.32.5" +version = "4.0.1" description = "A platform independent file lock." optional = false python-versions = ">=3.10" files = [ - {file = "filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2"}, - {file = "filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d"}, + {file = "filelock-4.0.1-py3-none-any.whl", hash = "sha256:481a321a27bef441e23c53371c6abc8d7d16e26b97090074ba44f7538a3fd55a"}, + {file = "filelock-4.0.1.tar.gz", hash = "sha256:fdefc3f3e87716d855ae2b732c1cfd521dd99799ef2b4d00e8c0d4dcdc7cc94b"}, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +description = "The FlatBuffers serialization format for Python" +optional = false +python-versions = "*" +files = [ + {file = "flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4"}, ] [[package]] @@ -1075,13 +1980,13 @@ files = [ [[package]] name = "fsspec" -version = "2026.7.0" +version = "2026.9.0" description = "File-system specification" optional = false python-versions = ">=3.10" files = [ - {file = "fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279"}, - {file = "fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88"}, + {file = "fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f"}, + {file = "fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe"}, ] [package.extras] @@ -1108,7 +2013,7 @@ sftp = ["paramiko"] smb = ["smbprotocol"] ssh = ["paramiko"] test = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "numpy", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "requests"] -test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray"] +test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray", "zarr"] test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "backports-zstd", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs (>=2026.4.0)", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas (<3.0.0)", "panel", "paramiko", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "s3fs (>=2026.6.0)", "smbprotocol", "tqdm", "urllib3", "zarr (<3.2.0)", "zstandard"] tqdm = ["tqdm"] @@ -1146,15 +2051,33 @@ files = [ [package.dependencies] smmap = ">=3.0.1,<6" +[[package]] +name = "github-copilot-sdk" +version = "1.0.14" +description = "Python SDK for GitHub Copilot CLI" +optional = false +python-versions = ">=3.11" +files = [ + {file = "github_copilot_sdk-1.0.14-py3-none-any.whl", hash = "sha256:9f4538cec7295f24507c650d794307396458a51f280a0fbbcd840da177109050"}, +] + +[package.dependencies] +httpx = ">=0.24.0" +pydantic = ">=2.0" +python-dateutil = ">=2.9.0.post0" + +[package.extras] +telemetry = ["opentelemetry-api (>=1.0.0)"] + [[package]] name = "gitpython" -version = "3.1.61" +version = "3.1.62" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "gitpython-3.1.61-py3-none-any.whl", hash = "sha256:8ab28c9da863cdd9e7d7694ec46cf3e6c9a12d8a30a1acd3447aec11975d530c"}, - {file = "gitpython-3.1.61.tar.gz", hash = "sha256:f51c24d8c0f733a195447385f5774a5dfe8767f5acfd7994a33755644c6ecc95"}, + {file = "gitpython-3.1.62-py3-none-any.whl", hash = "sha256:7002251225e10e29d2e1f49e6532613fe5d5d9f0b6f1f02997a52b38fe56899e"}, + {file = "gitpython-3.1.62.tar.gz", hash = "sha256:1791de66309bc0c7cfca40bf8d2e3de7ca091cbf94e6051be1ad0722c61062af"}, ] [package.dependencies] @@ -1164,6 +2087,160 @@ gitdb = ">=4.0.1,<5" doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] test = ["basedpyright (==1.39.9)", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy (==1.18.2)", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"] +[[package]] +name = "google-auth" +version = "2.58.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.10" +files = [ + {file = "google_auth-2.58.0-py3-none-any.whl", hash = "sha256:8a9c4645bb4c8e91668fb1934b95ae6a8687084232753639220ba9bf04a1610d"}, + {file = "google_auth-2.58.0.tar.gz", hash = "sha256:55e30cf15e737de92c5323d78cda8a83fcd57e7ffbaf900c4600039fd60a80fd"}, +] + +[package.dependencies] +cryptography = {version = ">=38.0.3", markers = "python_version < \"3.14\""} +pyasn1-modules = ">=0.2.1" +requests = {version = ">=2.30.0,<3.0.0", optional = true, markers = "extra == \"requests\""} + +[package.extras] +aiohttp = ["aiohttp (>=3.8.0,<4.0.0)", "aiohttp (>=3.9.0,<4.0.0)", "requests (>=2.30.0,<3.0.0)"] +cryptography = ["cryptography (>=38.0.3)", "cryptography (>=41.0.5)"] +enterprise-cert = ["cryptography (>=38.0.3)", "cryptography (>=41.0.5)"] +grpc = ["grpcio (>=1.59.0,<2.0.0)", "grpcio (>=1.75.1,<2.0.0)"] +pyjwt = ["pyjwt (>=2.0)"] +pyopenssl = ["cryptography (>=38.0.3)", "cryptography (>=41.0.5)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.30.0,<3.0.0)"] +rsa = ["rsa (>=4.0.0,<5)"] +testing = ["aiohttp (>=3.8.0,<4.0.0)", "aiohttp (>=3.9.0,<4.0.0)", "aioresponses", "flask", "freezegun", "grpcio (>=1.59.0,<2.0.0)", "grpcio (>=1.75.1,<2.0.0)", "packaging (>=20.0)", "pyjwt (>=2.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.30.0,<3.0.0)", "responses", "urllib3 (>=1.26.15,<3.0.0)"] +urllib3 = ["packaging (>=20.0)", "urllib3 (>=1.26.15,<3.0.0)"] + +[[package]] +name = "google-genai" +version = "2.24.0" +description = "GenAI Python SDK" +optional = false +python-versions = ">=3.10" +files = [ + {file = "google_genai-2.24.0-py3-none-any.whl", hash = "sha256:59e5f2d88bc2d6ed04aa1aa14cb5629090cfe9458401745e620b24d1fe9893d7"}, + {file = "google_genai-2.24.0.tar.gz", hash = "sha256:814b63d05dca4776c5a322699eb30c565040cf8d5fd77c38714076535fb2e019"}, +] + +[package.dependencies] +anyio = ">=4.8.0,<5.0.0" +distro = ">=1.7.0,<2" +google-auth = {version = ">=2.56.0,<3.0.0", extras = ["requests"]} +httpx = ">=0.28.1,<1.0.0" +pydantic = ">=2.12.5,<3.0.0" +requests = ">=2.28.1,<3.0.0" +sniffio = "*" +tenacity = ">=8.2.3,<9.2.0" +typing-extensions = ">=4.14.0,<5.0.0" +websockets = ">=13.0.0,<17.0" + +[package.extras] +aiohttp = ["aiohttp (>=3.10.11,<4.0.0)"] +local-tokenizer = ["pillow", "protobuf", "sentencepiece (>=0.2.0)", "torch", "torchvision", "transformers"] +pyopenssl = ["pyopenssl"] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.3" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.10" +files = [ + {file = "googleapis_common_protos-1.75.3-py3-none-any.whl", hash = "sha256:a018d2bf098ca9fb6faa08d5bb780e2a2c2f73c566f069761331386c9596d3f2"}, + {file = "googleapis_common_protos-1.75.3.tar.gz", hash = "sha256:57c435ac2c68b108999b6db075d9053e4d7a936ba57b4a3d45667b1346f1738a"}, +] + +[package.dependencies] +protobuf = ">=6.33.5,<8.0.0" + +[package.extras] +grpc = ["grpcio (>=1.59.0,<2.0.0)"] + +[[package]] +name = "greenlet" +version = "3.3.2" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.10" +files = [ + {file = "greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d"}, + {file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13"}, + {file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e"}, + {file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7"}, + {file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f"}, + {file = "greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef"}, + {file = "greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca"}, + {file = "greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f"}, + {file = "greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86"}, + {file = "greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f"}, + {file = "greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55"}, + {file = "greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2"}, + {file = "greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358"}, + {file = "greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99"}, + {file = "greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be"}, + {file = "greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5"}, + {file = "greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd"}, + {file = "greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd"}, + {file = "greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd"}, + {file = "greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac"}, + {file = "greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb"}, + {file = "greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070"}, + {file = "greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79"}, + {file = "greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395"}, + {file = "greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f"}, + {file = "greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643"}, + {file = "greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4"}, + {file = "greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986"}, + {file = "greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92"}, + {file = "greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd"}, + {file = "greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab"}, + {file = "greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a"}, + {file = "greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b"}, + {file = "greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124"}, + {file = "greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327"}, + {file = "greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab"}, + {file = "greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082"}, + {file = "greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9"}, + {file = "greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9"}, + {file = "greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506"}, + {file = "greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce"}, + {file = "greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5"}, + {file = "greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492"}, + {file = "greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71"}, + {file = "greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54"}, + {file = "greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4"}, + {file = "greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff"}, + {file = "greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf"}, + {file = "greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4"}, + {file = "greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727"}, + {file = "greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e"}, + {file = "greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a"}, + {file = "greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "griffelib" +version = "2.3.0" +description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." +optional = false +python-versions = ">=3.10" +files = [ + {file = "griffelib-2.3.0-py3-none-any.whl", hash = "sha256:1b8f9cd525681c26b1d6d574faa1371651e8459ca51d209684f50b8096ae06e0"}, + {file = "griffelib-2.3.0.tar.gz", hash = "sha256:7b0952caf5bca6afa4bb5ee8c6a2d183fe3f21b62efc5f6c7243cb2b26d2d115"}, +] + +[package.extras] +pypi = ["pip (>=24.0)", "platformdirs (>=4.2)", "wheel (>=0.42)"] + [[package]] name = "h11" version = "0.16.0" @@ -1204,6 +2281,84 @@ files = [ [package.extras] tests = ["pytest"] +[[package]] +name = "hindsight-api-slim" +version = "0.10.0" +description = "Hindsight: Agent Memory That Learns" +optional = false +python-versions = ">=3.11" +files = [ + {file = "hindsight_api_slim-0.10.0-py3-none-any.whl", hash = "sha256:6f7a11fb43735bcc8164d3bee84622690bfa081ca02aaa88e3b88ef72ef3eca3"}, + {file = "hindsight_api_slim-0.10.0.tar.gz", hash = "sha256:0b4d9e0f3af08c1b507fb331f935ae1932c0c7bf02e2b2cc7a269319c0181e8f"}, +] + +[package.dependencies] +aiohttp = ">=3.14.3" +alembic = ">=1.17.1" +anthropic = ">=0.40.0" +asyncpg = ">=0.30.0" +authlib = ">=1.6.9" +boto3 = ">=1.42.74" +claude-agent-sdk = ">=0.2.82" +cohere = ">=5.0.0" +croniter = ">=2.0.0" +cryptography = ">=50.0.0" +dateparser = ">=1.4.2" +fastapi = {version = ">=0.120.3", extras = ["standard"]} +fastmcp = ">=3.2.0" +filelock = ">=3.20.1" +github-copilot-sdk = ">=1.0.11" +google-auth = ">=2.0.0" +google-genai = ">=1.72.0" +greenlet = ">=3.2.4,<3.4.0" +httpx = ">=0.27.0" +json-repair = ">=0.63.2" +litellm = [ + {version = ">=1.91.3,<1.92", markers = "sys_platform == \"darwin\""}, + {version = ">=1.93.0", markers = "sys_platform != \"darwin\""}, +] +markitdown = {version = ">=0.1.4", extras = ["docx", "pdf", "pptx", "xls", "xlsx"]} +numpy = ">=1.26.0" +obstore = ">=0.4.0" +openai = ">=1.66.0" +opentelemetry-api = ">=1.44.0" +opentelemetry-exporter-otlp-proto-http = ">=1.44.0" +opentelemetry-exporter-prometheus = ">=0.65b0" +opentelemetry-instrumentation-fastapi = ">=0.65b0" +opentelemetry-sdk = ">=1.44.0" +opentelemetry-semantic-conventions = ">=0.65b0" +orjson = ">=3.11.0" +pgvector = ">=0.4.1" +pillow = ">=12.3.0" +protobuf = ">=7.35.1" +psycopg2-binary = ">=2.9.11" +pyasn1 = ">=0.6.3" +pydantic = ">=2.0.0" +pygments = ">=2.20.0" +pyjwt = ">=2.12.0" +python-dateutil = ">=2.8.0" +python-dotenv = ">=1.0.0" +python-multipart = ">=0.0.22" +regex = ">=2026.9.3" +rich = ">=13.0.0" +sqlalchemy = ">=2.0.44,<2.1" +toktok-rs = ">=0.1.3" +typer = ">=0.9.0" +urllib3 = ">=2.7.0" +uvicorn = ">=0.38.0" +uvloop = {version = ">=0.22.1", markers = "sys_platform != \"win32\""} +winloop = {version = ">=0.1.0", markers = "sys_platform == \"win32\""} +wsproto = ">=1.0.0" + +[package.extras] +all = ["einops (>=0.8.2)", "flashrank (>=0.2.0)", "huggingface-hub (>=0.20.0)", "mlx (>=0.31.0)", "mlx-lm (>=0.31.1)", "numpy (>=1.26.0)", "onnxruntime (>=1.17.0)", "pg0-embedded (>=0.15.0)", "safetensors (>=0.6.2)", "sentence-transformers (>=5.0.0)", "tokenizers (>=0.22.0,<=0.23.0)", "torch (>=2.11.0)", "transformers (>=5.5.0)"] +embedded-db = ["pg0-embedded (>=0.15.0)"] +local-llm = ["huggingface-hub (>=0.20.0)", "llama-cpp-python[server] (>=0.3.0)"] +local-ml = ["einops (>=0.8.2)", "flashrank (>=0.2.0)", "mlx (>=0.31.0)", "mlx-lm (>=0.31.1)", "safetensors (>=0.6.2)", "sentence-transformers (>=5.0.0)", "tokenizers (>=0.22.0,<=0.23.0)", "torch (>=2.11.0)", "transformers (>=5.5.0)"] +local-onnx = ["huggingface-hub (>=0.20.0)", "numpy (>=1.26.0)", "onnxruntime (>=1.17.0)", "tokenizers (>=0.22.0,<=0.23.0)", "transformers (>=5.5.0)"] +oracle = ["oracledb (>=2.5.0)"] +test = ["filelock (>=3.20.1)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.21.0)", "pytest-timeout (>=2.4.0)", "pytest-xdist (>=3.0.0)", "testcontainers (>=4.0.0)"] + [[package]] name = "httpcore" version = "1.0.9" @@ -1225,6 +2380,86 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] trio = ["trio (>=0.22.0,<1.0)"] +[[package]] +name = "httpcore2" +version = "2.13.0" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.10" +files = [ + {file = "httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e"}, + {file = "httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db"}, +] + +[package.dependencies] +h11 = ">=0.16" +truststore = ">=0.10" + +[package.extras] +asyncio = ["anyio (>=4.5.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.34.0,<1.0)"] + +[[package]] +name = "httptools" +version = "0.8.0" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.9" +files = [ + {file = "httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826"}, + {file = "httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77"}, + {file = "httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4"}, + {file = "httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb"}, + {file = "httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813"}, + {file = "httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba"}, + {file = "httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557"}, + {file = "httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168"}, + {file = "httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d"}, + {file = "httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376"}, + {file = "httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d"}, + {file = "httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085"}, + {file = "httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124"}, + {file = "httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07"}, + {file = "httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d"}, + {file = "httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5"}, + {file = "httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2"}, + {file = "httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09"}, + {file = "httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a"}, + {file = "httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745"}, + {file = "httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150"}, + {file = "httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8"}, + {file = "httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c"}, + {file = "httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7"}, + {file = "httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d"}, + {file = "httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681"}, + {file = "httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683"}, + {file = "httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1"}, + {file = "httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6"}, + {file = "httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b"}, + {file = "httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0"}, + {file = "httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e"}, + {file = "httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b"}, + {file = "httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0"}, + {file = "httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527"}, + {file = "httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568"}, + {file = "httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b"}, + {file = "httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca"}, + {file = "httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f"}, + {file = "httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d"}, + {file = "httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081"}, + {file = "httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77"}, + {file = "httptools-0.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:df31ef5494f406ab6cf827b7e64a22841c6e2d654100e6a116ea15b46d02d5e8"}, + {file = "httptools-0.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5eb911c515b96ee44bbd861e42cbefc488681d450545b1d02127f6136e3a86f5"}, + {file = "httptools-0.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c08ffe3e79756e0963cbc8fe410139f38a5884874b6f2e17761bef6563fdcd9b"}, + {file = "httptools-0.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72"}, + {file = "httptools-0.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7b71e7d7031928c650e1006e6c03e911bf967f7c69c011d37d541c3e7bf55005"}, + {file = "httptools-0.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9fc1644f415372cec4f8a5be3a64183737398f10dbb1263602a036427fe75247"}, + {file = "httptools-0.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:5d7fa4ba7292c1139c0526f0b5aad507c6263c948206ea1b1cbca015c8af1b62"}, + {file = "httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999"}, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1260,15 +2495,53 @@ files = [ {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, ] +[[package]] +name = "httpx2" +version = "2.13.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.10" +files = [ + {file = "httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a"}, + {file = "httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44"}, +] + +[package.dependencies] +anyio = {version = ">=4.10", markers = "sys_platform != \"emscripten\""} +httpcore2 = {version = "2.13.0", markers = "sys_platform != \"emscripten\""} +httpx2-jsfetch = {version = "*", markers = "sys_platform == \"emscripten\" and python_version >= \"3.12\""} +idna = ">=3.18" +truststore = {version = ">=0.10", markers = "sys_platform != \"emscripten\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version < \"3.13\""} + +[package.extras] +brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.2)"] +cli = ["click (>=8.4.2)", "pygments (==2.*)", "rich (>=10,<16)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +ws = ["wsproto (>=1.2)"] +zstd = ["backports-zstd (>=1.0.0)"] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +description = "httpx2 transports for Emscripten/Pyodide, backed by the JavaScript fetch API." +optional = false +python-versions = ">=3.12" +files = [ + {file = "httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32"}, + {file = "httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60"}, +] + [[package]] name = "huggingface-hub" -version = "1.29.0" +version = "1.32.0" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.10.0" files = [ - {file = "huggingface_hub-1.29.0-py3-none-any.whl", hash = "sha256:b00f7782afc14db4bc6572763810a635bdfbab8623d957bfb553bd18e03852cd"}, - {file = "huggingface_hub-1.29.0.tar.gz", hash = "sha256:6ebb385a581435325cf6d5c5b233d5d4bc91175834d99fd65dae14379b36e9ad"}, + {file = "huggingface_hub-1.32.0-py3-none-any.whl", hash = "sha256:b0c7c80561969d9cdacdd55fce67ba9584cca0b9d4ea80957a3a5c1445fac5c8"}, + {file = "huggingface_hub-1.32.0.tar.gz", hash = "sha256:ed70a45498abe86039df7c2f4e5f7575de524be908d3840e8f828d5525eafd6a"}, ] [package.dependencies] @@ -1295,15 +2568,29 @@ testing = ["Jinja2", "Pillow", "authlib (>=1.3.2)", "duckdb", "fastapi", "fastap torch = ["safetensors[torch]", "torch"] typing = ["types-PyYAML", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] +[[package]] +name = "humanfriendly" +version = "10.0" +description = "Human friendly output for text interfaces using Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, + {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, +] + +[package.dependencies] +pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} + [[package]] name = "idna" -version = "3.19" +version = "3.20" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" files = [ - {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, - {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, + {file = "idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c"}, + {file = "idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44"}, ] [package.extras] @@ -1343,6 +2630,83 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +description = "Utility functions for Python class constructs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, + {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, +] + +[package.dependencies] +more-itertools = "*" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +description = "Useful decorators and context managers" +optional = false +python-versions = ">=3.10" +files = [ + {file = "jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535"}, + {file = "jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3"}, +] + +[package.dependencies] +"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1)"] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +description = "Functools like those found in stdlib" +optional = false +python-versions = ">=3.10" +files = [ + {file = "jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30"}, + {file = "jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280"}, +] + +[package.dependencies] +more_itertools = "*" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1)"] + +[[package]] +name = "jeepney" +version = "0.9.0" +description = "Low-level, pure Python DBus protocol wrapper." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, + {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"}, +] + +[package.extras] +test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +trio = ["trio"] + [[package]] name = "jinja2" version = "3.1.6" @@ -1362,116 +2726,130 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "jiter" -version = "0.16.0" +version = "0.17.0" description = "Fast iterable JSON parser." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "jiter-0.16.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c5fc4f8def331036a7b8e981b4347ebe409981edbc8308a5ea842b8c3614fa6c"}, - {file = "jiter-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b"}, - {file = "jiter-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:741eed508c233a76313a1c7b001f8f21b82f14327e9196ae8bd29a2cc164ae84"}, - {file = "jiter-0.16.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fb7bc819187b56dc48aa5c833aaf92257da8e07efdb9306156667bd2eeb491c"}, - {file = "jiter-0.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9610fd25ebccb43fca584136f5c2fbb26802447eccd430dfdbab95a0fd5126"}, - {file = "jiter-0.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a1d68ff7ca1d3b5dee20a97a3decda7d5f15003823bf6d140c81f8561d3bc5c"}, - {file = "jiter-0.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de"}, - {file = "jiter-0.16.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:8fc4d94713c4697347e38faf7d6ef91547c142219bdcfc7220c4870879974244"}, - {file = "jiter-0.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0f05e229edb29e68cdd0ccb83cea13b64263416120cf943767a6fd72e6787f"}, - {file = "jiter-0.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c842cbf374a8daf50b2c04212995bee34ca2ac2cdc29a901b4cdb072c9c4131"}, - {file = "jiter-0.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5ed466aee31294d7cdcd4d37dfe5c42c97bc29d9a5f00eacf24504358309cb9b"}, - {file = "jiter-0.16.0-cp310-cp310-win32.whl", hash = "sha256:b42e9ff5376819c053da25809a8d4b6fa6e473b4856ebe42e298ac958be3d7f9"}, - {file = "jiter-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26"}, - {file = "jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3"}, - {file = "jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e"}, - {file = "jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037"}, - {file = "jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e"}, - {file = "jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b"}, - {file = "jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c"}, - {file = "jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee"}, - {file = "jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03"}, - {file = "jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea"}, - {file = "jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91"}, - {file = "jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3"}, - {file = "jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7"}, - {file = "jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1"}, - {file = "jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056"}, - {file = "jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a"}, - {file = "jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9"}, - {file = "jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72"}, - {file = "jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e"}, - {file = "jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290"}, - {file = "jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01"}, - {file = "jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48"}, - {file = "jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9"}, - {file = "jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5"}, - {file = "jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730"}, - {file = "jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f"}, - {file = "jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274"}, - {file = "jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7"}, - {file = "jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331"}, - {file = "jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195"}, - {file = "jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09"}, - {file = "jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536"}, - {file = "jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450"}, - {file = "jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e"}, - {file = "jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8"}, - {file = "jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026"}, - {file = "jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053"}, - {file = "jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e"}, - {file = "jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb"}, - {file = "jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84"}, - {file = "jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e"}, - {file = "jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd"}, - {file = "jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a"}, - {file = "jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee"}, - {file = "jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2"}, - {file = "jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883"}, - {file = "jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898"}, - {file = "jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5"}, - {file = "jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567"}, - {file = "jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69"}, - {file = "jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29"}, - {file = "jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93"}, - {file = "jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a"}, - {file = "jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00"}, - {file = "jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe"}, - {file = "jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106"}, - {file = "jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8"}, - {file = "jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585"}, - {file = "jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207"}, - {file = "jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818"}, - {file = "jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3"}, - {file = "jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284"}, - {file = "jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929"}, - {file = "jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620"}, - {file = "jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af"}, - {file = "jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e"}, - {file = "jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077"}, - {file = "jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734"}, - {file = "jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf"}, - {file = "jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db"}, - {file = "jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce"}, - {file = "jiter-0.16.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:d8f80521644426d451e70f00c7974240cab8f6ee088aedaa9af2697153ab7805"}, - {file = "jiter-0.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3b21b412b899fd8bd51a3046934b59a3bb068b79f70a5c6010053ac77cc53f0c"}, - {file = "jiter-0.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0758ab7747a984797cf048e8eedea1d8ef39d7994b25611daf5b48fc903e8873"}, - {file = "jiter-0.16.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ec553a99b0987efd7a3645a1a825cf29c224e494db267a83369fcc8da9aeda5"}, - {file = "jiter-0.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3bd327cdfa118bc1ce69c214c2678571d5bd39b8ccd0ebf43a54db00541ba9a"}, - {file = "jiter-0.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26d122613ada2b708eb714695446f40fce5bdf2edb4b02116dec62faa62dfab3"}, - {file = "jiter-0.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e03a5f21a5ce96a9441b8cb32719a8b88ed5388f53e0f339c5bcf54f1317f9d0"}, - {file = "jiter-0.16.0-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:a5c54ef4ff776d9675837ef535b3308d6e31c208d43ebc44a0f7ab8a208c68f7"}, - {file = "jiter-0.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1e7923093a376d93c6eb507c77045ae258d689ba577392846a1b3f10d0b09a9"}, - {file = "jiter-0.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2a0d46ef67cc58d906a6132dd3040ca70ae4f0b0d7c9c052fe432c658a69b3f6"}, - {file = "jiter-0.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:70a490b55634dc0d2606ce8a8e01b1d62459011beb368d15d76e1eaf62460e3d"}, - {file = "jiter-0.16.0-cp39-cp39-win32.whl", hash = "sha256:9acf1b2faec82d998811ecce7ae84d9005e53410773e9d37d61cdc424ba4581b"}, - {file = "jiter-0.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:491e7d072a253b156fff46b78bceac4652a697aa8d7082c9c18c03d7b7917d24"}, - {file = "jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad"}, - {file = "jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f"}, - {file = "jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5"}, - {file = "jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85"}, - {file = "jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127"}, - {file = "jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3"}, - {file = "jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702"}, - {file = "jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2"}, - {file = "jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c"}, + {file = "jiter-0.17.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ed1a24005daac667d577402d75a2922f9775a165b146b883ff1ad3602d8be689"}, + {file = "jiter-0.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b847b18d066c46b3b7ae49d6c94a7634c5e4a8983146ee25562a092000f5e3ad"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b68d3495d95da120651a5628c7ebadee84ed001a1b76e6afc325c42482f15b5"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c1a5336c04a41b1f1cf9572e294aec27cc569767ff73de7bf87a91f0bea7cb9"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b75f85660108965a94be77911a25a253429307294d9415b3c597118977a614de"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32aaaa764604496610a3ad2d98503ae88ccb2fbe769e892ff4533e778e85f708"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:826871c42cebaae22f0a2b5673a4a1a75c851bb2d13b3c17764a630a6b298984"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:00b5a98df3e3a3e8cf7b619f4ac2f8bf975bbf3d95d02c5d17b8dbfe5c8b8245"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6af5b74073bd25bae695e6d00919f6a9be7ed5a9f8836d981eb1ffe84139e6fb"}, + {file = "jiter-0.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:16dd0c1baf098ae70b8f3616574eb3fedf34e26670b89e16a7e67561f737ed2d"}, + {file = "jiter-0.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:545c36a0f3b2238c242cc9785439d3242a871b7bc39fe3f441bcaa07bf3aa83e"}, + {file = "jiter-0.17.0-cp310-cp310-win32.whl", hash = "sha256:155be7355bdb7ca76ab0961be8982c225f964a5c073a83984183f22391cc29fc"}, + {file = "jiter-0.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:37150a9e02e869475854fa20b7d0d5e26d18d0f8bc17293999973ff27e99ae7a"}, + {file = "jiter-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cfafd7be8b16ceadd298db542cead37cddc211c4c49e04ad2596924df18625b1"}, + {file = "jiter-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8adca2e793288e5f1bb29279bb439d0d3cfbb50eddca7e7e6ffd42ff4f482406"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c692d567ba206c7cca38c9d1d0ccc70c9786290173c184d871ca12e9981ed7"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81c83c0abe614446a283d994d2c07c4f58632dea2cdf66ba9e2921bb8ccd593e"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:073dc68c1a700c8fc480e877864a6b6ffc887533e261f4380c08c16bf09d057a"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:492f37230bbf9581ab2c17bcda862c249afb9ae2e3ab2dd6db59943bc4cc3153"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5888fe5abc1ca2fa834a3e1b4c7ef0dcece286a7d7e95a609ef0934b777b9fc9"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:84ac78df457e1ee3f7e733bd114823302ae8c5ad5542d7e6647d92ffaa090a04"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7573e80232c5bcf80c24c038cf7e53a463f5c3b1dd1dd4109d66304f4dccc233"}, + {file = "jiter-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:11902505d401691720f5785c15b02204248526edee11b635cd6c40cd52b81599"}, + {file = "jiter-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:64846211a2debe7c071d2146d2283d2b0c1c93dc8fd5fb7794faac2ca6061b5c"}, + {file = "jiter-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c19b9357309b8cc6de8a48fca8e44a8c9c2feaaa2f5896d037fa505d48fcab80"}, + {file = "jiter-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:e654b6b04e39c9cb19cb8b04c6ddf1f2db07751fa14156413969fd78bad0e5cb"}, + {file = "jiter-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:3ad556afc289f15d2b181b941982d01f06190863c07440185b9f354e1bd2def3"}, + {file = "jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91"}, + {file = "jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a"}, + {file = "jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44"}, + {file = "jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31"}, + {file = "jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd"}, + {file = "jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3"}, + {file = "jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8"}, + {file = "jiter-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa"}, + {file = "jiter-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8"}, + {file = "jiter-0.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9"}, + {file = "jiter-0.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8"}, + {file = "jiter-0.17.0-cp313-cp313-win32.whl", hash = "sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7"}, + {file = "jiter-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd"}, + {file = "jiter-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac"}, + {file = "jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5"}, + {file = "jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b"}, + {file = "jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9"}, + {file = "jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f"}, + {file = "jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03"}, + {file = "jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8"}, + {file = "jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312"}, + {file = "jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744"}, + {file = "jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718"}, + {file = "jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59"}, + {file = "jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1"}, + {file = "jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06"}, + {file = "jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9"}, + {file = "jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc"}, + {file = "jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925"}, + {file = "jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2"}, + {file = "jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575"}, + {file = "jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688"}, + {file = "jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec"}, + {file = "jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1"}, + {file = "jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1"}, + {file = "jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d"}, + {file = "jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb"}, + {file = "jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a"}, + {file = "jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d"}, + {file = "jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174"}, + {file = "jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23"}, + {file = "jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:eaba834b72d573547b9d966465b3394b749d5e14208cc70acb63aca37619ab33"}, + {file = "jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:51e1519d676a9f14dad9c2a411170d43b022ddb7989562df4e849b261ce127b2"}, + {file = "jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce4feb52493e3513335b2accdcd75605652e4632772d3c8c2f7b86954d7f39"}, + {file = "jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29f49b325e0234e4ad9ecca5b861ffbd09b95ccac9bd46fa55841b6e56eea5fe"}, + {file = "jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c"}, + {file = "jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2"}, + {file = "jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5"}, + {file = "jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff"}, + {file = "jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12"}, ] [[package]] @@ -1485,6 +2863,23 @@ files = [ {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, ] +[[package]] +name = "joserfc" +version = "1.7.5" +description = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" +optional = false +python-versions = ">=3.10" +files = [ + {file = "joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159"}, + {file = "joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea"}, +] + +[package.dependencies] +cryptography = ">=45.0.1" + +[package.extras] +drafts = ["pycryptodome"] + [[package]] name = "json-repair" version = "0.63.4" @@ -1499,6 +2894,17 @@ files = [ [package.extras] schema = ["jsonschema (>=4.21)", "pydantic (>=2)"] +[[package]] +name = "jsonref" +version = "1.1.0" +description = "jsonref is a library for automatic dereferencing of JSON Reference objects for Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9"}, + {file = "jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552"}, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -1520,6 +2926,26 @@ rpds-py = ">=0.25.0" format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] +[[package]] +name = "jsonschema-path" +version = "0.5.0" +description = "JSONSchema Spec with object-oriented paths" +optional = false +python-versions = "<4.0.0,>=3.10" +files = [ + {file = "jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2"}, + {file = "jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +pathable = ">=0.6.0,<0.7.0" +PyYAML = ">=5.1" +referencing = "<0.38.0" + +[package.extras] +requests = ["requests (>=2.31.0,<3.0.0)"] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -1534,6 +2960,35 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "keyring" +version = "25.7.0" +description = "Store and access your passwords safely." +optional = false +python-versions = ">=3.9" +files = [ + {file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"}, + {file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"}, +] + +[package.dependencies] +importlib_metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} +"jaraco.classes" = "*" +"jaraco.context" = "*" +"jaraco.functools" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +completion = ["shtab (>=1.1.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["pyfakefs", "pytest (>=6,!=8.1.*)"] +type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] + [[package]] name = "librt" version = "0.15.0" @@ -1682,19 +3137,57 @@ files = [ [[package]] name = "litellm" -version = "1.99.0" +version = "1.91.5" +description = "Library to easily interface with LLM API providers" +optional = false +python-versions = "<3.14,>=3.10" +files = [ + {file = "litellm-1.91.5-py3-none-any.whl", hash = "sha256:db09c6ea0b7c7de034b6c8b628208b000b54e02ef557468f84c2e63fbd7c8560"}, + {file = "litellm-1.91.5.tar.gz", hash = "sha256:f3a69ce82f61216903ca5e08aabe76a535862d4b935d7a7863953cbc8947e2f7"}, +] + +[package.dependencies] +aiohttp = ">=3.14.2,<4.0" +click = ">=8.0.0,<9.0" +fastuuid = ">=0.14.0,<1.0" +httpx = ">=0.28.0,<1.0" +importlib-metadata = ">=8.0.0,<9.0" +jinja2 = ">=3.1.6,<4.0" +jsonschema = ">=4.0.0,<5.0" +openai = ">=2.20.0,<3.0.0" +pydantic = ">=2.10.0,<3.0.0" +python-dotenv = ">=1.0.0,<2.0" +tiktoken = ">=0.8.0,<1.0" +tokenizers = ">=0.21.0,<1.0" + +[package.extras] +caching = ["diskcache (>=5.6.3,<6.0)"] +cli = ["pyyaml (>=6.0.3,<7.0)", "requests (>=2.32.0,<3.0)", "rich (>=13.9.4,<14.0)"] +extra-proxy = ["a2a-sdk (>=0.3.24,<1.0)", "azure-identity (>=1.25.2,<2.0)", "azure-keyvault-secrets (>=4.10.0,<5.0)", "google-cloud-iam (>=2.19.1,<3.0)", "google-cloud-kms (>=2.24.2,<3.0)", "prisma (>=0.11.0,<1.0)", "redisvl (>=0.4.1,<1.0)", "resend (>=2.23.0,<3.0)"] +google = ["google-cloud-aiplatform (>=1.133.0,<2.0)"] +grpc = ["grpcio (==1.78.0)"] +mlflow = ["mlflow (>=3.11.1,<4.0)"] +proxy = ["apscheduler (>=3.11.2,<4.0)", "azure-identity (>=1.25.2,<2.0)", "azure-storage-blob (>=12.28.0,<13.0)", "backoff (>=2.2.1,<3.0)", "boto3 (>=1.43.1,<2.0)", "cryptography (>=49.0.0,<51.0)", "expression (>=5.6.0,<6.0)", "fastapi (>=0.136.3,<1.0)", "fastapi-sso (>=0.19.0,<1.0)", "granian (>=2.7.4,<3.0)", "gunicorn (>=23.0.0,<24.0)", "litellm-enterprise (==0.1.44)", "litellm-proxy-extras (==0.4.74)", "mcp (>=1.26.0,<2.0)", "orjson (>=3.11.6,<4.0)", "polars (>=1.38.1,<2.0)", "pydantic-settings (>=2.14.1,<3.0)", "pyjwt (>=2.13.0,<3.0)", "pynacl (>=1.6.2,<2.0)", "pyroscope-io (>=0.8.16,<1.0)", "python-multipart (>=0.0.27,<1.0)", "pyyaml (>=6.0.3,<7.0)", "restrictedpython (>=8.1,<9.0)", "rich (>=13.9.4,<14.0)", "rq (>=2.7.0,<3.0)", "soundfile (>=0.12.1,<1.0)", "starlette (>=1.0.1,<2.0)", "uvicorn (>=0.33.0,<1.0)", "uvloop (>=0.21.0,<1.0)", "websockets (>=15.0.1,<16.0)"] +proxy-runtime = ["anthropic[vertex] (>=0.84.0,<1.0)", "azure-ai-contentsafety (>=1.0.0,<2.0)", "azure-storage-file-datalake (>=12.20.0,<13.0)", "ddtrace (>=4.8.2,<5.0)", "detect-secrets (>=1.5.0,<2.0)", "google-cloud-aiplatform (>=1.133.0,<2.0)", "google-genai (>=1.37.0,<2.0)", "grpcio (==1.78.0)", "langfuse (>=2.59.7,<3.0)", "llm-sandbox (>=0.3.39,<1.0)", "mangum (>=0.17.0,<1.0)", "opentelemetry-api (==1.28.0)", "opentelemetry-exporter-otlp (==1.28.0)", "opentelemetry-instrumentation-fastapi (==0.49b0)", "opentelemetry-sdk (==1.28.0)", "prometheus-client (>=0.20.0,<1.0)", "pypdf (>=6.12.0,<7.0)", "sentry-sdk (>=2.21.0,<3.0)"] +semantic-router = ["aurelio-sdk (>=0.0.19,<1.0)", "semantic-router (>=0.1.15,<1.0)"] +stt-nvidia-riva = ["audioread (>=3.0.1)", "numpy (>=1.26.0)", "nvidia-riva-client (>=2.15.0)", "soundfile (>=0.12.1)"] +utils = ["numpydoc (>=1.8.0,<2.0)"] + +[[package]] +name = "litellm" +version = "1.102.0" description = "Library to easily interface with LLM API providers" optional = false python-versions = "<3.15,>=3.10" files = [ - {file = "litellm-1.99.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a43e8716da8beed04480e91b4233ff2f1ab1fedd84cad332dbc526b54a9229ca"}, - {file = "litellm-1.99.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e2b383070656fdbec4bc44602edaaed2a21e99ceee4ea0a4650c8cb381e67b59"}, - {file = "litellm-1.99.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e461b7ce53af990e5287cf7ae30d82c956b56dcce886f63ef39a76e678f82a3b"}, - {file = "litellm-1.99.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1c45097e426fed2ae7fbd38b5404c3addeb203d0e1148c0a59848aabd5fe83c6"}, - {file = "litellm-1.99.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e42f94731665b68e263481efd79f7629e9b97eed7e10c57dc37a890eca058227"}, - {file = "litellm-1.99.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:71109c323164b4b6776ff259876523e6e883a465aa1413dd51a1bda8e92efc5f"}, - {file = "litellm-1.99.0-cp310-abi3-win_amd64.whl", hash = "sha256:5617804e838499bce8fecb41ad9bc984b7977361e557666fed0fef0c4623ce62"}, - {file = "litellm-1.99.0.tar.gz", hash = "sha256:594bf4b6ff6b79c6aa3c3b78c0e939d4afd12687e076ba3fb608d38a5aa7f9c6"}, + {file = "litellm-1.102.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9f3cae21e99ea71005cf53ea2cd8ad516f98914ab321dd866ef53fd0fca8eb5c"}, + {file = "litellm-1.102.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dabb40d4ae015d319582d93f80d3d4bd8c3c479a4227d7b130f46bc272e81e39"}, + {file = "litellm-1.102.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b2efcee314f78a46cb46624a29eb67eb949c123d9d830eb0bc452c235f6282bd"}, + {file = "litellm-1.102.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:76cd80a9a8eb97da62dfa695b3277b80845657cbabdc9fb4db085cde0012f31c"}, + {file = "litellm-1.102.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:db15cb7ee0c1afd07c5312039ee248d74f381bbe10eb1b951b4f2e6f358ebbe7"}, + {file = "litellm-1.102.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d03e18095383b9a6f22485149d1f11e99c7a0cd9748b4736676bb4d2f89fe5b2"}, + {file = "litellm-1.102.0-cp310-abi3-win_amd64.whl", hash = "sha256:fbcbaff335ce6ce17aa3c659a5c2b5605a2d8780001abbc1a716c3b10bd55e8e"}, + {file = "litellm-1.102.0.tar.gz", hash = "sha256:d2d6abe095bce53743b0ad5a80a93dbad58698d18e1a3f3e57a922ece8657bde"}, ] [package.dependencies] @@ -1716,208 +3209,269 @@ tokenizers = ">=0.21.0,<1.0" [package.extras] bedrock-realtime = ["aws-sdk-bedrock-runtime (>=0.7.0,<0.8.0)"] caching = ["diskcache (>=5.6.3,<6.0)"] -cli = ["inquirerpy (>=0.3.4,<1.0)", "keyring (>=25.6.0,<26.0)", "pyyaml (>=6.0.3,<7.0)", "requests (>=2.32.0,<3.0)", "rich (>=13.9.4,<14.0)"] -extra-proxy = ["a2a-sdk (>=1.1.0,<2.0)", "azure-identity (>=1.25.2,<2.0)", "azure-keyvault-secrets (>=4.10.0,<5.0)", "google-cloud-iam (>=2.19.1,<3.0)", "google-cloud-kms (>=2.24.2,<3.0)", "prisma (>=0.11.0,<1.0)", "redisvl (>=0.4.1,<1.0)", "resend (>=2.23.0,<3.0)"] +cli = ["inquirerpy (>=0.3.4,<1.0)", "keyring (>=25.6.0,<26.0)", "pyyaml (>=6.0.3,<7.0)", "requests (>=2.32.0,<3.0)", "rich (>=13.9.4,<14.0)", "tomlkit (>=0.13.3,<1.0)"] +extra-proxy = ["a2a-sdk (>=1.1.0,<2.0)", "azure-identity (>=1.25.2,<2.0)", "azure-keyvault-secrets (>=4.10.0,<5.0)", "google-cloud-iam (>=2.19.1,<3.0)", "google-cloud-kms (>=2.24.2,<3.0)", "prisma (>=0.11.0,<1.0)", "psycopg (>=3.2,<4.0)", "psycopg-binary (>=3.2,<4.0)", "redisvl (>=0.4.1,<1.0)", "resend (>=2.23.0,<3.0)"] google = ["google-cloud-aiplatform (>=1.133.0,<2.0)"] grpc = ["grpcio (==1.78.0)"] +mcp = ["mcp (>=1.28.1,<2.0)"] mlflow = ["mlflow (>=3.11.1,<4.0)"] -proxy = ["apscheduler (>=3.11.2,<4.0)", "azure-identity (>=1.25.2,<2.0)", "azure-storage-blob (>=12.28.0,<13.0)", "backoff (>=2.2.1,<3.0)", "boto3 (>=1.43.1,<2.0)", "cryptography (>=49.0.0,<51.0)", "expression (>=5.6.0,<6.0)", "fastapi (>=0.136.3,<1.0)", "fastapi-sso (>=0.19.0,<1.0)", "granian (>=2.7.4,<3.0)", "gunicorn (>=23.0.0,<24.0)", "hiredis (>=3.0.0,<4.0)", "inquirerpy (>=0.3.4,<1.0)", "litellm-enterprise (==0.1.59)", "litellm-proxy-extras (==0.4.89)", "mcp (>=1.28.1,<2.0)", "orjson (>=3.11.6,<4.0)", "polars (>=1.38.1,<2.0)", "pyjwt (>=2.13.0,<3.0)", "pynacl (>=1.6.2,<2.0)", "pyroscope-io (>=0.8.16,<1.0)", "python-multipart (>=0.0.27,<1.0)", "pyyaml (>=6.0.3,<7.0)", "restrictedpython (>=8.1,<9.0)", "rich (>=13.9.4,<14.0)", "rq (>=2.7.0,<3.0)", "soundfile (>=0.12.1,<1.0)", "starlette (>=1.0.1,<2.0)", "uvicorn (>=0.33.0,<1.0)", "uvloop (>=0.21.0,<1.0)", "websockets (>=15.0.1,<16.0)"] -proxy-runtime = ["anthropic[vertex] (>=0.84.0,<1.0)", "azure-ai-contentsafety (>=1.0.0,<2.0)", "azure-storage-file-datalake (>=12.20.0,<13.0)", "ddtrace (>=4.8.2,<5.0)", "detect-secrets (>=1.5.0,<2.0)", "google-cloud-aiplatform (>=1.133.0,<2.0)", "google-genai (>=1.37.0,<2.0)", "grpcio (==1.78.0)", "langfuse (>=2.59.7,<3.0)", "llm-sandbox (>=0.3.39,<1.0)", "mangum (>=0.17.0,<1.0)", "opentelemetry-api (==1.28.0)", "opentelemetry-exporter-otlp (==1.28.0)", "opentelemetry-instrumentation-fastapi (==0.49b0)", "opentelemetry-sdk (==1.28.0)", "prometheus-client (>=0.20.0,<1.0)", "pypdf (>=6.12.0,<7.0)", "sentry-sdk (>=2.21.0,<3.0)"] +proxy = ["apscheduler (>=3.11.2,<4.0)", "azure-identity (>=1.25.2,<2.0)", "azure-storage-blob (>=12.28.0,<13.0)", "backoff (>=2.2.1,<3.0)", "boto3 (>=1.43.1,<2.0)", "cryptography (>=49.0.0,<51.0)", "expression (>=5.6.0,<6.0)", "fastapi (>=0.136.3,<1.0)", "fastapi-sso (>=0.19.0,<1.0)", "granian (>=2.7.4,<3.0)", "gunicorn (>=23.0.0,<24.0)", "hiredis (>=3.0.0,<4.0)", "inquirerpy (>=0.3.4,<1.0)", "litellm-enterprise (==0.1.67)", "litellm-proxy-extras (==0.4.97)", "mcp (>=1.28.1,<2.0)", "orjson (>=3.11.6,<4.0)", "polars (>=1.38.1,<2.0)", "pyjwt (>=2.13.0,<3.0)", "pynacl (>=1.6.2,<2.0)", "pyroscope-io (>=0.8.16,<1.0)", "python-multipart (>=0.0.27,<1.0)", "pyyaml (>=6.0.3,<7.0)", "restrictedpython (>=8.5,<9.0)", "rich (>=13.9.4,<14.0)", "rq (>=2.7.0,<3.0)", "soundfile (>=0.12.1,<1.0)", "starlette (>=1.0.1,<2.0)", "tomlkit (>=0.13.3,<1.0)", "uvicorn (>=0.33.0,<1.0)", "uvloop (>=0.22.1,<1.0)", "websockets (>=15.0.1,<16.0)"] +proxy-runtime = ["anthropic[vertex] (>=0.84.0,<1.0)", "azure-ai-contentsafety (>=1.0.0,<2.0)", "azure-storage-file-datalake (>=12.20.0,<13.0)", "ddtrace (>=4.8.2,<5.0)", "detect-secrets (>=1.5.0,<2.0)", "google-cloud-aiplatform (>=1.133.0,<2.0)", "google-genai (>=1.37.0,<2.0)", "grpcio (==1.78.0)", "langfuse (>=2.59.7,<3.0)", "llm-sandbox (>=0.3.39,<1.0)", "mangum (>=0.17.0,<1.0)", "opentelemetry-api (==1.28.0)", "opentelemetry-exporter-otlp (==1.28.0)", "opentelemetry-instrumentation-fastapi (==0.49b0)", "opentelemetry-sdk (==1.28.0)", "prometheus-client (>=0.20.0,<1.0)", "pypdf (>=6.16.1,<7.0)", "sentry-sdk (>=2.21.0,<3.0)"] saml = ["python3-saml (>=1.16.0,<2.0)"] semantic-router = ["aurelio-sdk (>=0.0.19,<1.0)", "semantic-router (>=0.1.15,<1.0)"] stt-nvidia-riva = ["audioread (>=3.0.1)", "numpy (>=1.26.0)", "nvidia-riva-client (>=2.15.0)", "soundfile (>=0.12.1)"] utils = ["numpydoc (>=1.8.0,<2.0)"] [[package]] -name = "lxml" -version = "6.1.2" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +name = "lxml" +version = "6.1.3" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=3.8" +files = [ + {file = "lxml-6.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:40bcbd9f94166ffe925811e730607385cec959f42fb1bb7dad83748680465221"}, + {file = "lxml-6.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:05f5bce9af14fd1506997594bd81cee6d9c6b58ea80a39c058327aa6371ed9e9"}, + {file = "lxml-6.1.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff88a92cafde90888511242d1c54afcc1a8adbb6dc0a88fa7f87e29e92400d4a"}, + {file = "lxml-6.1.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c00e26288784460885fe76e4d4b293573e0f791f52e6d60e27b42edf005922eb"}, + {file = "lxml-6.1.3-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:773062aec2f2e56b2b22d37054123f0de8a22a4688a0c3376c3fe42685f975cf"}, + {file = "lxml-6.1.3-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6449672f9c93316deb5e2839e18931f468670e44d5bd9b1301a5a9655d45c07"}, + {file = "lxml-6.1.3-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:ec295280f4b37769256da025acf5890370355ac589c27e89caae0b5e9eedc702"}, + {file = "lxml-6.1.3-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:5929d9df5e7e3379183be0e21f7d559618a5b61cb63280df6164019242e337ed"}, + {file = "lxml-6.1.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e1eb8a4cbffd5553680ad96be6680e364710656eced73d1dc90ec489df599a3"}, + {file = "lxml-6.1.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16148acd77ed1d8836a56db883af2f5eed720f9723088110b16a0d08582130a6"}, + {file = "lxml-6.1.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:23c366231259cd75ad06495174701afb3fcb36a92917fa47de2d1f1bd9d95739"}, + {file = "lxml-6.1.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:da85db328e507da922d586c3c7416ec360ec22e9cd9e0700691afacde0c81f53"}, + {file = "lxml-6.1.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0f17d83c48ee9dfd96abae3ac3e2108c76d2fc86ce96355e37b8da9f7f4ecc08"}, + {file = "lxml-6.1.3-cp310-cp310-win32.whl", hash = "sha256:7dd624c1eaa629ad44b59a1a0145fdf2d67895592dce94c9358b938b3d075e65"}, + {file = "lxml-6.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:18a4db52b5a7b53a3540b0b0f4123319334621ee8083d496de314d0bf06ff59a"}, + {file = "lxml-6.1.3-cp310-cp310-win_arm64.whl", hash = "sha256:0feebef8d0521188d0157f758356072e840173aa61ca45b8b3f87959ac283dd5"}, + {file = "lxml-6.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11"}, + {file = "lxml-6.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4"}, + {file = "lxml-6.1.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b"}, + {file = "lxml-6.1.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13"}, + {file = "lxml-6.1.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41"}, + {file = "lxml-6.1.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0"}, + {file = "lxml-6.1.3-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867"}, + {file = "lxml-6.1.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054"}, + {file = "lxml-6.1.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6"}, + {file = "lxml-6.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c"}, + {file = "lxml-6.1.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48"}, + {file = "lxml-6.1.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0"}, + {file = "lxml-6.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12"}, + {file = "lxml-6.1.3-cp311-cp311-win32.whl", hash = "sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633"}, + {file = "lxml-6.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559"}, + {file = "lxml-6.1.3-cp311-cp311-win_arm64.whl", hash = "sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6"}, + {file = "lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc"}, + {file = "lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d"}, + {file = "lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5"}, + {file = "lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11"}, + {file = "lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a"}, + {file = "lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32"}, + {file = "lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c"}, + {file = "lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56"}, + {file = "lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f"}, + {file = "lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5"}, + {file = "lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385"}, + {file = "lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d"}, + {file = "lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9"}, + {file = "lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e"}, + {file = "lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5"}, + {file = "lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c"}, + {file = "lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c"}, + {file = "lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa"}, + {file = "lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd"}, + {file = "lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1"}, + {file = "lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d"}, + {file = "lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed"}, + {file = "lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2"}, + {file = "lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8"}, + {file = "lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e"}, + {file = "lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245"}, + {file = "lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0"}, + {file = "lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e"}, + {file = "lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2"}, + {file = "lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310"}, + {file = "lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748"}, + {file = "lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d"}, + {file = "lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc"}, + {file = "lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87"}, + {file = "lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477"}, + {file = "lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1"}, + {file = "lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165"}, + {file = "lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d"}, + {file = "lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e"}, + {file = "lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8"}, + {file = "lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75"}, + {file = "lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9"}, + {file = "lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0"}, + {file = "lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6"}, + {file = "lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023"}, + {file = "lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e"}, + {file = "lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92"}, + {file = "lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48"}, + {file = "lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d"}, + {file = "lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559"}, + {file = "lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415"}, + {file = "lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d"}, + {file = "lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861"}, + {file = "lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376"}, + {file = "lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f"}, + {file = "lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55"}, + {file = "lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2"}, + {file = "lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626"}, + {file = "lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414"}, + {file = "lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17"}, + {file = "lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473"}, + {file = "lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37"}, + {file = "lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70"}, + {file = "lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7"}, + {file = "lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2"}, + {file = "lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c"}, + {file = "lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8"}, + {file = "lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb"}, + {file = "lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8"}, + {file = "lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a"}, + {file = "lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2"}, + {file = "lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026"}, + {file = "lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0"}, + {file = "lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9"}, + {file = "lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79"}, + {file = "lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015"}, + {file = "lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a"}, + {file = "lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed"}, + {file = "lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156"}, + {file = "lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d"}, + {file = "lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0"}, + {file = "lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69"}, + {file = "lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0"}, + {file = "lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4"}, + {file = "lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4"}, + {file = "lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad"}, + {file = "lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758"}, + {file = "lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe"}, + {file = "lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741"}, + {file = "lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300"}, + {file = "lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0"}, + {file = "lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd"}, + {file = "lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e"}, + {file = "lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2"}, + {file = "lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a"}, + {file = "lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011"}, + {file = "lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5"}, + {file = "lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a"}, + {file = "lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887"}, + {file = "lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e"}, + {file = "lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6"}, + {file = "lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf"}, + {file = "lxml-6.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:13a620a3fcc20023f9e6ed5c383e00e826f1c2d5db554df2f67240760f9118e8"}, + {file = "lxml-6.1.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fbfb70ba01355251faf6b293171df49f73a88a1b6494db109ffea85442574458"}, + {file = "lxml-6.1.3-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:302f72413251c03f671e063c9414bed5dc8c927069e5abb69245521e51a4e81b"}, + {file = "lxml-6.1.3-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:ce1f220114959941170e22b8ad44279f6dee2dcef7591814d01ae805dc058889"}, + {file = "lxml-6.1.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:170773d8a3cdc76259065523ddd978c44f9806e28605f08812e8f86783e44ac6"}, + {file = "lxml-6.1.3-cp38-cp38-win32.whl", hash = "sha256:92d96586376fb79a33474797186bf993250152ee5c32650b67db78d54b92e6f3"}, + {file = "lxml-6.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:d44442effeb8781f392340c5dc8c6716fba41dbeacb82fd4c0f09026fb5ff682"}, + {file = "lxml-6.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:869dfcd4d381cb0ea87085cc4f011b9171b494ef21e76ad8665f6d5e2d1dc8a1"}, + {file = "lxml-6.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6ba4fe5bfbef6811a8e49b3719cde373ad399006c0c1ac184b7297116ecbba5d"}, + {file = "lxml-6.1.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:61116cec57ed69aebc70f37a545eec095339bb829efbdabcfb97c51e9536e158"}, + {file = "lxml-6.1.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e11e885e0704be185867fcf71b904d8f65d7d6877bc121f69870b0d0479ba7b"}, + {file = "lxml-6.1.3-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41e2d428110b408e963b6fb18f9bbf1f5c027b56bd4b498d54556476c0aeb1c3"}, + {file = "lxml-6.1.3-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa9fd1ee2a5dacfc41039ed49ffeeacfa75bafbd255b69f3b578e11897a0e623"}, + {file = "lxml-6.1.3-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:7f75b9b9fec2a9c6b18095c81865580e795b1441c429e42d22fcc82a77f40039"}, + {file = "lxml-6.1.3-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:cc669256d28736f7f3a149df5c380c50ace2692ba3e62203d10656fade4a2145"}, + {file = "lxml-6.1.3-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d077f21f4b16f0471353883748f126f62038760397c107bb9fad2ca94dc0dfb7"}, + {file = "lxml-6.1.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0d12846d6ce434fb3857918eef4315ec9b4769deb020c75828798614bfcfd"}, + {file = "lxml-6.1.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:2b9b1325ca1c2a9a2dbb6eb913ae563313f2082ae60b03210f7e83ee80712274"}, + {file = "lxml-6.1.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:a2e3f70673a1d5b82f38255f777d26cd855bf2092b1436c4867464a7892f9238"}, + {file = "lxml-6.1.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c34ca1dc41bd86d9ff830d5bdf4e4a752bba6c54f7d2707027ce0eabd36084c9"}, + {file = "lxml-6.1.3-cp39-cp39-win32.whl", hash = "sha256:b50343241eb69fd85f7791cf8bcc7b1c4729826b7d59ba2f6b27db29638fa745"}, + {file = "lxml-6.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:0794e04ba343852c6d78e996c58ef4b8e579b4ecc72f8df0d4058bf843b4c96e"}, + {file = "lxml-6.1.3-cp39-cp39-win_arm64.whl", hash = "sha256:0ab2467e405e748d93495fb5568e74044802b8d3ff2b2a1607c3f78c6e982de5"}, + {file = "lxml-6.1.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4b061064b4a2fe8598a466d723d43dbcd5a610a5d5cfe02fb6226f5c17349f75"}, + {file = "lxml-6.1.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8499d464de86fab0f102313cce32a9bed9ab1f06ec813cf025cb790964fbb765"}, + {file = "lxml-6.1.3-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e67324961ac9bbe616cce5100514d2e34d88665aeb07071e8b16eac55d06d94"}, + {file = "lxml-6.1.3-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d12669a2c419b0e8dc423d23dea24bb82f6f9cb829f32e04674b0ba40322a7c"}, + {file = "lxml-6.1.3-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97acecb11cbc411473f15b8d780df06d7a9f3a2aad9aca78364f56640c8fb70e"}, + {file = "lxml-6.1.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f8b9c8ceebae6387d0dc77f7f4dbbfbfc962dba2efbfe6877486075a480726b4"}, + {file = "lxml-6.1.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962"}, + {file = "lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a"}, + {file = "lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167"}, + {file = "lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a"}, + {file = "lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f"}, + {file = "lxml-6.1.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae"}, + {file = "lxml-6.1.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3847e71a78cbbc1aff955dbbbaf2fff12153f611d3162c5beaa3395636cbc2f9"}, + {file = "lxml-6.1.3-pp39-pypy39_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe91993149523aa59941b9e3c90e2eb45f57ad014697aef6c8b13339a59c019e"}, + {file = "lxml-6.1.3-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71532ebf30be0048a45559b4fab15333fbaaf9042f658e878d918ecd0cf09805"}, + {file = "lxml-6.1.3-pp39-pypy39_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1b50797ac246bb2942a04b6c0f69af0667aba7cf7535f39bbb1b3208fd5d128"}, + {file = "lxml-6.1.3-pp39-pypy39_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2bb7d703bed7ac893bf7f40d97b5d9279d35d2ce460624ca28929eab0d5a3d"}, + {file = "lxml-6.1.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:be5346653c0b0e34be96869ff9dbeba23860156f89a2896a64c64fb419260cb6"}, + {file = "lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml_html_clean"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] + +[[package]] +name = "magika" +version = "0.6.3" +description = "A tool to determine the content type of a file with deep learning" +optional = false +python-versions = ">=3.8" +files = [ + {file = "magika-0.6.3-py3-none-any.whl", hash = "sha256:eda443d08006ee495e02083b32e51b98cb3696ab595a7d13900d8e2ef506ec9d"}, + {file = "magika-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:86901e64b05dde5faff408c9b8245495b2e1fd4c226e3393d3d2a3fee65c504b"}, + {file = "magika-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d9661eedbdf445ac9567e97e7ceefb93545d77a6a32858139ea966b5806fb64"}, + {file = "magika-0.6.3-py3-none-win_amd64.whl", hash = "sha256:e57f75674447b20cab4db928ae58ab264d7d8582b55183a0b876711c2b2787f3"}, + {file = "magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1"}, +] + +[package.dependencies] +click = ">=8.1.7" +numpy = [ + {version = ">=1.24", markers = "python_version < \"3.12\""}, + {version = ">=1.26", markers = "python_version >= \"3.12\" and python_version < \"3.13\""}, + {version = ">=2.1.0", markers = "python_version >= \"3.13\""}, +] +onnxruntime = [ + {version = ">=1.17.0", markers = "python_version > \"3.9\""}, + {version = "<=1.20.1", markers = "sys_platform == \"win32\""}, +] +python-dotenv = ">=1.0.1" + +[[package]] +name = "mako" +version = "1.4.1" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.10" +files = [ + {file = "mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617"}, + {file = "mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua (>=4.16)"] +testing = ["pytest"] + +[[package]] +name = "mammoth" +version = "1.11.0" +description = "Convert Word documents from docx to simple and clean HTML and Markdown" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "lxml-6.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:522387e05cd015a81d1dc621fb167fb42b8f629ccd2e8b39de583828f165aae6"}, - {file = "lxml-6.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d86130d70a2557cdf825dffc56255f1f16b83a7bbeab677b4cd040c4c53d8c52"}, - {file = "lxml-6.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08cd52e6487435c75f2da0a5b276beef7fed161681b93ab766e66b954f0c349a"}, - {file = "lxml-6.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:785761d5123f222cd97f2263a510107226fe32ce7aa7824a90616a41c574ace1"}, - {file = "lxml-6.1.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae520f189895c5dd7eeb2b7a372d464da6f4a1ba1d0ecb741b1d4fe4c1f699ac"}, - {file = "lxml-6.1.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83e7510a6dda8df41d1b68b783de2953b3feb55a11dcebf693201ebaa5cc0c4a"}, - {file = "lxml-6.1.2-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:c20fa05d128c463209ef5323ebf33ee1cac6d87cdc3933fd789fd3c101017c8e"}, - {file = "lxml-6.1.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:e7269cc410f3cdf84a66914fc0ef54b1618115c87fb4f9a59a05c5dfc23bece1"}, - {file = "lxml-6.1.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7233a987a101bdf79059014130262a01339094a0a709f175162542f33b55d4e"}, - {file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee23f6599682bd4d48bb757c0633e78774eedfb65a7e52851f9ad182eeeb625e"}, - {file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e062f5ac1255dfa6c98e3e3863ec18bc79d0947d22d08921a3ca60cee40559fd"}, - {file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:cb0cf498efa3204621b3c5576f0accd80ad2ee85575f1cae5d2f98de32c8d9cc"}, - {file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ee7410c98222070fd717ad881ee2a80cc11826b7001b9a5a807155d8918bfc7a"}, - {file = "lxml-6.1.2-cp310-cp310-win32.whl", hash = "sha256:aa224ecc613d411690aa650dbf01daafbe385cd6c67145e80bc5fc01b3a71469"}, - {file = "lxml-6.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1c0173595dc1c25768f42681a1517dcfc74bb18a34695f127931cbd05f4dead6"}, - {file = "lxml-6.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:adbecbfe44a497c742792457b1c27300617967c18c3934d2416023eba8d8c553"}, - {file = "lxml-6.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da6a4f55f0e3308c07354b1ee239c5550afc212f81629a6067db505ace3b667a"}, - {file = "lxml-6.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f4d2c36fd5997d30ff19c29fb93293401d0daaf87512297d47610e6883964b5"}, - {file = "lxml-6.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1d55a614d2f0457b1f7511c1b7bec0db0dcdd4af4d09d226829eb054c647527c"}, - {file = "lxml-6.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:575fef7f30048b744dffb3e4ff64a18cac7dba3fd26efdea5730ade9d1bdeb33"}, - {file = "lxml-6.1.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79b428c3242e63bdacf3b526a34e0b8b26583846fc597da84b8f0c3d5ea446b2"}, - {file = "lxml-6.1.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12ecfea07d767f6accbf30b014e1c477b5eabb13eb4e8c748215efb52c0e314a"}, - {file = "lxml-6.1.2-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:bfcbee8ffff4188f4c6d97eceeff36d8eb983cf838933cbc12ce5f5dd51476c6"}, - {file = "lxml-6.1.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:822d9397033edbe530a13bb1e0091c0e817536b6aba87a9b4ad626ed779ca0bd"}, - {file = "lxml-6.1.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4303f904fb6c41b58dc70743b1d8a470aba6c9897427c48324cff1a95673ddb4"}, - {file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdd35422de747237f451e821766e2b6be3dd2c31955c1ecd7f17984c5b9bb62d"}, - {file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b3ca02ef3b5920b88119c82eb6badfb2d082b1f681d528a856dcce17c8706da8"}, - {file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4bf14db2f0214003ec7f46c4300e2065668fc93e20448c1c95bac2e952072168"}, - {file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2afd1688e372d8eafaa6f56c589399e0a87d086a0c110f6346b0b50f42e67e25"}, - {file = "lxml-6.1.2-cp311-cp311-win32.whl", hash = "sha256:aea814342f6afd20d832937ff8b333cd6506428a39c0c4c70c2380aab1887bfb"}, - {file = "lxml-6.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:b3db5497af55f7a557c95265dd3b91c75dc56364a7b59f258c45fa5576dce058"}, - {file = "lxml-6.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:e8dc3d29f2ed2bbf24c205a86326d6681230ace55abfb3f9d5230f42078ad63d"}, - {file = "lxml-6.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7d506bdba580ecb1a6ad2e2b5c49445e66d3e1f95894885739094393a1aad237"}, - {file = "lxml-6.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12acd337d2821cb8b9247dfe4b7aa2f2769a3df5ae8511b7e550df42b8f4d3c3"}, - {file = "lxml-6.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5078ff51e6316c0f75ea8127c2cd24374747fb351f62fb93d1761f8ae5a04a40"}, - {file = "lxml-6.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9477e14217c212e6023c994a71a1a349db19b0e10fd5bf189666b281ae63b1fd"}, - {file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261d98065326676d7253882db0198d0aa06748d7ee0443367acf10b148273f99"}, - {file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0666943ee1576fa890a6dc6316ef42e8241b5dd56f67bc5475acb2ac298c6ca9"}, - {file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04cf9e3f4ee9cab9d9ba05401bef8668840fa9620fcd4d8e85a2d2fd0b0fa960"}, - {file = "lxml-6.1.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:9429d2371d406344ed1da5b5686d9412e74137c07b0171278368ff704f470ed5"}, - {file = "lxml-6.1.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:eff128ffdc093cc6317955934ad9751105d37ed8dbca3ff4ccd751af6be37185"}, - {file = "lxml-6.1.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ba58574d710b82ead7cbedea01cac3e110bc3ef82d4731519b74a2c11f7cf5e9"}, - {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52f6d4dff133c9778a24e9a2cfc1608930b15869866171aacc5131b5a418a003"}, - {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8807998c1023d1e9d60e02500f90e85a0752dbc0b670989806bba87b82dd5b42"}, - {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2170d0a280c877b6e2dc6738217db947be35dd8cf09ca458b355aa1bab2a9e70"}, - {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c67f3c1278f942e97d8665c2a690324aaea5137de16f056583a21f0ac706177f"}, - {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093fbf547d0f3ca02705381f795a050fbb58988be4aac7f79f99f280c4082313"}, - {file = "lxml-6.1.2-cp312-cp312-win32.whl", hash = "sha256:be365ce8d2d411cf2fb573747684b4fd470fa6224e0094d9d5a21155acc369d3"}, - {file = "lxml-6.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:b97153ca609b434b712ddfb92cd6af101a7045a7724c542258bd4727a344472f"}, - {file = "lxml-6.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:7feb72424f19a893ae4f3373c7aae821b1aacb6076b708915c651f0683a97c49"}, - {file = "lxml-6.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:351318f5c0eb7fcab5b4fdb507c6f88fb2c4b5e67784c7e5911448c91fffb5d4"}, - {file = "lxml-6.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0edde95e4b4278dcc0175eda06dc8aa2631ad9f83ae5dbdbc4f0925e200b0b0"}, - {file = "lxml-6.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8326e24ae6c3a6bfb03fa8b4793f9a5d804c125228aa067f652b0428e31b87c"}, - {file = "lxml-6.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c534ed898413f439b048130011e99a4245ee13d62d431f6b4f7f2484d02a93a"}, - {file = "lxml-6.1.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e37fe49fe2d5aa40a2cb1cc8176673ad7de0d124e6f4a509d9318f5979c7871"}, - {file = "lxml-6.1.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9b52ea73a37fc64aa3357ff8607801d46dd170506d3cf8253a91a1d91639d4f9"}, - {file = "lxml-6.1.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8b9a92652e75e7731309ea51db5dee892eef414ce70a6ec3441e5d36bf5189f"}, - {file = "lxml-6.1.2-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:9088da25ecd609965f838d89fda0465a905b48f4dd90331db9845518f2177372"}, - {file = "lxml-6.1.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:0349321a0537d4fdbebb2af06dd1b64676132c72e2ae250de8cdb58f8c43019c"}, - {file = "lxml-6.1.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b20440e578d269c5e8a722ab602ddd0f0cedb8b080006b3f936da9991a593d3b"}, - {file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7766e525282dd38fd89567311323e441996eb958e8e816d16b38f782e3aecd2a"}, - {file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9221442682c27417f10fe11184ea4cce174b25ab52465570b1f3ee3f85f320fa"}, - {file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75530642d8471327e691ab9b0513a5f9c77f38871014ceda40f51bb51765c0a1"}, - {file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:678e35f1cbca98f55107511ee21a60568535c950f3c2371819bd64504c980d20"}, - {file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c2bae42b3a09f977330a08f4a8fe72aec58c4bdb89069d3fe7272a71d885881"}, - {file = "lxml-6.1.2-cp313-cp313-win32.whl", hash = "sha256:5848f3de6a8de8a93cff9f068134393ff5fa69ac2a04399f7d49cd67c61c348c"}, - {file = "lxml-6.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:6cb0c87421946030b92b558be416852780a912454e3dcba0998e4497c9c588d5"}, - {file = "lxml-6.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:648861c19b775b89ebefa14586f85090b10163367476d77f242c4131c835ce73"}, - {file = "lxml-6.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d50a44113fe6800dcc8a859332b823a4735b1e6ae1b0063882e4cca569ec3e29"}, - {file = "lxml-6.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fa813b0247d0543a563b993ac3dba6168eef59e3a61448432cf5453300c2412b"}, - {file = "lxml-6.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d858e718b94033ab4b67e4a58fe3114c65bae01ae2314a62fb39ae8897ed4324"}, - {file = "lxml-6.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e3b666f57a5d81562f38c766c762416b0f6eb58a00590546911514b48412abd"}, - {file = "lxml-6.1.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26ff164c6629e5c4d11c9e55d5ea3d6eed0be2a420eee1f55cbce6e2c23e231a"}, - {file = "lxml-6.1.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:962c12b51d0b164f12569af225dea57568477e24a845b96eaccbef6c07e4cc03"}, - {file = "lxml-6.1.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47e367dfe341521426692819803e260d0673899c0ff611f14af978d725e2c999"}, - {file = "lxml-6.1.2-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:92c2b366028ac01e90399e6d17734ce6e4f4aeddd8ba75fbaf80ea11d6c6d645"}, - {file = "lxml-6.1.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:7e81fc065ede5d58dd0bf0912025aee1bd04c52c2affd61fdb93226a97ce2fc6"}, - {file = "lxml-6.1.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:633ac039cb32366dd5935868e041e385875c017b8cd54ea56aeee3fe29ca5935"}, - {file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f3194777c0d05945ac91d8594be25d2679d1d826e01e1fc90bae568ff3a547b"}, - {file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1133bd969f2bfcc6b0c0cf7cdf5f2631e62b23fa2471ee8bd44f6ab73554ee9a"}, - {file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1edca8f4a92b94e873093df959f141d388f2141fcad0c47598442fb4730ef57a"}, - {file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8512b3775d68994dd1d6d533161e0a214f2ad9c634659d34a99c98e86c6c3d68"}, - {file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5005c0c9e4d749a76a2ff8bd5918a8bb248df8e08e73a55654b9f79c9cd1e2b"}, - {file = "lxml-6.1.2-cp314-cp314-win32.whl", hash = "sha256:e17e2c30e27f56da5551e7a425888b45f013e940b99ab07d125a1c33f77a4605"}, - {file = "lxml-6.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:87e9673cd8a3445024fe38e7f91b55fa3428437eec9b7a7ff7d81979520c0d2d"}, - {file = "lxml-6.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:878e7c8ada8f92c52f13f35a2ab98ef0adf7fd0211d164fc2af589e4c3cfed63"}, - {file = "lxml-6.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:94162456ed0a64fb1c06915df5bd06af4675ae3966d6048fcb73b0906e0e0222"}, - {file = "lxml-6.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4b0fa7109b1d0bc1747d8241a0853e135eefb1c978685241b544c46937383efd"}, - {file = "lxml-6.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:604f4778632588d7c000e7e19430639dc12fca58b5b6e99edffba7631725ef0e"}, - {file = "lxml-6.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a096d6a5f96b776a5b020cb45c17c545effd2a3b6639e6fa97bc95537600923"}, - {file = "lxml-6.1.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6454d184d556eaf4cb3d6f69e405d21602d6fdcf08b8d57796824275986c6595"}, - {file = "lxml-6.1.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b68f2548259bb04e0b3d5df0c397abe8b0080f5e1ffe4019fb7a8bf01a9339e"}, - {file = "lxml-6.1.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c9cc4b6532abe154dbdebb42aaba8d52c852919591e45067f5b7d46a0405e88"}, - {file = "lxml-6.1.2-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:57188e441ab24f906bd5a5c14eb55363ab51aa6c0de549f3dd320043721cc118"}, - {file = "lxml-6.1.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d0bfd719c254bbe60ea022cff0e6ffb799a6fa7d4d72852cebe0257957b32d68"}, - {file = "lxml-6.1.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be6f87cd224254a8f81324e34cc655508b83f1d70458a1a39857ad2aa9925852"}, - {file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:074a88f70a7360a4a0c5be5d898062cd26f898c25b459efb1bdd43ae700c5a1a"}, - {file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9031f5f01452681abf39fdd65f84a70cb01a7572a1bbf570042e826b1232d07b"}, - {file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:cfeac14425fc7a6fca7864b774d4ee63547926158f4a18c67d77b2c9a948acf1"}, - {file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8ec111ff8067325f85c08aa9c2b26179ec0537bb89c003fde31127139f85f82d"}, - {file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48e912f37c99a297175ba955f55a47c0e1c834b506ef162e52a6e4fe276e6e45"}, - {file = "lxml-6.1.2-cp314-cp314t-win32.whl", hash = "sha256:7c444c3a6e8e75334879980eed96568f0e12064c8b1913424eac1805e976736b"}, - {file = "lxml-6.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7f35ba7667004ecdafebbe08da7c9fa06ee6195275bb7ef7a29ee1901e69519c"}, - {file = "lxml-6.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d117f39b28ab8a330a74abdbe61c2255b51973b238db25fd6c2448de1eb2a02d"}, - {file = "lxml-6.1.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:1e3c67b817867c484794d7fe0d73045d7d0c67460c78a0a1249a9e92266e6a0e"}, - {file = "lxml-6.1.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d3e97ac4353cca3fbbfa829bc0c6a913771573d1c6d46932d4335c46f2b7796a"}, - {file = "lxml-6.1.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:827438bf6c8292d22a409bb7990d7cffce410f33e7664e46ca74d2ecc26975ef"}, - {file = "lxml-6.1.2-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c470d192e27f97842a068cf12a1c1296b20ca716c56a9249715c6654bc192d19"}, - {file = "lxml-6.1.2-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef0b8ba6e13597f681b2b4924ca9c4e8c88420bf0e21d9a9006c757f2fc39d1f"}, - {file = "lxml-6.1.2-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:65c32ddc5d0750129c7b119fb57d48192b76d334c21e6b690d19dfb06b34af79"}, - {file = "lxml-6.1.2-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0aa07065497f191ad26c4b587ce5dbb5a7105285a3789aafd0661750e8bac537"}, - {file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cde6b8db7d2e5135129eb5e74b7b44dd2053aa767cd5023541fccedddc262453"}, - {file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b28842b30c4bc2e6afe137d98a5d2071a62589471e76d053bea55b0e53298af9"}, - {file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:11f529062255209a421ae4de5b1bb36b2f0a2e1a700745e675a4bf4084d13c00"}, - {file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f8b89b3be75a37509602b03f9cfa1a28298d4eed4625748148307aeb907901b7"}, - {file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1a2331da06dd55a8184985306eb2afd72d708283ce7e85d67bba77317b785060"}, - {file = "lxml-6.1.2-cp315-cp315-win32.whl", hash = "sha256:442766b326d9892585a64e8c6c4b5ab81d0e6c0538c9f0fc11a84dc101a5d97f"}, - {file = "lxml-6.1.2-cp315-cp315-win_amd64.whl", hash = "sha256:a7fd1dd6faa3df9dcd8f1765237362cd885ca62cdf77a7c5f5ea383ae5b6048b"}, - {file = "lxml-6.1.2-cp315-cp315-win_arm64.whl", hash = "sha256:054175250531a5fb102d485743ff16412279c93add12385b3b1c3d7b16d8deaa"}, - {file = "lxml-6.1.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:84a2a46b93b789d8acb44cfcb3d967ce9dbe29884ddb93fbb1a33f0e0c8fcd86"}, - {file = "lxml-6.1.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4aced3284e0353c798b060fe2c175eb81410e99b9a7e2ae6951be5333732b111"}, - {file = "lxml-6.1.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47c92dc5167de16e27ace8332454f12ba172dcab04f7a78a9eae14e2e41b6a41"}, - {file = "lxml-6.1.2-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40366c23a938008a3bedfcfd80709b3a857c188b4d710b083e978ef5d2c1c715"}, - {file = "lxml-6.1.2-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c4c6dc1b2485aaa4adfb6ed754f90dddcb2b96a66bbebc9e1ac242b5ce5e818"}, - {file = "lxml-6.1.2-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:3a698fad6f122a9b3e2dc2fb598c1de7329c74a67c7a334c9109a440de2508e5"}, - {file = "lxml-6.1.2-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14879fa5eb2b793c040bbfcb62011aa3015c65d6c9875e063ea98ce2029d51fb"}, - {file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b631174cd2e4d9f8a94ef17f911c6ded10ede93b5e7860dee7bbf85961d321e9"}, - {file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:ceafa5e0536c62a5cd9f65327fa0b57d6f0b0e3435daf2c98a78d0dde7ecbae1"}, - {file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7c482e87cc86bed78a50462560675bc2c348ef72c47596f9b933346d5a8e920e"}, - {file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c0d2dde8a50520efc51644587f0fc4810e3af7d3e029d7af0be93bf39e2b5c"}, - {file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dd7ea3fa47154b9fff90591b961e41b3718bd7fcd5bc2d9bb47e9845c8ace088"}, - {file = "lxml-6.1.2-cp315-cp315t-win32.whl", hash = "sha256:87534cec6ea325435e4adf2326b0cf3110eee9a47abf73652eb155db639c08c6"}, - {file = "lxml-6.1.2-cp315-cp315t-win_amd64.whl", hash = "sha256:4e220a9c297e5d36895d489a08c9a3f1f6193b6414e702c5fb751e4a3767f8d0"}, - {file = "lxml-6.1.2-cp315-cp315t-win_arm64.whl", hash = "sha256:f16a407766bac51c65d605b06d900821751a79aa20e12185f273f14a17180e7b"}, - {file = "lxml-6.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:aebcc6b184c935e1f7091c09124cfe5107b7c2253894ba23ad646828c17e4c3b"}, - {file = "lxml-6.1.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6330cf0ce83f6273ad8ad99bdd25d6ebb3863912f9ac717f96bc8942706e0e26"}, - {file = "lxml-6.1.2-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af6585a466cee2c5a524f7fffc591844bd604a29fdd9cade964f548512b5ef7e"}, - {file = "lxml-6.1.2-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:841630176c15fa5d3c5cd6f755435d3c5540a82e1dd2a7de1799401f92ee6d24"}, - {file = "lxml-6.1.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:215bb3cc4be015ccac3c7d4f25eb7b941f857fe5b02c0e3504cca61f7fb12455"}, - {file = "lxml-6.1.2-cp38-cp38-win32.whl", hash = "sha256:7c687fd8e558c7d169f6f1987b696f37824d3a097f291bffd0ab4a2ea2307dfb"}, - {file = "lxml-6.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:69df1856cb6c065e5bfd23adcc7408bfa6dcf32b0018373a99b0769bd86e2256"}, - {file = "lxml-6.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2dcc69e307e0916c7a0b552212010938d02a664d29b6bda75ab2bc5fa487c861"}, - {file = "lxml-6.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:243ecef7cb7415766dd742336cd5b8361a84c6f297e2773c865b783724cbbe74"}, - {file = "lxml-6.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08f0c9ed7cded07c5e798b17c9c25bbba5d0650c8ff0a7f65f84c634966f0f10"}, - {file = "lxml-6.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8ffb17ec0a8bae18b6628ae40b0896eb264dd285e39a0faa864965c00933b64c"}, - {file = "lxml-6.1.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d78ba560f3dd404d87b1fcc89b2b382d638ea2998431a3b2e5cda0f3ba2da91"}, - {file = "lxml-6.1.2-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2b7fe53abced1fe8bd984a9ab3c8c98bc093ec4f9f543089a8817a493818208"}, - {file = "lxml-6.1.2-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:20134744db7abcbd5232214e767814ef64e5ab57a5b7df93a2bd68b74ef0a6c0"}, - {file = "lxml-6.1.2-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:a02164a8cd3e2dc028918e51af844c934c7a24a0b8f4064368360aa14ad1aac4"}, - {file = "lxml-6.1.2-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec8d09f460fdeb65f9ead9b75941e312def4bcbb23e1f951b7def061eb99501d"}, - {file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1fcfe8481302e6dec07909914b8f3f9e1739ae1615209d4b9e7544325fb699c4"}, - {file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3be94d2464f19e42d8c39a299f356b12f2fd095c28793671eabfcd9db9c76987"}, - {file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:86d93dc3882c283e9aa2124d7d2b50c85579485216a2b3b7f91ba479e31a128f"}, - {file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e613018a5ac66de7abaf1acaae0d7af37a5e1b9bf1ae190a1198b0fdb988ad8"}, - {file = "lxml-6.1.2-cp39-cp39-win32.whl", hash = "sha256:446f1f92c137e0cbb97eb7e932e15315c11a7c86974f43f15e68c9707ac6a9f6"}, - {file = "lxml-6.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:058c79e172926ef524fb3c7c6beea4b55e15886ac99cb0c139ecaac6b375f1e2"}, - {file = "lxml-6.1.2-cp39-cp39-win_arm64.whl", hash = "sha256:5295205fd57510c19a0e46385b516119f3a781d45c2672159bce02949238981a"}, - {file = "lxml-6.1.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:feda2ef68c339987dfb370af3a4b785dbc40f925723fe2365e68e43c2640f85a"}, - {file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9bdc2db9e04538f917bba0242920764dd740649d8df58700d6d687ead4429429"}, - {file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4a16457e330b7099aa5a8e8bfa5d53a33a1672a819fa656157e9e6dc433ac7a4"}, - {file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:614d4c5a34556e369b86cfcc8d0cf71cd0759a3444a464a07a9427ab0f5e3a99"}, - {file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18467b0e9f7f0bc477df69e99829a59ae17fb37d34e5f68399371c7c67be9002"}, - {file = "lxml-6.1.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:351855814dec4ad55ca5f24d0f4b1cdaca7927fe48023a2965351845f3b60cff"}, - {file = "lxml-6.1.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4622c5616683faf63791b349e6c8dad7717412dc5f29f4febe7575f110609a86"}, - {file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:733dfb492ec3dfef8350a5cc896e90d202c5171e791e1609e77563751d69a15d"}, - {file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4618b20f43dc98b49569b1dc822176140ea0f2598d672a6989187ba49bcbfec1"}, - {file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f93bc5e25992f5545709000d840c6cafdbd022781a7a0ed79d58a5633733a4e8"}, - {file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:662432a6103e671d971e06e75ed146d9ff67f39d2c98c2f26613b6057f54eafc"}, - {file = "lxml-6.1.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ba0dfead73be5be9ad0b7fbf9f31ff29c1b1eae858816dfc8d85099d6e4af0d6"}, - {file = "lxml-6.1.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:85690cfc8ed54c4292e36a08bcf984dde7957e653fd6d94f59184244bcc35843"}, - {file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e92e4419cad18d60b14bf18b82152fbae67f4b1128be7d73b172df275554f5d9"}, - {file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:50ee0c360862f4152db835b456e38614f94b674bca2a47bc8de7171ee6ccbbb8"}, - {file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:927f3e1d04dc0906265fc0416c13500363e42cd683bbb8d46911c79b73d26800"}, - {file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f86e23ed610727a7f025ebbff788f22a7956d3f1b24a25bb1d9286fc7b7642b0"}, - {file = "lxml-6.1.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2374235206ec83d4827ad219c93c0f7366b93626eab85392c0ee7c8026649376"}, - {file = "lxml-6.1.2.tar.gz", hash = "sha256:1055241852f2b02068af4a625a5d32c087db193c12251928af2562ecd2239f18"}, + {file = "mammoth-1.11.0-py2.py3-none-any.whl", hash = "sha256:c077ab0d450bd7c0c6ecd529a23bf7e0fa8190c929e28998308ff4eada3f063b"}, + {file = "mammoth-1.11.0.tar.gz", hash = "sha256:a0f59e442f34d5b6447f4b0999306cbf3e67aaabfa8cb516f878fb1456744637"}, ] -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html-clean = ["lxml_html_clean"] -html5 = ["html5lib"] -htmlsoup = ["BeautifulSoup4"] +[package.dependencies] +cobble = ">=0.1.3,<0.2" [[package]] name = "markdown-it-py" @@ -1957,6 +3511,46 @@ files = [ beautifulsoup4 = ">=4.9,<5" six = ">=1.15,<2" +[[package]] +name = "markitdown" +version = "0.1.7" +description = "Utility tool for converting various files to Markdown" +optional = false +python-versions = ">=3.10" +files = [ + {file = "markitdown-0.1.7-py3-none-any.whl", hash = "sha256:4eca912c87c6aa6897284a7f4bf6769a23bccf8544530f5d8b175fbe3797c916"}, + {file = "markitdown-0.1.7.tar.gz", hash = "sha256:4d1f3c69cd43b82288fdc3653686d759dcf355ee7c681aa6a855aed98a1e4f44"}, +] + +[package.dependencies] +beautifulsoup4 = "*" +charset-normalizer = "*" +defusedxml = "*" +lxml = {version = "*", optional = true, markers = "extra == \"docx\""} +magika = ">=0.6.1,<0.7.0" +mammoth = {version = ">=1.11.0,<1.12.0", optional = true, markers = "extra == \"docx\""} +markdownify = "*" +openpyxl = {version = "*", optional = true, markers = "extra == \"xlsx\""} +pandas = {version = "*", optional = true, markers = "extra == \"xls\" or extra == \"xlsx\""} +pdfminer-six = {version = ">=20251230", optional = true, markers = "extra == \"pdf\""} +pdfplumber = {version = ">=0.11.9", optional = true, markers = "extra == \"pdf\""} +python-pptx = {version = "*", optional = true, markers = "extra == \"pptx\""} +requests = "*" +xlrd = {version = "*", optional = true, markers = "extra == \"xls\""} + +[package.extras] +all = ["azure-ai-contentunderstanding (>=1.2.0b1)", "azure-ai-documentintelligence", "azure-identity", "lxml", "mammoth (>=1.11.0,<1.12.0)", "olefile", "openpyxl", "pandas", "pdfminer-six (>=20251230)", "pdfplumber (>=0.11.9)", "pydub", "python-pptx", "speechrecognition", "xlrd", "youtube-transcript-api (>=1.0.0,<1.1.0)"] +audio-transcription = ["pydub", "speechrecognition"] +az-content-understanding = ["azure-ai-contentunderstanding (>=1.2.0b1)", "azure-identity"] +az-doc-intel = ["azure-ai-documentintelligence", "azure-identity"] +docx = ["lxml", "mammoth (>=1.11.0,<1.12.0)"] +outlook = ["olefile"] +pdf = ["pdfminer-six (>=20251230)", "pdfplumber (>=0.11.9)"] +pptx = ["python-pptx"] +xls = ["pandas", "xlrd"] +xlsx = ["openpyxl", "pandas"] +youtube-transcription = ["youtube-transcript-api"] + [[package]] name = "markupsafe" version = "3.0.3" @@ -2057,13 +3651,13 @@ files = [ [[package]] name = "mcp" -version = "1.29.1" +version = "1.30.0" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" files = [ - {file = "mcp-1.29.1-py3-none-any.whl", hash = "sha256:b6310eeb59153300c4ab8b9aec4c52f4819a2d6a8e429eb43d908bed7c783648"}, - {file = "mcp-1.29.1.tar.gz", hash = "sha256:1967ba4c315f7a375146209949f45950d18b0efd2f913d7cf3400bc723ee5f04"}, + {file = "mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0"}, + {file = "mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4"}, ] [package.dependencies] @@ -2098,159 +3692,212 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.10" +files = [ + {file = "more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192"}, + {file = "more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d"}, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +description = "Python library for arbitrary-precision floating-point arithmetic" +optional = false +python-versions = "*" +files = [ + {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, + {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, +] + +[package.extras] +develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] +docs = ["sphinx"] +gmpy = ["gmpy2 (>=2.1.0a4)"] +tests = ["pytest (>=4.6)"] + [[package]] name = "multidict" -version = "6.7.1" +version = "6.9.0" description = "multidict implementation" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, - {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, - {file = "multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505"}, - {file = "multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122"}, - {file = "multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df"}, - {file = "multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db"}, - {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d"}, - {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e"}, - {file = "multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa"}, - {file = "multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a"}, - {file = "multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b"}, - {file = "multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6"}, - {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172"}, - {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd"}, - {file = "multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba"}, - {file = "multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511"}, - {file = "multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19"}, - {file = "multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf"}, - {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23"}, - {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2"}, - {file = "multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33"}, - {file = "multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3"}, - {file = "multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5"}, - {file = "multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df"}, - {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1"}, - {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963"}, - {file = "multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108"}, - {file = "multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32"}, - {file = "multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8"}, - {file = "multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118"}, - {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee"}, - {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2"}, - {file = "multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b"}, - {file = "multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d"}, - {file = "multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f"}, - {file = "multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5"}, - {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581"}, - {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a"}, - {file = "multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2"}, - {file = "multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7"}, - {file = "multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5"}, - {file = "multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2"}, - {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f"}, - {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358"}, - {file = "multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5"}, - {file = "multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0"}, - {file = "multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4"}, - {file = "multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9"}, - {file = "multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56"}, - {file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"}, + {file = "multidict-6.9.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:63c9da87fa6d4284e57d2a6e549dfe8f2938ab578fddab3f8ed9742812c9d0f3"}, + {file = "multidict-6.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3663ce6cb19c5e3123c9aad5907804a42d384f8cd4b67e59291efc9480f8da9a"}, + {file = "multidict-6.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f7f340a4a6ad4dacb0f866143452d794ea32a0d9ddf79d93ecb4c3ac5515d0d4"}, + {file = "multidict-6.9.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf44632149302f7fdcf5baecc277d816ab77a47a268f2392dc5d2d01c56770cf"}, + {file = "multidict-6.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:095b091158c89a03ad1237b4880f857d27628f93c23da1616ee5640011439c53"}, + {file = "multidict-6.9.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3958fa87a161a6b9c6661606784abdd8f1afa21a0d6e99d1b99512e70e6b4f1"}, + {file = "multidict-6.9.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9f99e0eec05aca200b66735a38f8de5c9be03a6afd62ba7f28860203d936a03f"}, + {file = "multidict-6.9.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d98efa7b004b67cb8cca26b218012efa50c7103cf4ae54898f2bbf89a3c62f"}, + {file = "multidict-6.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a23926c06835ad71e6bc755ebc07ad34655847de63c01a5d06b6501997712c4c"}, + {file = "multidict-6.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:06723cd13ace09a0ce1ed010fc9e57cd762e57ee23691d6cdb13b7fcf1a35468"}, + {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:27dee31d6f3a0d2be018b71721adbe24aed9b08bdc69d133c88eead07beee600"}, + {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:90d341b2c64eacef24fc0a35aaabc5227834ce93bb1da416d61601663df29456"}, + {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:986de92227e44b257fef664a69613a4346a090553c186d0ae3bf15ceab447f5e"}, + {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c8da6d6fc78f971ebb9e29563c891a429374861afda55e4692a7962e3822637d"}, + {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8903ce696f0aa9dc301c5621fa0f0e24e985d4e6cc247cd63bc9abcd5a081628"}, + {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bf345dacf96fd9c698ad30c620fbdbe6e1edadf83944203ce5b5688f52e3d141"}, + {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e63e05228c1f1c1bef09c08abd2f97b6fe597c9ca14a1c94bec4e76b4664046c"}, + {file = "multidict-6.9.0-cp310-cp310-win32.whl", hash = "sha256:f25229fddbd6bb3d47f1141c319bead19fa3c9e62790c6d51201564fde05fc2a"}, + {file = "multidict-6.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:05f197459db34dee24ae7e3560affa5c6f93989805899c9bba13358b5116a12e"}, + {file = "multidict-6.9.0-cp310-cp310-win_arm64.whl", hash = "sha256:d4464437dbbd7a5a71a5e86f2a9ec92ccbb8d96706f5a173ca985175f23adaf9"}, + {file = "multidict-6.9.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7e46237ef18c8ee183dde740f0174d6ecf9c26d362b506b5d23fb57bc3634bc0"}, + {file = "multidict-6.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e33fabca25633cf0e4f9dd4495733d09b7f0628fc9424c5b5e58da644fd4ab94"}, + {file = "multidict-6.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e82873b56ed37b328501fb063962299ea6f7fc1caf1a5c47966028824bde0807"}, + {file = "multidict-6.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e2169fdb886288fc6bf9d8e48abca9487f8b3ace1f5ab269b074864d212351e"}, + {file = "multidict-6.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eefec2a6b0381846ce54d88f30de4b3a5c15ff0bddc5264cccba85b710a8785d"}, + {file = "multidict-6.9.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c63013a210a09d4a8f30814b785450f1d0a2aaef8230c1e068e83f3afceb6030"}, + {file = "multidict-6.9.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4fd82f81dd0676059f9bc3a418a79f27feab19486100a255bf8488cccb3e60d3"}, + {file = "multidict-6.9.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:deea26b6979ce46f2fb3b2db9b651e12eb7f94c26a1ee457b79f66ad36a64fd1"}, + {file = "multidict-6.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58a4aee7188676c8317e3131bd844faf70e427979e1f20b8598c61f6cbd8b073"}, + {file = "multidict-6.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eee11705013394ebeeb383c92beb0fc9a3c449ac3382a6ee3aa23b38c6b06c3a"}, + {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:51e77d089f82ed2e288d3a1cbf6cb7ffa927c8055d8ef61d57b27f4d8da557b6"}, + {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:79640bb8746b703971fb16569e7bfa82d90548137cf4094769aa45cbafda9d9a"}, + {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:092214366f729803010a5c260f4a4f44d223c688641dfc27da851588ac762ffe"}, + {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:da469a672bf3d343eb6757d53ffbac36f1a29b93a9fe76558b5d0fd7a7efa599"}, + {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4216a8b4214a1ee4511c551c59d51534637394e9433765fbd8face86db43c532"}, + {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f05f5b3171e23801e72fd818de236be9f239b10ba699f23e43bb9596651f8b34"}, + {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0c69b098c85685ba39efef1d8a9711243df8a4c4b051a35d3cce7f323381f8bb"}, + {file = "multidict-6.9.0-cp311-cp311-win32.whl", hash = "sha256:855be03f39b97e352d54eaa7b14ba2a46846cf14719ddb689cdaaee811462440"}, + {file = "multidict-6.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:a8040dde3fe35ead6b04adf3cd3fdd3bfc7c941505775d230b4501d18227c77b"}, + {file = "multidict-6.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:70b3097b838d9fc336f426c7aa7d45b7881ee9b06a10f4bcffd0dcda0cbd44e5"}, + {file = "multidict-6.9.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fdd484b84d3394e805689c56be3ab1f877ae7ff0eb9ff90a3ee7a755cbebab4f"}, + {file = "multidict-6.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f93c9058a0eceac0df2ce9d4c8823b84786ee598194753c2ca0c100224405e47"}, + {file = "multidict-6.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c7ab60b91e11b25e7682c5cd8763fdd17929ea83f234ba441091f1492e631ea3"}, + {file = "multidict-6.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:640113258c5925a9eed2c12523410b25565ac5df2fa6735fbae88fb09bcdd212"}, + {file = "multidict-6.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2ea72901860ccbe94517421681c60b13533ce03ba2f7bd96293c3a4d16ac4ccb"}, + {file = "multidict-6.9.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:408fac672931f2458be3bc8c89d9facd16dac2aad17c7cee2ca1693eee99f07e"}, + {file = "multidict-6.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:67bed23e9803945b0760650ec3e903af772c49abe869f3bc03c66b2e7649d5ef"}, + {file = "multidict-6.9.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb769ccc72e15d7d441e1a08f169d418376be77cdc387e813129c26b357fe50"}, + {file = "multidict-6.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a5111a2bd824c821a3dd09da29680391b0caaa18fea7761358f4001e6898d1c"}, + {file = "multidict-6.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e991677c4bdc5d9f2e71c74717a4e32cfe98930ca05becdf722b5eae1329d6a"}, + {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb69b724c345420ba49187a17a894f146099f5b2e501df42ddb4452ac8be37fa"}, + {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cff3cff5a725bdb8359962de8d7429aea592d7693dbd197eeabbdfab9b6300e9"}, + {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fca5b74b5909c29041f857c40d51d9273636fb4221cf020e4c452deb1c448a40"}, + {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11e32ccf23cdbfcf8299a6a825930a858ec9a6aa05d6752d0f90f2bdf19489e1"}, + {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ec1e387b1f8a85ae5b94aa8c4e0576912ffa4d31bd0578f24c950d4f05ee476"}, + {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4826f6b56456fb1e98111d7bc20cbdc7fa0a41b1f9ad80ff2dd3f2f5b226fe1"}, + {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e71a072c52c78b7f97cd4611df6cef10977e4f2367cd0654a7626192f931adde"}, + {file = "multidict-6.9.0-cp312-cp312-win32.whl", hash = "sha256:95d339c3b75b4a50c665bdcf8417428cd71c3e5cd48e194cb1d336fcb856beac"}, + {file = "multidict-6.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f06c4da5315a6f709b13408c3e13f3b475f8c559ec7608c3c67062512871235"}, + {file = "multidict-6.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:0db5bf96ec2ce45a8bc7fbbe8a486089969bb2791a66b6789ee3aed0d5dd562e"}, + {file = "multidict-6.9.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:662315f8621b3559268813b11134c1f9633edd4ab5f7b713688a243c13f4026d"}, + {file = "multidict-6.9.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4bef8cb5edea9c9daeb8396a75eeb97f912c3fb3fa408fe659edfecf91e47424"}, + {file = "multidict-6.9.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:75f7fc21ffce9a792cb919f0e0b0b52117bd672f2a55d1929d573b6fc437f374"}, + {file = "multidict-6.9.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bb6be697065cbf31051465f9939d65624573d2219c3da8316db9e7cf5e0f5a7"}, + {file = "multidict-6.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67bcff396d2ad62197c95488a716a88462792b384b5ea6e44bf7c1070eddb8fc"}, + {file = "multidict-6.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:beda95a6bd0a2e2265b941b90ff72543afbeaebb6a450e3a6a010d10d4a2ffca"}, + {file = "multidict-6.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e78870909e9f9e3d672ba99f1eb75130d7e01c42e642e6a70f434df32ca0ee1"}, + {file = "multidict-6.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91092d597fcf0940cd6a59e64ba7156ee22d6899993fd7c5b1897fda71482f8f"}, + {file = "multidict-6.9.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4be612f23990a261060ccd8f15e89dad1f9b7bb0c1021c5c3987f5fc57959391"}, + {file = "multidict-6.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7aaa14f0b9ffa2780c5d3b21da0e9b58778ed47af3369df72e5fd6dc10ff8041"}, + {file = "multidict-6.9.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97555ad30ad20a8eb90fa86522088eebbbd68aba03e56d53f4850b3535c65e61"}, + {file = "multidict-6.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c51fd8d59e72e45c64907bf7f81fbd94f4c8dcd1c8b2f23f4c2d7ede788fcfc3"}, + {file = "multidict-6.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22067e88ff266e6a5dc59114529332692a01cc04b10dbdc2c14ab91217dee819"}, + {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e231de8ce43d4fd10bec4f67f71238ed88a974de30a9f20be7a4ab16c970a988"}, + {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b33e499a7b1f722547d57b4865ed68c03161643b9afe49fe097a0832078d183d"}, + {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1eb7939025bd9289d9dfe3a399102b1642f4fbb105a6af82f284052ee89e97a9"}, + {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:51a08dceed4b42ef25755ee2cbf50df325e90e6b415d00955ffdea2c394ad6c0"}, + {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb847cb4002e725f88ffe29883da8290fd0754fa0461ed8988735e20964a9f65"}, + {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:516fa4817cd070088f616a901380db56189f86daee9da79aada3c9b653f49ed7"}, + {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:254e53be2ec70518bb82dfa9b0c7166baaf2bd0e918bd17b65caaf489d5a5522"}, + {file = "multidict-6.9.0-cp313-cp313-win32.whl", hash = "sha256:9869105ab61db13004f9ed610cd29ce4237b2c609f5f7b8bb8f2339fb9dad17d"}, + {file = "multidict-6.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:85ed0f3c3b01174aea5a8a8fcd456f64c2e9719c0f42c612c2694ecd35974a65"}, + {file = "multidict-6.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:1126782e3c3b1a7ccd990be3d3221709348e4b07d6ecf8b50965a93ae2624145"}, + {file = "multidict-6.9.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:1b34b10b615db5a3ed424b174a362a3cdf06220bd892d03d151a619bb157c4e9"}, + {file = "multidict-6.9.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f7f0b996a20776018a253061a781ba983e7b944e17a0748822026048b8b0bb07"}, + {file = "multidict-6.9.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b612842f7f286c42abc5dcbda1db50e9dbed39720dd15feb07dbc9974bfeaa7c"}, + {file = "multidict-6.9.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:71ee2b3198323cbff7a846538f01599f309dddddba79b5bf5a3dca2e49460ef6"}, + {file = "multidict-6.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d0dc1f9c7dc07d86e5a4ed24f3a55b945106f3e7c70a984ff8dd9267270414ed"}, + {file = "multidict-6.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a26c8672d70d2c12c2f1c4df91fe92422e905efaf1d639bd4d7192868a21e711"}, + {file = "multidict-6.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:186cc28558e3d6921ba797e433cd58ce64eb8b2f553e93f75dccbb9a604b7457"}, + {file = "multidict-6.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebc02af6ac170631774219c26fd63b4404d1e836a1aa92b738a3127eaa2aebad"}, + {file = "multidict-6.9.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62c4ffba9f81a2b17cf081bfe4e6b7eaf17193dc17d5870c01b2763e48efed24"}, + {file = "multidict-6.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a7e2aaa0e9a6413b54cd90fa0fe5944487872a7130a1c07347627974e677a34"}, + {file = "multidict-6.9.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:26e34dbbd6c3438ee19e0db1f5b846a7ca957085d193ec312b488924c3941a0d"}, + {file = "multidict-6.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d811799f821c06883404a171faa880660ef04c2687fc32fdaef22fb645fe80"}, + {file = "multidict-6.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fde003cec43783193360c27de2fbb0c8ea2db1cfebc193c3fc657c436f9d38"}, + {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9bfa4ba6d97ae6d9c1f5336c2abad5690bce1802a040f9aa5fa39cb0b3099c02"}, + {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a776fbaf700549943b647f21479d69191c238c84fb55d5a48eec1b4cf6cf6e00"}, + {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ed04614c47379bcdfa01d3e165149518017ea034cd5f48e203c287cc3d2ebd4b"}, + {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9c65d9912983991f368bd01f993191b1593c90ecde6be990d73add46084d235f"}, + {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4b89b102be78ed286123e75a30efda519e1ef6b9d4a4d01a08213f5e01a18e45"}, + {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e3ed6563ea143214c63bb4452ae5e8787a39226b22101c179fa4d2eda5972267"}, + {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:448c05bd13f8675b3629fbc3fabb9e7bb19a5636a7a3eb677ea25a08d0544a56"}, + {file = "multidict-6.9.0-cp314-cp314-win32.whl", hash = "sha256:2b5db4f2ca34fce55d34b9c66a0eb5d0d01962e2420a1218ba836d8e46aa6ea3"}, + {file = "multidict-6.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:ccf9f7f07e9ab6e24f68ccb2b4da8ded65c1fbc5390ebd897a5fe55cdd4c064f"}, + {file = "multidict-6.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:64ef289ca3c6eaafaa705978686631cb5acc90a92a09bef39c95a810c5f4b804"}, + {file = "multidict-6.9.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f17f485e1837a7d31216be8be991a51265bcfc3cfd2457d5c94ade6086793ad7"}, + {file = "multidict-6.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3fccacf47e756a0f487b2b7e4afc9f167db0770ecc4e887b38ae2aec99dcb510"}, + {file = "multidict-6.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:527655c10fca73fd336cbcd5ccaaba5d8ded1512a3183ed7ebcc1569db5f1de3"}, + {file = "multidict-6.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d3520f774e78dad488c97398e5c7253333baa4eea09785aca56d477c4dc05c7"}, + {file = "multidict-6.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:518a76bff2567fb0941603b9ccecdd830dd2d717605e7a28c1edc9e64688759e"}, + {file = "multidict-6.9.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ff308def558cbbff74ea889ec659d117ac550d4d554467ed6cb3bf6adadac7e6"}, + {file = "multidict-6.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:875a77927cd7bc0bc1d0a0520a7ed2267bb169c5db03cc133eaa746526d4182b"}, + {file = "multidict-6.9.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2182da6ffc9793dd66d7d3632eea2b131f1a1e26eaedd51956845501830d84fe"}, + {file = "multidict-6.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e680a0b07e44b87a975eca63f84860a772667b0037066a6e93d998eb41ad2814"}, + {file = "multidict-6.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5066b7877e1e2ae2c3712c8037eff40a80d4cf35b393b4c42c81f1c4b73b346d"}, + {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fd0ec96e9877cb4dddf7942dffa777837809663f8e55d4f79a2bad06453446b0"}, + {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:98b0738ac1a1593f609a7b67d25e09c7de57c0f91c2879eb01c46b2dae55c0ba"}, + {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0548fdffa634c495b866d89e02ae58a9588cedaeeb972f6ced433e20dee1db5f"}, + {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7d0649ae61a341b9a47a2afab57424bd2a0ce98d36ec170ce9a51ce92c7284f6"}, + {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c6d470d693ff45678b9ab237113546f6dc18918be55b3c852a69ed05ae7404a8"}, + {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ac05ce26d2c902d2eb5afe70f2012d19f3b9510ed5ef054bab27a3d43bc7f39c"}, + {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e82ea3ca4409c20172074f27dc1ba5858174f143d0ee637dd058b8061ff024c6"}, + {file = "multidict-6.9.0-cp314-cp314t-win32.whl", hash = "sha256:d61680c1969b5b1b900fece510077e7732336eae02f68dda02162e04ff6ff321"}, + {file = "multidict-6.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a9af5d28024c1de9e0efe06a64b5a8561e6517daa66788169e60d2c588e15c5"}, + {file = "multidict-6.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:810ae53369d957fcc3e3855612e3a35a39960adaf4b3b1cb4cb4a2e94185bcfe"}, + {file = "multidict-6.9.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:b9bcbd708bb5e1ce8cf9c58908ae9777a90f777406315b046bb10cb3e87f8621"}, + {file = "multidict-6.9.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27ed5aaee62eeff74786a3259de81ada4f2bf84b75122bc06d7d8eadc1be5643"}, + {file = "multidict-6.9.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30ba80aa7ec7340e80140d29f563e33acb21f3756e42df13612f2a26c80c90ba"}, + {file = "multidict-6.9.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c26e56c68bde47f8028265b3829915450c944febc75a1e4d0365b8a75557861a"}, + {file = "multidict-6.9.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:f6705ae71e4b14c693877be3cc09fc62af10197ca08ee08bb57ffce6e0f9c5f4"}, + {file = "multidict-6.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:1fc7b1ce735737b7abf38935928729433ef2c81306dcab7f360ef3c3076bc8e9"}, + {file = "multidict-6.9.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7731a591920d77bd9372a9a8ab3f447129dbf17d29b10928c94005e1606641c"}, + {file = "multidict-6.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69a9295177c33756d7a6f8da0f1db345ac951f2436be6ad4b90f53056526e767"}, + {file = "multidict-6.9.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7b4ae468e341cc84c1ae035ee12f032cc0716f618b1eb417fb14e8454a17c620"}, + {file = "multidict-6.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb9f52b89e339007a06c88cb95068e9d612ce252baaaa5ece85de96e2d51fca9"}, + {file = "multidict-6.9.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69190050f74fe139a7b3759c730e4e87484db9cf74b1786910a24496ab7a40f9"}, + {file = "multidict-6.9.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5f04bbfb064b87455a8012f5829b20201d608cf4b3a1a0e50c7a14173a75109"}, + {file = "multidict-6.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d81d0ec43a73056458e844cf90239d2c3a7aeb4ef05fea8c0f64775d5111847"}, + {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:b8aea3d54f3cc20a557fb951c68294d9aa88d614cf7151aa5629b1d5e4033b74"}, + {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:31fd2961bd754ac3177aae9438c01becc22eae029911f13c558e5767a10e5dc2"}, + {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:04e6f4eddeff93c778a9e7137568284f28856d960275085fcbd87cf77a53a057"}, + {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:319160c73795ce31e86494773e0f12c446d32dae7d1a76f4829007ef69eb80bf"}, + {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:0d7fbd38d52119423ed8c69672ca47eac121062c30c976e00ba732d114774874"}, + {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:981280a946256fc151584fb3b82b99db091fccac81cfecce530585db559ec62c"}, + {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:fb3d1f3c63d49656a83e1604ef3ac573016cf2e62da84d1937f47107d5689c8b"}, + {file = "multidict-6.9.0-cp315-cp315-win32.whl", hash = "sha256:0c5388c41bc7c551548c4ce840349f0708f7ced6df877d82900bac82268160fc"}, + {file = "multidict-6.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:49e47de3823bee4896f1eedc17ce4fc4f42c433bab168ece7911f42e69a4f666"}, + {file = "multidict-6.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:1ac339b944616d4061a9ee9571c89767543db38c19c8c7917a9c08b1462c4800"}, + {file = "multidict-6.9.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:cb6be563dd43b2d4d528afded1878b559ef1c2f45f89e94d273ef515485727f9"}, + {file = "multidict-6.9.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:76f0df301516563085342d9cdad03fc3c0301b36c3cfa8454e49ca73878aa59c"}, + {file = "multidict-6.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:34c00a534e948eb5f8b3c50cacea9daa4fc4dfd503f6edd114bb532f53742c9e"}, + {file = "multidict-6.9.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cce5e33383904e32b58bef7765c38917899a9dc87058f75c421c82e7c11440ee"}, + {file = "multidict-6.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c375408881861294207c1d038a711120776989c5e8c489b251a818cd74d39886"}, + {file = "multidict-6.9.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f95940278766f273b668bbd9dd412ab5dd170ff40fc836b714c38de7852f1fc"}, + {file = "multidict-6.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6009b58d6994881c53f56d2f2010f0b3cd30139c7b14156c2329d00c0d592e8e"}, + {file = "multidict-6.9.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7d5ab831301decfb7c29d1c243823f6ccbb3766117c5957bdc5b9f283355d0f1"}, + {file = "multidict-6.9.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17b74ffc94e63843cf34b7eb874ab1653afac0eda3edfa3de1207db7fc8c0ed9"}, + {file = "multidict-6.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62dc471198ac2f1085f6c6ba9aa5ad3343c0cbac0756d2fbe551659d55e14212"}, + {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:da97c0a501c5da00d9c3af6d481ed97dace148f9030f4f5578534981d58682bb"}, + {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:986f2a23330e8823b389872ae63b83b862a10e63e8cfbd11b42ab8664d1eaa7e"}, + {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ed96992cac94b32a9c3da5870b05967271397a813029d68197939397860b1470"}, + {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:94e767d30be457da0ef134e688cd2b6a438d19b85bf000b1c4a2b9b6f55b4211"}, + {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d1f98df09bc22d298d88dee07eb5365dba1fbf3b41e876a6edb2652a8b3a6ca1"}, + {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c5290038269f7d96037afc47b05b286e4791adfa7046911e2a0483b6220c84fb"}, + {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8858d2ddd0bc21b4aaf189016c95840b29ddf0236a049af70a9cb4379bef9abc"}, + {file = "multidict-6.9.0-cp315-cp315t-win32.whl", hash = "sha256:f90724dade5865b1c9429d9b058f11096e44a7f1f0e0a21eff18d98ef32c22aa"}, + {file = "multidict-6.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:150f1e5bcc14234e99aacc931deafa52713157df947efeecc1a3b7601f2714ab"}, + {file = "multidict-6.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:734c985bbd60e84d0ea523bafe139394e46b8a3f35754a70111dbcbba243f7f4"}, + {file = "multidict-6.9.0-py3-none-any.whl", hash = "sha256:57c2445049f7d8e66306f712868219da7ff7168ef42263dc032401211bf1205c"}, + {file = "multidict-6.9.0.tar.gz", hash = "sha256:d7d32c0543494efbc9394e2b571725071d08e295993486bc9a43f6f89375ee01"}, ] [[package]] @@ -2339,6 +3986,232 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "numpy" +version = "2.4.6" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.11" +files = [ + {file = "numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538"}, + {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47"}, + {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93"}, + {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8"}, + {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6"}, + {file = "numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8"}, + {file = "numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147"}, + {file = "numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698"}, + {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f"}, + {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853"}, + {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a"}, + {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2"}, + {file = "numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45"}, + {file = "numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751"}, + {file = "numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3"}, + {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b"}, + {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089"}, + {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a"}, + {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605"}, + {file = "numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91"}, + {file = "numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359"}, + {file = "numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997"}, + {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20"}, + {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d"}, + {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67"}, + {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd"}, + {file = "numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab"}, + {file = "numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75"}, + {file = "numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096"}, + {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b"}, + {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8"}, + {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402"}, + {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb"}, + {file = "numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1"}, + {file = "numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261"}, + {file = "numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e"}, + {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43"}, + {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e"}, + {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895"}, + {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4"}, + {file = "numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063"}, + {file = "numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627"}, + {file = "numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73"}, + {file = "numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda"}, +] + +[[package]] +name = "obstore" +version = "0.11.1" +description = "" +optional = false +python-versions = ">=3.10" +files = [ + {file = "obstore-0.11.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a8cc4681d45196645b0567cf7259730fcb0edba751ecad8c4c3a6ccc63fabe24"}, + {file = "obstore-0.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3e657c600b90f465ef43d00e6dd921e676a1bec34713dad1fc09ef6715e30658"}, + {file = "obstore-0.11.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:614a36f19963cfefedd52f06c5370c034a6dc5b0a2be2e4602fe6ac8e52e97a6"}, + {file = "obstore-0.11.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:066cb4190e957b172b269affb98bee5a8f5cf6d0949b5b57ffe7f8cfaa540779"}, + {file = "obstore-0.11.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdc2d273b7656d862c7295d2dab45987a2c22197d60f1cad14c8b86c5af0b452"}, + {file = "obstore-0.11.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c71e619d9965375241566beda8358031fdce8cb136310d4f5b71faa88f278ad1"}, + {file = "obstore-0.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32dcdae4207656ef353f949f927342e31757f926c58f014873b2d90c70276cb7"}, + {file = "obstore-0.11.1-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:446dfe0019740da25394917585b8b0c116ae79454a633ee83aeb0c973c8812d2"}, + {file = "obstore-0.11.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1dd9d7ddc10bbf17cea0e84553a665a822878bd199380557a6df2aa1e60ce34"}, + {file = "obstore-0.11.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:75a12f1d0d38fd2923e972e417797d7bf123ff7d13e2b6c5919d2e9a3c6653cc"}, + {file = "obstore-0.11.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ebfb22202530863aaf00ebb9352fd17cdd1066aae50986006d76b10c1e8506e9"}, + {file = "obstore-0.11.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63be447e5fdaf0c31517a66906030148697ab3b1f1611fe03e30125a56199ccc"}, + {file = "obstore-0.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:2eab3231ce66bc08e57b686b795d4a1333133c28b2e50c11f9986311dec69899"}, + {file = "obstore-0.11.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d5c50b755d781efebe4f9c70ee6d00858e44d95f0229b8b5174358f861d9bd7c"}, + {file = "obstore-0.11.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:093152daa5c32b70a032f231bbc6a7eff76dda4285eed5a81d272b4485032dab"}, + {file = "obstore-0.11.1-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b97ee10456e65f166c030b5fbde8380ca508621e23b84efd77aad83afe99315a"}, + {file = "obstore-0.11.1-cp311-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48983a143de69b11de49212caa79b5c39acbff7f592f9e85d156a0133a0e5796"}, + {file = "obstore-0.11.1-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9cb988e03ff963914cf2176aee293eaaff7dcf4efcb905ce6834264b4b0884"}, + {file = "obstore-0.11.1-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97850f68c8417f7167549bdb705c362c825ed470298c6b04faf52258c5e9234e"}, + {file = "obstore-0.11.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a9f3d66dbf3c073dd6b6033c0787ef14edf78d97bff95595d6f6979692da264"}, + {file = "obstore-0.11.1-cp311-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:2ba3bceec4263b3a70eea873abedb82e03da9599f2326e80d6505cab0c10d401"}, + {file = "obstore-0.11.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e956fa8a953b7eb8580658d68cdf37e410356032c17697a06e44d0e8c4084a0f"}, + {file = "obstore-0.11.1-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:159a50d0f4cc53afe6f5c695313bcaf6e92ac81d7847692c8279153483272bfa"}, + {file = "obstore-0.11.1-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:128da07f3a1b9c70159e2b2be9e27f458a9d531d57b1856dd7c4ddb73b4c9937"}, + {file = "obstore-0.11.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:67b8acfbbab960c2cb14c34294a96556caa67fcff15ee77c716d5c1bd1558606"}, + {file = "obstore-0.11.1-cp311-abi3-win_amd64.whl", hash = "sha256:e23ea15cebe5f5be5d11005043d7b5ff56848e39499681ef52f8227bd385bd51"}, + {file = "obstore-0.11.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:73284fc8a9804596baf4d80b55c0e71a6007487bfa28d6924acd85264d5be81f"}, + {file = "obstore-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8a3f93310422b153629af929d9e88d1fc826d5e32a456de4d3a1926a770ae09c"}, + {file = "obstore-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d666690f0a53ae3df4be2c18af6820369a74c9c5b8960e8760f0f8c9a8f15b36"}, + {file = "obstore-0.11.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0844ab75c8413c0af2d0fd2d2f6aa42d166809b6ac4be478151cd946ce7e5d69"}, + {file = "obstore-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:307b5f9d64a7c00cd13371e376c05e3914216bd0818a19ff2ae05bc0135e01dc"}, + {file = "obstore-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f89647953b4ea50bab3f66591f7f462cd454a5d2fbbeefd885716a2c54e13ce"}, + {file = "obstore-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50de3116af69b6f1669cb443cc200ccdc1523a790a41f00854331b48f716cf8f"}, + {file = "obstore-0.11.1-cp314-cp314t-manylinux_2_24_aarch64.whl", hash = "sha256:eae71e1c5944ade976ce8cd1e780bed9b5c31d6d4f89cb7263e8dcff78f6cd8e"}, + {file = "obstore-0.11.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff634b5edbbf76c56ae81397aa41500a66ad0aa48b1856dcc0cf31b159172dc0"}, + {file = "obstore-0.11.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cbe509350d66249fc9e65ece4e7b1855d650f18e477721887128452b95c44b24"}, + {file = "obstore-0.11.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:46122e585f48ab3f2e4fed51401a4e866279a3a3d1aee2372d598ab85ba2c539"}, + {file = "obstore-0.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7275e75228059b2b30772b2ba3e41562a4a92ebdd3a96619d300f98ef152119f"}, + {file = "obstore-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:13bb0b6a40931ab2da93f93ad843865d483d95acec1bd586df79313daa5a50af"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:68adc24a822536148a3c12be0b7cac091239b3f99c0589b16bcdba5a6ccf58e4"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ed098bea084f8d626d91facfebdc8fe49115b96a339465db6fea681635fc7440"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d80248f106bf9a2860a4b21add7d14c95eaa5785c7fe11e56d28a4602c7a07c"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ad616bd597a453dcb225ef6bec2af0c71b24b22c314a4328bcc216f774d6b9a"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a399e7c816e8d7bfe5e992a04e25326bd0779822b65bfd3dbc943b8a62192dc"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6680094928da4587dfd03be4e9c17ce7eaa1ec287f49519c75896819c0767247"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ab26d158a096c750759981f75f279f6568d945b8fe640e0cebfdff8d601044"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:7b1b769fda200cbee559da2100586b3b7810bc9f207fe41b9a00fd749c997799"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:801f57c06df5d81eaff8ed553129cedadad12cf1d76fb7499fd6daa1d996c72b"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:b895230ad67a7b9a2c7dfff7c7c9801570a9dfe71f7e383013f831d35b74ea2b"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:955f2348bd17beb80f3f96bb52b119e61eee99c97d1f0b103aa61a0840b7c862"}, + {file = "obstore-0.11.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3901f539d764cd2ec74c4e900dd461d89d765b2cacf06379ac7b7014e8d132ca"}, + {file = "obstore-0.11.1.tar.gz", hash = "sha256:a5afe8b99e3b20cdc9133be7a1b381259acf0d470029f6b2fc79c3f9947ad436"}, +] + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_full_version < \"3.13\""} + +[[package]] +name = "onnxruntime" +version = "1.20.1" +description = "ONNX Runtime is a runtime accelerator for Machine Learning models" +optional = false +python-versions = "*" +files = [ + {file = "onnxruntime-1.20.1-cp310-cp310-macosx_13_0_universal2.whl", hash = "sha256:e50ba5ff7fed4f7d9253a6baf801ca2883cc08491f9d32d78a80da57256a5439"}, + {file = "onnxruntime-1.20.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b2908b50101a19e99c4d4e97ebb9905561daf61829403061c1adc1b588bc0de"}, + {file = "onnxruntime-1.20.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d82daaec24045a2e87598b8ac2b417b1cce623244e80e663882e9fe1aae86410"}, + {file = "onnxruntime-1.20.1-cp310-cp310-win32.whl", hash = "sha256:4c4b251a725a3b8cf2aab284f7d940c26094ecd9d442f07dd81ab5470e99b83f"}, + {file = "onnxruntime-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:d3b616bb53a77a9463707bb313637223380fc327f5064c9a782e8ec69c22e6a2"}, + {file = "onnxruntime-1.20.1-cp311-cp311-macosx_13_0_universal2.whl", hash = "sha256:06bfbf02ca9ab5f28946e0f912a562a5f005301d0c419283dc57b3ed7969bb7b"}, + {file = "onnxruntime-1.20.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6243e34d74423bdd1edf0ae9596dd61023b260f546ee17d701723915f06a9f7"}, + {file = "onnxruntime-1.20.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5eec64c0269dcdb8d9a9a53dc4d64f87b9e0c19801d9321246a53b7eb5a7d1bc"}, + {file = "onnxruntime-1.20.1-cp311-cp311-win32.whl", hash = "sha256:a19bc6e8c70e2485a1725b3d517a2319603acc14c1f1a017dda0afe6d4665b41"}, + {file = "onnxruntime-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:8508887eb1c5f9537a4071768723ec7c30c28eb2518a00d0adcd32c89dea3221"}, + {file = "onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9"}, + {file = "onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172"}, + {file = "onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e"}, + {file = "onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120"}, + {file = "onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb"}, + {file = "onnxruntime-1.20.1-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:cc01437a32d0042b606f462245c8bbae269e5442797f6213e36ce61d5abdd8cc"}, + {file = "onnxruntime-1.20.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb44b08e017a648924dbe91b82d89b0c105b1adcfe31e90d1dc06b8677ad37be"}, + {file = "onnxruntime-1.20.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda6aebdf7917c1d811f21d41633df00c58aff2bef2f598f69289c1f1dabc4b3"}, + {file = "onnxruntime-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:d30367df7e70f1d9fc5a6a68106f5961686d39b54d3221f760085524e8d38e16"}, + {file = "onnxruntime-1.20.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9158465745423b2b5d97ed25aa7740c7d38d2993ee2e5c3bfacb0c4145c49d8"}, + {file = "onnxruntime-1.20.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0df6f2df83d61f46e842dbcde610ede27218947c33e994545a22333491e72a3b"}, +] + +[package.dependencies] +coloredlogs = "*" +flatbuffers = "*" +numpy = ">=1.21.6" +packaging = "*" +protobuf = "*" +sympy = "*" + +[[package]] +name = "onnxruntime" +version = "1.30.0" +description = "ONNX Runtime is a runtime accelerator for Machine Learning models" +optional = false +python-versions = ">=3.11" +files = [ + {file = "onnxruntime-1.30.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f36c87ec504f4f328e968ee322e4ba9682abe66280b3425625afcbed5333fed7"}, + {file = "onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bac3aee7b40acd805659fd81a2acd6096312d5b0f014c97ddfe75fb4c1644627"}, + {file = "onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fd54b314ea385bcecac69ab431f020ba503e3878dad4ebb645fec5a24b041242"}, + {file = "onnxruntime-1.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:0edd0145a6e3fce8a1276491dc784d615e3c58bcb952c9b4e5c876d5c6a12ad7"}, + {file = "onnxruntime-1.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:16506cfa5d218850f2b46e0edce1b952121f2f84f8e0074dafe15f4787f0c8fb"}, + {file = "onnxruntime-1.30.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:001ed726c9bd5e2bc92faade7d37d889e9606a350b7d5529f0227df2e3bb57fd"}, + {file = "onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6c32a000d5139a38ba9349030b0032e3331acb559d596b22738d9d2b343a2b83"}, + {file = "onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa688e7891a6aa206636fe7372e27ee75fd17713289f6b4fc7b190e0a7de9328"}, + {file = "onnxruntime-1.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3501472571f1b1eee50e017851e7929f5ea37312d2d8c2494a19e8fc58b4a38"}, + {file = "onnxruntime-1.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc4c706f1935ebb62356e6a095b047859badd854482c40560888e95c328ed262"}, + {file = "onnxruntime-1.30.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:05e4fc41711d1f4abd19a9124b5be7a65a506cb2670a7f14b16162ef13c58134"}, + {file = "onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5327cf6aa15a02bad805fac8bd6882a62571e8b72f6f2938a8f37e6bd1966ce9"}, + {file = "onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f"}, + {file = "onnxruntime-1.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:4b63041bd623a9a9ac5e353948436c6fa7f43edd12d6b4a4ebc340bca959ba93"}, + {file = "onnxruntime-1.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:c389b6887fc95e0fcb80e89b156bc2cb18e662c29df55e9326fe64140e7d7b4f"}, + {file = "onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5224ba2b00284cb1c48b3edcd303c109245d66df9ec1b861858fd6de672a38e"}, + {file = "onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3f9e002417f1e3bbb31ed43dafa4f22ca1b2b68832244fdee192fcaa1ae19bca"}, + {file = "onnxruntime-1.30.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8b6169c16a48429890d2f4a0c774ebf54dfe9066a998514aad0518a16d398547"}, + {file = "onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d2184fddb6798136e7c478244391ca82443f5c757f59f15bb9e5ad2da5e03175"}, + {file = "onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:8b611d24db2954545ce6bd9acd4670183cb368e4642450de7a9ab6474eb374ec"}, + {file = "onnxruntime-1.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bdd1752a8502ac1a7ccc6e878d16db6943df574d54ed7f01058a6806ae05be4"}, + {file = "onnxruntime-1.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:83d543843cbd352cfa9996a6c7b92f8a480f114a18f95ff2a1acaf6921e85b6d"}, + {file = "onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:265de607ba6f9814264e1d5d413fa7069d70f48e3049b5e460b3b04bdfcef294"}, + {file = "onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:67ad7f03433b6462c627d0f555dece80e6a26bc71e8542ced35cebd32142d1b7"}, +] + +[package.dependencies] +flatbuffers = "*" +numpy = ">=1.21.6" +packaging = "*" +protobuf = ">=4.25.8" + +[package.extras] +quantization = ["ml_dtypes"] +symbolic = ["sympy"] + [[package]] name = "openai" version = "2.54.0" @@ -2368,6 +4241,219 @@ httpx2 = ["anyio (>=4.10.0,<5)", "httpx (>=0.25.1,<1)", "httpx2 (>=2.7.0,<3)"] realtime = ["websockets (>=13,<16)"] voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +description = "Pydantic OpenAPI schema implementation" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146"}, + {file = "openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d"}, +] + +[package.dependencies] +pydantic = ">=1.8" + +[[package]] +name = "openpyxl" +version = "3.1.5" +description = "A Python library to read/write Excel 2010 xlsx/xlsm files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2"}, + {file = "openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050"}, +] + +[package.dependencies] +et-xmlfile = "*" + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +description = "OpenTelemetry Python API" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef"}, + {file = "opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a"}, +] + +[package.dependencies] +typing-extensions = ">=4.5.0" + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +description = "OpenTelemetry Protobuf encoding" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac"}, +] + +[package.dependencies] +opentelemetry-proto = "1.44.0" + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.44.0" +description = "OpenTelemetry Collector Protobuf over HTTP Exporter" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.52,<2.0" +opentelemetry-api = ">=1.15,<2.0" +opentelemetry-exporter-otlp-proto-common = "1.44.0" +opentelemetry-proto = "1.44.0" +opentelemetry-sdk = ">=1.44.0,<1.45.0" +requests = ">=2.7,<3.0" +typing-extensions = ">=4.5.0" + +[package.extras] +gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"] + +[[package]] +name = "opentelemetry-exporter-prometheus" +version = "0.65b0" +description = "Prometheus Metric Exporter for OpenTelemetry" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_exporter_prometheus-0.65b0-py3-none-any.whl", hash = "sha256:3b3d24b586d0ad9712c7b52b7d19c8a9dfbb318b9b284121b5f95e90ed019367"}, + {file = "opentelemetry_exporter_prometheus-0.65b0.tar.gz", hash = "sha256:2777cbf41c403c119e10f418fce5d645c956b47f673a2ee120285d1d0c6df2d5"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-sdk = ">=1.44.0,<1.45.0" +prometheus-client = ">=0.5.0,<1.0.0" + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.65b0" +description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137"}, + {file = "opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.4,<2.0" +opentelemetry-semantic-conventions = "0.65b0" +packaging = ">=18.0" +wrapt = ">=1.0.0,<3.0.0" + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.65b0" +description = "ASGI instrumentation for OpenTelemetry" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_instrumentation_asgi-0.65b0-py3-none-any.whl", hash = "sha256:3a845a8ebd1c4ef0d8263401e6545f5b219b2feee612090d50f578a87e71fd65"}, + {file = "opentelemetry_instrumentation_asgi-0.65b0.tar.gz", hash = "sha256:892bca67c56522ffa85a8a83cf934d7b50b3be2132e45cbee705825f0a5ba426"}, +] + +[package.dependencies] +asgiref = ">=3.0,<4.0" +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-instrumentation = "0.65b0" +opentelemetry-semantic-conventions = "0.65b0" +opentelemetry-util-http = "0.65b0" + +[package.extras] +instruments = ["asgiref (>=3.0,<4.0)"] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.65b0" +description = "OpenTelemetry FastAPI Instrumentation" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_instrumentation_fastapi-0.65b0-py3-none-any.whl", hash = "sha256:cda2610a0ec1b22d19886f33e4d861e9f5dbb886aeaa3a1263b47aff82c36943"}, + {file = "opentelemetry_instrumentation_fastapi-0.65b0.tar.gz", hash = "sha256:10a3a95486036230413a58fe4fdf4a83fa6bba46918407e527476994bd92bd97"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-instrumentation = "0.65b0" +opentelemetry-instrumentation-asgi = "0.65b0" +opentelemetry-semantic-conventions = "0.65b0" +opentelemetry-util-http = "0.65b0" + +[package.extras] +instruments = ["fastapi (>=0.92,<1.0)"] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +description = "OpenTelemetry Python Proto" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56"}, + {file = "opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3"}, +] + +[package.dependencies] +protobuf = ">=5.0,<8.0" + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +description = "OpenTelemetry Python SDK" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad"}, + {file = "opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b"}, +] + +[package.dependencies] +opentelemetry-api = "1.44.0" +opentelemetry-semantic-conventions = "0.65b0" +typing-extensions = ">=4.5.0" + +[package.extras] +file-configuration = ["opentelemetry-configuration (==0.65b0)"] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +description = "OpenTelemetry Semantic Conventions" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb"}, + {file = "opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60"}, +] + +[package.dependencies] +opentelemetry-api = "1.44.0" +typing-extensions = ">=4.5.0" + +[[package]] +name = "opentelemetry-util-http" +version = "0.65b0" +description = "Web util for OpenTelemetry" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_util_http-0.65b0-py3-none-any.whl", hash = "sha256:7553b606f963097cb190536dc30556cce85090692e471a422fff30ca29b04348"}, + {file = "opentelemetry_util_http-0.65b0.tar.gz", hash = "sha256:84f82d826978bba416ab453460ff6a7391cdc3534c93a786595e4068680016b7"}, +] + [[package]] name = "orjson" version = "3.12.0" @@ -2453,6 +4539,115 @@ files = [ {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, ] +[[package]] +name = "pandas" +version = "3.0.6" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.11" +files = [ + {file = "pandas-3.0.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:085e3786ae6b2e82b406266bce36690f72b9dc1421903ba9296b2981a9fcf586"}, + {file = "pandas-3.0.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d7564d86a94c2eb8ab290b07f63ddaae5c032fa53897c29a2ff2197d43aee8af"}, + {file = "pandas-3.0.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7c0afdcaf6661d795fcefc2f647ddd1136f62cdc153fba177c685d97a87808"}, + {file = "pandas-3.0.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47121f9571503f724c9b93e297ab6254ac99c77adf5e9ed085ea419fd585c258"}, + {file = "pandas-3.0.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:994a79608263fe1c14cc48ffa7300e2b834b7d1cb406ffe96a08828cb0cdd79b"}, + {file = "pandas-3.0.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a3a22e07fe75347eaacc75b0e85297947af4fba6b4aae23916bd8b6828d0bba3"}, + {file = "pandas-3.0.6-cp311-cp311-win_amd64.whl", hash = "sha256:2e5fa32ff162dfdbc280157d664f44d23049ae414725af9676df339c501d82cd"}, + {file = "pandas-3.0.6-cp311-cp311-win_arm64.whl", hash = "sha256:5e75072773c1b2f7cb63faa3a6f562aede11f3976f68ed34cb538bc091a28171"}, + {file = "pandas-3.0.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dac2d65e9087e8e7b5a45fe15c4920911a221df061ab629943ce016489145c7"}, + {file = "pandas-3.0.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9dab635a549e58a053c7b0fa054dc0bd7be22f0ed9a720f4a85d5fb993276172"}, + {file = "pandas-3.0.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3dccb584123b399c07562ac4d62543e90ede49ddf8ce3c13ffc64cbe828c281"}, + {file = "pandas-3.0.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0704044b676496b8350e023b09f174a26772456c974a2b11c36bebb558c9490d"}, + {file = "pandas-3.0.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e7c1905ef02c3d6d43d9dbd5b6ccb4da4870a0b0c821bbc103fbdb6f3ad2707b"}, + {file = "pandas-3.0.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:569e114072b24fc4970c12e2b4bab252671668a40b324318903380cab0254c0c"}, + {file = "pandas-3.0.6-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:2a8fc94be2ee5f1d86f97aacd8cc566f81680b6498e76f3007421bb5d98151bf"}, + {file = "pandas-3.0.6-cp312-cp312-win_amd64.whl", hash = "sha256:3ef908d28590b3f42d7070e7ad8f9b34b442b260b7f3c1afb57e0040c58cdb1b"}, + {file = "pandas-3.0.6-cp312-cp312-win_arm64.whl", hash = "sha256:f4e7c52eb108d752e7592268108fd3e98efd76d83a3125cdd06c621c2e44359b"}, + {file = "pandas-3.0.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ae8073aed8e21d1a7fe263dcdc6840743549722a6738198a0a46000fa9476f2"}, + {file = "pandas-3.0.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:60d81f9e1799b36f3739e7fff44d1fbb2e8fd5a271b3863e03de9715fccda0fa"}, + {file = "pandas-3.0.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:097090508a1dd335013d39106fc10b20f4fd4a171638e47b77d55798ed9dab6c"}, + {file = "pandas-3.0.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e92d9fa834c7d877130027cddc0cad8dcff97c1f6cca26bd6310f847228b658"}, + {file = "pandas-3.0.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b27c8d890e4aa2171437ae2a39de1d215e674158e4865c4023a8b31c932513b2"}, + {file = "pandas-3.0.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8029ec0f1f89e4f985929ce1f6626dabf3140d61a4e9c1215afdab34eaf9a5d"}, + {file = "pandas-3.0.6-cp313-cp313-win_amd64.whl", hash = "sha256:f3ce8a6968045481e91a3990e797e348ce13db45ee164a7095bbc824e26c09dd"}, + {file = "pandas-3.0.6-cp313-cp313-win_arm64.whl", hash = "sha256:cc39303913e2ea129915670de5d1c9fbd647f543bb72e5543bac8baa94e9e42f"}, + {file = "pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1"}, + {file = "pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729"}, + {file = "pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34"}, + {file = "pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1"}, + {file = "pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de"}, + {file = "pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c"}, + {file = "pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553"}, + {file = "pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c"}, + {file = "pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514"}, + {file = "pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60"}, + {file = "pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541"}, + {file = "pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965"}, + {file = "pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7"}, + {file = "pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44"}, + {file = "pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630"}, + {file = "pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a"}, + {file = "pandas-3.0.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:50c44cbf5820b6b91a5f74aae04972472aefadd3cd9fbd1010409d85528bd570"}, + {file = "pandas-3.0.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:eb6900de08ac85f93ac4948aa6b80842eba555875337b8359035ac9c43e92d34"}, + {file = "pandas-3.0.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e25e2e1adee99ddfada6f7206a79ae8e9c8a8861b0e3eaaba165006d3eef18e"}, + {file = "pandas-3.0.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ff44b2cb51cbd691c91f92c4ea6c71e34003f239ebd67c2e857dc898466b49c"}, + {file = "pandas-3.0.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5edd0a7abb0986ecce1ac81f56d99b6763f86aa6946dceb6c661224f90af5a19"}, + {file = "pandas-3.0.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1bcb3e9ed29e74a7439cedff9e2aefd3ea65de84d7de9ccb6c194192541bd60e"}, + {file = "pandas-3.0.6-cp315-cp315-win_amd64.whl", hash = "sha256:253e12cb9081b0afbac607920f6142975966bc315135e09de275fdbaa415d2de"}, + {file = "pandas-3.0.6-cp315-cp315-win_arm64.whl", hash = "sha256:97274c9adf6255bb48c620cd6959805efa7f09ea2167f0e0ae006a448cd2fca7"}, + {file = "pandas-3.0.6-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:265f562fdd1079f69f3de96dd425c3405224038c0af4f920c54bd240ee2c4640"}, + {file = "pandas-3.0.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c6e4aae3e9bea26c6c9a20d88d96c86ec4a99b4db5fd516bcb4e829ab2c0ee36"}, + {file = "pandas-3.0.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a77a1a44e4d88f1c6a2a64d3eb12efec8420875722e14279800b173a7c7c2804"}, + {file = "pandas-3.0.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86fa853a12e0b70927e2b1ee00d56d2224ec9cbb4b9d58348b5ad52d2f21150e"}, + {file = "pandas-3.0.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c826e9babb7790142c399f58599d8de679bea059d7b39c5b6efa2096fac37266"}, + {file = "pandas-3.0.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8fe77b408d82e2615674dfed62533b95e18a03610573877422aada4f625d4947"}, + {file = "pandas-3.0.6-cp315-cp315t-win_amd64.whl", hash = "sha256:83e91d15738d7783c050197cef2f2cf82fc6353dae9865aa87ed1fa16aa4d55a"}, + {file = "pandas-3.0.6-cp315-cp315t-win_arm64.whl", hash = "sha256:963ca21199097a84c7827c4678b04e30833084fbf8ef44fde3fa7180a29f8fa0"}, + {file = "pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10"}, +] + +[package.dependencies] +numpy = {version = ">=1.26.0", markers = "python_version < \"3.14\""} +python-dateutil = ">=2.8.2" +tzdata = {version = "*", markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\""} + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)", "beautifulsoup4 (>=4.12.3)", "bottleneck (>=1.4.2)", "fastparquet (>=2024.11.0)", "fsspec (>=2024.10.0)", "gcsfs (>=2024.10.0)", "html5lib (>=1.1)", "hypothesis (>=6.116.0)", "jinja2 (>=3.1.5)", "lxml (>=5.3.0)", "matplotlib (>=3.9.3)", "numba (>=0.60.0)", "numexpr (>=2.10.2)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "psycopg2 (>=2.9.10)", "pyarrow (>=13.0.0)", "pyiceberg (>=0.8.1)", "pymysql (>=1.1.1)", "pyreadstat (>=1.2.8)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)", "python-calamine (>=0.3.0)", "pytz (>=2020.1)", "pyxlsb (>=1.0.10)", "qtpy (>=2.4.2)", "s3fs (>=2024.10.0)", "scipy (>=1.14.1)", "tables (>=3.10.1)", "tabulate (>=0.9.0)", "xarray (>=2024.10.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)", "zstandard (>=0.23.0)"] +aws = ["s3fs (>=2024.10.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.4.2)"] +compression = ["zstandard (>=0.23.0)"] +computation = ["scipy (>=1.14.1)", "xarray (>=2024.10.0)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "python-calamine (>=0.3.0)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)"] +feather = ["pyarrow (>=13.0.0)"] +fss = ["fsspec (>=2024.10.0)"] +gcp = ["gcsfs (>=2024.10.0)"] +hdf5 = ["tables (>=3.10.1)"] +html = ["beautifulsoup4 (>=4.12.3)", "html5lib (>=1.1)", "lxml (>=5.3.0)"] +iceberg = ["pyiceberg (>=0.8.1)"] +mysql = ["SQLAlchemy (>=2.0.36)", "pymysql (>=1.1.1)"] +output-formatting = ["jinja2 (>=3.1.5)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=13.0.0)"] +performance = ["bottleneck (>=1.4.2)", "numba (>=0.60.0)", "numexpr (>=2.10.2)"] +plot = ["matplotlib (>=3.9.3)"] +postgresql = ["SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "psycopg2 (>=2.9.10)"] +pyarrow = ["pyarrow (>=13.0.0)"] +spss = ["pyreadstat (>=1.2.8)"] +sql-other = ["SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)"] +test = ["hypothesis (>=6.116.0)", "pytest (>=8.3.4,<9.1)", "pytest-xdist (>=3.6.1)"] +timezone = ["pytz (>=2020.1)"] +xml = ["lxml (>=5.3.0)"] + +[[package]] +name = "pathable" +version = "0.6.0" +description = "Object-oriented paths" +optional = false +python-versions = "<4.0,>=3.10" +files = [ + {file = "pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566"}, + {file = "pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58"}, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -2469,24 +4664,184 @@ hyperscan = ["hyperscan (>=0.7)"] optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] +[[package]] +name = "pdfminer-six" +version = "20260107" +description = "PDF parser and analyzer" +optional = false +python-versions = ">=3.10" +files = [ + {file = "pdfminer_six-20260107-py3-none-any.whl", hash = "sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9"}, + {file = "pdfminer_six-20260107.tar.gz", hash = "sha256:96bfd431e3577a55a0efd25676968ca4ce8fd5b53f14565f85716ff363889602"}, +] + +[package.dependencies] +charset-normalizer = ">=2.0.0" +cryptography = ">=36.0.0" + +[package.extras] +image = ["Pillow"] + +[[package]] +name = "pdfplumber" +version = "0.11.10" +description = "Plumb a PDF for detailed information about each char, rectangle, and line." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pdfplumber-0.11.10-py3-none-any.whl", hash = "sha256:7741ea81bf165b474b153e6789d10d18e06b6ddcf3ec84289c3ef2fed6802580"}, + {file = "pdfplumber-0.11.10.tar.gz", hash = "sha256:b95b2d28c66efb0a794a83b88c6c6aea5987532a445d20a1cbcfa657022e6e57"}, +] + +[package.dependencies] +"pdfminer.six" = "20260107" +Pillow = ">=12.2.0" +pypdfium2 = ">=5.9.0" + [[package]] name = "pg0-embedded" -version = "0.15.1" +version = "0.15.2" description = "Python API for pg0 - embedded PostgreSQL" optional = false python-versions = ">=3.8" files = [ - {file = "pg0_embedded-0.15.1-py3-none-macosx_14_0_arm64.whl", hash = "sha256:b014d6bb49246ebe8ca2ab25503e366aeccfa87b89760ca7a7e701de8c148471"}, - {file = "pg0_embedded-0.15.1-py3-none-macosx_15_0_x86_64.whl", hash = "sha256:3eef6291a7fda4332d760c0a417f2e1930cfb4fc80c1b98b10583b0f958cc3e1"}, - {file = "pg0_embedded-0.15.1-py3-none-manylinux_2_35_aarch64.whl", hash = "sha256:96e1b0867421e3553e34b39c7492b2301aff34f85eeef7a553d45601fd2f4d77"}, - {file = "pg0_embedded-0.15.1-py3-none-manylinux_2_35_x86_64.whl", hash = "sha256:24336e666bd3c954cb70fac2f93db1f928116aa92cbd0aba9320f789acfbb263"}, - {file = "pg0_embedded-0.15.1-py3-none-win_amd64.whl", hash = "sha256:35a245d29452915a314ecb655f33a35d8d881583d6c00373ef616a47a84c639f"}, - {file = "pg0_embedded-0.15.1.tar.gz", hash = "sha256:3fef3f250f722e49f0a762d0bcf4f776948a13c852dfae505c2e3ba512391cc5"}, + {file = "pg0_embedded-0.15.2-py3-none-macosx_14_0_arm64.whl", hash = "sha256:cbfb988ef6ad39009835f7d1f734b6df5bc05db944050f932e08465bcc6bfe2d"}, + {file = "pg0_embedded-0.15.2-py3-none-macosx_15_0_x86_64.whl", hash = "sha256:27a4a6bba5bbe536fd948eab121686946ddce5fa7b2a5ecfdbb70ee9db0169bc"}, + {file = "pg0_embedded-0.15.2-py3-none-manylinux_2_35_aarch64.whl", hash = "sha256:aaf89e3c4cfe8affb87910f13dd1e8604596f5f3da7efff5a5eb812bd8b4437c"}, + {file = "pg0_embedded-0.15.2-py3-none-manylinux_2_35_x86_64.whl", hash = "sha256:c55f43e36de626d4d1cd1ea2f8897e17779c3b5a38cba0107329a149dddfae18"}, + {file = "pg0_embedded-0.15.2-py3-none-win_amd64.whl", hash = "sha256:1b47c938991243d938e28c7e1998c1298637fdb15c54ada6d853e02769d50d3e"}, + {file = "pg0_embedded-0.15.2.tar.gz", hash = "sha256:f7181c10aa4db55d61fdc85eca927fb0bd0cb49b1fd5e9898f1606df53757fd4"}, ] [package.extras] dev = ["pytest (>=8.0.0)"] +[[package]] +name = "pgvector" +version = "0.5.0" +description = "pgvector support for Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f"}, + {file = "pgvector-0.5.0.tar.gz", hash = "sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74"}, +] + +[[package]] +name = "pillow" +version = "12.3.0" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +files = [ + {file = "pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a"}, + {file = "pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7"}, + {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f"}, + {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec"}, + {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468"}, + {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed"}, + {file = "pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1"}, + {file = "pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb"}, + {file = "pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f"}, + {file = "pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756"}, + {file = "pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6"}, + {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd"}, + {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd"}, + {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c"}, + {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5"}, + {file = "pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b"}, + {file = "pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a"}, + {file = "pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26"}, + {file = "pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965"}, + {file = "pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7"}, + {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9"}, + {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91"}, + {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c"}, + {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df"}, + {file = "pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f"}, + {file = "pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09"}, + {file = "pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec"}, + {file = "pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66"}, + {file = "pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35"}, + {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65"}, + {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3"}, + {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a"}, + {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e"}, + {file = "pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f"}, + {file = "pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8"}, + {file = "pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930"}, + {file = "pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8"}, + {file = "pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0"}, + {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321"}, + {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b"}, + {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198"}, + {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130"}, + {file = "pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a"}, + {file = "pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d"}, + {file = "pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838"}, + {file = "pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e"}, + {file = "pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17"}, + {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385"}, + {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c"}, + {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d"}, + {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931"}, + {file = "pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7"}, + {file = "pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c"}, + {file = "pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c"}, + {file = "pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f"}, + {file = "pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701"}, + {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace"}, + {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4"}, + {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39"}, + {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71"}, + {file = "pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827"}, + {file = "pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5"}, + {file = "pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658"}, + {file = "pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf"}, + {file = "pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64"}, + {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e"}, + {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777"}, + {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1"}, + {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9"}, + {file = "pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8"}, + {file = "pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418"}, + {file = "pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a"}, + {file = "pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"] +xmp = ["defusedxml"] + +[[package]] +name = "platformdirs" +version = "4.11.11" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +files = [ + {file = "platformdirs-4.11.11-py3-none-any.whl", hash = "sha256:972ea6b2b387155a536226a0750e46923d731d459a387c4a255c583ed2f64547"}, + {file = "platformdirs-4.11.11.tar.gz", hash = "sha256:b0befe8a90759e4a9a8b9820d434ae226a6549063210b596da0038a7a05aede4"}, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2504,282 +4859,489 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "primp" -version = "2.0.0" +version = "2.0.1" description = "HTTP client that can impersonate web browsers" optional = false -python-versions = ">=3.10" +python-versions = ">=3.10" +files = [ + {file = "primp-2.0.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4296ae43a8660bcb1dfc1570ed07dc9f8ab64f11fdebf3272532411b7fe321ef"}, + {file = "primp-2.0.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca21c764f17ba42dde38c29d6a1f01970d39fa5f4793b0fe702006bd71b7a16a"}, + {file = "primp-2.0.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa85a55b1c53ef8c2d14f1c61f0b7ab0ff300b349b2768a52d6c2f3f3f9ca80c"}, + {file = "primp-2.0.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9edc6d2f2fd30d2ddf8c093bf533a3e99ae1385a1d2af15baaa299fb55310fb8"}, + {file = "primp-2.0.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24d3ffeb054c23c7588d0240b5488d960f669398d1ab4cb3873158322bdf821d"}, + {file = "primp-2.0.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0beec080cb61044bd8b2eb0e3ca60f15f2df8ced9f2fbc6f1760855c20aed42a"}, + {file = "primp-2.0.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7be373adfded677a9092ae2743873d5c8a9573148d617a189d26715f7d8ea5"}, + {file = "primp-2.0.1-cp310-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:acc8b31f7fcc241b8ecb94069efea611f4f451d90f1798684c379d05e108b2c0"}, + {file = "primp-2.0.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5f8d170c69b4afbe61d854b3f0cf27a0c0e557f0e54ebe595dad4db0f72127d"}, + {file = "primp-2.0.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:706c843c86162d431c5a2051b8b41e16c7c51bca6bd60d139d7614609463a6c5"}, + {file = "primp-2.0.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:415aa6bb1b998ace5a456734df95755b5342b73fa0a6babbcb3ca471c83b9dbe"}, + {file = "primp-2.0.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7f494686da2991212f5607d125c32cb8da72a92e179d497c7d2ffeba21abff1e"}, + {file = "primp-2.0.1-cp310-abi3-win32.whl", hash = "sha256:1cb429afd3a5ea98c625292f3c6581b0ccc0d398d3307603cce25ec140dfd671"}, + {file = "primp-2.0.1-cp310-abi3-win_amd64.whl", hash = "sha256:0e27f3e233cf34cae6cbc8158af58b93fb0a87ceb1802b4611c7a14a2d822cdc"}, + {file = "primp-2.0.1-cp310-abi3-win_arm64.whl", hash = "sha256:dea9370fcf6624725f564f9b9cf4fec3c127f86b7494196d03343a835fe3dee4"}, + {file = "primp-2.0.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:656ea4ff0d45bbd119394a6834a367e34192d75cb7a5945cfc2f1cbb266b1be4"}, + {file = "primp-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:31b3cd957f02dc49e5e9d9cbadd809747872caebfaf093dc34775a7f866a3e65"}, + {file = "primp-2.0.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b8af35eb64d61291b105479245c89ed1231a5fff1e9b75870515892ffabf054"}, + {file = "primp-2.0.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f659e6479073c4693195f0f43b7a96ae0afd353f6f057754f80be016eea6f20"}, + {file = "primp-2.0.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba278a9af63981f2aece0d98fec9d6cc5a918943eb56e4f4df2b3ca90dab787"}, + {file = "primp-2.0.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bedb42ea9188dd571db46d93f0b994d2e8e9271d2ddd55ee0ab7ac2844742bed"}, + {file = "primp-2.0.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:888ba0708e518acad84bfc0a8bb274ec7ae48655a95afdb9c8184bd88cc6d7ab"}, + {file = "primp-2.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:71fa07394c0084940d86c9f441bc2f05d7f8951a944bde315bebb6eec9b718d7"}, + {file = "primp-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c8f2b2eaacddf2bdff7b1b5f4219249d06246e577432fcb9b9babbc65146ff32"}, + {file = "primp-2.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:934696c74b8a88a7dbb40b3bb54a182b0c9782427f036035cb21bfc0cf7e24a9"}, + {file = "primp-2.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e5f2e7b9fe557a440a929746a318074fd9989be318ce75411d01f1f3ed7bc85d"}, + {file = "primp-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2b4970ab274deaa13224777fb52b8745523293c23566a6c44fa3fbd61e47c183"}, + {file = "primp-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:0440d84854d1f9218277eef2c688a1774408e0d8a3805077eb6db432a4fa7970"}, + {file = "primp-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f0806c7653ee05bbe3c7b28f97f19bd5d763d7da1366026d5cb85cd8713f5c33"}, + {file = "primp-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a26651747b21efdff1ff986ee3e98ec3b349ce84b01b22226ce0b7a967041f01"}, + {file = "primp-2.0.1.tar.gz", hash = "sha256:82ba17b077bef19a189d9ec8d77ca632496cb444e0f4fa37e27e90041cf0da8f"}, +] + +[package.extras] +dev = ["certifi", "mypy (>=1.14.1)", "pytest (>=8.1.1)", "pytest-asyncio (>=0.25.3)", "ruff (>=0.9.2)", "typing-extensions"] + +[[package]] +name = "prometheus-client" +version = "0.26.0" +description = "Python client for the Prometheus monitoring system." +optional = false +python-versions = ">=3.9" files = [ - {file = "primp-2.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5de7c1a2c3437394d77dca87841796a0cb704bf00e764dcbd2f7c073a11cc969"}, - {file = "primp-2.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c55f8ab86b7cc076b34ee0316ded6c30e9932ea010aeae3f8fe5cf30d9ecccf7"}, - {file = "primp-2.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd191d6f381eb814e1a7cb34b7eb5f47df81e73abdaecd8a99a8635a866357a3"}, - {file = "primp-2.0.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7c48b027246e09540ad645536d28966f618e09c6a4dff148db4c221ccf858d8"}, - {file = "primp-2.0.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9179d4d7839cf0f912700d201af239b1a87ca22225101e2a43d1a34c2a46c95"}, - {file = "primp-2.0.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d46cea402c5c0e70d65c237d946d41b46ef1a550fe3053ad6b66a3776decb759"}, - {file = "primp-2.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e8e16a3efc6a523da721fa56a7e4eab8ee62c36db910fb62782999398320d0a"}, - {file = "primp-2.0.0-cp310-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:e8d9354b078b8dc891d2546bd8e9109aa81f1351609262707f4c7cd2db5a7876"}, - {file = "primp-2.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:75960909d205a88e234d4e50fbd4c7c36d8235ef51b0c085da67bb171900cadd"}, - {file = "primp-2.0.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5ccc2f3b333254d80a63f291b6d3e122a3eebe445833995b136ed1b5ae5c66e7"}, - {file = "primp-2.0.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:2bd730ca8d86912924a3e4b775ad7befbe2115e26d0cf5b3c214d09dee80cfba"}, - {file = "primp-2.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:196896fa89c6b1eaa11331b14fe2cf539b9564b5e3398df8352c71ce30c46d84"}, - {file = "primp-2.0.0-cp310-abi3-win32.whl", hash = "sha256:0776fd400adcc940e47bb70896fb474f6607ffa545f5f7107ccda8d055fa02c9"}, - {file = "primp-2.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:7f205260fd41a0a409157deec95e6c4ec670476a79d9b4810d4c767ee7f9e49e"}, - {file = "primp-2.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:caf90ecc042a12b18414e4110ce767f4f1b0cfbc70310c5df39af948cb3eb516"}, - {file = "primp-2.0.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2ef2652eb2fd0072e474452663ddc790531f337dafed280a62a43a36f95d2c76"}, - {file = "primp-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fdc9abaf01b82e0e4b5ea925273bf690c31338c3bb7bc28f64420bf93f66647b"}, - {file = "primp-2.0.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b88af1baeaa70fb168a9cd8567cf40396b4d023d39895b4bab25b3e7dc6ddb20"}, - {file = "primp-2.0.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:db6a9efabcef034918e0ee92a78c21c2ccd93bdb994550c49f3c0edf207a0bc4"}, - {file = "primp-2.0.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e820ed0573d67b5d495252dcf29e6dba14c279453c5c2134955eb2b0fd3d258"}, - {file = "primp-2.0.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:819f1480be4efc1d0dca46fac7b9a3ec40fb0b89bc11932165b5abc7872dae00"}, - {file = "primp-2.0.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c97a87afd27c490b5384433748bc664ecd358a8d0a688abf48716a6baf03050"}, - {file = "primp-2.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:ca4efea30417cf1119aac732ead82fc649af40978d0c989a9c280e901f6d0b3a"}, - {file = "primp-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0ea78a7a2fc5d48a825fa73182911b956bcb58940297cbd60958963ec30375d4"}, - {file = "primp-2.0.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0f309642cff4e9f966fb8f312bf19239df04c7698b1b4ae142285528d08c4ddd"}, - {file = "primp-2.0.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:422001e516110a3b2d44b106ac65d248c431fb98e1d6cad034e0a6ee8f1d6eb2"}, - {file = "primp-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1abcdcd5d0ef7b918bdd6e56ce07baeba5b2fd136e9df1fe9f9443c35a6af9ec"}, - {file = "primp-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:8756ae0df3dda65591ef478e73c588588a4a07919ee5c8d871fdd4e78fefb357"}, - {file = "primp-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a83bbfd798c1a9c2c7f1c8b02bbfb38f91fd63ac9072add967915e2463d68aa0"}, - {file = "primp-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5dfad2f926451a64bcc3a6d84e76593ec82840f77c5500d080e98387c7a018b8"}, - {file = "primp-2.0.0.tar.gz", hash = "sha256:714ec75081b7a84f63d83f966eb36649b9a8ba93625113b142400c317f6e83c5"}, + {file = "prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6"}, + {file = "prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b"}, ] [package.extras] -dev = ["certifi", "mypy (>=1.14.1)", "pytest (>=8.1.1)", "pytest-asyncio (>=0.25.3)", "ruff (>=0.9.2)", "typing-extensions"] +aiohttp = ["aiohttp"] +django = ["django"] +twisted = ["twisted"] [[package]] name = "propcache" -version = "0.5.2" +version = "0.5.4" description = "Accelerated property cache" optional = false python-versions = ">=3.10" files = [ - {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b"}, - {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c"}, - {file = "propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb"}, - {file = "propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e"}, - {file = "propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e"}, - {file = "propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b"}, - {file = "propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d"}, - {file = "propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274"}, - {file = "propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe"}, - {file = "propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d"}, - {file = "propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5"}, - {file = "propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78"}, - {file = "propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959"}, - {file = "propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7"}, - {file = "propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511"}, - {file = "propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660"}, - {file = "propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66"}, - {file = "propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b"}, - {file = "propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f"}, - {file = "propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0"}, - {file = "propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82"}, - {file = "propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab"}, - {file = "propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba"}, - {file = "propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a"}, - {file = "propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf"}, - {file = "propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144"}, - {file = "propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9"}, - {file = "propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42"}, - {file = "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476"}, - {file = "propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a"}, - {file = "propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031"}, - {file = "propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42"}, - {file = "propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84"}, - {file = "propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a"}, - {file = "propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117"}, - {file = "propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098"}, - {file = "propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4"}, - {file = "propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e"}, - {file = "propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7"}, - {file = "propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d"}, - {file = "propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4"}, - {file = "propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d"}, - {file = "propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757"}, - {file = "propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f"}, - {file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d"}, - {file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa"}, - {file = "propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94"}, - {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164"}, - {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f"}, - {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c"}, - {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc"}, - {file = "propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568"}, - {file = "propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191"}, - {file = "propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7"}, - {file = "propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96"}, - {file = "propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999"}, - {file = "propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e"}, - {file = "propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539"}, - {file = "propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e"}, - {file = "propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979"}, - {file = "propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80"}, - {file = "propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825"}, - {file = "propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4"}, - {file = "propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0"}, - {file = "propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c"}, - {file = "propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0"}, - {file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb"}, - {file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078"}, - {file = "propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa"}, - {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917"}, - {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe"}, - {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03"}, - {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335"}, - {file = "propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2"}, - {file = "propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821"}, - {file = "propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370"}, - {file = "propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6"}, - {file = "propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe"}, - {file = "propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427"}, + {file = "propcache-0.5.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b77c313314524ca9c38fbd70f73515d04597ac58c40c939bc0e71eeb4abff680"}, + {file = "propcache-0.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8f911c395cef73c510bac566da9507bb6a43e7763d0c79138dc60ee53f11207e"}, + {file = "propcache-0.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d83b12902eb8bce151259c86c03ba746600b2d994543de46e370cecf96c452f2"}, + {file = "propcache-0.5.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9281e922c072158c91974d4589f1dbe0fee6d467f284c28e463f9f5a4d933f4"}, + {file = "propcache-0.5.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9f3551b8a35c1df3e7ea4d2d86edee15f0dde1bddd434a71744048683544d0ef"}, + {file = "propcache-0.5.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ec6a85f424afa8d23e0d9a094e5dbb6eda01da91c92b9183cd433768247ffc97"}, + {file = "propcache-0.5.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f574e460d1c8a08384a016fdb09ccf3543433263ed6b2f97104f979e64ea57c2"}, + {file = "propcache-0.5.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8e017eeb7482bed34cdb0d61cf2bcfc88d104bbab296a17cd16a6af8aabc70e"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f273dcf7149a50527c4fd1f55cfe9eac0f60753f5af544b4c9352578e20c0874"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:fc2461ecc45f17893f8207e73b46ea8ba93e33630e51cf4af3fbc21d47462b1a"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:279655a16973f1ee2bd2fe79973137681642fd9ae0d89215bba263726eb0dc3a"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e9f165403b81fea7e89c932d89046a1e3d9a3a60e8d7ef2f249dccdcb0982bf5"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1783582065a1f07f9d9ee1e992e13f15d7dc8fb1eb3a7476d43eb3f2e69d26bb"}, + {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d605bb239b796e82a81c6709548b2bd460ab73b4590cb0c83de8a2dd9694d0f"}, + {file = "propcache-0.5.4-cp310-cp310-win32.whl", hash = "sha256:141fdbd73748db0cf7636035030aaac383d2efde8f34e7bc24594cc776d225b8"}, + {file = "propcache-0.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:146f48a9e4812611a7581003b1a39de56c34967046310c4171a68ef908c9a745"}, + {file = "propcache-0.5.4-cp310-cp310-win_arm64.whl", hash = "sha256:6c7599df2b57ebeea8de011b5f2f7b85de95e76037d43d34b95e328430275487"}, + {file = "propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e"}, + {file = "propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d"}, + {file = "propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d"}, + {file = "propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8"}, + {file = "propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838"}, + {file = "propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757"}, + {file = "propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5"}, + {file = "propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d"}, + {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd"}, + {file = "propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9"}, + {file = "propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858"}, + {file = "propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39"}, + {file = "propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429"}, + {file = "propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8"}, + {file = "propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161"}, + {file = "propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8"}, + {file = "propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb"}, + {file = "propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737"}, + {file = "propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba"}, + {file = "propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0"}, + {file = "propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111"}, + {file = "propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c"}, + {file = "propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867"}, + {file = "propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9"}, + {file = "propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572"}, + {file = "propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1"}, + {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c"}, + {file = "propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25"}, + {file = "propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77"}, + {file = "propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177"}, + {file = "propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565"}, + {file = "propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993"}, + {file = "propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b"}, + {file = "propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc"}, + {file = "propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d"}, + {file = "propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f"}, + {file = "propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9"}, + {file = "propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792"}, + {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b"}, + {file = "propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea"}, + {file = "propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983"}, + {file = "propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac"}, + {file = "propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0"}, + {file = "propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5"}, + {file = "propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d"}, + {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788"}, + {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56"}, + {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574"}, + {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370"}, + {file = "propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e"}, + {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57"}, + {file = "propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97"}, + {file = "propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12"}, + {file = "propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827"}, + {file = "propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760"}, + {file = "propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51"}, + {file = "propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae"}, + {file = "propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51"}, + {file = "propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1"}, + {file = "propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f"}, + {file = "propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c"}, + {file = "propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c"}, + {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38"}, + {file = "propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123"}, + {file = "propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3"}, + {file = "propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30"}, + {file = "propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f"}, + {file = "propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3"}, + {file = "propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68"}, + {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c"}, + {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589"}, + {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62"}, + {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72"}, + {file = "propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284"}, + {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad"}, + {file = "propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec"}, + {file = "propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432"}, + {file = "propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e"}, + {file = "propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468"}, + {file = "propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558"}, +] + +[[package]] +name = "protobuf" +version = "7.36.2" +description = "" +optional = false +python-versions = ">=3.10" +files = [ + {file = "protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e"}, + {file = "protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e"}, + {file = "protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf"}, + {file = "protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2"}, + {file = "protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728"}, + {file = "protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353"}, + {file = "protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e"}, + {file = "protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb"}, ] [[package]] name = "psycopg" -version = "3.3.5" +version = "3.3.6" description = "PostgreSQL database adapter for Python" optional = false python-versions = ">=3.10" files = [ - {file = "psycopg-3.3.5-py3-none-any.whl", hash = "sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e"}, - {file = "psycopg-3.3.5.tar.gz", hash = "sha256:d0a3d9ccf5788af054cbd745278cb02401b5c312aeaafbf2c6144460aec47da4"}, + {file = "psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631"}, + {file = "psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2"}, ] [package.dependencies] -psycopg-binary = {version = "3.3.5", optional = true, markers = "implementation_name != \"pypy\" and extra == \"binary\""} +psycopg-binary = {version = "3.3.6", optional = true, markers = "implementation_name != \"pypy\" and extra == \"binary\""} psycopg-pool = {version = "*", optional = true, markers = "extra == \"pool\""} typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""} tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] -binary = ["psycopg-binary (==3.3.5)"] -c = ["psycopg-c (==3.3.5)"] -dev = ["ast-comments (>=1.1.2)", "black (>=26.1.0)", "codespell (>=2.2)", "cython-lint (>=0.21)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg", "isort[colors] (>=6.0,<9.0)", "mypy (>=2.1.0)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "types-shapely (>=2.0)", "wheel (>=0.37)"] +binary = ["psycopg-binary (==3.3.6)"] +c = ["psycopg-c (==3.3.6)"] +dev = ["ast-comments (>=1.1.2)", "black (>=26.1.0)", "codespell (>=2.2)", "cython-lint (>=0.21)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg (>=0.0.3)", "isort[colors] (>=6.0)", "mypy (>=2.1.0)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "types-shapely (>=2.0)", "wheel (>=0.37)"] docs = ["Sphinx (>=9.1)", "furo (==2025.12.19)", "sphinx-autobuild (>=2025.8.25)", "sphinx-autodoc-typehints (>=3.10.2)"] pool = ["psycopg-pool"] test = ["anyio (>=4.0)", "mypy (>=2.1.0)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] [[package]] name = "psycopg-binary" -version = "3.3.5" +version = "3.3.6" description = "PostgreSQL database adapter for Python -- C optimisation distribution" optional = false python-versions = ">=3.10" files = [ - {file = "psycopg_binary-3.3.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cd0faa2475ab254ad1b507430131cf7f7f0be927ffdc03c32ad3b33d2ef63f42"}, - {file = "psycopg_binary-3.3.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0d8a4b7ae47f3381e2ded89891d2455b809f4afb7e5b58086844abb8cfa420ea"}, - {file = "psycopg_binary-3.3.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:04f64b39830887c2c737b522cbfd6ad215d65e67ebfff674aa4cf21c02af487b"}, - {file = "psycopg_binary-3.3.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d06da67e9c687c6a6fdac9da4b17cbeb296ddd59bd01f6416ed4294bc57c5faf"}, - {file = "psycopg_binary-3.3.5-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:972cc28e943746e71ede254a4dfd1fdfcdd6dcadd6f375703849859f09377f24"}, - {file = "psycopg_binary-3.3.5-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:553b5443cbc94fdb9b0e31b62acdf615e0780982d6b13752a15eb3d6c0dfd0d2"}, - {file = "psycopg_binary-3.3.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fd5b047c9fd887b767d063845413e405f5de8ce1dc7a9d0da0637b77b836b469"}, - {file = "psycopg_binary-3.3.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4901e5b9a31c230211a1871263b6594373bacd770b14ad9b14d349716cb69cbe"}, - {file = "psycopg_binary-3.3.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b981d25fc2dd13fa7328e40703ea8a03f3e9d855ec946431e96a23206d3b9fd"}, - {file = "psycopg_binary-3.3.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:39e70c8e3b5fad70e2970ea4cc502bf3b612018f128aa1c670f1fa78b9774543"}, - {file = "psycopg_binary-3.3.5-cp310-cp310-win_amd64.whl", hash = "sha256:ae67072db949d0c094b747a8ec52ad0fa3c42b27842a5f746f3613d54dde3fba"}, - {file = "psycopg_binary-3.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6bd84e4cf67930f26f015dec33f615472b9c5871d46408efe112dbc1bc021de"}, - {file = "psycopg_binary-3.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fdbeb38c9b7ca8fa57a7bda3802bedb62f4494ad3dd46c7dd36dc3f77fd5093f"}, - {file = "psycopg_binary-3.3.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:06de14ac978a2d53e864069fb5487075c6e3cfb0740f1bfd7017bc8b9942067f"}, - {file = "psycopg_binary-3.3.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8b66353b20e79bf7ac0a80f03ae97f522ccbbf909d687eec62f112e56c0276d"}, - {file = "psycopg_binary-3.3.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af5084124fb2fd16557073822519dfe8c389636a16adef661a4c0c3918733171"}, - {file = "psycopg_binary-3.3.5-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1344fd57a19737554670e67aecabd4fb37cd7937f2925840009645d641117e5a"}, - {file = "psycopg_binary-3.3.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2719fe19a4da752c4110cc767716d0a5bdb760d1153d89018bb7c9c61717bde5"}, - {file = "psycopg_binary-3.3.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:893ce86a4b997f6ca1261a7826db2506727332a6ff66646fa7f024b39b5e630e"}, - {file = "psycopg_binary-3.3.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a5e45e4bb68656253ce5c7a344c0a425c293581eba954d7fd7c2e4b2dc9f3038"}, - {file = "psycopg_binary-3.3.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ca8af7c0454cdce235d4aedcb5528857468f1490202d25e24fc7af40e176d563"}, - {file = "psycopg_binary-3.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:7b443f943abfe35aa5a776630cea27c9348aa66659286cee0b99084332252080"}, - {file = "psycopg_binary-3.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25105f9b46bdf2a30fcb67f56976ed66f6855941ae16bc024192609b917d493c"}, - {file = "psycopg_binary-3.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0249c3e960cdee686000eb77169fb6590105c05bacc37e057ccdffdcd8e6ebde"}, - {file = "psycopg_binary-3.3.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5698ab5941a4d138c30fef858588e651fe7d583280cd6e41832825ad9e747750"}, - {file = "psycopg_binary-3.3.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:682a17a57415c3ca1731eec018ed031f012ffcb81ba74806eb219cb396065672"}, - {file = "psycopg_binary-3.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2a61e8147902771df7efe14062a3c8736347850d0d8befcf048235752504f2e"}, - {file = "psycopg_binary-3.3.5-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f7e1e45aad410e20de45df2b159df68ff6c8dbf47a3501f806c4489b27f4ad2b"}, - {file = "psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c09775c549b40b274206e1b043c5e5b5af39666e85c98382a30bd05d23ab677b"}, - {file = "psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cf0e5e63ee86098299c673992053d556c489ba9ae6aca6cb6e24d16a8e0b09e6"}, - {file = "psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c065531e8c1815276f50dbfa283e3a7f022671414cdda6fa9a16794dd53b28f9"}, - {file = "psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:df9853b832b7b916e02ef68e0d5403a7dab2d5c1ddfe94f22b1b155eb862622f"}, - {file = "psycopg_binary-3.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:35885e333020fc152d27bea1a494bef13b2e68f6fd92b6229015e93539152008"}, - {file = "psycopg_binary-3.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e85d50b87257fb117675a19ee59daa7bf9a57f6431500adf7059df799232ef4"}, - {file = "psycopg_binary-3.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5becd311f9af8d180bad372f51fb2252fd02cb2073056e2b170c9274f95fe7f"}, - {file = "psycopg_binary-3.3.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:19e5bf9872dbd164c220567fd385ba2309c7d9df1541f78343510c6b0f36a1b7"}, - {file = "psycopg_binary-3.3.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb3b3bffebfe07110730626e76238161124f35ac87b748d663316a28d22f58b0"}, - {file = "psycopg_binary-3.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2111f880add40fb03c60556069ad68e884a0908a74d2debafc603caf93b73552"}, - {file = "psycopg_binary-3.3.5-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:40505676b1526b9ea387dace034040a8c8b0bcf984cd6bd4720a2ab15e813586"}, - {file = "psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5816472e3bb05615f33a741e0835043d1f4bf9709ff30d2f4aed71815cfc6b5e"}, - {file = "psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:358748fc4c8ccdc0e2bdf55420494930e19c3ade586ea9c3a6de3dad1f897311"}, - {file = "psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1ef2e498be47800f6202b9a2304c22646325ca6d54001b7c785bcfdb24a1e8ab"}, - {file = "psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88e01aa2e938a45655a8a5213fc3a44ba78cb4cab8a569b3e0bcb3d1d0eaba16"}, - {file = "psycopg_binary-3.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:ba466011569297114449df9d523438e1adeedf3e4f31ffb78e897ec3fef3076b"}, - {file = "psycopg_binary-3.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f8b132c7243ef5f503f0b6f986bf16d38a51b0df1c6ba2577743f128be03e3"}, - {file = "psycopg_binary-3.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c0cac998b9b1e82dec853d2e53b3d34d56a525cf231f9441a636cfd5992929a9"}, - {file = "psycopg_binary-3.3.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:479b96fd78149cfa10369dc53fbfb89ee729be13146b584a23dbc7e164c0cf1e"}, - {file = "psycopg_binary-3.3.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f45d77e398542ce0937d9fa3cd9d84e9c5fc6b34c50a66404ae840bada312750"}, - {file = "psycopg_binary-3.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98a388509306e5e08a4203253ac52846bc1b034e5cbd0ae6da1211593cc28594"}, - {file = "psycopg_binary-3.3.5-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab39e2794b95af61a2ff69e33e5ab6ac5df36e9ffea9a3b18e38b2aaca8c5ad5"}, - {file = "psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9c071bf78e5c2e6efa40bc9089a954d7b41221347a72f35c6bf2d8c96e632f75"}, - {file = "psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:14fdfd65a96ecbd8b586d14546105641f4a6ac7cbe335c786830ea4de94bbe60"}, - {file = "psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8dbd694f3741dd4ac5bc60b70e17f7841aefb3f0f38cef4d2756de270e03af43"}, - {file = "psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:14f432430fd9e1a9e7d9ab2fe14956c77f5d074ebdc556a1ad04e9a1bd3fca04"}, - {file = "psycopg_binary-3.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7beb3e41c9a1e509f3ed85263386588cbe3e975aa67be21f79f44fd35ffaeefc"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa73160077345ec21b3f51e8e24b3de2e99586217e497629326eb9b2ea88c52e"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f87dbdc42e78ee0f7ea180c03f8c78e80a949e373066629bd90fefff10552dff"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a9348c5b43a3bb5ef8c2e89d5237c9c87eeafb01d338c84a7aebbc5cd0313299"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a52991594ac4db888c7d39bccef331797e30cb31a95cae02cf2607f83a42dc2"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ea8beeb5541780b4b50b462eeacbc4f594ce3b911dc20c81c75f267876f71d2"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:198a48e68cc99ccac03ba95ac857e73aa66f3bf6be77019fafb0832a05f7ad03"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fa34eb47969297471db7b7f193622c7e3ee839ec05abd05f1fe104d5b1b1dcf4"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b979a42815410432420275412633960807178b1ce26591a16ce06e78a5bd4bb2"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:889e42acec10450185e0cdfb396f375e2c1a8d7737c114830a7fde4654f59e30"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-win_amd64.whl", hash = "sha256:cbd5f73073ed19c378d4c35499db1e3e703a5b1a324e521204065967bfaa7a18"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:be4f9b3c9338ac5dd217c5847e21521b396c8117f78dc420d495a5c49bbef874"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0535693ce476a722b718b002d5d2c27d47e71ca945276ac194409c98e74c492"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3c9e663b2e800e3218994cf948c11bcc2844e6491b34aa80d089baf6531827bf"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a2e44a342d2aee40508e28a563d8961c39d9bbd8cae36d8578f0a3c6658aab0f"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f598f19fa9a91540b5cee17932ffd227b7b53a481605bcc4573c0eafa647300"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff05561e4a067d35507dc5c90f1deb2ec1c9703ac5cccc1bc26e08a197f9c5a"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:566dd827f17728efdf7d88a5b066f815170f6fdad13967ae952842d90e6aaa9f"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9b2f11794e017ce340934e35de46181c46ef71ec75ea3d85dd75cd836761c01e"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:910ace140e3e7b7596898d083f37a8fe90c5c40684252ad4e682364b2cd3deba"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37e517c146b185f9c0c6e8d0a0ebbdeeeb67896af28466e032bc810d0c7dc7a7"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:c7f92daa0d2a1c76f07264abddf8cbabd30152a2f09c3270e50f0c7efdf5dcac"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b"}, ] [[package]] name = "psycopg-pool" -version = "3.3.1" +version = "3.3.2" description = "Connection Pool for Psycopg" optional = false python-versions = ">=3.10" files = [ - {file = "psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5"}, - {file = "psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c"}, + {file = "psycopg_pool-3.3.2-py3-none-any.whl", hash = "sha256:96971f245eee54f1632d8eaae0de5bb6e9c624c1effdd42d8d6843e534f56e07"}, + {file = "psycopg_pool-3.3.2.tar.gz", hash = "sha256:0c70903c7f50cd7173ef0f6364bb7c9271fa3810f5746e058449a77afe579f49"}, ] [package.dependencies] typing-extensions = ">=4.6" [package.extras] -test = ["anyio (>=4.0)", "mypy (>=1.14)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] +test = ["anyio (>=4.0)", "mypy (>=2.1.0)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] + +[[package]] +name = "psycopg2-binary" +version = "2.9.13" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.10" +files = [ + {file = "psycopg2_binary-2.9.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c519e406287085f43aa0d3061936edf1ba51286093532f215315c6ab8ba92c3b"}, + {file = "psycopg2_binary-2.9.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:086659ab083119f7ee87a779e31b94211cf162b708fc9a6bec771f75c73ac3e6"}, + {file = "psycopg2_binary-2.9.13-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1f4c7bdbafdf9dc018efbc29213b73f8308332888ba76a4cf503f560bfd21705"}, + {file = "psycopg2_binary-2.9.13-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d2fc9342aad969b9a28490a4c3eaba94b35beb2d26e9a39b31d1430378aa71b2"}, + {file = "psycopg2_binary-2.9.13-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f124954a32640dfb5c000d33028f48053930d7ff226bc74cde5fb316f9c6fcb6"}, + {file = "psycopg2_binary-2.9.13-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c24c98fe1a113db287dfb1958771eafca97b7db812f23b7897c2a12b6b904c22"}, + {file = "psycopg2_binary-2.9.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f4cdfe41149dcc5583a3b7a2f0ad433f75bb3afd1c7a7332e63df89b05e34666"}, + {file = "psycopg2_binary-2.9.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33a6d3c47f9655b481b2cdc1b4bf71c235e054e55663d3066036b6ce5fbe5165"}, + {file = "psycopg2_binary-2.9.13-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:202dedd5cadb3e5dfd4d0415ab2fc5d5b44f4208de5308938e3e74ae222b638e"}, + {file = "psycopg2_binary-2.9.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:db31cf7f617a51625f1473d8a66fc35dac159af8b28e80bc014ed3ee994a9fbf"}, + {file = "psycopg2_binary-2.9.13-cp310-cp310-win_amd64.whl", hash = "sha256:28eb30bf4a52c1117406f45771038faa96f882fdeeeb0ce43b960a1dbc6c1fd2"}, + {file = "psycopg2_binary-2.9.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d19aec88857d2a52f99eefcefdbbb45921fb2f777bee5186a355a23d9cf8a0b9"}, + {file = "psycopg2_binary-2.9.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:32cd049095135d2b69e824aea9056745a4aaaa9115a9febbc65584793665d0d0"}, + {file = "psycopg2_binary-2.9.13-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e696297891b56ff0115f0665de6ad774e1e301e4f60745b8d5024001ae7c2f6"}, + {file = "psycopg2_binary-2.9.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:930e7e58b33a4f9c39e7532d7a40147925cf3372baed4229cbebe0cf3ba9ce6b"}, + {file = "psycopg2_binary-2.9.13-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aea95340825f5ff236e7b40f0b5602c2c77a1e95943f71fae34909834043d29"}, + {file = "psycopg2_binary-2.9.13-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27e539b4cafd5e03dcd32921db1b12dd72fe549dd06bae6d4d2a5b5838465f24"}, + {file = "psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a6444ac48e2c04f691c2ddd542b38ba30c89463a2d446b3d74ec7d8fc90c964"}, + {file = "psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8cb734989420c18ca1b71a82da880e11988f5ff3fcdaadd669161de3e98794ac"}, + {file = "psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f47f23db2d70db39cfb714b64fd5df76595b51b2ec0a669710a78f2dceb0c3f8"}, + {file = "psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f28b5f2fa8154d0d97e97a664136f58d1639ca008d45d6e09e69fff24826abee"}, + {file = "psycopg2_binary-2.9.13-cp311-cp311-win_amd64.whl", hash = "sha256:70d091f5c3a6177fac50c0da20181ce0e0c053f1e43c872d5f75bd6d9429c020"}, + {file = "psycopg2_binary-2.9.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bf9f97a6df69a5d89d054b8cf5257a0916096c479800715fbfe7974dbcb3a26"}, + {file = "psycopg2_binary-2.9.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:07b7bd9f410650c34c3532162cc329f112368d78a3fc8668cb1ea9df61bc11bf"}, + {file = "psycopg2_binary-2.9.13-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0463c00f946517f3e69192a59e6601e023ff9de45ad0a875eda3d6b1bebeb7ce"}, + {file = "psycopg2_binary-2.9.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e3861eba31f8ea8663fd876166b032fd89179e42aa63764d6feb281f13f9eb60"}, + {file = "psycopg2_binary-2.9.13-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dc3372b3731b3ef23407fe06b94f640ef87a2bda242fa386033d5589c87514a"}, + {file = "psycopg2_binary-2.9.13-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0405dd4d97720e7ab177aa02e493f524907c4cb3c445ac173e2627948d3d0528"}, + {file = "psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b6ae51708201f501a171b02419d0c30878a743c369c9054eb1289f0f8d5979e2"}, + {file = "psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:81682c227cc1849c4a6adf7b85274229073bb4c9d6ad5697222c695dcea5a8a7"}, + {file = "psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:13d955f6054a705a19554364fe9888d0a6e8b0746dc7ebc08a447c7b4fd4145c"}, + {file = "psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e2405196a8cfe6cd3e54172a54452dcf85c241eaf2e9dde7190d7469f7f5ef7"}, + {file = "psycopg2_binary-2.9.13-cp312-cp312-win_amd64.whl", hash = "sha256:376ebf7d8aee4b7386b2bac31fdc27911e7e57cd0a88f1e038b8b149398ac008"}, + {file = "psycopg2_binary-2.9.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4d66bfd44a46eb88cff0287929a4193fb45166b6c1f84bb1b233cc17ece0813c"}, + {file = "psycopg2_binary-2.9.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f818161d2302b3b3e9c75d5a1d0a5c5679e92e45cfec6432b9d5432dde5ff1f1"}, + {file = "psycopg2_binary-2.9.13-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:31db6cba66df5231dfd91d9f69188bec3fe6c8baae384e93a0ce792067ee2d98"}, + {file = "psycopg2_binary-2.9.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f04ada42bcd537adbaf8b7f3140237a204e452a88d0c1831cfce69f7d2e59f4e"}, + {file = "psycopg2_binary-2.9.13-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa37089795bd9701576edc2eb5849ce77a439eda9dfdfa47857449332cfa5292"}, + {file = "psycopg2_binary-2.9.13-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:41c2eb569ebd0e1b02d30d361a46932923b193fe1b5e641fb4d547c75e218955"}, + {file = "psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f699a5225094a5c61402984e2fc1eca20e940223e76767c88189efb0c313f69"}, + {file = "psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5f04ae99c9fbb94c3197ec88599ed7db921f6adcddfe83687a74c7ead4037c22"}, + {file = "psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:81404c37e0344ebcf10aac127d33d35137e5dbab1daf9f3deee46188fd5879c2"}, + {file = "psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:feb7b1856f6ca805cc0e08739858f6cdfed8ce903390126af30343c62899a389"}, + {file = "psycopg2_binary-2.9.13-cp313-cp313-win_amd64.whl", hash = "sha256:691da68ae5dd7c3ac77514357d35ece7b1ba8b5f3e6c92735198aa6159c355c8"}, + {file = "psycopg2_binary-2.9.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ca263643ae37998ae04d18e431df34d0d61f12b47640dab585f14b6dbe00798"}, + {file = "psycopg2_binary-2.9.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c0214c7da18a28d108aa7108c8a3cca8035c7911ec97ef9ec0827569c9a2720"}, + {file = "psycopg2_binary-2.9.13-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5d89e064bb12b40cad696cf4975e6da86f8c60f14cd06cb6c1bc0a7f5d01761f"}, + {file = "psycopg2_binary-2.9.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:190c18b97d9ef72f2e88c451b6588af90d6bd7bf54cb94b963280dc86a2c7076"}, + {file = "psycopg2_binary-2.9.13-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c00ebe9a2f31151aade0db233dc1446513a95e92c39ce055ee097af0ae86be1c"}, + {file = "psycopg2_binary-2.9.13-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5085f7ff7b1e890f279577cedeb8c628957869a340fa34a39f7f406500b3c916"}, + {file = "psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e55357d1943673d491bbabb171c891704fc6a22441fea539e05a5c27a79ea3c"}, + {file = "psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3e60b06ec7f9dc3e5f1106d12706514b6d6b92c3dc438fcdf4e43e65cc660d1b"}, + {file = "psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dde942b46ce20f6c4464cdf551f3293207f803f4e4354454eb1f5599c3eb1fa1"}, + {file = "psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:215777c62ce81c3b487cefdb6a41969944eb982309f91349ff3ca0323d6f17ed"}, + {file = "psycopg2_binary-2.9.13-cp314-cp314-win_amd64.whl", hash = "sha256:f3088eb80f58ed933c62d87128741d31e786edc862e23266d3c286763d646de0"}, + {file = "psycopg2_binary-2.9.13-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:38397def2d794ffde9db80f63d6820253e61b17483112652a318355f51a56f50"}, + {file = "psycopg2_binary-2.9.13-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:dff5c70ed9789ccb0d97ff4a7da51dc523a255c4ec95df188fa5d44adcae4ea8"}, + {file = "psycopg2_binary-2.9.13-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:08d3b81a6a91775c937abf97d4c58fc9142e8e35fb91c387d24f81d15c98e6cf"}, + {file = "psycopg2_binary-2.9.13-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:541a487a9ccd72b5e38f37f27b0ce78cb7eb3e336e7b5277d45463010c03a7a8"}, + {file = "psycopg2_binary-2.9.13-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:562fe2a43b30e781848dce63d9080c15414c777c96df348c4342558338cc7bf3"}, + {file = "psycopg2_binary-2.9.13-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dddfe650e7dda464d676c27fbedb5061f1ad05e1604627f54c770d7f799d36e9"}, + {file = "psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4ff0f575cbb14f30445858dcfdd751e043486f5290915df78a9818bc74042eff"}, + {file = "psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d79530b4c1af657d5620a1d21b8e39f2996aa06821d5564d05b22d6b8cd413d0"}, + {file = "psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:6ede8595767e19d30a7e8a84a7d47bfde6176d45d194fed08dbb68d1584a780b"}, + {file = "psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:0ebcf3c4266a695df9d0ef51296155f60c86ac51cf82f0d0dd2e827255a891c5"}, + {file = "psycopg2_binary-2.9.13-cp315-cp315-win_amd64.whl", hash = "sha256:1752b9821f1377404d65ac43af03d59a1eccc57fb2c1eb8305f9a3fe8eb7a8ba"}, + {file = "psycopg2_binary-2.9.13.tar.gz", hash = "sha256:e324ecf60f952d21dd11413b8bbed0951bbd99579a06fd06f28bfc37737cd373"}, +] + +[[package]] +name = "py-key-value-aio" +version = "0.4.6" +description = "Async Key-Value Store - A pluggable interface for KV Stores" +optional = false +python-versions = ">=3.10" +files = [ + {file = "py_key_value_aio-0.4.6-py3-none-any.whl", hash = "sha256:820b30c7959fc738ea3d00160cdb10cd258f51e38737b2add530defeea1ded77"}, + {file = "py_key_value_aio-0.4.6.tar.gz", hash = "sha256:267c03c3e24cb99d3097612f8a5cfd8e11785c6a2975e272db0e303ea1850bfd"}, +] + +[package.dependencies] +aiofile = {version = ">=3.12.3", optional = true, markers = "python_full_version >= \"3.11\" and extra == \"filetree\""} +anyio = {version = ">=4.4.0", optional = true, markers = "extra == \"filetree\""} +beartype = ">=0.20.0" +cachetools = {version = ">=5.0.0", optional = true, markers = "extra == \"memory\""} +keyring = {version = ">=25.6.0", optional = true, markers = "extra == \"keyring\""} +typing-extensions = ">=4.15.0" + +[package.extras] +aerospike = ["aerospike (>=16.0.0)"] +azure-tables = ["azure-core (>=1.29.0)", "azure-data-tables (>=12.4.0)"] +chdb = ["chdb (>=4.1.1)"] +disk = ["diskcache (>=5.0.0)", "pathvalidate (>=3.3.1)"] +docs = ["mkdocs (>=1.6.0)", "mkdocs-material (>=9.5.0)", "mkdocstrings-python (>=1.10.0)", "mkdocstrings[python] (>=0.30.0)"] +duckdb = ["duckdb (>=1.1.1)", "pytz (>=2025.2)"] +dynamodb = ["aioboto3 (>=13.3.0)", "types-aiobotocore-dynamodb (>=2.16.0)"] +elasticsearch = ["aiohttp (>=3.12)", "elasticsearch (>=8.0.0)"] +filetree = ["aiofile (>=3.12.3)", "aiofile (>=3.5.0,!=3.9.0,<3.10)", "anyio (>=4.4.0)"] +firestore = ["google-auth (>=2.24.0)", "google-cloud-firestore (>=2.13.0)"] +keyring = ["keyring (>=25.6.0)"] +keyring-linux = ["dbus-python (>=1.4.0)", "keyring (>=25.6.0)"] +memcached = ["aiomcache (>=0.8.0)"] +memory = ["cachetools (>=5.0.0)"] +mongodb = ["pymongo (>=4.9.0)"] +opensearch = ["opensearch-py[async] (>=2.0.0)"] +postgresql = ["asyncpg (>=0.30.0)"] +pydantic = ["pydantic (>=2.11.9)"] +redis = ["redis (>=4.3.0)"] +rocksdb = ["rocksdict (>=0.3.2)", "rocksdict (>=0.3.24)"] +s3 = ["aioboto3 (>=13.3.0)", "types-aiobotocore-s3 (>=2.16.0)"] +valkey = ["valkey-glide (>=2.1.0)"] +vault = ["hvac (>=2.3.0)", "types-hvac (>=2.3.0)"] +wrappers-encryption = ["cryptography (>=45.0.0)"] + +[[package]] +name = "pyasn1" +version = "0.6.4" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b"}, + {file = "pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, + {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, +] + +[package.dependencies] +pyasn1 = ">=0.6.1,<0.7.0" [[package]] name = "pycparser" @@ -2805,6 +5367,7 @@ files = [ [package.dependencies] annotated-types = ">=0.6.0" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} pydantic-core = "2.46.5" typing-extensions = ">=4.14.1" typing-inspection = ">=0.4.2" @@ -2945,6 +5508,31 @@ files = [ [package.dependencies] typing-extensions = ">=4.14.1" +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +description = "Extra Pydantic types." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1"}, + {file = "pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049"}, +] + +[package.dependencies] +pydantic = ">=2.5.2" +typing-extensions = "*" + +[package.extras] +all = ["cron-converter (>=1.2.2)", "pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<10)", "pycountry (>=23)", "pymongo (>=4.0.0,<5.0.0)", "python-ulid (>=1,<2)", "python-ulid (>=1,<4)", "pytz (>=2024.1)", "semver (>=3.0.2)", "semver (>=3.0.2,<3.1.0)", "tzdata (>=2024.1)", "uuid-utils (>=0.6.0)"] +cron = ["cron-converter (>=1.2.2)"] +pendulum = ["pendulum (>=3.0.0,<4.0.0)"] +phonenumbers = ["phonenumbers (>=8,<10)"] +pycountry = ["pycountry (>=23)"] +python-ulid = ["python-ulid (>=1,<2)", "python-ulid (>=1,<4)"] +semver = ["semver (>=3.0.2)"] +uuid-utils = ["uuid-utils (>=0.6.0)"] + [[package]] name = "pydantic-settings" version = "2.15.0" @@ -3002,13 +5590,13 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.14.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" files = [ - {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, - {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, + {file = "pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc"}, + {file = "pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86"}, ] [package.dependencies] @@ -3058,6 +5646,62 @@ cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy docs = ["sphinx (<7)", "sphinx_rtd_theme"] tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +[[package]] +name = "pypdfium2" +version = "5.13.0" +description = "Python bindings to PDFium" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pypdfium2-5.13.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:882f4bbd4b17a335b43603169a14cde9341de12b238acd5c39e690cbca7c4293"}, + {file = "pypdfium2-5.13.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:d96929bde3bd64c771ab3558ca1ffd7704cc4d872ab92cd9f8f8b8a20f7f36b8"}, + {file = "pypdfium2-5.13.0-py3-none-macosx_13_0_arm64.whl", hash = "sha256:da5c7b74eebf40b5c1fbe1de01aa1edc8827a79fb1efd999616bc20dcaf77ba4"}, + {file = "pypdfium2-5.13.0-py3-none-macosx_13_0_x86_64.whl", hash = "sha256:2abedfb5c70992b19c780ed58d7f7b929e8ce8ee52c9140158f44317c90ec6c7"}, + {file = "pypdfium2-5.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ee8c2bb2e68b396ab4a763215ac100dacb6b96d0da5bebeb239a021aecc3a7e"}, + {file = "pypdfium2-5.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07f58e91b8c45ca144a1ff3008faf3c73ef8a5e9fb32988831788363288228cd"}, + {file = "pypdfium2-5.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46b2f5be9e7ae941ee4216e3d20b66f9dc3d81944a3d57756272de5275204709"}, + {file = "pypdfium2-5.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d96beb7f379e6c76d874ca93fcd182ac3168dd499056407070f9927fb1061b8e"}, + {file = "pypdfium2-5.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81df25c1ab4c13ff773102d3cbea1967511d079123b067fc077bd0c4d57d91d8"}, + {file = "pypdfium2-5.13.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d66a32d89fa5b4a2715810171239eb194df4aba604727483ab760512f3c6a851"}, + {file = "pypdfium2-5.13.0-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b90b0a5ac310bb34db8eb848e58fcab4e201e124e3cf3cb1ccb7b85293e034af"}, + {file = "pypdfium2-5.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ada81c36483cd61d07e32bc7814620ee96256b4f421b913f566861bf91800248"}, + {file = "pypdfium2-5.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3826e521e895648983cb9ee6b934d4bf51552600043984f84e9c2b3b14b696f3"}, + {file = "pypdfium2-5.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5c029d7163a91f264eafab51fb442a84a33efd9fd83d5a06c0136a7857a3cc8d"}, + {file = "pypdfium2-5.13.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:be2dccbde0ce7efe334ecd8f348df4308db360756ede4f0821d82dfc9a58caa8"}, + {file = "pypdfium2-5.13.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:bcd81394fe101405e026eedb3e40bef84635c1e5d974dd6036420eb6937753c6"}, + {file = "pypdfium2-5.13.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:2ed32ff685f8e05e637c990bedbf5fca66727bf27718d8bc33eeab21ce0630d1"}, + {file = "pypdfium2-5.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9c777edba28d1d5fd15435ed3a78ee2fdb93dd069be37cb53b559bc122793770"}, + {file = "pypdfium2-5.13.0-py3-none-win32.whl", hash = "sha256:d33ee7077db67478b75efe4b5ea9610fb96c5416a0bc4949227f0f59c34dfcd9"}, + {file = "pypdfium2-5.13.0-py3-none-win_amd64.whl", hash = "sha256:47dcca2a8d507b5fd24f94c3c9d48fb379430f097bc20f01beff6c963ffbcedb"}, + {file = "pypdfium2-5.13.0-py3-none-win_arm64.whl", hash = "sha256:554a0b23376460af1410e3c915906895e2dac67a086b9e6ccde0643a795d3b0d"}, + {file = "pypdfium2-5.13.0.tar.gz", hash = "sha256:7ca2d8e31bd8d0d40c496416b7d8bea423388669ffd494929f50e8c3a82326b8"}, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" +optional = false +python-versions = "*" +files = [ + {file = "pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273"}, + {file = "pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6"}, +] + +[[package]] +name = "pyreadline3" +version = "3.5.6" +description = "A python implementation of GNU readline." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d"}, + {file = "pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf"}, +] + +[package.extras] +dev = ["build", "flake8", "mypy", "pytest", "twine"] + [[package]] name = "pytest" version = "9.1.1" @@ -3157,6 +5801,34 @@ files = [ {file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"}, ] +[[package]] +name = "python-pptx" +version = "1.0.2" +description = "Create, read, and update PowerPoint 2007+ (.pptx) files." +optional = false +python-versions = ">=3.8" +files = [ + {file = "python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba"}, + {file = "python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095"}, +] + +[package.dependencies] +lxml = ">=3.1.0" +Pillow = ">=3.3.2" +typing-extensions = ">=4.9.0" +XlsxWriter = ">=0.5.7" + +[[package]] +name = "pytz" +version = "2026.3.post1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815"}, + {file = "pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d"}, +] + [[package]] name = "pywin32" version = "312" @@ -3187,6 +5859,17 @@ files = [ {file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"}, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -3287,141 +5970,141 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} [[package]] name = "regex" -version = "2026.9.3" +version = "2026.9.10" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.10" files = [ - {file = "regex-2026.9.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6d416ed4058ea38f69884f3db523b0414c0094d262dde63694aa254e32a433e6"}, - {file = "regex-2026.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:107319f437fc382e1e445d0abf8923db07f76e5fb3245ac5b09604e5dc8d4f63"}, - {file = "regex-2026.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:05e9f7d16b42686fb38b1702071a7359469ba89e9d516e2ba5228e077dcac524"}, - {file = "regex-2026.9.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39d8d5490ba4b8f26ef2aef72b775b1e7fc1a5ebaf28d11e637eb27b0b6a3048"}, - {file = "regex-2026.9.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4381151a29a7b9307842ff444609c4b9a402775fbe9afb3ec3e34ec0396bbae"}, - {file = "regex-2026.9.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0efb99024ad5ba9198ffa816156b3319c3164b5a8d1e35940d92fdc9158b8d9f"}, - {file = "regex-2026.9.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ab7ad8b557d1f21e8b4a97a2c1a2ac82cf10c92d6a875c90f038ca9ed85dc5b"}, - {file = "regex-2026.9.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2a36e654181ecb996241bae256d28f26b03d019d6ef65c61dcf44396ab07c548"}, - {file = "regex-2026.9.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d36e9097a1bc6eebe8858216ccd2326f561662de4e50c27533452491c1cf1685"}, - {file = "regex-2026.9.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9c407afee6ea4caa313e815814e76d8447fcd32f81e3331d7937c2d33ab80c1b"}, - {file = "regex-2026.9.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:82c27460ff2ea683159a204db2f9ce0f9549dec7070aa0809fbc36226bac1816"}, - {file = "regex-2026.9.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5e49096c90364317897b8d9fe97e2f86063dc0fc0bcd352017ea6fdd703ff25d"}, - {file = "regex-2026.9.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:11f9b104fa7b23f736b50fe1980a1422eb4641865e82614a25413deabccae575"}, - {file = "regex-2026.9.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:92e06a5ecabd6e8352da58b9201ae241a1e7382c1419defa9d2f64ad4ea6abf2"}, - {file = "regex-2026.9.3-cp310-cp310-win32.whl", hash = "sha256:6ff5a98456194621f757d6226f88035c00ebbeab0a9095dc9b5a9ec5cc94b50b"}, - {file = "regex-2026.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:14f8969a92ed847f78e8eed81c6a0384a9e8058a56eb659f83ddb59879c2e4e3"}, - {file = "regex-2026.9.3-cp310-cp310-win_arm64.whl", hash = "sha256:32a32d2664e602b20e4b9c11234cb32f2e985323e453b2b396ea4ae686d82052"}, - {file = "regex-2026.9.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6fe39780de6916ecb1c664eda81802e6310fd9d4a07dccb13a86b63918e00e65"}, - {file = "regex-2026.9.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d75065d9f6ed1afb2a41588def408cf442cee65b3651cbd7e86650146127bb4"}, - {file = "regex-2026.9.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dd356a646fe549d42b766cb9075b54eccd3f20604d87a3eff25f1430bba5b2e"}, - {file = "regex-2026.9.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90aa2a7f9cc1cd2e8082db61618a2ed0f4197eef52266a6420e88277f184e328"}, - {file = "regex-2026.9.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f975a75dae06e88665e4a709873d936a7e8f9445e3d354b75de0e735d28cf71"}, - {file = "regex-2026.9.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d52739de118acf82bfbaf7046955ead4fb613d24ba4187be565cd91ff9a64e"}, - {file = "regex-2026.9.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99034ec353c973e2c89555866083491b9a2dbe81f2fbe15fb0f2b68506232f01"}, - {file = "regex-2026.9.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cc1a82779315f7b2a4642d5028b39057838cd4c0415744d4e53c8b512352141"}, - {file = "regex-2026.9.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c0ea77435b1d5a27cccf27f8762f50e73fd2d94e8e412a8e5cdedb650b36d5fc"}, - {file = "regex-2026.9.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6c997a1703401089bc02d731e360127428fb4ecdf6524268e8975882ff02528b"}, - {file = "regex-2026.9.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:70082b2a8f099b8bf660b553b22a4b8ffd34fcc09ef154fc6b9c7108518fc124"}, - {file = "regex-2026.9.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f5e8a0ce681ddabf6a35d7b817d74a94ab237ae7233a5b97118db0b4e524473f"}, - {file = "regex-2026.9.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3367d5eefae493ac2a1586ec11cc8213c3305528ed3f8e19d5c3871cc01da6ce"}, - {file = "regex-2026.9.3-cp311-cp311-win32.whl", hash = "sha256:33d3a772ff62c882a5d1402045e17c0199f33b9b173139071c4203e7e0292420"}, - {file = "regex-2026.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:8c8a63d55cdf3c716225a2f8741a1df7a52b5fa98ac7530165c9cc9b32feabcc"}, - {file = "regex-2026.9.3-cp311-cp311-win_arm64.whl", hash = "sha256:cb6374a84f11a6b25e63aa69ee2d015286048c1efee93c4a3b5b8df62da79ff8"}, - {file = "regex-2026.9.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5db80d0b1c8238940b5957dd66b5c818ea40a221f6652fb717c027a562d09c77"}, - {file = "regex-2026.9.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:35d48ce3dee087b63b15cd0a7a3110d0a76c29edbe1f2ad0520b8c4adb7cb596"}, - {file = "regex-2026.9.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f22e0d21ae7016c77175c139a7fca465b988efc1280df4816c79752068d9e2e"}, - {file = "regex-2026.9.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:233662cf8cfdfe3c0e58aa8f7bbefc579b5be0ac34546f123c159804179e8687"}, - {file = "regex-2026.9.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2eed2e4d231278a2ccab3f4bfa2c1e39855f336475f7756a281d767d2b1753"}, - {file = "regex-2026.9.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e674cecb61cb160be392da07fd8a71509ef927f437fbf3215432692ed385151"}, - {file = "regex-2026.9.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:665207e41bacd435db001099eeab44103197c2c1a729d73ade74688a905ed4ce"}, - {file = "regex-2026.9.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a7ddc9a8ca1795166a1ca80364b8ce74187fc210e112d3fb048b711b934f36c"}, - {file = "regex-2026.9.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3037d02425863ce9501afbaa04ba967162810004bacde39a53ea9a5b740eb32"}, - {file = "regex-2026.9.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3de4eab8c763393b75bbb26f81934ab2cc8794f48f79e90622e3ab7ea57f3d14"}, - {file = "regex-2026.9.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:98620c9c4c22568ad70f57b80527c780b6f8fd26e36507bf8e2273262a228275"}, - {file = "regex-2026.9.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0b1ba3aaaf5776de473ee16625ac60ac195abb0343afb273575a8201d99be089"}, - {file = "regex-2026.9.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56d8659c65166641d8f1b5efccc391c62c8a899eff4d528b981cc62b7b402a4b"}, - {file = "regex-2026.9.3-cp312-cp312-win32.whl", hash = "sha256:837c1859913798d8bebcd98d4a037e113f8d79e81733009bf590e449769eecb3"}, - {file = "regex-2026.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:1ba1dbbb93c5c5629c1861763aec5bfa9f05ad24ef450694130e25029ce7bc36"}, - {file = "regex-2026.9.3-cp312-cp312-win_arm64.whl", hash = "sha256:d7b3a8a4bbd83ad8b29758f5d24bab10a3f2de87970db36f1e3651c733353136"}, - {file = "regex-2026.9.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d9148e47cfa1a067138867996b1d5d825de0132fed8dac3c92eebaaf312d280"}, - {file = "regex-2026.9.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:33c2860b73ea342c0a42bee9ebe3b3a0de3d68c580c4dcb52241cf4b6663731b"}, - {file = "regex-2026.9.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e33dfc13c02d9c4e55bcf3f3b2eb448537823a6f6f30bf737b2974b63a530bc9"}, - {file = "regex-2026.9.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e84252a16234ee860206a738f9a5084f830a5d0a1370a418d3af4e917f5e08"}, - {file = "regex-2026.9.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3077ace9bf59f8513c8471a817a5af63699987dc024f535c1eac3447a4d70211"}, - {file = "regex-2026.9.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:635482cd183a1856da75a39c473a2e222697b7927f1955f586db83fc8a5da17c"}, - {file = "regex-2026.9.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f0809798071f56fb1bc536bb93714a95e8ed2ec0dfd869f095deebb30fd11a"}, - {file = "regex-2026.9.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d514026ca1c473cc14440e4d7bdf6721c642455b647a74c8143c0f22da358c28"}, - {file = "regex-2026.9.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b9190d4901d7786af9ab0ec46172e27cf7d72cdba2b82ee38eb40aadd3239a6e"}, - {file = "regex-2026.9.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6c0f60b05cc708e6cdf68dbca86b7a36d99695962db0189bb1b3884ca3b28e90"}, - {file = "regex-2026.9.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b7b7e6be82fd6d5256adabb82253c5c307de981cc20c0ce4cff0cbe6de88529b"}, - {file = "regex-2026.9.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b5f85bffdfe17da7dfff78eb32b261c27f3cd64c060033645079493f4cebc8e9"}, - {file = "regex-2026.9.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d10a442c6450ebd35aa89392b8e4b0459ba474df63fc5e6828573d0a31a627ec"}, - {file = "regex-2026.9.3-cp313-cp313-win32.whl", hash = "sha256:63fa79eab192623acb169de1dfe8e733598c4047d06f7712347d1bb810a5ad20"}, - {file = "regex-2026.9.3-cp313-cp313-win_amd64.whl", hash = "sha256:185c1ae881856208dda05708b6c908aff76878e59c998c8548d365c1bbcaf1bd"}, - {file = "regex-2026.9.3-cp313-cp313-win_arm64.whl", hash = "sha256:db6538d733047f9ce4b74ee29c77643a1f99e4ca36e273495da93fbeedd2f03f"}, - {file = "regex-2026.9.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:99896cc18fb421be93e337d6bf2c1686ba330bc2d5c0ef581c842f0639a5e886"}, - {file = "regex-2026.9.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0540de6e7917f89acaf9771bdcd6fa7e505c67416d3b283e52ad4ab25399c6d6"}, - {file = "regex-2026.9.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:647983d2609be6155c748249e770ab7e75e15e386cbf15469569f3eaf165bbb7"}, - {file = "regex-2026.9.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7bb75921a4885d30d9e881d7a595ce50407a8a82933e15451ad7e0d89d1a5944"}, - {file = "regex-2026.9.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f93c60d8c522b4ecea35dd6c58cc42f251ecb12882a5d67d4bd8d12137fbd05b"}, - {file = "regex-2026.9.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9e61478a8a06e6456ff2e66b9ac18f7f28d76a75fa5fda0c5198a4de07b8dcb8"}, - {file = "regex-2026.9.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f64c66b3b13758b4f8f56f17972cd0ce5d0033d19d7332ed32e2dbdbce94dec"}, - {file = "regex-2026.9.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b5dc780377e35be6b0cf6fec7fb4a45cadb1e834bc0ef2ce596cc015290ef69"}, - {file = "regex-2026.9.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f02091b425bbcc2d8481913855c744baa4dd73e334814337b201d837e9040ef7"}, - {file = "regex-2026.9.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ad2027883344e70ddddff02259411f80faf47d5e779eb0e39cb95ee546fa628c"}, - {file = "regex-2026.9.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0edd12c8201222f58817689dc61fe44893f3f2f2aee530b211dfa92af84df9cc"}, - {file = "regex-2026.9.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4a9cd8485a6729387c889c88c24d5eace39dc0c0c9ddb9003f9c81751f654b69"}, - {file = "regex-2026.9.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:994fa00a9b0d14c6e6926ff5ade98d4d83676a5eeab6f87718170e481396d380"}, - {file = "regex-2026.9.3-cp314-cp314-win32.whl", hash = "sha256:4f39485bb02dae23e14cdbad086ab0e468756775bc5c64bb69e7cb756ebc1dcd"}, - {file = "regex-2026.9.3-cp314-cp314-win_amd64.whl", hash = "sha256:445623b1337e971ccc571d3642aeb3f2fec77e60b6ee193dd7688168471d1846"}, - {file = "regex-2026.9.3-cp314-cp314-win_arm64.whl", hash = "sha256:9887e9455398a1517294dec14e23ed9a178c8dd909f2788e971b66623d3f7c16"}, - {file = "regex-2026.9.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c1fa3f84cee5211a3e574ba76ac9596df8c8d16a855f31a25681d23eadcc6c16"}, - {file = "regex-2026.9.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d539a51be176e874ed66029b2df8cb8e1123a3e6420d52a690de6effded4f20d"}, - {file = "regex-2026.9.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5133884ec10c9d6bcf7fed4ceb98fb3a6acbb6c2ba1bab6c4b6700d6c39c7595"}, - {file = "regex-2026.9.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fecba510f6b8f9cf1dfd103d785a61da47221cf09d4cec10946d1950daf1a17"}, - {file = "regex-2026.9.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:72df83ee0eb89b070e28d1260786d13485b98a8b80228c7439d303dd9aac9970"}, - {file = "regex-2026.9.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:610371fe95c7e8e824ad8762248836cabbb5a4ab8befb982cb7dc8df773075d3"}, - {file = "regex-2026.9.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af06c9099df15ee44fda3fdfa002bfe37de02901c2b3a5ef350853861ef3b4b5"}, - {file = "regex-2026.9.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e27003c0a93a5aa541c260bd8ba8b917a2af4c378eb684daa9f1565f5e363181"}, - {file = "regex-2026.9.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9ce34fc5a6f6c9b2ba4a8060009d989e4f55630e3f9c3bf5962f42dcc31355ef"}, - {file = "regex-2026.9.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2cbc83154c8b0201ada07bc5d6e37106df6325f2fda60d73228e2e8da7433cae"}, - {file = "regex-2026.9.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f568bdc17b7ebb3a323ee8920468d2ed74af84da911a00d9585ac887bac73b88"}, - {file = "regex-2026.9.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ae9f7055e7357b2873866a3d77d0136a251ca2ae2dde3d8cdb819425d717c177"}, - {file = "regex-2026.9.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d963442186918577ad83e3a8c5564eeeba90e2da233ce59f6eeccbb7b5cf771e"}, - {file = "regex-2026.9.3-cp314-cp314t-win32.whl", hash = "sha256:bda6af6fb4d5fe9532620e4f72d3c7efcdf7472ead9037b184c0a060f0e7c71f"}, - {file = "regex-2026.9.3-cp314-cp314t-win_amd64.whl", hash = "sha256:2d25e41851e41539898116ac760fcc856e2c1047a843a336b19e2c8e4a43ad16"}, - {file = "regex-2026.9.3-cp314-cp314t-win_arm64.whl", hash = "sha256:356fc21b4c313decb3214a4306bb5bd0623dce4a8d03d0d5ada6fb0b6a5b93b5"}, - {file = "regex-2026.9.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:e337dceb936f333775cf51d49f6badb8cd3d2a6b27e8cb443a6c5861fbf3c1f9"}, - {file = "regex-2026.9.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bb2d4ad7f9bac398a7a19f07bd09fd5b2c1e4eeab316065aa84a305f62fc5361"}, - {file = "regex-2026.9.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:01bdce6a372efd5ae3d8560cedbc691be259a50206564bbaf04008b2937721ab"}, - {file = "regex-2026.9.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55493b5b6cb6c4ec9a3c310d4ef947dbd34326ee4eecd4249e18e00d84f14be5"}, - {file = "regex-2026.9.3-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:690ab9d06cd689b79aab84afa0984a1bc85ea4b93b0a42b015f3ff5e2f5b08be"}, - {file = "regex-2026.9.3-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:24b4bbb65ff2c4e8c552c93c342bf5ffa0ad162d963d96bac7c1883c20e1b34e"}, - {file = "regex-2026.9.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbaf76379bf2a72e534bbb1276d45e63a80d8cade17953ed79a350d6426262fb"}, - {file = "regex-2026.9.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57f405fcb82e2b78df88f04f8aa49ce513ce23bfea073b581597bd52545e9b3f"}, - {file = "regex-2026.9.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a87ab35e92d40b53c5373af36625794e422f83ec56122f0a63871892856d721c"}, - {file = "regex-2026.9.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:14bf4a88833c12a990dbf5cca77eb293401d60878be7d566a2d5096fb0216c27"}, - {file = "regex-2026.9.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:862c29f7e7927e71391df6700079b1dd7c2aeb75115dc46e37004a092f8f16b7"}, - {file = "regex-2026.9.3-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:61a9da95a836e7d300945891265bbeb6510a860863f6b01ed6397e6239a31253"}, - {file = "regex-2026.9.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e7663a6803a47255c32cc7e17ae3ccfe02dba5b0849f87729651c0263a129d20"}, - {file = "regex-2026.9.3-cp315-cp315-win32.whl", hash = "sha256:0ee4721472e00e96b3cceec545c9867f91815f628f6ea304ae6cea93a7e4e7ae"}, - {file = "regex-2026.9.3-cp315-cp315-win_amd64.whl", hash = "sha256:cc5d0f82cf05beb6c0d463398173a09357ed4f4a631f7641cef6f6480a05bc57"}, - {file = "regex-2026.9.3-cp315-cp315-win_arm64.whl", hash = "sha256:6f198b622a3ccf02eeab00de71f6b2f45b1b50b1979aa56b25178c963e475950"}, - {file = "regex-2026.9.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:294ef8fb58a45f912513692380f366ca191940de5b3b3de5308ce2e8500d8ea4"}, - {file = "regex-2026.9.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:edfd2b0cad175780f8668fd6f66486354b8770e4057e44038daaa4a066f8ddff"}, - {file = "regex-2026.9.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:cda393bb35828e3993fcaf39c8ede78b16614954eb15fe77336d5d141be40f76"}, - {file = "regex-2026.9.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce8e5243d95068f595155663e3e18b1938b16cf716dce6cf2491ebc08b98d96"}, - {file = "regex-2026.9.3-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2a1269856278ae8bc78342bf20191c1f10638d2c22df72364d2fa02d70d49f35"}, - {file = "regex-2026.9.3-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ac52b95a938789fcfcfbf6faf647b45d7be940f0c51f06991c1353db54c67aa"}, - {file = "regex-2026.9.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:490a81770599b17b8594227674872dfb215af05f2be0065a32a7c70175fd9e23"}, - {file = "regex-2026.9.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f584dd93ef6ddb10ba028e247fe1fc0ba52ed70ca4d596bb8d52bf4304c147ec"}, - {file = "regex-2026.9.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c07eaf30bc072b179acc8ac50519a2a81f03f88a220750c6d83dadbdc33fb1de"}, - {file = "regex-2026.9.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:9e1c602c55dc8ec05cc7e0a8e31f3ad3d4644dbcb7ac39114105c9bd0384371f"}, - {file = "regex-2026.9.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:2ef9a284ec658d48ec57edfba0944d1582532601472f695b799ba08d37fd6544"}, - {file = "regex-2026.9.3-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c90f34f1b1905d7d6c42b25b6ffb4e5066e6c95c7715169e3332b1760614d092"}, - {file = "regex-2026.9.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:307dbd844f678de48ea74cdc909b007729c50e6be7f182bdf6a1df6732374c69"}, - {file = "regex-2026.9.3-cp315-cp315t-win32.whl", hash = "sha256:ce6505846d29f860966e0e9cfadaad1041a7d8ce7dc4af39940fcb1877dec29a"}, - {file = "regex-2026.9.3-cp315-cp315t-win_amd64.whl", hash = "sha256:2c71da070224baf426850d9eab23ac797d9859b1841dbda43012c01b69697803"}, - {file = "regex-2026.9.3-cp315-cp315t-win_arm64.whl", hash = "sha256:ecc27adda0d1e1bc39793b41fdd562d2f4bc4dcee6ee0e3c733d519c332183b2"}, - {file = "regex-2026.9.3.tar.gz", hash = "sha256:aabd43208e335f4c3f0b56de3464b066dd425983a58f6eeb5738bcd7465403db"}, + {file = "regex-2026.9.10-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7dcad477c49c4c626a6c4fcd71b39a971aa217060cc40a6569fd24edcc0fa509"}, + {file = "regex-2026.9.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d414c411c06fe0009eac33488fb1591c66b5c2673e342e452e7bb2fe63da8194"}, + {file = "regex-2026.9.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f2f43bf4e47ff7ce9e585558706d698c6204d0f80bf2207766382ed817c8e9f4"}, + {file = "regex-2026.9.10-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d12a625a37c89c2b09303402a06942f55f071b95a7916a49c17034c3d47cd5"}, + {file = "regex-2026.9.10-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef4ce69ff97fbb44b46751cfea5e859ad0b66d1a50abf34954f0645f51e81671"}, + {file = "regex-2026.9.10-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:044bd4639b6bb409ec9e5d8b7accd57e02b4c4a4e2eafde916f8ae8006b3e40b"}, + {file = "regex-2026.9.10-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3d95d7d9538b5b726dd6fcd7b6117a71e6565202f6d64f5845fb4d8f203f533"}, + {file = "regex-2026.9.10-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:faa911fbbcf8ac90bda0e0657d60768e3390954ef0588211d63a22add1cb1cd1"}, + {file = "regex-2026.9.10-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ecb2e7acb18f8cc4a67f0ad986c0af291ea4dd385d0614ba9bc09d7f8bbb478c"}, + {file = "regex-2026.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4971776b4f2bd7fd9a83eceb2cb2592cbe2924f639fe8045e6a9de5ba4bfcf25"}, + {file = "regex-2026.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f8bdec659a8fa7af51a32b224b3b7c02bc415d54ffd35187b1d224176b17d607"}, + {file = "regex-2026.9.10-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3540734dbe241ebb3b87d5713781f6749a3e4d45480f506aa5fb5cbb0c37d249"}, + {file = "regex-2026.9.10-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87f5f75c109f08f5c602d68e1af54cead8165189c727b6ac946b30b9833a3ba4"}, + {file = "regex-2026.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:88b02aa8d0ec9b6189fe933d425775882271c23700ac11fd26d1779b0f56fde3"}, + {file = "regex-2026.9.10-cp310-cp310-win32.whl", hash = "sha256:13c52fc377792675f604a207a2ae5958c080f6854f7698d40d9ff034d95b1e76"}, + {file = "regex-2026.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:e6b99181d184d0f5c7b36b8d12b94d1e9499cce6246594331f9edc5d2ea9fceb"}, + {file = "regex-2026.9.10-cp310-cp310-win_arm64.whl", hash = "sha256:abbfc1c33bf8efddcc43844aba61e036d74a918680dc3ce8ce2538b004eda0f9"}, + {file = "regex-2026.9.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:debc629e98b95abaea1cf3057ca296151f348c697c9b8a59d18013adb302c0dd"}, + {file = "regex-2026.9.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d772586951d7d6a5d162d48f414065e483b1c81ab38fd8ed97c78b05883421a"}, + {file = "regex-2026.9.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b43456de605c8ee77eb75f07bc1ee44ba27f9cee22207deb77d495e954b7d953"}, + {file = "regex-2026.9.10-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f0a8b4928823bc8b217a1ab7bf3d90598909dec9a70fbbfe9a52cc4eca55990"}, + {file = "regex-2026.9.10-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b91c37551bf39d75116c02b146956f65b9aa0337a4a652f4ae186983789d4001"}, + {file = "regex-2026.9.10-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b9ba3b2765cdfe18f0f561a69f78a69701f2896654a81c711108d35d14e5099"}, + {file = "regex-2026.9.10-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0acee94b480dd853e39434aa9a575f95385b1b4b8fa3feae56db363ca5cad782"}, + {file = "regex-2026.9.10-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a41693eb3fc4b92e6127d113813c6c395237f7edd3224abf67609af48c690d11"}, + {file = "regex-2026.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6888065672b341e5246f391ec16dc258a29218ac784172fd67c30d941544755b"}, + {file = "regex-2026.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8c07021a4faa3f092869adbd1f35cdc7a592276c807aeebc3ceb8ff1a638f0b4"}, + {file = "regex-2026.9.10-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:58c01f7b81079cf0817ba831ff4d9eff5d28be4a3ac76c353e6f09bd63f4c386"}, + {file = "regex-2026.9.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f0e2e5d23448b660d60a6ed85c46cc03b4b48bd276b8f4041d4a5fe2a4a0626b"}, + {file = "regex-2026.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:94d096369b7cd96d15343fef5257fe39eff9d0e8758b92a0e15e358b92cdb2fc"}, + {file = "regex-2026.9.10-cp311-cp311-win32.whl", hash = "sha256:7f8f10015866608fe4c043cec2e4fe4c39a94bb50e45091de4cdf4004b9ae4b0"}, + {file = "regex-2026.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ce7c118cb102975f974585688357a717ffbf9dddd64ab0bb1bc93eb5b367cf95"}, + {file = "regex-2026.9.10-cp311-cp311-win_arm64.whl", hash = "sha256:1e954e246466d5a1a78f563ce8364b5d7cb19e7adb0ccdec8f9c9610083187bc"}, + {file = "regex-2026.9.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880ac684c27176464c00c3fdc456116364f5ebc70da07aad0c2d4a7ba45e98db"}, + {file = "regex-2026.9.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9d36b03dc362aa40ffaaec9d9bd75e87763529563ec008c43b0e07782f5be7a"}, + {file = "regex-2026.9.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866de9f98df0611d7b62b3a8729d3284a64c0cc6edd90bb95a533e443a4939cb"}, + {file = "regex-2026.9.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c66d54042a14a503907d81861b8a5235e6d1f03d4fbc1d8767f652eaf957ac1"}, + {file = "regex-2026.9.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:032da15431c890d376f53547f0a6219f4f4cd19f3e4f11bdc321453b5bd207e4"}, + {file = "regex-2026.9.10-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:23ac9a28180f274d7dd7651fa131ad5b02d343b75df4b040737f0356223895dd"}, + {file = "regex-2026.9.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e67f8843f0e4b931f1fa860bf3bbe4134b714c0155cc5c7c0d7ea450230aae0"}, + {file = "regex-2026.9.10-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bb7774924f8cd69f49cba0b3c2d679a6326f777e0e67d130ad5203e4df53f0d3"}, + {file = "regex-2026.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c32480f3371b75068decaf9e5da72c224e953830dd71e36e06cf80e30ea39d8"}, + {file = "regex-2026.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d2d377fd1cad611b806cdd732d86b65f536c768209890cb442556548daa65a23"}, + {file = "regex-2026.9.10-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c014641157e9049b0603b8daa5343bd408d9b757b709aaa0f373cd3fab2d7944"}, + {file = "regex-2026.9.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1562aabd9d4eb09bd88a62ad97ed06800094b529ac43419e43020b9cefec79b0"}, + {file = "regex-2026.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2479171edccced52ef02b899558f88ab2c235fe05b93180fdcae1670aacd89e1"}, + {file = "regex-2026.9.10-cp312-cp312-win32.whl", hash = "sha256:239620b0e0681669367c0e218c8eb2551d9f8fe3b9fccfc8d0003377804e8348"}, + {file = "regex-2026.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4db7d00c4afbfbb55b8e17b1e371da11418ea9389b030acec63c1fa4c7ad4b86"}, + {file = "regex-2026.9.10-cp312-cp312-win_arm64.whl", hash = "sha256:c25a754bb81a2edcfc3b65eda50f017d736f818112ed43e8aafd595cb00678ae"}, + {file = "regex-2026.9.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ef5a059ea1c6ee5d1c7e99a2484e628608d010921efe876c6f0e2029d2f35eca"}, + {file = "regex-2026.9.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dce932f8e3ba936475ea3d0d8b59f7b050a9e206e994f53f8fd80299871e87da"}, + {file = "regex-2026.9.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d8c668af8f7bdb1d18739c27d30cd9f4b371495a883f75a002fb7a39d740fecd"}, + {file = "regex-2026.9.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aebdd9a946de328b3f6f61dbf48dd064a36eb6dddf96e34ae6651d37f6e9383"}, + {file = "regex-2026.9.10-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f2374c27deb189b282ec7e16106752c22ad39b056bbd8018960b1e4cc95d67a1"}, + {file = "regex-2026.9.10-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e0dc78251154b66dc60211563fc115345da332eaa881e4e2523fb1edae3772f4"}, + {file = "regex-2026.9.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bafa41b0dd63669e5c0f8adf3d24819efeb73c847f492eb011212eb352e69041"}, + {file = "regex-2026.9.10-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ebb2ba68e4641a994061f70bf44ed448fba0b9b1d18c94ffb9efc1cca805b39b"}, + {file = "regex-2026.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:048a89ee797db10160bd2bd519286577a6b43a100279bd4b7d8456a3d69c80a0"}, + {file = "regex-2026.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:79e9432995e14c749d34209413de5e621ec8e67789bf4f46dbfabea9d06a2406"}, + {file = "regex-2026.9.10-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5847e22bbf959764d776937d791d034cc2d19b787e361c88d97e859e8dc68502"}, + {file = "regex-2026.9.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c103b3b14e011774af4fb7e4617ad4d72b9171905cd3b231a70a4efd76e477d7"}, + {file = "regex-2026.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6b34a778c695d24e77c140e3b4c95da69282e34f2f6b02b55656aa4a0379f643"}, + {file = "regex-2026.9.10-cp313-cp313-win32.whl", hash = "sha256:7abb38b8c40f3a235235a44da452c64b7b5c1d650ec6351027db0e090804f2e5"}, + {file = "regex-2026.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:20e8bfb07ad79a282f8b95b56fe67f9750b1b7f775724e4ba1f23cb296115ce4"}, + {file = "regex-2026.9.10-cp313-cp313-win_arm64.whl", hash = "sha256:3bdeed3318a8eb2bbadc9c56347e0ff651639e934a47e168d05a3b12929fd0e7"}, + {file = "regex-2026.9.10-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd6bd89b9fc06018d35851cab0240adb7dd84d51941b19f6574ac90cd54e3ae5"}, + {file = "regex-2026.9.10-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ef4c0a9dfdc90581b90b1b95a8c3d1557f8ff8f5a2a53536d26314de699d1468"}, + {file = "regex-2026.9.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:14caa05ce39ec70437af5aac8814c50ee6628f4a90353871c059692f448a164f"}, + {file = "regex-2026.9.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3264132d576847ab5f88bb83e7debe67854bf165b3ea613bd467312b6099536a"}, + {file = "regex-2026.9.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cef9f3d14796500ea834c41dbe688f1f6b23c7024dc23e8a794d7ebaf5d71d0"}, + {file = "regex-2026.9.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d278ad30ec83b6b9202685b0f80b741a51ea3ca7f0595ebda96e7628b6398876"}, + {file = "regex-2026.9.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb018cfe7144585fc83882405906ff84994a2d154afc2509ecc7752c51f864"}, + {file = "regex-2026.9.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6fd555fc9abef50c530869690b2daca054c8811a7aff632d11f9a7b2590b2742"}, + {file = "regex-2026.9.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8d5c4518235a2ec1611e57af85fa488d529c1106aacff12adadcedf8687012cd"}, + {file = "regex-2026.9.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:175cf49ce7a994c88b8f15e3cb17cdb66a48ebb2d36de736b8205033db950f89"}, + {file = "regex-2026.9.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b71649169a9fcf30b395ee01047fa7ad6654a4c900ca75b23c04dedcce6a1f8c"}, + {file = "regex-2026.9.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8ba1f78bd4fef2d8f84b894ec28ac3481afe6cc07aaa253ad4717ef7b3fe6bcb"}, + {file = "regex-2026.9.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:217e98ba5fc8908ed8ffd4ebac04753a0c831067cbfb495b9821b94cc61eaa76"}, + {file = "regex-2026.9.10-cp314-cp314-win32.whl", hash = "sha256:b298cdc33c5cc6969ff07f0fba19cc73e0fd8576373c50935feadaca2f6b4405"}, + {file = "regex-2026.9.10-cp314-cp314-win_amd64.whl", hash = "sha256:c32818b28bcd153b25b63038348a9fe9b9fbcddb60df43f204c3ab55eeb57f77"}, + {file = "regex-2026.9.10-cp314-cp314-win_arm64.whl", hash = "sha256:75242f44a3e283106077be4ab717bc535e4701c9d54ad69e195945c22f137a1d"}, + {file = "regex-2026.9.10-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2dd9286093c71afc8f55ef035c5b9d2776641fd72c6535f1febc92d0b0be9666"}, + {file = "regex-2026.9.10-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:71879292c9c7ac67b1680345b16daba1be937cb027362cfa04e68f65db2dcfdd"}, + {file = "regex-2026.9.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ccd139b2061132e7b265cfb4b4721baeb9f8928b81415304abf1ec7e3181c26"}, + {file = "regex-2026.9.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7327795089ddb44912dce1434e1d7244be2e9fb48fcc2d6782936af7a3062db"}, + {file = "regex-2026.9.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff4d7b14ea19e50c8d9d6d83f45bd9b45cbb624c07ac1fa54db0a019049abed7"}, + {file = "regex-2026.9.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f0407474ffac8e5e89d93ca41d60891e29f0ab8423eb66ff292d850a86a0843"}, + {file = "regex-2026.9.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ad10a135fa0b4e4a462a61d07c6654d7518cfdb5cb8da08f9ff7d61384af1fe"}, + {file = "regex-2026.9.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9fbd2e5d8002dc49a6129fb321ec51c57a025e752ed525ddce0ba9223c4350a7"}, + {file = "regex-2026.9.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4a761ea45f2ad74c575ef5850ea514cef97302a552d3c7c9d1a1a870d4661d6c"}, + {file = "regex-2026.9.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:75aa39d3f4f1650eea84e46b0d8cefe77dd5478c10e3d0aaf0b0f00493475a7a"}, + {file = "regex-2026.9.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f5c629df03adec31ee505dda3c8988f106c9390e4cbd343600036eb8b3d6724f"}, + {file = "regex-2026.9.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3a66e40a1a20de96a2fee00ed67e11012b62d85b277688258677fd19997addb7"}, + {file = "regex-2026.9.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:968c1e33edd9a104d1bf24c8d476c72de7e3839ae7f894b37e9e4f4739fdeeca"}, + {file = "regex-2026.9.10-cp314-cp314t-win32.whl", hash = "sha256:fbc4e2f3cb7ce8436154e6483079e7d35eeb321a952fa936e180300630d8b873"}, + {file = "regex-2026.9.10-cp314-cp314t-win_amd64.whl", hash = "sha256:c37fa93bf18bf4f90b01c0fa9f11ea567ee4b7dd8bf96e63663e5edc37aa38cf"}, + {file = "regex-2026.9.10-cp314-cp314t-win_arm64.whl", hash = "sha256:ffc2da104e43db716ce30cef9f28049a1faa6aca385dd8771b033268d0730b07"}, + {file = "regex-2026.9.10-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6afcad14310f1311d077553ed374b42a5e538f85a8c884b4e38e52de091c8077"}, + {file = "regex-2026.9.10-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:3fb4ae8cf83ef4e9addd43b2da31a9f45be816a8036fae8af59c8998b72718e2"}, + {file = "regex-2026.9.10-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f7d4656e17ab736e9415a6442a345bfc97bb8b7dcce47884bb74a37f70f08d0c"}, + {file = "regex-2026.9.10-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ba3bab0c45079735f55ac61526774de1d84bc4a0333cc554e1a4ab74913924"}, + {file = "regex-2026.9.10-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff6b3267318661dfddf6b3628663e00e5946bd0a5c8fa678537a1401f0388f91"}, + {file = "regex-2026.9.10-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030fa9e23624e39b3b94e46b90a5abd1a1678eb2f58fcdd3fd6c27526bf91c7e"}, + {file = "regex-2026.9.10-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fbc8314436353e097c050e11b01a6c11433579437ed0579730157676ef59e2f"}, + {file = "regex-2026.9.10-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e6c0b5ec6ddee4032247585dc491b0fa58627745b66a705728703a3f0331231"}, + {file = "regex-2026.9.10-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bf29611e5376fec8f795879bb5c6153a76c3a292573d173c26784042b01eb840"}, + {file = "regex-2026.9.10-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:ec8855f08c17895a26fbf5f19ed829722e19b34a96629e49a43c92974924026b"}, + {file = "regex-2026.9.10-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94c5ce3bc41d226b4eb89ca3f842b2e28c031487fb1f34eb2153d98235831325"}, + {file = "regex-2026.9.10-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:9ce239acb15843ab03976626af810a4424b0409689ec2bbc52088ab5479ab487"}, + {file = "regex-2026.9.10-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c22df8dd6373bbe3898e77429ffc85594300e39d752fd0e68a31e59d37899376"}, + {file = "regex-2026.9.10-cp315-cp315-win32.whl", hash = "sha256:1aa309ab7ba89a62d6cf70dbd38d4176440bce3c7001ab86256704cf4c18c6eb"}, + {file = "regex-2026.9.10-cp315-cp315-win_amd64.whl", hash = "sha256:58da726d3e766c0b3f5a3997dfaf0275898a1107b8191cdd6b0437fe45fd817d"}, + {file = "regex-2026.9.10-cp315-cp315-win_arm64.whl", hash = "sha256:75f9297b16fcb588a1f8d8a55dabef3c0c20b0c7bac43c87ceaaaf1a825c12f4"}, + {file = "regex-2026.9.10-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1270cdec69248592bbe38a0b263ed58d907b891bd2b93703e225c317e421bda1"}, + {file = "regex-2026.9.10-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:681ed38664b64c6617d3c3c332018d1948c77e139c5ea667c1886efa671e426f"}, + {file = "regex-2026.9.10-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8e127d9a80cbf1c3276bb465c6d047e8705e97b58c2b8f2f0c0a69c336b44b37"}, + {file = "regex-2026.9.10-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:990797e765d89a423880052c68b61c31afe701de94a8c060f61c40605ca6c727"}, + {file = "regex-2026.9.10-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e5e4a6e0734a685d13b9685622bb503bdbb2927f8b0df025a5085f0ea067475b"}, + {file = "regex-2026.9.10-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63bb62cf62217dc38c8a6b2b61b165b0e4eb8fa93b0aba12139251c0986a8fa3"}, + {file = "regex-2026.9.10-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb76a9c4e07a6a47849726af0ed14c41741a182f097f134a8cf29c1bc0f4dde8"}, + {file = "regex-2026.9.10-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31e4df2b11d48f61d511019bc1ee9b477055f17c352b68fe72db7a98b14d603c"}, + {file = "regex-2026.9.10-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:cf377960d2ac37d987394a9dbaa75e91338c41a46d41e1d25e90125e7b3ee2dc"}, + {file = "regex-2026.9.10-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:c8fbd9cb30c68c1686b94029b9ef845d5870d3d65baf66cb126b676849b9d72b"}, + {file = "regex-2026.9.10-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:53e182b6b04d0011909b47d51a2d72d908de07c7b1c7f16b3adda2204d723bc1"}, + {file = "regex-2026.9.10-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:0aa7589394230e0f0a422ab6b90841ff12c87e855e7aaf75d192a54a5f124548"}, + {file = "regex-2026.9.10-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1b891f77554bff991804cee24b78b40789f7d5993a24c7907bc7025fd2a70c8d"}, + {file = "regex-2026.9.10-cp315-cp315t-win32.whl", hash = "sha256:5bef622850cf760154719d4e0d74b0a855962432995168e250069899ae12fe8f"}, + {file = "regex-2026.9.10-cp315-cp315t-win_amd64.whl", hash = "sha256:07b45ba5c94b8fcb30cb6c56a11f715c57533a3017964504322ea52690a27b72"}, + {file = "regex-2026.9.10-cp315-cp315t-win_arm64.whl", hash = "sha256:f70b9f0e39c2dba1d9da6bf7ef7c377cad7277f8440e9a69be05ede529ff024c"}, + {file = "regex-2026.9.10.tar.gz", hash = "sha256:1e321e2c84f0e52c457f5ea5944f796d6e8e09cb99738ea98dcc1bfe402a128d"}, ] [[package]] @@ -3477,6 +6160,222 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "rich-rst" +version = "2.1.0" +description = "A beautiful reStructuredText renderer for rich" +optional = false +python-versions = "*" +files = [ + {file = "rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255"}, + {file = "rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd"}, +] + +[package.dependencies] +pygments = ">=2.0.0" +rich = ">=12.0.0" + +[package.extras] +dev = ["mypy", "pre-commit", "ruff"] +docs = ["docutils", "sphinx", "sphinx_copybutton", "sphinx_rtd_theme"] +tests = ["pytest", "pytest-cov"] + +[[package]] +name = "rich-toolkit" +version = "0.20.5" +description = "Rich toolkit for building command-line applications" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rich_toolkit-0.20.5-py3-none-any.whl", hash = "sha256:e21fd616db9c0539d3c50f3433fb1173db95c19d3cbf215615e224182fad6c18"}, + {file = "rich_toolkit-0.20.5.tar.gz", hash = "sha256:0c9e1c414ffb0720be26285d472e263d1e704b71d31a7e13274b9996db4969e1"}, +] + +[package.dependencies] +click = ">=8.1.7" +rich = ">=13.7.1" +typing-extensions = ">=4.12.2" + +[[package]] +name = "rignore" +version = "0.8.1" +description = "Python Bindings for the ignore crate" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rignore-0.8.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b56052bff3aa1e6dacb42192be701efdbcc546c52f6302d4146ed93a31df4593"}, + {file = "rignore-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8625d98b9d83ef55f11c06155bf31cee21bab9c1fc001459a7cca878bf1e8c1"}, + {file = "rignore-0.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8a6b6664b3e9b403af1abcf81475782120b52e1f4063250638dce18a1856fb8"}, + {file = "rignore-0.8.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2584771ef0e3e6c12f19bc50eafc1aff338e125efe0030ba0c3b29731faabf6d"}, + {file = "rignore-0.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:773acea1c43241d5e96db86d52133f3098246337639a1f6f474ee8d5b304b36b"}, + {file = "rignore-0.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a742a2c09a63bd65a52ed6047ba324593b6bf2080bfc570af199b00d617a7349"}, + {file = "rignore-0.8.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c76ef4db664b9bde5da57f1cc58ed8545f9dfee864fd51eca21c60ebe9206dae"}, + {file = "rignore-0.8.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:39478de64db6d233d1bdfca58fa8223a073a13f0facf992ba05a739a093fe4ae"}, + {file = "rignore-0.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f2f0ea489c0e3439f1e94f2323e55218321b0a5f098dda4f1537e1caee12b035"}, + {file = "rignore-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7d38b47249754453e435d728ec019decee5ab450d50347025605484aa7526bdf"}, + {file = "rignore-0.8.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1524ca0df28b6c967fb9594a00cf1ba79e280dc513ec3e02d29345c79390e028"}, + {file = "rignore-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e0ac8e46f89a824326626f4023253cdb8dd09c55c65da51b9824200d261697a6"}, + {file = "rignore-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20743f8ef88aa6211f5ce3618c2dda34dd0afb4553e70ed7b18ef4cc1a0d8d9e"}, + {file = "rignore-0.8.1-cp310-cp310-win32.whl", hash = "sha256:b2cf5a1498d7f1a14fb5599e59805875cefbcd8b7d989e3b73f52829200ffc60"}, + {file = "rignore-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:54bd28cc90dc4c89c92a9191bc899e84786087bfb7c417ad0c29aeb90c2d0333"}, + {file = "rignore-0.8.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e4bc5ad282f7f82d9dcbb51b2e079f9a09c189e8c36017833e34003d253e0117"}, + {file = "rignore-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36381a30591b410b15d342279e1b3a3515e240a734c37d119cd0a9e6f71ef596"}, + {file = "rignore-0.8.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2aa76742832db1fdac070fa8e694dda0daf6f2f7191832bd555d1e80f8c9c42b"}, + {file = "rignore-0.8.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6c53ebb7ea2d22116d99ec155c5313c3d76220f06b4118c1e1de629de721a95"}, + {file = "rignore-0.8.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0dbea2e28538fa64f8409085abf50240f1f7c5981a5479bbb1aea7b84d44aa45"}, + {file = "rignore-0.8.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:05fe665768bfe8ddda77e5ef1c2332555ea2abecd33aad05fa41a19d959ba011"}, + {file = "rignore-0.8.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9869b618dc28104003292f26cad26a2d54f6a3941684f16fe9ff435b5df1b0c0"}, + {file = "rignore-0.8.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d59e4ee62d89136eb3f0f04db023c17f9294cde905cae2a0fc8d89a4ab86c57b"}, + {file = "rignore-0.8.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df64fa4f0b451cf198bcb1596bdce1d976396e579188141276d4ee8288ee2486"}, + {file = "rignore-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a1fd861fe4c1fea39541e7c9e8a409472b9cf7604bb472fa9dc09c97bf7134b0"}, + {file = "rignore-0.8.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c49ea7bac11f3bed7af4a21fb76dd7820341103dd3d68676a7177d797262b84c"}, + {file = "rignore-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:514f7be6d04a6f6be4835748b14bdb027799759d02ada03ead064a50c763b70d"}, + {file = "rignore-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b493c787dd2cc31b1e1c678e626bde593d83f9df420096f91dee6841ca317bfd"}, + {file = "rignore-0.8.1-cp311-cp311-win32.whl", hash = "sha256:d398c9e0d2a51ad16c296ea287a9e70d75715ff5dea124a7215228228cec8f22"}, + {file = "rignore-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:516ff2dba78230ed5a150db4fdfc0ad435a5a9137d27366e20a311001ca5a878"}, + {file = "rignore-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:13addb2a4805736a3c8adcd1c60871f517a4bd8b3a2b127e36d1e701924ccc0f"}, + {file = "rignore-0.8.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:487fb09f98c1e771f52b8433880c0012c25c5868db6e6f9f740ee0d3a13d4ae1"}, + {file = "rignore-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e7ba47a5bb25ad45d39983047ecfbcba01ecda0145458c548cd3f390b73bb7"}, + {file = "rignore-0.8.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac0a7cadcf6154dd60b2f101425644179b34c540a88b84089995c1745e8c623e"}, + {file = "rignore-0.8.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab53a0908a1f24d2aaf4201920932134fbeb19be1d4c9619514ef7c781c9f3cf"}, + {file = "rignore-0.8.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd43540d294fbcb66daf66836b6c57043c0d3af672722099cd4a0c91448d948e"}, + {file = "rignore-0.8.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b4316b266e88c25ac7b4a0c765aa93c436dfa689a29429bd276878db45e17153"}, + {file = "rignore-0.8.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e68cfc4ee0a2909952af2aebd608d1cd22f7d1cdce332cb0b5ea3762939865d"}, + {file = "rignore-0.8.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:4d891dbe52b1aa4df22a69346e731ee3df219128956ca4391722773e6baab16c"}, + {file = "rignore-0.8.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:463f9734f06f2dd939c45f0e58d50c0648f799e9c41fb657e40d236054b14cfc"}, + {file = "rignore-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97fceadfe03f3b8cd77cceeed92d00fad67f3ab80a0fd4d31e8dec104b721018"}, + {file = "rignore-0.8.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:43342bf37e7bb57d69f766678c2b19fecf2b3ec757be3f1bb2fb774d2be8c81a"}, + {file = "rignore-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:514000eabd84e8f6f6a589a30d00e76d7cebe0d99b6cacede1223f64dca4b742"}, + {file = "rignore-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8dfa13e24fc32d3df33d40788cd04e07f6b607f3c5306808ee0c285d2135effe"}, + {file = "rignore-0.8.1-cp312-cp312-win32.whl", hash = "sha256:39b300b82e90f7a6ad102544b777f2aa8a26e9260ebb021399ee5a0a708a7c2d"}, + {file = "rignore-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:562055b620844654f0afff4df0a9c83f3679452c276cfa76cc5e49c14ddba481"}, + {file = "rignore-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:19606039e35dea7c1a8360f76fe39c5df6715d593a4f2e0c0c712b1b50985d10"}, + {file = "rignore-0.8.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:16b17b02d3e6ff3b88583af4809b64e14c77cf4de195763f063e5e77d70cd112"}, + {file = "rignore-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:58d7172259fd45d8ba00f3e02af5af2113f547f4a7fefab42cab567aa7c999fc"}, + {file = "rignore-0.8.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c5ce3b10ce4b716abc535bc0fdd66b0b9fa9f5b1987b36d3f5ece7d0e2a9a81"}, + {file = "rignore-0.8.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c199fa2d4a898e9b686d846371ead3c8e08d3e29f78e2ecabf4c580820a8f764"}, + {file = "rignore-0.8.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7136b7ff29c37c8ec8effa3c59e27839482ba542af94fdc2581a59405e99037"}, + {file = "rignore-0.8.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32b0fcd01495cc4f4d10b5307f9f15818c9db752cfbd7a9ecb00b0f129a70dc2"}, + {file = "rignore-0.8.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ca91c91a53135889945e77b286215ecd41c8f1090398db97ca3459c5a290eb6"}, + {file = "rignore-0.8.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:67e30c0883f9aef3bcc45e5dc6980bc41f161da6c1b1a460f0bd87064c1d6594"}, + {file = "rignore-0.8.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3012fc79b19953f76a67b2e6bae5f456742e96f0ed33d3c6c9390b0586fc2da9"}, + {file = "rignore-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3a437c870f1465aba36eb4ac7108c09d5097c436123fdaec980e7a26a4595141"}, + {file = "rignore-0.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:59f4f92ec5165619b3bc58130ab558bc618f9c5e9df06807339a85d965143fd5"}, + {file = "rignore-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:304ddf5f807c788c08a8210f9977131e7bfa9aa94704668d127689c00279ef41"}, + {file = "rignore-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ce5c6d8f02badc55112b014d4fc9af662f0649869913d25785d7b3676ac19ed"}, + {file = "rignore-0.8.1-cp313-cp313-win32.whl", hash = "sha256:711369d017f8959e3a253dedb76f6d2f43eb669a4228b2c1b796e30a5370ca36"}, + {file = "rignore-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:0585eafcacf15f8f2764b8d03a579761296e8c51dd0558f8eb59040810ba8404"}, + {file = "rignore-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8ddf078c4b5da0665a83f7b558dee2d746293ffcce3dfe8786015aed0aac4c2"}, + {file = "rignore-0.8.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3054fab93e2d2ad483cb89417f7b883cee025cb22286eb824af0073f2ffd5f7e"}, + {file = "rignore-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60f1ae9eaa51b50d5afe99bd25ffb2cfc10729701ca08c179489555d2614f3ae"}, + {file = "rignore-0.8.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6012766ea3a5a635d9b79f3e8c3797d5e47ce5e5dd81993c9f92a3ea4ff68b4c"}, + {file = "rignore-0.8.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90e60f0073caae0f1d59c993adedafa3a57bc6fac551669677cb25aa7fa9d9b8"}, + {file = "rignore-0.8.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:121ab7ac93e39fd1d70098461c1ed9a6fb89d54e7bf8c60ae351b23b05cbb8c1"}, + {file = "rignore-0.8.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:befb772556c8463c640b290f632b57440182edd39996708a33c50bcf437796f9"}, + {file = "rignore-0.8.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a184fa45db8cdc7a8604d2df020a107be3fc0adc2e522b4e4eb5cd5b57d5f84"}, + {file = "rignore-0.8.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e68a572efc126aa45195f1581a5ea97c4e36eeba6874a6635ae44daa4fbec7a4"}, + {file = "rignore-0.8.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c75ac1952ca8892422de328a925f4804120b149543042313bb3af7cbfdd65d64"}, + {file = "rignore-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3538084cef4a66ba3fee7c453d17db7cfb32a6653456381b62afd8d53090d6fa"}, + {file = "rignore-0.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:28a3baab3d1b7ea42c38e492d4a100de3e5c67217432364c62b5719e0f04e96a"}, + {file = "rignore-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:74996d8ed1a494ff8e61d9194a0dd3637e6b19a582cbed6fe0e1a4cc60e7b266"}, + {file = "rignore-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a2e5df9ba53e502f676b054c4d12371273204e2b9c6ec29afad3a155b9ad3399"}, + {file = "rignore-0.8.1-cp314-cp314-win32.whl", hash = "sha256:69db47c2fa51d88e93b4e4de4f44220ce7a9337e4b7526a16f5660b19974a8ca"}, + {file = "rignore-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:1c668a541ecd8af3d7bd06c48c62e1c3e0a755028d57c07cee697a25d9b0678d"}, + {file = "rignore-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:d63559131efa9cbba82494ec8698e38bea403f214969066c661b3eb4da4ad4f9"}, + {file = "rignore-0.8.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:b60f3751f681a12798927d51d1d6a46b81fe7f9923a513dd8fcc7947d785e12f"}, + {file = "rignore-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7d56bf138418e31991ce17b738c491fa1ac098bfcc8c3ba67fc0faeecfed357"}, + {file = "rignore-0.8.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f020b018577d0081a2b23da199d22c58f4cbd63935c8e991adffbb1a755b467"}, + {file = "rignore-0.8.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d4a70c9b857657c3a542fd367e16ae106fb6e3e4448cddb30e4edc604d5a025"}, + {file = "rignore-0.8.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ab668664e6388afc08fec50186ec21daf17c738ee71bb039d12cf5f14e964dde"}, + {file = "rignore-0.8.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a3454cdd8bc145fd055dd45f650ccf1509e7b4edfc720152eb2be594b230f03"}, + {file = "rignore-0.8.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:425b962f3d68b86ebb785409153708a38b0658542c918ee706ea433e2547c805"}, + {file = "rignore-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:5094965794d163e1c4a71dccf3d1c3b3df86802e3afbfa461b300aa923cd7ea5"}, + {file = "rignore-0.8.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1467756e8454d3f816131bbad8b0efc52b7f6924e9b151f2962cec9e2f4af706"}, + {file = "rignore-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b5acf12993258f0eac4db81c30bed5469ebadd1b5bb2863c985fe0d802d94e0f"}, + {file = "rignore-0.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:329ae0bea9598541cb818bbecb1e1f56fc1faae77828ebb3846ef2b24a050d05"}, + {file = "rignore-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4069fcdff01999cd2d5a426eccda45a8b31a3e0eeee8b5a9da5a452714cbb2c7"}, + {file = "rignore-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf28097a83e1237f87e11e33fb12def022e103cab2a20340a19753e8e222aebf"}, + {file = "rignore-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:c978f26c25e66c118da1e6218f34cc5ce2bf9c5cc3969011cde47bacbfe4a072"}, + {file = "rignore-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:790e14dba9e1f8a532071d64e1c2fa7529c71db75996e95519d06d3378cbbcd5"}, + {file = "rignore-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:484c0a4803a1eb9097d875df3200e60e58cbf316d4d40357f92e8e757ce529dd"}, + {file = "rignore-0.8.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1c2a29e7f54e879fa54982887de480b2cfa6e3cb51f01d70dd4e4035f5b79670"}, + {file = "rignore-0.8.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58449c827c36dba68133a5ee4137b67d7d6e050234a95c3a8f5e3cefbd6970ec"}, + {file = "rignore-0.8.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e250726b08957aabcf4e28aef4ea18bcccbd95f240cebb75a74b424f067dce4b"}, + {file = "rignore-0.8.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62470f4d70d83f124381975614ed7c7db6f5c5fb4d777ee88f7bdb9a7d14b65c"}, + {file = "rignore-0.8.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d886d6bbdd0a1a3ef73bd38d6768cabc9b30bfd1be7b157b27a4ac7ec6c5244b"}, + {file = "rignore-0.8.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ed31657a20df59b0bd63283d278e8678749aa0154ba98fae0838a9845599695"}, + {file = "rignore-0.8.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e425a42f6601bfed266f76f3294bca48efcfdb10c3c0c279fb2f977b1e2cc2bc"}, + {file = "rignore-0.8.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:ff09db59f672d929bca88ee7089d3697256967df76a2bab8b187208f2b517bc0"}, + {file = "rignore-0.8.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8dbc5898945c027dc0ae451de0c983e20bd8fa8297f40ee38d0ce1a3ed924d4c"}, + {file = "rignore-0.8.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1160abcd855964a9dd69f3d603ef57be14b1ea51ebaf50b07f737a3f3a8b89a6"}, + {file = "rignore-0.8.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:be994859b2cbbc69338351bc9908dd7d049de232e6eab5e998ec1feac3faf785"}, + {file = "rignore-0.8.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:f52098f3245c7557229d8253ea3a53a03de03704685aba8a1ccd24a5f004db70"}, + {file = "rignore-0.8.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c676080bb70cbd5429052bb5a3827b35543876a42c792c6d713d9e162bdaa00d"}, + {file = "rignore-0.8.1-cp315-cp315-win32.whl", hash = "sha256:ec800546b960d5044d2468d22dd0689210e846350c42cc9f02519c2a2528b3ef"}, + {file = "rignore-0.8.1-cp315-cp315-win_amd64.whl", hash = "sha256:4225f2d2f0b3e3c39a815f15dbe551f39f0d7f82e2a92d16bdbdb07ff4b6718e"}, + {file = "rignore-0.8.1-cp315-cp315-win_arm64.whl", hash = "sha256:42480707cdd5f92d6b1252faada600e03ab22fbfd53b124c6f7b33c21e06b6cf"}, + {file = "rignore-0.8.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:51e608acd3c1aa834f2b7d956ff5380a0a6b6b595df80e665f2a2889982f1855"}, + {file = "rignore-0.8.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6602011b6e6ecd157f3b0a0a25b1777b40c541ca21de6ebe2137af713d0efa15"}, + {file = "rignore-0.8.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8db2be0d49175d5db4cd503f8dc62cbe687b9ebc71af4cede1c8b40bab8f4f4a"}, + {file = "rignore-0.8.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:601ff49d8458a21d745908e35f792c02b45ecae4a37fa3cd1fc03fa065962bc0"}, + {file = "rignore-0.8.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb58e93997d546fad4cfcd730a54d7c12cd7a61fe7a9b31bfc75402403dc559f"}, + {file = "rignore-0.8.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0378cf77b8d64560e0cb433deada18438b36ef7933dd284dd65347d03c56c429"}, + {file = "rignore-0.8.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0097a8c35106997d2b52851f0888777efe10e34772140a9fdb018b2f99238159"}, + {file = "rignore-0.8.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:2a809a250f1532b93dcc52e173ee71adcafdb536a125532687666047c4537ac0"}, + {file = "rignore-0.8.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:580e019787032b430b857335a66db53d7ae0a200586b3a6b5d3ef227648300d6"}, + {file = "rignore-0.8.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f922dcf01e6a7ad26adfcb7f42635ae63066b883d409876129db62297061327e"}, + {file = "rignore-0.8.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4900bd1ba8938e5a5e601306504c7b8169783a2d44a8396c8fda4a9b659eed51"}, + {file = "rignore-0.8.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:3d581bf107490abae4d40a9aa823a79e18c88a21658514ccaf95e42e85d278f7"}, + {file = "rignore-0.8.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:78490b93aec14a87fa4c23e634b1d1dcb2bf5dcb90923f3b03a3428892a13d9f"}, + {file = "rignore-0.8.1-cp315-cp315t-win32.whl", hash = "sha256:e8315151a83b982fe972420372e80c550616095181d1549faaa1ec31f363f5ae"}, + {file = "rignore-0.8.1-cp315-cp315t-win_amd64.whl", hash = "sha256:69be37202a052d9e13affed6b774f86d8bf3b417b15d54ad737033e56da07ec0"}, + {file = "rignore-0.8.1-cp315-cp315t-win_arm64.whl", hash = "sha256:4268e83bcb88f240ed4f43d73e7de9e4bcd8026bdfcfa836567d6c29863e5361"}, + {file = "rignore-0.8.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:8232b4a3e5a51f45f3bb8c53be7c96ac437bb238e414dfc37a0a2a42a6c90394"}, + {file = "rignore-0.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fc042ee2f3e3a38f6f44bd9f097c81ee94686897d3931f5e43d21789d34a81a0"}, + {file = "rignore-0.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:916f8602d4dec7fc8b492883539b06fb3fea4f7b29ba8e4c300e77b21bdb8d28"}, + {file = "rignore-0.8.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f98b4481c8d60635e8f2c2d33b92d0b8c02d2a87eea49e19d7b25cde87a77e"}, + {file = "rignore-0.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149b7a5cd9ece252ae28b386a979fac575546af5cb667a3f93148addfd8e6686"}, + {file = "rignore-0.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87d0e03fde346256577b7aaafce44d593a7454995ea88c03005982c80890d6c8"}, + {file = "rignore-0.8.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67a0ac8ceceb67fe9acb68ed16c29c30303395795c12c699c1af55e4af07410d"}, + {file = "rignore-0.8.1-cp38-cp38-manylinux_2_31_riscv64.whl", hash = "sha256:46aa37be9078fca572461a40b5832dd80aa34673d6e557d606feafe57072afe0"}, + {file = "rignore-0.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:45ec57389b855fa95b251205e3a8a1963a7185f28665c1a0b24e05d05012da89"}, + {file = "rignore-0.8.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b0ad0565d31aad6500a7ded6affb0165ae39560b6b056c6b179e5550ff3a887b"}, + {file = "rignore-0.8.1-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:c0a66b10f89af390b014e39db3f20489336eafa2521872b0c05017459721a420"}, + {file = "rignore-0.8.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:96ef71291f53bf43b85055c220697053c35173418aa1a5452ae71e86ef83d3b2"}, + {file = "rignore-0.8.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:75b9782c32434196c66f475cdf7be6f2a8c15c61c8c238cbe38b64657137f21a"}, + {file = "rignore-0.8.1-cp38-cp38-win32.whl", hash = "sha256:3b745c799e031dbad32aed9089d02f6c7be6024284311a5a76dc2f7d43635409"}, + {file = "rignore-0.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:0cbb7afa893d46ffc9a303a76d9058e0215ebf085c2e3100e31d2226f6a9bb7f"}, + {file = "rignore-0.8.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:092930871001f214069d03744225ccef7379a32a515e2f005f9f642a2ef34629"}, + {file = "rignore-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3b1018f5ad66da4eee7f451db38a10444aae68952dda2afa52ad49207acf8490"}, + {file = "rignore-0.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8c4905de9800dd404084e2af0b97fddbf002e283663809c37d9b10dbea1c024"}, + {file = "rignore-0.8.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cad647c6e0542219cfa421780e854cd1890936bda7096cfb9d859fc84eeb441"}, + {file = "rignore-0.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc48dde27bf241e3982756edad000ca17da2e42bdf163919932f1e37d588f404"}, + {file = "rignore-0.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d463ce689125a3e311489d1b1b39b8471fe0d6e76b8afe9e26b40766a3fac19"}, + {file = "rignore-0.8.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c51c54dd953d18b6cefd5c6c70913aa87567efbead5c1f34005931305f9c53d"}, + {file = "rignore-0.8.1-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:baf3188d642019c0b9e93230cdca140d2a46dd846544d10c19a7c857dd953eea"}, + {file = "rignore-0.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0089e02bc87182e92b0f676659c748edc0378a390510ee0971e14d60b4e53f78"}, + {file = "rignore-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:66623b4b19027202abef32ffdba74b17722b63f9b2024a6a2d660eb0c4f4abc8"}, + {file = "rignore-0.8.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:4fb05a077bb4b90b51c3fc7ffd4110c3003a90ccf6ec1a8f8a4b2c017a3e8442"}, + {file = "rignore-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:831c9e863acb90176a47db90e55b33f1e6305206d708d784a0161bdf87b8ae5a"}, + {file = "rignore-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:98028ec3e74c216fbba4a01538b7b440fdd6a908ad330dcbacbe591bcc06bb2b"}, + {file = "rignore-0.8.1-cp39-cp39-win32.whl", hash = "sha256:7c1e95cc854f8666383905582d879c1dd3275a983fe2e312e399c02c90fcd0e8"}, + {file = "rignore-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:f109c7f0c579a8fe795300d830eea53e5e28a96c6f819686c33f7d4afaa9c9b5"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6e658a26627f27e15ec54027f051c50818c86b60f940cc20991054e00f5c0152"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1867356484fdc502695f71c2c986cde702e2a14d6db76ca3837a6f066cafb93c"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a82567032d81f559ba4a9f3e96ffcb48ae149dff2f44c58c966b5890eb248895"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9a9812dc25dcfb9bf2e546247f6e38b0c29a0d39864eb62de2a48b2ba41b961"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c735c5586f4306d9a62ad374093d6127b24ddadeaac322684e3e4c923eeb6a58"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a249e142d2d313872e526949b9cdc5363041d64531f53553e702c00747225dc2"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe7a7dcd84affbf312336991202c11922c13aff323e2857c5baf10afc0dd7f2d"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:08acabc3203c68a8bed7689c50b546a4ad7c9144ced2199c1261a149f7a3cef2"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4862e015736af87a4afa1a75023d28363ee34449928f8f5b521d1f9eabb0c826"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c96c3a434b50a7663e011cf6060b0f9ba44fd956b310b230bbca9dd665bb1f53"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:4cc3003a462778a75aadabde87edebc0e1d205c119c703c155bb03a4f3e8be23"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:0dc7765f94d6660574d648c89e28c9932248499d47dad49c2786f2e49231e1d9"}, + {file = "rignore-0.8.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:f7b929db3f8f3fa240b0f328453de0b1b747e51b7992a9198c8412d16f4f0027"}, + {file = "rignore-0.8.1.tar.gz", hash = "sha256:2b6cf58501e9ff1b6a71c3fd66c8a105311e1f23237626fd4c9c00606bb3d30f"}, +] + [[package]] name = "rpds-py" version = "2026.6.3" @@ -3604,29 +6503,29 @@ files = [ [[package]] name = "ruff" -version = "0.16.5" +version = "0.16.8" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b"}, - {file = "ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3"}, - {file = "ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb"}, - {file = "ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025"}, - {file = "ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9"}, - {file = "ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7"}, - {file = "ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde"}, - {file = "ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea"}, - {file = "ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105"}, - {file = "ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29"}, - {file = "ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf"}, - {file = "ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91"}, - {file = "ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a"}, - {file = "ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef"}, - {file = "ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26"}, - {file = "ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f"}, - {file = "ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e"}, - {file = "ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b"}, + {file = "ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38"}, + {file = "ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e"}, + {file = "ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773"}, + {file = "ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f"}, + {file = "ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa"}, + {file = "ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a"}, + {file = "ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b"}, ] [[package]] @@ -3646,6 +6545,84 @@ botocore = ">=1.37.4,<2.0a.0" [package.extras] crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +[[package]] +name = "secretstorage" +version = "3.5.0" +description = "Python bindings to FreeDesktop.org Secret Service API" +optional = false +python-versions = ">=3.10" +files = [ + {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"}, + {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"}, +] + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + +[[package]] +name = "sentry-sdk" +version = "2.69.2" +description = "Python client for Sentry (https://sentry.io)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "sentry_sdk-2.69.2-py3-none-any.whl", hash = "sha256:25bc0a6c55bba0b83398212a7247f4f9f3190f189a1909b68e4807ef4b785895"}, + {file = "sentry_sdk-2.69.2.tar.gz", hash = "sha256:b4d8915a526e626b0b14a2925907554b722d3b8e3e3781570fc35791712af323"}, +] + +[package.dependencies] +certifi = "*" +urllib3 = ">=1.26.11" + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +anthropic = ["anthropic (>=0.16)"] +arq = ["arq (>=0.23)"] +asyncio = ["httpcore[asyncio] (==1.*)"] +asyncpg = ["asyncpg (>=0.23)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +celery-redbeat = ["celery-redbeat (>=2)"] +chalice = ["chalice (>=1.16.0)"] +clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] +google-genai = ["google-genai (>=1.29.0)"] +grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] +http2 = ["httpcore[http2] (==1.*)"] +httpx = ["httpx (>=0.16.0)"] +huey = ["huey (>=2)"] +huggingface-hub = ["huggingface_hub (>=0.22)"] +langchain = ["langchain (>=0.0.210)"] +langgraph = ["langgraph (>=0.6.6)"] +launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] +litellm = ["litellm (>=1.77.5,!=1.82.7,!=1.82.8)"] +litestar = ["litestar (>=2.0.0)"] +loguru = ["loguru (>=0.5)"] +mcp = ["mcp (>=1.15.0)"] +openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] +openfeature = ["openfeature-sdk (>=0.7.1)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] +opentelemetry-experimental = ["opentelemetry-distro"] +opentelemetry-otlp = ["opentelemetry-distro[otlp] (>=0.35b0)"] +pure-eval = ["asttokens", "executing", "pure_eval"] +pydantic-ai = ["pydantic-ai (>=1.0.0)"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] +statsig = ["statsig (>=0.55.3)"] +tornado = ["tornado (>=6)"] +unleash = ["UnleashClient (>=6.0.1)"] + [[package]] name = "shellingham" version = "1.5.4" @@ -3701,15 +6678,111 @@ files = [ {file = "soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74"}, ] +[[package]] +name = "sqlalchemy" +version = "2.0.54" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sqlalchemy-2.0.54-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:24ae093dec196ba37fc2beb0316de53e7871d3d246a50faecbbb53034e41ded2"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cc6532f930c27974e9239e5ce5abebe7600ba9807cea4fcf42f1b6cab18fe7"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e7a76d5dce712ce50435d0f97181eb955ec27d138c004176f01282e063bac52"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f5c09090b1a7c4d389d1431f820931e8df318f82caafc53f9a72c872fef467c5"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:762cfe4d340c56368256d936a98b620a9a5650e49c1c84eba51d6edd17ffefb2"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-win32.whl", hash = "sha256:6b6d4e601c4f6d85e99bb3416107cc9418c5603ca73d4ee0f5f8d79c2a1ed9e8"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-win_amd64.whl", hash = "sha256:03cbf8d9a67da618bd65500a5eb3ddac89caf4c61e99b2f03fa4a1952a0725a9"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d03084f3352dd92048cb19c71d90f116d076c9c7937e0ebc7752c4685de6d38"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92622fbbda1b1fe1632f3402a6e516a93c0e41d9158839c6b3dfb12117f26b72"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5800ddea045c2c860ef1d359a07a3066c7c0c426f45e3abc3874e116cb3c6937"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1019abef05a4b5eafc8eae6fb483167fa28a4dbe5f518d577b744f31a5276a37"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b67749f7da3985a529cefbb1474783cb91ef44371cb9713630bade3de908760d"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-win32.whl", hash = "sha256:2f61a70b3b82e2ec7ad6a4f2301422b9ca93ff06917983e41317bcae878bddf6"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-win_amd64.whl", hash = "sha256:1d887fbd5d248e250807bd801e697fc73e3b44866ce5f093dbc90512e75bde25"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffba7eb2d67c7505e82a0902aa854d8824b74c28a183820d6a8bd3cfd0f812c2"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63cae7210fea9899e0bf35c1f1ae55d3ddd9c6d47cae8b6b43d945afa79dd65b"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68d994e9b0d0423a02a20039631fa6fcbb7fa829a992f7605025774940305d19"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3de32cc6721eb42c3aad35bcfb244bb7a18f66c00f3582aae6281d6287a339b5"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d31a2bc06a854ee52dd86b455be4df7c750b28817e2d1b884e31fff126c4fd7b"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-win32.whl", hash = "sha256:32de6deded25e8b9b11d07428d496ff24dfbc882b8e990c177266948cb5f3d9e"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-win_amd64.whl", hash = "sha256:d65f8ca742ef1e1e14bc417ef59dc2ddf207a7b66b30cfdc6152447314e030cf"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b374e3bc91e246a942592a98ba6a23be76fff21358b00546ac8c0ebc0fd0e00b"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d5458672a6f72db2c087f4a5098b3c8503ea0254186ff29205d63afa9401a4"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cad78d04254967bdbcccbed5e631d88fe4868530946ab0929aa45e9032849518"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:48611087a75d26d798003645c688c7d3cfc26b89dbe4a2c568d6b378d330deae"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d6adf80277372a89910a0f3ccfe960b846d279dc55b366dd5c5ec07f41c84758"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-win32.whl", hash = "sha256:264460333ed0b177cbb1956355d0ee4e0cab83fb415c934ce12a25db2e7be39c"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-win_amd64.whl", hash = "sha256:cf89e92bf0d4204a6afcc17af27b9271ed9c7e34e17d6f80c085d431ea4a1747"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abd6b21bc58e91c1932eb5d6d7f1bd44a551dfec7b6a7f517c3638ccd67233a0"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5417322b3c025dd82918725d3bf09ec105fac95efc195722b8b06e1d9c381139"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f84099e4b04a5c2d44500a2a8302eee5af4bc6fee63e8c6e9cf6786e747280e"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0956dc754d3884da7fe60097110ec7a8a105d26afa2f0844468f4b1598c6912"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:87ba8834318b0d8dc94fc6f405d071b5c08be32a6c3fd68107fd6952ee949615"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-win32.whl", hash = "sha256:842540e4382472f23c79589995752648d14696a8200d0807ed8c5c59c92ade44"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-win_amd64.whl", hash = "sha256:f4e8f955d13af83fb4e35c3472e5377ee22d3445eada1e5e48199588edb69835"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca05f4e7852cf48083b0cf157e4f9504b7068780422a50fa82f45353b8c5e14a"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a8b6417cbb7b735cf91c2b59453c2a554cefa0a8d7bd15aa35740739410d77"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e55a0b96a1577a1e108c91ccdeeb9cd92768f28ce206597311c3bf6d6423abd"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:69cab115c40fd02c5a22c68e4ee630fa6ef9a1650f1de944419aab1f7096fc4f"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e08397c6c42f53b2488acde9108b8bfefd52d7afd1bf2f03d2ffcab7a204aceb"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-win32.whl", hash = "sha256:b9086b8ad48280ef6a7ba68262d5e44f7db1c4cb1973e8cdae8a9f467ae66f51"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-win_amd64.whl", hash = "sha256:b67c1744e453af833667fc1b84de07adb4a64f3536ef52a8ec5ac2b941d43970"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:330d35f9ce815d35cb1daab038d4d7ec0e907f4d7ed0fc8bcb2411d1f23d0b50"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1f455db400289f77ba2f7b62fffafe8875153812d0e3777aa4ff2b34a0fc1f7"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8a4afcc7d714cc3c8a57facdff4c3529f5f93d71e54b7da1e03e022c9089c9"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:34e10af7d274a5c4b7cd0fced5e7361008c5e07d97dd48a93852d5b2f1142a1c"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:7108f410f596c5ac22fe43ba467e864d27c4e1477ae89e90c6c87120b2c1be23"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-win32.whl", hash = "sha256:c1a3455a88f66e4851792bedb098ed942912253d31caed1dbc58afbfa9e875cd"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-win_amd64.whl", hash = "sha256:f3ea33bcf0aa599c1511fe5c9fb126f45aa450419084c4823f786155fe4c79f1"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b6c419c83a87fd901f0b1b5338ffcb82471c3ac32a86bb8883688c18f8eb85d3"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:415239eb2ddbbc508ba4cac97affb91c0f210548fd1731edda6e529b0bb93015"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:279bde5bfedb0f3e0f1bdbcffa2daa39c6c54d90f9408ef3b1802001597199f0"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7b973e4facc2f80e42f5a27b841feb7e202661881a6320580abbe597a28a007f"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:deeab253fe01a770f634c7007c73702df2324c868a79ae756507a9a1a76294fe"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-win32.whl", hash = "sha256:d566099d60cded87d175d4171dc899b9613d2e3b663573364565ca1b27ccd241"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-win_amd64.whl", hash = "sha256:744fb219a390561a57dbbd59cd69a22b5b5b2facfde794c1f79236dd847fa67a"}, + {file = "sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2"}, + {file = "sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.12)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + [[package]] name = "sse-starlette" -version = "3.4.8" +version = "3.4.11" description = "SSE plugin for Starlette" optional = false python-versions = ">=3.10" files = [ - {file = "sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d"}, - {file = "sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c"}, + {file = "sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453"}, + {file = "sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade"}, ] [package.dependencies] @@ -3741,6 +6814,23 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\"" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +[[package]] +name = "sympy" +version = "1.14.0" +description = "Computer algebra system (CAS) in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"}, + {file = "sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517"}, +] + +[package.dependencies] +mpmath = ">=1.1.0,<1.4" + +[package.extras] +dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] + [[package]] name = "tenacity" version = "9.1.4" @@ -3838,28 +6928,28 @@ blobfile = ["blobfile (>=3)"] [[package]] name = "tokenizers" -version = "0.23.1" +version = "0.23.2" description = "" optional = false python-versions = ">=3.10" files = [ - {file = "tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224"}, - {file = "tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e"}, - {file = "tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3"}, - {file = "tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6"}, - {file = "tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959"}, - {file = "tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51"}, - {file = "tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a"}, - {file = "tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4"}, - {file = "tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a"}, - {file = "tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e"}, - {file = "tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288"}, - {file = "tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4"}, - {file = "tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96"}, - {file = "tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948"}, - {file = "tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7"}, - {file = "tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9"}, - {file = "tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa"}, + {file = "tokenizers-0.23.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:85a9a357a3764aecc904ee76bdaf8cf1ad8e5a67a1b929a487c4a39b49ed0e90"}, + {file = "tokenizers-0.23.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:986670e43691469dcee610ea0f846f91a8f84e91fc6f7a48d4c064414c0ec2bf"}, + {file = "tokenizers-0.23.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a37039b5dfc4af84eb3ef0a92f4307e28936c8f9adccba2629d36f652e9bf7a2"}, + {file = "tokenizers-0.23.2-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b7e37ba198f24150f523e1242e83c4970de4a525480586be5dcc24d9add32c5"}, + {file = "tokenizers-0.23.2-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43e4f2071e3cc8d5d86421c874aebc82659bb51a68bcdef5a0da75ee89511ccb"}, + {file = "tokenizers-0.23.2-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:325fee2e0418a9dc6c9ecf736a5f5f0db7875183ace9549ae339da76f7a1fbb7"}, + {file = "tokenizers-0.23.2-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:950d7c9426fa72406a0ffeacdbc0bb9985f5db20eb8b263f29c79aaf83105703"}, + {file = "tokenizers-0.23.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305"}, + {file = "tokenizers-0.23.2-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:12f0835dc2ee694746a76adf7b1567d4346a4a502ebe93fb1f5f80ea49799b78"}, + {file = "tokenizers-0.23.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eb2f9c8a24da020ea8c11a01a19c1c2547912d92121ae4a01cfbca46125dee40"}, + {file = "tokenizers-0.23.2-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f486f402f6f9abee5bb032553736813af0c710a86b2e0ca592634c55cea1f835"}, + {file = "tokenizers-0.23.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:bef235815a067b2648caf6dcc7a71091b0b0fff9ee8057f6451eb9335fae52ef"}, + {file = "tokenizers-0.23.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5c56bda1511921587789163e524d196ed8284174ac23abd7685d5ea8da6c4718"}, + {file = "tokenizers-0.23.2-cp310-abi3-win32.whl", hash = "sha256:debf978920d93ba9c219bd67cc4bbfaf912c9039e41e7a28b91ec15e3728c95a"}, + {file = "tokenizers-0.23.2-cp310-abi3-win_amd64.whl", hash = "sha256:2e96f5699d5249c9c64aa8412e044f727aae3a4098cf830f9901ec1afc361cde"}, + {file = "tokenizers-0.23.2-cp310-abi3-win_arm64.whl", hash = "sha256:e49c394456dd9985787fec76132438ba3fb8911f857b1bf3d40119f9292d41aa"}, + {file = "tokenizers-0.23.2.tar.gz", hash = "sha256:7f0f085686b9de0d0079e6f874ae053600db64c5d13049e0bbc0119926d25aac"}, ] [package.dependencies] @@ -3870,15 +6960,36 @@ dev = ["tokenizers[testing]"] docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"] testing = ["datasets", "numpy", "pytest", "pytest-asyncio", "requests", "ruff", "ty"] +[[package]] +name = "toktok-rs" +version = "0.1.4" +description = "Fast, exact BPE tokenizer for OpenAI encodings — Rust core, tiktoken-identical ids" +optional = false +python-versions = ">=3.11" +files = [ + {file = "toktok_rs-0.1.4-cp311-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d52814fa973c0dec408acc80797ddba12cdc26aa0cc9b2fe58c30458f06e7590"}, + {file = "toktok_rs-0.1.4-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f738105be418632a34d144e812803e513ba1e41228aa1f13656b5d6642b19fb1"}, + {file = "toktok_rs-0.1.4-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43ab1d12f91faada33eb59648088e294bdb1e235cc31a0a37ce7f6b1c950aebf"}, + {file = "toktok_rs-0.1.4-cp311-abi3-win_amd64.whl", hash = "sha256:76b3bc9cb6520d7bd8ddc05e1677fa15fe48c6fdc30c606a3261c5a9122fc553"}, + {file = "toktok_rs-0.1.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e99175c2070ac7dc321010afd0a6209d665257a7790bce79266e4b0209d8b5fe"}, + {file = "toktok_rs-0.1.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e64c33de11a5b2157117f04d18a8700ab08d701b302595a1d3cb40f518c59546"}, + {file = "toktok_rs-0.1.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:59c8266118a7f6d6a66bf9f4dc103eeb9ed05956bd2f24a6c7b491b4c7bd1c45"}, + {file = "toktok_rs-0.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:d701e1cb30bbd5e4948d1d9a8a58703d49cfe87e0877b94207f6f45b62d1bc69"}, + {file = "toktok_rs-0.1.4.tar.gz", hash = "sha256:5669460cae0c765c6183a648aeb0f12bb62743f1df450f90ab051e62af83c426"}, +] + +[package.extras] +numpy = ["numpy (>=1.21)"] + [[package]] name = "tqdm" -version = "4.70.0" +version = "4.70.1" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.8" files = [ - {file = "tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953"}, - {file = "tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220"}, + {file = "tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73"}, + {file = "tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4"}, ] [package.dependencies] @@ -4149,6 +7260,17 @@ files = [ [package.extras] core = ["tree-sitter (>=0.23,<1.0)"] +[[package]] +name = "truststore" +version = "0.10.4" +description = "Verify certificates using native system trust stores" +optional = false +python-versions = ">=3.10" +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + [[package]] name = "typer" version = "0.27.2" @@ -4166,6 +7288,20 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} rich = ">=13.8.0" shellingham = ">=1.3.0" +[[package]] +name = "types-requests" +version = "2.33.0.20260906" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.10" +files = [ + {file = "types_requests-2.33.0.20260906-py3-none-any.whl", hash = "sha256:9f53622652bd921ead7a54d665be1b8d518165c8b51cc9d68d944cd6b6bfa8fb"}, + {file = "types_requests-2.33.0.20260906.tar.gz", hash = "sha256:76ab8a0fb736744a0c3deee7aa57b2927e301f078d9e61f5391b3e92002416b9"}, +] + +[package.dependencies] +urllib3 = ">=2" + [[package]] name = "typing-extensions" version = "4.16.0" @@ -4193,24 +7329,53 @@ typing-extensions = ">=4.15.0" [[package]] name = "tzdata" -version = "2026.3" +version = "2026.4" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"}, - {file = "tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415"}, + {file = "tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81"}, + {file = "tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79"}, +] + +[[package]] +name = "tzlocal" +version = "5.4.4" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.10" +files = [ + {file = "tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15"}, + {file = "tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["zest.releaser"] +testing = ["check_manifest", "pyroma", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "ruff"] + +[[package]] +name = "uncalled-for" +version = "0.4.0" +description = "Async dependency injection for Python functions" +optional = false +python-versions = ">=3.10" +files = [ + {file = "uncalled_for-0.4.0-py3-none-any.whl", hash = "sha256:16c4bb3337532e4bd5569adc192285976f3ad5305402256d34c67a12b5c968bd"}, + {file = "uncalled_for-0.4.0.tar.gz", hash = "sha256:335b95bd2422332ec210d518f314a16e4c640921c39fc8bf2ad095bd3538f4af"}, ] [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" files = [ - {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, - {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, + {file = "urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3"}, + {file = "urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63"}, ] [package.extras] @@ -4221,133 +7386,582 @@ zstd = ["backports-zstd (>=1.0.0)"] [[package]] name = "uvicorn" -version = "0.52.4" +version = "0.53.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.10" files = [ - {file = "uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1"}, - {file = "uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86"}, + {file = "uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e"}, + {file = "uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e"}, ] [package.dependencies] click = ">=7.0" h11 = ">=0.8" +httptools = {version = ">=0.8.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.20", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=13.0", optional = true, markers = "extra == \"standard\""} [package.extras] standard = ["httptools (>=0.8.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1)", "watchfiles (>=0.20)", "websockets (>=13.0)"] +[[package]] +name = "uvloop" +version = "0.22.1" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7"}, + {file = "uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f"}, +] + +[package.extras] +dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx_rtd_theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=25.3.0,<25.4.0)", "pycodestyle (>=2.11.0,<2.12.0)"] + +[[package]] +name = "watchfiles" +version = "1.3.0" +description = "Simple, modern and high performance file watching and code reload in python." +optional = false +python-versions = ">=3.10" +files = [ + {file = "watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441"}, + {file = "watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c"}, + {file = "watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5"}, + {file = "watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca"}, + {file = "watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30"}, + {file = "watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95"}, + {file = "watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29"}, + {file = "watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1"}, + {file = "watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935"}, + {file = "watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3"}, + {file = "watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509"}, + {file = "watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661"}, + {file = "watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61"}, + {file = "watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc"}, + {file = "watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6"}, + {file = "watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80"}, + {file = "watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef"}, + {file = "watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a"}, + {file = "watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26"}, + {file = "watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3"}, + {file = "watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977"}, + {file = "watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7"}, + {file = "watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4"}, + {file = "watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a"}, + {file = "watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694"}, + {file = "watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e"}, + {file = "watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39"}, + {file = "watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0"}, + {file = "watchfiles-1.3.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:45726c6b5a8d67c8087eb2933c4398948546d04206ef40f629dc4881d65d23d1"}, + {file = "watchfiles-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6398d250994baff581147ccba846223076fe6492ca93c0d1b3588fcf5355eb5c"}, + {file = "watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:591de392458bca26f6130fea4e667c06b657e873ad334ea06fad5a79f5086cef"}, + {file = "watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ec46a1ff4c1d81405a5fc645abdbff5e4e9388f7c9a6cdb392316a7c29b884f"}, + {file = "watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + +[[package]] +name = "websockets" +version = "16.1.1" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.10" +files = [ + {file = "websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d"}, + {file = "websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731"}, + {file = "websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4"}, + {file = "websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc"}, + {file = "websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a"}, + {file = "websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9"}, + {file = "websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f"}, + {file = "websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c"}, + {file = "websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a"}, + {file = "websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512"}, + {file = "websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be"}, + {file = "websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81"}, + {file = "websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57"}, + {file = "websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a"}, + {file = "websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00"}, + {file = "websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b"}, + {file = "websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847"}, + {file = "websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383"}, + {file = "websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3"}, + {file = "websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747"}, + {file = "websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7"}, + {file = "websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1"}, + {file = "websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df"}, + {file = "websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49"}, + {file = "websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e"}, + {file = "websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87"}, + {file = "websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea"}, + {file = "websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68"}, + {file = "websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8"}, + {file = "websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293"}, + {file = "websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3"}, + {file = "websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985"}, + {file = "websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9"}, + {file = "websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328"}, + {file = "websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc"}, + {file = "websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573"}, + {file = "websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999"}, + {file = "websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43"}, + {file = "websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217"}, + {file = "websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737"}, + {file = "websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7"}, + {file = "websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231"}, + {file = "websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869"}, + {file = "websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9"}, + {file = "websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e"}, + {file = "websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d"}, + {file = "websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5"}, + {file = "websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3"}, + {file = "websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57"}, +] + +[[package]] +name = "winloop" +version = "0.6.4" +description = "Windows version of uvloop" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "winloop-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:5339f42586c9817e510a05c83f9fea8ff966a25ab499e73ef9d523cb9a3814de"}, + {file = "winloop-0.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:2b4b717e65b0c9c45fb1fcb3ebb7d12191872084088c6ca19477a38b89d72506"}, + {file = "winloop-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:6871e87acb7211688b34df71647673ff67e58ca76887b39cfd1e04624b06ca54"}, + {file = "winloop-0.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:95580d21d951815eea3401113cd1e30228cde5b227c4f0c9b7d8a6973e799f39"}, + {file = "winloop-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:4670143d095948af5a68440deb275c73b602bb4df14e575e9628562fb1149a52"}, + {file = "winloop-0.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:543469da05f16d39f458df16bf8ac9a7da302342ac962de1f0192508e2bd4256"}, + {file = "winloop-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:ef6f2dfe5bf662791c2f32086e57dd788aa22d614881a6a3653b916d668fa70f"}, + {file = "winloop-0.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:4520ab3d501d54176fa28b1b41527ecfe6773e5148ff8d719d0be9b3b3b1414b"}, + {file = "winloop-0.6.4-cp314-cp314-win_amd64.whl", hash = "sha256:13b391ee3897630cec2391075641096fb7e5d09c3a92d3008f110eeeea32eeaf"}, + {file = "winloop-0.6.4-cp314-cp314-win_arm64.whl", hash = "sha256:c16df8143a9f9d1d1f7c51bc947720999db58d4d5b936b86b7bc9258de528fe8"}, + {file = "winloop-0.6.4-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3c95c8b07637ce3bcd50b69903f8933f6ab1ef0da9548f8f567dcdf0f763e"}, + {file = "winloop-0.6.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d5d642e5635dc98653a52dbe85a1048068cdf797c9a42b4bf4b9f6a0f7226562"}, + {file = "winloop-0.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:1cf8a5be289bf5f0536270c5eba7258804efe76442c8698433830c863a34e3e2"}, + {file = "winloop-0.6.4-cp39-cp39-win_arm64.whl", hash = "sha256:1d54beb8420893258605ba1e9c11087d3038ac20bce165c4db306fd50a14b5f2"}, + {file = "winloop-0.6.4.tar.gz", hash = "sha256:20a5de55b5be63ed559d041a7b2256b67bd42f3cf3a118d8c7a71e5442c8e9e9"}, +] + +[package.extras] +dev = ["Cython (==3.3.0)", "packaging", "setuptools (>=60)"] +docs = ["Sphinx (>=4.1.2,<9.2.0)", "sphinx_rtd_theme (>=0.5.2,<3.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["aiohttp (>=3.10.5)", "flake8 (>=5,<8)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0,<26.5)", "pycodestyle (>=2.9,<2.15)"] + +[[package]] +name = "wrapt" +version = "2.4.1" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.9" +files = [ + {file = "wrapt-2.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7aaff952dd6930fc87b52d44bf72ac8426e45e90058fe0f2da446889738eff44"}, + {file = "wrapt-2.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8d67b916f2f777a6d31067e842598e059f1a0e29022c86454564fc9efb9a0c19"}, + {file = "wrapt-2.4.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2945f4bce1518e09eb25458dbd09800f7aa3fee81a5a5cefe66c4e82fe8cb69c"}, + {file = "wrapt-2.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7ce62ee24eddb76c317ec790c44f4fbe9f34ee0a5dae37b30c65a2b8f3e3604"}, + {file = "wrapt-2.4.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cbee8d00840ac89846f6567c75506260a15fbb4dc74de180678fafe09458d47"}, + {file = "wrapt-2.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b78dd8d058468156003ee8c212dab334b6eea18cd9c5db3b81f6c90a9408afcc"}, + {file = "wrapt-2.4.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4d48f1720569b4f6e2df97783fd270554abc4c6ebd74b2e5f15e5110283aaf78"}, + {file = "wrapt-2.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:de5d2d7f12557c9e994b4037330efa5700fccb9f2b9df0bd4ccf384eaf73c254"}, + {file = "wrapt-2.4.1-cp310-cp310-win32.whl", hash = "sha256:5c0217b8c12952bf4d137a19ff2c61cc9b626f52c09c43f0289769c9f5f808aa"}, + {file = "wrapt-2.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:0aacc1af512e040fb0b3c4593a8bded9aaa04235025f08a54eda9d8c1103d3c6"}, + {file = "wrapt-2.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:6f32a45d0e883918387aabf5384952e5a93ae40be0a6fb5f1eddc8281af4c714"}, + {file = "wrapt-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b6ea396edcfb305698f44ef0a47b9adaca15d30386ce093ca606844f776d7236"}, + {file = "wrapt-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b86bfe741840cec6d8d5858b1eb53a325f564f08d7e6fb5694981961edcec230"}, + {file = "wrapt-2.4.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ec7aeb92e956810eedc268fe75f8c11f05d6c3d864d0995a53eb6623b3b6a51"}, + {file = "wrapt-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba66eccadd4b857a845e309c08457bb71fa8300000e508e3a86cd695bf9c7515"}, + {file = "wrapt-2.4.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3d5c8f3b4eae814213d4097f134be03aeb23816e6358213454ec5e62e8449fb6"}, + {file = "wrapt-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3bc7ab495e564449f39db2f54c1033d7190c944736d525f3bd06cefaf0134df0"}, + {file = "wrapt-2.4.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2e2e694057bcfd46f43e28f7b50fad8f5fec7113cc6445fdf251219539a08f28"}, + {file = "wrapt-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f5577fdc702427698c81c8c9c3b8479cbecb3dfd0f697be8c1d09c89f5838cc"}, + {file = "wrapt-2.4.1-cp311-cp311-win32.whl", hash = "sha256:e78c0f6d66a564bab245faf170d55595df6fc237117b99d97fd838678f0906f0"}, + {file = "wrapt-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:1f1851be0e593d65e68d7c1bc9d3b971e19fefa219f617e490d75e80e467ee14"}, + {file = "wrapt-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:7ff549316d94c404999f96bb0a6993222566231ff4bede1b6f68908d1958a08f"}, + {file = "wrapt-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7e86fbc2ac8a363ea04abf631fad82720e16b17a25020f32dbe9b24a2ed2b0e3"}, + {file = "wrapt-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24389748f0b9d5b67e478fad4fc8b3f1108422ef80716e48eead6cebcebbff08"}, + {file = "wrapt-2.4.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30d11c289b013bf384ff1a1a6553f150d0b855901708a9bef667a5680f8247c9"}, + {file = "wrapt-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9356dbb59199a0e4709de35fa4a1ac1a88ef6da99711a397f5b009233faff326"}, + {file = "wrapt-2.4.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8342f332dada211f64b74609e332d727b13315e9a83177f7918bf68c59f815f2"}, + {file = "wrapt-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2f86e328c482bc5383b4eda5094be0bed3617fc3076aa9225ff1a9eb6372de9b"}, + {file = "wrapt-2.4.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4dc92697444ee380544fbb43c86612d8486529aadf917c22b5524141d4af074c"}, + {file = "wrapt-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:edd03758a7578526642508b8833d43496fdfba0f64e0025dfca153a7c1777735"}, + {file = "wrapt-2.4.1-cp312-cp312-win32.whl", hash = "sha256:5d83e412665aeb1e854eefbf1564d0d67872d9994b502a0bce96e6ff7f4970b7"}, + {file = "wrapt-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:b4e7efdd476ac631a0181551fd9aace844765ea3ce2b5133b194fae4421e8ad0"}, + {file = "wrapt-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:38819761401baa2d11916d7265b82f23265f8fe5a31c431dd7c24a8863c65f88"}, + {file = "wrapt-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:55f36bb1461f93beaf18d818e568b5de343dd8fade7456773d201a38ee723bd3"}, + {file = "wrapt-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:53e15cd74bd6b84d7fa90b93dda7334d85f4641fae97632de8aa61d268dfd145"}, + {file = "wrapt-2.4.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be6cdd7121adc89a6f52e3c2f4e26a2d4dcdc1c0fde47e3156234db8939e4cdc"}, + {file = "wrapt-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03b5598edd435373278731d0d53449ce7a9626bc48d5548e4a71124ee3e526a1"}, + {file = "wrapt-2.4.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fdc997819a6df4c65bdb0a1c5601c98b479ee34cc2f94ffa98a767034ad6366d"}, + {file = "wrapt-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3bfc6907ebed560d2d3f677c3b17bf6199679163b6c6e475035c9dca497d1697"}, + {file = "wrapt-2.4.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a0c3b217332cf0c4df085fec41c126bf7507d6c1ca0efb3ba8d2b3fd234d4e73"}, + {file = "wrapt-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a23b89621cfeb3329b1a290596bf402e61d7d5a647a65ca8ea735e48771b71d4"}, + {file = "wrapt-2.4.1-cp313-cp313-win32.whl", hash = "sha256:bc67d4872af5ab2dc1b88904097b92ac00e7658fdf010dee36807807fe882ac4"}, + {file = "wrapt-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:1fe758b9c2d49138231ec3efabd106fec665f86fec50b3d02d7edd75f08a69ca"}, + {file = "wrapt-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c974c36e8205255a3947dad9c2fe000431b57dd522946e56c192174a7f92a0f"}, + {file = "wrapt-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6c35541cc729964c65c2b9b1f9cf317811039abc2da13b4557f20f21cdc292b"}, + {file = "wrapt-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6b9df84f0a96763159cccb8e3b0ed83cc950c7c8bf82d6e45428372a805b3224"}, + {file = "wrapt-2.4.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78af62413095d0a57077606654ce85e273deda8e2bfa28fdb04257b962d94ef2"}, + {file = "wrapt-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60702ebc914d0bb01aa48f5c1785ceaaaa505e0a841b444a69d6ceb5de4097e"}, + {file = "wrapt-2.4.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8c4b44e4be7680fc496816e824b6ed134d781c4da296e44b75399196e5c248f1"}, + {file = "wrapt-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fb5b3f94258bcf71db902795f4a71151c7a74d9fe21fffa00752d2bd286866f8"}, + {file = "wrapt-2.4.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f1556a96b20d5bfdc9cb5dcd0a3ec65cbbac8a4eebcd3cd34efdc91c9519f660"}, + {file = "wrapt-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:094a606d0bf1c4b847b3a743d22fc69cb164015b8c70cf6f20a534d30889ed47"}, + {file = "wrapt-2.4.1-cp314-cp314-win32.whl", hash = "sha256:b0ee076be124406a7f97ca663c4a3ba32b6bcdd9102ca82497feaca79e9cb33c"}, + {file = "wrapt-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:d2d6f9abaa52de05090a2b4a4c1d0e858a268c4de69eec277506af9fbcccff91"}, + {file = "wrapt-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:3152b2e94d733a9bd70dbd1c95f148266f079a70e9ffca2a9c5dc421ade1b4e6"}, + {file = "wrapt-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:37ff91b390400463ecd4d080ea823314510b6843ac53e6e35cc09bba1805d466"}, + {file = "wrapt-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e0a518cac3e789443af54fc77f23e66f17d80192c281b160b42058f11fabccc5"}, + {file = "wrapt-2.4.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ecdeb8a1ec13397421e6f925412186bd87ab86d63517e6825b6d7bccf781f26"}, + {file = "wrapt-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c15c4ede3fde08723cabab0a892d4b75d35b5a6f0a51c84e6542ac0bdc0507aa"}, + {file = "wrapt-2.4.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e32c5951c36fed88b6c603dc0bab209a62dc3217bd8762413634725fe28f2f3c"}, + {file = "wrapt-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:898513db90d55a4ed3009312d41c12932b8163edbae3535280046d47aaff774f"}, + {file = "wrapt-2.4.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:42d01574bd4bcafc3476e77a95c6c0dd6167101991401325fc5591e2db21a91d"}, + {file = "wrapt-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:68a403adbeb4dd2654d6d108e43e69f0f90e6d34cb7588e0e6589109f6985e67"}, + {file = "wrapt-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:7cb3035b332bc9d21478600ba82c7c71e2d074ed9b4e76364297f54598792227"}, + {file = "wrapt-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:707d2bef68deddd0fc74a81103d91b286a69ac5a9ff0f0a6dad66f8787e86697"}, + {file = "wrapt-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:28cb1c2713b4377bf03ddcb3e76d8216e4bb334199066c6122be7eed2f72de8c"}, + {file = "wrapt-2.4.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:96a5023fa63ca2f095f7776c8bfaa1694547577f762646c375abafd8f8ae649b"}, + {file = "wrapt-2.4.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b21924949dedc3ac63725e09b9b0a130e771975f03432789344ed3422c46008"}, + {file = "wrapt-2.4.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2f725af353bb3319c528ee69dbff838599426974288b281a3258d5397b18cb63"}, + {file = "wrapt-2.4.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20327e162ef7953fae56b31a43ca457f94ed1fc7a206d01e3d5c8412d1b91572"}, + {file = "wrapt-2.4.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2cedb743dbdfb9b6d4f11acd8cb6329460264429777e81ea2e86b1b72ea503af"}, + {file = "wrapt-2.4.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5a54744b1193505f19016194979b773ee3754a199e9ad90db18f2e9a18fffa16"}, + {file = "wrapt-2.4.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b0d38d9cc23e9781e6584f303e5c5a9f0d45b85de92ad45678c4dde8d49d9535"}, + {file = "wrapt-2.4.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a524ca32f0bcdde2b728d9f81f9527d4dd24b13f15d180f05d08cdc01d1cebe0"}, + {file = "wrapt-2.4.1-cp315-cp315-win32.whl", hash = "sha256:47c267617551e906de72f6e7265aa3bce84c63d44513d2d2e735e943422aa0a2"}, + {file = "wrapt-2.4.1-cp315-cp315-win_amd64.whl", hash = "sha256:ee437bd7fd050823ef731aad20e968ca8fe670b95e1841f5d201bfdbad4a4e96"}, + {file = "wrapt-2.4.1-cp315-cp315-win_arm64.whl", hash = "sha256:ebf3b703752b53366fd02b7fbd8c447428e0349c9af3fc17c7a05076dbdd749a"}, + {file = "wrapt-2.4.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:0f5990f5db090f069fcd577cc61cc8d463db73cde132abba9033b0a264fc06d0"}, + {file = "wrapt-2.4.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef19a2590b195ac294deff8ee350a027a479afdd2ef2170ce3900af92406e110"}, + {file = "wrapt-2.4.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:432f402f9b6014403cacf9fd18a6bf089c77964330eae951a155131ab0414f5d"}, + {file = "wrapt-2.4.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb7c0f8bdd21e954bad89d554cfb7431c2f8179842853f199841ab90cbebe914"}, + {file = "wrapt-2.4.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e82a1669d63b79a2b53041bbb6ea096b5763cab3ca698ed7ac244b3acb7cd51"}, + {file = "wrapt-2.4.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b19e71c914c435d2c5652caea386c9bf2807979e957183f5bb06d5ed5fa8b55e"}, + {file = "wrapt-2.4.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a1e4870d3368c6c918f38e308d5dcea970ea5b908ce889c21412f3a517ebf0c3"}, + {file = "wrapt-2.4.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2286e8e4a937966706463d1bcea30dfefee529e6e73e097a18e9e066a07ae6c2"}, + {file = "wrapt-2.4.1-cp315-cp315t-win32.whl", hash = "sha256:80afa3b7010e82899044a2a189c468a0cd8c89980398a59ba3b96b451a6dc5e9"}, + {file = "wrapt-2.4.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3593b43fabab6b59fe77e38f7e40974aae120c30cea3fd1ceaea6621ee96be00"}, + {file = "wrapt-2.4.1-cp315-cp315t-win_arm64.whl", hash = "sha256:ac1939ccf3e1c33f463706fbf52db5075f5eefb6042bcc1f9cf48c2c20ef478c"}, + {file = "wrapt-2.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c2ab24ed5d3a19f31fb815419a61b27904b5b0222eaacac5e1853d5361035df8"}, + {file = "wrapt-2.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:69fa244ddc1f9c3e8d390e169ebbf018eee53c2e05bda61c980418df89585ccf"}, + {file = "wrapt-2.4.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:355e766371657ceca791041b82a7943aa3c8a0d879359c112e63bf3c1c8cfea7"}, + {file = "wrapt-2.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52e474424ea1863199e8635a3915b279164929e71b81af97f2105cab1ce78ef2"}, + {file = "wrapt-2.4.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e59f1d5db7bba65f57a2b121eea839214465e878d957d9ab29ad039279ec2a66"}, + {file = "wrapt-2.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e5b347c2184906daedae9a2f9fac67cf02c61f7b6498f09cafdcdd6b404ea9be"}, + {file = "wrapt-2.4.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c89bdfe72c2b96bbe431764a90a25723570a896b605d08e08bca6fe150b8a33"}, + {file = "wrapt-2.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4935ad7324d8637715f7cefc35ad65be80900f91d1631947b01bf70651daa328"}, + {file = "wrapt-2.4.1-cp39-cp39-win32.whl", hash = "sha256:c57abea6aa7de8c584b32958d8f606a08d659d3a5a1296ba2038ea9c185a417e"}, + {file = "wrapt-2.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:7033c3c5eb7cd301d9bea8a01660fbe9561c9a3a069d1733e9a712917def0aa2"}, + {file = "wrapt-2.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:730195aa58afaa7dbb5d351e8728cec6763be2605ef82eec9f134950e3c8618e"}, + {file = "wrapt-2.4.1-py3-none-any.whl", hash = "sha256:1e84ec5d89a0a07a0ef6bcd343f5c8ecdc95601d71de3058cdc63274e86c193c"}, + {file = "wrapt-2.4.1.tar.gz", hash = "sha256:fd6390aab9e8aa40c52eff3c180f098e8d9f5894b1fd4c4fd2c207067b33ed16"}, +] + +[package.extras] +dev = ["pytest", "setuptools"] + +[[package]] +name = "wsproto" +version = "1.3.2" +description = "Pure-Python WebSocket protocol implementation" +optional = false +python-versions = ">=3.10" +files = [ + {file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"}, + {file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"}, +] + +[package.dependencies] +h11 = ">=0.16.0,<1" + +[[package]] +name = "xlrd" +version = "2.0.2" +description = "Library for developers to extract data from Microsoft Excel (tm) .xls spreadsheet files" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9"}, + {file = "xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9"}, +] + +[package.extras] +build = ["twine", "wheel"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "xlsxwriter" +version = "3.2.9" +description = "A Python module for creating Excel XLSX files." +optional = false +python-versions = ">=3.8" +files = [ + {file = "xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3"}, + {file = "xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c"}, +] + [[package]] name = "yarl" -version = "1.24.5" +version = "1.25.1" description = "Yet another URL library" optional = false python-versions = ">=3.10" files = [ - {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750"}, - {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2"}, - {file = "yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871"}, - {file = "yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0"}, - {file = "yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e"}, - {file = "yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2"}, - {file = "yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621"}, - {file = "yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba"}, - {file = "yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950"}, - {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00"}, - {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed"}, - {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440"}, - {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1"}, - {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6"}, - {file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d"}, - {file = "yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224"}, - {file = "yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13"}, - {file = "yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3"}, - {file = "yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a"}, - {file = "yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840"}, - {file = "yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966"}, - {file = "yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723"}, - {file = "yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb"}, - {file = "yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780"}, - {file = "yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e"}, - {file = "yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2"}, - {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58"}, - {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61"}, - {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6"}, - {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f"}, - {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077"}, - {file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd"}, - {file = "yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25"}, - {file = "yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a"}, - {file = "yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d"}, - {file = "yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec"}, - {file = "yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c"}, - {file = "yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54"}, - {file = "yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12"}, - {file = "yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d"}, - {file = "yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1"}, - {file = "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9"}, - {file = "yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027"}, - {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b"}, - {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293"}, - {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e"}, - {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b"}, - {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce"}, - {file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba"}, - {file = "yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b"}, - {file = "yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c"}, - {file = "yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2"}, - {file = "yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb"}, - {file = "yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075"}, - {file = "yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff"}, - {file = "yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448"}, - {file = "yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f"}, - {file = "yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd"}, - {file = "yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16"}, - {file = "yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213"}, - {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24"}, - {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385"}, - {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c"}, - {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4"}, - {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144"}, - {file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4"}, - {file = "yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740"}, - {file = "yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1"}, - {file = "yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76"}, - {file = "yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d"}, - {file = "yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75"}, - {file = "yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9"}, - {file = "yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede"}, - {file = "yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca"}, - {file = "yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027"}, - {file = "yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9"}, - {file = "yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41"}, - {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373"}, - {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36"}, - {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0"}, - {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5"}, - {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5"}, - {file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4"}, - {file = "yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad"}, - {file = "yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f"}, - {file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88"}, - {file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba"}, - {file = "yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928"}, - {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f"}, - {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95"}, - {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc"}, - {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da"}, - {file = "yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a"}, - {file = "yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0"}, - {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498"}, - {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104"}, - {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331"}, - {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550"}, - {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6"}, - {file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047"}, - {file = "yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104"}, - {file = "yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688"}, - {file = "yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7"}, - {file = "yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f"}, + {file = "yarl-1.25.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:142c06c4d6a35ee3ec5da08499805e879cb3ca7c1fbfbecb0140fe72403818d6"}, + {file = "yarl-1.25.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24ce942011a61953e7d313438038f4d32ff21387b775f58a957f7a07dd55ef95"}, + {file = "yarl-1.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e23c82b63cd7652fc24d33ed6cc17099d607aa3b4fc4ddc75e95062f3d82df4"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee202350cf57abf0e9502a41601841019c25d3db7ff52d980aaf31446254059"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5df89f769cc8ff94c3d7e7603386fba309d25ce5240132d26c15baa8d0e96c4c"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83e9f4a25085bd4b7214701a0794ff1f50fc633ffb8bdfebf07abdd81c2db126"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e636b64d24fd9c38053c5e389a1174c66361fa49dcfd220f4dd35b4abde7cb89"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5637ca8d0bd7fb72648a6c7934af4baaccb697657f7438c9d264fc2abb8b0b1"}, + {file = "yarl-1.25.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:683e362b8ba453080f7489c66f4ea794e751c35b72e7eab3575ef784c2fbc7fb"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df23df54b5114a17c2d0ef192433e2e5a9f0c5178c32375e90b7cfc965f349d0"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f53dcd26694f148f738edc052b5a69234833e739f10f4c3287bdfd8ec0f7b326"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b8075fe90bc08e40b8b8a1874fab42ee4c7b56af05c5886e9cc841397f916908"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a8c2b841478068440d8b733005d13a5ef535b9928cbc05f17182d410f32ba449"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ca32926d7d77bcc8838425c4c95e040a3ace1cb7dfdae599013458dcda2607ca"}, + {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:192a866877a49993949ef1975864ad8728bea28ee810f6abe1a0729c2b500426"}, + {file = "yarl-1.25.1-cp310-cp310-win_amd64.whl", hash = "sha256:3f4d48a6112712973e676bd792121fee470e432d749177162d9949d5c9460a1b"}, + {file = "yarl-1.25.1-cp310-cp310-win_arm64.whl", hash = "sha256:48796ea00a303961507dc6c8437c4b325a6fc3f95f7c36c71b91ea9a8150963c"}, + {file = "yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865"}, + {file = "yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d"}, + {file = "yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2"}, + {file = "yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1"}, + {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b"}, + {file = "yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e"}, + {file = "yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35"}, + {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4"}, + {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d"}, + {file = "yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707"}, + {file = "yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03"}, + {file = "yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85"}, + {file = "yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5"}, + {file = "yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26"}, + {file = "yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab"}, + {file = "yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98"}, + {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa"}, + {file = "yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9"}, + {file = "yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe"}, + {file = "yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e"}, + {file = "yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f"}, + {file = "yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57"}, + {file = "yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62"}, + {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233"}, + {file = "yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b"}, + {file = "yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414"}, + {file = "yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa"}, + {file = "yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287"}, + {file = "yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6"}, + {file = "yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3"}, + {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3"}, + {file = "yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371"}, + {file = "yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6"}, + {file = "yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc"}, + {file = "yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc"}, + {file = "yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf"}, + {file = "yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b"}, + {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c"}, + {file = "yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee"}, + {file = "yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d"}, + {file = "yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8"}, + {file = "yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4"}, + {file = "yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225"}, + {file = "yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64"}, + {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f"}, + {file = "yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd"}, + {file = "yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25"}, + {file = "yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3"}, + {file = "yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7"}, ] [package.dependencies] @@ -4377,4 +7991,4 @@ type = ["pytest-mypy (>=1.0.1)"] [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.14" -content-hash = "885f769ac40c22e1d6298ee073d3383bd706df862f4c56d1d7300200c55c2040" +content-hash = "3b7642a471af522321225111c7772c667121dcee7cc77e848c02299da8e8dc0d" diff --git a/pyproject.toml b/pyproject.toml index d219a2b..328d5c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ beautifulsoup4 = ">=4.12.0" markdownify = ">=0.15.0" ddgs = ">=8.0.0" pg0-embedded = ">=0.15" +hindsight-api-slim = ">=0.10" [tool.poetry.group.dev.dependencies] pytest = ">=8.0.0" diff --git a/src/codespy/agents/memory/cerebral/__init__.py b/src/codespy/agents/memory/cerebral/__init__.py new file mode 100644 index 0000000..aa18260 --- /dev/null +++ b/src/codespy/agents/memory/cerebral/__init__.py @@ -0,0 +1,3 @@ +from codespy.agents.memory.cerebral.cerebral import Cerebral + +__all__ = ["Cerebral"] diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py new file mode 100644 index 0000000..d109cd8 --- /dev/null +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -0,0 +1,197 @@ +"""Cerebral – Hindsight semantic memory for episodes. + +Uses MemoryEngine (direct Python API) sharing the same PostgreSQL +instance as the episodic store, under the ``semantic`` schema. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import threading +from typing import TYPE_CHECKING + +from hindsight_api import MemoryEngine +from hindsight_api.engine.cross_encoder import RRFPassthroughCrossEncoder +from hindsight_api.engine.embeddings import LiteLLMSDKEmbeddings +from hindsight_api.extensions.builtin.tenant import DefaultTenantExtension +from hindsight_api.models import RequestContext + +if TYPE_CHECKING: + from codespy.agents.memory.hippocampus.episode import Episode + +logger = logging.getLogger(__name__) + +HINDSIGHT_SCHEMA = "semantic" + + +class Cerebral: + """Retains episode observations and artifacts in Hindsight semantic memory. + + Uses MemoryEngine directly — no HTTP server. All calls are in-process + against the same PostgreSQL instance under the ``semantic`` schema. + """ + + def __init__( + self, + database_url: str, + llm_provider: str, + llm_model: str | None = None, + llm_api_key: str | None = None, + llm_base_url: str | None = None, + bank_id: str = "codespy", + embeddings_model: str = "openai/text-embedding-3-small", + ): + # Fail fast: test the embedding model before building MemoryEngine. + # A bad model name, missing creds, or unavailable region surfaces here + # with a clear error instead of buried inside MemoryEngine.initialize(). + import litellm + try: + litellm.embedding(model=embeddings_model, input=["test"], timeout=15) + except Exception as exc: + raise RuntimeError( + f"Cerebral: embedding model {embeddings_model!r} is not available. " + f"Set MEMORY_HINDSIGHT_EMBEDDINGS_MODEL to a valid litellm embedding model. " + f"Error: {exc}" + ) from exc + + # Start a dedicated event loop in a background thread. + # The MemoryEngine's asyncpg pool binds to this loop; it must + # outlive every operation, so we never close it until close(). + self._loop = asyncio.new_event_loop() + self._loop_thread = threading.Thread( + target=self._loop.run_forever, daemon=True, name="cerebral-loop" + ) + self._loop_thread.start() + + self._engine = MemoryEngine( + db_url=database_url, + memory_llm_provider=llm_provider, + memory_llm_model=llm_model, + memory_llm_api_key=llm_api_key, + memory_llm_base_url=llm_base_url or None, + embeddings=LiteLLMSDKEmbeddings(model=embeddings_model), + cross_encoder=RRFPassthroughCrossEncoder(), + tenant_extension=DefaultTenantExtension(config={"schema": HINDSIGHT_SCHEMA}), + skip_llm_verification=True, + ) + self._run_async(self._engine.initialize()) + + self._bank_id = bank_id + self._bank_ensured = False + + logger.info( + "Cerebral MemoryEngine initialized (schema=%s, bank=%s, embeddings=%s)", + HINDSIGHT_SCHEMA, bank_id, embeddings_model, + ) + + def _run_async(self, coro): + """Submit a coroutine to the dedicated event loop and block until done.""" + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + return future.result() + + def close(self) -> None: + """Shut down the MemoryEngine and stop the event loop.""" + try: + self._run_async(self._engine.close()) + except Exception: + logger.debug("cerebral: error closing engine", exc_info=True) + self._loop.call_soon_threadsafe(self._loop.stop) + self._loop_thread.join(timeout=10) + + def _ensure_bank(self) -> None: + """Create the Hindsight bank and configure it (idempotent).""" + if self._bank_ensured: + return + ctx = RequestContext() + try: + self._run_async( + self._engine.ensure_bank_profile( + self._bank_id, + request_context=ctx, + ) + ) + except Exception: + logger.debug("cerebral: bank %s profile ensure failed", self._bank_id) + + try: + self._run_async( + self._engine.update_bank_config( + self._bank_id, + updates={ + "retain_extraction_mode": "verbose", + "retain_mission": ( + "Retain code review observations, analysis results, and artifacts. " + "Focus on patterns, architectural decisions, dependency relationships, " + "security findings, and technical insights across repositories." + ), + }, + request_context=ctx, + ) + ) + except Exception: + logger.warning("cerebral: failed to configure bank %s", self._bank_id, exc_info=True) + self._bank_ensured = True + + def retain_episode(self, episode: Episode) -> None: + """Retain all observations and artifacts from an episode.""" + self._ensure_bank() + + tags = self._build_tags(episode) + episode_doc_id = f"episode-{episode.id}" + + # Build contents list: one dict per observation + one per artifact + # Each dict has 'content', 'context', 'tags', 'document_id', 'event_date' + contents: list[dict] = [] + + for section_name in episode.context_memory.section_names(): + for obs in getattr(episode.context_memory, section_name): + contents.append({ + "content": f"[{section_name}] {obs.content}", + "context": f"code review {episode.task} observation ({section_name})", + "tags": tags, + "document_id": episode_doc_id, + "event_date": episode.timestamp.isoformat(), + }) + + for name, content in (episode.artifacts or {}).items(): + contents.append({ + "content": content, + "context": f"code review {episode.task} artifact ({name})", + "tags": tags, + "document_id": episode_doc_id, + "event_date": episode.timestamp.isoformat(), + }) + + if not contents: + logger.debug("cerebral: no items for episode %s", episode.id) + return + + try: + self._run_async( + self._engine.retain_batch_async( + bank_id=self._bank_id, + contents=contents, + request_context=RequestContext(), + ) + ) + logger.info( + "cerebral: retained %d items for episode %s (bank=%s, task=%s)", + len(contents), episode.id, self._bank_id, episode.task, + ) + except Exception: + logger.warning( + "cerebral: failed to retain episode %s", + episode.id, exc_info=True, + ) + + @staticmethod + def _build_tags(episode: Episode) -> list[str]: + tags: list[str] = [] + for topic in episode.context_memory.topics: + tags.append(f"{topic.type}:{topic.id}") + tags.append(f"episode:{episode.id}") + tags.append(f"task:{episode.task}") + tags.append(f"run_id:{episode.run_id}") + return tags diff --git a/src/codespy/agents/memory/postgres.py b/src/codespy/agents/memory/postgres.py index c315002..bf05831 100644 --- a/src/codespy/agents/memory/postgres.py +++ b/src/codespy/agents/memory/postgres.py @@ -86,6 +86,19 @@ def ensure_schema(self) -> None: # from any schema's search_path (episodic, semantic, etc.) cur.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA public") + # Ensure the semantic schema exists (used by Cerebral/MemoryEngine) + cur.execute("CREATE SCHEMA IF NOT EXISTS semantic") + + # pgvector extension — required by MemoryEngine for semantic search. + # Wrapped in a nested transaction (savepoint): pgvector may not + # be installed on all PostgreSQL instances; episodic memory + # works fine without it. + try: + with conn.transaction(): + cur.execute("CREATE EXTENSION IF NOT EXISTS vector SCHEMA public") + except Exception: + logger.debug("pgvector extension not available — semantic search requires it") + # Schema version table cur.execute(""" CREATE TABLE IF NOT EXISTS schema_version ( diff --git a/src/codespy/agents/review/audit/agent.py b/src/codespy/agents/review/audit/agent.py index 96cc8f8..00dfe03 100644 --- a/src/codespy/agents/review/audit/agent.py +++ b/src/codespy/agents/review/audit/agent.py @@ -13,7 +13,7 @@ from codespy.agents.review.models import Issue, ReviewContext from codespy.agents.review.helpers import deepest_common_folder from codespy.config import get_settings -from codespy.config_memory import get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store if TYPE_CHECKING: from codespy.agents.review.scope.models import ScopeResult @@ -180,12 +180,19 @@ def forward( ) # Background episode save (after SignatureContext closes) + cerebral = get_cerebral(self._settings) if hippo is not None and store is not None: + _cerebral = cerebral def _persist(): try: hippo.end_episode(store, artifacts=_artifacts) except Exception: logger.warning("Audit episode save failed", exc_info=True) + if _cerebral is not None and hippo.episode is not None: + try: + _cerebral.retain_episode(hippo.episode) + except Exception: + logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="audit-episode-save") return result.quality_assessment, result.recommendation diff --git a/src/codespy/agents/review/code_review/agent.py b/src/codespy/agents/review/code_review/agent.py index f672f85..20b0ed0 100644 --- a/src/codespy/agents/review/code_review/agent.py +++ b/src/codespy/agents/review/code_review/agent.py @@ -22,7 +22,7 @@ restore_repo_paths, ) from codespy.config import get_settings -from codespy.config_memory import get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store from codespy.tools.mcp_utils import cleanup_mcp_contexts, connect_mcp_server logger = logging.getLogger(__name__) @@ -337,11 +337,17 @@ async def _review_scope( ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} - def _persist(h=hippo, s=store, a=_artifacts): + cerebral = get_cerebral(self._settings) + def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): try: h.end_episode(s, artifacts=a) except Exception: logger.warning("Background code_review episode save failed", exc_info=True) + if c is not None and h.episode is not None: + try: + c.retain_episode(h.episode) + except Exception: + logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="code-review-episode-save") else: result = await agent.acall( diff --git a/src/codespy/agents/review/doc/agent.py b/src/codespy/agents/review/doc/agent.py index 039f2b4..1fd4aa3 100644 --- a/src/codespy/agents/review/doc/agent.py +++ b/src/codespy/agents/review/doc/agent.py @@ -24,7 +24,7 @@ restore_repo_paths, ) from codespy.config import get_settings -from codespy.config_memory import get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store logger = logging.getLogger(__name__) @@ -260,11 +260,17 @@ async def _review_scope( ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} - def _persist(h=hippo, s=store, a=_artifacts): + cerebral = get_cerebral(self._settings) + def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): try: h.end_episode(s, artifacts=a) except Exception: logger.warning("Background doc episode save failed", exc_info=True) + if c is not None and h.episode is not None: + try: + c.retain_episode(h.episode) + except Exception: + logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="doc-episode-save") else: result = await asyncio.to_thread( diff --git a/src/codespy/agents/review/scope/agent.py b/src/codespy/agents/review/scope/agent.py index 93939ab..8aeba91 100644 --- a/src/codespy/agents/review/scope/agent.py +++ b/src/codespy/agents/review/scope/agent.py @@ -25,7 +25,7 @@ from codespy.agents.review.scope.manifest_parser import extract_package_name from codespy.agents.review.scope.models import PackageManifest, ScopeResult, ScopeType from codespy.config import get_settings -from codespy.config_memory import get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store from codespy.tools.git.client import get_client from codespy.tools.git.models import ChangedFile, PullRequest, should_review_file from codespy.tools.mcp_utils import cleanup_mcp_contexts, connect_mcp_server @@ -1164,16 +1164,23 @@ async def _refine_scopes( hippo.bind_topics(scope_topics, stamp_topic_ids) # Fire-and-forget background episode save + cerebral = get_cerebral(self._settings) if hippo is not None and store is not None: scope_desc = "\n".join( f"- {s.subroot} ({s.scope_type.value}): {len(s.changed_files)} files" for s in final_scopes ) + _cerebral = cerebral def _persist(): try: hippo.end_episode(store, artifacts={"scopes": scope_desc}) except Exception: logger.warning("Background scope episode save failed", exc_info=True) + if _cerebral is not None and hippo.episode is not None: + try: + _cerebral.retain_episode(hippo.episode) + except Exception: + logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="scope-episode-save") return final_scopes diff --git a/src/codespy/agents/review/summary/agent.py b/src/codespy/agents/review/summary/agent.py index 0318645..fac78d9 100644 --- a/src/codespy/agents/review/summary/agent.py +++ b/src/codespy/agents/review/summary/agent.py @@ -12,7 +12,7 @@ from codespy.agents.memory.hippocampus.context_memory import Topic from codespy.agents.review.helpers import deepest_common_folder from codespy.config import get_settings -from codespy.config_memory import get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store if TYPE_CHECKING: from codespy.agents.review.models import PRContext @@ -136,11 +136,18 @@ def forward( hippo.observe(result) # Fire-and-forget episode save _summary_text = result.summary + cerebral = get_cerebral(self._settings) + _cerebral = cerebral def _persist(): try: hippo.end_episode(store, artifacts={"summary": _summary_text}) except Exception: logger.warning("Background summary episode save failed", exc_info=True) + if _cerebral is not None and hippo.episode is not None: + try: + _cerebral.retain_episode(hippo.episode) + except Exception: + logger.warning("Background summary cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="summary-episode-save") else: result = summarizer( diff --git a/src/codespy/agents/review/supply_chain/agent.py b/src/codespy/agents/review/supply_chain/agent.py index 2f14fb1..232d83c 100644 --- a/src/codespy/agents/review/supply_chain/agent.py +++ b/src/codespy/agents/review/supply_chain/agent.py @@ -22,7 +22,7 @@ strip_prefix, ) from codespy.config import get_settings -from codespy.config_memory import get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store from codespy.tools.mcp_utils import cleanup_mcp_contexts, connect_mcp_server logger = logging.getLogger(__name__) @@ -439,11 +439,17 @@ async def _review_scope( ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} - def _persist(h=hippo, s=store, a=_artifacts): + cerebral = get_cerebral(self._settings) + def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): try: h.end_episode(s, artifacts=a) except Exception: logger.warning("Background supply_chain episode save failed", exc_info=True) + if c is not None and h.episode is not None: + try: + c.retain_episode(h.episode) + except Exception: + logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="supply-chain-episode-save") else: result = await supply_chain_agent.acall( diff --git a/src/codespy/config.py b/src/codespy/config.py index a9d2d91..bc65470 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -260,10 +260,10 @@ def get_scan_unchanged(self, signature_name: str) -> bool: def get_memory_enabled(self, signature_name: str) -> bool: """Whether Hippocampus memory is enabled for a signature. - Per-signature ``memory.enabled`` overrides ``memory.default_enabled``. + Per-signature ``memory.enabled`` overrides ``memory.enabled``. """ config = self.get_signature_config(signature_name).memory - return config.enabled if config.enabled is not None else self.memory.default_enabled + return config.enabled if config.enabled is not None else self.memory.enabled def get_memory_budget(self, signature_name: str) -> "MemoryBudget": """Resolve the ``MemoryBudget`` for a signature. diff --git a/src/codespy/config_dspy.py b/src/codespy/config_dspy.py index daadb79..f3cb85e 100644 --- a/src/codespy/config_dspy.py +++ b/src/codespy/config_dspy.py @@ -140,7 +140,7 @@ def apply_signature_env_overrides(config: dict[str, Any]) -> dict[str, Any]: - ``SUPPLY_CHAIN_ENABLED`` -> signatures.supply_chain.enabled - ``CODE_REVIEW_MEMORY_ENABLED`` -> signatures.code_review.memory.enabled - Top-level settings (DEFAULT_MODEL, AWS_REGION, MEMORY_DEFAULT_ENABLED, etc.) + Top-level settings (DEFAULT_MODEL, AWS_REGION, MEMORY_ENABLED, etc.) are handled directly by pydantic-settings and should NOT be processed here. """ # Load .env file first to ensure env vars are available diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 2268a71..7d01a24 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -18,6 +18,23 @@ logger = logging.getLogger(__name__) +# Default embedding model per LLM provider prefix. Used by get_cerebral() +# when hindsight.embeddings_model is None. litellm-sdk routes through litellm. +EMBEDDING_MODELS: dict[str, str] = { + "bedrock": "bedrock/cohere.embed-multilingual-v3", + "openai": "openai/text-embedding-3-small", + "anthropic": "openai/text-embedding-3-small", + "gemini": "gemini/text-embedding-004", + "azure": "azure/text-embedding-3-small", + "litellm": "openai/text-embedding-3-small", +} + + +class HindsightConfig(BaseModel): + """Hindsight MemoryEngine settings for Cerebral semantic memory.""" + embeddings_model: str | None = None # MEMORY_HINDSIGHT_EMBEDDINGS_MODEL + + class ReflectionModuleConfig(BaseModel): """LLM overrides for a single reflection module (Distiller / Cartographer). @@ -89,11 +106,10 @@ class Pg0Config(BaseModel): class MemoryConfig(BaseModel): - """Global memory (Hippocampus) configuration. + """Global memory (Hippocampus + Cerebral) configuration. - Controls where episodes are persisted and the default memory knob - applied to every agent. Per-signature ``memory:`` blocks override the - ``default_*`` values. + Controls where episodes are persisted and the memory knob applied to + every agent. Per-signature ``memory:`` blocks override ``enabled``. """ # PostgreSQL connection settings @@ -101,8 +117,8 @@ class MemoryConfig(BaseModel): pg0: Pg0Config = Field(default_factory=Pg0Config) bank_id: str | None = None # MEMORY_BANK_ID (defaults to "codespy") - # Memory default — overridable per-signature - default_enabled: bool = False # MEMORY_DEFAULT_ENABLED + # Master switch — overridable per-signature via signatures..memory.enabled + enabled: bool = False # MEMORY_ENABLED # Whether to apply head+tail trajectory bounding before distillation. # When false, the full trajectory goes to the Distiller and ContextSafe @@ -139,13 +155,10 @@ class MemoryConfig(BaseModel): max_question_tokens: int | None = 8192 # MEMORY_MAX_QUESTION_TOKENS # Per-module LLM overrides for the reflection pipeline. - # Unset fields fall back to the top-level ``default_*`` settings. - distiller: ReflectionModuleConfig = Field( - default_factory=ReflectionModuleConfig - ) # MEMORY_DISTILLER_* - cartographer: ReflectionModuleConfig = Field( - default_factory=ReflectionModuleConfig - ) # MEMORY_CARTOGRAPHER_* + distiller: ReflectionModuleConfig = Field(default_factory=ReflectionModuleConfig) + cartographer: ReflectionModuleConfig = Field(default_factory=ReflectionModuleConfig) + cerebral: ReflectionModuleConfig = Field(default_factory=ReflectionModuleConfig) # MEMORY_CEREBRAL_* + hindsight: HindsightConfig = Field(default_factory=HindsightConfig) # Env var suffix (after MEMORY_POSTGRES_) -> PostgresConfig field name. @@ -165,13 +178,17 @@ class MemoryConfig(BaseModel): "DATA_DIR": "data_dir", } +HINDSIGHT_ENV_SETTINGS = { + "EMBEDDINGS_MODEL": "embeddings_model", +} + # Env var name (without the MEMORY_ prefix) -> MemoryConfig field name. # ``memory`` is a nested model and ``Settings`` does not set # ``env_nested_delimiter``, so pydantic-settings cannot populate these fields # from the environment on its own. apply_memory_env_overrides() bridges the gap. MEMORY_ENV_SETTINGS = { "BANK_ID": "bank_id", - "DEFAULT_ENABLED": "default_enabled", + "ENABLED": "enabled", "COMPACT_TRAJECTORY": "compact_trajectory", "MAX_CONTEXT_MEMORY_TOKENS": "max_context_memory_tokens", "MAX_CONTEXT_ITEM_TOKENS": "max_context_item_tokens", @@ -198,6 +215,7 @@ class MemoryConfig(BaseModel): NESTED_ENV_PREFIXES: dict[str, tuple[str, dict[str, str]]] = { "POSTGRES_": ("postgres", POSTGRES_ENV_SETTINGS), "PG0_": ("pg0", PG0_ENV_SETTINGS), + "HINDSIGHT_": ("hindsight", HINDSIGHT_ENV_SETTINGS), } # Add reflection modules dynamically (same pattern, shared settings map) for _mod in REFLECTION_MODULES: @@ -215,7 +233,7 @@ def apply_memory_env_overrides(config: dict[str, Any]) -> dict[str, Any]: Maps flat env vars onto the nested ``memory`` config, e.g.:: MEMORY_POSTGRES_HOST=myhost -> memory.postgres.host - MEMORY_DEFAULT_ENABLED=true -> memory.default_enabled + MEMORY_ENABLED=true -> memory.enabled MEMORY_MAX_CONTEXT_MEMORY_TOKENS=512 -> memory.max_context_memory_tokens Nested sub-model overrides use a second level of nesting:: @@ -399,3 +417,153 @@ def verify_memory_access(settings: Settings) -> tuple[bool, str]: return False, f"Memory storage not accessible: {e}" return True, f"Memory storage verified (PostgreSQL, bank={settings.memory.bank_id or _generate_bank_id()})" + + +# Cached singleton Cerebral instance. +_cerebral: "Cerebral" | None = None +_cerebral_built = False + + +def _derive_cerebral_llm_params(settings: "Settings") -> tuple[str, str | None, str | None, str | None]: + """Derive MemoryEngine LLM params from the cerebral model config + LLM credentials. + + Parses the litellm model string to extract the provider prefix and maps + credentials from ``settings.llm``. + + Returns: + ``(provider, model, api_key, base_url)`` + """ + llm_config = settings.get_llm_config("cerebral") + model = llm_config.model # e.g. "bedrock/converse/moonshotai.kimi-k2.5" + + # Parse litellm model string: "provider/model_path" + parts = model.split("/", 1) + provider = parts[0] if len(parts) > 1 else "openai" + model_name = parts[1] if len(parts) > 1 else model + + # Map credentials from Settings.llm + api_key: str | None = None + base_url: str | None = None + llm = settings.llm + + if provider == "bedrock": + pass # Uses AWS env vars (AWS_ACCESS_KEY_ID, etc.) + elif provider == "openai": + api_key = llm.openai_api_key.get_secret_value() if llm.openai_api_key else None + base_url = llm.openai_api_base + elif provider == "anthropic": + api_key = llm.anthropic_api_key.get_secret_value() if llm.anthropic_api_key else None + elif provider == "gemini": + api_key = llm.gemini_api_key.get_secret_value() if llm.gemini_api_key else None + elif provider in ("azure", "azure_ai"): + api_key = llm.azure_api_key.get_secret_value() if llm.azure_api_key else None + base_url = llm.azure_api_base + else: + # Unknown provider — pass model as-is, let MemoryEngine/litellm resolve + provider = "litellm" + model_name = model + + return provider, model_name, api_key, base_url + + +def get_cerebral(settings: "Settings") -> "Cerebral" | None: + """Return the cached Cerebral instance, or None if unavailable. + + Cerebral activates unconditionally (like ``get_episode_store``). + Agent-level ``get_memory_enabled(sig)`` handles per-signature gating. + LLM parameters are auto-derived from the ``cerebral`` + ReflectionModuleConfig model string and ``settings.llm`` credentials. + + The store is built once and cached (module-level singleton). + + Call :func:`reset_cerebral` after changing settings (e.g. via + ``reload_settings``) to force a rebuild on next access. + + Args: + settings: Application settings. + + Returns: + Cached Cerebral instance, or None when hindsight-api-slim is + not installed or PostgreSQL is not available. + """ + global _cerebral, _cerebral_built + if _cerebral_built: + return _cerebral + + try: + from codespy.agents.memory.cerebral import Cerebral + except ImportError: + logger.warning( + "Memory is enabled but hindsight-api-slim is not installed. " + "Semantic memory disabled. Install with: pip install hindsight-api-slim" + ) + _cerebral = None + _cerebral_built = True + return None + + pg_uri = settings.memory.postgres.build_uri() + if not pg_uri: + try: + from codespy.agents.memory.pg0_manager import get_pg0_uri + + pg_uri = get_pg0_uri( + name=settings.memory.pg0.name, + port=settings.memory.pg0.port, + data_dir=settings.memory.pg0.data_dir, + ) + except Exception: + logger.warning("Memory enabled but no PostgreSQL available for Cerebral") + _cerebral = None + _cerebral_built = True + return None + + provider, model_name, api_key, base_url = _derive_cerebral_llm_params(settings) + bank_id = settings.memory.bank_id or "codespy" + embeddings_model = ( + settings.memory.hindsight.embeddings_model + or EMBEDDING_MODELS.get(provider, "openai/text-embedding-3-small") + ) + + try: + _cerebral = Cerebral( + database_url=pg_uri, + llm_provider=provider, + llm_model=model_name, + llm_api_key=api_key, + llm_base_url=base_url, + bank_id=bank_id, + embeddings_model=embeddings_model, + ) + except Exception: + logger.error( + "Cerebral initialization FAILED (bank=%s, schema=semantic). " + "Semantic memory is disabled for this run.", + bank_id, exc_info=True, + ) + _cerebral = None + _cerebral_built = True + return None + + llm_config = settings.get_llm_config("cerebral") + logger.info( + "Cerebral initialized (bank=%s, model=%s, provider=%s, schema=semantic)", + bank_id, llm_config.model, provider, + ) + _cerebral_built = True + return _cerebral + + +def reset_cerebral() -> None: + """Clear the cached Cerebral instance so it is rebuilt on next access. + + Call this after reloading settings (e.g. ``reload_settings()``) so a + changed ``memory`` configuration takes effect. + """ + global _cerebral, _cerebral_built + if _cerebral is not None: + try: + _cerebral.close() + except Exception: + pass + _cerebral = None + _cerebral_built = False diff --git a/tests/test_cerebral.py b/tests/test_cerebral.py new file mode 100644 index 0000000..2f480e7 --- /dev/null +++ b/tests/test_cerebral.py @@ -0,0 +1,447 @@ +"""Tests for the Cerebral Hindsight semantic memory module.""" + +from __future__ import annotations + +import os +import uuid +from datetime import UTC, datetime +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from codespy.agents.memory.cerebral import Cerebral +from codespy.agents.memory.hippocampus.context_memory import ( + ContextMemory, + Observation, + Topic, +) +from codespy.agents.memory.hippocampus.episode import Episode + + +def async_mock(*args, **kwargs): + """Create a coroutine mock.""" + async def mock_coro(*args, **kwargs): + return None + return mock_coro() + + +@pytest.fixture +def mock_engine(): + """Create a mock MemoryEngine.""" + engine = MagicMock() + # Mock async methods - need to return coroutines + async def mock_coro(*args, **kwargs): + return None + + engine.initialize = mock_coro + engine.ensure_bank_profile = mock_coro + engine.update_bank_config = mock_coro + engine.retain_batch_async = mock_coro + return engine + + +@pytest.fixture +def mock_request_context(): + """Create a mock RequestContext.""" + with patch("codespy.agents.memory.cerebral.cerebral.RequestContext") as mock_ctx: + mock_ctx.return_value = MagicMock() + yield mock_ctx + + +@pytest.fixture +def mock_memory_engine_class(mock_engine): + """Create a mock MemoryEngine class.""" + with patch("codespy.agents.memory.cerebral.cerebral.MemoryEngine") as mock_cls: + mock_cls.return_value = mock_engine + yield mock_cls + + +@pytest.fixture +def mock_litellm(): + """Mock litellm.embedding to avoid API calls during tests.""" + with patch("litellm.embedding") as mock_emb: + mock_emb.return_value = {"data": [{"embedding": [0.1, 0.2, 0.3]}]} + yield mock_emb + + +@pytest.fixture +def episode(): + """Create a test episode with observations and artifacts.""" + topics = [ + Topic(id="test/repo/package", type="project_scope", description="Test package"), + Topic(id="https://github.com/test/repo/pull/1", type="pull_request", description="Test PR"), + ] + + context_memory = ContextMemory( + topics=topics, + context_understanding=[ + Observation( + id="cu-test-1", + content="This is a test observation", + topic_ids=["test/repo/package"], + ), + ], + actions=[ + Observation( + id="ac-test-1", + content="Performed tool call", + topic_ids=["test/repo/package"], + ), + ], + ) + + return Episode( + id=uuid.uuid4(), + run_id="test-run-123", + timestamp=datetime.now(UTC), + task="code_review", + module="CodeReviewer", + question="Review PR #1", + artifacts={"review": "# Review Results\n\nFound 2 issues."}, + context_memory=context_memory, + ) + + +class TestCerebralInit: + """Tests for Cerebral initialization.""" + + def test_init_creates_engine_with_correct_params(self, mock_memory_engine_class, mock_litellm): + """Test that MemoryEngine is created with correct parameters.""" + Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + llm_model="gpt-4", + llm_api_key="sk-test", + llm_base_url="https://custom.openai.com", + bank_id="test-bank", + ) + + mock_memory_engine_class.assert_called_once_with( + db_url="postgresql://localhost:5432/test", + memory_llm_provider="openai", + memory_llm_model="gpt-4", + memory_llm_api_key="sk-test", + memory_llm_base_url="https://custom.openai.com", + embeddings=mock_memory_engine_class.call_args.kwargs["embeddings"], + cross_encoder=mock_memory_engine_class.call_args.kwargs["cross_encoder"], + tenant_extension=mock_memory_engine_class.call_args.kwargs["tenant_extension"], + skip_llm_verification=mock_memory_engine_class.call_args.kwargs["skip_llm_verification"], + ) + + def test_init_calls_initialize(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that initialize() is called on engine during construction.""" + with patch.object(Cerebral, "_run_async") as mock_run_async: + Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + mock_run_async.assert_called_once() + + def test_init_handles_none_base_url(self, mock_memory_engine_class, mock_litellm): + """Test that None base_url is handled correctly.""" + Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + llm_model="gpt-4", + llm_api_key="sk-test", + llm_base_url=None, + ) + + mock_memory_engine_class.assert_called_once_with( + db_url="postgresql://localhost:5432/test", + memory_llm_provider="openai", + memory_llm_model="gpt-4", + memory_llm_api_key="sk-test", + memory_llm_base_url=None, + embeddings=mock_memory_engine_class.call_args.kwargs["embeddings"], + cross_encoder=mock_memory_engine_class.call_args.kwargs["cross_encoder"], + tenant_extension=mock_memory_engine_class.call_args.kwargs["tenant_extension"], + skip_llm_verification=mock_memory_engine_class.call_args.kwargs["skip_llm_verification"], + ) + + +class TestCerebralBank: + """Tests for Cerebral bank management.""" + + def test_ensure_bank_called_once(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that _ensure_bank is only called once.""" + with patch.object(Cerebral, "_run_async") as mock_run_async: + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + + # Reset the mock to track only _ensure_bank calls + mock_run_async.reset_mock() + + # First call to _ensure_bank + cerebral._ensure_bank() + + # Second call should be a no-op + cerebral._ensure_bank() + + # ensure_bank_profile and update_bank_config should only be called once + assert mock_run_async.call_count == 2 # Once for ensure, once for update + + def test_ensure_bank_calls_profile_and_config(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that _ensure_bank calls ensure_bank_profile and update_bank_config.""" + with patch.object(Cerebral, "_run_async") as mock_run_async: + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + bank_id="test-bank", + ) + + mock_run_async.reset_mock() + cerebral._bank_ensured = False + cerebral._ensure_bank() + + # Should call ensure_bank_profile and update_bank_config + assert mock_run_async.call_count == 2 + + +class TestCerebralRetainEpisode: + """Tests for Cerebral retain_episode method.""" + + def test_retain_episode_builds_correct_tags(self, mock_memory_engine_class, episode, mock_litellm): + """Test that tags are built correctly from episode.""" + with patch.object(Cerebral, "_run_async"): + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + + tags = cerebral._build_tags(episode) + + expected_tags = [ + "project_scope:test/repo/package", + "pull_request:https://github.com/test/repo/pull/1", + f"episode:{episode.id}", + "task:code_review", + "run_id:test-run-123", + ] + assert tags == expected_tags + + def test_retain_episode_creates_contents_for_observations(self, mock_memory_engine_class, episode, mock_engine, mock_litellm): + """Test that retain_episode creates content items for each observation.""" + with patch.object(Cerebral, "_run_async") as mock_run_async: + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + + mock_run_async.reset_mock() + cerebral._bank_ensured = True # Skip bank setup + + cerebral.retain_episode(episode) + + # Should call retain_batch_async + mock_run_async.assert_called_once() + + # Get the arguments passed to retain_batch_async + call_args = mock_run_async.call_args + engine_call = mock_engine.retain_batch_async + assert call_args is not None + + def test_retain_episode_creates_contents_for_artifacts(self, mock_memory_engine_class, episode, mock_litellm): + """Test that retain_episode creates content items for each artifact.""" + with patch.object(Cerebral, "_run_async") as mock_run_async: + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + + mock_run_async.reset_mock() + cerebral._bank_ensured = True + + cerebral.retain_episode(episode) + + # Should call retain_batch_async + mock_run_async.assert_called_once() + + def test_retain_episode_skips_when_no_contents(self, mock_memory_engine_class, mock_litellm): + """Test that retain_episode skips when there are no observations or artifacts.""" + empty_episode = Episode( + id=uuid.uuid4(), + run_id="test-run", + timestamp=datetime.now(UTC), + task="code_review", + module="CodeReviewer", + question="Review PR", + artifacts={}, + context_memory=ContextMemory(), + ) + + with patch.object(Cerebral, "_run_async") as mock_run_async: + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + + mock_run_async.reset_mock() + cerebral._bank_ensured = True + + cerebral.retain_episode(empty_episode) + + # Should NOT call retain_batch_async + mock_run_async.assert_not_called() + + def test_retain_episode_handles_exception_gracefully(self, mock_memory_engine_class, episode, mock_litellm): + """Test that retain_episode handles exceptions gracefully.""" + with patch.object(Cerebral, "_run_async") as mock_run_async: + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + + cerebral._bank_ensured = True + + # Set side effect AFTER creating Cerebral (so initialize succeeds) + mock_run_async.side_effect = Exception("Network error") + + # Should not raise + cerebral.retain_episode(episode) + + def test_retain_episode_uses_correct_document_id(self, mock_memory_engine_class, episode, mock_engine, mock_litellm): + """Test that retain_episode uses correct document_id format.""" + with patch.object(Cerebral, "_run_async") as mock_run_async: + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + + mock_run_async.reset_mock() + cerebral._bank_ensured = True + + cerebral.retain_episode(episode) + + # Get the contents passed to retain_batch_async + call_args = mock_run_async.call_args + assert call_args is not None + + +class TestCerebralRunAsync: + """Tests for Cerebral _run_async helper.""" + + def test_run_async_submits_to_dedicated_loop(self, mock_memory_engine_class, mock_litellm): + """Test _run_async submits coroutine to the dedicated event loop.""" + with mock_memory_engine_class: + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + + async def test_coro(): + return "result" + + result = cerebral._run_async(test_coro()) + assert result == "result" + # Verify the loop is running in a background thread + assert cerebral._loop_thread.is_alive() + assert cerebral._loop.is_running() + + def test_run_async_from_different_thread(self, mock_memory_engine_class, mock_litellm): + """Test _run_async is thread-safe when called from other threads.""" + import concurrent.futures + + with mock_memory_engine_class: + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + + async def test_coro(): + return "thread_result" + + # Submit from a different thread using ThreadPoolExecutor + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(cerebral._run_async, test_coro()) + result = future.result(timeout=5) + + assert result == "thread_result" + + +class TestCerebralClose: + """Tests for Cerebral close method.""" + + def test_close_stops_loop_and_joins_thread(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that close() stops the event loop and joins the thread.""" + with mock_memory_engine_class: + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + + # Verify loop is running before close + assert cerebral._loop_thread.is_alive() + assert cerebral._loop.is_running() + + cerebral.close() + + # After close, thread should be stopped + assert not cerebral._loop_thread.is_alive() + assert not cerebral._loop.is_running() + + def test_close_calls_engine_close(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that close() calls engine.close().""" + with mock_memory_engine_class: + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="openai", + ) + + cerebral.close() + + # Verify engine.close() was called + mock_engine.close.assert_called_once() + + +class TestCerebralBuildTags: + """Tests for Cerebral _build_tags static method.""" + + def test_build_tags_with_multiple_topics(self): + """Test tag building with multiple topics.""" + episode = Episode( + id=uuid.uuid4(), + run_id="run-123", + timestamp=datetime.now(UTC), + task="scope", + module="ScopeResolver", + question="Resolve scopes", + artifacts={}, + context_memory=ContextMemory( + topics=[ + Topic(id="owner/repo", type="project_scope", description="Main scope"), + Topic(id="pr-123", type="pull_request", description="PR"), + ], + ), + ) + + tags = Cerebral._build_tags(episode) + + assert len(tags) == 5 + assert "project_scope:owner/repo" in tags + assert "pull_request:pr-123" in tags + assert f"episode:{episode.id}" in tags + assert "task:scope" in tags + assert "run_id:run-123" in tags + + def test_build_tags_with_no_topics(self): + """Test tag building with no topics.""" + episode = Episode( + id=uuid.uuid4(), + run_id="run-456", + timestamp=datetime.now(UTC), + task="code_review", + module="CodeReviewer", + question="Review code", + artifacts={}, + context_memory=ContextMemory(), + ) + + tags = Cerebral._build_tags(episode) + + assert len(tags) == 3 + assert f"episode:{episode.id}" in tags + assert "task:code_review" in tags + assert "run_id:run-456" in tags diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index dbbf27c..99a85a3 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -97,12 +97,12 @@ def test_override_pg0_data_dir(self, monkeypatch): result = apply_memory_env_overrides(config) assert result["memory"]["pg0"]["data_dir"] == "/custom/path" - def test_override_default_enabled(self, monkeypatch): - """MEMORY_DEFAULT_ENABLED should set memory.default_enabled as bool.""" - monkeypatch.setenv("MEMORY_DEFAULT_ENABLED", "true") + def test_override_enabled(self, monkeypatch): + """MEMORY_ENABLED should set memory.enabled as bool.""" + monkeypatch.setenv("MEMORY_ENABLED", "true") config = {} result = apply_memory_env_overrides(config) - assert result["memory"]["default_enabled"] is True + assert result["memory"]["enabled"] is True def test_override_reflection_module(self, monkeypatch): """MEMORY_DISTILLER_MODEL should set memory.distiller.model.""" From ffb8e535a2293bd290ad7c9e651126855f712645 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Wed, 23 Sep 2026 15:35:49 +0200 Subject: [PATCH 02/39] retain works --- src/codespy/agents/memory/cerebral/cerebral.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index d109cd8..8db1128 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -149,7 +149,7 @@ def retain_episode(self, episode: Episode) -> None: for obs in getattr(episode.context_memory, section_name): contents.append({ "content": f"[{section_name}] {obs.content}", - "context": f"code review {episode.task} observation ({section_name})", + "context": f"{episode.task}: {episode.question}: observation ({section_name})", "tags": tags, "document_id": episode_doc_id, "event_date": episode.timestamp.isoformat(), @@ -158,7 +158,7 @@ def retain_episode(self, episode: Episode) -> None: for name, content in (episode.artifacts or {}).items(): contents.append({ "content": content, - "context": f"code review {episode.task} artifact ({name})", + "context": f"{episode.task}: {episode.question}: artifact ({name})", "tags": tags, "document_id": episode_doc_id, "event_date": episode.timestamp.isoformat(), From 8ee95daf389ba6e41bc57b65afb47c243c64a6ba Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Thu, 24 Sep 2026 15:38:36 +0200 Subject: [PATCH 03/39] cost tracking --- src/codespy/agents/cost_tracker.py | 20 + .../agents/memory/cerebral/cerebral.py | 28 +- src/codespy/agents/memory/cerebral/cost.py | 272 ++++++++++++ src/codespy/config_memory.py | 36 +- tests/test_cerebral.py | 56 ++- tests/test_cerebral_cost.py | 384 +++++++++++++++++ tests/test_config_memory.py | 118 ++++++ tests/test_cost_tracker.py | 386 ++++++++++++++++++ 8 files changed, 1256 insertions(+), 44 deletions(-) create mode 100644 src/codespy/agents/memory/cerebral/cost.py create mode 100644 tests/test_cerebral_cost.py create mode 100644 tests/test_cost_tracker.py diff --git a/src/codespy/agents/cost_tracker.py b/src/codespy/agents/cost_tracker.py index 1be9f31..464194b 100644 --- a/src/codespy/agents/cost_tracker.py +++ b/src/codespy/agents/cost_tracker.py @@ -95,6 +95,26 @@ def end_signature(self, signature_name: str, cost: float, tokens: int, call_coun stats.tokens += tokens stats.call_count += call_count + def add_external_call(self, name: str, cost: float, tokens: int, calls: int = 1) -> None: + """Accumulate costs from external LLM calls (not tracked by DSPy). + + Creates a SignatureStats entry if missing, without touching + start_time/end_time (external calls have no duration tracking). + + Args: + name: Bucket name for the external call (e.g., "cerebral_retain") + cost: Cost in USD for the call(s) + tokens: Total tokens used + calls: Number of calls (default 1) + """ + with self._lock: + if name not in self._signature_stats: + self._signature_stats[name] = SignatureStats(name=name) + stats = self._signature_stats[name] + stats.cost += cost + stats.tokens += tokens + stats.call_count += calls + @property def total_cost(self) -> float: """Get total cost in USD across all signatures.""" diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index 8db1128..bad8763 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -18,6 +18,12 @@ from hindsight_api.extensions.builtin.tenant import DefaultTenantExtension from hindsight_api.models import RequestContext +from codespy.agents.memory.cerebral.cost import ( + CerebralCostRecorder, + MeteredLiteLLMSDKEmbeddings, + register_cerebral_cost_recorder, +) + if TYPE_CHECKING: from codespy.agents.memory.hippocampus.episode import Episode @@ -65,13 +71,27 @@ def __init__( ) self._loop_thread.start() + # Register cost recorder before initializing MemoryEngine so + # all LLM calls (including the probe) are captured. + cost_recorder = register_cerebral_cost_recorder() + + # Use metered embeddings that capture usage costs + base_embeddings = LiteLLMSDKEmbeddings( + model=embeddings_model, + api_key=None, # litellm reads from env + ) + metered_embeddings = MeteredLiteLLMSDKEmbeddings( + base=base_embeddings, + cost_recorder=cost_recorder, + ) + self._engine = MemoryEngine( db_url=database_url, memory_llm_provider=llm_provider, memory_llm_model=llm_model, memory_llm_api_key=llm_api_key, memory_llm_base_url=llm_base_url or None, - embeddings=LiteLLMSDKEmbeddings(model=embeddings_model), + embeddings=metered_embeddings, cross_encoder=RRFPassthroughCrossEncoder(), tenant_extension=DefaultTenantExtension(config={"schema": HINDSIGHT_SCHEMA}), skip_llm_verification=True, @@ -82,8 +102,8 @@ def __init__( self._bank_ensured = False logger.info( - "Cerebral MemoryEngine initialized (schema=%s, bank=%s, embeddings=%s)", - HINDSIGHT_SCHEMA, bank_id, embeddings_model, + "Cerebral MemoryEngine initialized (schema=%s, bank=%s, embeddings=%s, provider=%s)", + HINDSIGHT_SCHEMA, bank_id, embeddings_model, llm_provider, ) def _run_async(self, coro): @@ -120,7 +140,7 @@ def _ensure_bank(self) -> None: self._engine.update_bank_config( self._bank_id, updates={ - "retain_extraction_mode": "verbose", + "retain_extraction_mode": "concise", "retain_mission": ( "Retain code review observations, analysis results, and artifacts. " "Focus on patterns, architectural decisions, dependency relationships, " diff --git a/src/codespy/agents/memory/cerebral/cost.py b/src/codespy/agents/memory/cerebral/cost.py new file mode 100644 index 0000000..7f66dc4 --- /dev/null +++ b/src/codespy/agents/memory/cerebral/cost.py @@ -0,0 +1,272 @@ +"""Cost tracking for Cerebral (Hindsight) LLM calls and embeddings. + +Uses the Hindsight span recorder to intercept LLM calls made by MemoryEngine +and price them using litellm.cost_per_token. Embeddings are metered by +wrapping the LiteLLM SDK with a proxy that captures usage. +""" + +from __future__ import annotations + +import logging +import threading +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from hindsight_api.engine.embeddings import LiteLLMSDKEmbeddings + +logger = logging.getLogger(__name__) + +# Module-level lock for thread-safe registration +_recorder_lock = threading.Lock() +_recorder_registered = False + +# Buckets for cost attribution +BUCKET_CEREBRAL_RETAIN = "cerebral_retain" +BUCKET_CEREBRAL_OTHER = "cerebral_other" +BUCKET_CEREBRAL_EMBEDDINGS = "cerebral_embeddings" + +# Track models we've warned about missing prices (one warning per model) +_warned_unpriced_models: set[str] = set() + + +class CerebralCostRecorder: + """Records Hindsight LLM calls into the CostTracker. + + Registered as a span recorder with Hindsight's CompositeSpanRecorder. + Receives every LLM call made by MemoryEngine (retain, consolidation, etc.) + and accumulates costs via CostTracker.add_external_call. + """ + + def record_llm_call( # type: ignore[return] + self, + *, + model: str = "", + scope: str = "", + input_tokens: int = 0, + output_tokens: int = 0, + error: Exception | None = None, + **_: Any, + ) -> None: + """Record a completed LLM call. + + Args: + model: Full litellm model string (e.g., "bedrock/converse/moonshotai.kimi-k2.5") + scope: Hindsight scope (e.g., "retain_extract_facts", "consolidation") + input_tokens: Input/prompt token count + output_tokens: Output/completion token count + error: Exception if the call failed + **_: Ignored extra kwargs for forward compatibility + """ + try: + # Skip failed calls + if error is not None: + return + + # Skip calls with no tokens + if input_tokens == 0 and output_tokens == 0: + return + + # Determine bucket from scope + if scope and scope.startswith("retain"): + bucket = BUCKET_CEREBRAL_RETAIN + else: + bucket = BUCKET_CEREBRAL_OTHER + + # Price the call using litellm + cost = self._price_call(model, input_tokens, output_tokens) + + # Import here to avoid circular imports at module load + from codespy.agents.cost_tracker import get_cost_tracker + + tracker = get_cost_tracker() + tokens = input_tokens + output_tokens + tracker.add_external_call(bucket, cost, tokens, calls=1) + + except Exception as e: + # Metering must never break the actual operation + logger.debug("CerebralCostRecorder failed to record call: %s", e) + + def _price_call(self, model: str, input_tokens: int, output_tokens: int) -> float: + """Price a call using litellm.cost_per_token. + + Returns 0.0 if the model has no price registered. Logs a warning + once per unpriced model. + """ + try: + import litellm + + prompt_cost, completion_cost = litellm.cost_per_token( + model=model, + prompt_tokens=input_tokens, + completion_tokens=output_tokens, + ) + return prompt_cost + completion_cost + except Exception: + # Model may not have a price + if model not in _warned_unpriced_models: + _warned_unpriced_models.add(model) + logger.warning( + "No litellm price for model %s; cost will be $0. " + "Tokens are still recorded.", + model, + ) + return 0.0 + + +class _LiteLLMProxy: + """Proxy for the litellm module that meters embedding calls. + + Intercepts ``aembedding`` to capture usage and record costs before + returning the response. All other attributes forward to the real litellm. + """ + + def __init__(self, real_litellm: Any, cost_recorder: "CerebralCostRecorder") -> None: + self._real = real_litellm + self._recorder = cost_recorder + + async def aembedding(self, **kwargs: Any) -> Any: + """Call litellm.aembedding and meter the usage.""" + response = await self._real.aembedding(**kwargs) + self._meter_embedding(response, kwargs.get("model", "")) + return response + + def __getattr__(self, name: str) -> Any: + """Forward all other attributes to the real litellm.""" + return getattr(self._real, name) + + def _meter_embedding(self, response: Any, model: str) -> None: + """Extract usage from embedding response and record cost.""" + try: + # Try _hidden_params["response_cost"] first (litellm populates this) + cost: float | None = None + if hasattr(response, "_hidden_params") and isinstance(response._hidden_params, dict): + cost = response._hidden_params.get("response_cost") + + # Fallback to litellm.completion_cost if available + if cost is None: + try: + import litellm + + cost = litellm.completion_cost(response) + except Exception: + cost = 0.0 + + # Token extraction: usage.prompt_tokens or usage.total_tokens + tokens = 0 + if hasattr(response, "usage") and response.usage: + usage = response.usage + if hasattr(usage, "prompt_tokens"): + tokens = usage.prompt_tokens + elif hasattr(usage, "total_tokens"): + tokens = usage.total_tokens + elif isinstance(usage, dict): + tokens = usage.get("prompt_tokens", 0) or usage.get("total_tokens", 0) + + if tokens == 0: + # Can't meter without tokens + return + + # Import here to avoid circular imports + from codespy.agents.cost_tracker import get_cost_tracker + + tracker = get_cost_tracker() + tracker.add_external_call(BUCKET_CEREBRAL_EMBEDDINGS, cost or 0.0, tokens, calls=1) + + except Exception as e: + logger.debug("Failed to meter embedding call: %s", e) + + +class MeteredLiteLLMSDKEmbeddings: + """Wrapper for LiteLLMSDKEmbeddings that meters costs. + + Proxies the underlying ``_litellm`` module after initialization +to capture embedding usage. The dimension detection probe during + ``initialize()`` is intentionally unmetered. + """ + + def __init__(self, *, base: LiteLLMSDKEmbeddings, cost_recorder: CerebralCostRecorder) -> None: + self._base = base + self._recorder = cost_recorder + self._proxy_set = False + + async def initialize(self) -> None: + """Initialize the base embeddings, then wrap _litellm.""" + # Run base initialization (this does the dimension probe) + await self._base.initialize() + + # Wrap _litellm after initialization to meter actual retain/recall + # calls, not the probe. Must check if already wrapped because + # initialize() may be called multiple times. + if not self._proxy_set and hasattr(self._base, "_litellm") and self._base._litellm is not None: + self._base._litellm = _LiteLLMProxy(self._base._litellm, self._recorder) + self._proxy_set = True + + def __getattr__(self, name: str) -> Any: + """Forward all other attributes to the base embeddings.""" + return getattr(self._base, name) + + +def register_cerebral_cost_recorder() -> CerebralCostRecorder: + """Register the Cerebral cost recorder with Hindsight's span recorder. + + Idempotent: returns existing recorder if already registered. + Call unregister_cerebral_cost_recorder() to remove. + + Returns: + The registered CerebralCostRecorder instance. + """ + global _recorder_registered + + with _recorder_lock: + if _recorder_registered: + # Find and return existing recorder + try: + from hindsight_api.tracing import get_span_recorder + + recorder = get_span_recorder() + for r in getattr(recorder, "_recorders", []): + if isinstance(r, CerebralCostRecorder): + return r + except Exception: + pass + # If we can't find it, create a new one anyway + + recorder = CerebralCostRecorder() + try: + from hindsight_api.tracing import register_span_recorder + + register_span_recorder(recorder) + _recorder_registered = True + logger.debug("CerebralCostRecorder registered") + except Exception as e: + logger.warning("Failed to register CerebralCostRecorder: %s", e) + + return recorder + + +def unregister_cerebral_cost_recorder(recorder: CerebralCostRecorder | None = None) -> None: + """Unregister the Cerebral cost recorder. + + Args: + recorder: The recorder to unregister. If None, attempts to find + and unregister any CerebralCostRecorder in the composite. + """ + global _recorder_registered + + with _recorder_lock: + try: + from hindsight_api.tracing import get_span_recorder, unregister_span_recorder + + if recorder is not None: + unregister_span_recorder(recorder) + else: + # Find and unregister any CerebralCostRecorder + span_recorder = get_span_recorder() + for r in getattr(span_recorder, "_recorders", []): + if isinstance(r, CerebralCostRecorder): + unregister_span_recorder(r) + break + _recorder_registered = False + logger.debug("CerebralCostRecorder unregistered") + except Exception as e: + logger.debug("Failed to unregister CerebralCostRecorder: %s", e) diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 7d01a24..14c58f1 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -424,14 +424,14 @@ def verify_memory_access(settings: Settings) -> tuple[bool, str]: _cerebral_built = False -def _derive_cerebral_llm_params(settings: "Settings") -> tuple[str, str | None, str | None, str | None]: - """Derive MemoryEngine LLM params from the cerebral model config + LLM credentials. +def _cerebral_litellm_params(settings: "Settings") -> tuple[str, str | None, str | None]: + """Derive MemoryEngine litellm params from the cerebral model config + LLM credentials. - Parses the litellm model string to extract the provider prefix and maps - credentials from ``settings.llm``. + All Cerebral LLM calls go through litellm. This function parses the litellm + model string and extracts credentials based on the provider prefix. Returns: - ``(provider, model, api_key, base_url)`` + ``(model, api_key, base_url)`` - model is the full litellm string unchanged """ llm_config = settings.get_llm_config("cerebral") model = llm_config.model # e.g. "bedrock/converse/moonshotai.kimi-k2.5" @@ -439,7 +439,6 @@ def _derive_cerebral_llm_params(settings: "Settings") -> tuple[str, str | None, # Parse litellm model string: "provider/model_path" parts = model.split("/", 1) provider = parts[0] if len(parts) > 1 else "openai" - model_name = parts[1] if len(parts) > 1 else model # Map credentials from Settings.llm api_key: str | None = None @@ -447,7 +446,7 @@ def _derive_cerebral_llm_params(settings: "Settings") -> tuple[str, str | None, llm = settings.llm if provider == "bedrock": - pass # Uses AWS env vars (AWS_ACCESS_KEY_ID, etc.) + pass # Uses AWS env vars (AWS_ACCESS_KEY_ID, etc.) - litellm reads these elif provider == "openai": api_key = llm.openai_api_key.get_secret_value() if llm.openai_api_key else None base_url = llm.openai_api_base @@ -458,12 +457,10 @@ def _derive_cerebral_llm_params(settings: "Settings") -> tuple[str, str | None, elif provider in ("azure", "azure_ai"): api_key = llm.azure_api_key.get_secret_value() if llm.azure_api_key else None base_url = llm.azure_api_base - else: - # Unknown provider — pass model as-is, let MemoryEngine/litellm resolve - provider = "litellm" - model_name = model + # Azure API version comes from env var AZURE_API_VERSION, same as DSPy + # For any other provider, let litellm resolve from env/globals - return provider, model_name, api_key, base_url + return model, api_key, base_url def get_cerebral(settings: "Settings") -> "Cerebral" | None: @@ -517,18 +514,20 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: _cerebral_built = True return None - provider, model_name, api_key, base_url = _derive_cerebral_llm_params(settings) + model, api_key, base_url = _cerebral_litellm_params(settings) bank_id = settings.memory.bank_id or "codespy" + # Choose embedding default based on the provider prefix + provider_prefix = model.split("/", 1)[0] if "/" in model else "openai" embeddings_model = ( settings.memory.hindsight.embeddings_model - or EMBEDDING_MODELS.get(provider, "openai/text-embedding-3-small") + or EMBEDDING_MODELS.get(provider_prefix, "openai/text-embedding-3-small") ) try: _cerebral = Cerebral( database_url=pg_uri, - llm_provider=provider, - llm_model=model_name, + llm_provider="litellm", + llm_model=model, llm_api_key=api_key, llm_base_url=base_url, bank_id=bank_id, @@ -544,10 +543,9 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: _cerebral_built = True return None - llm_config = settings.get_llm_config("cerebral") logger.info( - "Cerebral initialized (bank=%s, model=%s, provider=%s, schema=semantic)", - bank_id, llm_config.model, provider, + "Cerebral initialized (bank=%s, provider=litellm, model=%s, schema=semantic)", + bank_id, model, ) _cerebral_built = True return _cerebral diff --git a/tests/test_cerebral.py b/tests/test_cerebral.py index 2f480e7..9567aad 100644 --- a/tests/test_cerebral.py +++ b/tests/test_cerebral.py @@ -10,6 +10,7 @@ import pytest from codespy.agents.memory.cerebral import Cerebral +from codespy.agents.memory.cerebral.cost import MeteredLiteLLMSDKEmbeddings from codespy.agents.memory.hippocampus.context_memory import ( ContextMemory, Observation, @@ -109,8 +110,8 @@ def test_init_creates_engine_with_correct_params(self, mock_memory_engine_class, """Test that MemoryEngine is created with correct parameters.""" Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", - llm_model="gpt-4", + llm_provider="litellm", + llm_model="openai/gpt-4", llm_api_key="sk-test", llm_base_url="https://custom.openai.com", bank_id="test-bank", @@ -118,8 +119,8 @@ def test_init_creates_engine_with_correct_params(self, mock_memory_engine_class, mock_memory_engine_class.assert_called_once_with( db_url="postgresql://localhost:5432/test", - memory_llm_provider="openai", - memory_llm_model="gpt-4", + memory_llm_provider="litellm", + memory_llm_model="openai/gpt-4", memory_llm_api_key="sk-test", memory_llm_base_url="https://custom.openai.com", embeddings=mock_memory_engine_class.call_args.kwargs["embeddings"], @@ -128,12 +129,25 @@ def test_init_creates_engine_with_correct_params(self, mock_memory_engine_class, skip_llm_verification=mock_memory_engine_class.call_args.kwargs["skip_llm_verification"], ) + def test_init_uses_metered_embeddings(self, mock_memory_engine_class, mock_litellm): + """Test that MemoryEngine is created with MeteredLiteLLMSDKEmbeddings.""" + Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="litellm", + llm_model="openai/gpt-4", + embeddings_model="openai/text-embedding-3-small", + ) + + # Check that embeddings is a MeteredLiteLLMSDKEmbeddings + embeddings = mock_memory_engine_class.call_args.kwargs["embeddings"] + assert isinstance(embeddings, MeteredLiteLLMSDKEmbeddings) + def test_init_calls_initialize(self, mock_memory_engine_class, mock_engine, mock_litellm): """Test that initialize() is called on engine during construction.""" with patch.object(Cerebral, "_run_async") as mock_run_async: Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) mock_run_async.assert_called_once() @@ -141,16 +155,16 @@ def test_init_handles_none_base_url(self, mock_memory_engine_class, mock_litellm """Test that None base_url is handled correctly.""" Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", - llm_model="gpt-4", + llm_provider="litellm", + llm_model="openai/gpt-4", llm_api_key="sk-test", llm_base_url=None, ) mock_memory_engine_class.assert_called_once_with( db_url="postgresql://localhost:5432/test", - memory_llm_provider="openai", - memory_llm_model="gpt-4", + memory_llm_provider="litellm", + memory_llm_model="openai/gpt-4", memory_llm_api_key="sk-test", memory_llm_base_url=None, embeddings=mock_memory_engine_class.call_args.kwargs["embeddings"], @@ -168,7 +182,7 @@ def test_ensure_bank_called_once(self, mock_memory_engine_class, mock_engine, mo with patch.object(Cerebral, "_run_async") as mock_run_async: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) # Reset the mock to track only _ensure_bank calls @@ -188,7 +202,7 @@ def test_ensure_bank_calls_profile_and_config(self, mock_memory_engine_class, mo with patch.object(Cerebral, "_run_async") as mock_run_async: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", bank_id="test-bank", ) @@ -208,7 +222,7 @@ def test_retain_episode_builds_correct_tags(self, mock_memory_engine_class, epis with patch.object(Cerebral, "_run_async"): cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) tags = cerebral._build_tags(episode) @@ -227,7 +241,7 @@ def test_retain_episode_creates_contents_for_observations(self, mock_memory_engi with patch.object(Cerebral, "_run_async") as mock_run_async: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) mock_run_async.reset_mock() @@ -248,7 +262,7 @@ def test_retain_episode_creates_contents_for_artifacts(self, mock_memory_engine_ with patch.object(Cerebral, "_run_async") as mock_run_async: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) mock_run_async.reset_mock() @@ -275,7 +289,7 @@ def test_retain_episode_skips_when_no_contents(self, mock_memory_engine_class, m with patch.object(Cerebral, "_run_async") as mock_run_async: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) mock_run_async.reset_mock() @@ -291,7 +305,7 @@ def test_retain_episode_handles_exception_gracefully(self, mock_memory_engine_cl with patch.object(Cerebral, "_run_async") as mock_run_async: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) cerebral._bank_ensured = True @@ -307,7 +321,7 @@ def test_retain_episode_uses_correct_document_id(self, mock_memory_engine_class, with patch.object(Cerebral, "_run_async") as mock_run_async: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) mock_run_async.reset_mock() @@ -328,7 +342,7 @@ def test_run_async_submits_to_dedicated_loop(self, mock_memory_engine_class, moc with mock_memory_engine_class: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) async def test_coro(): @@ -347,7 +361,7 @@ def test_run_async_from_different_thread(self, mock_memory_engine_class, mock_li with mock_memory_engine_class: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) async def test_coro(): @@ -369,7 +383,7 @@ def test_close_stops_loop_and_joins_thread(self, mock_memory_engine_class, mock_ with mock_memory_engine_class: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) # Verify loop is running before close @@ -387,7 +401,7 @@ def test_close_calls_engine_close(self, mock_memory_engine_class, mock_engine, m with mock_memory_engine_class: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", - llm_provider="openai", + llm_provider="litellm", ) cerebral.close() diff --git a/tests/test_cerebral_cost.py b/tests/test_cerebral_cost.py new file mode 100644 index 0000000..342f241 --- /dev/null +++ b/tests/test_cerebral_cost.py @@ -0,0 +1,384 @@ +"""Tests for the Cerebral cost tracking module.""" + +import asyncio +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from codespy.agents.cost_tracker import CostTracker, get_cost_tracker +from codespy.agents.memory.cerebral.cost import ( + BUCKET_CEREBRAL_EMBEDDINGS, + BUCKET_CEREBRAL_OTHER, + BUCKET_CEREBRAL_RETAIN, + CerebralCostRecorder, + MeteredLiteLLMSDKEmbeddings, + _LiteLLMProxy, + _recorder_registered, + register_cerebral_cost_recorder, + unregister_cerebral_cost_recorder, +) + + +@pytest.fixture +def fresh_recorder(): + """Reset registration state before/after each test.""" + # Reset module state + from codespy.agents.memory.cerebral import cost as cost_module + + cost_module._recorder_registered = False + cost_module._warned_unpriced_models.clear() + + yield + + # Cleanup after test + try: + unregister_cerebral_cost_recorder() + except Exception: + pass + cost_module._recorder_registered = False + cost_module._warned_unpriced_models.clear() + + +@pytest.fixture +def mock_tracker(): + """Create a fresh CostTracker for tests.""" + tracker = CostTracker() + tracker.reset() + return tracker + + +@pytest.fixture +def mock_cost_recorder(): + """Create a mock CerebralCostRecorder.""" + return CerebralCostRecorder() + + +class TestCerebralCostRecorder: + """Tests for CerebralCostRecorder class.""" + + def test_record_llm_call_with_retain_scope(self, mock_tracker, fresh_recorder): + """scope='retain_extract_facts' → cerebral_retain bucket.""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch.object( + CerebralCostRecorder, "_price_call", return_value=0.05 + ): + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="retain_extract_facts", + input_tokens=100, + output_tokens=50, + ) + + stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + assert stats is not None + assert stats.cost == 0.05 + assert stats.tokens == 150 + + def test_record_llm_call_with_consolidation_scope(self, mock_tracker, fresh_recorder): + """scope='consolidation' → cerebral_other bucket.""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch.object(CerebralCostRecorder, "_price_call", return_value=0.03): + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="consolidation", + input_tokens=100, + output_tokens=50, + ) + + stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_OTHER) + assert stats is not None + + def test_record_llm_call_with_other_scope(self, mock_tracker, fresh_recorder): + """Any other scope → cerebral_other bucket.""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch.object(CerebralCostRecorder, "_price_call", return_value=0.01): + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="reflect", + input_tokens=100, + output_tokens=50, + ) + + stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_OTHER) + assert stats is not None + + def test_record_llm_call_skips_error_calls(self, mock_tracker, fresh_recorder): + """A call with error set → nothing recorded.""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="retain_extract_facts", + input_tokens=100, + output_tokens=50, + error=Exception("API error"), + ) + + stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + assert stats is None + + def test_record_llm_call_skips_zero_tokens(self, mock_tracker, fresh_recorder): + """Calls with zero tokens → nothing recorded.""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="retain_extract_facts", + input_tokens=0, + output_tokens=0, + ) + + stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + assert stats is None + + def test_record_llm_call_prices_using_litellm(self, mock_tracker, fresh_recorder): + """Verify pricing uses litellm.cost_per_token.""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch("litellm.cost_per_token", return_value=(0.01, 0.02)) as mock_cost: + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="retain_extract_facts", + input_tokens=100, + output_tokens=50, + ) + + mock_cost.assert_called_once_with( + model="openai/gpt-4", + prompt_tokens=100, + completion_tokens=50, + ) + + stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + assert stats.cost == 0.03 # 0.01 + 0.02 + + def test_record_llm_call_logs_warning_for_unpriced_model( + self, mock_tracker, fresh_recorder, caplog + ): + """An unpriced model → tokens recorded, $0, a single warning.""" + import logging + + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch( + "litellm.cost_per_token", side_effect=Exception("No price") + ): + recorder = CerebralCostRecorder() + with caplog.at_level(logging.WARNING, logger="codespy.agents.memory.cerebral.cost"): + # First call should warn + recorder.record_llm_call( + model="custom/model", + scope="retain_extract_facts", + input_tokens=100, + output_tokens=50, + ) + # Second call should NOT warn (already warned) + recorder.record_llm_call( + model="custom/model", + scope="retain_extract_facts", + input_tokens=100, + output_tokens=50, + ) + + stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + assert stats.cost == 0.0 + assert stats.tokens == 300 # 150 * 2 + # Should only have one warning in the log + assert caplog.text.count("No litellm price for model") == 1 + + +class TestLiteLLMProxy: + """Tests for _LiteLLMProxy class.""" + + @pytest.fixture + def mock_litellm(self): + """Create a mock litellm module.""" + return MagicMock() + + @pytest.fixture + def mock_response(self): + """Create a mock embedding response.""" + response = MagicMock() + response.usage = MagicMock() + response.usage.prompt_tokens = 100 + response._hidden_params = {"response_cost": 0.01} + return response + + @pytest.mark.asyncio + async def test_aembedding_meters_usage(self, mock_litellm, mock_response, mock_tracker): + """Embedding call meters usage to cerebral_embeddings bucket.""" + mock_litellm.aembedding.return_value = asyncio.Future() + mock_litellm.aembedding.return_value.set_result(mock_response) + + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + cost_recorder = CerebralCostRecorder() + proxy = _LiteLLMProxy(mock_litellm, cost_recorder) + await proxy.aembedding(model="openai/text-embedding-3-small", input=["test"]) + + stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_EMBEDDINGS) + assert stats is not None + assert stats.tokens == 100 + assert stats.cost == 0.01 + + @pytest.mark.asyncio + async def test_aembedding_returns_response_unchanged(self, mock_litellm, mock_response): + """Embedding call returns the response unchanged.""" + mock_litellm.aembedding.return_value = asyncio.Future() + mock_litellm.aembedding.return_value.set_result(mock_response) + + cost_recorder = CerebralCostRecorder() + proxy = _LiteLLMProxy(mock_litellm, cost_recorder) + result = await proxy.aembedding(model="openai/text-embedding-3-small", input=["test"]) + + assert result is mock_response + + def test_getattr_forwards_other_attributes(self, mock_litellm): + """Other attributes forward to real litellm.""" + mock_litellm.some_attr = "value" + cost_recorder = CerebralCostRecorder() + proxy = _LiteLLMProxy(mock_litellm, cost_recorder) + + assert proxy.some_attr == "value" + + +class TestMeteredLiteLLMSDKEmbeddings: + """Tests for MeteredLiteLLMSDKEmbeddings class.""" + + @pytest.fixture + def mock_base_embeddings(self): + """Create a mock LiteLLMSDKEmbeddings.""" + base = MagicMock() + base._litellm = MagicMock() + base.initialize = MagicMock(return_value=asyncio.Future()) + base.initialize.return_value.set_result(None) + return base + + @pytest.fixture + def mock_cost_recorder(self): + """Create a mock CerebralCostRecorder.""" + return CerebralCostRecorder() + + @pytest.mark.asyncio + async def test_initialize_wraps_litellm_after_base_init( + self, mock_base_embeddings, mock_cost_recorder + ): + """Initialize wraps _litellm after base initialization.""" + metered = MeteredLiteLLMSDKEmbeddings( + base=mock_base_embeddings, cost_recorder=mock_cost_recorder + ) + + await metered.initialize() + + # Base initialize should have been called + mock_base_embeddings.initialize.assert_called_once() + # _litellm should now be wrapped + assert isinstance(mock_base_embeddings._litellm, _LiteLLMProxy) + + @pytest.mark.asyncio + async def test_initialize_skips_if_already_set( + self, mock_base_embeddings, mock_cost_recorder + ): + """Initialize skips wrapping if already wrapped.""" + metered = MeteredLiteLLMSDKEmbeddings( + base=mock_base_embeddings, cost_recorder=mock_cost_recorder + ) + + await metered.initialize() + first_proxy = mock_base_embeddings._litellm + + # Second initialize should not create a new proxy + await metered.initialize() + + assert mock_base_embeddings._litellm is first_proxy + + def test_getattr_forwards_to_base(self, mock_base_embeddings, mock_cost_recorder): + """Other attributes forward to base embeddings.""" + mock_base_embeddings.some_attr = "value" + metered = MeteredLiteLLMSDKEmbeddings( + base=mock_base_embeddings, cost_recorder=mock_cost_recorder + ) + + assert metered.some_attr == "value" + + +class TestRegisterCerebralCostRecorder: + """Tests for register_cerebral_cost_recorder function.""" + + def test_registration_is_idempotent(self, fresh_recorder): + """Registration is idempotent (one entry in _recorders).""" + with patch("hindsight_api.tracing.register_span_recorder") as mock_register: + recorder1 = register_cerebral_cost_recorder() + recorder2 = register_cerebral_cost_recorder() + + # Should return same recorder (or at least register once) + assert isinstance(recorder1, CerebralCostRecorder) + assert isinstance(recorder2, CerebralCostRecorder) + + def test_registers_with_span_recorder(self, fresh_recorder): + """Registers with Hindsight span recorder.""" + with patch("hindsight_api.tracing.register_span_recorder") as mock_register: + recorder = register_cerebral_cost_recorder() + + mock_register.assert_called_once() + assert mock_register.call_args[0][0] is recorder + + +class TestUnregisterCerebralCostRecorder: + """Tests for unregister_cerebral_cost_recorder function.""" + + def test_unregisters_specific_recorder(self, fresh_recorder): + """Unregister specific recorder.""" + with patch("hindsight_api.tracing.register_span_recorder"): + with patch("hindsight_api.tracing.unregister_span_recorder") as mock_unregister: + recorder = register_cerebral_cost_recorder() + unregister_cerebral_cost_recorder(recorder) + + mock_unregister.assert_called_once_with(recorder) + + def test_unregisters_any_cerebral_recorder(self, fresh_recorder): + """Unregister finds and removes any CerebralCostRecorder.""" + recorder = CerebralCostRecorder() + mock_span_recorder = MagicMock() + mock_span_recorder._recorders = [recorder] + + with patch("hindsight_api.tracing.get_span_recorder", return_value=mock_span_recorder): + with patch("hindsight_api.tracing.unregister_span_recorder") as mock_unregister: + unregister_cerebral_cost_recorder() + + mock_unregister.assert_called_once_with(recorder) + + +class TestPinTests: + """Pin tests for Hindsight internals we rely on.""" + + def test_lite_llm_sdke_has_litellm_attribute(self): + """LiteLLMSDKEmbeddings uses self._litellm attribute.""" + from hindsight_api.engine.embeddings import LiteLLMSDKEmbeddings + + # Check that LiteLLMSDKEmbeddings has _litellm attribute + embeddings = LiteLLMSDKEmbeddings.__new__(LiteLLMSDKEmbeddings) + assert hasattr(embeddings, "_litellm") + + def test_lite_llm_sdke_has_aembedding_method(self): + """LiteLLMSDKEmbeddings._litellm has aembedding method.""" + import litellm + + assert hasattr(litellm, "aembedding") + + def test_tracing_has_span_recorder(self): + """hindsight_api.tracing has get_span_recorder and register_span_recorder.""" + from hindsight_api import tracing + + assert hasattr(tracing, "get_span_recorder") + assert hasattr(tracing, "register_span_recorder") + assert hasattr(tracing, "unregister_span_recorder") + + def test_composite_span_recorder_has_recorders_list(self): + """CompositeSpanRecorder has _recorders list.""" + from hindsight_api.tracing import CompositeSpanRecorder + + recorder = CompositeSpanRecorder() + assert hasattr(recorder, "_recorders") + assert isinstance(recorder._recorders, list) diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index 99a85a3..06d42aa 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -5,8 +5,10 @@ import pytest from codespy.config_memory import ( + EMBEDDING_MODELS, PostgresConfig, Pg0Config, + _cerebral_litellm_params, _generate_bank_id, apply_memory_env_overrides, get_episode_store, @@ -389,3 +391,119 @@ def memory_enabled(sig): assert success is False assert "not accessible" in message assert "Cannot connect" in message + + +class TestCerebralLitellmParams: + """Tests for _cerebral_litellm_params function.""" + + @pytest.fixture + def mock_settings(self): + """Create a mock settings object with LLM config.""" + settings = MagicMock() + settings.llm.openai_api_key.get_secret_value.return_value = "sk-openai-test" + settings.llm.openai_api_base = "https://api.openai.com/v1" + settings.llm.anthropic_api_key.get_secret_value.return_value = "sk-anthropic-test" + settings.llm.gemini_api_key.get_secret_value.return_value = "gemini-test" + settings.llm.azure_api_key.get_secret_value.return_value = "azure-test" + settings.llm.azure_api_base = "https://my-resource.openai.azure.com" + return settings + + def test_openai_prefix(self, mock_settings): + """openai/ prefix should use OpenAI credentials.""" + mock_settings.get_llm_config.return_value.model = "openai/gpt-4" + + model, api_key, base_url = _cerebral_litellm_params(mock_settings) + + assert model == "openai/gpt-4" + assert api_key == "sk-openai-test" + assert base_url == "https://api.openai.com/v1" + + def test_anthropic_prefix(self, mock_settings): + """anthropic/ prefix should use Anthropic credentials.""" + mock_settings.get_llm_config.return_value.model = "anthropic/claude-3-opus-4-1" + + model, api_key, base_url = _cerebral_litellm_params(mock_settings) + + assert model == "anthropic/claude-3-opus-4-1" + assert api_key == "sk-anthropic-test" + assert base_url is None + + def test_gemini_prefix(self, mock_settings): + """gemini/ prefix should use Gemini credentials.""" + mock_settings.get_llm_config.return_value.model = "gemini/gemini-pro" + + model, api_key, base_url = _cerebral_litellm_params(mock_settings) + + assert model == "gemini/gemini-pro" + assert api_key == "gemini-test" + assert base_url is None + + def test_azure_prefix(self, mock_settings): + """azure/ prefix should use Azure credentials.""" + mock_settings.get_llm_config.return_value.model = "azure/gpt-4" + + model, api_key, base_url = _cerebral_litellm_params(mock_settings) + + assert model == "azure/gpt-4" + assert api_key == "azure-test" + assert base_url == "https://my-resource.openai.azure.com" + + def test_azure_ai_prefix(self, mock_settings): + """azure_ai/ prefix should use Azure credentials.""" + mock_settings.get_llm_config.return_value.model = "azure_ai/gpt-4" + + model, api_key, base_url = _cerebral_litellm_params(mock_settings) + + assert model == "azure_ai/gpt-4" + assert api_key == "azure-test" + assert base_url == "https://my-resource.openai.azure.com" + + def test_bedrock_prefix(self, mock_settings): + """bedrock/ prefix should use no credentials (AWS env vars).""" + mock_settings.get_llm_config.return_value.model = "bedrock/converse/moonshotai.kimi-k2.5" + + model, api_key, base_url = _cerebral_litellm_params(mock_settings) + + assert model == "bedrock/converse/moonshotai.kimi-k2.5" + assert api_key is None + assert base_url is None + + def test_unknown_prefix(self, mock_settings): + """Unknown prefix should still return model with no credentials.""" + mock_settings.get_llm_config.return_value.model = "custom/model" + + model, api_key, base_url = _cerebral_litellm_params(mock_settings) + + assert model == "custom/model" + assert api_key is None + assert base_url is None + + def test_model_unchanged(self, mock_settings): + """Full litellm model string should be passed unchanged.""" + mock_settings.get_llm_config.return_value.model = "bedrock/converse/moonshotai.kimi-k2.5" + + model, _, _ = _cerebral_litellm_params(mock_settings) + + assert model == "bedrock/converse/moonshotai.kimi-k2.5" + + def test_embedding_models_mapping_bedrock(self): + """EMBEDDING_MODELS should have entry for bedrock.""" + assert "bedrock" in EMBEDDING_MODELS + assert "cohere" in EMBEDDING_MODELS["bedrock"] + + def test_embedding_models_mapping_openai(self): + """EMBEDDING_MODELS should have entry for openai.""" + assert "openai" in EMBEDDING_MODELS + assert "text-embedding-3-small" in EMBEDDING_MODELS["openai"] + + def test_embedding_models_mapping_anthropic(self): + """EMBEDDING_MODELS should have entry for anthropic.""" + assert "anthropic" in EMBEDDING_MODELS + + def test_embedding_models_mapping_gemini(self): + """EMBEDDING_MODELS should have entry for gemini.""" + assert "gemini" in EMBEDDING_MODELS + + def test_embedding_models_mapping_azure(self): + """EMBEDDING_MODELS should have entry for azure.""" + assert "azure" in EMBEDDING_MODELS diff --git a/tests/test_cost_tracker.py b/tests/test_cost_tracker.py new file mode 100644 index 0000000..4837b02 --- /dev/null +++ b/tests/test_cost_tracker.py @@ -0,0 +1,386 @@ +"""Tests for the CostTracker module.""" + +import threading +from unittest.mock import MagicMock, patch + +import pytest + +from codespy.agents.cost_tracker import ( + CostTracker, + SignatureContext, + SignatureStats, + _as_number, + _calculate_costs_from_entries, + _get_history_entries, + _get_history_uuids, + get_cost_tracker, +) + + +class TestSignatureStats: + """Tests for SignatureStats dataclass.""" + + def test_duration_seconds_returns_zero_if_no_start_time(self): + """Duration should be 0 if start_time is None.""" + stats = SignatureStats(name="test") + assert stats.duration_seconds == 0.0 + + def test_duration_seconds_calculates_from_start_time(self): + """Duration should calculate from start_time to now.""" + import time + + stats = SignatureStats(name="test", start_time=time.time() - 1.0) + assert stats.duration_seconds >= 0.9 + + def test_duration_seconds_uses_end_time_if_set(self): + """Duration should use end_time if set.""" + stats = SignatureStats(name="test", start_time=0.0, end_time=5.0) + assert stats.duration_seconds == 5.0 + + def test_to_dict_includes_all_fields(self): + """to_dict should include all relevant fields.""" + stats = SignatureStats( + name="test", cost=0.5, tokens=1000, call_count=5, start_time=0.0, end_time=5.0 + ) + result = stats.to_dict() + + assert result["name"] == "test" + assert result["cost"] == 0.5 + assert result["tokens"] == 1000 + assert result["call_count"] == 5 + assert result["duration_seconds"] == 5.0 + + +class TestAsNumber: + """Tests for _as_number helper.""" + + def test_none_returns_zero(self): + assert _as_number(None) == 0.0 + + def test_bool_returns_zero(self): + assert _as_number(True) == 0.0 + assert _as_number(False) == 0.0 + + def test_int_returns_float(self): + assert _as_number(42) == 42.0 + + def test_float_returns_float(self): + assert _as_number(3.14) == 3.14 + + def test_numeric_string_returns_float(self): + assert _as_number("10.5") == 10.5 + + def test_invalid_string_returns_zero(self): + assert _as_number("not a number") == 0.0 + + def test_list_returns_zero(self): + assert _as_number([1, 2, 3]) == 0.0 + + +class TestGetHistoryEntries: + """Tests for _get_history_entries helper.""" + + def test_returns_empty_list_when_no_lm(self): + with patch("dspy.settings.lm", None): + result = _get_history_entries() + assert result == [] + + def test_returns_empty_list_when_no_history_attr(self): + mock_lm = MagicMock() + del mock_lm.history + with patch("dspy.settings.lm", mock_lm): + result = _get_history_entries() + assert result == [] + + def test_returns_history_when_available(self): + mock_history = [{"uuid": "test-uuid", "cost": 0.5}] + mock_lm = MagicMock() + mock_lm.history = mock_history + with patch("dspy.settings.lm", mock_lm): + result = _get_history_entries() + assert result == mock_history + + +class TestGetHistoryUuids: + """Tests for _get_history_uuids helper.""" + + def test_returns_set_of_uuids(self): + mock_history = [ + {"uuid": "uuid-1", "cost": 0.5}, + {"uuid": "uuid-2", "cost": 0.3}, + {"cost": 0.1}, # No uuid + ] + mock_lm = MagicMock() + mock_lm.history = mock_history + with patch("dspy.settings.lm", mock_lm): + result = _get_history_uuids() + assert result == {"uuid-1", "uuid-2"} + + +class TestCalculateCostsFromEntries: + """Tests for _calculate_costs_from_entries helper.""" + + def test_calculates_costs_and_tokens(self): + entries = [ + { + "uuid": "uuid-1", + "cost": 0.5, + "usage": {"prompt_tokens": 100, "completion_tokens": 50}, + }, + { + "uuid": "uuid-2", + "cost": 0.3, + "usage": {"prompt_tokens": 200, "completion_tokens": 100}, + }, + ] + exclude = set() + + cost, tokens, calls = _calculate_costs_from_entries(entries, exclude) + + assert cost == 0.8 + assert tokens == 450 + assert calls == 2 + + def test_excludes_specified_uuids(self): + entries = [ + {"uuid": "uuid-1", "cost": 0.5, "usage": {"prompt_tokens": 100}}, + {"uuid": "uuid-2", "cost": 0.3, "usage": {"prompt_tokens": 200}}, + ] + exclude = {"uuid-1"} + + cost, tokens, calls = _calculate_costs_from_entries(entries, exclude) + + assert cost == 0.3 + assert tokens == 200 + assert calls == 1 + + def test_handles_non_dict_entries(self): + entries = ["not a dict", {"uuid": "uuid-1", "cost": 0.5, "usage": {}}] + exclude = set() + + cost, tokens, calls = _calculate_costs_from_entries(entries, exclude) + + assert cost == 0.5 + assert calls == 1 + + def test_handles_missing_usage(self): + entries = [ + {"uuid": "uuid-1", "cost": 0.5}, # No usage + ] + exclude = set() + + cost, tokens, calls = _calculate_costs_from_entries(entries, exclude) + + assert cost == 0.5 + assert tokens == 0 + assert calls == 1 + + +class TestCostTracker: + """Tests for CostTracker class.""" + + def test_reset_clears_all_stats(self): + tracker = CostTracker() + tracker.add_external_call("test", 1.0, 100) + tracker.reset() + + assert tracker.total_cost == 0.0 + assert tracker.total_tokens == 0 + assert tracker.call_count == 0 + + def test_start_signature_creates_new_entry(self): + tracker = CostTracker() + tracker.start_signature("test_sig") + + stats = tracker.get_signature_stats("test_sig") + assert stats is not None + assert stats.name == "test_sig" + assert stats.start_time is not None + + def test_end_signature_updates_stats(self): + tracker = CostTracker() + tracker.start_signature("test_sig") + tracker.end_signature("test_sig", 0.5, 100, 2) + + stats = tracker.get_signature_stats("test_sig") + assert stats.cost == 0.5 + assert stats.tokens == 100 + assert stats.call_count == 2 + assert stats.end_time is not None + + def test_end_signature_accumulates_multiple_calls(self): + tracker = CostTracker() + tracker.start_signature("test_sig") + tracker.end_signature("test_sig", 0.5, 100, 2) + tracker.end_signature("test_sig", 0.3, 50, 1) + + stats = tracker.get_signature_stats("test_sig") + assert stats.cost == 0.8 + assert stats.tokens == 150 + assert stats.call_count == 3 + + def test_total_cost_sums_all_signatures(self): + tracker = CostTracker() + tracker.add_external_call("sig1", 0.5, 100) + tracker.add_external_call("sig2", 0.3, 50) + + assert tracker.total_cost == 0.8 + + def test_total_tokens_sums_all_signatures(self): + tracker = CostTracker() + tracker.add_external_call("sig1", 0.5, 100) + tracker.add_external_call("sig2", 0.3, 50) + + assert tracker.total_tokens == 150 + + def test_call_count_sums_all_signatures(self): + tracker = CostTracker() + tracker.add_external_call("sig1", 0.5, 100, 2) + tracker.add_external_call("sig2", 0.3, 50, 3) + + assert tracker.call_count == 5 + + def test_get_signature_stats_returns_none_for_unknown(self): + tracker = CostTracker() + result = tracker.get_signature_stats("unknown") + assert result is None + + def test_get_all_signature_stats_returns_copy(self): + tracker = CostTracker() + tracker.add_external_call("test", 0.5, 100) + + stats1 = tracker.get_all_signature_stats() + tracker.add_external_call("test", 0.3, 50) # Modify after copy + stats2 = tracker.get_all_signature_stats() + + assert stats1["test"].cost == 0.5 # Original copy unchanged + assert stats2["test"].cost == 0.8 # New copy reflects updates + + +class TestCostTrackerAddExternalCall: + """Tests for CostTracker.add_external_call method.""" + + def test_add_external_call_creates_new_entry(self): + tracker = CostTracker() + tracker.add_external_call("cerebral_retain", 0.5, 100, 1) + + stats = tracker.get_signature_stats("cerebral_retain") + assert stats is not None + assert stats.name == "cerebral_retain" + assert stats.cost == 0.5 + assert stats.tokens == 100 + assert stats.call_count == 1 + + def test_add_external_call_accumulates_existing_entry(self): + tracker = CostTracker() + tracker.add_external_call("cerebral_retain", 0.5, 100, 1) + tracker.add_external_call("cerebral_retain", 0.3, 50, 2) + + stats = tracker.get_signature_stats("cerebral_retain") + assert stats.cost == 0.8 + assert stats.tokens == 150 + assert stats.call_count == 3 + + def test_add_external_call_does_not_touch_start_end_time(self): + tracker = CostTracker() + tracker.add_external_call("cerebral_retain", 0.5, 100) + + stats = tracker.get_signature_stats("cerebral_retain") + assert stats.start_time is None + assert stats.end_time is None + + def test_add_external_call_default_calls_is_one(self): + tracker = CostTracker() + tracker.add_external_call("cerebral_retain", 0.5, 100) + + stats = tracker.get_signature_stats("cerebral_retain") + assert stats.call_count == 1 + + def test_add_external_call_is_thread_safe(self): + """Concurrent add_external_call calls should all be counted.""" + tracker = CostTracker() + errors = [] + + def add_call(n): + try: + tracker.add_external_call("cerebral_retain", 0.1, 10, 1) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=add_call, args=(i,)) for i in range(100)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors + stats = tracker.get_signature_stats("cerebral_retain") + assert stats.cost == pytest.approx(10.0, rel=0.01) + assert stats.tokens == 1000 + assert stats.call_count == 100 + + +class TestSignatureContext: + """Tests for SignatureContext context manager.""" + + @pytest.fixture + def mock_lm_context(self): + with patch("codespy.agents.dspy_config.lm_context") as mock: + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=None) + mock.return_value = ctx + yield mock + + def test_enter_applies_lm_and_starts_tracking(self, mock_lm_context): + tracker = CostTracker() + + with patch("dspy.settings.lm", MagicMock(history=[])): + with SignatureContext("test_sig", tracker): + pass + + stats = tracker.get_signature_stats("test_sig") + assert stats is not None + + def test_exit_calculates_costs(self, mock_lm_context): + tracker = CostTracker() + mock_history = [ + {"uuid": "new-uuid", "cost": 0.5, "usage": {"prompt_tokens": 100}}, + ] + + with patch("dspy.settings.lm", MagicMock(history=mock_history)): + with patch.object(tracker, "end_signature") as mock_end: + with SignatureContext("test_sig", tracker): + pass + + mock_end.assert_called_once() + args = mock_end.call_args + assert args[0][0] == "test_sig" + + def test_exit_always_releases_lm_context(self, mock_lm_context): + tracker = CostTracker() + mock_lm = MagicMock() + mock_lm.history = [] + + with patch("dspy.settings.lm", mock_lm): + try: + with SignatureContext("test_sig", tracker): + raise ValueError("Test error") + except ValueError: + pass + + # lm_context.__exit__ should have been called + assert mock_lm_context.return_value.__exit__.called + + +class TestGetCostTracker: + """Tests for get_cost_tracker function.""" + + def test_returns_same_instance(self): + tracker1 = get_cost_tracker() + tracker2 = get_cost_tracker() + assert tracker1 is tracker2 + + def test_returns_cost_tracker_instance(self): + tracker = get_cost_tracker() + assert isinstance(tracker, CostTracker) From 6e2871c83acb8e22929eb8d08d54d858d49c0b93 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Thu, 24 Sep 2026 17:53:27 +0200 Subject: [PATCH 04/39] retain cost --- .../agents/memory/cerebral/cerebral.py | 38 ++++++--- tests/test_cerebral.py | 81 ++++++++++++++++++- 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index bad8763..5b5dbf9 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -141,6 +141,11 @@ def _ensure_bank(self) -> None: self._bank_id, updates={ "retain_extraction_mode": "concise", + # Chunk size must accommodate the merged observations blob. + # Observations are bounded by max_context_memory_tokens (~65K chars), + # so 65536 chars ensures each episode's observations stay a single chunk. + # If max_context_memory_tokens is raised above ~65K, bump this too. + "retain_chunk_size": 65536, "retain_mission": ( "Retain code review observations, analysis results, and artifacts. " "Focus on patterns, architectural decisions, dependency relationships, " @@ -161,24 +166,33 @@ def retain_episode(self, episode: Episode) -> None: tags = self._build_tags(episode) episode_doc_id = f"episode-{episode.id}" - # Build contents list: one dict per observation + one per artifact - # Each dict has 'content', 'context', 'tags', 'document_id', 'event_date' + # Build contents list: at most TWO items per episode + # One merged observations blob + one merged artifacts blob + # This reduces LLM call count by bundling items that share tags/document_id/event_date contents: list[dict] = [] + # Merge all observations into a single content item + obs_lines: list[str] = [] for section_name in episode.context_memory.section_names(): for obs in getattr(episode.context_memory, section_name): - contents.append({ - "content": f"[{section_name}] {obs.content}", - "context": f"{episode.task}: {episode.question}: observation ({section_name})", - "tags": tags, - "document_id": episode_doc_id, - "event_date": episode.timestamp.isoformat(), - }) + obs_lines.append(f"[{section_name}] {obs.content}") + if obs_lines: + contents.append({ + "content": "\n\n".join(obs_lines), + "context": f"{episode.task}: {episode.question}: observations", + "tags": tags, + "document_id": episode_doc_id, + "event_date": episode.timestamp.isoformat(), + }) + # Merge all artifacts into a single content item + artifact_lines: list[str] = [] for name, content in (episode.artifacts or {}).items(): + artifact_lines.append(f"[artifact:{name}] {content}") + if artifact_lines: contents.append({ - "content": content, - "context": f"{episode.task}: {episode.question}: artifact ({name})", + "content": "\n\n".join(artifact_lines), + "context": f"{episode.task}: {episode.question}: artifacts", "tags": tags, "document_id": episode_doc_id, "event_date": episode.timestamp.isoformat(), @@ -197,7 +211,7 @@ def retain_episode(self, episode: Episode) -> None: ) ) logger.info( - "cerebral: retained %d items for episode %s (bank=%s, task=%s)", + "cerebral: retained %d content blobs for episode %s (bank=%s, task=%s)", len(contents), episode.id, self._bank_id, episode.task, ) except Exception: diff --git a/tests/test_cerebral.py b/tests/test_cerebral.py index 9567aad..510b77a 100644 --- a/tests/test_cerebral.py +++ b/tests/test_cerebral.py @@ -237,7 +237,7 @@ def test_retain_episode_builds_correct_tags(self, mock_memory_engine_class, epis assert tags == expected_tags def test_retain_episode_creates_contents_for_observations(self, mock_memory_engine_class, episode, mock_engine, mock_litellm): - """Test that retain_episode creates content items for each observation.""" + """Test that retain_episode creates a single merged content item for all observations.""" with patch.object(Cerebral, "_run_async") as mock_run_async: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", @@ -252,13 +252,20 @@ def test_retain_episode_creates_contents_for_observations(self, mock_memory_engi # Should call retain_batch_async mock_run_async.assert_called_once() - # Get the arguments passed to retain_batch_async + # Get the contents passed to retain_batch_async call_args = mock_run_async.call_args + assert call_args is not None + + # Extract the contents argument from the coroutine call + # The first positional arg is the coroutine, we need to inspect it + coro = call_args[0][0] + # Access the bound arguments via the coroutine's __self__ if possible + # or check the call was made to retain_batch_async engine_call = mock_engine.retain_batch_async assert call_args is not None def test_retain_episode_creates_contents_for_artifacts(self, mock_memory_engine_class, episode, mock_litellm): - """Test that retain_episode creates content items for each artifact.""" + """Test that retain_episode creates a single merged content item for all artifacts.""" with patch.object(Cerebral, "_run_async") as mock_run_async: cerebral = Cerebral( database_url="postgresql://localhost:5432/test", @@ -316,6 +323,74 @@ def test_retain_episode_handles_exception_gracefully(self, mock_memory_engine_cl # Should not raise cerebral.retain_episode(episode) + def test_retain_episode_bundles_content_correctly(self, mock_memory_engine_class, episode, mock_litellm): + """Test that observations and artifacts are merged into at most 2 content items with proper headers.""" + captured_contents = [] + + # Create a mock engine that captures the contents passed to retain_batch_async + async def mock_retain_batch_async(*, bank_id, contents, request_context): + captured_contents.extend(contents) + return None + + # Create async mock coroutines for other methods + async def mock_coro(*args, **kwargs): + return None + + def mock_memory_engine(*args, **kwargs): + engine = MagicMock() + engine.initialize = mock_coro + engine.ensure_bank_profile = mock_coro + engine.update_bank_config = mock_coro + engine.retain_batch_async = mock_retain_batch_async + engine.close = mock_coro + return engine + + # Patch the MemoryEngine class at the module level + with patch("codespy.agents.memory.cerebral.cerebral.MemoryEngine", mock_memory_engine): + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="litellm", + ) + cerebral._bank_ensured = True + + cerebral.retain_episode(episode) + + # Episode has 2 observations (context_understanding, actions) + 1 artifact (review) + # Should be bundled into 2 content items max (observations + artifacts) + assert len(captured_contents) <= 2, f"Expected at most 2 content items, got {len(captured_contents)}" + assert len(captured_contents) >= 1, f"Expected at least 1 content item, got {len(captured_contents)}" + + # Find observations and artifacts content items + obs_item = None + art_item = None + for item in captured_contents: + if "observations" in item.get("context", ""): + obs_item = item + elif "artifacts" in item.get("context", ""): + art_item = item + + # Verify observations item + assert obs_item is not None, "Observations content item not found" + assert "[context_understanding]" in obs_item["content"], "Missing context_understanding header" + assert "[actions]" in obs_item["content"], "Missing actions header" + assert "This is a test observation" in obs_item["content"], "Missing observation content" + assert "Performed tool call" in obs_item["content"], "Missing action content" + + # Verify artifacts item + assert art_item is not None, "Artifacts content item not found" + assert "[artifact:review]" in art_item["content"], "Missing artifact header" + assert "# Review Results" in art_item["content"], "Missing artifact content" + + # Verify shared fields + expected_tags = cerebral._build_tags(episode) + expected_doc_id = f"episode-{episode.id}" + expected_event_date = episode.timestamp.isoformat() + + for item in captured_contents: + assert item["tags"] == expected_tags, f"Tags mismatch: {item['tags']} != {expected_tags}" + assert item["document_id"] == expected_doc_id, f"document_id mismatch" + assert item["event_date"] == expected_event_date, f"event_date mismatch" + def test_retain_episode_uses_correct_document_id(self, mock_memory_engine_class, episode, mock_engine, mock_litellm): """Test that retain_episode uses correct document_id format.""" with patch.object(Cerebral, "_run_async") as mock_run_async: From d08aeee506b2755548fd573be0d556d6d5a47efc Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Thu, 24 Sep 2026 18:59:47 +0200 Subject: [PATCH 05/39] retain cost --- src/codespy/agents/cost_tracker.py | 151 ++++++++++++++++-- src/codespy/agents/memory/cerebral/cost.py | 48 +++++- src/codespy/cli_local.py | 16 +- src/codespy/cli_remote.py | 8 +- src/codespy/workflows/review/models.py | 13 +- src/codespy/workflows/review/pipeline.py | 4 + src/codespy/workflows/review/reporters/git.py | 9 +- tests/test_cerebral_cost.py | 14 +- tests/test_cost_tracker.py | 56 +++++-- 9 files changed, 274 insertions(+), 45 deletions(-) diff --git a/src/codespy/agents/cost_tracker.py b/src/codespy/agents/cost_tracker.py index 464194b..1f283b0 100644 --- a/src/codespy/agents/cost_tracker.py +++ b/src/codespy/agents/cost_tracker.py @@ -28,6 +28,10 @@ class SignatureStats: call_count: int = 0 start_time: float | None = None end_time: float | None = None + input_tokens: int = 0 + output_tokens: int = 0 + input_cost: float = 0.0 + output_cost: float = 0.0 @property def duration_seconds(self) -> float: @@ -45,6 +49,10 @@ def to_dict(self) -> dict: "tokens": self.tokens, "call_count": self.call_count, "duration_seconds": self.duration_seconds, + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "input_cost": self.input_cost, + "output_cost": self.output_cost, } @@ -77,7 +85,17 @@ def start_signature(self, signature_name: str) -> None: self._signature_stats[signature_name].start_time = time.time() self._signature_stats[signature_name].end_time = None - def end_signature(self, signature_name: str, cost: float, tokens: int, call_count: int) -> None: + def end_signature( + self, + signature_name: str, + cost: float, + tokens: int, + call_count: int, + input_tokens: int = 0, + output_tokens: int = 0, + input_cost: float = 0.0, + output_cost: float = 0.0, + ) -> None: """Mark the end of a signature's execution with its costs. Args: @@ -85,6 +103,10 @@ def end_signature(self, signature_name: str, cost: float, tokens: int, call_coun cost: Total cost for this signature's LLM calls tokens: Total tokens used by this signature call_count: Number of LLM calls made by this signature + input_tokens: Input/prompt tokens used + output_tokens: Output/completion tokens used + input_cost: Cost for input tokens + output_cost: Cost for output tokens """ with self._lock: if signature_name not in self._signature_stats: @@ -94,8 +116,22 @@ def end_signature(self, signature_name: str, cost: float, tokens: int, call_coun stats.cost += cost stats.tokens += tokens stats.call_count += call_count + stats.input_tokens += input_tokens + stats.output_tokens += output_tokens + stats.input_cost += input_cost + stats.output_cost += output_cost - def add_external_call(self, name: str, cost: float, tokens: int, calls: int = 1) -> None: + def add_external_call( + self, + name: str, + cost: float, + tokens: int, + calls: int = 1, + input_tokens: int = 0, + output_tokens: int = 0, + input_cost: float = 0.0, + output_cost: float = 0.0, + ) -> None: """Accumulate costs from external LLM calls (not tracked by DSPy). Creates a SignatureStats entry if missing, without touching @@ -106,6 +142,10 @@ def add_external_call(self, name: str, cost: float, tokens: int, calls: int = 1) cost: Cost in USD for the call(s) tokens: Total tokens used calls: Number of calls (default 1) + input_tokens: Input/prompt tokens used + output_tokens: Output/completion tokens used + input_cost: Cost for input tokens + output_cost: Cost for output tokens """ with self._lock: if name not in self._signature_stats: @@ -114,6 +154,10 @@ def add_external_call(self, name: str, cost: float, tokens: int, calls: int = 1) stats.cost += cost stats.tokens += tokens stats.call_count += calls + stats.input_tokens += input_tokens + stats.output_tokens += output_tokens + stats.input_cost += input_cost + stats.output_cost += output_cost @property def total_cost(self) -> float: @@ -161,6 +205,10 @@ def get_all_signature_stats(self) -> dict[str, SignatureStats]: call_count=v.call_count, start_time=v.start_time, end_time=v.end_time, + input_tokens=v.input_tokens, + output_tokens=v.output_tokens, + input_cost=v.input_cost, + output_cost=v.output_cost, ) for k, v in self._signature_stats.items() } @@ -216,7 +264,7 @@ def _as_number(value: object) -> float: def _calculate_costs_from_entries( entries: list[dict], exclude_uuids: set[str] -) -> tuple[float, int, int]: +) -> tuple[float, int, int, int, int, float, float]: """Calculate costs from history entries, excluding specific UUIDs. Every field is read defensively: cost accounting is observability, so a @@ -228,11 +276,16 @@ def _calculate_costs_from_entries( exclude_uuids: Set of UUIDs to exclude from calculation Returns: - Tuple of (total_cost, total_tokens, call_count) + Tuple of (total_cost, total_tokens, call_count, input_tokens, output_tokens, + input_cost, output_cost) """ total_cost = 0.0 total_tokens = 0 call_count = 0 + input_tokens = 0 + output_tokens = 0 + input_cost = 0.0 + output_cost = 0.0 for entry in entries: if not isinstance(entry, dict): @@ -240,17 +293,84 @@ def _calculate_costs_from_entries( entry_uuid = entry.get("uuid", "") if entry_uuid and entry_uuid not in exclude_uuids: - total_cost += _as_number(entry.get("cost")) + entry_cost = _as_number(entry.get("cost")) + total_cost += entry_cost # Get tokens from usage usage = entry.get("usage") + entry_input_tokens = 0 + entry_output_tokens = 0 if isinstance(usage, dict): - total_tokens += int(_as_number(usage.get("prompt_tokens"))) - total_tokens += int(_as_number(usage.get("completion_tokens"))) + entry_input_tokens = int(_as_number(usage.get("prompt_tokens"))) + entry_output_tokens = int(_as_number(usage.get("completion_tokens"))) + input_tokens += entry_input_tokens + output_tokens += entry_output_tokens + total_tokens += entry_input_tokens + entry_output_tokens + + # Calculate split costs using litellm if model is available + entry_input_cost, entry_output_cost = _calculate_split_cost( + entry, entry_input_tokens, entry_output_tokens, entry_cost + ) + input_cost += entry_input_cost + output_cost += entry_output_cost call_count += 1 - return total_cost, total_tokens, call_count + return total_cost, total_tokens, call_count, input_tokens, output_tokens, input_cost, output_cost + + +def _calculate_split_cost( + entry: dict, prompt_tokens: int, completion_tokens: int, fallback_cost: float +) -> tuple[float, float]: + """Calculate split input/output cost for a history entry. + + Uses litellm.cost_per_token if model is available, otherwise falls back + to proportional split of the fallback_cost. + + Args: + entry: History entry dict + prompt_tokens: Number of prompt tokens + completion_tokens: Number of completion tokens + fallback_cost: Fallback total cost for proportional split + + Returns: + Tuple of (input_cost, output_cost) + """ + model = entry.get("model") + if not model: + # Fall back to proportional split by token ratio + return _proportional_split(prompt_tokens, completion_tokens, fallback_cost) + + try: + import litellm + + prompt_cost, completion_cost = litellm.cost_per_token( + model=model, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + ) + return float(prompt_cost), float(completion_cost) + except Exception: + # Model may not have pricing or be available; use proportional split + return _proportional_split(prompt_tokens, completion_tokens, fallback_cost) + + +def _proportional_split(input_tokens: int, output_tokens: int, total_cost: float) -> tuple[float, float]: + """Split cost proportionally by token count. + + Args: + input_tokens: Number of input tokens + output_tokens: Number of output tokens + total_cost: Total cost to split + + Returns: + Tuple of (input_cost, output_cost) + """ + total = input_tokens + output_tokens + if total == 0: + return 0.0, 0.0 + input_ratio = input_tokens / total + return total_cost * input_ratio, total_cost * (1 - input_ratio) class SignatureContext: @@ -332,8 +452,19 @@ def __exit__( # Read history before leaving the LM context, so dspy.settings.lm # still points at the LM whose history we need. entries = _get_history_entries() - cost, tokens, call_count = _calculate_costs_from_entries(entries, self._before_uuids) - self.tracker.end_signature(self.signature_name, cost, tokens, call_count) + cost, tokens, call_count, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries( + entries, self._before_uuids + ) + self.tracker.end_signature( + self.signature_name, + cost=cost, + tokens=tokens, + call_count=call_count, + input_tokens=input_tokens, + output_tokens=output_tokens, + input_cost=input_cost, + output_cost=output_cost, + ) except Exception as e: logger.warning("Cost calculation failed for %s: %s", self.signature_name, e) finally: diff --git a/src/codespy/agents/memory/cerebral/cost.py b/src/codespy/agents/memory/cerebral/cost.py index 7f66dc4..26caa7d 100644 --- a/src/codespy/agents/memory/cerebral/cost.py +++ b/src/codespy/agents/memory/cerebral/cost.py @@ -73,14 +73,24 @@ def record_llm_call( # type: ignore[return] bucket = BUCKET_CEREBRAL_OTHER # Price the call using litellm - cost = self._price_call(model, input_tokens, output_tokens) + prompt_cost, completion_cost = self._price_call_split(model, input_tokens, output_tokens) + cost = prompt_cost + completion_cost + tokens = input_tokens + output_tokens # Import here to avoid circular imports at module load from codespy.agents.cost_tracker import get_cost_tracker tracker = get_cost_tracker() - tokens = input_tokens + output_tokens - tracker.add_external_call(bucket, cost, tokens, calls=1) + tracker.add_external_call( + bucket, + cost=cost, + tokens=tokens, + calls=1, + input_tokens=input_tokens, + output_tokens=output_tokens, + input_cost=prompt_cost, + output_cost=completion_cost, + ) except Exception as e: # Metering must never break the actual operation @@ -91,6 +101,22 @@ def _price_call(self, model: str, input_tokens: int, output_tokens: int) -> floa Returns 0.0 if the model has no price registered. Logs a warning once per unpriced model. + + Deprecated: Use _price_call_split for input/output cost breakdown. + """ + prompt_cost, completion_cost = self._price_call_split(model, input_tokens, output_tokens) + return prompt_cost + completion_cost + + def _price_call_split( + self, model: str, input_tokens: int, output_tokens: int + ) -> tuple[float, float]: + """Price a call using litellm.cost_per_token. + + Returns (prompt_cost, completion_cost). Returns (0.0, 0.0) if the model + has no price registered. Logs a warning once per unpriced model. + + Returns: + Tuple of (input/prompt cost, output/completion cost) """ try: import litellm @@ -100,7 +126,7 @@ def _price_call(self, model: str, input_tokens: int, output_tokens: int) -> floa prompt_tokens=input_tokens, completion_tokens=output_tokens, ) - return prompt_cost + completion_cost + return float(prompt_cost), float(completion_cost) except Exception: # Model may not have a price if model not in _warned_unpriced_models: @@ -110,7 +136,7 @@ def _price_call(self, model: str, input_tokens: int, output_tokens: int) -> floa "Tokens are still recorded.", model, ) - return 0.0 + return 0.0, 0.0 class _LiteLLMProxy: @@ -170,7 +196,17 @@ def _meter_embedding(self, response: Any, model: str) -> None: from codespy.agents.cost_tracker import get_cost_tracker tracker = get_cost_tracker() - tracker.add_external_call(BUCKET_CEREBRAL_EMBEDDINGS, cost or 0.0, tokens, calls=1) + # Embeddings have no output tokens - all tokens are input + tracker.add_external_call( + BUCKET_CEREBRAL_EMBEDDINGS, + cost=cost or 0.0, + tokens=tokens, + calls=1, + input_tokens=tokens, + output_tokens=0, + input_cost=cost or 0.0, + output_cost=0.0, + ) except Exception as e: logger.debug("Failed to meter embedding call: %s", e) diff --git a/src/codespy/cli_local.py b/src/codespy/cli_local.py index 60cdd43..f9175db 100644 --- a/src/codespy/cli_local.py +++ b/src/codespy/cli_local.py @@ -120,10 +120,16 @@ def review_local( if result.llm_calls > 0: cost_str = f"${result.total_cost:.4f}" if result.total_cost > 0 else "N/A" + # Calculate input/output totals from signature_stats + total_in_tokens = sum(s.input_tokens for s in result.signature_stats) + total_out_tokens = sum(s.output_tokens for s in result.signature_stats) + total_in_cost = sum(s.input_cost for s in result.signature_stats) + total_out_cost = sum(s.output_cost for s in result.signature_stats) console.print( Panel( f"[bold]LLM Calls:[/bold] {result.llm_calls}\n" - f"[bold]Total Tokens:[/bold] {result.total_tokens:,}\n" + f"[bold]In Tokens:[/bold] {total_in_tokens:,} [bold]Out Tokens:[/bold] {total_out_tokens:,}\n" + f"[bold]In Cost:[/bold] ${total_in_cost:.4f} [bold]Out Cost:[/bold] ${total_out_cost:.4f}\n" f"[bold]Total Cost:[/bold] {cost_str}", title="Cost Summary", ) @@ -233,10 +239,16 @@ def review_uncommitted( if result.llm_calls > 0: cost_str = f"${result.total_cost:.4f}" if result.total_cost > 0 else "N/A" + # Calculate input/output totals from signature_stats + total_in_tokens = sum(s.input_tokens for s in result.signature_stats) + total_out_tokens = sum(s.output_tokens for s in result.signature_stats) + total_in_cost = sum(s.input_cost for s in result.signature_stats) + total_out_cost = sum(s.output_cost for s in result.signature_stats) console.print( Panel( f"[bold]LLM Calls:[/bold] {result.llm_calls}\n" - f"[bold]Total Tokens:[/bold] {result.total_tokens:,}\n" + f"[bold]In Tokens:[/bold] {total_in_tokens:,} [bold]Out Tokens:[/bold] {total_out_tokens:,}\n" + f"[bold]In Cost:[/bold] ${total_in_cost:.4f} [bold]Out Cost:[/bold] ${total_out_cost:.4f}\n" f"[bold]Total Cost:[/bold] {cost_str}", title="Cost Summary", ) diff --git a/src/codespy/cli_remote.py b/src/codespy/cli_remote.py index 2761358..594b148 100644 --- a/src/codespy/cli_remote.py +++ b/src/codespy/cli_remote.py @@ -173,10 +173,16 @@ def review( # Show cost summary if result.llm_calls > 0: cost_str = f"${result.total_cost:.4f}" if result.total_cost > 0 else "N/A" + # Calculate input/output totals from signature_stats + total_in_tokens = sum(s.input_tokens for s in result.signature_stats) + total_out_tokens = sum(s.output_tokens for s in result.signature_stats) + total_in_cost = sum(s.input_cost for s in result.signature_stats) + total_out_cost = sum(s.output_cost for s in result.signature_stats) console.print( Panel( f"[bold]LLM Calls:[/bold] {result.llm_calls}\n" - f"[bold]Total Tokens:[/bold] {result.total_tokens:,}\n" + f"[bold]In Tokens:[/bold] {total_in_tokens:,} [bold]Out Tokens:[/bold] {total_out_tokens:,}\n" + f"[bold]In Cost:[/bold] ${total_in_cost:.4f} [bold]Out Cost:[/bold] ${total_out_cost:.4f}\n" f"[bold]Total Cost:[/bold] {cost_str}", title="Cost Summary", ) diff --git a/src/codespy/workflows/review/models.py b/src/codespy/workflows/review/models.py index 187b78e..eddd633 100644 --- a/src/codespy/workflows/review/models.py +++ b/src/codespy/workflows/review/models.py @@ -20,6 +20,10 @@ class SignatureStatsResult(BaseModel): tokens: int = Field(default=0, description="Tokens used by this signature") call_count: int = Field(default=0, description="Number of LLM calls made by this signature") duration_seconds: float = Field(default=0.0, description="Execution time in seconds") + input_tokens: int = Field(default=0, description="Input/prompt tokens used") + output_tokens: int = Field(default=0, description="Output/completion tokens used") + input_cost: float = Field(default=0.0, description="Cost for input tokens") + output_cost: float = Field(default=0.0, description="Cost for output tokens") @property def cost_per_call(self) -> float: @@ -169,7 +173,6 @@ def to_markdown(self) -> str: "## Cost", "", f"- **LLM Calls:** {self.llm_calls}", - f"- **Total Tokens:** {self.total_tokens:,}", f"- **Total Cost:** ${self.total_cost:.4f}", "", ] @@ -181,15 +184,15 @@ def to_markdown(self) -> str: [ "### Per-Signature Breakdown", "", - "| Signature | Cost | Tokens | Calls | Duration |", - "|-----------|------|--------|-------|----------|", + "| Signature | In Tokens | Out Tokens | In Cost | Out Cost | Calls | Duration |", + "|-----------|-----------|------------|---------|----------|-------|----------|", ] ) for stats in sorted(self.signature_stats, key=lambda x: x.cost, reverse=True): duration_str = f"{stats.duration_seconds:.1f}s" lines.append( - f"| {stats.name} | ${stats.cost:.4f} | {stats.tokens:,} | " - f"{stats.call_count} | {duration_str} |" + f"| {stats.name} | {stats.input_tokens:,} | {stats.output_tokens:,} | " + f"${stats.input_cost:.4f} | ${stats.output_cost:.4f} | {stats.call_count} | {duration_str} |" ) lines.append("") diff --git a/src/codespy/workflows/review/pipeline.py b/src/codespy/workflows/review/pipeline.py index a3f2304..442a85b 100644 --- a/src/codespy/workflows/review/pipeline.py +++ b/src/codespy/workflows/review/pipeline.py @@ -288,6 +288,10 @@ def _collect_signature_stats(self) -> list[SignatureStatsResult]: tokens=stats.tokens, call_count=stats.call_count, duration_seconds=stats.duration_seconds, + input_tokens=stats.input_tokens, + output_tokens=stats.output_tokens, + input_cost=stats.input_cost, + output_cost=stats.output_cost, ) ) diff --git a/src/codespy/workflows/review/reporters/git.py b/src/codespy/workflows/review/reporters/git.py index bda5a26..2668eb4 100644 --- a/src/codespy/workflows/review/reporters/git.py +++ b/src/codespy/workflows/review/reporters/git.py @@ -159,7 +159,6 @@ def _build_review_body( "💰 Cost Summary", "", f"**Total:** ${result.total_cost:.4f} | " - f"**Tokens:** {result.total_tokens:,} | " f"**LLM Calls:** {result.llm_calls}", "", ] @@ -168,15 +167,15 @@ def _build_review_body( if result.signature_stats: lines.extend( [ - "| Signature | Cost | Tokens | Calls | Duration |", - "|-----------|------|--------|-------|----------|", + "| Signature | In Tokens | Out Tokens | In Cost | Out Cost | Calls | Duration |", + "|-----------|-----------|------------|---------|----------|-------|----------|", ] ) for stats in sorted(result.signature_stats, key=lambda x: x.cost, reverse=True): duration_str = f"{stats.duration_seconds:.1f}s" lines.append( - f"| {stats.name} | ${stats.cost:.4f} | {stats.tokens:,} | " - f"{stats.call_count} | {duration_str} |" + f"| {stats.name} | {stats.input_tokens:,} | {stats.output_tokens:,} | " + f"${stats.input_cost:.4f} | ${stats.output_cost:.4f} | {stats.call_count} | {duration_str} |" ) lines.append("") diff --git a/tests/test_cerebral_cost.py b/tests/test_cerebral_cost.py index 342f241..76a938a 100644 --- a/tests/test_cerebral_cost.py +++ b/tests/test_cerebral_cost.py @@ -60,7 +60,7 @@ def test_record_llm_call_with_retain_scope(self, mock_tracker, fresh_recorder): """scope='retain_extract_facts' → cerebral_retain bucket.""" with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): with patch.object( - CerebralCostRecorder, "_price_call", return_value=0.05 + CerebralCostRecorder, "_price_call_split", return_value=(0.03, 0.02) ): recorder = CerebralCostRecorder() recorder.record_llm_call( @@ -72,13 +72,17 @@ def test_record_llm_call_with_retain_scope(self, mock_tracker, fresh_recorder): stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) assert stats is not None - assert stats.cost == 0.05 + assert stats.cost == 0.05 # 0.03 + 0.02 assert stats.tokens == 150 + assert stats.input_tokens == 100 + assert stats.output_tokens == 50 + assert stats.input_cost == 0.03 + assert stats.output_cost == 0.02 def test_record_llm_call_with_consolidation_scope(self, mock_tracker, fresh_recorder): """scope='consolidation' → cerebral_other bucket.""" with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): - with patch.object(CerebralCostRecorder, "_price_call", return_value=0.03): + with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.02, 0.01)): recorder = CerebralCostRecorder() recorder.record_llm_call( model="openai/gpt-4", @@ -89,11 +93,13 @@ def test_record_llm_call_with_consolidation_scope(self, mock_tracker, fresh_reco stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_OTHER) assert stats is not None + assert stats.input_tokens == 100 + assert stats.output_tokens == 50 def test_record_llm_call_with_other_scope(self, mock_tracker, fresh_recorder): """Any other scope → cerebral_other bucket.""" with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): - with patch.object(CerebralCostRecorder, "_price_call", return_value=0.01): + with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.005, 0.005)): recorder = CerebralCostRecorder() recorder.record_llm_call( model="openai/gpt-4", diff --git a/tests/test_cost_tracker.py b/tests/test_cost_tracker.py index 4837b02..5bafbd4 100644 --- a/tests/test_cost_tracker.py +++ b/tests/test_cost_tracker.py @@ -135,11 +135,14 @@ def test_calculates_costs_and_tokens(self): ] exclude = set() - cost, tokens, calls = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) assert cost == 0.8 assert tokens == 450 assert calls == 2 + assert input_tokens == 300 + assert output_tokens == 150 + assert input_cost + output_cost == cost # Costs should sum correctly def test_excludes_specified_uuids(self): entries = [ @@ -148,17 +151,19 @@ def test_excludes_specified_uuids(self): ] exclude = {"uuid-1"} - cost, tokens, calls = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) assert cost == 0.3 assert tokens == 200 assert calls == 1 + assert input_tokens == 200 # Only uuid-2's tokens + assert output_tokens == 0 def test_handles_non_dict_entries(self): entries = ["not a dict", {"uuid": "uuid-1", "cost": 0.5, "usage": {}}] exclude = set() - cost, tokens, calls = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) assert cost == 0.5 assert calls == 1 @@ -169,11 +174,13 @@ def test_handles_missing_usage(self): ] exclude = set() - cost, tokens, calls = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) assert cost == 0.5 assert tokens == 0 assert calls == 1 + assert input_tokens == 0 + assert output_tokens == 0 class TestCostTracker: @@ -200,24 +207,32 @@ def test_start_signature_creates_new_entry(self): def test_end_signature_updates_stats(self): tracker = CostTracker() tracker.start_signature("test_sig") - tracker.end_signature("test_sig", 0.5, 100, 2) + tracker.end_signature("test_sig", 0.5, 100, 2, input_tokens=80, output_tokens=20, input_cost=0.3, output_cost=0.2) stats = tracker.get_signature_stats("test_sig") assert stats.cost == 0.5 assert stats.tokens == 100 assert stats.call_count == 2 assert stats.end_time is not None + assert stats.input_tokens == 80 + assert stats.output_tokens == 20 + assert stats.input_cost == 0.3 + assert stats.output_cost == 0.2 def test_end_signature_accumulates_multiple_calls(self): tracker = CostTracker() tracker.start_signature("test_sig") - tracker.end_signature("test_sig", 0.5, 100, 2) - tracker.end_signature("test_sig", 0.3, 50, 1) + tracker.end_signature("test_sig", 0.5, 100, 2, input_tokens=80, output_tokens=20, input_cost=0.3, output_cost=0.2) + tracker.end_signature("test_sig", 0.3, 50, 1, input_tokens=30, output_tokens=20, input_cost=0.2, output_cost=0.1) stats = tracker.get_signature_stats("test_sig") assert stats.cost == 0.8 assert stats.tokens == 150 assert stats.call_count == 3 + assert stats.input_tokens == 110 # 80 + 30 + assert stats.output_tokens == 40 # 20 + 20 + assert stats.input_cost == pytest.approx(0.5) # 0.3 + 0.2 + assert stats.output_cost == pytest.approx(0.3) # 0.2 + 0.1 def test_total_cost_sums_all_signatures(self): tracker = CostTracker() @@ -262,7 +277,7 @@ class TestCostTrackerAddExternalCall: def test_add_external_call_creates_new_entry(self): tracker = CostTracker() - tracker.add_external_call("cerebral_retain", 0.5, 100, 1) + tracker.add_external_call("cerebral_retain", 0.5, 100, 1, input_tokens=100, output_tokens=0, input_cost=0.5, output_cost=0.0) stats = tracker.get_signature_stats("cerebral_retain") assert stats is not None @@ -270,16 +285,24 @@ def test_add_external_call_creates_new_entry(self): assert stats.cost == 0.5 assert stats.tokens == 100 assert stats.call_count == 1 + assert stats.input_tokens == 100 + assert stats.output_tokens == 0 + assert stats.input_cost == 0.5 + assert stats.output_cost == 0.0 def test_add_external_call_accumulates_existing_entry(self): tracker = CostTracker() - tracker.add_external_call("cerebral_retain", 0.5, 100, 1) - tracker.add_external_call("cerebral_retain", 0.3, 50, 2) + tracker.add_external_call("cerebral_retain", 0.5, 100, 1, input_tokens=100, output_tokens=0, input_cost=0.5, output_cost=0.0) + tracker.add_external_call("cerebral_retain", 0.3, 50, 2, input_tokens=30, output_tokens=20, input_cost=0.2, output_cost=0.1) stats = tracker.get_signature_stats("cerebral_retain") assert stats.cost == 0.8 assert stats.tokens == 150 assert stats.call_count == 3 + assert stats.input_tokens == 130 # 100 + 30 + assert stats.output_tokens == 20 # 0 + 20 + assert stats.input_cost == 0.7 # 0.5 + 0.2 + assert stats.output_cost == 0.1 # 0.0 + 0.1 def test_add_external_call_does_not_touch_start_end_time(self): tracker = CostTracker() @@ -291,10 +314,15 @@ def test_add_external_call_does_not_touch_start_end_time(self): def test_add_external_call_default_calls_is_one(self): tracker = CostTracker() - tracker.add_external_call("cerebral_retain", 0.5, 100) + tracker.add_external_call("cerebral_retain", 0.5, 100) # Uses defaults for new fields stats = tracker.get_signature_stats("cerebral_retain") assert stats.call_count == 1 + # Default values for new fields + assert stats.input_tokens == 0 + assert stats.output_tokens == 0 + assert stats.input_cost == 0.0 + assert stats.output_cost == 0.0 def test_add_external_call_is_thread_safe(self): """Concurrent add_external_call calls should all be counted.""" @@ -303,7 +331,7 @@ def test_add_external_call_is_thread_safe(self): def add_call(n): try: - tracker.add_external_call("cerebral_retain", 0.1, 10, 1) + tracker.add_external_call("cerebral_retain", 0.1, 10, 1, input_tokens=8, output_tokens=2, input_cost=0.08, output_cost=0.02) except Exception as e: errors.append(e) @@ -318,6 +346,10 @@ def add_call(n): assert stats.cost == pytest.approx(10.0, rel=0.01) assert stats.tokens == 1000 assert stats.call_count == 100 + assert stats.input_tokens == 800 + assert stats.output_tokens == 200 + assert stats.input_cost == pytest.approx(8.0, rel=0.01) + assert stats.output_cost == pytest.approx(2.0, rel=0.01) class TestSignatureContext: From bc154ce3c77068a9344733b88f5923c31a2c3646 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Thu, 24 Sep 2026 19:26:05 +0200 Subject: [PATCH 06/39] retain cost --- src/codespy/agents/cost_tracker.py | 26 ++++++++++---- src/codespy/agents/memory/cerebral/cost.py | 11 ++++-- tests/test_cerebral_cost.py | 42 ++++++++++++++++++++++ tests/test_cost_tracker.py | 41 +++++++++++++++++++++ 4 files changed, 112 insertions(+), 8 deletions(-) diff --git a/src/codespy/agents/cost_tracker.py b/src/codespy/agents/cost_tracker.py index 1f283b0..44a4742 100644 --- a/src/codespy/agents/cost_tracker.py +++ b/src/codespy/agents/cost_tracker.py @@ -32,14 +32,22 @@ class SignatureStats: output_tokens: int = 0 input_cost: float = 0.0 output_cost: float = 0.0 + external_duration_seconds: float = 0.0 @property def duration_seconds(self) -> float: - """Get duration in seconds, or 0 if not completed.""" - if self.start_time is None: - return 0.0 - end = self.end_time if self.end_time is not None else time.time() - return end - self.start_time + """Get duration in seconds, or 0 if not completed. + + For signatures tracked via SignatureContext, this is wall-clock time + from start to end. For external calls (e.g., cerebral), this is the + sum of per-call work durations (not wall-clock), consistent with how + cost/tokens are summed. + """ + wall = 0.0 + if self.start_time is not None: + end = self.end_time if self.end_time is not None else time.time() + wall = end - self.start_time + return wall + self.external_duration_seconds def to_dict(self) -> dict: """Convert to dictionary for serialization.""" @@ -53,6 +61,7 @@ def to_dict(self) -> dict: "output_tokens": self.output_tokens, "input_cost": self.input_cost, "output_cost": self.output_cost, + "external_duration_seconds": self.external_duration_seconds, } @@ -131,11 +140,13 @@ def add_external_call( output_tokens: int = 0, input_cost: float = 0.0, output_cost: float = 0.0, + duration: float = 0.0, ) -> None: """Accumulate costs from external LLM calls (not tracked by DSPy). Creates a SignatureStats entry if missing, without touching - start_time/end_time (external calls have no duration tracking). + start_time/end_time. Duration is summed per-call work time for the + bucket (not wall-clock), consistent with how cost/tokens are summed. Args: name: Bucket name for the external call (e.g., "cerebral_retain") @@ -146,6 +157,7 @@ def add_external_call( output_tokens: Output/completion tokens used input_cost: Cost for input tokens output_cost: Cost for output tokens + duration: Duration in seconds for this call (default 0.0) """ with self._lock: if name not in self._signature_stats: @@ -158,6 +170,7 @@ def add_external_call( stats.output_tokens += output_tokens stats.input_cost += input_cost stats.output_cost += output_cost + stats.external_duration_seconds += duration @property def total_cost(self) -> float: @@ -209,6 +222,7 @@ def get_all_signature_stats(self) -> dict[str, SignatureStats]: output_tokens=v.output_tokens, input_cost=v.input_cost, output_cost=v.output_cost, + external_duration_seconds=v.external_duration_seconds, ) for k, v in self._signature_stats.items() } diff --git a/src/codespy/agents/memory/cerebral/cost.py b/src/codespy/agents/memory/cerebral/cost.py index 26caa7d..afffec1 100644 --- a/src/codespy/agents/memory/cerebral/cost.py +++ b/src/codespy/agents/memory/cerebral/cost.py @@ -9,6 +9,7 @@ import logging import threading +import time from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -45,6 +46,7 @@ def record_llm_call( # type: ignore[return] input_tokens: int = 0, output_tokens: int = 0, error: Exception | None = None, + duration: float = 0.0, **_: Any, ) -> None: """Record a completed LLM call. @@ -55,6 +57,7 @@ def record_llm_call( # type: ignore[return] input_tokens: Input/prompt token count output_tokens: Output/completion token count error: Exception if the call failed + duration: Duration of the call in seconds **_: Ignored extra kwargs for forward compatibility """ try: @@ -90,6 +93,7 @@ def record_llm_call( # type: ignore[return] output_tokens=output_tokens, input_cost=prompt_cost, output_cost=completion_cost, + duration=duration, ) except Exception as e: @@ -152,15 +156,17 @@ def __init__(self, real_litellm: Any, cost_recorder: "CerebralCostRecorder") -> async def aembedding(self, **kwargs: Any) -> Any: """Call litellm.aembedding and meter the usage.""" + start = time.perf_counter() response = await self._real.aembedding(**kwargs) - self._meter_embedding(response, kwargs.get("model", "")) + elapsed = time.perf_counter() - start + self._meter_embedding(response, kwargs.get("model", ""), elapsed) return response def __getattr__(self, name: str) -> Any: """Forward all other attributes to the real litellm.""" return getattr(self._real, name) - def _meter_embedding(self, response: Any, model: str) -> None: + def _meter_embedding(self, response: Any, model: str, duration: float = 0.0) -> None: """Extract usage from embedding response and record cost.""" try: # Try _hidden_params["response_cost"] first (litellm populates this) @@ -206,6 +212,7 @@ def _meter_embedding(self, response: Any, model: str) -> None: output_tokens=0, input_cost=cost or 0.0, output_cost=0.0, + duration=duration, ) except Exception as e: diff --git a/tests/test_cerebral_cost.py b/tests/test_cerebral_cost.py index 76a938a..766c74a 100644 --- a/tests/test_cerebral_cost.py +++ b/tests/test_cerebral_cost.py @@ -161,6 +161,24 @@ def test_record_llm_call_prices_using_litellm(self, mock_tracker, fresh_recorder stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) assert stats.cost == 0.03 # 0.01 + 0.02 + def test_record_llm_call_forwards_duration(self, mock_tracker, fresh_recorder): + """duration parameter is forwarded to add_external_call.""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.03, 0.02)): + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="retain_extract_facts", + input_tokens=100, + output_tokens=50, + duration=1.25, + ) + + stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + assert stats is not None + assert stats.external_duration_seconds == 1.25 + assert stats.duration_seconds == 1.25 + def test_record_llm_call_logs_warning_for_unpriced_model( self, mock_tracker, fresh_recorder, caplog ): @@ -228,6 +246,22 @@ async def test_aembedding_meters_usage(self, mock_litellm, mock_response, mock_t assert stats.tokens == 100 assert stats.cost == 0.01 + @pytest.mark.asyncio + async def test_aembedding_records_duration(self, mock_litellm, mock_response, mock_tracker): + """Embedding call records nonzero duration to cerebral_embeddings bucket.""" + mock_litellm.aembedding.return_value = asyncio.Future() + mock_litellm.aembedding.return_value.set_result(mock_response) + + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + cost_recorder = CerebralCostRecorder() + proxy = _LiteLLMProxy(mock_litellm, cost_recorder) + await proxy.aembedding(model="openai/text-embedding-3-small", input=["test"]) + + stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_EMBEDDINGS) + assert stats is not None + assert stats.external_duration_seconds >= 0.0 # Should have some duration (could be very small) + assert stats.duration_seconds == stats.external_duration_seconds + @pytest.mark.asyncio async def test_aembedding_returns_response_unchanged(self, mock_litellm, mock_response): """Embedding call returns the response unchanged.""" @@ -388,3 +422,11 @@ def test_composite_span_recorder_has_recorders_list(self): recorder = CompositeSpanRecorder() assert hasattr(recorder, "_recorders") assert isinstance(recorder._recorders, list) + + def test_llm_span_recorder_accepts_duration_param(self): + """LLMSpanRecorder.record_llm_call accepts duration parameter (upstream contract).""" + import inspect + from hindsight_api.tracing import LLMSpanRecorder + + sig = inspect.signature(LLMSpanRecorder.record_llm_call) + assert "duration" in sig.parameters, "record_llm_call must accept 'duration' parameter" diff --git a/tests/test_cost_tracker.py b/tests/test_cost_tracker.py index 5bafbd4..cb47175 100644 --- a/tests/test_cost_tracker.py +++ b/tests/test_cost_tracker.py @@ -37,6 +37,16 @@ def test_duration_seconds_uses_end_time_if_set(self): stats = SignatureStats(name="test", start_time=0.0, end_time=5.0) assert stats.duration_seconds == 5.0 + def test_duration_seconds_includes_external_duration(self): + """Duration should include external_duration_seconds.""" + stats = SignatureStats(name="test", start_time=0.0, end_time=5.0, external_duration_seconds=2.0) + assert stats.duration_seconds == 7.0 # 5.0 wall + 2.0 external + + def test_duration_seconds_external_only_when_no_start_time(self): + """Duration should be external_duration_seconds when start_time is None.""" + stats = SignatureStats(name="test", external_duration_seconds=3.0) + assert stats.duration_seconds == 3.0 + def test_to_dict_includes_all_fields(self): """to_dict should include all relevant fields.""" stats = SignatureStats( @@ -49,6 +59,7 @@ def test_to_dict_includes_all_fields(self): assert result["tokens"] == 1000 assert result["call_count"] == 5 assert result["duration_seconds"] == 5.0 + assert "external_duration_seconds" in result class TestAsNumber: @@ -351,6 +362,36 @@ def add_call(n): assert stats.input_cost == pytest.approx(8.0, rel=0.01) assert stats.output_cost == pytest.approx(2.0, rel=0.01) + def test_add_external_call_accumulates_duration(self): + """add_external_call with duration accumulates external_duration_seconds.""" + tracker = CostTracker() + tracker.add_external_call("cerebral_retain", 0.5, 100, duration=1.5) + tracker.add_external_call("cerebral_retain", 0.3, 50, duration=2.5) + + stats = tracker.get_signature_stats("cerebral_retain") + assert stats.external_duration_seconds == 4.0 # 1.5 + 2.5 + assert stats.duration_seconds == 4.0 # No start_time, so just external + assert stats.start_time is None # Should not touch start_time + assert stats.end_time is None # Should not touch end_time + + def test_add_external_call_duration_default_is_zero(self): + """add_external_call without duration defaults to 0.0.""" + tracker = CostTracker() + tracker.add_external_call("cerebral_retain", 0.5, 100) + + stats = tracker.get_signature_stats("cerebral_retain") + assert stats.external_duration_seconds == 0.0 + assert stats.duration_seconds == 0.0 + + def test_get_all_signature_stats_preserves_external_duration(self): + """get_all_signature_stats copy preserves external_duration_seconds.""" + tracker = CostTracker() + tracker.add_external_call("cerebral_retain", 0.5, 100, duration=2.0) + + all_stats = tracker.get_all_signature_stats() + assert all_stats["cerebral_retain"].external_duration_seconds == 2.0 + assert all_stats["cerebral_retain"].duration_seconds == 2.0 + class TestSignatureContext: """Tests for SignatureContext context manager.""" From 3e98236ab20b3f2710a3ce4a9bbbaf782e0088c0 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 25 Sep 2026 17:52:45 +0200 Subject: [PATCH 07/39] cost --- src/codespy/agents/memory/cerebral/cerebral.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index 5b5dbf9..a1912db 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -141,11 +141,16 @@ def _ensure_bank(self) -> None: self._bank_id, updates={ "retain_extraction_mode": "concise", - # Chunk size must accommodate the merged observations blob. - # Observations are bounded by max_context_memory_tokens (~65K chars), - # so 65536 chars ensures each episode's observations stay a single chunk. - # If max_context_memory_tokens is raised above ~65K, bump this too. - "retain_chunk_size": 65536, + # retain_chunk_size (chars) MUST be strictly less than + # retain_max_completion_tokens (default 64000): Hindsight's + # validate_retain_completion_token_budget compares the two values + # directly (chars vs tokens is NOT reconciled), and a bank-config + # update that violates it is rejected wholesale — silently reverting + # the bank to defaults (retain_chunk_size=3000). Do not raise this at + # or above 64000. The merged observations blob is bounded by + # max_context_memory_tokens (16384 tokens ≈ ~65K chars worst case), so + # at 12288 a large blob splits into several chunks (graceful, no error). + "retain_chunk_size": 12288, "retain_mission": ( "Retain code review observations, analysis results, and artifacts. " "Focus on patterns, architectural decisions, dependency relationships, " From f5479cfa325e029d2862a8bbbf103fc3e42065e0 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 25 Sep 2026 18:37:19 +0200 Subject: [PATCH 08/39] cost --- src/codespy/agents/review/scope/__init__.py | 15 +- src/codespy/agents/review/scope/agent.py | 185 ++++++++++---------- src/codespy/workflows/review/pipeline.py | 67 ++++--- tests/test_scope_resolver.py | 41 ++++- 4 files changed, 186 insertions(+), 122 deletions(-) diff --git a/src/codespy/agents/review/scope/__init__.py b/src/codespy/agents/review/scope/__init__.py index 04cab8e..b599070 100644 --- a/src/codespy/agents/review/scope/__init__.py +++ b/src/codespy/agents/review/scope/__init__.py @@ -1,2 +1,15 @@ -from codespy.agents.review.scope.agent import MANIFEST_FILES, MANIFEST_GLOBS, ScopeResolver, derive_sparse_paths +from codespy.agents.review.scope.agent import MANIFEST_FILES, MANIFEST_GLOBS, ScopeResolver, derive_sparse_paths, build_sparse_patterns, AI_DIRS, AI_FILES from codespy.agents.review.scope.models import PackageManifest, ScopeResult, ScopeType + +__all__ = [ + "MANIFEST_FILES", + "MANIFEST_GLOBS", + "AI_DIRS", + "AI_FILES", + "ScopeResolver", + "derive_sparse_paths", + "build_sparse_patterns", + "PackageManifest", + "ScopeResult", + "ScopeType", +] diff --git a/src/codespy/agents/review/scope/agent.py b/src/codespy/agents/review/scope/agent.py index 8aeba91..bbb53be 100644 --- a/src/codespy/agents/review/scope/agent.py +++ b/src/codespy/agents/review/scope/agent.py @@ -373,27 +373,55 @@ class ScopeRefinementSignature(dspy.Signature): ) -def derive_sparse_paths(changed_files: list[str]) -> list[str]: - """Derive minimal sparse checkout paths from changed files. +# AI instruction directories and files (used for sparse patterns) +AI_DIRS: list[str] = [".claude/", ".kilo/", ".agent/", ".ai/", ".cursor/", ".codex/"] +AI_FILES: list[str] = ["AGENTS.md", "CLAUDE.md", "SKILL.md"] + + +def build_sparse_patterns( + changed_files: list[str], + include_scope_roots: bool = True, + include_manifests: bool = True, + include_ai_files: bool = True, +) -> list[str]: + """Build anchored sparse checkout patterns from changed files. + + In non-cone sparse-checkout mode (gitignore-style semantics): + - Patterns with a leading/middle slash are anchored to repo root + - Patterns without a slash match at any depth (expensive in treeless clones) + - Patterns ending with slash match directories at any depth + + This function emits anchored patterns only: // + to limit materialization to ancestor dirs of changed files. Args: - changed_files: List of changed file paths + changed_files: List of changed file paths (relative to repo root) + include_scope_roots: Include scope subtree roots (e.g., /packages/auth/) + include_manifests: Include anchored manifest patterns + include_ai_files: Include anchored AI instruction dir/file patterns Returns: - List of sparse paths for git sparse-checkout + List of anchored sparse patterns for git sparse-checkout """ - scope_roots: set[str] = set() + patterns: set[str] = set() + ancestor_dirs: set[str] = set() + # Build scope roots from changed files (for subtree checkout) + scope_roots: set[str] = set() for filepath in changed_files: parts = filepath.split("/") if len(parts) <= 1: - continue # Root-level file, handled by "/*" below + continue # Root-level file + + # Collect all ancestor directories of this file + for depth in range(1, len(parts)): + ancestor_dirs.add("/".join(parts[:depth])) # Strategy A: Find scope indicator and take the next directory found_indicator = False for i, part in enumerate(parts[:-1]): # Skip filename if part.lower() in SCOPE_INDICATOR_DIRS and i + 1 < len(parts) - 1: - scope_root = "/".join(parts[: i + 2]) + "/" + scope_root = "/".join(parts[: i + 2]) scope_roots.add(scope_root) found_indicator = True break @@ -401,36 +429,65 @@ def derive_sparse_paths(changed_files: list[str]) -> list[str]: # Strategy B: No indicator found -- use depth-2 prefix if not found_indicator: depth = min(2, len(parts) - 1) - scope_roots.add("/".join(parts[:depth]) + "/") - - # Always include root-level files for root manifests - paths = sorted(scope_roots) - paths.append("/*") - - # Explicitly add root manifest files to ensure they are checked out - # in sparse/treeless clones (/* pattern doesn't always work reliably) - for manifest in MANIFEST_FILES: - paths.append(manifest) - for manifest_pattern in MANIFEST_GLOBS: - # For glob patterns like *.csproj, we need to add the pattern itself - paths.append(manifest_pattern) - - # Agent config directories — project instructions for ReAct agents - paths.extend( - [ - ".claude/", - ".kilo/", - ".agent/", - ".ai/", - ".cursor/", - ".codex/", - "AGENTS.md", - "CLAUDE.md", - "SKILL.md", - ] - ) + scope_roots.add("/".join(parts[:depth])) + + # Always include root in ancestor dirs + ancestor_dirs.add(".") + + # Scope roots (for subtree checkout) - anchored as // + if include_scope_roots: + for scope_root in scope_roots: + patterns.add(f"/{scope_root}/") + + # Root-level files + patterns.add("/*") + + # Manifest files - anchored to each ancestor dir to avoid repo-wide matching + if include_manifests: + for ancestor in ancestor_dirs: + for manifest in MANIFEST_FILES: + if ancestor == ".": + patterns.add(f"/{manifest}") + else: + patterns.add(f"/{ancestor}/{manifest}") + for ancestor in ancestor_dirs: + for manifest_pattern in MANIFEST_GLOBS: + if ancestor == ".": + patterns.add(f"/{manifest_pattern}") + else: + patterns.add(f"/{ancestor}/{manifest_pattern}") + + # AI instruction directories and files - anchored to each ancestor dir + if include_ai_files: + for ancestor in ancestor_dirs: + for ai_dir in AI_DIRS: + if ancestor == ".": + patterns.add(f"/{ai_dir}") + else: + patterns.add(f"/{ancestor}/{ai_dir}") + for ancestor in ancestor_dirs: + for ai_file in AI_FILES: + if ancestor == ".": + patterns.add(f"/{ai_file}") + else: + patterns.add(f"/{ancestor}/{ai_file}") + + return sorted(patterns) + + +def derive_sparse_paths(changed_files: list[str]) -> list[str]: + """Derive minimal sparse checkout paths from changed files. - return paths + Deprecated: Use build_sparse_patterns() for new code. + This function is kept for backward compatibility. + + Args: + changed_files: List of changed file paths + + Returns: + List of sparse paths for git sparse-checkout + """ + return build_sparse_patterns(changed_files) class ScopeResolver(dspy.Module): @@ -502,8 +559,6 @@ async def _ensure_repo(self, pr: PullRequest, repo_path: Path, is_local: bool) - repo.git.update_environment(GIT_TERMINAL_PROMPT="0") repo.git.fetch("origin", pr.head_sha, "--depth", "1") repo.git.checkout(pr.head_sha) - # Ensure manifests at root + parent dirs - await self._ensure_manifests(repo_path, changed_file_paths) return changed_file_paths = [f.filename for f in pr.changed_files] @@ -530,60 +585,6 @@ async def _ensure_repo(self, pr: PullRequest, repo_path: Path, is_local: bool) - ) logger.info("Clone complete: %s", repo_path) - # Ensure manifest files at root and parent directories are checked out - await self._ensure_manifests(repo_path, changed_file_paths) - - async def _ensure_manifests(self, repo_path: Path, changed_files: list[str]) -> None: - """Ensure manifest files at root and parent directories are checked out. - - Sparse/treeless clones may not materialize manifests at ancestor directories. - This explicitly checks out known manifest files at: - - Repository root - - Every ancestor directory of every changed file path - - Args: - repo_path: Path to the repository root - changed_files: List of changed file paths - """ - from git import Repo - - # Collect all ancestor directories of changed files - parent_dirs: set[str] = set() - for filepath in changed_files: - parts = filepath.split("/") - for depth in range(1, len(parts)): # skip filename, collect dirs - parent_dirs.add("/".join(parts[:depth])) - - # Build list of manifest paths to check - manifest_paths: list[str] = [] - - # Root manifests - for manifest in MANIFEST_FILES: - manifest_paths.append(manifest) - - # Parent manifests - for parent in parent_dirs: - for manifest in MANIFEST_FILES: - manifest_paths.append(f"{parent}/{manifest}") - - # Checkout missing manifests - try: - from git import Repo - from git.exc import GitCommandError - - repo = Repo(repo_path) - for path in manifest_paths: - if not (repo_path / path).exists(): - try: - repo.git.checkout("HEAD", "--", path) - logger.debug("Checked out manifest: %s", path) - except GitCommandError: - pass # File doesn't exist in repo — expected - except Exception as e: - logger.warning("Unexpected error checking out manifest %s: %s", path, e) - except Exception as e: - logger.warning("Failed to ensure manifests: %s", e) - def _resolve( self, repo_path: Path, changed_files: list[ChangedFile], repo: str ) -> tuple[list[ScopeResult], list[ChangedFile]]: diff --git a/src/codespy/workflows/review/pipeline.py b/src/codespy/workflows/review/pipeline.py index 442a85b..b0acc17 100644 --- a/src/codespy/workflows/review/pipeline.py +++ b/src/codespy/workflows/review/pipeline.py @@ -21,7 +21,7 @@ ) from codespy.agents.memory.hippocampus.episode import join_episode_saves from codespy.agents.review.helpers import build_patches -from codespy.agents.review.scope import MANIFEST_FILES, MANIFEST_GLOBS +from codespy.agents.review.scope import MANIFEST_FILES, MANIFEST_GLOBS, build_sparse_patterns from codespy.config import Settings, get_settings from codespy.config_memory import verify_memory_access from codespy.tools.git import ChangedFile, GitClient, PullRequest, get_client @@ -210,9 +210,9 @@ def forward(self, config: ReviewConfig) -> ReviewResult: if manifest.dependencies_changed: logger.info(" Dependencies changed: Yes") # Expand sparse checkout to cover full scope subtrees - if not is_local: - self._expand_sparse_for_scopes(scopes, repo_path) changed_file_paths = [f.filename for f in pr.changed_files] + if not is_local: + self._expand_sparse_for_scopes(scopes, repo_path, changed_file_paths) patches = build_patches(pr.changed_files) if self.settings.compact_patches: logger.info("Compacting patches to function boundaries...") @@ -297,11 +297,20 @@ def _collect_signature_stats(self) -> list[SignatureStatsResult]: return stats_list - def _expand_sparse_for_scopes(self, scopes: list, repo_path: Path) -> None: + def _expand_sparse_for_scopes(self, scopes: list, repo_path: Path, changed_files: list[str] | None = None) -> None: """Expand sparse checkout to cover full subtree of each identified scope. Called after scope identification, before compact_patches and review modules, to ensure read_file and patch compaction have full scope context available. + + Uses the same sparse pattern builder as derive_sparse_paths to ensure + manifests and AI instruction files are consistently anchored to ancestor + dirs of changed files, avoiding repo-wide pattern matching. + + Args: + scopes: List of identified scope results + repo_path: Path to the repository root + changed_files: Optional list of changed file paths (if None, extracts from scopes) """ from git import Repo from git.exc import GitCommandError @@ -310,26 +319,41 @@ def _expand_sparse_for_scopes(self, scopes: list, repo_path: Path) -> None: if not git_dir.exists(): return - # Build scope-aware sparse paths - sparse_paths: set[str] = set() + # Check for root scope — disable sparse checkout entirely + has_root_scope = any(s.subroot == "." for s in scopes) + if has_root_scope: + try: + repo = Repo(repo_path) + repo.git.update_environment(GIT_TERMINAL_PROMPT="0") + # Disable sparse checkout to get full repo + repo.git.config("core.sparseCheckout", "false") + # Re-checkout to materialize everything + if repo.head.is_valid(): + repo.git.checkout() + logger.info("Root scope: disabled sparse checkout, full repo checked out") + return + except (GitCommandError, ValueError, TypeError) as e: + logger.warning("Failed to disable sparse checkout for root scope: %s", e) + return + + # Build sparse patterns using the canonical builder + if changed_files is None: + changed_files = [] + for scope in scopes: + changed_files.extend(f.filename for f in scope.changed_files) + + sparse_paths = build_sparse_patterns(changed_files) + + # Add scope subtrees (full directories) for expanded scope context for scope in scopes: - if scope.subroot == ".": - # Root scope — need everything; disable sparse checkout effectively - sparse_paths.add("/*") - sparse_paths.add("*/") - break - else: - sparse_paths.add(scope.subroot.rstrip("/") + "/") - - # Always include root-level files and manifests - sparse_paths.add("/*") - for manifest in MANIFEST_FILES: - sparse_paths.add(manifest) - for pattern in MANIFEST_GLOBS: - sparse_paths.add(pattern) + if scope.subroot != ".": + sparse_paths.append(f"/{scope.subroot.rstrip('/')}/") + + # Deduplicate and sort + sparse_paths = sorted(set(sparse_paths)) sparse_file = git_dir / "info" / "sparse-checkout" - sparse_file.write_text("\n".join(sorted(sparse_paths)) + "\n") + sparse_file.write_text("\n".join(sparse_paths) + "\n") # Re-checkout to materialize newly included paths try: @@ -342,5 +366,6 @@ def _expand_sparse_for_scopes(self, scopes: list, repo_path: Path) -> None: ) return repo.git.checkout() + logger.debug("Sparse checkout expanded for %d scope(s)", len(scopes)) except (GitCommandError, ValueError, TypeError) as e: logger.warning("Sparse checkout expansion failed (non-fatal): %s", e) diff --git a/tests/test_scope_resolver.py b/tests/test_scope_resolver.py index 8c029a6..80543aa 100644 --- a/tests/test_scope_resolver.py +++ b/tests/test_scope_resolver.py @@ -20,7 +20,8 @@ def test_single_scope_indicator(self): """Test deriving sparse paths with single scope indicator.""" changed_files = ["packages/auth/src/index.ts"] paths = derive_sparse_paths(changed_files) - assert "packages/auth/" in paths + # Anchored paths start with / + assert "/packages/auth/" in paths assert "/*" in paths def test_multiple_scope_indicators(self): @@ -30,15 +31,17 @@ def test_multiple_scope_indicators(self): "packages/auth/src/index.ts", ] paths = derive_sparse_paths(changed_files) - assert "mono/svc/api/" in paths - assert "packages/auth/" in paths + # Anchored paths start with / + assert "/mono/svc/api/" in paths + assert "/packages/auth/" in paths assert "/*" in paths def test_no_scope_indicator_uses_depth_fallback(self): """Test fallback to depth-2 when no indicator found.""" changed_files = ["backend/api/handler.go"] paths = derive_sparse_paths(changed_files) - assert "backend/api/" in paths + # Anchored paths start with / + assert "/backend/api/" in paths def test_root_level_file(self): """Test root-level files don't add extra paths.""" @@ -55,8 +58,8 @@ def test_deduplication(self): "packages/auth/tests/auth.test.ts", ] paths = derive_sparse_paths(changed_files) - # Should only have one packages/auth/ - assert paths.count("packages/auth/") == 1 + # Should only have one /packages/auth/ (anchored) + assert paths.count("/packages/auth/") == 1 class TestScopeResolver: @@ -204,8 +207,30 @@ def test_internal_dir_not_scope_indicator(self): """internal/ is not a scope indicator for sparse paths.""" changed_files = ["backend/internal/cache/redis.go"] paths = derive_sparse_paths(changed_files) - # Should use depth-2 fallback, not scope indicator - assert "backend/internal/" in paths # depth-2 prefix + # Should use depth-2 fallback, not scope indicator (anchored) + assert "/backend/internal/" in paths # depth-2 prefix + + def test_anchored_manifest_patterns(self): + """Manifest patterns are anchored to ancestor dirs, not repo-wide.""" + changed_files = ["packages/auth/src/index.ts"] + paths = derive_sparse_paths(changed_files) + # Should have anchored manifest patterns, not bare manifest names + assert "/go.mod" in paths # root manifest + assert "/packages/go.mod" in paths # ancestor manifest + assert "/packages/auth/go.mod" in paths # scope manifest + # Should NOT have bare repo-wide patterns + assert "go.mod" not in paths or paths.count("go.mod") == 0 + + def test_anchored_ai_file_patterns(self): + """AI instruction files are anchored to ancestor dirs.""" + changed_files = ["packages/auth/src/index.ts"] + paths = derive_sparse_paths(changed_files) + # Should have anchored AI patterns + assert "/.kilo/" in paths # root AI dir + assert "/packages/.kilo/" in paths # ancestor AI dir + assert "/packages/auth/.kilo/" in paths # scope AI dir + assert "/AGENTS.md" in paths # root AI file + assert "/packages/AGENTS.md" in paths # ancestor AI file def test_indicator_suppressed_under_manifest(self): """Indicator scopes not created when parent manifest covers file.""" From cab4cb61905a7e9ba8b469a15eab52e1a6ac1640 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 25 Sep 2026 18:52:58 +0200 Subject: [PATCH 09/39] cost --- src/codespy/agents/memory/cerebral/cerebral.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index a1912db..af4f7db 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -12,6 +12,19 @@ import threading from typing import TYPE_CHECKING +# Hindsight defaults embeddings_provider / reranker_provider to "local", which +# needs sentence-transformers and emits a misleading startup warning +# (hindsight_api/config.py _validate). That config is built and cached at IMPORT +# time — the `from hindsight_api import MemoryEngine` below pulls in +# engine.llm_wrapper, whose module-level _get_raw_config() calls +# HindsightConfig.from_env(). So these env vars MUST be set before that import +# line, not in Cerebral.__init__ (too late). We inject our own LiteLLM-SDK +# embeddings and an RRF-passthrough cross-encoder in Cerebral.__init__, so the +# env-driven providers are never used; align them to non-local values so the +# warning does not fire. setdefault keeps operator overrides intact. +os.environ.setdefault("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "litellm-sdk") +os.environ.setdefault("HINDSIGHT_API_RERANKER_PROVIDER", "none") + from hindsight_api import MemoryEngine from hindsight_api.engine.cross_encoder import RRFPassthroughCrossEncoder from hindsight_api.engine.embeddings import LiteLLMSDKEmbeddings From 0e31a825874b71979ef626e92fc3dba6a3f64a68 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 25 Sep 2026 19:40:38 +0200 Subject: [PATCH 10/39] cost --- src/codespy/config_memory.py | 6 ++++-- tests/test_config_memory.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 14c58f1..404f020 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -80,7 +80,9 @@ class PostgresConfig(BaseModel): user: str | None = None # MEMORY_POSTGRES_USER password: str | None = None # MEMORY_POSTGRES_PASSWORD database: str = "codespy" # MEMORY_POSTGRES_DATABASE - schema: str | None = "episodic" # MEMORY_POSTGRES_SCHEMA (search_path per memory type; None = public) + schema_name: str | None = Field( + default="episodic", alias="schema" + ) # MEMORY_POSTGRES_SCHEMA (search_path per memory type; None = public) def build_uri(self) -> str | None: """Build a psycopg connection URI. Returns None when host is unset. @@ -338,7 +340,7 @@ def get_episode_store(settings: Settings) -> EpisodeStore | None: mem = settings.memory bank_id = mem.bank_id or _generate_bank_id() - schema = mem.postgres.schema # "episodic" by default + schema = mem.postgres.schema_name # "episodic" by default # Try external PostgreSQL first uri = mem.postgres.build_uri() diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index 06d42aa..63398eb 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -206,7 +206,7 @@ def test_returns_none_when_no_config(self): settings = MagicMock() settings.memory.postgres = MagicMock() settings.memory.postgres.build_uri.return_value = None - settings.memory.postgres.schema = "episodic" + settings.memory.postgres.schema_name = "episodic" settings.memory.pg0 = MagicMock() settings.memory.pg0.name = "codespy" settings.memory.pg0.port = None @@ -229,7 +229,7 @@ def test_uses_postgres_uri_when_set(self): settings = MagicMock() settings.memory.postgres = MagicMock() settings.memory.postgres.build_uri.return_value = "postgresql://u:p@host:5432/codespy" - settings.memory.postgres.schema = "episodic" + settings.memory.postgres.schema_name = "episodic" settings.memory.bank_id = "test-bank" mock_store = MagicMock() From d041f555a1c62ea47e7ff04d01b7d60f9aec4083 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 25 Sep 2026 21:54:38 +0200 Subject: [PATCH 11/39] cost --- .../agents/memory/cerebral/cerebral.py | 31 +- .../agents/memory/cerebral/routines.py | 230 +++++++++++++ tests/test_cerebral.py | 7 +- tests/test_cerebral_routines.py | 324 ++++++++++++++++++ 4 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 src/codespy/agents/memory/cerebral/routines.py create mode 100644 tests/test_cerebral_routines.py diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index af4f7db..97a8e07 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -12,6 +12,9 @@ import threading from typing import TYPE_CHECKING +# Module constants (must be defined before the Hindsight import) +HINDSIGHT_SCHEMA_DEFAULT = "semantic" + # Hindsight defaults embeddings_provider / reranker_provider to "local", which # needs sentence-transformers and emits a misleading startup warning # (hindsight_api/config.py _validate). That config is built and cached at IMPORT @@ -25,6 +28,15 @@ os.environ.setdefault("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "litellm-sdk") os.environ.setdefault("HINDSIGHT_API_RERANKER_PROVIDER", "none") +# Upstream #2638: The maintenance routines (mental_models_with_cron, etc.) are +# installed by migrations into `database_schema` and called by name from that +# schema via fq_routine(). Cerebral sets its schema only via the tenant +# extension, so without this env var, `database_schema` defaults to "public". +# With database_schema="public" and migrations running with target_schema="semantic", +# the routines are never created. Setting this makes the routines install into +# "semantic" and be called from "semantic", matching the tenant schema. +os.environ.setdefault("HINDSIGHT_API_DATABASE_SCHEMA", HINDSIGHT_SCHEMA_DEFAULT) + from hindsight_api import MemoryEngine from hindsight_api.engine.cross_encoder import RRFPassthroughCrossEncoder from hindsight_api.engine.embeddings import LiteLLMSDKEmbeddings @@ -36,13 +48,25 @@ MeteredLiteLLMSDKEmbeddings, register_cerebral_cost_recorder, ) +from codespy.agents.memory.cerebral.routines import ensure_maintenance_routines if TYPE_CHECKING: from codespy.agents.memory.hippocampus.episode import Episode logger = logging.getLogger(__name__) -HINDSIGHT_SCHEMA = "semantic" +HINDSIGHT_SCHEMA = HINDSIGHT_SCHEMA_DEFAULT + +# Warn if the operator has overridden HINDSIGHT_API_DATABASE_SCHEMA to a different +# value than our schema — the routines would then be missing again (upstream #2638). +if os.environ.get("HINDSIGHT_API_DATABASE_SCHEMA") != HINDSIGHT_SCHEMA: + logger.warning( + "HINDSIGHT_API_DATABASE_SCHEMA is set to %r, which differs from the " + "expected schema %r. Maintenance routines may not be found. " + "See upstream #2638 for details.", + os.environ.get("HINDSIGHT_API_DATABASE_SCHEMA"), + HINDSIGHT_SCHEMA, + ) class Cerebral: @@ -111,6 +135,11 @@ def __init__( ) self._run_async(self._engine.initialize()) + # One-time repair: install maintenance routines if missing. + # This is idempotent and only needed for databases that were migrated + # before HINDSIGHT_API_DATABASE_SCHEMA was set to "semantic" (upstream #2638). + self._run_async(ensure_maintenance_routines(self._engine, HINDSIGHT_SCHEMA)) + self._bank_id = bank_id self._bank_ensured = False diff --git a/src/codespy/agents/memory/cerebral/routines.py b/src/codespy/agents/memory/cerebral/routines.py new file mode 100644 index 0000000..569314e --- /dev/null +++ b/src/codespy/agents/memory/cerebral/routines.py @@ -0,0 +1,230 @@ +"""Maintenance routine repair for Hindsight semantic memory. + +Upstream #2638: the maintenance routines (banks_needing_consolidation, +mental_models_with_cron, schemas_with_expired_rows, schemas_with_expired_operations) +install into and are called from `database_schema`. Cerebral sets its schema only +on the tenant extension, so `HINDSIGHT_API_DATABASE_SCHEMA` defaults to `public`, +but migrations run with `target_schema="semantic"`. With `database_schema=public` +and `target_schema=semantic`, the install migrations skip creating the routines, +and the maintenance loop warns that `public.() does not exist`. + +This module provides a one-time idempotent repair for existing databases. +Fresh databases get the routines via the normal migration path once the env +var is set (see cerebral.py). +""" + +from __future__ import annotations + +import glob +import importlib.util +import logging +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from hindsight_api import MemoryEngine + +logger = logging.getLogger(__name__) + +# The four maintenance routines that must exist in the configured schema +ROUTINE_NAMES = [ + "banks_needing_consolidation", + "schemas_with_expired_rows", + "mental_models_with_cron", + "schemas_with_expired_operations", +] + +# Upstream migration that defines the current routine bodies +REVISION_ID = "c8b4e2a71f95" + + +def _get_migration_path() -> Path | None: + """Find the migration file by revision ID in the hindsight_api package.""" + try: + import hindsight_api + except ImportError: + return None + + pkg_dir = Path(hindsight_api.__file__).parent + versions_dir = pkg_dir / "alembic" / "versions" + if not versions_dir.exists(): + return None + + pattern = str(versions_dir / f"{REVISION_ID}_*.py") + matches = glob.glob(pattern) + if len(matches) == 1: + return Path(matches[0]) + return None + + +def collect_routine_sql(schema: str) -> dict[str, str]: + """Extract SQL for all four maintenance routines from the upstream migration. + + Returns a mapping from routine name -> CREATE OR REPLACE FUNCTION SQL. + The SQL is qualified with the given schema (e.g., "semantic".routine_name). + + Raises RuntimeError if the migration file is missing or if the configured + schema does not match the requested schema (meaning HINDSIGHT_API_DATABASE_SCHEMA + was overridden to a different value). + """ + import hindsight_api + from hindsight_api.alembic._owned import externally_owned + + migration_path = _get_migration_path() + if migration_path is None: + raise RuntimeError( + f"Migration {REVISION_ID} not found in hindsight_api. " + "The repair cannot proceed." + ) + + # Load the migration module without registering in sys.modules permanently + module_name = f"_codespy_hs_{REVISION_ID}" + spec = importlib.util.spec_from_file_location(module_name, migration_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Failed to load spec for {migration_path}") + + module = importlib.util.module_from_spec(spec) + # Temporarily register to allow the loader to work + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + finally: + del sys.modules[module_name] + + # Prepare the collector that intercepts execute_unless_owned calls + captured: dict[str, str] = {} + + def collector(name: str, sql: str) -> None: + # Skip if externally owned (operator is managing this routine) + if externally_owned(name): + logger.info( + "Skipping routine %r: named in HINDSIGHT_API_EXTERNALLY_OWNED_ROUTINES", + name, + ) + return + captured[name] = sql + + # Mock the alembic context so _pg_upgrade can run without an active env + # The migration only uses context.config.get_main_option("target_schema") + module.context = SimpleNamespace( + config=SimpleNamespace( + get_main_option=lambda k: schema if k == "target_schema" else None + ) + ) + # Replace execute_unless_owned with our collector + module.execute_unless_owned = collector + + # Run the PostgreSQL upgrade function directly (not upgrade(), which needs op.get_bind) + module._pg_upgrade() + + # Validate results + if not captured: + # _pg_upgrade returned early because configured schema != target_schema + raise RuntimeError( + f"Migration {REVISION_ID} produced no SQL: configured database_schema " + f"does not match requested schema {schema!r}. " + "Is HINDSIGHT_API_DATABASE_SCHEMA set to a different value?" + ) + + # Verify all expected routines are present (unless externally owned) + missing = set(ROUTINE_NAMES) - set(captured.keys()) + if missing: + # Some routines may be externally owned, that's fine + for name in missing: + if externally_owned(name): + continue + raise RuntimeError( + f"Migration {REVISION_ID} did not produce SQL for routine {name!r}" + ) + + # Verify SQL contains the expected schema qualification + for name, sql in captured.items(): + expected = f'"{schema}".{name}' + if expected not in sql: + raise RuntimeError( + f"Routine {name!r} SQL does not contain expected qualification {expected!r}" + ) + + return captured + + +async def ensure_maintenance_routines(engine: MemoryEngine, schema: str) -> list[str]: + """Ensure all four maintenance routines exist in the given schema. + + Returns a list of routine names that were installed (may be empty if all + already exist). This is idempotent: running twice is a no-op on the second + run (the presence check returns all four). + + On any failure (migration file missing, permissions error, etc.), logs a + warning and returns an empty list. Cerebral stays usable; the maintenance + warnings will continue as the visible symptom. + """ + from hindsight_api.engine.db_utils import acquire_with_retry + + try: + sql_map = collect_routine_sql(schema) + except RuntimeError as exc: + logger.warning( + "Failed to collect maintenance routine SQL (revision %s): %s", + REVISION_ID, + exc, + exc_info=True, + ) + return [] + + # Check which routines are already present + try: + async with acquire_with_retry(engine._backend, max_retries=1) as conn: + rows = await conn.fetch( + """ + SELECT p.proname + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 + AND p.proname = ANY($2::text[]) + """, + schema, + ROUTINE_NAMES, + ) + present = {row["proname"] for row in rows} + except Exception as exc: + logger.warning( + "Failed to check existing routines: %s", + exc, + exc_info=True, + ) + return [] + + missing = [name for name in ROUTINE_NAMES if name not in present and name in sql_map] + if not missing: + logger.debug("All maintenance routines already present in schema %r", schema) + return [] + + # Install missing routines in one transaction + installed: list[str] = [] + try: + async with acquire_with_retry(engine._backend, max_retries=1) as conn: + async with conn.transaction(): + for name in missing: + sql = sql_map[name] + await conn.execute(sql) + installed.append(name) + except Exception as exc: + logger.warning( + "Failed to install maintenance routines %s: %s", + missing, + exc, + exc_info=True, + ) + # Concurrent installs can fail with "tuple concurrently updated"; + # next boot's presence check will succeed if another process won. + return [] + + logger.info( + "Installed maintenance routines in schema %r: %s", + schema, + installed, + ) + return installed diff --git a/tests/test_cerebral.py b/tests/test_cerebral.py index 510b77a..80a4140 100644 --- a/tests/test_cerebral.py +++ b/tests/test_cerebral.py @@ -142,14 +142,15 @@ def test_init_uses_metered_embeddings(self, mock_memory_engine_class, mock_litel embeddings = mock_memory_engine_class.call_args.kwargs["embeddings"] assert isinstance(embeddings, MeteredLiteLLMSDKEmbeddings) - def test_init_calls_initialize(self, mock_memory_engine_class, mock_engine, mock_litellm): - """Test that initialize() is called on engine during construction.""" + def test_init_calls_initialize_and_routine_repair(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that initialize() and routine repair are called during construction.""" with patch.object(Cerebral, "_run_async") as mock_run_async: Cerebral( database_url="postgresql://localhost:5432/test", llm_provider="litellm", ) - mock_run_async.assert_called_once() + # Now called twice: once for initialize(), once for ensure_maintenance_routines() + assert mock_run_async.call_count == 2 def test_init_handles_none_base_url(self, mock_memory_engine_class, mock_litellm): """Test that None base_url is handled correctly.""" diff --git a/tests/test_cerebral_routines.py b/tests/test_cerebral_routines.py new file mode 100644 index 0000000..53b4de5 --- /dev/null +++ b/tests/test_cerebral_routines.py @@ -0,0 +1,324 @@ +"""Unit tests for cerebral.routines module. + +These tests verify that the maintenance routine repair works correctly +without needing a live database. The tests mock the migration file and +database connection where necessary. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + + +class TestCollectRoutineSql: + """Tests for collect_routine_sql function.""" + + @pytest.fixture(autouse=True) + def reset_env(self, monkeypatch): + """Ensure HINDSIGHT_API_DATABASE_SCHEMA matches the test schema.""" + # Import the module fresh for each test + monkeypatch.setenv("HINDSIGHT_API_DATABASE_SCHEMA", "semantic") + yield + + def test_returns_all_four_routines(self): + """Test that collect_routine_sql returns all four expected routines.""" + from codespy.agents.memory.cerebral import routines as routines_module + + result = routines_module.collect_routine_sql("semantic") + + expected_routines = { + "banks_needing_consolidation", + "schemas_with_expired_rows", + "mental_models_with_cron", + "schemas_with_expired_operations", + } + + assert set(result.keys()) == expected_routines + + def test_sql_contains_schema_qualification(self): + """Test that each SQL statement contains the schema qualification.""" + from codespy.agents.memory.cerebral import routines as routines_module + + result = routines_module.collect_routine_sql("semantic") + + for name, sql in result.items(): + expected = f'"semantic".{name}' + assert expected in sql, f"SQL for {name} missing schema qualification" + + def test_sql_contains_create_or_replace(self): + """Test that each SQL statement contains CREATE OR REPLACE.""" + from codespy.agents.memory.cerebral import routines as routines_module + + result = routines_module.collect_routine_sql("semantic") + + for name, sql in result.items(): + assert "CREATE OR REPLACE FUNCTION" in sql, f"SQL for {name} missing CREATE OR REPLACE" + + def test_no_public_in_sql(self): + """Test that SQL does not contain 'public' when semantic schema is requested.""" + from codespy.agents.memory.cerebral import routines as routines_module + + result = routines_module.collect_routine_sql("semantic") + + for name, sql in result.items(): + # Should not have "public" qualified in the function definition + assert '"public".' not in sql, f"SQL for {name} incorrectly contains 'public'" + + +class TestExternallyOwnedRoutines: + """Tests for handling of externally owned routines.""" + + def test_externally_owned_routine_skipped(self, monkeypatch): + """Test that externally owned routines are skipped.""" + monkeypatch.setenv("HINDSIGHT_API_DATABASE_SCHEMA", "semantic") + monkeypatch.setenv( + "HINDSIGHT_API_EXTERNALLY_OWNED_ROUTINES", + "mental_models_with_cron" + ) + + from codespy.agents.memory.cerebral import routines as routines_module + + result = routines_module.collect_routine_sql("semantic") + + # mental_models_with_cron should be missing + assert "mental_models_with_cron" not in result + # The others should be present + assert "banks_needing_consolidation" in result + assert "schemas_with_expired_rows" in result + assert "schemas_with_expired_operations" in result + + def test_multiple_externally_owned_routines(self, monkeypatch): + """Test that multiple externally owned routines are all skipped.""" + monkeypatch.setenv("HINDSIGHT_API_DATABASE_SCHEMA", "semantic") + monkeypatch.setenv( + "HINDSIGHT_API_EXTERNALLY_OWNED_ROUTINES", + "mental_models_with_cron,banks_needing_consolidation" + ) + + from codespy.agents.memory.cerebral import routines as routines_module + + result = routines_module.collect_routine_sql("semantic") + + assert "mental_models_with_cron" not in result + assert "banks_needing_consolidation" not in result + assert "schemas_with_expired_rows" in result + assert "schemas_with_expired_operations" in result + + def test_whitespace_handling_in_env_var(self, monkeypatch): + """Test that whitespace around routine names is handled correctly.""" + monkeypatch.setenv("HINDSIGHT_API_DATABASE_SCHEMA", "semantic") + monkeypatch.setenv( + "HINDSIGHT_API_EXTERNALLY_OWNED_ROUTINES", + " mental_models_with_cron , banks_needing_consolidation " + ) + + from codespy.agents.memory.cerebral import routines as routines_module + + result = routines_module.collect_routine_sql("semantic") + + assert "mental_models_with_cron" not in result + assert "banks_needing_consolidation" not in result + + +class TestCollectRoutineSqlErrors: + """Tests for error handling in collect_routine_sql.""" + + def test_migration_file_not_found(self): + """Test that RuntimeError is raised when migration file is missing.""" + from codespy.agents.memory.cerebral import routines as routines_module + + # Patch _get_migration_path to return None + with patch.object(routines_module, "_get_migration_path", return_value=None): + with pytest.raises(RuntimeError) as exc_info: + routines_module.collect_routine_sql("semantic") + + assert "c8b4e2a71f95" in str(exc_info.value) + assert "not found" in str(exc_info.value) + + +class TestEnsureMaintenanceRoutines: + """Tests for ensure_maintenance_routines function.""" + + @pytest.fixture + def mock_engine(self): + """Create a mock MemoryEngine with _backend.""" + engine = MagicMock() + engine._backend = MagicMock() + return engine + + @pytest.fixture + def mock_sql_map(self): + """Create a mock SQL map with all four routines.""" + return { + "banks_needing_consolidation": "CREATE OR REPLACE FUNCTION ...", + "schemas_with_expired_rows": "CREATE OR REPLACE FUNCTION ...", + "mental_models_with_cron": "CREATE OR REPLACE FUNCTION ...", + "schemas_with_expired_operations": "CREATE OR REPLACE FUNCTION ...", + } + + @pytest.mark.asyncio + async def test_all_present_returns_empty_list(self, mock_engine, mock_sql_map): + """Test that when all routines are present, returns empty list.""" + from codespy.agents.memory.cerebral import routines as routines_module + + # Create a proper async mock context manager + async def mock_fetch(*args, **kwargs): + return [ + {"proname": "banks_needing_consolidation"}, + {"proname": "schemas_with_expired_rows"}, + {"proname": "mental_models_with_cron"}, + {"proname": "schemas_with_expired_operations"}, + ] + + mock_conn = MagicMock() + mock_conn.fetch = AsyncMock(side_effect=mock_fetch) + + # Mock acquire_with_retry to return mock_conn via async context manager + @MagicMock + async def mock_acquire_cm(*args, **kwargs): + class CtxManager: + async def __aenter__(self): + return mock_conn + async def __aexit__(self, *args): + return None + return CtxManager() + + # Create a proper async context manager + class AsyncContextManager: + async def __aenter__(self): + return mock_conn + async def __aexit__(self, *args): + return None + + with patch.object(routines_module, "collect_routine_sql", return_value=mock_sql_map): + with patch("hindsight_api.engine.db_utils.acquire_with_retry", return_value=AsyncContextManager()): + result = await routines_module.ensure_maintenance_routines( + mock_engine, "semantic" + ) + + assert result == [] + + @pytest.mark.asyncio + async def test_missing_routines_get_installed(self, mock_engine, mock_sql_map): + """Test that missing routines are installed.""" + from codespy.agents.memory.cerebral import routines as routines_module + + # Track which SQL statements were executed + executed_sql = [] + + async def mock_fetch(*args, **kwargs): + # Only two routines present + return [ + {"proname": "banks_needing_consolidation"}, + {"proname": "schemas_with_expired_rows"}, + ] + + async def mock_execute(sql): + executed_sql.append(sql) + + mock_conn = MagicMock() + mock_conn.fetch = AsyncMock(side_effect=mock_fetch) + mock_conn.execute = AsyncMock(side_effect=mock_execute) + + # Create a proper async context manager + class AsyncContextManager: + async def __aenter__(self): + return mock_conn + async def __aexit__(self, *args): + return None + + class MockTransaction: + async def __aenter__(self): + return None + async def __aexit__(self, *args): + return None + + mock_conn.transaction = MagicMock(return_value=MockTransaction()) + + with patch.object(routines_module, "collect_routine_sql", return_value=mock_sql_map): + with patch("hindsight_api.engine.db_utils.acquire_with_retry", return_value=AsyncContextManager()): + result = await routines_module.ensure_maintenance_routines( + mock_engine, "semantic" + ) + + # Should have installed the two missing routines + assert len(result) == 2 + assert "mental_models_with_cron" in result + assert "schemas_with_expired_operations" in result + + @pytest.mark.asyncio + async def test_collector_failure_returns_empty_list(self, mock_engine): + """Test that if collect_routine_sql fails, we return empty list and log warning.""" + from codespy.agents.memory.cerebral import routines as routines_module + + with patch.object( + routines_module, "collect_routine_sql", side_effect=RuntimeError("Migration not found") + ): + result = await routines_module.ensure_maintenance_routines(mock_engine, "semantic") + assert result == [] + + @pytest.mark.asyncio + async def test_db_check_failure_returns_empty_list(self, mock_engine, mock_sql_map): + """Test that if DB check fails, we return empty list and log warning.""" + from codespy.agents.memory.cerebral import routines as routines_module + + # Create a proper async context manager that raises on enter + class AsyncContextManager: + async def __aenter__(self): + raise Exception("Connection failed") + async def __aexit__(self, *args): + return None + + with patch.object(routines_module, "collect_routine_sql", return_value=mock_sql_map): + with patch("hindsight_api.engine.db_utils.acquire_with_retry", return_value=AsyncContextManager()): + result = await routines_module.ensure_maintenance_routines(mock_engine, "semantic") + assert result == [] + + +class TestGetMigrationPath: + """Tests for _get_migration_path helper.""" + + def test_finds_migration_file(self): + """Test that the migration file is found.""" + from codespy.agents.memory.cerebral import routines as routines_module + + path = routines_module._get_migration_path() + assert path is not None + assert path.exists() + assert "c8b4e2a71f95" in path.name + + def test_returns_none_when_hindsight_not_importable(self): + """Test that None is returned if hindsight_api is not available.""" + from codespy.agents.memory.cerebral import routines as routines_module + + with patch.object(routines_module, "_get_migration_path", return_value=None): + # Test that our patch works + path = routines_module._get_migration_path() + assert path is None + + +class TestRoutineNamesConstant: + """Tests for the ROUTINE_NAMES constant.""" + + def test_contains_all_four_routines(self): + """Test that ROUTINE_NAMES contains all four expected routine names.""" + from codespy.agents.memory.cerebral import routines as routines_module + + expected = [ + "banks_needing_consolidation", + "schemas_with_expired_rows", + "mental_models_with_cron", + "schemas_with_expired_operations", + ] + assert routines_module.ROUTINE_NAMES == expected + + def test_revision_id_is_set(self): + """Test that REVISION_ID is set to expected value.""" + from codespy.agents.memory.cerebral import routines as routines_module + + assert routines_module.REVISION_ID == "c8b4e2a71f95" From e3dbebcbc0b6dc638061bde62b58150c44414857 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 25 Sep 2026 23:54:48 +0200 Subject: [PATCH 12/39] cost --- poetry.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index 631b68e..5ef9df8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3175,19 +3175,19 @@ utils = ["numpydoc (>=1.8.0,<2.0)"] [[package]] name = "litellm" -version = "1.102.0" +version = "1.102.1" description = "Library to easily interface with LLM API providers" optional = false python-versions = "<3.15,>=3.10" files = [ - {file = "litellm-1.102.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9f3cae21e99ea71005cf53ea2cd8ad516f98914ab321dd866ef53fd0fca8eb5c"}, - {file = "litellm-1.102.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dabb40d4ae015d319582d93f80d3d4bd8c3c479a4227d7b130f46bc272e81e39"}, - {file = "litellm-1.102.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b2efcee314f78a46cb46624a29eb67eb949c123d9d830eb0bc452c235f6282bd"}, - {file = "litellm-1.102.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:76cd80a9a8eb97da62dfa695b3277b80845657cbabdc9fb4db085cde0012f31c"}, - {file = "litellm-1.102.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:db15cb7ee0c1afd07c5312039ee248d74f381bbe10eb1b951b4f2e6f358ebbe7"}, - {file = "litellm-1.102.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d03e18095383b9a6f22485149d1f11e99c7a0cd9748b4736676bb4d2f89fe5b2"}, - {file = "litellm-1.102.0-cp310-abi3-win_amd64.whl", hash = "sha256:fbcbaff335ce6ce17aa3c659a5c2b5605a2d8780001abbc1a716c3b10bd55e8e"}, - {file = "litellm-1.102.0.tar.gz", hash = "sha256:d2d6abe095bce53743b0ad5a80a93dbad58698d18e1a3f3e57a922ece8657bde"}, + {file = "litellm-1.102.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:01550e79e052249dc5df53631ed9bdff030ca7c2a22107c5ce482d53ecf5c8d8"}, + {file = "litellm-1.102.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:da73fe3f6a1bf70232d752f7e1fe49f5d4d9473c02972553e0d0f7b514b280c7"}, + {file = "litellm-1.102.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:296c49171d7b80e6298fdf1bb18c7b69a826b82ae2b917bc6f5a80f153ee8b29"}, + {file = "litellm-1.102.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b36c46e89e2fc50f42848120ed03a31c7347103cbaa733421f8843915b6f3355"}, + {file = "litellm-1.102.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f7c4ffead69cf010fe3f551d2941bd6190bdaa816b98d5ebc85df67096724180"}, + {file = "litellm-1.102.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c5fcfbe322e3052f96fa92409fc61ec9712a7b2b567e7760e057b17b7587d021"}, + {file = "litellm-1.102.1-cp310-abi3-win_amd64.whl", hash = "sha256:b2c24cad24c637b20e91f6dd7b812971a5fa64ad2330213e517ff8f38d25c0d8"}, + {file = "litellm-1.102.1.tar.gz", hash = "sha256:7216f46e6a1eba50f4dbb6000afdb1f5b77360eb498a4f341fc655191108cfd4"}, ] [package.dependencies] From f70370528d10f118c62ddf0f2e15ef0de44d9346 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Sat, 26 Sep 2026 15:33:33 +0200 Subject: [PATCH 13/39] cost --- poetry.lock | 788 ++++++++++++++++++++++++++----------------------- pyproject.toml | 2 +- 2 files changed, 419 insertions(+), 371 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5ef9df8..a6bc281 100644 --- a/poetry.lock +++ b/poetry.lock @@ -235,13 +235,13 @@ files = [ [[package]] name = "anthropic" -version = "1.7.0" +version = "1.8.0" description = "The official Python library for the anthropic API" optional = false python-versions = ">=3.10" files = [ - {file = "anthropic-1.7.0-py3-none-any.whl", hash = "sha256:6b681b6ee00f232bb54f50f9a0d6d30e7be368c1b6f754bfd470c8385b8b6058"}, - {file = "anthropic-1.7.0.tar.gz", hash = "sha256:0ab1b04668606ba1ae93f6d9e8dcc2e0c4f0debebd4eea4773a3a595f0836db1"}, + {file = "anthropic-1.8.0-py3-none-any.whl", hash = "sha256:79a4516a21e64fd7b15be1a49ebf544bd6376c96a971a77365358823a2717bc6"}, + {file = "anthropic-1.8.0.tar.gz", hash = "sha256:9c1783ed90f409617749a61c5ab98e20624a572626f2e0a15cea03ed8e1401e5"}, ] [package.dependencies] @@ -514,17 +514,17 @@ lxml = ["lxml"] [[package]] name = "boto3" -version = "1.43.98" +version = "1.43.103" description = "The AWS SDK for Python (Boto3)" optional = false python-versions = ">=3.10" files = [ - {file = "boto3-1.43.98-py3-none-any.whl", hash = "sha256:7454f666a9e852a56db0a7fa23be33a899f64e27828930eac43c3edba7b34e17"}, - {file = "boto3-1.43.98.tar.gz", hash = "sha256:1ec732e023fb29c12dc8520f925b5bbbed29eeb36b5764b5a5c26052d7c721f7"}, + {file = "boto3-1.43.103-py3-none-any.whl", hash = "sha256:574796187eb4e4fcaaf5c1b88c1724a1ba5a95bc27aeae5573676178666acc71"}, + {file = "boto3-1.43.103.tar.gz", hash = "sha256:524821052527f6446d249bf710847b032d9b12135e346c9751777a0a2811cf04"}, ] [package.dependencies] -botocore = ">=1.43.98,<1.44.0" +botocore = ">=1.43.103,<1.44.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.19.0,<0.20.0" @@ -533,13 +533,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.43.98" +version = "1.43.103" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.10" files = [ - {file = "botocore-1.43.98-py3-none-any.whl", hash = "sha256:84b35b10402c2fc0c265f634fbf86336eecc6489ee23f55074b329924b2cfd6f"}, - {file = "botocore-1.43.98.tar.gz", hash = "sha256:6135dd639ea6d1b3b49381bc253c8a139d61f7d44cb5f7dae8e7cd1791758572"}, + {file = "botocore-1.43.103-py3-none-any.whl", hash = "sha256:6a6a561a6de43845ae42bc7ff7e2c798742bfa277f5215398f9c15ed9b1bd67c"}, + {file = "botocore-1.43.103.tar.gz", hash = "sha256:8c7f220e09f3b7ec99c59c716aa66cf6f5b2b57fd8731544054f3778aa744c6a"}, ] [package.dependencies] @@ -563,44 +563,54 @@ files = [ [[package]] name = "caio" -version = "0.12.4" +version = "0.12.9" description = "Asynchronous file IO for Linux MacOS or Windows." optional = false python-versions = ">=3.10" files = [ - {file = "caio-0.12.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e9eda6a88f309a0a53bcba844471638da01a92758015e2df099fa361891dc72"}, - {file = "caio-0.12.4-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:51b8600eadc8756751dfc152514852040c6c28deb6bdcd6bfbdddb2a38e424e1"}, - {file = "caio-0.12.4-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:97bff8f7da696321552b42629d1869983a8ced2f010bac9731b2719c1195a20c"}, - {file = "caio-0.12.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:759b952df48a5bbdb16e90631c748c877fb0ec7ca9d64eef1066569925ccdd30"}, - {file = "caio-0.12.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20f1d3fe05be285d82413b00bd8061eeb62d3027d3f565adaf7db10bebc594dc"}, - {file = "caio-0.12.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b0444d20067642bb272e423de3cc8ef05bf134d335d57fa5bddcfcc98e04313e"}, - {file = "caio-0.12.4-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:780465251a0680039b29c990bca73b40b843e39d06505c62e353ed56490e4576"}, - {file = "caio-0.12.4-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:67f1bddc997bef281db36600e3e58d6b16d6486b3540eb62c9e54c6584f137fd"}, - {file = "caio-0.12.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:20acd9a8df90d25b63bc0b7dc264e2a09b99082a420b5d640c76056027ee6b52"}, - {file = "caio-0.12.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d8d2826bf622644fc374a9f53ac00918b489d03599783953fcbe79af19123f5"}, - {file = "caio-0.12.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:67b725641fea682a2e9b1d3b2a150d21f1a25383e3ec8351bd7677ff857fa982"}, - {file = "caio-0.12.4-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4854a029e359e5ddfb5589ab66157a34af6a0dda0acdaa43f5e640d60182c1ea"}, - {file = "caio-0.12.4-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:f063624a98a64bab387430c5eaea61ef3825a98399104a297b8da4741c15139f"}, - {file = "caio-0.12.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a926d562f8c06767774a91239770ebaa93c10a24074e21ea741db208df9cc1d"}, - {file = "caio-0.12.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53605636e3b1eaeca368b475f1471ad9774736c6502a4b58b8b6b8d74b531770"}, - {file = "caio-0.12.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:134d9d145d75f454de5ece9e87595bad433639b51061b693bafc369f689f8742"}, - {file = "caio-0.12.4-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:27ec5671ac05650abc7ac1fb12e95ad09417ae495ce9be565927786688edd6c5"}, - {file = "caio-0.12.4-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:1e1d5570fd0ec75b1b2c2877c5545ed9f0738b5d23b000e2b6a8fc830dc8b310"}, - {file = "caio-0.12.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e22ce2e69d94e4c80e0c11c871e547b3c2d147f977632894cb2527ddb6e87a8d"}, - {file = "caio-0.12.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9dc0fd6f09ef72d18d3f43ca3d1140295222d925c583114ab9b1e8843a109a6e"}, - {file = "caio-0.12.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:413565d77dfdf2dd841ac100571ef1cc710f9c56137312f3ec51356350b4f3a5"}, - {file = "caio-0.12.4-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:cd1d00ed1867a3b7a4cffb64b1bd8644de4b6f88cb25240cf82a9cc160ace975"}, - {file = "caio-0.12.4-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:4189104e5579553031340a677762398c130b81ddb7d7c9b69eebffddaecc4f58"}, - {file = "caio-0.12.4-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:4f2e0b6d393dc0ea78cfd13a9a4321fc35fd6a2db802e5f64651317e859fc929"}, - {file = "caio-0.12.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:710ffe4a6f3d69dbfe98e9f856c24a544e4d639c7bd210f9f5503a26e62502f4"}, - {file = "caio-0.12.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddf8d946b795ebd7c75b331b6dcbab4808fdd2c9f16ca76012b4757512861133"}, - {file = "caio-0.12.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:17a41de51203fc4d787d8d577bd56f169167aa9822f14fada015504688791aa4"}, - {file = "caio-0.12.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ee9de68d6ede7ed69e55a1c09073ce146d9f9470088796d55238c485a93fe203"}, - {file = "caio-0.12.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:1c1a387f8784a86a56a8912ca42e1ad67599f16406f497b5b655b461a1b89426"}, - {file = "caio-0.12.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ceee64265a55b9aba9c38c1d01a159b99f1e36eb56938022018199d2e7fe1742"}, - {file = "caio-0.12.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:456a93868ff007cea65d916d96d192cda8b9992c6eee046cb0159040f9446ab5"}, - {file = "caio-0.12.4-py3-none-any.whl", hash = "sha256:7a5e231bbf81eaed269f99afa9ef46457f51f9407952a1795c0795b3a62c23c0"}, - {file = "caio-0.12.4.tar.gz", hash = "sha256:32d8e9f3e2099c8db29446679252766c9bcd806eb88b4fb60ad274f73df2a5e9"}, + {file = "caio-0.12.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:98e88c20217456113ae3021882aa310fab8ae77e54ec62a7c990f1c34a9f62a6"}, + {file = "caio-0.12.9-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:0b9922e7d5800b9aadafbe8ba956ed095ac37f85e0b38b96c97586e443548ff5"}, + {file = "caio-0.12.9-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c0432568cdf7cd36868f5d9800dfbb840d0ffaf0e8f2df0b42fbcbf83f2353ab"}, + {file = "caio-0.12.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17ec3d3cb28878e4771445f73161677a964f833f091fc88634a7214013dd8ed5"}, + {file = "caio-0.12.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3bf16c16b9318498ee7669a495d2198c44abdc0bc64483a500682c4eb41d5b52"}, + {file = "caio-0.12.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4edc95d8dc49f91fc9e870174860729fd088f9529464a7a6a1deda8a14d59a4f"}, + {file = "caio-0.12.9-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:14483697a27aefd265decb4b595e70814d284f46bc352c75eb9e9dae94a7b398"}, + {file = "caio-0.12.9-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:e10e3be34fca464cc5e27d3011e284fd0cde0cc488cda4742e765c8e9291647d"}, + {file = "caio-0.12.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:25255807af48386c50a2b186eaa582ffd5347d67c4f1cb3aa1bb79b36ace7af9"}, + {file = "caio-0.12.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:477d66e16948845d0f5ec6535e84c0c9ab7420b2f78f81bcf7b0f81d18a34ffb"}, + {file = "caio-0.12.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:554c6ee883a0f1e95236d85b8e7d8187b212668855ac3a858acb80f5e2e82e82"}, + {file = "caio-0.12.9-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:6e72fb0ddd369f712a4ad229ee0f1d7df6852e9b59b36fe8a46210b4c9ea8e82"}, + {file = "caio-0.12.9-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:f0698976f84dd40024204f0f77cf59ed7446e989575c8ad8cb64cd4b3e2871f3"}, + {file = "caio-0.12.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a06afa038f76324f439595b7a3242abf090a80b03006ed3d7dbbb9c031baf8a3"}, + {file = "caio-0.12.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed343077d8178c003b1596f40b1f46955be963a2871a1f85519be823113b24b0"}, + {file = "caio-0.12.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ed23f6ad6897fae4c02e3e8d236aa3cc18e766a7c5bb0352ea5e9fb26c5d3335"}, + {file = "caio-0.12.9-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:c327977b8174337c1aaff27da17ac249d176ef8ea6e2dbf70b49cdd8038d57d3"}, + {file = "caio-0.12.9-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:f6ffb3d448016d20d8c40c53864d81bdf7463157969de65841eeb370519dbf78"}, + {file = "caio-0.12.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a11421fb4ac591e6fea5512d9a8ad1b488e7b28cf610ede973bbfb4be5177454"}, + {file = "caio-0.12.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c3f6dc05486ce4e1027f1d2da4d84c1d6bb815886e76f34a9228d370d86a5537"}, + {file = "caio-0.12.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5c43eaebf220aeb9a388d309a5352e82562a7b9b2ea0102502204413641cfed7"}, + {file = "caio-0.12.9-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:bc63db6b4a54b2f1c519424acfb2b7198664e4ef8427d397215a81c0bed9e9e6"}, + {file = "caio-0.12.9-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:83718f0ba9ff56de9c3ce7a61b463466fbb064be4f087230064abac5d08b8100"}, + {file = "caio-0.12.9-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:4a69de19ef8780ea67f5fffa6fed95af32ed4c036e338a361307314306ac816c"}, + {file = "caio-0.12.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d3d3664c757d59d330381666683cdfa287c5bfff90819868e98ef0bb8c2d2382"}, + {file = "caio-0.12.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e152e1a1970d49b6056c1c94547801fcc6b41fc12185db6628ed58ec7785b04"}, + {file = "caio-0.12.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:32755af4459fed70d10a5e8c396de3ccb57e02365c7d818937a9794884b172da"}, + {file = "caio-0.12.9-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:de4458707370b9f13de2ead07e6719305624b4f0aad665ce6cb2f1452eb4965d"}, + {file = "caio-0.12.9-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:0a571981c8724f69c34ed4c7619585d552d27fe38bad0daf970dca3e14d6922b"}, + {file = "caio-0.12.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1c043b15a19e33c0b18b1937493be6730c3b08acc33af6899f669ac8957a3e46"}, + {file = "caio-0.12.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dada2e1ca5481e5c11201d269ac8e009ce86d34478f4426e9739470b0c2a1030"}, + {file = "caio-0.12.9-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:53d3febc3e46707786a023b28bd21213ac78332db46fae7df2a03c0cc208976d"}, + {file = "caio-0.12.9-cp315-cp315-manylinux_2_34_aarch64.whl", hash = "sha256:6634c57de5883819e0fb423094fe5cb480e81b8f5f46f601eb143e2c762f4c14"}, + {file = "caio-0.12.9-cp315-cp315-manylinux_2_34_x86_64.whl", hash = "sha256:aa0fe6b459ef45d9d1fe82e22d5d849dcdb07e3a87f56444d1ff799249a08576"}, + {file = "caio-0.12.9-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:7fee93281b220488a5e6949ac197a52c2f1e877c6e32aec5ce658e9add3eba60"}, + {file = "caio-0.12.9-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6c28c4789a9c6d8e8b36cac15f85727bd8e4d312c98fc5aa4fceaa4631669786"}, + {file = "caio-0.12.9-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e600822c774fdb434e43163c7fc0d4dcfaa6540ebbf77e82e5f0dac071b27f8f"}, + {file = "caio-0.12.9-cp315-cp315t-manylinux_2_34_aarch64.whl", hash = "sha256:cc30e0c458d2d6785e72bb5117086ffce61b5c058bee5637c5aea69042db5e86"}, + {file = "caio-0.12.9-cp315-cp315t-manylinux_2_34_x86_64.whl", hash = "sha256:7490517a72f4ad01b39311ff8e909c3cab77f12dddd370ba6deb035313038468"}, + {file = "caio-0.12.9-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b30f3f36e45afb0814fc828cbb10ef9dd91043ff0b0a14ffe1cd8a348b461179"}, + {file = "caio-0.12.9-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b17b9aca6360f72bf07f1062bd60d8849f3fd5c1cb77f43923d8dec7fdbbb5df"}, + {file = "caio-0.12.9-py3-none-any.whl", hash = "sha256:bf12d4f014b2a33e642ed7905b5787656ede2fc24be21f7c086826ff0d32cec3"}, + {file = "caio-0.12.9.tar.gz", hash = "sha256:99e99419b44ab5511f7468c6a452887dd125b8e4042672a7589f0cf01d254ea8"}, ] [package.extras] @@ -912,16 +922,16 @@ files = [ [[package]] name = "claude-agent-sdk" -version = "0.2.157" +version = "0.2.160" description = "Python SDK for Claude Code" optional = false python-versions = ">=3.10" files = [ - {file = "claude_agent_sdk-0.2.157-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6a2c7aa1901e43b827411d62f2327aed82f53ed8ca6b076eaa5be4a1fc681f"}, - {file = "claude_agent_sdk-0.2.157-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ebf3f514c61db9be01bbf6d1acb0d993247340583b3559d2df75e3822f618bbb"}, - {file = "claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:a58089e0c11364db2ec7a139e09b36a4fb9c95817a758bee7b28e0434f55c0b6"}, - {file = "claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:bd126b2edee3a247b2c7c4cee1f5a3ea68ef748288ec0650db88dd3918e5471a"}, - {file = "claude_agent_sdk-0.2.157.tar.gz", hash = "sha256:4ce6f41bc965fcb4a9d11bd7a3cac266975c75b632f3d74a26f55f0fffb3160a"}, + {file = "claude_agent_sdk-0.2.160-py3-none-macosx_11_0_arm64.whl", hash = "sha256:547cb16c94c67b57873c1ac9b5ce83076941d5189d52601b9791032d13a21456"}, + {file = "claude_agent_sdk-0.2.160-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cc77386858bd5e716914544a35a0620fe1586d130299d1c7b2e207bebd02ace4"}, + {file = "claude_agent_sdk-0.2.160-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:59638c6acd0159da200eb9d8fbb282d6c8d2191e6fcdd02fa751857c014b7e40"}, + {file = "claude_agent_sdk-0.2.160-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:70d1dd4286b69ecdc334d66e7b9dae8532790803f7cd7e0bf269bd5e407f2259"}, + {file = "claude_agent_sdk-0.2.160.tar.gz", hash = "sha256:1107f7804c656e194cbf80d9e20b4b861803bc16c4067a04839a347c9d140f48"}, ] [package.dependencies] @@ -1098,27 +1108,26 @@ ssh = ["bcrypt (>=3.1.5)"] [[package]] name = "cyclopts" -version = "4.25.3" +version = "5.0.0" description = "Intuitive, easy CLIs based on type hints." optional = false -python-versions = ">=3.10" +python-versions = ">=3.11" files = [ - {file = "cyclopts-4.25.3-py3-none-any.whl", hash = "sha256:40e76b3153ea90e885abd8d3a4d8184db5a5eec2fde8540025aae8d72fc973fc"}, - {file = "cyclopts-4.25.3.tar.gz", hash = "sha256:7d3a067bc7699a1a65fe2ea0b092a925855bb711a05adc1e65c5b9ba307acea4"}, + {file = "cyclopts-5.0.0-py3-none-any.whl", hash = "sha256:69c5d98bd48827a0f7c663b76f966f730652cbc48daa7625f1e69ae429b82376"}, + {file = "cyclopts-5.0.0.tar.gz", hash = "sha256:8bee8822319f5cd90711f568aec54a0a02bd3571ddff45c94aa62405beed38b6"}, ] [package.dependencies] attrs = ">=23.1.0" docstring-parser = ">=0.15,<4.0" rich = ">=13.6.0" -rich-rst = ">=1.3.1,<3.0.0" +rich-rst = ">=2.0.1,<3" [package.extras] debug = ["ipdb (>=0.13.9)", "line-profiler (>=3.5.1)"] dev = ["coverage[toml] (>=5.1)", "mkdocs (>=1.4.0)", "pexpect (>=4.9.0)", "pre-commit (>=2.16.0)", "pydantic (>=2.11.2,<3.0.0)", "pytest (>=8.2.0)", "pytest-cov (>=3.0.0)", "pytest-mock (>=3.7.0)", "pytest-timeout (>=2.3.0)", "pyyaml (>=6.0.1)", "syrupy (>=4.0.0)", "toml (>=0.10.2,<1.0.0)", "trio (>=0.10.0)"] -docs = ["gitpython (>=3.1.31)", "myst-parser[linkify] (>=3.0.1,<5.0.0)", "sphinx (>=7.4.7,<8.2.0)", "sphinx-autodoc-typehints (>=1.25.2,<4.0.0)", "sphinx-copybutton (>=0.5,<1.0)", "sphinx-rtd-dark-mode (>=1.3.0,<2.0.0)", "sphinx-rtd-theme (>=3.0.0,<4.0.0)"] +docs = ["gitpython (>=3.1.31)", "myst-parser[linkify] (>=3.0.1,<6.0.0)", "sphinx (>=7.4.7,<8.2.0)", "sphinx-autodoc-typehints (>=1.25.2,<4.0.0)", "sphinx-copybutton (>=0.5,<1.0)", "sphinx-rtd-dark-mode (>=1.3.0,<2.0.0)", "sphinx-rtd-theme (>=3.0.0,<4.0.0)"] mkdocs = ["markdown (>=3.3)", "mkdocs (>=1.4.0)", "pymdown-extensions (>=10.0)"] -toml = ["tomli (>=2.0.0)"] trio = ["trio (>=0.10.0)"] yaml = ["pyyaml (>=6.0.1)"] @@ -1246,13 +1255,13 @@ test = ["pytest"] [[package]] name = "dspy" -version = "3.3.1" +version = "3.4.0" description = "DSPy" optional = false python-versions = "<3.15,>=3.10" files = [ - {file = "dspy-3.3.1-py3-none-any.whl", hash = "sha256:250049f565f52c014609ce2d3ca0de17a6c9449ac961492d61a009aa30dceabf"}, - {file = "dspy-3.3.1.tar.gz", hash = "sha256:ca53a428ac6a30984a894cf3847cf623b7dd7e8aadf8a63620b1f1f6155e4eaf"}, + {file = "dspy-3.4.0-py3-none-any.whl", hash = "sha256:36de329ecdaf18e31977e26c61b587caa470a34f574f2fe6cb7f4c82bcbf97cc"}, + {file = "dspy-3.4.0.tar.gz", hash = "sha256:4dea089c6bd1f8d314e1095e7f64b4b4ff64bb8a09549efee026c0a609f08c16"}, ] [package.dependencies] @@ -1266,7 +1275,7 @@ litellm = ">=1.65.8" mcp = {version = "*", optional = true, markers = "python_version >= \"3.10\" and extra == \"mcp\""} openai = ">=1.66.2" orjson = ">=3.9.0" -pydantic = ">=2.0" +pydantic = ">=2.11.0" regex = ">=2023.10.3" requests = ">=2.31.0" tenacity = ">=8.2.3" @@ -1282,6 +1291,7 @@ mcp = ["mcp"] numpy = ["numpy (>=1.26.0)"] optuna = ["optuna (>=3.4.0)"] test-extras = ["datasets (>=2.14.6)", "langchain_core (>=0.3.0)", "mcp", "numpy (>=1.26.0)", "optuna (>=3.4.0)", "pandas (>=2.1.1)"] +typesafe = ["typesafe-sdk (>=0.6.0,<1.0.0)"] weaviate = ["weaviate-client (>=4.5.4,<4.22.0)"] [[package]] @@ -1820,13 +1830,13 @@ files = [ [[package]] name = "filelock" -version = "4.0.1" +version = "4.0.3" description = "A platform independent file lock." optional = false python-versions = ">=3.10" files = [ - {file = "filelock-4.0.1-py3-none-any.whl", hash = "sha256:481a321a27bef441e23c53371c6abc8d7d16e26b97090074ba44f7538a3fd55a"}, - {file = "filelock-4.0.1.tar.gz", hash = "sha256:fdefc3f3e87716d855ae2b732c1cfd521dd99799ef2b4d00e8c0d4dcdc7cc94b"}, + {file = "filelock-4.0.3-py3-none-any.whl", hash = "sha256:30cd166e2aee2c7534ce2c33c6367c4cd051b8368e20e2c4eb0c34f699bacfab"}, + {file = "filelock-4.0.3.tar.gz", hash = "sha256:87296d60478e14204fd9406e79831400fef76693bae2895deec236c98e87a8aa"}, ] [[package]] @@ -2089,13 +2099,13 @@ test = ["basedpyright (==1.39.9)", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "m [[package]] name = "google-auth" -version = "2.58.0" +version = "2.58.1" description = "Google Authentication Library" optional = false python-versions = ">=3.10" files = [ - {file = "google_auth-2.58.0-py3-none-any.whl", hash = "sha256:8a9c4645bb4c8e91668fb1934b95ae6a8687084232753639220ba9bf04a1610d"}, - {file = "google_auth-2.58.0.tar.gz", hash = "sha256:55e30cf15e737de92c5323d78cda8a83fcd57e7ffbaf900c4600039fd60a80fd"}, + {file = "google_auth-2.58.1-py3-none-any.whl", hash = "sha256:66a63572d0ab629554768544df04018d79178c0bd7439ba31683ac576b79024d"}, + {file = "google_auth-2.58.1.tar.gz", hash = "sha256:1480461d8b2347e679af758f4003700ea79a59b603c9134e18bfa95e8004ee70"}, ] [package.dependencies] @@ -2118,13 +2128,13 @@ urllib3 = ["packaging (>=20.0)", "urllib3 (>=1.26.15,<3.0.0)"] [[package]] name = "google-genai" -version = "2.24.0" +version = "2.25.0" description = "GenAI Python SDK" optional = false python-versions = ">=3.10" files = [ - {file = "google_genai-2.24.0-py3-none-any.whl", hash = "sha256:59e5f2d88bc2d6ed04aa1aa14cb5629090cfe9458401745e620b24d1fe9893d7"}, - {file = "google_genai-2.24.0.tar.gz", hash = "sha256:814b63d05dca4776c5a322699eb30c565040cf8d5fd77c38714076535fb2e019"}, + {file = "google_genai-2.25.0-py3-none-any.whl", hash = "sha256:2e8f3a5f76ed00d5ffc3153f6089bc7d3511054eed53ad9cfc6095a7dc59b028"}, + {file = "google_genai-2.25.0.tar.gz", hash = "sha256:ab603baa5eee0205926ad0f8d7f93e0400df6d67650e99c33e01ab228ea16ad6"}, ] [package.dependencies] @@ -2146,13 +2156,13 @@ pyopenssl = ["pyopenssl"] [[package]] name = "googleapis-common-protos" -version = "1.75.3" +version = "1.75.4" description = "Common protobufs used in Google APIs" optional = false python-versions = ">=3.10" files = [ - {file = "googleapis_common_protos-1.75.3-py3-none-any.whl", hash = "sha256:a018d2bf098ca9fb6faa08d5bb780e2a2c2f73c566f069761331386c9596d3f2"}, - {file = "googleapis_common_protos-1.75.3.tar.gz", hash = "sha256:57c435ac2c68b108999b6db075d9053e4d7a936ba57b4a3d45667b1346f1738a"}, + {file = "googleapis_common_protos-1.75.4-py3-none-any.whl", hash = "sha256:e8eb9cffa9a9f3423090cc7a086012004196abab388a0561690e81887fd79cc1"}, + {file = "googleapis_common_protos-1.75.4.tar.gz", hash = "sha256:4587babdc82a8d7e5a3d4f5a6697e064bf44a598b4d08341c212b68185eadbcd"}, ] [package.dependencies] @@ -2283,13 +2293,13 @@ tests = ["pytest"] [[package]] name = "hindsight-api-slim" -version = "0.10.0" +version = "0.10.1" description = "Hindsight: Agent Memory That Learns" optional = false python-versions = ">=3.11" files = [ - {file = "hindsight_api_slim-0.10.0-py3-none-any.whl", hash = "sha256:6f7a11fb43735bcc8164d3bee84622690bfa081ca02aaa88e3b88ef72ef3eca3"}, - {file = "hindsight_api_slim-0.10.0.tar.gz", hash = "sha256:0b4d9e0f3af08c1b507fb331f935ae1932c0c7bf02e2b2cc7a269319c0181e8f"}, + {file = "hindsight_api_slim-0.10.1-py3-none-any.whl", hash = "sha256:998dfe11ed8685311f7f1aaa06c97fdf6cc3a4289b2666b6f526a6e1f0905aa1"}, + {file = "hindsight_api_slim-0.10.1.tar.gz", hash = "sha256:8bdc5a484f2e86a827354d941d6a164fb74849690ca7ecb98751637075c3997e"}, ] [package.dependencies] @@ -2382,13 +2392,13 @@ trio = ["trio (>=0.22.0,<1.0)"] [[package]] name = "httpcore2" -version = "2.13.0" +version = "2.13.1" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.10" files = [ - {file = "httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e"}, - {file = "httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db"}, + {file = "httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a"}, + {file = "httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103"}, ] [package.dependencies] @@ -2497,18 +2507,18 @@ files = [ [[package]] name = "httpx2" -version = "2.13.0" +version = "2.13.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.10" files = [ - {file = "httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a"}, - {file = "httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44"}, + {file = "httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4"}, + {file = "httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa"}, ] [package.dependencies] anyio = {version = ">=4.10", markers = "sys_platform != \"emscripten\""} -httpcore2 = {version = "2.13.0", markers = "sys_platform != \"emscripten\""} +httpcore2 = {version = "2.13.1", markers = "sys_platform != \"emscripten\""} httpx2-jsfetch = {version = "*", markers = "sys_platform == \"emscripten\" and python_version >= \"3.12\""} idna = ">=3.18" truststore = {version = ">=0.10", markers = "sys_platform != \"emscripten\""} @@ -2535,20 +2545,20 @@ files = [ [[package]] name = "huggingface-hub" -version = "1.32.0" +version = "1.33.0" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.10.0" files = [ - {file = "huggingface_hub-1.32.0-py3-none-any.whl", hash = "sha256:b0c7c80561969d9cdacdd55fce67ba9584cca0b9d4ea80957a3a5c1445fac5c8"}, - {file = "huggingface_hub-1.32.0.tar.gz", hash = "sha256:ed70a45498abe86039df7c2f4e5f7575de524be908d3840e8f828d5525eafd6a"}, + {file = "huggingface_hub-1.33.0-py3-none-any.whl", hash = "sha256:04e434b06e100eddbce9a6e817d72693a7884b10a79bd67ab48080d5c07eb899"}, + {file = "huggingface_hub-1.33.0.tar.gz", hash = "sha256:367be21a201db9523eddf8aeac7048f2602c1b308691c97640d5e72ed188007e"}, ] [package.dependencies] click = ">=8.4.2,<9.0.0" filelock = ">=3.10.0" fsspec = ">=2023.5.0" -hf-xet = {version = ">=1.5.2,<2.0.0", markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\""} +hf-xet = {version = ">=1.6.0,<2.0.0", markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\""} httpx = ">=0.23.0,<1" packaging = ">=20.9" pyyaml = ">=5.1" @@ -2560,8 +2570,8 @@ all = ["Jinja2", "Pillow", "authlib (>=1.3.2)", "duckdb", "fastapi", "fastapi", dev = ["Jinja2", "Pillow", "authlib (>=1.3.2)", "duckdb", "fastapi", "fastapi", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0)", "numpy", "pytest (>=8.4.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (>=16.2)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] gradio = ["gradio (>=5.0.0)", "requests"] -hf-xet = ["hf-xet (>=1.5.2,<2.0.0)"] -mcp = ["mcp (>=1.8.0,<2.0.0)"] +hf-xet = ["hf-xet (>=1.6.0,<2.0.0)"] +mcp = ["mcp (>=1.9.4,<2.0.0)"] oauth = ["authlib (>=1.3.2)", "fastapi", "httpx", "itsdangerous"] quality = ["libcst (>=1.4.0)", "mypy (==1.15.0)", "ruff (>=0.9.0)", "ty"] testing = ["Jinja2", "Pillow", "authlib (>=1.3.2)", "duckdb", "fastapi", "fastapi", "httpx", "itsdangerous", "jedi", "numpy", "pytest (>=8.4.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (>=16.2)", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] @@ -2882,13 +2892,13 @@ drafts = ["pycryptodome"] [[package]] name = "json-repair" -version = "0.63.4" +version = "0.63.5" description = "A package to repair broken json strings" optional = false python-versions = ">=3.10" files = [ - {file = "json_repair-0.63.4-py3-none-any.whl", hash = "sha256:0f374f3eee21454aef0a5d72c06b8689b660a1788f80ab392639e3f7d5c5d458"}, - {file = "json_repair-0.63.4.tar.gz", hash = "sha256:77aa642193d62b02b889e8ce0df33898d3ea87282f0b9d8653f8ce8772c642b4"}, + {file = "json_repair-0.63.5-py3-none-any.whl", hash = "sha256:0e39b3066f7829b4bd7fc614e463cfd4ff7a4c46f82f5b65431fc11e29a74c81"}, + {file = "json_repair-0.63.5.tar.gz", hash = "sha256:75a6a1a4ea954935dd3fb69b6e49ac0e3e3d69b4290127a0c8d37af93b709f3a"}, ] [package.extras] @@ -3442,13 +3452,13 @@ python-dotenv = ">=1.0.1" [[package]] name = "mako" -version = "1.4.1" +version = "1.4.3" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." optional = false python-versions = ">=3.10" files = [ - {file = "mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617"}, - {file = "mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27"}, + {file = "mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f"}, + {file = "mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a"}, ] [package.dependencies] @@ -3513,13 +3523,13 @@ six = ">=1.15,<2" [[package]] name = "markitdown" -version = "0.1.7" +version = "0.1.8" description = "Utility tool for converting various files to Markdown" optional = false -python-versions = ">=3.10" +python-versions = "<3.15,>=3.10" files = [ - {file = "markitdown-0.1.7-py3-none-any.whl", hash = "sha256:4eca912c87c6aa6897284a7f4bf6769a23bccf8544530f5d8b175fbe3797c916"}, - {file = "markitdown-0.1.7.tar.gz", hash = "sha256:4d1f3c69cd43b82288fdc3653686d759dcf355ee7c681aa6a855aed98a1e4f44"}, + {file = "markitdown-0.1.8-py3-none-any.whl", hash = "sha256:de7375a50578a39bcbbf13b48c67d99033d988e0ae8ad25af46ed432dbe4cbab"}, + {file = "markitdown-0.1.8.tar.gz", hash = "sha256:17188ad827ea79fc264c7b1ca8cf5a242a16278d84cc32f2edc475dbe92812ed"}, ] [package.dependencies] @@ -3539,7 +3549,7 @@ requests = "*" xlrd = {version = "*", optional = true, markers = "extra == \"xls\""} [package.extras] -all = ["azure-ai-contentunderstanding (>=1.2.0b1)", "azure-ai-documentintelligence", "azure-identity", "lxml", "mammoth (>=1.11.0,<1.12.0)", "olefile", "openpyxl", "pandas", "pdfminer-six (>=20251230)", "pdfplumber (>=0.11.9)", "pydub", "python-pptx", "speechrecognition", "xlrd", "youtube-transcript-api (>=1.0.0,<1.1.0)"] +all = ["azure-ai-contentunderstanding (>=1.2.0b1)", "azure-ai-documentintelligence", "azure-identity", "lxml", "mammoth (>=1.11.0,<1.12.0)", "olefile", "openpyxl", "pandas", "pdfminer-six (>=20251230)", "pdfplumber (>=0.11.9)", "pydub", "python-pptx", "speechrecognition", "xlrd", "youtube-transcript-api (>=1.2.3,<1.3.0)"] audio-transcription = ["pydub", "speechrecognition"] az-content-understanding = ["azure-ai-contentunderstanding (>=1.2.0b1)", "azure-identity"] az-doc-intel = ["azure-ai-documentintelligence", "azure-identity"] @@ -3549,7 +3559,7 @@ pdf = ["pdfminer-six (>=20251230)", "pdfplumber (>=0.11.9)"] pptx = ["python-pptx"] xls = ["pandas", "xlrd"] xlsx = ["openpyxl", "pandas"] -youtube-transcription = ["youtube-transcript-api"] +youtube-transcription = ["youtube-transcript-api (>=1.2.3,<1.3.0)"] [[package]] name = "markupsafe" @@ -3722,182 +3732,182 @@ tests = ["pytest (>=4.6)"] [[package]] name = "multidict" -version = "6.9.0" +version = "6.9.1" description = "multidict implementation" optional = false python-versions = ">=3.10" files = [ - {file = "multidict-6.9.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:63c9da87fa6d4284e57d2a6e549dfe8f2938ab578fddab3f8ed9742812c9d0f3"}, - {file = "multidict-6.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3663ce6cb19c5e3123c9aad5907804a42d384f8cd4b67e59291efc9480f8da9a"}, - {file = "multidict-6.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f7f340a4a6ad4dacb0f866143452d794ea32a0d9ddf79d93ecb4c3ac5515d0d4"}, - {file = "multidict-6.9.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf44632149302f7fdcf5baecc277d816ab77a47a268f2392dc5d2d01c56770cf"}, - {file = "multidict-6.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:095b091158c89a03ad1237b4880f857d27628f93c23da1616ee5640011439c53"}, - {file = "multidict-6.9.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3958fa87a161a6b9c6661606784abdd8f1afa21a0d6e99d1b99512e70e6b4f1"}, - {file = "multidict-6.9.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9f99e0eec05aca200b66735a38f8de5c9be03a6afd62ba7f28860203d936a03f"}, - {file = "multidict-6.9.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d98efa7b004b67cb8cca26b218012efa50c7103cf4ae54898f2bbf89a3c62f"}, - {file = "multidict-6.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a23926c06835ad71e6bc755ebc07ad34655847de63c01a5d06b6501997712c4c"}, - {file = "multidict-6.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:06723cd13ace09a0ce1ed010fc9e57cd762e57ee23691d6cdb13b7fcf1a35468"}, - {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:27dee31d6f3a0d2be018b71721adbe24aed9b08bdc69d133c88eead07beee600"}, - {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:90d341b2c64eacef24fc0a35aaabc5227834ce93bb1da416d61601663df29456"}, - {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:986de92227e44b257fef664a69613a4346a090553c186d0ae3bf15ceab447f5e"}, - {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c8da6d6fc78f971ebb9e29563c891a429374861afda55e4692a7962e3822637d"}, - {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8903ce696f0aa9dc301c5621fa0f0e24e985d4e6cc247cd63bc9abcd5a081628"}, - {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bf345dacf96fd9c698ad30c620fbdbe6e1edadf83944203ce5b5688f52e3d141"}, - {file = "multidict-6.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e63e05228c1f1c1bef09c08abd2f97b6fe597c9ca14a1c94bec4e76b4664046c"}, - {file = "multidict-6.9.0-cp310-cp310-win32.whl", hash = "sha256:f25229fddbd6bb3d47f1141c319bead19fa3c9e62790c6d51201564fde05fc2a"}, - {file = "multidict-6.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:05f197459db34dee24ae7e3560affa5c6f93989805899c9bba13358b5116a12e"}, - {file = "multidict-6.9.0-cp310-cp310-win_arm64.whl", hash = "sha256:d4464437dbbd7a5a71a5e86f2a9ec92ccbb8d96706f5a173ca985175f23adaf9"}, - {file = "multidict-6.9.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7e46237ef18c8ee183dde740f0174d6ecf9c26d362b506b5d23fb57bc3634bc0"}, - {file = "multidict-6.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e33fabca25633cf0e4f9dd4495733d09b7f0628fc9424c5b5e58da644fd4ab94"}, - {file = "multidict-6.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e82873b56ed37b328501fb063962299ea6f7fc1caf1a5c47966028824bde0807"}, - {file = "multidict-6.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e2169fdb886288fc6bf9d8e48abca9487f8b3ace1f5ab269b074864d212351e"}, - {file = "multidict-6.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eefec2a6b0381846ce54d88f30de4b3a5c15ff0bddc5264cccba85b710a8785d"}, - {file = "multidict-6.9.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c63013a210a09d4a8f30814b785450f1d0a2aaef8230c1e068e83f3afceb6030"}, - {file = "multidict-6.9.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4fd82f81dd0676059f9bc3a418a79f27feab19486100a255bf8488cccb3e60d3"}, - {file = "multidict-6.9.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:deea26b6979ce46f2fb3b2db9b651e12eb7f94c26a1ee457b79f66ad36a64fd1"}, - {file = "multidict-6.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58a4aee7188676c8317e3131bd844faf70e427979e1f20b8598c61f6cbd8b073"}, - {file = "multidict-6.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eee11705013394ebeeb383c92beb0fc9a3c449ac3382a6ee3aa23b38c6b06c3a"}, - {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:51e77d089f82ed2e288d3a1cbf6cb7ffa927c8055d8ef61d57b27f4d8da557b6"}, - {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:79640bb8746b703971fb16569e7bfa82d90548137cf4094769aa45cbafda9d9a"}, - {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:092214366f729803010a5c260f4a4f44d223c688641dfc27da851588ac762ffe"}, - {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:da469a672bf3d343eb6757d53ffbac36f1a29b93a9fe76558b5d0fd7a7efa599"}, - {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4216a8b4214a1ee4511c551c59d51534637394e9433765fbd8face86db43c532"}, - {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f05f5b3171e23801e72fd818de236be9f239b10ba699f23e43bb9596651f8b34"}, - {file = "multidict-6.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0c69b098c85685ba39efef1d8a9711243df8a4c4b051a35d3cce7f323381f8bb"}, - {file = "multidict-6.9.0-cp311-cp311-win32.whl", hash = "sha256:855be03f39b97e352d54eaa7b14ba2a46846cf14719ddb689cdaaee811462440"}, - {file = "multidict-6.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:a8040dde3fe35ead6b04adf3cd3fdd3bfc7c941505775d230b4501d18227c77b"}, - {file = "multidict-6.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:70b3097b838d9fc336f426c7aa7d45b7881ee9b06a10f4bcffd0dcda0cbd44e5"}, - {file = "multidict-6.9.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fdd484b84d3394e805689c56be3ab1f877ae7ff0eb9ff90a3ee7a755cbebab4f"}, - {file = "multidict-6.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f93c9058a0eceac0df2ce9d4c8823b84786ee598194753c2ca0c100224405e47"}, - {file = "multidict-6.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c7ab60b91e11b25e7682c5cd8763fdd17929ea83f234ba441091f1492e631ea3"}, - {file = "multidict-6.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:640113258c5925a9eed2c12523410b25565ac5df2fa6735fbae88fb09bcdd212"}, - {file = "multidict-6.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2ea72901860ccbe94517421681c60b13533ce03ba2f7bd96293c3a4d16ac4ccb"}, - {file = "multidict-6.9.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:408fac672931f2458be3bc8c89d9facd16dac2aad17c7cee2ca1693eee99f07e"}, - {file = "multidict-6.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:67bed23e9803945b0760650ec3e903af772c49abe869f3bc03c66b2e7649d5ef"}, - {file = "multidict-6.9.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb769ccc72e15d7d441e1a08f169d418376be77cdc387e813129c26b357fe50"}, - {file = "multidict-6.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a5111a2bd824c821a3dd09da29680391b0caaa18fea7761358f4001e6898d1c"}, - {file = "multidict-6.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e991677c4bdc5d9f2e71c74717a4e32cfe98930ca05becdf722b5eae1329d6a"}, - {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb69b724c345420ba49187a17a894f146099f5b2e501df42ddb4452ac8be37fa"}, - {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cff3cff5a725bdb8359962de8d7429aea592d7693dbd197eeabbdfab9b6300e9"}, - {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fca5b74b5909c29041f857c40d51d9273636fb4221cf020e4c452deb1c448a40"}, - {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11e32ccf23cdbfcf8299a6a825930a858ec9a6aa05d6752d0f90f2bdf19489e1"}, - {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ec1e387b1f8a85ae5b94aa8c4e0576912ffa4d31bd0578f24c950d4f05ee476"}, - {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4826f6b56456fb1e98111d7bc20cbdc7fa0a41b1f9ad80ff2dd3f2f5b226fe1"}, - {file = "multidict-6.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e71a072c52c78b7f97cd4611df6cef10977e4f2367cd0654a7626192f931adde"}, - {file = "multidict-6.9.0-cp312-cp312-win32.whl", hash = "sha256:95d339c3b75b4a50c665bdcf8417428cd71c3e5cd48e194cb1d336fcb856beac"}, - {file = "multidict-6.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f06c4da5315a6f709b13408c3e13f3b475f8c559ec7608c3c67062512871235"}, - {file = "multidict-6.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:0db5bf96ec2ce45a8bc7fbbe8a486089969bb2791a66b6789ee3aed0d5dd562e"}, - {file = "multidict-6.9.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:662315f8621b3559268813b11134c1f9633edd4ab5f7b713688a243c13f4026d"}, - {file = "multidict-6.9.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4bef8cb5edea9c9daeb8396a75eeb97f912c3fb3fa408fe659edfecf91e47424"}, - {file = "multidict-6.9.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:75f7fc21ffce9a792cb919f0e0b0b52117bd672f2a55d1929d573b6fc437f374"}, - {file = "multidict-6.9.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bb6be697065cbf31051465f9939d65624573d2219c3da8316db9e7cf5e0f5a7"}, - {file = "multidict-6.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67bcff396d2ad62197c95488a716a88462792b384b5ea6e44bf7c1070eddb8fc"}, - {file = "multidict-6.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:beda95a6bd0a2e2265b941b90ff72543afbeaebb6a450e3a6a010d10d4a2ffca"}, - {file = "multidict-6.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e78870909e9f9e3d672ba99f1eb75130d7e01c42e642e6a70f434df32ca0ee1"}, - {file = "multidict-6.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91092d597fcf0940cd6a59e64ba7156ee22d6899993fd7c5b1897fda71482f8f"}, - {file = "multidict-6.9.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4be612f23990a261060ccd8f15e89dad1f9b7bb0c1021c5c3987f5fc57959391"}, - {file = "multidict-6.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7aaa14f0b9ffa2780c5d3b21da0e9b58778ed47af3369df72e5fd6dc10ff8041"}, - {file = "multidict-6.9.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97555ad30ad20a8eb90fa86522088eebbbd68aba03e56d53f4850b3535c65e61"}, - {file = "multidict-6.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c51fd8d59e72e45c64907bf7f81fbd94f4c8dcd1c8b2f23f4c2d7ede788fcfc3"}, - {file = "multidict-6.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22067e88ff266e6a5dc59114529332692a01cc04b10dbdc2c14ab91217dee819"}, - {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e231de8ce43d4fd10bec4f67f71238ed88a974de30a9f20be7a4ab16c970a988"}, - {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b33e499a7b1f722547d57b4865ed68c03161643b9afe49fe097a0832078d183d"}, - {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1eb7939025bd9289d9dfe3a399102b1642f4fbb105a6af82f284052ee89e97a9"}, - {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:51a08dceed4b42ef25755ee2cbf50df325e90e6b415d00955ffdea2c394ad6c0"}, - {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb847cb4002e725f88ffe29883da8290fd0754fa0461ed8988735e20964a9f65"}, - {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:516fa4817cd070088f616a901380db56189f86daee9da79aada3c9b653f49ed7"}, - {file = "multidict-6.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:254e53be2ec70518bb82dfa9b0c7166baaf2bd0e918bd17b65caaf489d5a5522"}, - {file = "multidict-6.9.0-cp313-cp313-win32.whl", hash = "sha256:9869105ab61db13004f9ed610cd29ce4237b2c609f5f7b8bb8f2339fb9dad17d"}, - {file = "multidict-6.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:85ed0f3c3b01174aea5a8a8fcd456f64c2e9719c0f42c612c2694ecd35974a65"}, - {file = "multidict-6.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:1126782e3c3b1a7ccd990be3d3221709348e4b07d6ecf8b50965a93ae2624145"}, - {file = "multidict-6.9.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:1b34b10b615db5a3ed424b174a362a3cdf06220bd892d03d151a619bb157c4e9"}, - {file = "multidict-6.9.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f7f0b996a20776018a253061a781ba983e7b944e17a0748822026048b8b0bb07"}, - {file = "multidict-6.9.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b612842f7f286c42abc5dcbda1db50e9dbed39720dd15feb07dbc9974bfeaa7c"}, - {file = "multidict-6.9.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:71ee2b3198323cbff7a846538f01599f309dddddba79b5bf5a3dca2e49460ef6"}, - {file = "multidict-6.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d0dc1f9c7dc07d86e5a4ed24f3a55b945106f3e7c70a984ff8dd9267270414ed"}, - {file = "multidict-6.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a26c8672d70d2c12c2f1c4df91fe92422e905efaf1d639bd4d7192868a21e711"}, - {file = "multidict-6.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:186cc28558e3d6921ba797e433cd58ce64eb8b2f553e93f75dccbb9a604b7457"}, - {file = "multidict-6.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebc02af6ac170631774219c26fd63b4404d1e836a1aa92b738a3127eaa2aebad"}, - {file = "multidict-6.9.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62c4ffba9f81a2b17cf081bfe4e6b7eaf17193dc17d5870c01b2763e48efed24"}, - {file = "multidict-6.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a7e2aaa0e9a6413b54cd90fa0fe5944487872a7130a1c07347627974e677a34"}, - {file = "multidict-6.9.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:26e34dbbd6c3438ee19e0db1f5b846a7ca957085d193ec312b488924c3941a0d"}, - {file = "multidict-6.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d811799f821c06883404a171faa880660ef04c2687fc32fdaef22fb645fe80"}, - {file = "multidict-6.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fde003cec43783193360c27de2fbb0c8ea2db1cfebc193c3fc657c436f9d38"}, - {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9bfa4ba6d97ae6d9c1f5336c2abad5690bce1802a040f9aa5fa39cb0b3099c02"}, - {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a776fbaf700549943b647f21479d69191c238c84fb55d5a48eec1b4cf6cf6e00"}, - {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ed04614c47379bcdfa01d3e165149518017ea034cd5f48e203c287cc3d2ebd4b"}, - {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9c65d9912983991f368bd01f993191b1593c90ecde6be990d73add46084d235f"}, - {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4b89b102be78ed286123e75a30efda519e1ef6b9d4a4d01a08213f5e01a18e45"}, - {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e3ed6563ea143214c63bb4452ae5e8787a39226b22101c179fa4d2eda5972267"}, - {file = "multidict-6.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:448c05bd13f8675b3629fbc3fabb9e7bb19a5636a7a3eb677ea25a08d0544a56"}, - {file = "multidict-6.9.0-cp314-cp314-win32.whl", hash = "sha256:2b5db4f2ca34fce55d34b9c66a0eb5d0d01962e2420a1218ba836d8e46aa6ea3"}, - {file = "multidict-6.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:ccf9f7f07e9ab6e24f68ccb2b4da8ded65c1fbc5390ebd897a5fe55cdd4c064f"}, - {file = "multidict-6.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:64ef289ca3c6eaafaa705978686631cb5acc90a92a09bef39c95a810c5f4b804"}, - {file = "multidict-6.9.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f17f485e1837a7d31216be8be991a51265bcfc3cfd2457d5c94ade6086793ad7"}, - {file = "multidict-6.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3fccacf47e756a0f487b2b7e4afc9f167db0770ecc4e887b38ae2aec99dcb510"}, - {file = "multidict-6.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:527655c10fca73fd336cbcd5ccaaba5d8ded1512a3183ed7ebcc1569db5f1de3"}, - {file = "multidict-6.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d3520f774e78dad488c97398e5c7253333baa4eea09785aca56d477c4dc05c7"}, - {file = "multidict-6.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:518a76bff2567fb0941603b9ccecdd830dd2d717605e7a28c1edc9e64688759e"}, - {file = "multidict-6.9.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ff308def558cbbff74ea889ec659d117ac550d4d554467ed6cb3bf6adadac7e6"}, - {file = "multidict-6.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:875a77927cd7bc0bc1d0a0520a7ed2267bb169c5db03cc133eaa746526d4182b"}, - {file = "multidict-6.9.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2182da6ffc9793dd66d7d3632eea2b131f1a1e26eaedd51956845501830d84fe"}, - {file = "multidict-6.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e680a0b07e44b87a975eca63f84860a772667b0037066a6e93d998eb41ad2814"}, - {file = "multidict-6.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5066b7877e1e2ae2c3712c8037eff40a80d4cf35b393b4c42c81f1c4b73b346d"}, - {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fd0ec96e9877cb4dddf7942dffa777837809663f8e55d4f79a2bad06453446b0"}, - {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:98b0738ac1a1593f609a7b67d25e09c7de57c0f91c2879eb01c46b2dae55c0ba"}, - {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0548fdffa634c495b866d89e02ae58a9588cedaeeb972f6ced433e20dee1db5f"}, - {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7d0649ae61a341b9a47a2afab57424bd2a0ce98d36ec170ce9a51ce92c7284f6"}, - {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c6d470d693ff45678b9ab237113546f6dc18918be55b3c852a69ed05ae7404a8"}, - {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ac05ce26d2c902d2eb5afe70f2012d19f3b9510ed5ef054bab27a3d43bc7f39c"}, - {file = "multidict-6.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e82ea3ca4409c20172074f27dc1ba5858174f143d0ee637dd058b8061ff024c6"}, - {file = "multidict-6.9.0-cp314-cp314t-win32.whl", hash = "sha256:d61680c1969b5b1b900fece510077e7732336eae02f68dda02162e04ff6ff321"}, - {file = "multidict-6.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a9af5d28024c1de9e0efe06a64b5a8561e6517daa66788169e60d2c588e15c5"}, - {file = "multidict-6.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:810ae53369d957fcc3e3855612e3a35a39960adaf4b3b1cb4cb4a2e94185bcfe"}, - {file = "multidict-6.9.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:b9bcbd708bb5e1ce8cf9c58908ae9777a90f777406315b046bb10cb3e87f8621"}, - {file = "multidict-6.9.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27ed5aaee62eeff74786a3259de81ada4f2bf84b75122bc06d7d8eadc1be5643"}, - {file = "multidict-6.9.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30ba80aa7ec7340e80140d29f563e33acb21f3756e42df13612f2a26c80c90ba"}, - {file = "multidict-6.9.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c26e56c68bde47f8028265b3829915450c944febc75a1e4d0365b8a75557861a"}, - {file = "multidict-6.9.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:f6705ae71e4b14c693877be3cc09fc62af10197ca08ee08bb57ffce6e0f9c5f4"}, - {file = "multidict-6.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:1fc7b1ce735737b7abf38935928729433ef2c81306dcab7f360ef3c3076bc8e9"}, - {file = "multidict-6.9.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7731a591920d77bd9372a9a8ab3f447129dbf17d29b10928c94005e1606641c"}, - {file = "multidict-6.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69a9295177c33756d7a6f8da0f1db345ac951f2436be6ad4b90f53056526e767"}, - {file = "multidict-6.9.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7b4ae468e341cc84c1ae035ee12f032cc0716f618b1eb417fb14e8454a17c620"}, - {file = "multidict-6.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb9f52b89e339007a06c88cb95068e9d612ce252baaaa5ece85de96e2d51fca9"}, - {file = "multidict-6.9.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69190050f74fe139a7b3759c730e4e87484db9cf74b1786910a24496ab7a40f9"}, - {file = "multidict-6.9.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5f04bbfb064b87455a8012f5829b20201d608cf4b3a1a0e50c7a14173a75109"}, - {file = "multidict-6.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d81d0ec43a73056458e844cf90239d2c3a7aeb4ef05fea8c0f64775d5111847"}, - {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:b8aea3d54f3cc20a557fb951c68294d9aa88d614cf7151aa5629b1d5e4033b74"}, - {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:31fd2961bd754ac3177aae9438c01becc22eae029911f13c558e5767a10e5dc2"}, - {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:04e6f4eddeff93c778a9e7137568284f28856d960275085fcbd87cf77a53a057"}, - {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:319160c73795ce31e86494773e0f12c446d32dae7d1a76f4829007ef69eb80bf"}, - {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:0d7fbd38d52119423ed8c69672ca47eac121062c30c976e00ba732d114774874"}, - {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:981280a946256fc151584fb3b82b99db091fccac81cfecce530585db559ec62c"}, - {file = "multidict-6.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:fb3d1f3c63d49656a83e1604ef3ac573016cf2e62da84d1937f47107d5689c8b"}, - {file = "multidict-6.9.0-cp315-cp315-win32.whl", hash = "sha256:0c5388c41bc7c551548c4ce840349f0708f7ced6df877d82900bac82268160fc"}, - {file = "multidict-6.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:49e47de3823bee4896f1eedc17ce4fc4f42c433bab168ece7911f42e69a4f666"}, - {file = "multidict-6.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:1ac339b944616d4061a9ee9571c89767543db38c19c8c7917a9c08b1462c4800"}, - {file = "multidict-6.9.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:cb6be563dd43b2d4d528afded1878b559ef1c2f45f89e94d273ef515485727f9"}, - {file = "multidict-6.9.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:76f0df301516563085342d9cdad03fc3c0301b36c3cfa8454e49ca73878aa59c"}, - {file = "multidict-6.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:34c00a534e948eb5f8b3c50cacea9daa4fc4dfd503f6edd114bb532f53742c9e"}, - {file = "multidict-6.9.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cce5e33383904e32b58bef7765c38917899a9dc87058f75c421c82e7c11440ee"}, - {file = "multidict-6.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c375408881861294207c1d038a711120776989c5e8c489b251a818cd74d39886"}, - {file = "multidict-6.9.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f95940278766f273b668bbd9dd412ab5dd170ff40fc836b714c38de7852f1fc"}, - {file = "multidict-6.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6009b58d6994881c53f56d2f2010f0b3cd30139c7b14156c2329d00c0d592e8e"}, - {file = "multidict-6.9.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7d5ab831301decfb7c29d1c243823f6ccbb3766117c5957bdc5b9f283355d0f1"}, - {file = "multidict-6.9.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17b74ffc94e63843cf34b7eb874ab1653afac0eda3edfa3de1207db7fc8c0ed9"}, - {file = "multidict-6.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62dc471198ac2f1085f6c6ba9aa5ad3343c0cbac0756d2fbe551659d55e14212"}, - {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:da97c0a501c5da00d9c3af6d481ed97dace148f9030f4f5578534981d58682bb"}, - {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:986f2a23330e8823b389872ae63b83b862a10e63e8cfbd11b42ab8664d1eaa7e"}, - {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ed96992cac94b32a9c3da5870b05967271397a813029d68197939397860b1470"}, - {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:94e767d30be457da0ef134e688cd2b6a438d19b85bf000b1c4a2b9b6f55b4211"}, - {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d1f98df09bc22d298d88dee07eb5365dba1fbf3b41e876a6edb2652a8b3a6ca1"}, - {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c5290038269f7d96037afc47b05b286e4791adfa7046911e2a0483b6220c84fb"}, - {file = "multidict-6.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8858d2ddd0bc21b4aaf189016c95840b29ddf0236a049af70a9cb4379bef9abc"}, - {file = "multidict-6.9.0-cp315-cp315t-win32.whl", hash = "sha256:f90724dade5865b1c9429d9b058f11096e44a7f1f0e0a21eff18d98ef32c22aa"}, - {file = "multidict-6.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:150f1e5bcc14234e99aacc931deafa52713157df947efeecc1a3b7601f2714ab"}, - {file = "multidict-6.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:734c985bbd60e84d0ea523bafe139394e46b8a3f35754a70111dbcbba243f7f4"}, - {file = "multidict-6.9.0-py3-none-any.whl", hash = "sha256:57c2445049f7d8e66306f712868219da7ff7168ef42263dc032401211bf1205c"}, - {file = "multidict-6.9.0.tar.gz", hash = "sha256:d7d32c0543494efbc9394e2b571725071d08e295993486bc9a43f6f89375ee01"}, + {file = "multidict-6.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aef74e9beabbd6c4aafc091dabff86d046ccf013ce1e4396c0fbb01b4cad9de8"}, + {file = "multidict-6.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1ca5ebe6d454f1e5496cf052386a559f1080bf2de75bf327ebca0a6003b79f19"}, + {file = "multidict-6.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02f6d0c4b70f783305e73f9944d8efe6be1022550f0974ba0ae9d8893c0350fa"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8050e75af7e4c6e2d5260b84eeedb618f3e452e66473432e085b5d1b81429299"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4736d350371337825cac1793c9f7c40701c32a03912548c2a7608e51679cbb96"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:02fe09dc197b8ae7e355371e51e5dce2f39060cd5a8badf94904527e23a3f188"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ad4528cdce058b684f75fad1faf4a6a6c992fe2f08376370ca66e4ce5916a84a"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8885e3808aedbd6725b921fb67dacaae0678933561ddd47c2b01315198c70e2e"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3b6e6840421c83ccb60398e333b44f910b0907bb409597685ab2eedd1e22eab"}, + {file = "multidict-6.9.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c9648ed33dc8179e4ec04bbc73bd7f0038e1e81217a69467f61f02a78bf07e88"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d9d6544790ba50438a9c1a903c3c4afb1ec8a7832db5518549275b40ef0dd4b1"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1c9f3c25df6c9d3bbae4f6fd3f514b1c5c740a2110f56ccf36069c85417e289c"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cf22b43f35b7dbb9f71e8ee2041b5c00029cdfc28ede3a2f31cf8906f9a6c126"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed78ccad4c7b421804f5d524b7740946a7ef75d89dc0dac52e9d7c24c472410"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3b1aa25f01238886a6baed9e13b9a9240ed344346c79ae280ae65e8603b21d5"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bc94a68ea5e18f8e85dc6b522bcb53093f692c8eb62b4837ac047da73956cbe4"}, + {file = "multidict-6.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73533644e1f69ea1164d56cc6505f564c6c44072b646b66d67df2d31fed0348c"}, + {file = "multidict-6.9.1-cp310-cp310-win32.whl", hash = "sha256:66987aa68b0f7c2a1cc5f388ca962b8ed92b10f79de38d6d0d8c716154f519d9"}, + {file = "multidict-6.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a32b78c1e52ebd8e247bb68300b90b233300d8816faa008ed0713bc539fb6af0"}, + {file = "multidict-6.9.1-cp310-cp310-win_arm64.whl", hash = "sha256:ab64ace1a68682d191d9bedd9d4c939406ad86b1d9f628180410644249ad46c2"}, + {file = "multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4"}, + {file = "multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987"}, + {file = "multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387"}, + {file = "multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a"}, + {file = "multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13"}, + {file = "multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f"}, + {file = "multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e"}, + {file = "multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d"}, + {file = "multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe"}, + {file = "multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006"}, + {file = "multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d"}, + {file = "multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a"}, + {file = "multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6"}, + {file = "multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb"}, + {file = "multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203"}, + {file = "multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc"}, + {file = "multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3"}, + {file = "multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382"}, + {file = "multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a"}, + {file = "multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40"}, + {file = "multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8"}, + {file = "multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c"}, + {file = "multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771"}, + {file = "multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14"}, + {file = "multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40"}, + {file = "multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d"}, + {file = "multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4"}, + {file = "multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5"}, + {file = "multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9"}, + {file = "multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca"}, + {file = "multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840"}, + {file = "multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800"}, + {file = "multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a"}, + {file = "multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d"}, + {file = "multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194"}, + {file = "multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841"}, + {file = "multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3"}, + {file = "multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9"}, + {file = "multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76"}, + {file = "multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b"}, + {file = "multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45"}, + {file = "multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b"}, + {file = "multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e"}, + {file = "multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd"}, + {file = "multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9"}, + {file = "multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3"}, + {file = "multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1"}, + {file = "multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82"}, + {file = "multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea"}, + {file = "multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c"}, + {file = "multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08"}, + {file = "multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e"}, + {file = "multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4"}, + {file = "multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d"}, + {file = "multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d"}, + {file = "multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f"}, + {file = "multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f"}, + {file = "multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7"}, + {file = "multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68"}, + {file = "multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20"}, + {file = "multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d"}, + {file = "multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1"}, + {file = "multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1"}, + {file = "multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec"}, + {file = "multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3"}, + {file = "multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b"}, + {file = "multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2"}, ] [[package]] @@ -4271,139 +4281,177 @@ et-xmlfile = "*" [[package]] name = "opentelemetry-api" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.10" files = [ - {file = "opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef"}, - {file = "opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a"}, + {file = "opentelemetry_api-1.45.0-py3-none-any.whl", hash = "sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3"}, + {file = "opentelemetry_api-1.45.0.tar.gz", hash = "sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7"}, ] [package.dependencies] typing-extensions = ">=4.5.0" +[[package]] +name = "opentelemetry-exporter-http-transport" +version = "0.66b0" +description = "OpenTelemetry Exporters HTTP transport" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_exporter_http_transport-0.66b0-py3-none-any.whl", hash = "sha256:c82689928a11505a2a0a26a04afe0ce06d270d3db984bc16c1a80ffa902db00e"}, + {file = "opentelemetry_exporter_http_transport-0.66b0.tar.gz", hash = "sha256:2c229b6593eaa22c86d9b8a15843dc23b406dbda00fb138339189aab07923b4e"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.15,<2.0" +urllib3 = {version = ">=1.26", optional = true, markers = "extra == \"urllib3\""} + +[package.extras] +requests = ["requests (>=2.25,<3.0)"] +urllib3 = ["urllib3 (>=1.26)"] + +[[package]] +name = "opentelemetry-exporter-otlp-common" +version = "0.66b0" +description = "OpenTelemetry OTLP HTTP export utilities" +optional = false +python-versions = ">=3.10" +files = [ + {file = "opentelemetry_exporter_otlp_common-0.66b0-py3-none-any.whl", hash = "sha256:35d24c867310f4713a9738b0202b6bade77955418fccb9e2f7eea01b76263916"}, + {file = "opentelemetry_exporter_otlp_common-0.66b0.tar.gz", hash = "sha256:362268ec6aa705e183776ff938539df1e8ce45bc5509d242538b1d40c26fe6a6"}, +] + +[package.dependencies] +opentelemetry-sdk = ">=1.45.0,<1.46.0" + +[package.extras] +http = ["opentelemetry-exporter-http-transport (==0.66b0)"] + [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Protobuf encoding" optional = false python-versions = ">=3.10" files = [ - {file = "opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694"}, - {file = "opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.45.0-py3-none-any.whl", hash = "sha256:7e1410ae6d3ed301f7a74bec96467d519d3f37e52f9d95b309ae125e5a1af863"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.45.0.tar.gz", hash = "sha256:36495115a0c6a7aa946cfda9d59b6ed4e917b6ab0f75cdaf66bc1b176ec1be1f"}, ] [package.dependencies] -opentelemetry-proto = "1.44.0" +opentelemetry-proto = "1.45.0" [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Collector Protobuf over HTTP Exporter" optional = false python-versions = ">=3.10" files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.45.0-py3-none-any.whl", hash = "sha256:74385f99266dafdefff41f3da4b76f6dba79d237a9d4487e6ff1ee653c74a0a3"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.45.0.tar.gz", hash = "sha256:2f35496d96809f946f41b8805e6b93aec6c9b71b5fd759b75b6af4c084d992ae"}, ] [package.dependencies] googleapis-common-protos = ">=1.52,<2.0" opentelemetry-api = ">=1.15,<2.0" -opentelemetry-exporter-otlp-proto-common = "1.44.0" -opentelemetry-proto = "1.44.0" -opentelemetry-sdk = ">=1.44.0,<1.45.0" -requests = ">=2.7,<3.0" +opentelemetry-exporter-http-transport = {version = "0.66b0", extras = ["urllib3"]} +opentelemetry-exporter-otlp-common = "0.66b0" +opentelemetry-exporter-otlp-proto-common = "1.45.0" +opentelemetry-proto = "1.45.0" +opentelemetry-sdk = ">=1.45.0,<1.46.0" typing-extensions = ">=4.5.0" [package.extras] gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"] +requests = ["opentelemetry-exporter-http-transport[requests] (==0.66b0)", "requests (>=2.7,<3.0)"] [[package]] name = "opentelemetry-exporter-prometheus" -version = "0.65b0" +version = "0.66b0" description = "Prometheus Metric Exporter for OpenTelemetry" optional = false python-versions = ">=3.10" files = [ - {file = "opentelemetry_exporter_prometheus-0.65b0-py3-none-any.whl", hash = "sha256:3b3d24b586d0ad9712c7b52b7d19c8a9dfbb318b9b284121b5f95e90ed019367"}, - {file = "opentelemetry_exporter_prometheus-0.65b0.tar.gz", hash = "sha256:2777cbf41c403c119e10f418fce5d645c956b47f673a2ee120285d1d0c6df2d5"}, + {file = "opentelemetry_exporter_prometheus-0.66b0-py3-none-any.whl", hash = "sha256:12448682ee2824fef00bd290568297bb5bebd5fd8403fd7d70b3e567cf8b2a93"}, + {file = "opentelemetry_exporter_prometheus-0.66b0.tar.gz", hash = "sha256:941434dd88e7c0fb8c1ec0d4e5c1b62c6206bb47b8b35f287cbac56fea82bd40"}, ] [package.dependencies] opentelemetry-api = ">=1.12,<2.0" -opentelemetry-sdk = ">=1.44.0,<1.45.0" +opentelemetry-sdk = ">=1.45.0,<1.46.0" prometheus-client = ">=0.5.0,<1.0.0" [[package]] name = "opentelemetry-instrumentation" -version = "0.65b0" +version = "0.66b0" description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" optional = false python-versions = ">=3.10" files = [ - {file = "opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137"}, - {file = "opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b"}, + {file = "opentelemetry_instrumentation-0.66b0-py3-none-any.whl", hash = "sha256:0b32e2fcfec1e4bd48511a3b77ce32bfb1c16dcd5479396b21f9086a4b06c0c9"}, + {file = "opentelemetry_instrumentation-0.66b0.tar.gz", hash = "sha256:316715ed7b33a12530c0ae3e8d4cdf26d70e1aad358d7ba256e5dea2d4ceed71"}, ] [package.dependencies] opentelemetry-api = ">=1.4,<2.0" -opentelemetry-semantic-conventions = "0.65b0" +opentelemetry-semantic-conventions = "0.66b0" packaging = ">=18.0" wrapt = ">=1.0.0,<3.0.0" [[package]] name = "opentelemetry-instrumentation-asgi" -version = "0.65b0" +version = "0.66b0" description = "ASGI instrumentation for OpenTelemetry" optional = false python-versions = ">=3.10" files = [ - {file = "opentelemetry_instrumentation_asgi-0.65b0-py3-none-any.whl", hash = "sha256:3a845a8ebd1c4ef0d8263401e6545f5b219b2feee612090d50f578a87e71fd65"}, - {file = "opentelemetry_instrumentation_asgi-0.65b0.tar.gz", hash = "sha256:892bca67c56522ffa85a8a83cf934d7b50b3be2132e45cbee705825f0a5ba426"}, + {file = "opentelemetry_instrumentation_asgi-0.66b0-py3-none-any.whl", hash = "sha256:8c31fe67b2aef6f7dc3df9c0434ebd0cacd6d8545da703a55a6f160f9bc06fc5"}, + {file = "opentelemetry_instrumentation_asgi-0.66b0.tar.gz", hash = "sha256:ab3f9c86aaddbf9c332534e0fce6c918cbacdab7a33951825a9ebba6b2fb7200"}, ] [package.dependencies] asgiref = ">=3.0,<4.0" opentelemetry-api = ">=1.12,<2.0" -opentelemetry-instrumentation = "0.65b0" -opentelemetry-semantic-conventions = "0.65b0" -opentelemetry-util-http = "0.65b0" +opentelemetry-instrumentation = "0.66b0" +opentelemetry-semantic-conventions = "0.66b0" +opentelemetry-util-http = "0.66b0" [package.extras] instruments = ["asgiref (>=3.0,<4.0)"] [[package]] name = "opentelemetry-instrumentation-fastapi" -version = "0.65b0" +version = "0.66b0" description = "OpenTelemetry FastAPI Instrumentation" optional = false python-versions = ">=3.10" files = [ - {file = "opentelemetry_instrumentation_fastapi-0.65b0-py3-none-any.whl", hash = "sha256:cda2610a0ec1b22d19886f33e4d861e9f5dbb886aeaa3a1263b47aff82c36943"}, - {file = "opentelemetry_instrumentation_fastapi-0.65b0.tar.gz", hash = "sha256:10a3a95486036230413a58fe4fdf4a83fa6bba46918407e527476994bd92bd97"}, + {file = "opentelemetry_instrumentation_fastapi-0.66b0-py3-none-any.whl", hash = "sha256:ad9543fc5a87b511d487aa94625a97299ccd818611cb0c979b46985ba782f1cc"}, + {file = "opentelemetry_instrumentation_fastapi-0.66b0.tar.gz", hash = "sha256:3d49e678d0957a782d0ca32ad21139cdfabe8584d82c018dc9faf6bfc5a72701"}, ] [package.dependencies] opentelemetry-api = ">=1.12,<2.0" -opentelemetry-instrumentation = "0.65b0" -opentelemetry-instrumentation-asgi = "0.65b0" -opentelemetry-semantic-conventions = "0.65b0" -opentelemetry-util-http = "0.65b0" +opentelemetry-instrumentation = "0.66b0" +opentelemetry-instrumentation-asgi = "0.66b0" +opentelemetry-semantic-conventions = "0.66b0" +opentelemetry-util-http = "0.66b0" [package.extras] instruments = ["fastapi (>=0.92,<1.0)"] [[package]] name = "opentelemetry-proto" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Python Proto" optional = false python-versions = ">=3.10" files = [ - {file = "opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56"}, - {file = "opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3"}, + {file = "opentelemetry_proto-1.45.0-py3-none-any.whl", hash = "sha256:9731566359d7b8e1ee1e149d8e4ad6865bab965e657ad3387ec2784d16927666"}, + {file = "opentelemetry_proto-1.45.0.tar.gz", hash = "sha256:96ee414f24bc3f61ea8e17dc56b4348d4049d73db3eb17c6b3edf75b5b403300"}, ] [package.dependencies] @@ -4411,47 +4459,47 @@ protobuf = ">=5.0,<8.0" [[package]] name = "opentelemetry-sdk" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Python SDK" optional = false python-versions = ">=3.10" files = [ - {file = "opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad"}, - {file = "opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b"}, + {file = "opentelemetry_sdk-1.45.0-py3-none-any.whl", hash = "sha256:5dc634c946546f61b757c5b1781f9fd1a10e96ffaf7357c797e508fe9c57e75e"}, + {file = "opentelemetry_sdk-1.45.0.tar.gz", hash = "sha256:20caa5130505e386c67c3da1c76e446c842698ced54c76c6148679539aa97972"}, ] [package.dependencies] -opentelemetry-api = "1.44.0" -opentelemetry-semantic-conventions = "0.65b0" +opentelemetry-api = "1.45.0" +opentelemetry-semantic-conventions = "0.66b0" typing-extensions = ">=4.5.0" [package.extras] -file-configuration = ["opentelemetry-configuration (==0.65b0)"] +file-configuration = ["opentelemetry-configuration (==0.66b0)"] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.65b0" +version = "0.66b0" description = "OpenTelemetry Semantic Conventions" optional = false python-versions = ">=3.10" files = [ - {file = "opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb"}, - {file = "opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60"}, + {file = "opentelemetry_semantic_conventions-0.66b0-py3-none-any.whl", hash = "sha256:175b19dd98c4473f4f43a2b1df59186fd7b4a48cd3f77cbe03438b6d6fda230a"}, + {file = "opentelemetry_semantic_conventions-0.66b0.tar.gz", hash = "sha256:97a77dce484c54861e7eeff7651fd8a806dd3c30e501dc316730215ec36890e6"}, ] [package.dependencies] -opentelemetry-api = "1.44.0" +opentelemetry-api = "1.45.0" typing-extensions = ">=4.5.0" [[package]] name = "opentelemetry-util-http" -version = "0.65b0" +version = "0.66b0" description = "Web util for OpenTelemetry" optional = false python-versions = ">=3.10" files = [ - {file = "opentelemetry_util_http-0.65b0-py3-none-any.whl", hash = "sha256:7553b606f963097cb190536dc30556cce85090692e471a422fff30ca29b04348"}, - {file = "opentelemetry_util_http-0.65b0.tar.gz", hash = "sha256:84f82d826978bba416ab453460ff6a7391cdc3534c93a786595e4068680016b7"}, + {file = "opentelemetry_util_http-0.66b0-py3-none-any.whl", hash = "sha256:7f5e581e94f023b30995f700e41ce319cec82bd542ff1c1c7c9b56d6b10bb687"}, + {file = "opentelemetry_util_http-0.66b0.tar.gz", hash = "sha256:a2d53859630abe8e5abcc4f867c5a809bb95873033573b1a67f6ef43b57a8627"}, ] [[package]] @@ -4833,13 +4881,13 @@ xmp = ["defusedxml"] [[package]] name = "platformdirs" -version = "4.11.11" +version = "4.11.15" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" files = [ - {file = "platformdirs-4.11.11-py3-none-any.whl", hash = "sha256:972ea6b2b387155a536226a0750e46923d731d459a387c4a255c583ed2f64547"}, - {file = "platformdirs-4.11.11.tar.gz", hash = "sha256:b0befe8a90759e4a9a8b9820d434ae226a6549063210b596da0038a7a05aede4"}, + {file = "platformdirs-4.11.15-py3-none-any.whl", hash = "sha256:84508421c77d3c6462e71dd8c0d2776c2ace0e94eb006dc4fd383bc1dcf5a176"}, + {file = "platformdirs-4.11.15.tar.gz", hash = "sha256:d7419e973b2b740d428200130f80c0e79304d1c71081001db911e15b26a3a6d4"}, ] [[package]] @@ -5182,13 +5230,13 @@ files = [ [[package]] name = "psycopg-pool" -version = "3.3.2" +version = "3.3.3" description = "Connection Pool for Psycopg" optional = false python-versions = ">=3.10" files = [ - {file = "psycopg_pool-3.3.2-py3-none-any.whl", hash = "sha256:96971f245eee54f1632d8eaae0de5bb6e9c624c1effdd42d8d6843e534f56e07"}, - {file = "psycopg_pool-3.3.2.tar.gz", hash = "sha256:0c70903c7f50cd7173ef0f6364bb7c9271fa3810f5746e058449a77afe579f49"}, + {file = "psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37"}, + {file = "psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d"}, ] [package.dependencies] @@ -5590,13 +5638,13 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.14.0" +version = "2.15.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" files = [ - {file = "pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc"}, - {file = "pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86"}, + {file = "pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8"}, + {file = "pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4"}, ] [package.dependencies] @@ -5820,13 +5868,13 @@ XlsxWriter = ">=0.5.7" [[package]] name = "pytz" -version = "2026.3.post1" +version = "2026.4" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815"}, - {file = "pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d"}, + {file = "pytz-2026.4-py2.py3-none-any.whl", hash = "sha256:9d514388fbc89ca0833203464272ac485b8828568ab73532f5020f17e892a0ff"}, + {file = "pytz-2026.4.tar.gz", hash = "sha256:464303645bafafd72418898368b2429458f709cf1eb6a15372fbcc396b64da63"}, ] [[package]] @@ -6503,29 +6551,29 @@ files = [ [[package]] name = "ruff" -version = "0.16.8" +version = "0.16.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38"}, - {file = "ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e"}, - {file = "ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773"}, - {file = "ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f"}, - {file = "ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa"}, - {file = "ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a"}, - {file = "ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b"}, + {file = "ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96"}, + {file = "ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67"}, + {file = "ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588"}, + {file = "ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385"}, + {file = "ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b"}, + {file = "ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284"}, + {file = "ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161"}, ] [[package]] @@ -6562,13 +6610,13 @@ jeepney = ">=0.6" [[package]] name = "sentry-sdk" -version = "2.69.2" +version = "2.70.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" files = [ - {file = "sentry_sdk-2.69.2-py3-none-any.whl", hash = "sha256:25bc0a6c55bba0b83398212a7247f4f9f3190f189a1909b68e4807ef4b785895"}, - {file = "sentry_sdk-2.69.2.tar.gz", hash = "sha256:b4d8915a526e626b0b14a2925907554b722d3b8e3e3781570fc35791712af323"}, + {file = "sentry_sdk-2.70.0-py3-none-any.whl", hash = "sha256:89600e8bb5fc5239cad5c4434217f453204f2207ad29399ea58947460b37a62e"}, + {file = "sentry_sdk-2.70.0.tar.gz", hash = "sha256:27e4b512f47be93136645dbe8f02d430473ee73a389ce430b2b7104baa66cf75"}, ] [package.dependencies] @@ -6669,13 +6717,13 @@ files = [ [[package]] name = "soupsieve" -version = "2.9.2" +version = "2.10" description = "A modern CSS selector implementation for Beautiful Soup." optional = false python-versions = ">=3.10" files = [ - {file = "soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823"}, - {file = "soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74"}, + {file = "soupsieve-2.10-py3-none-any.whl", hash = "sha256:8596eb8967d744174820280fa62b4542a2e955bfaccca73ed8a13c6eb8e9b502"}, + {file = "soupsieve-2.10.tar.gz", hash = "sha256:49e9380d7d2905463583bafe285e818c7366a9ed7b3aee221c1ac79c905d8bc0"}, ] [[package]] @@ -6798,21 +6846,21 @@ uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "1.6.0" +version = "1.7.0" description = "The little ASGI library that shines." optional = false python-versions = ">=3.10" files = [ - {file = "starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c"}, - {file = "starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b"}, + {file = "starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e"}, + {file = "starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d"}, ] [package.dependencies] -anyio = ">=3.6.2,<5" +anyio = ">=4.0.0,<5" typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "opentelemetry-api", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "sympy" @@ -7386,13 +7434,13 @@ zstd = ["backports-zstd (>=1.0.0)"] [[package]] name = "uvicorn" -version = "0.53.0" +version = "0.54.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.10" files = [ - {file = "uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e"}, - {file = "uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e"}, + {file = "uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf"}, + {file = "uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620"}, ] [package.dependencies] @@ -7991,4 +8039,4 @@ type = ["pytest-mypy (>=1.0.1)"] [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.14" -content-hash = "3b7642a471af522321225111c7772c667121dcee7cc77e848c02299da8e8dc0d" +content-hash = "842ece45c1b8291c55302a8505f8e02b8cecd9ec3c94ecdae4795c789a6e67f0" diff --git a/pyproject.toml b/pyproject.toml index 328d5c6..48ccf59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ beautifulsoup4 = ">=4.12.0" markdownify = ">=0.15.0" ddgs = ">=8.0.0" pg0-embedded = ">=0.15" -hindsight-api-slim = ">=0.10" +hindsight-api-slim = ">=0.10.1" [tool.poetry.group.dev.dependencies] pytest = ">=8.0.0" From 9b2b6c1911afcd32b51c42de8a5f74ebaad26852 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Sat, 26 Sep 2026 16:08:56 +0200 Subject: [PATCH 14/39] cost --- CHANGELOG.md | 7 ++ poetry.lock | 105 +++++++++--------- pyproject.toml | 2 +- src/codespy/agents/dspy_config.py | 11 -- src/codespy/tools/cyber/osv/server.py | 8 +- src/codespy/tools/git/server.py | 8 +- src/codespy/tools/mcp_utils.py | 30 ++--- src/codespy/tools/parsers/ripgrep/server.py | 8 +- .../tools/parsers/treesitter/server.py | 8 +- .../tools/storage/filesystem/server.py | 8 +- src/codespy/tools/storage/s3/server.py | 8 +- src/codespy/tools/web/server.py | 8 +- src/codespy/workflows/review/server.py | 8 +- 13 files changed, 91 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abef75..c07432c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Changed +- **Dependencies**: MCP Python SDK upgraded from v1.x to v2.x + - `mcp` dependency: `>=1.29.0,<2.0.0` → `>=2.2,<3` + - All 8 MCP servers migrated from `FastMCP` to `MCPServer` (v2 API) + - `mcp_utils.py` updated to use new `Client` class with `StdioServerParameters` + - Removed dead log suppression code for "Processing request of type" messages (no longer present in v2) + ## [1.2.4] - 2026-09-18 ### Changed diff --git a/poetry.lock b/poetry.lock index a6bc281..48f4fac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1668,36 +1668,37 @@ zstandard = ["backports.zstd"] [[package]] name = "fastmcp" -version = "3.4.7" +version = "4.0.10" description = "The fast, Pythonic way to build MCP servers and clients." optional = false python-versions = ">=3.10" files = [ - {file = "fastmcp-3.4.7-py3-none-any.whl", hash = "sha256:e4e7698cb4af5bc667b1901685261fa2f3526dc73d243a461fca42500c8dbe56"}, - {file = "fastmcp-3.4.7.tar.gz", hash = "sha256:43117aca886f5ee2f6a569bba91cef02b59c339aad04ba29950ff18d251c822a"}, + {file = "fastmcp-4.0.10-py3-none-any.whl", hash = "sha256:1f8462e3d97394a637e1b0f1eaea0002da661c8d98000ad43f0aa3fa1ff523e8"}, + {file = "fastmcp-4.0.10.tar.gz", hash = "sha256:64539631a7dc5873a1c7a44089b2f7b3395dea3ca857e9f81bfe9665a59038d5"}, ] [package.dependencies] -fastmcp-slim = {version = "3.4.7", extras = ["client", "server"]} +fastmcp-slim = {version = "4.0.10", extras = ["client", "server"]} [package.extras] -anthropic = ["fastmcp-slim[anthropic] (==3.4.7)"] -apps = ["fastmcp-slim[apps] (==3.4.7)"] -azure = ["fastmcp-slim[azure] (==3.4.7)"] -code-mode = ["fastmcp-slim[code-mode] (==3.4.7)"] -gemini = ["fastmcp-slim[gemini] (==3.4.7)"] -openai = ["fastmcp-slim[openai] (==3.4.7)"] -tasks = ["fastmcp-slim[tasks] (==3.4.7)"] +anthropic = ["fastmcp-slim[anthropic] (==4.0.10)"] +apps = ["fastmcp-slim[apps] (==4.0.10)"] +azure = ["fastmcp-slim[azure] (==4.0.10)"] +code-mode = ["fastmcp-slim[code-mode] (==4.0.10)"] +gemini = ["fastmcp-slim[gemini] (==4.0.10)"] +jev = ["fastmcp-slim[jev] (==4.0.10)"] +openai = ["fastmcp-slim[openai] (==4.0.10)"] +tasks = ["fastmcp-tasks (==4.0.10)"] [[package]] name = "fastmcp-slim" -version = "3.4.7" +version = "4.0.10" description = "The dependency-slim FastMCP package." optional = false python-versions = ">=3.10" files = [ - {file = "fastmcp_slim-3.4.7-py3-none-any.whl", hash = "sha256:6c931a0089705f3f2935428ef9b2bc74ad94140adc64aab84d116d103e694b3a"}, - {file = "fastmcp_slim-3.4.7.tar.gz", hash = "sha256:06b32a358320a7dc2b2ee040ba89ea55ddc20763dff2949f384f7974b13b5d8f"}, + {file = "fastmcp_slim-4.0.10-py3-none-any.whl", hash = "sha256:c2abd40302b8f06b0291413cf537d57bc0f171b5fd98af5cfef3b2b6b506122f"}, + {file = "fastmcp_slim-4.0.10.tar.gz", hash = "sha256:d22b423f93ad945bf65d446a1689ff84be7b91dfd9b6f23c5013f4b2fe9776a7"}, ] [package.dependencies] @@ -1705,17 +1706,18 @@ authlib = {version = ">=1.6.11", optional = true, markers = "extra == \"client\" cyclopts = {version = ">=4.0.0", optional = true, markers = "extra == \"server\""} exceptiongroup = {version = ">=1.2.2", optional = true, markers = "extra == \"client\" or extra == \"server\""} griffelib = {version = ">=2.0.0", optional = true, markers = "extra == \"server\""} -httpx = {version = ">=0.28.1,<1.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} -joserfc = {version = ">=1.1.0", optional = true, markers = "extra == \"server\""} +httpx2 = {version = ">=2.5.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +joserfc = {version = ">=1.5.0", optional = true, markers = "extra == \"server\""} jsonref = {version = ">=1.1.0", optional = true, markers = "extra == \"server\""} jsonschema-path = {version = ">=0.3.4", optional = true, markers = "extra == \"server\""} -mcp = {version = ">=1.24.0,<2.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +mcp = {version = ">=2.0.0,<3.0.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +mcp-types = ">=2.0.0,<3.0.0" openapi-pydantic = {version = ">=0.5.1", optional = true, markers = "extra == \"server\""} -opentelemetry-api = {version = ">=1.20.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +opentelemetry-api = {version = ">=1.28.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} packaging = {version = ">=24.0", optional = true, markers = "extra == \"server\""} platformdirs = ">=4.0.0" py-key-value-aio = {version = ">=0.4.4,<0.5.0", extras = ["filetree", "keyring", "memory"], optional = true, markers = "extra == \"client\" or extra == \"server\""} -pydantic = {version = ">=2.11.7", extras = ["email"]} +pydantic = {version = ">=2.12.0", extras = ["email"]} pydantic-settings = ">=2.0.0" pyperclip = {version = ">=1.9.0", optional = true, markers = "extra == \"server\""} python-dotenv = ">=1.1.0" @@ -1724,22 +1726,22 @@ pyyaml = {version = ">=6.0,<7.0", optional = true, markers = "extra == \"server\ rich = ">=13.9.4" starlette = {version = ">=1.0.1", optional = true, markers = "extra == \"client\" or extra == \"server\""} typing-extensions = ">=4.0.0" -uncalled-for = {version = ">=0.2.0", optional = true, markers = "extra == \"server\""} +uncalled-for = {version = ">=0.4.0", optional = true, markers = "extra == \"server\""} uvicorn = {version = ">=0.35", optional = true, markers = "extra == \"server\""} watchfiles = {version = ">=1.0.0", optional = true, markers = "extra == \"server\""} websockets = {version = ">=15.0.1", optional = true, markers = "extra == \"server\""} [package.extras] -anthropic = ["anthropic (>=0.48.0)"] +anthropic = ["anthropic (>=0.48.0,<2)"] apps = ["prefab-ui (>=0.18.0)"] azure = ["azure-identity (>=1.16.0)", "pyjwt (>=2.12.0)"] -client = ["authlib (>=1.6.11)", "exceptiongroup (>=1.2.2)", "httpx (>=0.28.1,<1.0)", "mcp (>=1.24.0,<2.0)", "opentelemetry-api (>=1.20.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "starlette (>=1.0.1)"] -code-mode = ["pydantic-monty (==0.0.17)"] +client = ["authlib (>=1.6.11)", "exceptiongroup (>=1.2.2)", "httpx2 (>=2.5.0)", "mcp (>=2.0.0,<3.0.0)", "opentelemetry-api (>=1.28.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "starlette (>=1.0.1)"] +code-mode = ["pydantic-monty (==0.0.21)"] gemini = ["google-genai (>=1.18.0)", "jsonref (>=1.1.0)"] -mcp = ["exceptiongroup (>=1.2.2)", "httpx (>=0.28.1,<1.0)", "mcp (>=1.24.0,<2.0)", "opentelemetry-api (>=1.20.0)", "starlette (>=1.0.1)"] +jev = ["typesafe-sdk (>=0.5.7)"] +mcp = ["exceptiongroup (>=1.2.2)", "httpx2 (>=2.5.0)", "mcp (>=2.0.0,<3.0.0)", "opentelemetry-api (>=1.28.0)", "starlette (>=1.0.1)"] openai = ["openai (>=1.102.0)"] -server = ["authlib (>=1.6.11)", "cyclopts (>=4.0.0)", "exceptiongroup (>=1.2.2)", "griffelib (>=2.0.0)", "httpx (>=0.28.1,<1.0)", "joserfc (>=1.1.0)", "jsonref (>=1.1.0)", "jsonschema-path (>=0.3.4)", "mcp (>=1.24.0,<2.0)", "openapi-pydantic (>=0.5.1)", "opentelemetry-api (>=1.20.0)", "packaging (>=24.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "pyperclip (>=1.9.0)", "python-multipart (>=0.0.26)", "pyyaml (>=6.0,<7.0)", "starlette (>=1.0.1)", "uncalled-for (>=0.2.0)", "uvicorn (>=0.35)", "watchfiles (>=1.0.0)", "websockets (>=15.0.1)"] -tasks = ["pydocket (>=0.20.0)"] +server = ["authlib (>=1.6.11)", "cyclopts (>=4.0.0)", "exceptiongroup (>=1.2.2)", "griffelib (>=2.0.0)", "httpx2 (>=2.5.0)", "joserfc (>=1.5.0)", "jsonref (>=1.1.0)", "jsonschema-path (>=0.3.4)", "mcp (>=2.0.0,<3.0.0)", "openapi-pydantic (>=0.5.1)", "opentelemetry-api (>=1.28.0)", "packaging (>=24.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "pyperclip (>=1.9.0)", "python-multipart (>=0.0.26)", "pyyaml (>=6.0,<7.0)", "starlette (>=1.0.1)", "uncalled-for (>=0.4.0)", "uvicorn (>=0.35)", "watchfiles (>=1.0.0)", "websockets (>=15.0.1)"] [[package]] name = "fastuuid" @@ -2494,17 +2496,6 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] -[[package]] -name = "httpx-sse" -version = "0.4.3" -description = "Consume Server-Sent Event (SSE) messages with HTTPX." -optional = false -python-versions = ">=3.9" -files = [ - {file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"}, - {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, -] - [[package]] name = "httpx2" version = "2.13.1" @@ -3661,35 +3652,49 @@ files = [ [[package]] name = "mcp" -version = "1.30.0" +version = "2.2.0" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" files = [ - {file = "mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0"}, - {file = "mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4"}, + {file = "mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81"}, + {file = "mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd"}, ] [package.dependencies] -anyio = ">=4.5" -httpx = ">=0.27.1,<1.0.0" -httpx-sse = ">=0.4" +anyio = {version = ">=4.9", markers = "python_version < \"3.14\""} +httpx2 = ">=2.5.0" jsonschema = ">=4.20.0" -pydantic = {version = ">=2.11.0,<3.0.0", markers = "python_version < \"3.14\""} -pydantic-settings = ">=2.5.2" +mcp-types = "2.2.0" +opentelemetry-api = ">=1.28.0" +pydantic = ">=2.12.0" pyjwt = {version = ">=2.10.1", extras = ["crypto"]} python-multipart = ">=0.0.9" -pywin32 = {version = ">=310", markers = "sys_platform == \"win32\" and python_version < \"3.14\""} -sse-starlette = ">=1.6.1" +pywin32 = {version = ">=311", markers = "sys_platform == \"win32\""} +sse-starlette = ">=3.0.0" starlette = {version = ">=0.27", markers = "python_version < \"3.14\""} -typing-extensions = ">=4.9.0" +typing-extensions = ">=4.13.0" typing-inspection = ">=0.4.1" uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} [package.extras] cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] rich = ["rich (>=13.9.4)"] -ws = ["websockets (>=15.0.1)"] + +[[package]] +name = "mcp-types" +version = "2.2.0" +description = "Model Context Protocol wire types" +optional = false +python-versions = ">=3.10" +files = [ + {file = "mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13"}, + {file = "mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad"}, +] + +[package.dependencies] +pydantic = ">=2.12.0" +typing-extensions = ">=4.13.0" [[package]] name = "mdurl" @@ -8039,4 +8044,4 @@ type = ["pytest-mypy (>=1.0.1)"] [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.14" -content-hash = "842ece45c1b8291c55302a8505f8e02b8cecd9ec3c94ecdae4795c789a6e67f0" +content-hash = "3361922bf6a698b1b3c447b0e207645a37b9ef6cff0983b7f324b9c421e8357c" diff --git a/pyproject.toml b/pyproject.toml index 48ccf59..44be1dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ tree-sitter-kotlin = ">=1.0" tree-sitter-objc = ">=3.0" tree-sitter-rust = ">=0.23" tree-sitter-hcl = ">=1.2.0" -mcp = ">=1.29.0,<2.0.0" +mcp = ">=2.2,<3" psycopg = {version = ">=3.1", extras = ["binary", "pool"]} beautifulsoup4 = ">=4.12.0" markdownify = ">=0.15.0" diff --git a/src/codespy/agents/dspy_config.py b/src/codespy/agents/dspy_config.py index 4a8c658..9552089 100644 --- a/src/codespy/agents/dspy_config.py +++ b/src/codespy/agents/dspy_config.py @@ -281,16 +281,5 @@ def filter(self, record: logging.LogRecord) -> bool: return not ("Task was destroyed" in msg and "LoggingWorker" in msg) -class _MCPRequestFilter(logging.Filter): - """Filter to suppress all noisy 'Processing request of type' MCP server messages.""" - - def filter(self, record: logging.LogRecord) -> bool: - return "Processing request of type" not in record.getMessage() - - # Suppress LiteLLM's async logging worker warnings that occur during multi-threaded execution logging.getLogger("asyncio").addFilter(_TaskDestroyedFilter()) - -# Suppress noisy MCP server "Processing request" messages -logging.getLogger("mcp.server").addFilter(_MCPRequestFilter()) -logging.getLogger("mcp.server.lowlevel").addFilter(_MCPRequestFilter()) diff --git a/src/codespy/tools/cyber/osv/server.py b/src/codespy/tools/cyber/osv/server.py index f37dd4c..1caea0f 100644 --- a/src/codespy/tools/cyber/osv/server.py +++ b/src/codespy/tools/cyber/osv/server.py @@ -5,14 +5,14 @@ from functools import lru_cache from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer from codespy.tools.cyber.osv.client import OSVClient logger = logging.getLogger(__name__) _caller_module = os.environ.get("MCP_CALLER_MODULE", "unknown") -mcp = FastMCP("osv") +mcp = MCPServer("osv") _client: OSVClient | None = None @@ -225,9 +225,5 @@ def scan_cargo_package(name: str, version: str) -> dict[str, Any]: if __name__ == "__main__": - # Suppress noisy MCP server "Processing request" logs - logging.getLogger("mcp.server").setLevel(logging.WARNING) - logging.getLogger("mcp.server.lowlevel").setLevel(logging.WARNING) - _client = OSVClient() mcp.run() diff --git a/src/codespy/tools/git/server.py b/src/codespy/tools/git/server.py index 5194c84..24de8ae 100644 --- a/src/codespy/tools/git/server.py +++ b/src/codespy/tools/git/server.py @@ -3,7 +3,7 @@ import logging import os -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer from codespy.config import Settings from codespy.tools.git.base import GitClient @@ -14,7 +14,7 @@ # Get caller module from environment (set by mcp_utils.py) _caller_module = os.environ.get("MCP_CALLER_MODULE", "unknown") -mcp = FastMCP("git") +mcp = MCPServer("git") _settings: Settings | None = None @@ -155,10 +155,6 @@ def detect_git_platform(url: str) -> dict: if __name__ == "__main__": - # Suppress noisy MCP server "Processing request" logs - logging.getLogger("mcp.server").setLevel(logging.WARNING) - logging.getLogger("mcp.server.lowlevel").setLevel(logging.WARNING) - # Initialize with settings from environment _settings = Settings() mcp.run() diff --git a/src/codespy/tools/mcp_utils.py b/src/codespy/tools/mcp_utils.py index e075d4d..93fb2b2 100644 --- a/src/codespy/tools/mcp_utils.py +++ b/src/codespy/tools/mcp_utils.py @@ -7,8 +7,7 @@ from typing import Any import dspy # type: ignore[import-untyped] -from mcp import ClientSession, StdioServerParameters # type: ignore[import-not-found] -from mcp.client.stdio import stdio_client # type: ignore[import-not-found] +from mcp import Client, StdioServerParameters logger = logging.getLogger(__name__) @@ -24,7 +23,7 @@ async def connect_mcp_server( Args: mcp_path: Path to the MCP server Python module args: Additional command-line arguments for the server - contexts: List to append context managers for cleanup (transport, session) + contexts: List to append context managers for cleanup (client) caller_module: Name of the calling module for logging (e.g., 'supply_chain_auditor') Returns: @@ -39,23 +38,18 @@ async def connect_mcp_server( env = os.environ.copy() env["MCP_CALLER_MODULE"] = caller_module - params = StdioServerParameters( - command=sys.executable, - args=[str(mcp_path)] + args, - env=env, + client = Client( + StdioServerParameters( + command=sys.executable, + args=[str(mcp_path)] + args, + env=env, + ) ) + await client.__aenter__() + contexts.append(client) - transport = stdio_client(params) - streams = await transport.__aenter__() - contexts.append(transport) - - session = ClientSession(*streams) - await session.__aenter__() - contexts.append(session) - await session.initialize() - - tools_response = await session.list_tools() - return [dspy.Tool.from_mcp_tool(session, tool) for tool in tools_response.tools] + tools_response = await client.list_tools() + return [dspy.Tool.from_mcp_tool(client, tool) for tool in tools_response.tools] async def cleanup_mcp_contexts(contexts: list[Any]) -> None: diff --git a/src/codespy/tools/parsers/ripgrep/server.py b/src/codespy/tools/parsers/ripgrep/server.py index a7a53ed..1815764 100644 --- a/src/codespy/tools/parsers/ripgrep/server.py +++ b/src/codespy/tools/parsers/ripgrep/server.py @@ -6,14 +6,14 @@ from dataclasses import asdict from functools import lru_cache -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer from codespy.tools.parsers.ripgrep.client import RipgrepSearch logger = logging.getLogger(__name__) _caller_module = os.environ.get("MCP_CALLER_MODULE", "unknown") -mcp = FastMCP("ripgrep") +mcp = MCPServer("ripgrep") _search: RipgrepSearch | None = None @@ -184,10 +184,6 @@ def search_literal( if __name__ == "__main__": - # Suppress noisy MCP server "Processing request" logs - logging.getLogger("mcp.server").setLevel(logging.WARNING) - logging.getLogger("mcp.server.lowlevel").setLevel(logging.WARNING) - repo_path = sys.argv[1] if len(sys.argv) > 1 else "." _search = RipgrepSearch(repo_path) mcp.run() diff --git a/src/codespy/tools/parsers/treesitter/server.py b/src/codespy/tools/parsers/treesitter/server.py index 773c1dc..fd4a6ef 100644 --- a/src/codespy/tools/parsers/treesitter/server.py +++ b/src/codespy/tools/parsers/treesitter/server.py @@ -8,14 +8,14 @@ from pathlib import Path from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer from codespy.tools.parsers.treesitter.parser import TreeSitterParser logger = logging.getLogger(__name__) _caller_module = os.environ.get("MCP_CALLER_MODULE", "unknown") -mcp = FastMCP("treesitter") +mcp = MCPServer("treesitter") _parser: TreeSitterParser | None = None @@ -342,10 +342,6 @@ def get_terraform_summary(file_path: str, content: str | None = None) -> dict[st if __name__ == "__main__": - # Suppress noisy MCP server "Processing request" logs - logging.getLogger("mcp.server").setLevel(logging.WARNING) - logging.getLogger("mcp.server.lowlevel").setLevel(logging.WARNING) - repo_path = sys.argv[1] if len(sys.argv) > 1 else "." _parser = TreeSitterParser(Path(repo_path)) mcp.run() diff --git a/src/codespy/tools/storage/filesystem/server.py b/src/codespy/tools/storage/filesystem/server.py index 295cda4..108b158 100644 --- a/src/codespy/tools/storage/filesystem/server.py +++ b/src/codespy/tools/storage/filesystem/server.py @@ -6,7 +6,7 @@ from collections import OrderedDict from functools import lru_cache -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer from codespy.tools.storage.filesystem.client import FileSystem @@ -15,7 +15,7 @@ # Get caller module from environment (set by mcp_utils.py) _caller_module = os.environ.get("MCP_CALLER_MODULE", "unknown") -mcp = FastMCP("filesystem") +mcp = MCPServer("filesystem") _fs: FileSystem | None = None # Manual cache for read_file to skip caching error results @@ -157,10 +157,6 @@ def get_file_info(path: str = "") -> dict: if __name__ == "__main__": - # Suppress noisy MCP server "Processing request" logs - logging.getLogger("mcp.server").setLevel(logging.WARNING) - logging.getLogger("mcp.server.lowlevel").setLevel(logging.WARNING) - root = sys.argv[1] if len(sys.argv) > 1 else "." _fs = FileSystem(root) mcp.run() diff --git a/src/codespy/tools/storage/s3/server.py b/src/codespy/tools/storage/s3/server.py index dc71e47..c393005 100644 --- a/src/codespy/tools/storage/s3/server.py +++ b/src/codespy/tools/storage/s3/server.py @@ -6,7 +6,7 @@ from collections import OrderedDict from functools import lru_cache -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer from codespy.tools.storage.s3.client import S3Client @@ -15,7 +15,7 @@ # Get caller module from environment (set by mcp_utils.py) _caller_module = os.environ.get("MCP_CALLER_MODULE", "unknown") -mcp = FastMCP("s3") +mcp = MCPServer("s3") _client: S3Client | None = None # Manual cache for read_file to skip caching error results @@ -221,10 +221,6 @@ def delete_file(path: str) -> dict: if __name__ == "__main__": - # Suppress noisy MCP server "Processing request" logs - logging.getLogger("mcp.server").setLevel(logging.WARNING) - logging.getLogger("mcp.server.lowlevel").setLevel(logging.WARNING) - if len(sys.argv) < 2: print("Usage: python server.py [region] [endpoint_url]", file=sys.stderr) sys.exit(1) diff --git a/src/codespy/tools/web/server.py b/src/codespy/tools/web/server.py index 4bee6e6..d572464 100644 --- a/src/codespy/tools/web/server.py +++ b/src/codespy/tools/web/server.py @@ -3,14 +3,14 @@ import logging import os -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer from codespy.tools.web.client import WebBrowser logger = logging.getLogger(__name__) _caller_module = os.environ.get("MCP_CALLER_MODULE", "unknown") -mcp = FastMCP("web") +mcp = MCPServer("web") _browser: WebBrowser | None = None @@ -69,9 +69,5 @@ def search_and_fetch(query: str, num_results: int = 3) -> list[dict]: if __name__ == "__main__": - # Suppress noisy MCP server "Processing request" logs - logging.getLogger("mcp.server").setLevel(logging.WARNING) - logging.getLogger("mcp.server.lowlevel").setLevel(logging.WARNING) - _browser = WebBrowser() mcp.run() diff --git a/src/codespy/workflows/review/server.py b/src/codespy/workflows/review/server.py index 035ff0c..9e4fccd 100644 --- a/src/codespy/workflows/review/server.py +++ b/src/codespy/workflows/review/server.py @@ -7,13 +7,13 @@ from pathlib import Path from typing import Any -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer from codespy.config import Settings logger = logging.getLogger(__name__) -mcp = FastMCP("codespy-reviewer") +mcp = MCPServer("codespy-reviewer") _settings: Settings | None = None _pipeline: Any = None @@ -184,10 +184,6 @@ def run_server(settings: Settings | None = None) -> None: """Start the MCP server (called from CLI or __main__).""" global _settings - # Suppress noisy MCP server logs — keep the transport clean - logging.getLogger("mcp.server").setLevel(logging.WARNING) - logging.getLogger("mcp.server.lowlevel").setLevel(logging.WARNING) - # Send application logs to stderr (stdout is the MCP transport) logging.basicConfig( level=logging.INFO, From 8dbb295806cdb6811e72e41fbe78169a97680e81 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Sun, 27 Sep 2026 13:36:52 +0200 Subject: [PATCH 15/39] cost --- .env.example | 304 ++++----- codespy.yaml | 463 +++++--------- src/codespy/agents/dspy_config.py | 45 +- .../agents/review/code_review/agent.py | 4 +- src/codespy/agents/review/doc/agent.py | 4 +- src/codespy/agents/review/scope/agent.py | 4 +- .../agents/review/supply_chain/agent.py | 4 +- src/codespy/cli.py | 14 +- src/codespy/cli_local.py | 24 +- src/codespy/cli_remote.py | 24 +- src/codespy/config.py | 298 +++------ src/codespy/config_dspy.py | 165 ++--- src/codespy/config_llm.py | 19 +- src/codespy/config_memory.py | 289 +++------ src/codespy/config_utils.py | 160 ++++- src/codespy/tools/git/github_client.py | 2 +- src/codespy/tools/git/gitlab_client.py | 2 +- src/codespy/tools/git/patch_utils.py | 438 ------------- src/codespy/workflows/review/pipeline.py | 13 +- src/codespy/workflows/review/server.py | 2 +- tests/test_config.py | 111 ++-- tests/test_config_memory.py | 416 ++---------- tests/test_context_safe.py | 118 +++- tests/test_patch_utils.py | 600 ------------------ 24 files changed, 967 insertions(+), 2556 deletions(-) delete mode 100644 src/codespy/tools/git/patch_utils.py delete mode 100644 tests/test_patch_utils.py diff --git a/.env.example b/.env.example index 4f8cbf2..3d95173 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ # NOTE: For advanced configuration (per-signature settings, model overrides), # use codespy.yaml instead. See codespy.yaml for full options. # Priority: Environment Variables > YAML Config > Defaults +# +# Environment variables follow the YAML path, uppercased and joined with _. +# Provider credentials keep their standard names. # ============================================================================= # Git Platform Tokens @@ -42,38 +45,38 @@ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # - gemini/gemini-2.5-pro (Google Gemini) # - gemini/gemini-2.5-flash (Google Gemini) # - ollama/llama-4-70b (Local Ollama) -DEFAULT_MODEL=anthropic/claude-opus-4-6 +LLM_DEFAULT_MODEL=anthropic/claude-opus-4-6 -# ----------------------------------------------------------------------------- +# ------------------------------------------------------------------------------ # Option 1: Anthropic (direct) -# ----------------------------------------------------------------------------- +# ------------------------------------------------------------------------------ # Auto-discovered from: $ANTHROPIC_API_KEY, ~/.config/anthropic/, ~/.anthropic/ # ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -# ----------------------------------------------------------------------------- +# ------------------------------------------------------------------------------ # Option 2: AWS Bedrock -# ----------------------------------------------------------------------------- +# ------------------------------------------------------------------------------ # Auto-discovered from: ~/.aws/credentials, AWS CLI, $AWS_ACCESS_KEY_ID # AWS_REGION=us-east-1 # AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE # AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY # AWS_PROFILE=default -# ----------------------------------------------------------------------------- +# ------------------------------------------------------------------------------ # Option 3: OpenAI -# ----------------------------------------------------------------------------- +# ------------------------------------------------------------------------------ # Auto-discovered from: $OPENAI_API_KEY, ~/.config/openai/, ~/.openai/ # OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -# ----------------------------------------------------------------------------- +# ------------------------------------------------------------------------------ # Option 4: Google Gemini -# ----------------------------------------------------------------------------- +# ------------------------------------------------------------------------------ # Auto-discovered from: $GEMINI_API_KEY, $GOOGLE_API_KEY, gcloud ADC # GEMINI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -# ----------------------------------------------------------------------------- +# ------------------------------------------------------------------------------ # Option 5: Azure OpenAI -# ----------------------------------------------------------------------------- +# ------------------------------------------------------------------------------ # AZURE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # AZURE_API_BASE=https://your-resource.openai.azure.com/ # AZURE_API_VERSION=2024-02-15-preview @@ -82,37 +85,37 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # Auto-Discovery Settings # ============================================================================= # Disable auto-discovery for specific providers (all default to true) -# AUTO_DISCOVER_OPENAI=false -# AUTO_DISCOVER_ANTHROPIC=false -# AUTO_DISCOVER_GEMINI=false -# AUTO_DISCOVER_AWS=false +# LLM_AUTO_DISCOVER_OPENAI=false +# LLM_AUTO_DISCOVER_ANTHROPIC=false +# LLM_AUTO_DISCOVER_GEMINI=false +# LLM_AUTO_DISCOVER_AWS=false # ============================================================================= # Recommended Model Strategy # ============================================================================= # codespy uses a tiered model approach to balance review quality and cost: # -# Smart (DEFAULT_MODEL): Core analysis — code review (bugs, security, smells), +# Smart (LLM_DEFAULT_MODEL): Core analysis — code review (bugs, security, smells), # supply chain, scope identification, doc review. Requires strong reasoning. # Recommended: anthropic/claude-opus-4-6 or equivalent. # -# Mid-tier (EXTRACTION_MODEL): Structured field extraction. Needs accuracy +# Mid-tier (LLM_EXTRACTION_MODEL): Structured field extraction. Needs accuracy # but not deep reasoning. Recommended: anthropic/claude-sonnet-4-5-20250929 # or equivalent. # -# Cheap (SUMMARY_MODEL): PR summary generation. Simple synthesis. +# Cheap (REVIEW_SUMMARY_MODEL): PR summary generation. Simple synthesis. # Recommended: anthropic/claude-haiku-4-5-20251001 or equivalent. # -# Mid-tier (MEMORY_DISTILLER_MODEL / MEMORY_CARTOGRAPHER_MODEL): Memory -# reflection — summarizing a trajectory and curating the context memory. +# Mid-tier (MEMORY_HIPPOCAMPUS_DISTILLER_MODEL / MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MODEL): +# Memory reflection — summarizing a trajectory and curating the context memory. # Requires nuance. # Recommended: anthropic/claude-sonnet-4-5-20250929 or equivalent. # -# By default, all models fall back to DEFAULT_MODEL. To optimize costs: -# EXTRACTION_MODEL=anthropic/claude-sonnet-4-5-20250929 -# SUMMARY_MODEL=anthropic/claude-haiku-4-5-20251001 -# MEMORY_DISTILLER_MODEL=anthropic/claude-sonnet-4-5-20250929 -# MEMORY_CARTOGRAPHER_MODEL=anthropic/claude-sonnet-4-5-20250929 +# By default, all models fall back to LLM_DEFAULT_MODEL. To optimize costs: +# LLM_EXTRACTION_MODEL=anthropic/claude-sonnet-4-5-20250929 +# REVIEW_SUMMARY_MODEL=anthropic/claude-haiku-4-5-20251001 +# MEMORY_HIPPOCAMPUS_DISTILLER_MODEL=anthropic/claude-sonnet-4-5-20250929 +# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MODEL=anthropic/claude-sonnet-4-5-20250929 # ============================================================================= @@ -120,158 +123,99 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # ============================================================================= # These apply to all signatures unless overridden per-signature -# Extraction model for TwoStepAdapter (extracts structured fields from free-form LLM responses) -# Falls back to DEFAULT_MODEL if not set -# EXTRACTION_MODEL=anthropic/claude-sonnet-4-5-20250929 +# Extraction model for TwoStepAdapter +# LLM_EXTRACTION_MODEL=anthropic/claude-sonnet-4-5-20250929 -# DEFAULT_MAX_ITERS=5 -# DEFAULT_MAX_LLM_CALLS=8 +# LLM_DEFAULT_MAX_ITERS=5 +# LLM_DEFAULT_MAX_LLM_CALLS=8 # Provider reasoning budget: minimal | low | medium | high -# DEFAULT_REASONING_EFFORT=medium -# DEFAULT_TEMPERATURE=0.2 - -# Output token budget for a single completion (default: 32000). This is an OUTPUT -# ceiling, not a context window, and reasoning/thinking tokens are charged against -# it — so keep it well above the expected answer size while DEFAULT_REASONING_EFFORT -# is set. codespy always sends an explicit value: without one LiteLLM silently falls -# back to its own 4096 default and truncates responses mid-answer (DSPy warns -# "LM response was truncated ... max_tokens=None"). -# The value is clamped down to each model's real output ceiling before use, so a -# generous setting stays valid on smaller models; models LiteLLM doesn't know -# (Ollama, custom endpoints) receive it unchanged. -# NOTE: LiteLLM also reads DEFAULT_MAX_TOKENS for its own internal fallback, so -# setting this aligns both layers. -# DEFAULT_MAX_TOKENS=32000 +# LLM_DEFAULT_REASONING_EFFORT=medium +# LLM_DEFAULT_TEMPERATURE=0.2 +# Output token budget for a single completion (default: 32000) +# LLM_DEFAULT_MAX_TOKENS=32000 # Global LLM reliability settings -# Number of retries for LLM API calls # LLM_RETRIES=2 -# Timeout in seconds for LLM calls # LLM_TIMEOUT=240 -# Enable provider-side prompt caching (reduces latency and costs) -# ENABLE_PROMPT_CACHING=true - -# Minimum confidence threshold for reported issues (0.0-1.0) -# MIN_CONFIDENCE=0.81 - -# Expand diff context to full function bodies (Tree-sitter) before review. -# When false (default), reviewers see original PR patches. -# COMPACT_PATCHES=false - # ============================================================================= # RLM Fallback (Context Rot Prevention) # ============================================================================= -# Proactive fallback to RLM when input tokens exceed a ratio of the model's -# max_input_tokens. Prevents quality degradation ("context rot") that occurs -# well before the hard context window limit. The hard overflow safety net -# remains active regardless of these settings. -# -# Thresholds are per module type: -# react: Multi-step tool-using agents (code_review, scope, supply_chain) -# chain_of_thought: Single-pass reasoning (doc, summary, audit) -# predict: Basic completion (not currently used) -# -# Minimum floor: proactive threshold never fires below 8192 input tokens. +# LLM_RLM_FALLBACK_ENABLED=true +# LLM_RLM_FALLBACK_REACT_THRESHOLD=0.30 +# LLM_RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD=0.40 +# LLM_RLM_FALLBACK_PREDICT_THRESHOLD=0.50 + -# RLM_FALLBACK_ENABLED=true -# RLM_FALLBACK_REACT_THRESHOLD=0.30 -# RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD=0.40 -# RLM_FALLBACK_PREDICT_THRESHOLD=0.50 +# ============================================================================= +# Minimum Confidence +# ============================================================================= +# REVIEW_MIN_CONFIDENCE=0.81 # ============================================================================= # Memory (Hippocampus) # ============================================================================= -# Hippocampus lets scope-based agents (scope, code_review, doc, supply_chain) -# consolidate their run into a ContextMemory and persist it as an Episode. -# Episodes are stored in PostgreSQL using a relational schema with pgvector -# for semantic search. +# Hippocampus lets scope-based agents consolidate their run into a ContextMemory +# and persist it as an Episode. Episodes are stored in PostgreSQL. # # Production uses MEMORY_POSTGRES_HOST (+ credentials) to connect to an # external PostgreSQL instance (AWS RDS, etc.). For local development, # pg0-embedded is used if MEMORY_POSTGRES_HOST is not set. -# PostgreSQL connection — set for production (AWS RDS, etc.) -# When MEMORY_POSTGRES_HOST is unset, pg0-embedded auto-starts a local instance. +# PostgreSQL connection # MEMORY_POSTGRES_HOST=rds-host.amazonaws.com # MEMORY_POSTGRES_PORT=5432 # MEMORY_POSTGRES_USER=myuser # MEMORY_POSTGRES_PASSWORD=mypassword # MEMORY_POSTGRES_DATABASE=codespy -# MEMORY_POSTGRES_SCHEMA=episodic # PostgreSQL search_path (default: episodic; each memory type uses its own) +# MEMORY_POSTGRES_SCHEMA=episodic # Bank ID — nickname, username, email, or agent name scoping all memory data. # When unset, defaults to "codespy". # MEMORY_BANK_ID=my-agent -# pg0 embedded settings (local dev only, ignored when MEMORY_POSTGRES_HOST is set) +# pg0 embedded settings (local dev only) # MEMORY_PG0_NAME=codespy # MEMORY_PG0_PORT=5432 -# MEMORY_PG0_DATA_DIR=~/.cache/codespy/pg0 # default; persists on Docker-mounted codespy-cache volume +# MEMORY_PG0_DATA_DIR=~/.cache/codespy/pg0 -# Memory master switch — overridable per-signature via _MEMORY_ENABLED +# Memory master switch — overridable per-signature via REVIEW__MEMORY_ENABLED # MEMORY_ENABLED=false # Whether to compact trajectories (head+tail bounding) before distillation. -# When false, the full trajectory is sent to the Distiller and ContextSafe -# RLM fallback handles overflow if the trajectory exceeds the model's context. -# MEMORY_COMPACT_TRAJECTORY=true - -# Four independent token budgets, most to least cost-sensitive: -# -# 1. Ceiling on the rendered ContextMemory. This is the persisted artifact, and it is -# prepended to every agent iteration, so it is re-sent ~DEFAULT_MAX_ITERS times -# per scope. Divided by MEMORY_MAX_CONTEXT_ITEM_TOKENS it gives the memory's item -# capacity (16384 / 512 = 32 items). -# MEMORY_MAX_CONTEXT_MEMORY_TOKENS=16384 -# 2. Budget for a SINGLE context-memory item, given to the Distiller and the -# Cartographer as a prompt input so no one item eats the whole memory. Soft limit -# (expressed to the LLM, not enforced — truncating an item could corrupt an exact -# constant). Lower it for more, terser items; raise it for fewer, richer ones. -# MEMORY_MAX_CONTEXT_ITEM_TOKENS=512 -# 3. Cap on the trajectory fed to the Distiller. Tool-using agents can produce -# 100k+ token trajectories and TwoStepAdapter sends the value twice, so keep this -# to ~5-15% of the reflection model's context window. Unset = full trajectory -# (unbounded — not recommended for tool-using agents). -# MEMORY_MAX_TRAJECTORY_TOKENS=16384 -# 4. Cap on the serialized agent inputs used as the reflection "question". Without -# it, every input field is sent in full (for code review, the complete patch of -# every changed file). Unset = unbounded. -# MEMORY_MAX_QUESTION_TOKENS=8192 - -# LLM settings for the reflection modules. The Distiller summarizes a trajectory; -# the Cartographer curates the context memory. Both are compact summarize/curate -# tasks, so a cheaper tier than code review usually suffices. -# Each falls back to the corresponding DEFAULT_* value when unset. -# MEMORY_DISTILLER_MODEL=anthropic/claude-haiku-4-5-20251001 -# MEMORY_DISTILLER_REASONING_EFFORT=low -# MEMORY_DISTILLER_TEMPERATURE=1 -# MEMORY_DISTILLER_MAX_TOKENS=64000 -# MEMORY_DISTILLER_MAX_ITERS=1 -# MEMORY_DISTILLER_MAX_LLM_CALLS=2 - -# MEMORY_CARTOGRAPHER_MODEL=anthropic/claude-haiku-4-5-20251001 -# MEMORY_CARTOGRAPHER_REASONING_EFFORT=low -# MEMORY_CARTOGRAPHER_TEMPERATURE=1 -# MEMORY_CARTOGRAPHER_MAX_TOKENS=64000 -# MEMORY_CARTOGRAPHER_MAX_ITERS=1 -# MEMORY_CARTOGRAPHER_MAX_LLM_CALLS=2 +# MEMORY_HIPPOCAMPUS_COMPACT_TRAJECTORY=true + +# Token budgets +# MEMORY_HIPPOCAMPUS_MAX_CONTEXT_MEMORY_TOKENS=16384 +# MEMORY_HIPPOCAMPUS_MAX_CONTEXT_ITEM_TOKENS=512 +# MEMORY_HIPPOCAMPUS_MAX_TRAJECTORY_TOKENS=16384 +# MEMORY_HIPPOCAMPUS_MAX_QUESTION_TOKENS=8192 + +# LLM settings for reflection modules +# MEMORY_HIPPOCAMPUS_DISTILLER_MODEL=anthropic/claude-haiku-4-5-20251001 +# MEMORY_HIPPOCAMPUS_DISTILLER_REASONING_EFFORT=low +# MEMORY_HIPPOCAMPUS_DISTILLER_TEMPERATURE=1 +# MEMORY_HIPPOCAMPUS_DISTILLER_MAX_TOKENS=64000 +# MEMORY_HIPPOCAMPUS_DISTILLER_MAX_ITERS=1 +# MEMORY_HIPPOCAMPUS_DISTILLER_MAX_LLM_CALLS=2 + +# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MODEL=anthropic/claude-haiku-4-5-20251001 +# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_REASONING_EFFORT=low +# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_TEMPERATURE=1 +# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_TOKENS=64000 +# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_ITERS=1 +# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_LLM_CALLS=2 # ============================================================================= -# Cerebral LLM overrides (same pattern as distiller/cartographer) +# Cerebral (Semantic Memory) # ============================================================================= -# Cerebral (semantic memory) activates automatically when MEMORY_ENABLED=true. +# Cerebral activates automatically when MEMORY_ENABLED=true. # LLM provider and credentials are derived from the model string and existing # LLM config (e.g. bedrock uses AWS env vars, openai uses OPENAI_API_KEY). -# MEMORY_CEREBRAL_MODEL= # Falls back to DEFAULT_MODEL -# MEMORY_CEREBRAL_TEMPERATURE= -# MEMORY_CEREBRAL_MAX_TOKENS= - -# Hindsight embedding model (for semantic memory). -# Auto-derived from LLM provider if unset. -# MEMORY_HINDSIGHT_EMBEDDINGS_MODEL= +# MEMORY_CEREBRAL_RETAIN_MODEL= # Falls back to LLM_DEFAULT_MODEL +# MEMORY_CEREBRAL_EMBEDDINGS_MODEL= # Auto-derived from provider # ============================================================================= @@ -279,71 +223,73 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # ============================================================================= # Output format: markdown or json (default: markdown) -# OUTPUT_FORMAT=markdown +# REVIEW_OUTPUT_FORMAT=markdown # Enable stdout output (default: true) -# OUTPUT_STDOUT=true +# REVIEW_OUTPUT_STDOUT=true -# Post review to Git platform (GitHub PR or GitLab MR) (default: false) -# OUTPUT_GIT=false +# Post review to Git platform (GitHub PR or GitLab MR) (default: true) +# REVIEW_OUTPUT_GIT=true # Cache directory for cloned repositories (default: ~/.cache/codespy) -# CACHE_DIR=/path/to/cache +# REVIEW_CACHE_DIR=/path/to/cache + # ============================================================================= # Per-Signature Configuration (override via env vars) # ============================================================================= # Use underscore (_) between signature name and setting -# Format: SIGNATURE_NAME_SETTING=value +# Format: REVIEW__=value # # Available signatures: # - CODE_REVIEW (bugs, security, removed defensive code, code smells) # - DOC (compares patches against documentation) # - SCOPE (code scope detection) # - SUPPLY_CHAIN (supply chain security analysis) -# - SUMMARY (PR summary generation) +# - SUMMARY (PR summary generation) +# - AUDIT (quality assessment after reviews) # # Available settings per signature: # - ENABLED (true/false) # - MAX_ITERS (integer) -# - MAX_LLM_CALLS (integer) - RLM fallback max calls (unset -> DEFAULT_MAX_LLM_CALLS) +# - MAX_LLM_CALLS (integer) - RLM fallback max calls # - MODEL (LiteLLM model string) -# - REASONING_EFFORT (minimal|low|medium|high) - Provider reasoning budget -# - TEMPERATURE (float) - Must be 1 while reasoning is enabled -# - MAX_TOKENS (integer) - Output token budget (unset -> DEFAULT_MAX_TOKENS) -# - MEMORY_ENABLED (true/false) - Hippocampus memory (unset -> MEMORY_ENABLED) +# - REASONING_EFFORT (minimal|low|medium|high) +# - TEMPERATURE (float) +# - MAX_TOKENS (integer) +# - MEMORY_ENABLED (true/false) # Examples: -# CODE_REVIEW_ENABLED=true -# CODE_REVIEW_MAX_ITERS=4 -# CODE_REVIEW_MODEL=anthropic/claude-sonnet-4-5-20250929 -# CODE_REVIEW_REASONING_EFFORT=medium -# CODE_REVIEW_TEMPERATURE=1 -# CODE_REVIEW_MAX_TOKENS=32000 - -# CODE_REVIEW_MEMORY_ENABLED=true - -# SUPPLY_CHAIN_ENABLED=true -# When true: scans ALL artifacts (Dockerfiles, etc.) and manifests -# When false (default): only scans artifacts/manifests modified in the MR -# SUPPLY_CHAIN_SCAN_UNCHANGED=false -# SUPPLY_CHAIN_MEMORY_ENABLED=true - -# DOC_ENABLED=true -# DOC_MAX_ITERS=1 -# DOC_MAX_LLM_CALLS=2 -# DOC_MODEL=anthropic/claude-sonnet-4-5-20250929 -# DOC_MEMORY_ENABLED=true - -# SCOPE_ENABLED=true -# SCOPE_MAX_ITERS=4 -# SCOPE_REASONING_EFFORT=low -# SCOPE_MAX_TOKENS=32000 -# SCOPE_MEMORY_ENABLED=true - -# SUMMARY_ENABLED=true -# SUMMARY_MAX_ITERS=1 -# SUMMARY_MAX_LLM_CALLS=2 -# SUMMARY_MODEL=anthropic/claude-haiku-4-5-20251001 -# SUMMARY_MEMORY_ENABLED=true - +# REVIEW_CODE_REVIEW_ENABLED=true +# REVIEW_CODE_REVIEW_MAX_ITERS=4 +# REVIEW_CODE_REVIEW_MODEL=anthropic/claude-sonnet-4-5-20250929 +# REVIEW_CODE_REVIEW_REASONING_EFFORT=medium +# REVIEW_CODE_REVIEW_TEMPERATURE=1 +# REVIEW_CODE_REVIEW_MAX_TOKENS=32000 +# REVIEW_CODE_REVIEW_MEMORY_ENABLED=true + +# REVIEW_SUPPLY_CHAIN_ENABLED=true +# REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED=false +# REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED=true + +# REVIEW_DOC_ENABLED=true +# REVIEW_DOC_MAX_ITERS=1 +# REVIEW_DOC_MAX_LLM_CALLS=2 +# REVIEW_DOC_MODEL=anthropic/claude-sonnet-4-5-20250929 +# REVIEW_DOC_MEMORY_ENABLED=true + +# REVIEW_SCOPE_ENABLED=true +# REVIEW_SCOPE_MAX_ITERS=4 +# REVIEW_SCOPE_REASONING_EFFORT=low +# REVIEW_SCOPE_MAX_TOKENS=32000 +# REVIEW_SCOPE_MEMORY_ENABLED=true + +# REVIEW_SUMMARY_ENABLED=true +# REVIEW_SUMMARY_MAX_ITERS=1 +# REVIEW_SUMMARY_MAX_LLM_CALLS=2 +# REVIEW_SUMMARY_MODEL=anthropic/claude-haiku-4-5-20251001 +# REVIEW_SUMMARY_MEMORY_ENABLED=true + +# REVIEW_AUDIT_ENABLED=true +# REVIEW_AUDIT_MAX_ITERS=1 +# REVIEW_AUDIT_MEMORY_ENABLED=true diff --git a/codespy.yaml b/codespy.yaml index 874497d..5a79203 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -1,13 +1,14 @@ # codespy.yaml - Configuration file for codespy # # ENVIRONMENT VARIABLE OVERRIDES: -# All settings can be overridden via environment variables. -# - Flat values: (e.g., OUTPUT_FORMAT=json, DEFAULT_MODEL=openai/gpt-5) -# - Signature values: _ -# Examples: -# CODE_REVIEW_MAX_ITERS=15 -# CODE_REVIEW_ENABLED=false -# SUPPLY_CHAIN_MODEL=openai/gpt-4-turbo +# All settings can be overridden via environment variables using the YAML path, +# uppercased and joined with underscores. Examples: +# - llm.default_model → LLM_DEFAULT_MODEL +# - review.code_review.max_iters → REVIEW_CODE_REVIEW_MAX_ITERS +# - memory.hippocampus.distiller.model → MEMORY_HIPPOCAMPUS_DISTILLER_MODEL +# +# Provider credentials keep their standard names: +# OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AWS_*, AZURE_* # # Priority: Environment Variables > YAML Config > Defaults @@ -18,360 +19,220 @@ llm: # OpenAI openai_api_key: null # OPENAI_API_KEY openai_api_base: null # OPENAI_API_BASE - auto_discover_openai: true # AUTO_DISCOVER_OPENAI (set to false to disable) + auto_discover_openai: true # LLM_AUTO_DISCOVER_OPENAI # Anthropic anthropic_api_key: null # ANTHROPIC_API_KEY - auto_discover_anthropic: true # AUTO_DISCOVER_ANTHROPIC (set to false to disable) + auto_discover_anthropic: true # LLM_AUTO_DISCOVER_ANTHROPIC # Google Gemini gemini_api_key: null # GEMINI_API_KEY - auto_discover_gemini: true # AUTO_DISCOVER_GEMINI (set to false to disable) + auto_discover_gemini: true # LLM_AUTO_DISCOVER_GEMINI # AWS Bedrock aws_region: us-east-1 # AWS_REGION aws_access_key_id: null # AWS_ACCESS_KEY_ID aws_secret_access_key: null # AWS_SECRET_ACCESS_KEY aws_profile: null # AWS_PROFILE - auto_discover_aws: true # AUTO_DISCOVER_AWS (set to false to disable) + auto_discover_aws: true # LLM_AUTO_DISCOVER_AWS # Azure OpenAI azure_api_key: null # AZURE_API_KEY azure_api_base: null # AZURE_API_BASE azure_api_version: null # AZURE_API_VERSION - # Enable provider-side prompt caching (Anthropic, OpenAI, Bedrock, etc.) - # Caches system prompts on the LLM provider's servers to reduce latency and costs - enable_prompt_caching: true # ENABLE_PROMPT_CACHING - -# Minimum confidence threshold for reported issues (0.0–1.0). -# Issues below this threshold are silently discarded. -min_confidence: 0.81 # MIN_CONFIDENCE + # Default model settings + default_model: anthropic/claude-opus-4-6 # LLM_DEFAULT_MODEL + extraction_model: null # LLM_EXTRACTION_MODEL (falls back to default_model) + default_max_iters: 5 # LLM_DEFAULT_MAX_ITERS + default_max_llm_calls: 8 # LLM_DEFAULT_MAX_LLM_CALLS + default_reasoning_effort: medium # LLM_DEFAULT_REASONING_EFFORT + default_temperature: 0.2 # LLM_DEFAULT_TEMPERATURE + default_max_tokens: 32000 # LLM_DEFAULT_MAX_TOKENS -# Expand diff context to full function bodies (Tree-sitter) before review. -# When false (default), reviewers see the original PR patches. -compact_patches: false # COMPACT_PATCHES + # Global LLM reliability settings + retries: 2 # LLM_RETRIES + timeout: 240 # LLM_TIMEOUT + # RLM Fallback (context rot prevention) + rlm_fallback: + enabled: true # LLM_RLM_FALLBACK_ENABLED + react_threshold: 0.30 # LLM_RLM_FALLBACK_REACT_THRESHOLD + chain_of_thought_threshold: 0.40 # LLM_RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD + predict_threshold: 0.50 # LLM_RLM_FALLBACK_PREDICT_THRESHOLD # ============================================================================ # GIT PLATFORMS # ============================================================================ github: token: null # GITHUB_TOKEN (auto-discovered if not set) - auto_discover_token: true # GITHUB_AUTO_DISCOVER_TOKEN (set to false to disable auto-discovery) + auto_discover_token: true # GITHUB_AUTO_DISCOVER_TOKEN gitlab: token: null # GITLAB_TOKEN (auto-discovered if not set) - url: https://gitlab.com # GITLAB_URL (for self-hosted instances) - auto_discover_token: true # GITLAB_AUTO_DISCOVER_TOKEN (set to false to disable auto-discovery) + url: https://gitlab.com # GITLAB_URL + auto_discover_token: true # GITLAB_AUTO_DISCOVER_TOKEN # ============================================================================ # MEMORY # ============================================================================ -# Hippocampus lets scope-based agents (scope, code_review, doc, supply_chain) -# and the summary step consolidate their run into a ContextMemory and -# persist it as an Episode. Save-only for now (no loading). Disabled by -# default globally; enabled by default for summary — see `memory:` -# blocks under each signature below. - -# Episodes are persisted in PostgreSQL. When postgres.host is unset, -# pg0-embedded auto-starts a local PostgreSQL instance (zero config). -# For production, set postgres.host to an external PostgreSQL (AWS RDS, etc.). memory: - # External PostgreSQL (production, AWS RDS, etc.). - # When postgres.host is unset, pg0-embedded auto-starts a local instance. + # PostgreSQL connection postgres: host: null # MEMORY_POSTGRES_HOST port: 5432 # MEMORY_POSTGRES_PORT - user: null # MEMORY_POSTGRES_USER (default: postgres) + user: null # MEMORY_POSTGRES_USER password: null # MEMORY_POSTGRES_PASSWORD database: codespy # MEMORY_POSTGRES_DATABASE - schema: episodic # MEMORY_POSTGRES_SCHEMA (search_path per memory type; null = public) + schema: episodic # MEMORY_POSTGRES_SCHEMA - # pg0-embedded settings (local dev only, ignored when postgres.host is set) + # pg0-embedded settings pg0: name: codespy # MEMORY_PG0_NAME - port: null # MEMORY_PG0_PORT (auto-detected if unset) - data_dir: null # MEMORY_PG0_DATA_DIR (default: ~/.cache/codespy/pg0) + port: null # MEMORY_PG0_PORT + data_dir: null # MEMORY_PG0_DATA_DIR - # Bank ID — scopes all memory data. Can be a nickname, username, email, - # or agent name. When unset, auto-generated from hostname. - bank_id: null # MEMORY_BANK_ID - - # Master switch — overridable per-signature via signatures..memory + # Master switch enabled: false # MEMORY_ENABLED + bank_id: null # MEMORY_BANK_ID - # Four independent token budgets, from most to least cost-sensitive: - # - # 1. max_context_memory_tokens — ceiling on the rendered ContextMemory. This is the - # persisted artifact, and it is prepended to every agent iteration, so it is - # re-sent ~default_max_iters times per scope. Divided by max_context_item_tokens it - # gives the memory's item capacity (16384 / 512 = 32 items). - # 2. max_context_item_tokens — budget for a SINGLE context memory item, given to the Distiller - # and the Cartographer as a prompt input so no one item eats the whole memory. - # Soft limit (expressed to the LLM, not enforced — truncating an item could - # corrupt an exact constant). Lower it for more, terser items; raise it for - # fewer, richer ones. - # 3. max_trajectory_tokens — cap on the trajectory fed to the Distiller. Tool-using - # agents can produce 100k+ token trajectories and TwoStepAdapter sends the value - # twice, so keep this to ~5-15% of the reflection model's context window. - # null = full trajectory (unbounded — not recommended for tool-using agents). - # 4. max_question_tokens — cap on the serialized agent inputs used as the reflection - # "question". Without it, every input field is sent in full (for code review that - # means the complete patch of every changed file). null = unbounded. - max_context_memory_tokens: 16384 # MEMORY_MAX_CONTEXT_MEMORY_TOKENS - max_context_item_tokens: 512 # MEMORY_MAX_CONTEXT_ITEM_TOKENS - max_trajectory_tokens: 16384 # MEMORY_MAX_TRAJECTORY_TOKENS - max_question_tokens: 8192 # MEMORY_MAX_QUESTION_TOKENS - compact_trajectory: true # MEMORY_COMPACT_TRAJECTORY - - # LLM settings for the reflection modules. The Distiller summarizes a - # trajectory; the Cartographer curates the context memory. Both are compact - # summarize/curate tasks, so a cheaper tier than code review usually suffices. - # null inherits the top-level default_* value. - distiller: - model: null # MEMORY_DISTILLER_MODEL - reasoning_effort: null # MEMORY_DISTILLER_REASONING_EFFORT - temperature: null # MEMORY_DISTILLER_TEMPERATURE - max_tokens: null # MEMORY_DISTILLER_MAX_TOKENS - max_iters: 1 # MEMORY_DISTILLER_MAX_ITERS - max_llm_calls: 2 # MEMORY_DISTILLER_MAX_LLM_CALLS - cartographer: - model: null # MEMORY_CARTOGRAPHER_MODEL - reasoning_effort: null # MEMORY_CARTOGRAPHER_REASONING_EFFORT - temperature: null # MEMORY_CARTOGRAPHER_TEMPERATURE - max_tokens: null # MEMORY_CARTOGRAPHER_MAX_TOKENS - max_iters: 1 # MEMORY_CARTOGRAPHER_MAX_ITERS - max_llm_calls: 2 # MEMORY_CARTOGRAPHER_MAX_LLM_CALLS + # Hippocampus (episodic memory) + hippocampus: + compact_trajectory: true # MEMORY_HIPPOCAMPUS_COMPACT_TRAJECTORY + max_context_memory_tokens: 16384 # MEMORY_HIPPOCAMPUS_MAX_CONTEXT_MEMORY_TOKENS + max_context_item_tokens: 512 # MEMORY_HIPPOCAMPUS_MAX_CONTEXT_ITEM_TOKENS + max_trajectory_tokens: 16384 # MEMORY_HIPPOCAMPUS_MAX_TRAJECTORY_TOKENS + max_question_tokens: 8192 # MEMORY_HIPPOCAMPUS_MAX_QUESTION_TOKENS + + distiller: + model: null # MEMORY_HIPPOCAMPUS_DISTILLER_MODEL + reasoning_effort: null # MEMORY_HIPPOCAMPUS_DISTILLER_REASONING_EFFORT + temperature: null # MEMORY_HIPPOCAMPUS_DISTILLER_TEMPERATURE + max_tokens: null # MEMORY_HIPPOCAMPUS_DISTILLER_MAX_TOKENS + max_iters: 1 # MEMORY_HIPPOCAMPUS_DISTILLER_MAX_ITERS + max_llm_calls: 2 # MEMORY_HIPPOCAMPUS_DISTILLER_MAX_LLM_CALLS + + cartographer: + model: null # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MODEL + reasoning_effort: null # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_REASONING_EFFORT + temperature: null # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_TEMPERATURE + max_tokens: null # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_TOKENS + max_iters: 1 # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_ITERS + max_llm_calls: 2 # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_LLM_CALLS + + # Cerebral (semantic memory) cerebral: - model: null # MEMORY_CEREBRAL_MODEL (falls back to default_model) - reasoning_effort: null # MEMORY_CEREBRAL_REASONING_EFFORT - temperature: null # MEMORY_CEREBRAL_TEMPERATURE - max_tokens: null # MEMORY_CEREBRAL_MAX_TOKENS - max_iters: 1 # MEMORY_CEREBRAL_MAX_ITERS - max_llm_calls: 2 # MEMORY_CEREBRAL_MAX_LLM_CALLS - - # Hindsight MemoryEngine settings (semantic memory). - # The LLM for fact extraction is configured via cerebral: above. - # embeddings_model is auto-derived from the LLM provider when null: - # bedrock → bedrock/cohere.embed-multilingual-v3 - # openai → openai/text-embedding-3-small - # gemini → gemini/text-embedding-004 - hindsight: - embeddings_model: null # MEMORY_HINDSIGHT_EMBEDDINGS_MODEL - - -# ============================================================================ -# RLM FALLBACK (CONTEXT ROT PREVENTION) -# ============================================================================ -# Proactive fallback to RLM when input tokens exceed a ratio of the model's -# max_input_tokens. Prevents quality degradation that occurs well before the -# hard context window limit. The hard overflow safety net remains active -# regardless of these settings. -# -# Thresholds are per DSPy module type: -# - react: Multi-step tool-using agents (code_review, scope, supply_chain) -# - chain_of_thought: Single-pass reasoning (doc, summary, audit) -# - predict: Basic completion (not currently used) -# -# Minimum floor: proactive threshold never fires below 8192 input tokens. - -rlm_fallback: - enabled: true # RLM_FALLBACK_ENABLED - react_threshold: 0.30 # RLM_FALLBACK_REACT_THRESHOLD - chain_of_thought_threshold: 0.40 # RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD - predict_threshold: 0.50 # RLM_FALLBACK_PREDICT_THRESHOLD - - -# ============================================================================ -# SIGNATURES -# ============================================================================ - -# Each signature config supports: enabled, max_iters, model, reasoning_effort, -# temperature, max_tokens. Set to null to inherit the corresponding default_* value. - -# -# RECOMMENDED MODEL STRATEGY -# ============================================================================ -# codespy uses a tiered model approach to balance review quality and cost: -# -# Smart (default_model): Used for core analysis — code review (bugs, security, -# smells), supply chain, scope identification, doc review. Requires strong reasoning. -# Recommended: anthropic/claude-opus-4-6 or equivalent. -# -# Mid-tier (extraction_model): Used for structured field -# extraction (TwoStepAdapter). Needs accuracy but not deep reasoning. -# Recommended: anthropic/claude-sonnet-4-5-20250929 or equivalent. -# -# Cheap (summary): Used for PR summary generation. Simple synthesis -# task. Recommended: anthropic/claude-haiku-4-5-20251001 or equivalent. -# - # Mid-tier (memory.distiller / memory.cartographer): Memory reflection — - # summarizing a trajectory and curating the context memory. Requires nuance. - # Recommended: anthropic/claude-sonnet-4-5-20250929 or equivalent. - # - # Mid-tier (memory.cerebral): Hindsight fact extraction — distils episode - # observations and artifacts into structured facts, entities, and knowledge. - # Recommended: anthropic/claude-sonnet-4-5-20250929 or equivalent. -# -# By default, all models fall back to default_model. Override extraction_model, -# the summary model, and the reflection models for cost optimization: -# -# default_model: anthropic/claude-opus-4-6 -# extraction_model: anthropic/claude-sonnet-4-5-20250929 -# signatures: -# summary: -# model: anthropic/claude-haiku-4-5-20251001 -# memory: -# distiller: -# model: anthropic/claude-sonnet-4-5-20250929 -# cartographer: -# model: anthropic/claude-sonnet-4-5-20250929 -# ============================================================================ - - -# These apply to all signatures and reflection modules unless overridden -default_model: anthropic/claude-opus-4-6 # DEFAULT_MODEL -extraction_model: null # EXTRACTION_MODEL (falls back to default_model) -default_max_iters: 5 # DEFAULT_MAX_ITERS -default_max_llm_calls: 8 # DEFAULT_MAX_LLM_CALLS -default_reasoning_effort: medium # DEFAULT_REASONING_EFFORT (minimal | low | medium | high) -default_temperature: 0.2 # DEFAULT_TEMPERATURE - -# Output token budget for a single completion. This is an OUTPUT ceiling, not a -# context window, and reasoning/thinking tokens are charged against it — so it -# must be well above the expected answer size whenever reasoning_effort is set. -# Leaving it unset is not an option: LiteLLM then silently falls back to its own -# 4096 default, which truncates responses mid-answer (DSPy warns -# "LM response was truncated ... max_tokens=None"). -# The value is clamped down to each model's real output ceiling before use, so a -# generous setting stays valid on smaller models. Models LiteLLM doesn't know -# (Ollama, custom endpoints) receive it unchanged. -default_max_tokens: 32000 # DEFAULT_MAX_TOKENS - - -# Global LLM reliability settings -llm_retries: 2 # LLM_RETRIES (number of retries for LLM API calls) -llm_timeout: 240 # LLM_TIMEOUT (timeout in seconds for LLM calls) - -signatures: - # Supply Chain Security signature + retain: + model: null # MEMORY_CEREBRAL_RETAIN_MODEL (falls back to llm.default_model) + embeddings: + model: null # MEMORY_CEREBRAL_EMBEDDINGS_MODEL (auto-derived from provider) + +# ============================================================================ +# REVIEW +# ============================================================================ +review: + min_confidence: 0.81 # REVIEW_MIN_CONFIDENCE + output_format: markdown # REVIEW_OUTPUT_FORMAT + output_stdout: true # REVIEW_OUTPUT_STDOUT + output_git: true # REVIEW_OUTPUT_GIT + cache_dir: ~/.cache/codespy # REVIEW_CACHE_DIR + + excluded_directories: # REVIEW_EXCLUDED_DIRECTORIES (JSON array in env) + - vendor + - node_modules + - third_party + - external + - deps + - _vendor + - vendored + - dist + - build + - out + - target + - .bundle + - Pods + - Carthage + - bower_components + - jspm_packages + - .git + - .svn + - .hg + - __pycache__ + - .cache + - .pytest_cache + - .mypy_cache + - .ruff_cache + + # Supply Chain Security supply_chain: - enabled: true # SUPPLY_CHAIN_ENABLED - max_iters: null # SUPPLY_CHAIN_MAX_ITERS - max_llm_calls: null # SUPPLY_CHAIN_MAX_LLM_CALLS - model: null # SUPPLY_CHAIN_MODEL (Haiku 4.5) - reasoning_effort: null # SUPPLY_CHAIN_REASONING_EFFORT - temperature: null # SUPPLY_CHAIN_TEMPERATURE - max_tokens: null # SUPPLY_CHAIN_MAX_TOKENS - scan_unchanged: false # SUPPLY_CHAIN_SCAN_UNCHANGED - # When true: scans ALL artifacts (Dockerfiles, etc.) and manifests - # When false (default): only scans artifacts/manifests that were modified in the MR + enabled: true # REVIEW_SUPPLY_CHAIN_ENABLED + max_iters: null # REVIEW_SUPPLY_CHAIN_MAX_ITERS + max_llm_calls: null # REVIEW_SUPPLY_CHAIN_MAX_LLM_CALLS + model: null # REVIEW_SUPPLY_CHAIN_MODEL + reasoning_effort: null # REVIEW_SUPPLY_CHAIN_REASONING_EFFORT + temperature: null # REVIEW_SUPPLY_CHAIN_TEMPERATURE + max_tokens: null # REVIEW_SUPPLY_CHAIN_MAX_TOKENS + scan_unchanged: false # REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED memory: - enabled: null # SUPPLY_CHAIN_MEMORY_ENABLED (null -> memory.enabled) + enabled: null # REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED - # Code Reviewer signature (bugs, security, removed defensive code, code smells) - # Unified code review: bugs, security vulnerabilities, and code smells in a single agent pass per scope + # Code Review code_review: - enabled: true # CODE_REVIEW_ENABLED - max_iters: 5 # CODE_REVIEW_MAX_ITERS - max_llm_calls: 8 # CODE_REVIEW_MAX_LLM_CALLS - model: null # CODE_REVIEW_MODEL - reasoning_effort: null # CODE_REVIEW_REASONING_EFFORT - temperature: null # CODE_REVIEW_TEMPERATURE - max_tokens: null # CODE_REVIEW_MAX_TOKENS + enabled: true # REVIEW_CODE_REVIEW_ENABLED + max_iters: 5 # REVIEW_CODE_REVIEW_MAX_ITERS + max_llm_calls: 8 # REVIEW_CODE_REVIEW_MAX_LLM_CALLS + model: null # REVIEW_CODE_REVIEW_MODEL + reasoning_effort: null # REVIEW_CODE_REVIEW_REASONING_EFFORT + temperature: null # REVIEW_CODE_REVIEW_TEMPERATURE + max_tokens: null # REVIEW_CODE_REVIEW_MAX_TOKENS memory: - enabled: null # CODE_REVIEW_MEMORY_ENABLED (null -> memory.enabled) + enabled: null # REVIEW_CODE_REVIEW_MEMORY_ENABLED - # Documentation Reviewer signature (compares patches against extracted documentation) - # Note: doc extraction is now deterministic (no LLM) — see doc_extractor.py + # Documentation Review doc: - enabled: true # DOC_ENABLED - max_iters: 1 # DOC_MAX_ITERS - max_llm_calls: 2 # DOC_MAX_LLM_CALLS - model: null # DOC_MODEL - reasoning_effort: null # DOC_REASONING_EFFORT - temperature: null # DOC_TEMPERATURE - max_tokens: null # DOC_MAX_TOKENS + enabled: true # REVIEW_DOC_ENABLED + max_iters: 1 # REVIEW_DOC_MAX_ITERS + max_llm_calls: 2 # REVIEW_DOC_MAX_LLM_CALLS + model: null # REVIEW_DOC_MODEL + reasoning_effort: null # REVIEW_DOC_REASONING_EFFORT + temperature: null # REVIEW_DOC_TEMPERATURE + max_tokens: null # REVIEW_DOC_MAX_TOKENS memory: - enabled: null # DOC_MEMORY_ENABLED (null -> memory.enabled) + enabled: null # REVIEW_DOC_MEMORY_ENABLED - # Scope Identifier signature + # Scope Identification scope: - enabled: true # SCOPE_ENABLED - max_iters: 3 # SCOPE_MAX_ITERS - max_llm_calls: 5 # SCOPE_MAX_LLM_CALLS - model: null # SCOPE_MODEL - reasoning_effort: null # SCOPE_REASONING_EFFORT - temperature: null # SCOPE_TEMPERATURE - max_tokens: null # SCOPE_MAX_TOKENS + enabled: true # REVIEW_SCOPE_ENABLED + max_iters: 3 # REVIEW_SCOPE_MAX_ITERS + max_llm_calls: 5 # REVIEW_SCOPE_MAX_LLM_CALLS + model: null # REVIEW_SCOPE_MODEL + reasoning_effort: null # REVIEW_SCOPE_REASONING_EFFORT + temperature: null # REVIEW_SCOPE_TEMPERATURE + max_tokens: null # REVIEW_SCOPE_MAX_TOKENS + skip_refinement_when_clean: null # REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN memory: - enabled: null # SCOPE_MEMORY_ENABLED (null -> memory.enabled) + enabled: null # REVIEW_SCOPE_MEMORY_ENABLED - # Summarizer signature + # Summary summary: - enabled: true # SUMMARY_ENABLED - max_iters: 1 # SUMMARY_MAX_ITERS - max_llm_calls: 2 # SUMMARY_MAX_LLM_CALLS - model: null # SUMMARY_MODEL (falls back to default_model) - reasoning_effort: null # SUMMARY_REASONING_EFFORT - temperature: null # SUMMARY_TEMPERATURE - max_tokens: null # SUMMARY_MAX_TOKENS + enabled: true # REVIEW_SUMMARY_ENABLED + max_iters: 1 # REVIEW_SUMMARY_MAX_ITERS + max_llm_calls: 2 # REVIEW_SUMMARY_MAX_LLM_CALLS + model: null # REVIEW_SUMMARY_MODEL + reasoning_effort: null # REVIEW_SUMMARY_REASONING_EFFORT + temperature: null # REVIEW_SUMMARY_TEMPERATURE + max_tokens: null # REVIEW_SUMMARY_MAX_TOKENS memory: - enabled: null # SUMMARY_MEMORY_ENABLED (null -> memory.enabled) + enabled: null # REVIEW_SUMMARY_MEMORY_ENABLED - # Auditor signature (quality assessment + recommendation after all reviews) + # Audit audit: - enabled: true # AUDIT_ENABLED - max_iters: 1 # AUDIT_MAX_ITERS - max_llm_calls: 2 # AUDIT_MAX_LLM_CALLS - model: null # AUDIT_MODEL - reasoning_effort: null # AUDIT_REASONING_EFFORT - temperature: null # AUDIT_TEMPERATURE - max_tokens: null # AUDIT_MAX_TOKENS + enabled: true # REVIEW_AUDIT_ENABLED + max_iters: 1 # REVIEW_AUDIT_MAX_ITERS + max_llm_calls: 2 # REVIEW_AUDIT_MAX_LLM_CALLS + model: null # REVIEW_AUDIT_MODEL + reasoning_effort: null # REVIEW_AUDIT_REASONING_EFFORT + temperature: null # REVIEW_AUDIT_TEMPERATURE + max_tokens: null # REVIEW_AUDIT_MAX_TOKENS memory: - enabled: null # AUDIT_MEMORY_ENABLED (null -> memory.enabled) - -# ============================================================================ -# OUTPUT -# ============================================================================ -output_format: markdown # OUTPUT_FORMAT (markdown | json) -output_stdout: true # OUTPUT_STDOUT (enable stdout output) -output_git: true # OUTPUT_GIT (post review to GitHub PR or GitLab MR) -cache_dir: ~/.cache/codespy # CACHE_DIR - -# ============================================================================ -# FILE EXCLUSIONS -# ============================================================================ -# Directories to skip during code review (binary files, lock files, and -# minified files are always excluded automatically) -excluded_directories: # EXCLUDED_DIRECTORIES (JSON array in env) - # Vendor/dependency directories - - vendor - - node_modules - - third_party - - external - - deps - - _vendor - - vendored - # Build output directories - - dist - - build - - out - - target - # Package manager directories - - .bundle - - Pods - - Carthage - - bower_components - - jspm_packages - # Version control - - .git - - .svn - - .hg - # Cache directories - - __pycache__ - - .cache - - .pytest_cache - - .mypy_cache - - .ruff_cache + enabled: null # REVIEW_AUDIT_MEMORY_ENABLED diff --git a/src/codespy/agents/dspy_config.py b/src/codespy/agents/dspy_config.py index 9552089..26b76dd 100644 --- a/src/codespy/agents/dspy_config.py +++ b/src/codespy/agents/dspy_config.py @@ -95,20 +95,20 @@ def new_lm(settings: Settings, config: LLMSettings) -> dspy.LM: "model": config.model, "temperature": config.temperature, "max_tokens": _resolve_max_tokens(config.model, config.max_tokens), - "timeout": settings.llm_timeout, - "num_retries": settings.llm_retries, + "timeout": settings.llm.timeout, + "num_retries": settings.llm.retries, "drop_params": True, "reasoning_effort": config.reasoning_effort, } # Cache system prompts via explicit Anthropic-style cache_control markers. # Only injected for providers that use explicit markers (Anthropic, Bedrock # Anthropic); OpenAI/Gemini have automatic caching that needs no markers. - if settings.enable_prompt_caching and _supports_cache_control(config.model): + if _supports_cache_control(config.model): lm_kwargs["cache_control_injection_points"] = [{"location": "message", "role": "system"}] - elif settings.enable_prompt_caching: + else: logger.debug( - f"Prompt caching enabled but model {config.model} does not use " - f"explicit cache_control markers — skipping injection" + f"Model {config.model} does not use explicit cache_control markers — " + f"using provider-automatic caching" ) return dspy.LM(**lm_kwargs) @@ -150,24 +150,24 @@ def configure_dspy(settings: Settings) -> None: Args: settings: Application settings containing model and API key configuration. """ - model = settings.default_model + model = settings.llm.default_model # Configure LiteLLM environment if needed - openai_key = secret_value(settings.openai_api_key) + openai_key = secret_value(settings.llm.openai_api_key) if openai_key: litellm.openai_key = openai_key - anthropic_key = secret_value(settings.anthropic_api_key) + anthropic_key = secret_value(settings.llm.anthropic_api_key) if anthropic_key: litellm.anthropic_key = anthropic_key # Set up AWS credentials for Bedrock if using Bedrock model if model.startswith("bedrock/"): import os - os.environ["AWS_REGION_NAME"] = settings.aws_region - aws_access = secret_value(settings.aws_access_key_id) + os.environ["AWS_REGION_NAME"] = settings.llm.aws_region + aws_access = secret_value(settings.llm.aws_access_key_id) if aws_access: os.environ["AWS_ACCESS_KEY_ID"] = aws_access - aws_secret = secret_value(settings.aws_secret_access_key) + aws_secret = secret_value(settings.llm.aws_secret_access_key) if aws_secret: os.environ["AWS_SECRET_ACCESS_KEY"] = aws_secret @@ -201,9 +201,7 @@ def configure_dspy(settings: Settings) -> None: enable_memory_cache=True, enable_disk_cache=False, memory_max_entries=10000 ) - if not settings.enable_prompt_caching: - prompt_cache_status = "disabled" - elif _supports_cache_control(model): + if _supports_cache_control(model): prompt_cache_status = "enabled (cache_control markers)" else: prompt_cache_status = "enabled (provider-automatic, no markers)" @@ -211,7 +209,7 @@ def configure_dspy(settings: Settings) -> None: f"Configured DSPy with model: {model} " f"(TwoStepAdapter with extraction_model={extraction_model}, " f"max_tokens={_resolve_max_tokens(defaults.model, defaults.max_tokens)}, " - f"timeout={settings.llm_timeout}s, retries={settings.llm_retries}, " + f"timeout={settings.llm.timeout}s, retries={settings.llm.retries}, " f"provider prompt caching {prompt_cache_status})" ) @@ -220,7 +218,7 @@ def verify_model_access(settings: Settings) -> tuple[bool, str]: """Verify that all configured models are accessible. Checks the default model, all per-signature model overrides, and the - memory reflection models, so a typo in any of them fails fast at startup + memory reflection modules, so a typo in any of them fails fast at startup rather than mid-review. Args: @@ -230,21 +228,26 @@ def verify_model_access(settings: Settings) -> tuple[bool, str]: Tuple of (success, message) """ # Collect all unique models from config - models_to_check: set[str] = {settings.default_model} + models_to_check: set[str] = {settings.llm.default_model} # Check all signature-specific models - for _sig_name, sig_config in settings.signatures.items(): + for _sig_name, sig_config in settings.review.signatures().items(): if sig_config.model: models_to_check.add(sig_config.model) # Global extraction model (if different from default_model) - if settings.extraction_model: - models_to_check.add(settings.extraction_model) + if settings.llm.extraction_model: + models_to_check.add(settings.llm.extraction_model) + # Memory reflection modules for module in REFLECTION_MODULES: reflection = settings.get_llm_config(module) models_to_check.add(reflection.model) + # Cerebral retain model + cerebral = settings.get_llm_config("cerebral") + models_to_check.add(cerebral.model) + # Check each model verified: list[str] = [] failed: list[str] = [] diff --git a/src/codespy/agents/review/code_review/agent.py b/src/codespy/agents/review/code_review/agent.py index 20b0ed0..563c338 100644 --- a/src/codespy/agents/review/code_review/agent.py +++ b/src/codespy/agents/review/code_review/agent.py @@ -333,7 +333,7 @@ async def _review_scope( issues = [ issue for issue in (result.issues or []) - if issue.confidence >= self._settings.min_confidence + if issue.confidence >= self._settings.review.min_confidence ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} @@ -357,7 +357,7 @@ def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): issues = [ issue for issue in (result.issues or []) - if issue.confidence >= self._settings.min_confidence + if issue.confidence >= self._settings.review.min_confidence ] restore_repo_paths(issues, scope.subroot) logger.debug(f" Scope {scope.subroot}: {len(issues)} code review issues") diff --git a/src/codespy/agents/review/doc/agent.py b/src/codespy/agents/review/doc/agent.py index 1fd4aa3..f86db8d 100644 --- a/src/codespy/agents/review/doc/agent.py +++ b/src/codespy/agents/review/doc/agent.py @@ -256,7 +256,7 @@ async def _review_scope( issues = [ issue for issue in (result.issues or []) - if issue.confidence >= self._settings.min_confidence + if issue.confidence >= self._settings.review.min_confidence ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} @@ -282,7 +282,7 @@ def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): issues = [ issue for issue in (result.issues or []) - if issue.confidence >= self._settings.min_confidence + if issue.confidence >= self._settings.review.min_confidence ] restore_repo_paths(issues, scope.subroot) logger.debug(f" Scope {scope.subroot}: {len(issues)} doc issues") diff --git a/src/codespy/agents/review/scope/agent.py b/src/codespy/agents/review/scope/agent.py index bbb53be..0644391 100644 --- a/src/codespy/agents/review/scope/agent.py +++ b/src/codespy/agents/review/scope/agent.py @@ -598,7 +598,7 @@ def _resolve( Returns: Tuple of (active scopes, orphan files) """ - excluded_dirs = self._settings.excluded_directories + excluded_dirs = self._settings.review.excluded_directories manifests = self._discover_manifests(repo_path, changed_files, excluded_dirs) logger.info( "Manifest discovery at %s found %d manifest(s): %s", @@ -1207,7 +1207,7 @@ async def aforward( is_local = review_context.metadata.is_local run_id = review_context.metadata.run_id - excluded_dirs = self._settings.excluded_directories + excluded_dirs = self._settings.review.excluded_directories reviewable_files = [f for f in pr.changed_files if should_review_file(f, excluded_dirs)] if not reviewable_files: return [] diff --git a/src/codespy/agents/review/supply_chain/agent.py b/src/codespy/agents/review/supply_chain/agent.py index 232d83c..4f91dcd 100644 --- a/src/codespy/agents/review/supply_chain/agent.py +++ b/src/codespy/agents/review/supply_chain/agent.py @@ -435,7 +435,7 @@ async def _review_scope( issues = [ issue for issue in result.issues - if issue.confidence >= self._settings.min_confidence + if issue.confidence >= self._settings.review.min_confidence ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} @@ -461,7 +461,7 @@ def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): issues = [ issue for issue in result.issues - if issue.confidence >= self._settings.min_confidence + if issue.confidence >= self._settings.review.min_confidence ] # Restore repo-root-relative paths in reported issues restore_repo_paths(issues, scope.subroot) diff --git a/src/codespy/cli.py b/src/codespy/cli.py index 99cdc90..d8e6cff 100644 --- a/src/codespy/cli.py +++ b/src/codespy/cli.py @@ -76,12 +76,12 @@ def config( console.print(Panel("[bold]Current Configuration[/bold]", title="codespy")) # Show non-sensitive settings - console.print(f"[bold]Model:[/bold] {settings.default_model}") - console.print(f"[bold]AWS Region:[/bold] {settings.aws_region}") - console.print(f"[bold]Reasoning Effort:[/bold] {settings.default_reasoning_effort}") + console.print(f"[bold]Model:[/bold] {settings.llm.default_model}") + console.print(f"[bold]AWS Region:[/bold] {settings.llm.aws_region}") + console.print(f"[bold]Reasoning Effort:[/bold] {settings.llm.default_reasoning_effort}") - console.print(f"[bold]Output Format:[/bold] {settings.output_format}") - console.print(f"[bold]Cache Directory:[/bold] {settings.cache_dir}") + console.print(f"[bold]Output Format:[/bold] {settings.review.output_format}") + console.print(f"[bold]Cache Directory:[/bold] {settings.review.cache_dir}") # Show GitHub token status github_token_source = get_github_token_source() @@ -105,9 +105,9 @@ def config( console.print(f"[bold]GitLab URL:[/bold] {settings.gitlab_url}") - openai_status = "[green]configured[/green]" if secret_value(settings.openai_api_key) else "[dim]not set[/dim]" + openai_status = "[green]configured[/green]" if secret_value(settings.llm.openai_api_key) else "[dim]not set[/dim]" anthropic_status = ( - "[green]configured[/green]" if secret_value(settings.anthropic_api_key) else "[dim]not set[/dim]" + "[green]configured[/green]" if secret_value(settings.llm.anthropic_api_key) else "[dim]not set[/dim]" ) console.print(f"[bold]OpenAI API Key:[/bold] {openai_status}") console.print(f"[bold]Anthropic API Key:[/bold] {anthropic_status}") diff --git a/src/codespy/cli_local.py b/src/codespy/cli_local.py index f9175db..f645d84 100644 --- a/src/codespy/cli_local.py +++ b/src/codespy/cli_local.py @@ -51,7 +51,7 @@ def review_local( typer.Option( "--model", "-m", - help="LLM model to use (overrides DEFAULT_MODEL env var)", + help="LLM model to use (overrides LLM_DEFAULT_MODEL env var)", ), ] = None, ) -> None: @@ -81,9 +81,9 @@ def review_local( raise typer.Exit(1) from None if model: - settings.default_model = model + settings.llm.default_model = model if output: - settings.output_format = output # type: ignore + settings.review.output_format = output # type: ignore repo = Path(repo_path if repo_path else os.getcwd()).resolve() @@ -100,8 +100,8 @@ def review_local( f"[bold blue]Reviewing local changes[/bold blue]\n" f"[bold]Repository:[/bold] {repo}\n" f"[bold]Base ref:[/bold] {base_ref}\n" - f"[bold]Model:[/bold] {settings.default_model}\n" - f"[bold]Output:[/bold] {settings.output_format}", + f"[bold]Model:[/bold] {settings.llm.default_model}\n" + f"[bold]Output:[/bold] {settings.review.output_format}", title="codespy review-local", ) ) @@ -137,7 +137,7 @@ def review_local( from codespy.workflows.review.reporters import StdoutReporter - stdout_reporter = StdoutReporter(format=settings.output_format, console=console) + stdout_reporter = StdoutReporter(format=settings.review.output_format, console=console) stdout_reporter.report(result) except Exception as e: @@ -174,7 +174,7 @@ def review_uncommitted( typer.Option( "--model", "-m", - help="LLM model to use (overrides DEFAULT_MODEL env var)", + help="LLM model to use (overrides LLM_DEFAULT_MODEL env var)", ), ] = None, ) -> None: @@ -201,9 +201,9 @@ def review_uncommitted( raise typer.Exit(1) from None if model: - settings.default_model = model + settings.llm.default_model = model if output: - settings.output_format = output # type: ignore + settings.review.output_format = output # type: ignore repo = Path(repo_path if repo_path else os.getcwd()).resolve() @@ -219,8 +219,8 @@ def review_uncommitted( Panel( f"[bold blue]Reviewing uncommitted changes[/bold blue]\n" f"[bold]Repository:[/bold] {repo}\n" - f"[bold]Model:[/bold] {settings.default_model}\n" - f"[bold]Output:[/bold] {settings.output_format}", + f"[bold]Model:[/bold] {settings.llm.default_model}\n" + f"[bold]Output:[/bold] {settings.review.output_format}", title="codespy review-uncommitted", ) ) @@ -256,7 +256,7 @@ def review_uncommitted( from codespy.workflows.review.reporters import StdoutReporter - stdout_reporter = StdoutReporter(format=settings.output_format, console=console) + stdout_reporter = StdoutReporter(format=settings.review.output_format, console=console) stdout_reporter.report(result) except Exception as e: diff --git a/src/codespy/cli_remote.py b/src/codespy/cli_remote.py index 594b148..26965a4 100644 --- a/src/codespy/cli_remote.py +++ b/src/codespy/cli_remote.py @@ -44,7 +44,7 @@ def review( typer.Option( "--model", "-m", - help="LLM model to use (overrides DEFAULT_MODEL env var)", + help="LLM model to use (overrides LLM_DEFAULT_MODEL env var)", ), ] = None, stdout: Annotated[ @@ -95,13 +95,13 @@ def review( # Override settings if provided via CLI (CLI > env > yaml > defaults) if model: - settings.default_model = model + settings.llm.default_model = model if output: - settings.output_format = output # type: ignore + settings.review.output_format = output # type: ignore if stdout is not None: - settings.output_stdout = stdout + settings.review.output_stdout = stdout if git_comment is not None: - settings.output_git = git_comment + settings.review.output_git = git_comment # Validate URL format if not is_supported_url(pr_url): @@ -140,9 +140,9 @@ def review( # Build output destinations display output_destinations = [] - if settings.output_stdout: - output_destinations.append(f"stdout ({settings.output_format})") - if settings.output_git: + if settings.review.output_stdout: + output_destinations.append(f"stdout ({settings.review.output_format})") + if settings.review.output_git: output_destinations.append(f"{platform.title()} comment") output_display = ", ".join(output_destinations) if output_destinations else "[red]none[/red]" @@ -150,7 +150,7 @@ def review( Panel( f"[bold blue]Reviewing PR:[/bold blue] {pr_url}\n" f"[bold]Platform:[/bold] {platform.title()}\n" - f"[bold]Model:[/bold] {settings.default_model}\n" + f"[bold]Model:[/bold] {settings.llm.default_model}\n" f"[bold]Output:[/bold] {output_display}\n" f"[bold]{platform.title()} Token:[/bold] [green]found[/green] " f"[dim]({token_source})[/dim]", @@ -191,11 +191,11 @@ def review( # Output results using reporters from codespy.workflows.review.reporters import GitReporter, StdoutReporter - if settings.output_stdout: - stdout_reporter = StdoutReporter(format=settings.output_format, console=console) + if settings.review.output_stdout: + stdout_reporter = StdoutReporter(format=settings.review.output_format, console=console) stdout_reporter.report(result) - if settings.output_git: + if settings.review.output_git: console.print(f"[dim]Posting review to {platform.title()}...[/dim]") git_reporter = GitReporter(url=pr_url, settings=settings) git_reporter.report(result) diff --git a/src/codespy/config.py b/src/codespy/config.py index bc65470..2d86e9f 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -8,14 +8,7 @@ from pydantic import Field, SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from codespy.config_utils import secret_value -from codespy.config_dspy import ( - ReasoningEffort, - RLMFallbackConfig, - SignatureConfig, - apply_rlm_fallback_env_overrides, - apply_signature_env_overrides, -) +from codespy.config_dspy import ReviewConfig, RLMFallbackConfig, SignatureConfig from codespy.config_git import ( GitHubConfig, GitLabConfig, @@ -26,21 +19,19 @@ set_github_token_source, set_gitlab_token_source, ) -from codespy.config_io import DEFAULT_EXCLUDED_DIRECTORIES, OutputFormat -from codespy.config_llm import ( - LLMConfig, - discover_anthropic_api_key, - discover_aws_credentials, - discover_gemini_api_key, - discover_openai_api_key, -) +from codespy.config_llm import LLMConfig from codespy.config_memory import ( REFLECTION_MODULES, LLMSettings, MemoryConfig, - apply_memory_env_overrides, + ReflectionModuleConfig, reset_episode_store, ) +from codespy.config_utils import ( + apply_env_overrides, + build_env_map, + secret_value, +) if TYPE_CHECKING: # Imported lazily inside get_memory_budget(): importing this at module level @@ -66,10 +57,35 @@ "GitLabConfig", "SignatureConfig", "MemoryConfig", - "OutputFormat", ] +# Build the env map once at module level +# Define bare fields (credential fields that don't use LLM_ prefix) +_BARE_LLM_FIELDS = { + "openai_api_key", + "openai_api_base", + "anthropic_api_key", + "gemini_api_key", + "aws_region", + "aws_access_key_id", + "aws_secret_access_key", + "aws_profile", + "azure_api_key", + "azure_api_base", + "azure_api_version", +} + +_ENV_MAP = build_env_map( + sections={ + "llm": LLMConfig, + "review": ReviewConfig, + "memory": MemoryConfig, + }, + bare_fields={"llm": _BARE_LLM_FIELDS}, +) + + def _load_yaml_config() -> dict[str, Any]: """Load YAML config file if it exists. @@ -115,52 +131,7 @@ class Settings(BaseSettings): github: GitHubConfig = Field(default_factory=GitHubConfig) gitlab: GitLabConfig = Field(default_factory=GitLabConfig) memory: MemoryConfig = Field(default_factory=MemoryConfig) - rlm_fallback: RLMFallbackConfig = Field(default_factory=RLMFallbackConfig) - - # Flat signature configs (signature_name -> SignatureConfig) - signatures: dict[str, SignatureConfig] = Field(default_factory=dict) - - # Top-level defaults (also available via env vars DEFAULT_MODEL, etc.) - default_model: str = "anthropic/claude-opus-4-6" - extraction_model: str | None = None # TwoStepAdapter extraction (falls back to default_model) - default_max_iters: int = 5 - default_max_llm_calls: int = 8 - # Provider reasoning budget; LiteLLM maps this to each provider's native parameter. - default_reasoning_effort: ReasoningEffort = "medium" - default_temperature: float = 0.2 - # Output token budget per completion. Must be set explicitly: when it is - # omitted LiteLLM silently falls back to its own 4096 default, which - # truncates reasoning models (thinking tokens are charged against this - # budget) and long structured outputs. 32000 matches the output ceiling of - # the Claude 4.x tier and satisfies dspy.LM's >=16000 guard for OpenAI - # reasoning models; new_lm() clamps it down to each model's real ceiling. - default_max_tokens: int = 32000 - - # Global LLM reliability settings - llm_retries: int = 2 # Number of retries for LLM API calls - llm_timeout: int = 240 # Timeout in seconds for LLM calls - - # Enable provider-side prompt caching (Anthropic, OpenAI, Bedrock, etc.) - enable_prompt_caching: bool = True - - # Expand diff hunks to full function bodies (Tree-sitter) before review. - # When False (default), reviewers see original PR patches. - compact_patches: bool = False - - # Minimum confidence threshold for reported issues. - # Issues below this threshold are silently discarded by reviewer modules. - min_confidence: float = Field(default=0.81, ge=0.0, le=1.0) - - # Top-level settings - output_format: OutputFormat = "markdown" - cache_dir: Path = Path.home() / ".cache" / "codespy" - - # Output destinations - output_stdout: bool = True # Enable stdout output (markdown or json) - output_git: bool = True # Enable Git platform review comments (GitHub PR or GitLab MR) - - # File exclusion settings - excluded_directories: list[str] = Field(default=DEFAULT_EXCLUDED_DIRECTORIES) + review: ReviewConfig = Field(default_factory=ReviewConfig) # GitHub token (can also use GITHUB_TOKEN or GH_TOKEN env var) github_token: SecretStr | None = None @@ -172,25 +143,12 @@ class Settings(BaseSettings): gitlab_url: str = "https://gitlab.com" # GITLAB_URL for self-hosted instances gitlab_auto_discover_token: bool = True # GITLAB_AUTO_DISCOVER_TOKEN - # LLM provider settings (flat for simple env var access) - aws_region: str = "us-east-1" - aws_access_key_id: SecretStr | None = None - aws_secret_access_key: SecretStr | None = None - aws_profile: str | None = None - openai_api_key: SecretStr | None = None - anthropic_api_key: SecretStr | None = None - gemini_api_key: SecretStr | None = None - - # Auto-discovery toggles (flat for env var access) - auto_discover_aws: bool = True # AUTO_DISCOVER_AWS - auto_discover_openai: bool = True # AUTO_DISCOVER_OPENAI - auto_discover_anthropic: bool = True # AUTO_DISCOVER_ANTHROPIC - auto_discover_gemini: bool = True # AUTO_DISCOVER_GEMINI - - # Helper methods for signature config def get_signature_config(self, signature_name: str) -> SignatureConfig: - """Get config for a signature.""" - return self.signatures.get(signature_name, SignatureConfig()) + """Get config for a signature. + + Only returns actual signature configs, not other review fields. + """ + return getattr(self.review, signature_name, SignatureConfig()) def is_signature_enabled(self, signature_name: str) -> bool: """Check if a signature is enabled.""" @@ -199,52 +157,65 @@ def is_signature_enabled(self, signature_name: str) -> bool: def get_max_iters(self, name: str) -> int: """Get max_iters for a signature or reflection module (name-specific or default).""" if name in REFLECTION_MODULES: - config = getattr(self.memory, name) - return config.max_iters or self.default_max_iters + config = getattr(self.memory.hippocampus, name) + return config.max_iters or self.llm.default_max_iters config = self.get_signature_config(name) - return config.max_iters or self.default_max_iters + return config.max_iters or self.llm.default_max_iters def get_max_llm_calls(self, name: str) -> int: """Get max_llm_calls for a signature or reflection module (name-specific or default).""" if name in REFLECTION_MODULES: - config = getattr(self.memory, name) - return config.max_llm_calls or self.default_max_llm_calls + config = getattr(self.memory.hippocampus, name) + return config.max_llm_calls or self.llm.default_max_llm_calls config = self.get_signature_config(name) - return config.max_llm_calls or self.default_max_llm_calls + return config.max_llm_calls or self.llm.default_max_llm_calls def get_llm_config(self, name: str) -> LLMSettings: """Resolve the LLM settings for one named unit of LLM work. ``name`` addresses either a signature or a memory reflection module:: - "code_review" -> signatures.code_review - "distiller" -> memory.distiller + "code_review" -> review.code_review + "distiller" -> memory.hippocampus.distiller + "cerebral" -> memory.cerebral.retain - Every field falls back to its top-level ``default_*`` counterpart, so + Every field falls back to its ``llm.default_*`` counterpart, so the result has no ``None`` fields and callers never re-apply fallbacks. Args: name: A signature name, or a reflection module name - (see ``REFLECTION_MODULES``). + (see ``REFLECTION_MODULES``), or "cerebral". Returns: The fully resolved settings for ``name``. """ - if name in REFLECTION_MODULES: - config = getattr(self.memory, name) + if name == "cerebral": + config = self.memory.cerebral.retain + model = config.model or self.llm.default_model + # For cerebral, every other field falls back to llm.default_* values + defaults = self.llm + elif name in REFLECTION_MODULES: + config = getattr(self.memory.hippocampus, name) + model = config.model or self.llm.default_model + defaults = self.llm + elif name == "default": + # Pure defaults for configure_dspy + model = self.llm.default_model + config = ReflectionModuleConfig() # Empty config + defaults = self.llm else: config = self.get_signature_config(name) + model = config.model or self.llm.default_model + defaults = self.llm - model = config.model or self.default_model - module_extraction = getattr(config, "extraction_model", None) return LLMSettings( model=model, - extraction_model=module_extraction or self.extraction_model or model, - reasoning_effort=config.reasoning_effort or self.default_reasoning_effort, + extraction_model=self.llm.extraction_model or model, + reasoning_effort=config.reasoning_effort or defaults.default_reasoning_effort, temperature=( - config.temperature if config.temperature is not None else self.default_temperature + config.temperature if config.temperature is not None else defaults.default_temperature ), - max_tokens=config.max_tokens or self.default_max_tokens, + max_tokens=config.max_tokens or defaults.default_max_tokens, ) def get_scan_unchanged(self, signature_name: str) -> bool: @@ -268,17 +239,17 @@ def get_memory_enabled(self, signature_name: str) -> bool: def get_memory_budget(self, signature_name: str) -> "MemoryBudget": """Resolve the ``MemoryBudget`` for a signature. - Token budgets are global (``memory.default_*``); only ``enabled`` + Token budgets are global (``memory.hippocampus.*``); only ``enabled`` supports per-signature overrides. """ from codespy.agents.memory.hippocampus.budget import MemoryBudget return MemoryBudget( - max_context_memory_tokens=self.memory.max_context_memory_tokens, - max_context_item_tokens=self.memory.max_context_item_tokens, - max_trajectory_tokens=self.memory.max_trajectory_tokens, - max_question_tokens=self.memory.max_question_tokens, - compact_trajectory=self.memory.compact_trajectory, + max_context_memory_tokens=self.memory.hippocampus.max_context_memory_tokens, + max_context_item_tokens=self.memory.hippocampus.max_context_item_tokens, + max_trajectory_tokens=self.memory.hippocampus.max_trajectory_tokens, + max_question_tokens=self.memory.hippocampus.max_question_tokens, + compact_trajectory=self.memory.hippocampus.compact_trajectory, ) def get_scope_skip_refinement(self) -> bool: @@ -302,21 +273,21 @@ def get_rlm_threshold(self, module_type: str) -> float: Returns: Threshold ratio (0.0-1.0), or 1.0 if disabled. """ - if not self.rlm_fallback.enabled: + if not self.llm.rlm_fallback.enabled: return 1.0 - return getattr(self.rlm_fallback, f"{module_type}_threshold", 1.0) + return getattr(self.llm.rlm_fallback, f"{module_type}_threshold", 1.0) def log_signature_configs(self) -> None: """Log all signature and reflection module LLM configurations.""" logger.info("RLM fallback configuration:") logger.info( - f" enabled={self.rlm_fallback.enabled}, " - f"react_threshold={self.rlm_fallback.react_threshold}, " - f"chain_of_thought_threshold={self.rlm_fallback.chain_of_thought_threshold}, " - f"predict_threshold={self.rlm_fallback.predict_threshold}" + f" enabled={self.llm.rlm_fallback.enabled}, " + f"react_threshold={self.llm.rlm_fallback.react_threshold}, " + f"chain_of_thought_threshold={self.llm.rlm_fallback.chain_of_thought_threshold}, " + f"predict_threshold={self.llm.rlm_fallback.predict_threshold}" ) logger.info("Signature configurations:") - for sig_name, sig_config in self.signatures.items(): + for sig_name, sig_config in self.review.signatures().items(): status = "enabled" if sig_config.enabled else "disabled" llm = self.get_llm_config(sig_name) logger.info( @@ -344,12 +315,8 @@ def load_yaml_config(cls, values: dict[str, Any]) -> dict[str, Any]: Priority: Environment Variables > YAML Config > Defaults """ yaml_config = _load_yaml_config() - yaml_config = apply_signature_env_overrides(yaml_config) - # MEMORY_* env vars target the nested `memory` model, which - # pydantic-settings cannot populate on its own (no env_nested_delimiter). - yaml_config = apply_memory_env_overrides(yaml_config) - # RLM_FALLBACK_* env vars target the nested `rlm_fallback` model. - yaml_config = apply_rlm_fallback_env_overrides(yaml_config) + # Apply unified env overrides before merging + yaml_config = apply_env_overrides(yaml_config, _ENV_MAP) # Merge YAML config into values only if not already set (env vars take precedence) for key, val in yaml_config.items(): @@ -469,7 +436,7 @@ def is_placeholder(token: str) -> bool: @model_validator(mode="after") def expand_paths(self) -> "Settings": """Expand ~ in paths to the user's home directory.""" - self.cache_dir = Path(self.cache_dir).expanduser().resolve() + self.review.cache_dir = Path(self.review.cache_dir).expanduser().resolve() return self @model_validator(mode="after") @@ -480,92 +447,17 @@ def sync_llm_settings(self) -> "Settings": env vars (flat fields from pydantic-settings) > YAML/nested > defaults. """ merged = self.llm.sync_from_flat( - openai_api_key=self.openai_api_key, - anthropic_api_key=self.anthropic_api_key, - gemini_api_key=self.gemini_api_key, - aws_region=self.aws_region, - aws_access_key_id=self.aws_access_key_id, - aws_secret_access_key=self.aws_secret_access_key, + openai_api_key=self.llm.openai_api_key, + anthropic_api_key=self.llm.anthropic_api_key, + gemini_api_key=self.llm.gemini_api_key, + aws_region=self.llm.aws_region, + aws_access_key_id=self.llm.aws_access_key_id, + aws_secret_access_key=self.llm.aws_secret_access_key, ) - # Propagate merged values back to flat fields + # Propagate merged values back to llm for field, value in merged.items(): if value is not None: - setattr(self, field, value) - return self - - @model_validator(mode="after") - def resolve_llm_credentials(self) -> "Settings": - """Auto-discover LLM provider credentials if not explicitly set.""" - - def is_placeholder(value: str | None) -> bool: - """Check if value looks like a placeholder.""" - if not value: - return True - placeholders = ["xxx", "your", "key", "example", "placeholder", "null"] - value_lower = value.lower() - return any(p in value_lower for p in placeholders) - - # AWS credentials auto-discovery - if not secret_value(self.aws_access_key_id) or not secret_value(self.aws_secret_access_key): - auto_discover = self.llm.auto_discover_aws and self.auto_discover_aws - if auto_discover: - access_key, secret_key, region, profile, source = discover_aws_credentials() - if access_key and secret_key: - self.aws_access_key_id = SecretStr(access_key) - self.aws_secret_access_key = SecretStr(secret_key) - self.llm.aws_access_key_id = SecretStr(access_key) - self.llm.aws_secret_access_key = SecretStr(secret_key) - if region: - self.aws_region = region - self.llm.aws_region = region - if profile: - self.aws_profile = profile - self.llm.aws_profile = profile - logger.debug(f"AWS credentials discovered from: {source}") - else: - logger.debug("AWS credentials auto-discovery is disabled") - - # OpenAI API key auto-discovery - openai_val = secret_value(self.openai_api_key) - if not openai_val or is_placeholder(openai_val): - auto_discover = self.llm.auto_discover_openai and self.auto_discover_openai - if auto_discover: - key, source = discover_openai_api_key() - if key and not is_placeholder(key): # key is raw str from discovery - self.openai_api_key = SecretStr(key) - self.llm.openai_api_key = SecretStr(key) - logger.debug(f"OpenAI API key discovered from: {source}") - else: - logger.debug("OpenAI API key auto-discovery is disabled") - - # Anthropic API key auto-discovery - anthropic_val = secret_value(self.anthropic_api_key) - if not anthropic_val or is_placeholder(anthropic_val): - auto_discover = self.llm.auto_discover_anthropic and self.auto_discover_anthropic - if auto_discover: - key, source = discover_anthropic_api_key() - if key and not is_placeholder(key): # key is raw str from discovery - self.anthropic_api_key = SecretStr(key) - self.llm.anthropic_api_key = SecretStr(key) - logger.debug(f"Anthropic API key discovered from: {source}") - else: - logger.debug("Anthropic API key auto-discovery is disabled") - - # Gemini API key auto-discovery - gemini_val = secret_value(self.gemini_api_key) - if not gemini_val or is_placeholder(gemini_val): - auto_discover = self.llm.auto_discover_gemini and self.auto_discover_gemini - if auto_discover: - key, source = discover_gemini_api_key() - if key and not is_placeholder(key): # key is raw str from discovery - self.gemini_api_key = SecretStr(key) - self.llm.gemini_api_key = SecretStr(key) - logger.debug(f"Gemini API key discovered from: {source}") - elif source != "not found": - logger.debug(f"Gemini: {source}") - else: - logger.debug("Gemini API key auto-discovery is disabled") - + setattr(self.llm, field, value) return self diff --git a/src/codespy/config_dspy.py b/src/codespy/config_dspy.py index f3cb85e..0daf35c 100644 --- a/src/codespy/config_dspy.py +++ b/src/codespy/config_dspy.py @@ -1,11 +1,12 @@ """DSPy signatures configuration and environment variable handling.""" import logging -import os from typing import Any, Literal from pydantic import BaseModel, Field +from codespy.config_io import DEFAULT_EXCLUDED_DIRECTORIES, OutputFormat + logger = logging.getLogger(__name__) @@ -56,14 +57,54 @@ class SignatureConfig(BaseModel): memory: MemorySignatureConfig = Field(default_factory=MemorySignatureConfig) -# Known signature names for env var routing -SIGNATURE_NAMES = { - "code_review", - "doc", - "scope", - "supply_chain", - "summary", - "audit", +class ReviewConfig(BaseModel): + """Configuration for the review subsystem. + + Contains per-signature configs and top-level review settings. + """ + + # Minimum confidence threshold for reported issues + min_confidence: float = Field(default=0.81, ge=0.0, le=1.0) + + # Output settings + output_format: OutputFormat = "markdown" + output_stdout: bool = True # Enable stdout output + output_git: bool = True # Enable Git platform review comments + + # Cache directory + cache_dir: str = "~/.cache/codespy" + + # File exclusion settings + excluded_directories: list[str] = Field(default=DEFAULT_EXCLUDED_DIRECTORIES) + + # Per-signature configs + supply_chain: SignatureConfig = Field(default_factory=SignatureConfig) + code_review: SignatureConfig = Field(default_factory=SignatureConfig) + doc: SignatureConfig = Field(default_factory=lambda: SignatureConfig(max_iters=1, max_llm_calls=2)) + scope: SignatureConfig = Field(default_factory=lambda: SignatureConfig(max_iters=3, max_llm_calls=5)) + summary: SignatureConfig = Field(default_factory=lambda: SignatureConfig(max_iters=1, max_llm_calls=2)) + audit: SignatureConfig = Field(default_factory=lambda: SignatureConfig(max_iters=1, max_llm_calls=2)) + + def signatures(self) -> dict[str, SignatureConfig]: + """Return a dict of signature name -> SignatureConfig.""" + return { + "supply_chain": self.supply_chain, + "code_review": self.code_review, + "doc": self.doc, + "scope": self.scope, + "summary": self.summary, + "audit": self.audit, + } + + +# Known signature names derived from ReviewConfig +SIGNATURE_NAMES = set(ReviewConfig.model_fields.keys()) - { + "min_confidence", + "output_format", + "output_stdout", + "output_git", + "cache_dir", + "excluded_directories", } # Create uppercase prefixes for matching (e.g., "CODE_REVIEW_", "SUPPLY_CHAIN_") @@ -85,109 +126,3 @@ class SignatureConfig(BaseModel): "CHAIN_OF_THOUGHT_THRESHOLD": "chain_of_thought_threshold", "PREDICT_THRESHOLD": "predict_threshold", } - - -def apply_rlm_fallback_env_overrides(config: dict[str, Any]) -> dict[str, Any]: - """Apply RLM_FALLBACK_* environment variable overrides. - - Maps: RLM_FALLBACK_REACT_THRESHOLD=0.30 -> rlm_fallback.react_threshold - """ - from dotenv import dotenv_values - - env_vars = {**dotenv_values(".env"), **os.environ} - - for key, value in env_vars.items(): - if value is None: - continue - key_upper = key.upper() - if not key_upper.startswith("RLM_FALLBACK_"): - continue - remainder = key_upper[len("RLM_FALLBACK_") :] - field = RLM_FALLBACK_ENV_SETTINGS.get(remainder) - if field is None: - continue - rlm_config = config.setdefault("rlm_fallback", {}) - if not isinstance(rlm_config, dict): - continue - rlm_config[field] = convert_env_value(value) - - return config - - -def convert_env_value(value: str) -> Any: - """Convert environment variable string to appropriate Python type.""" - import json - - if value.lower() in ("true", "false"): - return value.lower() == "true" - elif value.isdigit(): - return int(value) - elif value.startswith("[") or value.startswith("{"): - try: - return json.loads(value) - except json.JSONDecodeError: - return value - elif value.lower() == "null" or value == "": - return None - return value - - -def apply_signature_env_overrides(config: dict[str, Any]) -> dict[str, Any]: - """Apply environment variable overrides to config for signature settings. - - Handles three patterns: - - ``CODE_REVIEW_MAX_ITERS`` -> signatures.code_review.max_iters - - ``SUPPLY_CHAIN_ENABLED`` -> signatures.supply_chain.enabled - - ``CODE_REVIEW_MEMORY_ENABLED`` -> signatures.code_review.memory.enabled - - Top-level settings (DEFAULT_MODEL, AWS_REGION, MEMORY_ENABLED, etc.) - are handled directly by pydantic-settings and should NOT be processed here. - """ - # Load .env file first to ensure env vars are available - from dotenv import dotenv_values - - env_vars = {**dotenv_values(".env"), **os.environ} # .env + actual env vars - - for key, value in env_vars.items(): - if value is None: - continue - key_upper = key.upper() - - # Match signature prefix - signature_name = None - remainder = None - for prefix, sig_name in SIGNATURE_PREFIXES.items(): - if key_upper.startswith(prefix): - signature_name = sig_name - remainder = key_upper[len(prefix) :] - break - - if not signature_name or remainder is None: - continue - - # Nested memory setting: _MEMORY_ - if remainder.startswith("MEMORY_"): - memory_setting = remainder[len("MEMORY_") :].lower() - if memory_setting not in MEMORY_SIGNATURE_SETTINGS: - continue - if "signatures" not in config: - config["signatures"] = {} - if signature_name not in config["signatures"]: - config["signatures"][signature_name] = {} - if "memory" not in config["signatures"][signature_name]: - config["signatures"][signature_name]["memory"] = {} - sig_memory = config["signatures"][signature_name]["memory"] - sig_memory[memory_setting] = convert_env_value(value) - continue - - # Flat signature setting - setting = remainder.lower() - if setting not in SIGNATURE_SETTINGS: - continue - if "signatures" not in config: - config["signatures"] = {} - if signature_name not in config["signatures"]: - config["signatures"][signature_name] = {} - config["signatures"][signature_name][setting] = convert_env_value(value) - - return config diff --git a/src/codespy/config_llm.py b/src/codespy/config_llm.py index e728f21..9c7ac03 100644 --- a/src/codespy/config_llm.py +++ b/src/codespy/config_llm.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field, SecretStr +from codespy.config_dspy import ReasoningEffort, RLMFallbackConfig from codespy.config_utils import secret_value logger = logging.getLogger(__name__) @@ -275,8 +276,21 @@ class LLMConfig(BaseModel): azure_api_base: str | None = None azure_api_version: str | None = None - # Enable provider-side prompt caching (Anthropic, OpenAI, Bedrock, etc.) - enable_prompt_caching: bool = True + # Default model settings + default_model: str = "anthropic/claude-opus-4-6" + extraction_model: str | None = None # TwoStepAdapter extraction (falls back to default_model) + default_max_iters: int = 5 + default_max_llm_calls: int = 8 + default_reasoning_effort: ReasoningEffort = "medium" + default_temperature: float = 0.2 + default_max_tokens: int = 32000 + + # Global LLM reliability settings + retries: int = 2 # LLM_RETRIES + timeout: int = 240 # LLM_TIMEOUT + + # RLM fallback configuration + rlm_fallback: RLMFallbackConfig = Field(default_factory=RLMFallbackConfig) def sync_from_flat( self, @@ -314,5 +328,4 @@ def sync_from_flat( "gemini_api_key": gemini_api_key if secret_value(gemini_api_key) else self.gemini_api_key, "aws_access_key_id": aws_access_key_id if secret_value(aws_access_key_id) else self.aws_access_key_id, "aws_secret_access_key": aws_secret_access_key if secret_value(aws_secret_access_key) else self.aws_secret_access_key, - "enable_prompt_caching": self.enable_prompt_caching, } diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 404f020..92bd4de 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -3,8 +3,6 @@ from __future__ import annotations import logging -import os - from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field @@ -19,7 +17,7 @@ # Default embedding model per LLM provider prefix. Used by get_cerebral() -# when hindsight.embeddings_model is None. litellm-sdk routes through litellm. +# when embeddings.model is None. litellm-sdk routes through litellm. EMBEDDING_MODELS: dict[str, str] = { "bedrock": "bedrock/cohere.embed-multilingual-v3", "openai": "openai/text-embedding-3-small", @@ -30,36 +28,67 @@ } -class HindsightConfig(BaseModel): - """Hindsight MemoryEngine settings for Cerebral semantic memory.""" - embeddings_model: str | None = None # MEMORY_HINDSIGHT_EMBEDDINGS_MODEL - - class ReflectionModuleConfig(BaseModel): """LLM overrides for a single reflection module (Distiller / Cartographer). All fields are optional — ``None`` means "fall back to the corresponding - top-level ``default_*`` setting" (see ``codespy.config.Settings``). + top-level ``llm.default_*`` setting" (see ``codespy.config.Settings``). The reflection modules are compact summarize/curate tasks rather than deep analysis, so they are good candidates for a cheaper model tier than the one used for code review. """ - model: str | None = None # MEMORY__MODEL - reasoning_effort: ReasoningEffort | None = None # MEMORY__REASONING_EFFORT - temperature: float | None = None # MEMORY__TEMPERATURE - max_tokens: int | None = None # MEMORY__MAX_TOKENS - max_iters: int | None = 1 # MEMORY__MAX_ITERS - max_llm_calls: int | None = 2 # MEMORY__MAX_LLM_CALLS + model: str | None = None + reasoning_effort: ReasoningEffort | None = None + temperature: float | None = None + max_tokens: int | None = None + max_iters: int | None = 1 + max_llm_calls: int | None = 2 + + +class HippocampusConfig(BaseModel): + """Hippocampus (episodic memory) configuration.""" + + # Whether to apply head+tail trajectory bounding before distillation. + compact_trajectory: bool = True + + # Token budgets + max_context_memory_tokens: int = Field(default=16384) + max_context_item_tokens: int = Field(default=512) + max_trajectory_tokens: int | None = Field(default=16384) + max_question_tokens: int | None = Field(default=8192) + + # Reflection modules + distiller: ReflectionModuleConfig = Field(default_factory=ReflectionModuleConfig) + cartographer: ReflectionModuleConfig = Field(default_factory=ReflectionModuleConfig) + + +class CerebralRetainConfig(BaseModel): + """Cerebral (semantic memory) LLM configuration for fact extraction.""" + + model: str | None = None # Falls back to llm.default_model + + +class CerebralEmbeddingsConfig(BaseModel): + """Cerebral (semantic memory) embeddings configuration.""" + + model: str | None = None # Auto-derived from provider if unset + + +class CerebralConfig(BaseModel): + """Cerebral (semantic memory) configuration.""" + + retain: CerebralRetainConfig = Field(default_factory=CerebralRetainConfig) + embeddings: CerebralEmbeddingsConfig = Field(default_factory=CerebralEmbeddingsConfig) class LLMSettings(BaseModel): """Fully resolved LLM settings for one named unit of work. Produced by ``Settings.get_llm_config()`` for either a signature - (``signatures.``) or a reflection module (``memory.``): every - field is either the name-specific override or the corresponding top-level + (``review.``) or a reflection module (``memory.hippocampus.``): + every field is either the name-specific override or the corresponding top-level default, so consumers never re-apply fallback logic. """ @@ -75,14 +104,15 @@ class LLMSettings(BaseModel): class PostgresConfig(BaseModel): """External PostgreSQL connection settings (production).""" - host: str | None = None # MEMORY_POSTGRES_HOST - port: int = 5432 # MEMORY_POSTGRES_PORT - user: str | None = None # MEMORY_POSTGRES_USER - password: str | None = None # MEMORY_POSTGRES_PASSWORD - database: str = "codespy" # MEMORY_POSTGRES_DATABASE + + host: str | None = None + port: int = 5432 + user: str | None = None + password: str | None = None + database: str = "codespy" schema_name: str | None = Field( default="episodic", alias="schema" - ) # MEMORY_POSTGRES_SCHEMA (search_path per memory type; None = public) + ) def build_uri(self) -> str | None: """Build a psycopg connection URI. Returns None when host is unset. @@ -95,6 +125,7 @@ def build_uri(self) -> str | None: if not self.host: return None from urllib.parse import quote_plus + user = quote_plus(self.user) if self.user else "postgres" cred = f"{user}:{quote_plus(self.password)}" if self.password else user return f"postgresql://{cred}@{self.host}:{self.port}/{self.database}" @@ -102,211 +133,43 @@ def build_uri(self) -> str | None: class Pg0Config(BaseModel): """pg0-embedded settings (local dev only, ignored when postgres.host is set).""" - name: str = "codespy" # MEMORY_PG0_NAME - port: int | None = None # MEMORY_PG0_PORT - data_dir: str | None = None # MEMORY_PG0_DATA_DIR + + name: str = "codespy" + port: int | None = None + data_dir: str | None = None class MemoryConfig(BaseModel): """Global memory (Hippocampus + Cerebral) configuration. Controls where episodes are persisted and the memory knob applied to - every agent. Per-signature ``memory:`` blocks override ``enabled``. + every agent. Per-signature ``memory:`` blocks override ``enabled``. """ # PostgreSQL connection settings postgres: PostgresConfig = Field(default_factory=PostgresConfig) pg0: Pg0Config = Field(default_factory=Pg0Config) - bank_id: str | None = None # MEMORY_BANK_ID (defaults to "codespy") - - # Master switch — overridable per-signature via signatures..memory.enabled - enabled: bool = False # MEMORY_ENABLED - - # Whether to apply head+tail trajectory bounding before distillation. - # When false, the full trajectory goes to the Distiller and ContextSafe - # RLM fallback handles overflow if it exceeds the model's context window. - compact_trajectory: bool = True # MEMORY_COMPACT_TRAJECTORY - - # Ceiling on the rendered ContextMemory. This is the *persisted* artifact and it - # is prepended to every predictor of the wrapped agent, so it is re-sent on - # every ReAct iteration (~default_max_iters times per scope) plus once per - # reflection call. Easily the most cost-sensitive of the three budgets. - # Approximate item capacity is max_context_memory_tokens divided by - # max_context_item_tokens (16384 / 512 = 32 items). - # MEMORY_MAX_CONTEXT_MEMORY_TOKENS - max_context_memory_tokens: int = Field(default=16384) - - # Per-item ceiling handed to the Distiller/Cartographer as a prompt input, so - # they keep each context-memory item compact instead of spending the whole memory - # budget on one verbose entry. Soft limit: it is expressed to the LLM rather - # than enforced in code (truncating an item could corrupt an exact constant). - # The hard, memory-wide limit is max_context_memory_tokens, enforced by the - # Evictor. MEMORY_MAX_CONTEXT_ITEM_TOKENS - max_context_item_tokens: int = Field(default=512) + bank_id: str | None = None - # Head+tail cap on the agent trajectory fed to the Distiller. Without it a - # single tool-heavy scope can produce a 100k+ token trajectory; TwoStepAdapter - # then sends it twice. 16384 is ~12% of a 128k window and preserves both the - # orientation steps (60% head) and the conclusions (40% tail). - max_trajectory_tokens: int | None = 16384 # MEMORY_MAX_TRAJECTORY_TOKENS + # Master switch — overridable per-signature via review..memory.enabled + enabled: bool = False - # Head+tail cap on the serialized agent inputs used as the Distiller/Cartographer - # "question". Only applies when the caller passes no 'question': otherwise - # every input field is serialized, which for code review means the full patch - # of every changed file. See Hippocampus.max_question_tokens. - max_question_tokens: int | None = 8192 # MEMORY_MAX_QUESTION_TOKENS + # Hippocampus (episodic memory) configuration + hippocampus: HippocampusConfig = Field(default_factory=HippocampusConfig) - # Per-module LLM overrides for the reflection pipeline. - distiller: ReflectionModuleConfig = Field(default_factory=ReflectionModuleConfig) - cartographer: ReflectionModuleConfig = Field(default_factory=ReflectionModuleConfig) - cerebral: ReflectionModuleConfig = Field(default_factory=ReflectionModuleConfig) # MEMORY_CEREBRAL_* - hindsight: HindsightConfig = Field(default_factory=HindsightConfig) - - -# Env var suffix (after MEMORY_POSTGRES_) -> PostgresConfig field name. -POSTGRES_ENV_SETTINGS = { - "HOST": "host", - "PORT": "port", - "USER": "user", - "PASSWORD": "password", - "DATABASE": "database", - "SCHEMA": "schema", -} - -# Env var suffix (after MEMORY_PG0_) -> Pg0Config field name. -PG0_ENV_SETTINGS = { - "NAME": "name", - "PORT": "port", - "DATA_DIR": "data_dir", -} - -HINDSIGHT_ENV_SETTINGS = { - "EMBEDDINGS_MODEL": "embeddings_model", -} + # Cerebral (semantic memory) configuration + cerebral: CerebralConfig = Field(default_factory=CerebralConfig) -# Env var name (without the MEMORY_ prefix) -> MemoryConfig field name. -# ``memory`` is a nested model and ``Settings`` does not set -# ``env_nested_delimiter``, so pydantic-settings cannot populate these fields -# from the environment on its own. apply_memory_env_overrides() bridges the gap. -MEMORY_ENV_SETTINGS = { - "BANK_ID": "bank_id", - "ENABLED": "enabled", - "COMPACT_TRAJECTORY": "compact_trajectory", - "MAX_CONTEXT_MEMORY_TOKENS": "max_context_memory_tokens", - "MAX_CONTEXT_ITEM_TOKENS": "max_context_item_tokens", - "MAX_TRAJECTORY_TOKENS": "max_trajectory_tokens", - "MAX_QUESTION_TOKENS": "max_question_tokens", -} -# The reflection modules, derived from the MemoryConfig fields that hold a +# The reflection modules, derived from the HippocampusConfig fields that hold a # ReflectionModuleConfig. Iterate this instead of hardcoding module names so # adding a new reflection module only requires declaring its field above. REFLECTION_MODULES: tuple[str, ...] = tuple( name - for name, field in MemoryConfig.model_fields.items() + for name, field in HippocampusConfig.model_fields.items() if field.annotation is ReflectionModuleConfig ) -# Env var suffix -> ReflectionModuleConfig field name, routed via -# MEMORY__ (e.g. MEMORY_DISTILLER_MODEL). -REFLECTION_MODULE_ENV_SETTINGS = { - name.upper(): name for name in ReflectionModuleConfig.model_fields -} - -# Maps env prefix (after MEMORY_) -> (config field name, suffix->field map) -NESTED_ENV_PREFIXES: dict[str, tuple[str, dict[str, str]]] = { - "POSTGRES_": ("postgres", POSTGRES_ENV_SETTINGS), - "PG0_": ("pg0", PG0_ENV_SETTINGS), - "HINDSIGHT_": ("hindsight", HINDSIGHT_ENV_SETTINGS), -} -# Add reflection modules dynamically (same pattern, shared settings map) -for _mod in REFLECTION_MODULES: - NESTED_ENV_PREFIXES[f"{_mod.upper()}_"] = (_mod, REFLECTION_MODULE_ENV_SETTINGS) - - -def _generate_bank_id() -> str: - """Generate a default bank_id.""" - return "codespy" - - -def apply_memory_env_overrides(config: dict[str, Any]) -> dict[str, Any]: - """Apply ``MEMORY_*`` environment variable overrides to the ``memory`` block. - - Maps flat env vars onto the nested ``memory`` config, e.g.:: - - MEMORY_POSTGRES_HOST=myhost -> memory.postgres.host - MEMORY_ENABLED=true -> memory.enabled - MEMORY_MAX_CONTEXT_MEMORY_TOKENS=512 -> memory.max_context_memory_tokens - - Nested sub-model overrides use a second level of nesting:: - - MEMORY_DISTILLER_MODEL=... -> memory.distiller.model - MEMORY_CARTOGRAPHER_TEMPERATURE=0 -> memory.cartographer.temperature - - MEMORY_PG0_NAME=mydb -> memory.pg0.name - MEMORY_PG0_PORT=5433 -> memory.pg0.port - - Env vars take precedence over YAML, matching the documented priority - (Environment Variables > YAML Config > Defaults). - - - Note: ``_MEMORY_*`` vars are handled separately by - ``apply_signature_env_overrides`` and are ignored here, since they never - match a bare ``MEMORY_`` prefix. - - Args: - config: The YAML-derived config dict to mutate. - - Returns: - The same dict, with ``memory`` overrides applied. - """ - from dotenv import dotenv_values - - from codespy.config_dspy import convert_env_value - - env_vars = {**dotenv_values(".env"), **os.environ} - - for key, value in env_vars.items(): - if value is None: - continue - key_upper = key.upper() - if not key_upper.startswith("MEMORY_"): - continue - remainder = key_upper[len("MEMORY_") :] - - memory_config = config.setdefault("memory", {}) - if not isinstance(memory_config, dict): - continue - - # Nested sub-model: MEMORY_ - # Checked before the flat lookup, since e.g. MEMORY_PG0_NAME has no entry in - # MEMORY_ENV_SETTINGS and would otherwise be silently dropped. - nested = next( - ( - (field, settings_map, remainder[len(prefix):]) - for prefix, (field, settings_map) in NESTED_ENV_PREFIXES.items() - if remainder.startswith(prefix) - ), - None, - ) - if nested is not None: - field, settings_map, setting = nested - setting_field = settings_map.get(setting) - if setting_field is None: - continue - sub_config = memory_config.setdefault(field, {}) - if not isinstance(sub_config, dict): - continue - sub_config[setting_field] = convert_env_value(value) - continue - - field = MEMORY_ENV_SETTINGS.get(remainder) - if field is None: - continue - memory_config[field] = convert_env_value(value) - - return config - # Cached singleton store. Avoids reconstructing the EpisodeStore's connection pool # on every call. @@ -339,7 +202,7 @@ def get_episode_store(settings: Settings) -> EpisodeStore | None: return _store mem = settings.memory - bank_id = mem.bank_id or _generate_bank_id() + bank_id = mem.bank_id or "codespy" schema = mem.postgres.schema_name # "episodic" by default # Try external PostgreSQL first @@ -418,7 +281,7 @@ def verify_memory_access(settings: Settings) -> tuple[bool, str]: except Exception as e: return False, f"Memory storage not accessible: {e}" - return True, f"Memory storage verified (PostgreSQL, bank={settings.memory.bank_id or _generate_bank_id()})" + return True, f"Memory storage verified (PostgreSQL, bank={settings.memory.bank_id or 'codespy'})" # Cached singleton Cerebral instance. @@ -470,8 +333,8 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: Cerebral activates unconditionally (like ``get_episode_store``). Agent-level ``get_memory_enabled(sig)`` handles per-signature gating. - LLM parameters are auto-derived from the ``cerebral`` - ReflectionModuleConfig model string and ``settings.llm`` credentials. + LLM parameters are auto-derived from the ``cerebral.retain`` + config model string and ``settings.llm`` credentials. The store is built once and cached (module-level singleton). @@ -521,7 +384,7 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: # Choose embedding default based on the provider prefix provider_prefix = model.split("/", 1)[0] if "/" in model else "openai" embeddings_model = ( - settings.memory.hindsight.embeddings_model + settings.memory.cerebral.embeddings.model or EMBEDDING_MODELS.get(provider_prefix, "openai/text-embedding-3-small") ) @@ -539,7 +402,8 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: logger.error( "Cerebral initialization FAILED (bank=%s, schema=semantic). " "Semantic memory is disabled for this run.", - bank_id, exc_info=True, + bank_id, + exc_info=True, ) _cerebral = None _cerebral_built = True @@ -547,7 +411,8 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: logger.info( "Cerebral initialized (bank=%s, provider=litellm, model=%s, schema=semantic)", - bank_id, model, + bank_id, + model, ) _cerebral_built = True return _cerebral diff --git a/src/codespy/config_utils.py b/src/codespy/config_utils.py index 559e7a3..63cc074 100644 --- a/src/codespy/config_utils.py +++ b/src/codespy/config_utils.py @@ -2,7 +2,12 @@ from __future__ import annotations -from pydantic import SecretStr +import json +import os +from typing import Any + +from dotenv import dotenv_values +from pydantic import BaseModel, SecretStr def secret_value(s: SecretStr | None) -> str: @@ -19,3 +24,156 @@ def secret_value(s: SecretStr | None) -> str: if s is None: return "" return s.get_secret_value() + + +def convert_env_value(value: str) -> Any: + """Convert environment variable string to appropriate Python type. + + Conversion rules: + - Empty string: treated as unset (return None to signal skip) + - Literal "null": returns Python None (explicit reset) + - JSON array/object: parsed via json.loads + - "true"/"false": converted to bool + - Other values: returned as raw string (let pydantic coerce) + """ + # Empty string means unset - skip this value + if value == "": + return None # Signal to skip + + # Literal null means explicit reset to None + if value.lower() == "null": + return None + + # JSON arrays/objects + if value.startswith("[") or value.startswith("{"): + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + # Boolean values + if value.lower() in ("true", "false"): + return value.lower() == "true" + + # Return raw string - pydantic will coerce int/float as needed + return value + + +def build_env_map( + sections: dict[str, type[BaseModel]], + bare_fields: dict[str, set[str]] | None = None, +) -> dict[str, tuple[str, ...]]: + """Build a mapping of env var name -> config path tuple. + + Args: + sections: Dict of section name -> BaseModel subclass (e.g., {"llm": LLMConfig}) + bare_fields: Optional dict of section name -> set of field names that + should use bare (non-prefixed) env var names (e.g., {"llm": {"openai_api_key"}}) + + Returns: + Dict mapping env var name to tuple path (e.g., {"LLM_DEFAULT_MODEL": ("llm", "default_model")}) + """ + env_map: dict[str, tuple[str, ...]] = {} + bare_fields = bare_fields or {} + + for section_name, model_cls in sections.items(): + section_bare = bare_fields.get(section_name, set()) + _build_env_map_for_model( + model_cls, + prefix=(section_name,), + bare_fields=section_bare, + env_map=env_map, + ) + + # Validate uniqueness + seen: dict[str, str] = {} + for env_name, path in env_map.items(): + if env_name in seen: + raise ValueError( + f"Duplicate env var name '{env_name}' from paths {seen[env_name]} and {path}" + ) + seen[env_name] = str(path) + + return env_map + + +def _build_env_map_for_model( + model_cls: type[BaseModel], + prefix: tuple[str, ...], + bare_fields: set[str], + env_map: dict[str, tuple[str, ...]], +) -> None: + """Recursively build env map for a model and its nested models.""" + for field_name, field_info in model_cls.model_fields.items(): + annotation = field_info.annotation + + # Check if this field is a nested BaseModel (recurse) or a leaf + is_bare = field_name in bare_fields and len(prefix) == 1 # Only direct children + + if is_bare: + # Use bare field name (e.g., OPENAI_API_KEY instead of LLM_OPENAI_API_KEY) + env_name = field_name.upper() + else: + # Use full path (e.g., LLM_DEFAULT_MODEL) + env_name = "_".join(prefix + (field_name,)).upper() + + # Check if this is a nested BaseModel + origin = getattr(annotation, "__origin__", None) + if origin is not None: + # Handle Optional[X] or List[X] etc - unwrap + args = getattr(annotation, "__args__", ()) + if args: + annotation = args[0] + + # Check if the unwrapped annotation is a BaseModel subclass + try: + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + # Recurse into nested model, but don't add to env_map directly + _build_env_map_for_model( + annotation, + prefix=prefix + (field_name,), + bare_fields=set(), # Nested models don't use bare names + env_map=env_map, + ) + continue + except TypeError: + pass + + # This is a leaf field - add to map + env_map[env_name] = prefix + (field_name,) + + +def apply_env_overrides(config: dict[str, Any], env_map: dict[str, tuple[str, ...]]) -> dict[str, Any]: + """Apply environment variable overrides to a config dict. + + Args: + config: The YAML-derived config dict to mutate. + env_map: Mapping of env var name -> config path tuple. + + Returns: + The same dict, with env overrides applied. + """ + # Load .env file first, then actual env vars (env vars win) + env_vars = {**dotenv_values(".env"), **os.environ} + + for env_name, path in env_map.items(): + value = env_vars.get(env_name) + if value is None: + continue + + converted = convert_env_value(value) + if converted is None and value != "null": + # Empty string - skip + continue + + # Navigate/create the nested dict structure + target = config + for key in path[:-1]: + if key not in target or target[key] is None: + target[key] = {} + target = target[key] + + # Set the value + target[path[-1]] = converted + + return config diff --git a/src/codespy/tools/git/github_client.py b/src/codespy/tools/git/github_client.py index 1a20966..94f851f 100644 --- a/src/codespy/tools/git/github_client.py +++ b/src/codespy/tools/git/github_client.py @@ -148,7 +148,7 @@ def clone_repository( """ # Determine target directory if target_path is None: - cache_dir = self.settings.cache_dir + cache_dir = self.settings.review.cache_dir cache_dir.mkdir(parents=True, exist_ok=True) repo_dir = cache_dir / owner / repo_name else: diff --git a/src/codespy/tools/git/gitlab_client.py b/src/codespy/tools/git/gitlab_client.py index 72dcbbf..a26a72c 100644 --- a/src/codespy/tools/git/gitlab_client.py +++ b/src/codespy/tools/git/gitlab_client.py @@ -223,7 +223,7 @@ def clone_repository( """ # Determine target directory if target_path is None: - cache_dir = self.settings.cache_dir + cache_dir = self.settings.review.cache_dir cache_dir.mkdir(parents=True, exist_ok=True) # Handle nested namespaces repo_dir = cache_dir / owner.replace("/", "_") / repo_name diff --git a/src/codespy/tools/git/patch_utils.py b/src/codespy/tools/git/patch_utils.py deleted file mode 100644 index 2af0e4d..0000000 --- a/src/codespy/tools/git/patch_utils.py +++ /dev/null @@ -1,438 +0,0 @@ -"""Patch compaction utilities to expand diff context to function boundaries.""" - -from __future__ import annotations - -import logging -import re -from pathlib import Path -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from codespy.agents.review.scope.models import ScopeResult - -from codespy.tools.git.models import ChangedFile, FileStatus -from codespy.tools.parsers.treesitter import FunctionInfo, TreeSitterParser - -logger = logging.getLogger(__name__) - -# Non-code file extensions that should not be processed -NON_CODE_EXTENSIONS = { - "md", - "txt", - "rst", - "yaml", - "yml", - "json", - "toml", - "ini", - "cfg", - "conf", - "xml", - "html", - "htm", - "css", - "scss", - "sass", - "less", - "csv", - "tsv", -} - -# Compiled regex for hunk header parsing -HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") - - -def compact_patches(scopes: list[ScopeResult], repo_path: Path) -> None: - """Compact patches on all changed files across scopes (mutates in place). - - For each code file with a patch, attempts compaction. Non-code files - and files where compaction fails keep their original patch. - - Args: - scopes: List of scope results containing changed files - repo_path: Path to the cloned repository - """ - parser = TreeSitterParser(repo_path) - - for scope in scopes: - for file in scope.changed_files: - if not _should_compact_file(file): - continue - - try: - if file.patch is None: - continue - compacted = compact_patch(file.patch, Path(file.filename), repo_path, parser) - if compacted != file.patch: - logger.debug(f"Compacted patch for {file.filename}") - file.patch = compacted - except Exception as e: - logger.debug(f"Failed to compact patch for {file.filename}: {e}") - # Keep original patch on failure - - -def _should_compact_file(file: ChangedFile) -> bool: - """Check if a file should be compacted. - - Args: - file: The ChangedFile to check - - Returns: - True if the file should be compacted - """ - # Skip files without patches - if not file.patch: - return False - - # Skip deleted files (no source to read) - if file.status == FileStatus.REMOVED: - return False - - # Skip non-code files - if file.extension in NON_CODE_EXTENSIONS: - return False - - # Skip binary and lock files - return not (file.is_binary or file.is_lock_file) - - -def compact_patch( - raw_patch: str, - file_path: Path, - repo_path: Path, - parser: TreeSitterParser | None = None, -) -> str: - """Expand diff context to full function bodies using TreeSitter. - - Returns compacted patch string, or raw_patch unchanged if compaction - isn't possible (no TreeSitter, not a code file, deleted file, etc.). - - Args: - raw_patch: The original unified diff patch - file_path: Path to the file (relative to repo root) - repo_path: Path to the repository root - parser: Optional TreeSitterParser instance (created if not provided) - - Returns: - Compacted patch or original patch if compaction fails - """ - # Skip empty patches - if not raw_patch: - return raw_patch - - # Create parser if not provided - if parser is None: - parser = TreeSitterParser(repo_path) - - # Check if TreeSitter is available - if not parser.available: - logger.debug(f"TreeSitter not available, skipping compaction for {file_path}") - return raw_patch - - # Check if file is a code file we can parse - extension = file_path.suffix.lstrip(".") if file_path.suffix else "" - if not extension or extension in NON_CODE_EXTENSIONS: - return raw_patch - - # Get absolute file path - abs_file_path = repo_path / file_path - if not abs_file_path.exists(): - logger.warning(f"Source file not found: {abs_file_path}") - return raw_patch - - # Find function definitions in the file - try: - functions = parser.find_function_definitions(abs_file_path) - except Exception as e: - logger.debug(f"Failed to find functions in {file_path}: {e}") - return raw_patch - - if not functions: - # No functions found, nothing to expand - return raw_patch - - # Parse hunks from patch - hunks = _parse_hunks(raw_patch) - if not hunks: - return raw_patch - - # Read source file lines - try: - source_lines = abs_file_path.read_text().splitlines() - except Exception as e: - logger.debug(f"Failed to read source file {file_path}: {e}") - return raw_patch - - # Expand each hunk to function boundaries - expanded_hunks: list[dict[str, Any]] = [] - for hunk in hunks: - expanded = _expand_hunk_to_functions(hunk, functions, source_lines) - if expanded: - expanded_hunks.append(expanded) - - if not expanded_hunks: - return raw_patch - - # Merge overlapping hunks - merged_hunks = _merge_hunks(expanded_hunks) - - # Rebuild the compacted patch - compacted = _rebuild_patch(raw_patch, merged_hunks, source_lines) - - return compacted if compacted else raw_patch - - -def _parse_hunks(patch: str) -> list[dict[str, Any]]: - """Parse hunks from a unified diff patch. - - Args: - patch: The unified diff patch string - - Returns: - List of hunk dictionaries with parsed info - """ - hunks = [] - lines = patch.split("\n") - i = 0 - - while i < len(lines): - line = lines[i] - - # Check for hunk header - match = HUNK_HEADER_RE.match(line) - if match: - old_start = int(match.group(1)) - old_count = int(match.group(2)) if match.group(2) else 1 - new_start = int(match.group(3)) - new_count = int(match.group(4)) if match.group(4) else 1 - - # Collect hunk lines - hunk_lines = [] - i += 1 - changed_new_lines = [] - current_new_line = new_start - - while i < len(lines): - hunk_line = lines[i] - - # Stop at next hunk header or empty line that ends the hunk - if hunk_line.startswith("@@"): - break - - hunk_lines.append(hunk_line) - - # Track changed lines in the new file - if hunk_line.startswith("+"): - changed_new_lines.append(current_new_line) - current_new_line += 1 - elif hunk_line.startswith(" "): - current_new_line += 1 - elif hunk_line.startswith("-"): - pass # Deleted line, not in new file - elif hunk_line.startswith("\\"): - # "\ No newline at end of file" marker - pass - - i += 1 - - hunks.append( - { - "header": line, - "old_start": old_start, - "old_count": old_count, - "new_start": new_start, - "new_count": new_count, - "lines": hunk_lines, - "changed_new_lines": changed_new_lines, - } - ) - else: - i += 1 - - return hunks - - -def _expand_hunk_to_functions( - hunk: dict[str, Any], - functions: list[FunctionInfo], - source_lines: list[str], -) -> dict[str, Any] | None: - """Expand a hunk to cover enclosing function boundaries. - - Args: - hunk: The hunk dictionary - functions: List of function definitions - source_lines: Source file lines - - Returns: - Expanded hunk dictionary or None if no expansion needed - """ - changed_lines = hunk["changed_new_lines"] - if not changed_lines: - return hunk - - # Find enclosing functions for changed lines - enclosing_functions: list[FunctionInfo] = [] - for line_num in changed_lines: - # Find innermost enclosing function - best_match: FunctionInfo | None = None - for func in functions: - if func.line_start <= line_num <= func.line_end and ( - best_match is None - or ( - func.line_start >= best_match.line_start - and func.line_end <= best_match.line_end - ) - ): - best_match = func - - if best_match and best_match not in enclosing_functions: - enclosing_functions.append(best_match) - - if not enclosing_functions: - # No enclosing functions, return hunk unchanged - return hunk - - # Calculate expansion range - min_func_start = min(f.line_start for f in enclosing_functions) - max_func_end = max(f.line_end for f in enclosing_functions) - - # Determine hunk boundaries in new file - hunk_start_new = hunk["new_start"] - hunk_end_new = hunk_start_new + hunk["new_count"] - 1 - # Account for lines that don't end with newline - if hunk["lines"] and hunk["lines"][-1].startswith("\\"): - hunk_end_new = hunk_start_new + hunk["new_count"] - - # Don't expand if already covering the entire function(s) - if min_func_start >= hunk_start_new and max_func_end <= hunk_end_new: - return hunk - - # Calculate expansion - expansion_start = min(hunk_start_new, min_func_start) - expansion_end = max(hunk_end_new, max_func_end) - - return { - "original_hunk": hunk, - "expansion_start": expansion_start, - "expansion_end": expansion_end, - "hunk_start_new": hunk_start_new, - "hunk_end_new": hunk_end_new, - "enclosing_functions": enclosing_functions, - } - - -def _merge_hunks(expanded_hunks: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Merge overlapping or adjacent expanded hunks. - - Args: - expanded_hunks: List of expanded hunk dictionaries - - Returns: - List of merged hunks - """ - if len(expanded_hunks) <= 1: - return expanded_hunks - - # Sort by expansion start (fallback to new_start for non-expanded hunks) - sorted_hunks = sorted( - expanded_hunks, key=lambda h: h.get("expansion_start", h.get("new_start", 0)) - ) - - merged = [sorted_hunks[0]] - - for hunk in sorted_hunks[1:]: - last = merged[-1] - - # Check if this hunk overlaps or is adjacent to the last merged hunk - last_end = last.get("expansion_end", 0) - current_start = hunk.get("expansion_start", 0) - - # Only merge expanded hunks (those with expansion boundaries) - can_merge = "expansion_start" in last and "expansion_start" in hunk - if can_merge and current_start <= last_end + 1: # Overlapping or adjacent - # Merge: extend the end if needed - last["expansion_end"] = max(last_end, hunk.get("expansion_end", 0)) - # Keep track of original hunks for reconstruction - if "original_hunk" in last and "original_hunk" in hunk: - if "merged_hunks" not in last: - last["merged_hunks"] = [last["original_hunk"]] - last["merged_hunks"].append(hunk["original_hunk"]) - else: - merged.append(hunk) - - return merged - - -def _build_expanded_hunk( - merged_hunk: dict[str, Any], - source_lines: list[str], -) -> tuple[str, list[str]]: - """Build the header and content lines for one expanded hunk. - - Returns: - Tuple of (hunk_header, hunk_lines). - """ - expansion_start = merged_hunk.get("expansion_start") - expansion_end = merged_hunk.get("expansion_end") - original_hunk = merged_hunk.get("original_hunk", merged_hunk) - - if expansion_start is None or expansion_end is None: - return original_hunk["header"], original_hunk["lines"] - - hunk_start_new = merged_hunk.get("hunk_start_new", expansion_start) - hunk_end_new = merged_hunk.get("hunk_end_new", expansion_end) - - new_hunk_lines: list[str] = [] - - # Pre-context - for line_num in range(expansion_start, hunk_start_new): - if line_num <= len(source_lines): - new_hunk_lines.append(f" {source_lines[line_num - 1]}") - - # Diff lines from constituent hunks - for sub_hunk in merged_hunk.get("merged_hunks", [original_hunk]): - new_hunk_lines.extend(sub_hunk.get("lines", [])) - - # Post-context - for line_num in range(hunk_end_new + 1, expansion_end + 1): - if line_num <= len(source_lines): - new_hunk_lines.append(f" {source_lines[line_num - 1]}") - - # Compute counts - new_file_count = sum(1 for ln in new_hunk_lines if ln.startswith((" ", "+"))) - old_count = sum(1 for ln in new_hunk_lines if ln.startswith((" ", "-"))) - header = f"@@ -{expansion_start},{old_count} +{expansion_start},{new_file_count} @@" - - return header, new_hunk_lines - - -def _rebuild_patch( - raw_patch: str, - merged_hunks: list[dict[str, Any]], - source_lines: list[str], -) -> str | None: - """Rebuild the patch with expanded context. - - Args: - raw_patch: The original patch - merged_hunks: List of merged expanded hunks - source_lines: Source file lines - - Returns: - Rebuilt patch string or None if rebuild fails - """ - if not merged_hunks: - return None - lines = raw_patch.split("\n") - header_lines: list[str] = [] - for line in lines: - if line.startswith("@@"): - break - header_lines.append(line) - result_lines: list[str] = list(header_lines) - for merged_hunk in merged_hunks: - hunk_header, hunk_lines = _build_expanded_hunk(merged_hunk, source_lines) - result_lines.append(hunk_header) - result_lines.extend(hunk_lines) - return "\n".join(result_lines) diff --git a/src/codespy/workflows/review/pipeline.py b/src/codespy/workflows/review/pipeline.py index b0acc17..bec4a2b 100644 --- a/src/codespy/workflows/review/pipeline.py +++ b/src/codespy/workflows/review/pipeline.py @@ -26,7 +26,7 @@ from codespy.config_memory import verify_memory_access from codespy.tools.git import ChangedFile, GitClient, PullRequest, get_client from codespy.tools.git.local_diff import build_pr_from_diff -from codespy.tools.git.patch_utils import compact_patches + from codespy.workflows.review.models import ( LocalReviewConfig, RemoteReviewConfig, @@ -89,7 +89,7 @@ def _fetch_pr(self, pr_url: str) -> PullRequest: def _get_repo_path(self, pr: PullRequest) -> Path: """Get the local repository path for a MR, creating directories if needed.""" - cache_dir = self.settings.cache_dir + cache_dir = self.settings.review.cache_dir cache_dir.mkdir(parents=True, exist_ok=True) # Handle nested namespaces for GitLab owner_path = pr.repo_owner.replace("/", "_") @@ -214,11 +214,6 @@ def forward(self, config: ReviewConfig) -> ReviewResult: if not is_local: self._expand_sparse_for_scopes(scopes, repo_path, changed_file_paths) patches = build_patches(pr.changed_files) - if self.settings.compact_patches: - logger.info("Compacting patches to function boundaries...") - compact_patches(scopes, repo_path) - else: - logger.debug("Compact patches disabled, using original PR patches") # Step 2: Run Summarizer (now receives scopes for per-scope episode persistence) # Build all scope topics (scope topics + PR topic) all_scope_topics = [s.topic(pr.repo_full_name) for s in scopes] @@ -260,7 +255,7 @@ def forward(self, config: ReviewConfig) -> ReviewResult: pr_url=pr.url, repo=pr.repo_full_name, run_id=run_id, - model_used=self.settings.default_model, + model_used=self.settings.llm.default_model, issues=all_issues, overall_summary=pr_summary, quality_assessment=quality_assessment, @@ -300,7 +295,7 @@ def _collect_signature_stats(self) -> list[SignatureStatsResult]: def _expand_sparse_for_scopes(self, scopes: list, repo_path: Path, changed_files: list[str] | None = None) -> None: """Expand sparse checkout to cover full subtree of each identified scope. - Called after scope identification, before compact_patches and review modules, + Called after scope identification and before review modules, to ensure read_file and patch compaction have full scope context available. Uses the same sparse pattern builder as derive_sparse_paths to ensure diff --git a/src/codespy/workflows/review/server.py b/src/codespy/workflows/review/server.py index 9e4fccd..f3ffdcc 100644 --- a/src/codespy/workflows/review/server.py +++ b/src/codespy/workflows/review/server.py @@ -192,7 +192,7 @@ def run_server(settings: Settings | None = None) -> None: ) _settings = settings if settings is not None else Settings() - logger.info(f"codespy-reviewer MCP server starting (model: {_settings.default_model})") + logger.info(f"codespy-reviewer MCP server starting (model: {_settings.llm.default_model})") mcp.run() diff --git a/tests/test_config.py b/tests/test_config.py index 89389f3..e00f9ad 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,7 @@ """Tests for Settings configuration. -Tests for the Settings class in codespy.config, covering top-level -boolean configuration fields. +Tests for the Settings class in codespy.config, covering the new +restructured configuration. """ import os @@ -16,42 +16,79 @@ from codespy.config import Settings -class TestCompactPatchesSetting: - """Tests for the compact_patches configuration setting.""" +class TestSettingsStructure: + """Tests for the restructured configuration.""" - def test_default_is_false(self): - """Test that compact_patches defaults to False.""" - # Clear any env var that might affect the setting - env_backup = os.environ.pop("COMPACT_PATCHES", None) + def test_llm_config_exists(self): + """Test that llm config exists and has expected fields.""" + settings = Settings() + assert hasattr(settings, "llm") + assert hasattr(settings.llm, "default_model") + assert hasattr(settings.llm, "retries") + assert hasattr(settings.llm, "timeout") + + def test_review_config_exists(self): + """Test that review config exists and has expected fields.""" + settings = Settings() + assert hasattr(settings, "review") + assert hasattr(settings.review, "min_confidence") + assert hasattr(settings.review, "output_format") + assert hasattr(settings.review, "output_stdout") + assert hasattr(settings.review, "output_git") + assert hasattr(settings.review, "cache_dir") + + def test_memory_config_exists(self): + """Test that memory config exists and has expected fields.""" + settings = Settings() + assert hasattr(settings, "memory") + assert hasattr(settings.memory, "enabled") + assert hasattr(settings.memory, "hippocampus") + assert hasattr(settings.memory, "cerebral") + + def test_signature_configs_exist(self): + """Test that signature configs exist in review section.""" + settings = Settings() + assert hasattr(settings.review, "code_review") + assert hasattr(settings.review, "doc") + assert hasattr(settings.review, "scope") + assert hasattr(settings.review, "supply_chain") + assert hasattr(settings.review, "summary") + assert hasattr(settings.review, "audit") + + def test_old_top_level_fields_removed(self): + """Test that old top-level fields are removed.""" + settings = Settings() + # These should no longer exist at top level + old_fields = [ + "default_model", + "extraction_model", + "default_max_iters", + "default_max_llm_calls", + "default_reasoning_effort", + "default_temperature", + "default_max_tokens", + "llm_retries", + "llm_timeout", + "rlm_fallback", + "signatures", + "min_confidence", + "output_format", + "output_stdout", + "output_git", + "cache_dir", + "excluded_directories", + "enable_prompt_caching", + "compact_patches", + ] + for field in old_fields: + assert not hasattr(settings, field), f"Old field '{field}' should not exist" + + def test_env_override_mapping(self): + """Test that env vars are mapped correctly to new paths.""" + # Check that LLM_DEFAULT_MODEL works + os.environ["LLM_DEFAULT_MODEL"] = "test-model" try: settings = Settings() - assert settings.compact_patches is False + assert settings.llm.default_model == "test-model" finally: - # Restore env var if it was set - if env_backup is not None: - os.environ["COMPACT_PATCHES"] = env_backup - - def test_explicit_true(self): - """Test that compact_patches can be explicitly set to True.""" - settings = Settings(compact_patches=True) - assert settings.compact_patches is True - - def test_explicit_false(self): - """Test that compact_patches can be explicitly set to False.""" - settings = Settings(compact_patches=False) - assert settings.compact_patches is False - - def test_env_var_true(self, monkeypatch): - """Test that COMPACT_PATCHES env var sets the value to True.""" - monkeypatch.setenv("COMPACT_PATCHES", "true") - # Settings reload is needed to pick up env var changes - from codespy.config import reload_settings - settings = reload_settings() - assert settings.compact_patches is True - - def test_env_var_false(self, monkeypatch): - """Test that COMPACT_PATCHES env var sets the value to False.""" - monkeypatch.setenv("COMPACT_PATCHES", "false") - from codespy.config import reload_settings - settings = reload_settings() - assert settings.compact_patches is False + del os.environ["LLM_DEFAULT_MODEL"] diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index 63398eb..7a6fbd1 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -9,8 +9,6 @@ PostgresConfig, Pg0Config, _cerebral_litellm_params, - _generate_bank_id, - apply_memory_env_overrides, get_episode_store, reset_episode_store, verify_memory_access, @@ -18,107 +16,71 @@ class TestGenerateBankId: - """Tests for _generate_bank_id function.""" + """Tests for default bank_id behavior.""" - def test_generate_bank_id_returns_codespy(self): - """Should return 'codespy' as the default bank_id.""" - result = _generate_bank_id() - assert result == "codespy" + def test_default_bank_id_is_codespy(self): + """Bank ID defaults to 'codespy' when not set.""" + settings = MagicMock() + settings.memory.bank_id = None + assert settings.memory.bank_id or "codespy" == "codespy" class TestApplyMemoryEnvOverrides: - """Tests for apply_memory_env_overrides function.""" + """Tests for memory env var overrides with new paths.""" def test_override_postgres_host(self, monkeypatch): """MEMORY_POSTGRES_HOST should set memory.postgres.host.""" monkeypatch.setenv("MEMORY_POSTGRES_HOST", "myhost.example.com") + from codespy.config_utils import apply_env_overrides, build_env_map + from codespy.config_llm import LLMConfig + from codespy.config_dspy import ReviewConfig + from codespy.config_memory import MemoryConfig + + env_map = build_env_map( + sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, + bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", + "aws_region", "aws_access_key_id", "aws_secret_access_key", + "azure_api_key", "azure_api_base", "azure_api_version"}}, + ) config = {} - result = apply_memory_env_overrides(config) + result = apply_env_overrides(config, env_map) assert result["memory"]["postgres"]["host"] == "myhost.example.com" - def test_override_postgres_port(self, monkeypatch): - """MEMORY_POSTGRES_PORT should set memory.postgres.port as int.""" - monkeypatch.setenv("MEMORY_POSTGRES_PORT", "5433") - config = {} - result = apply_memory_env_overrides(config) - assert result["memory"]["postgres"]["port"] == 5433 - - def test_override_postgres_user(self, monkeypatch): - """MEMORY_POSTGRES_USER should set memory.postgres.user.""" - monkeypatch.setenv("MEMORY_POSTGRES_USER", "admin") - config = {} - result = apply_memory_env_overrides(config) - assert result["memory"]["postgres"]["user"] == "admin" - - def test_override_postgres_password(self, monkeypatch): - """MEMORY_POSTGRES_PASSWORD should set memory.postgres.password.""" - monkeypatch.setenv("MEMORY_POSTGRES_PASSWORD", "secret123") - config = {} - result = apply_memory_env_overrides(config) - assert result["memory"]["postgres"]["password"] == "secret123" - - def test_override_postgres_database(self, monkeypatch): - """MEMORY_POSTGRES_DATABASE should set memory.postgres.database.""" - monkeypatch.setenv("MEMORY_POSTGRES_DATABASE", "mydb") - config = {} - result = apply_memory_env_overrides(config) - assert result["memory"]["postgres"]["database"] == "mydb" - - def test_override_postgres_schema(self, monkeypatch): - """MEMORY_POSTGRES_SCHEMA should set memory.postgres.schema.""" - monkeypatch.setenv("MEMORY_POSTGRES_SCHEMA", "public") - config = {} - result = apply_memory_env_overrides(config) - assert result["memory"]["postgres"]["schema"] == "public" - - def test_override_bank_id(self, monkeypatch): - """MEMORY_BANK_ID should set memory.bank_id.""" - monkeypatch.setenv("MEMORY_BANK_ID", "my-agent") - config = {} - result = apply_memory_env_overrides(config) - assert result["memory"]["bank_id"] == "my-agent" - - def test_override_pg0_name(self, monkeypatch): - """MEMORY_PG0_NAME should set memory.pg0.name.""" - monkeypatch.setenv("MEMORY_PG0_NAME", "custom_name") - config = {} - result = apply_memory_env_overrides(config) - assert result["memory"]["pg0"]["name"] == "custom_name" - - def test_override_pg0_port(self, monkeypatch): - """MEMORY_PG0_PORT should set memory.pg0.port as int.""" - monkeypatch.setenv("MEMORY_PG0_PORT", "5433") - config = {} - result = apply_memory_env_overrides(config) - assert result["memory"]["pg0"]["port"] == 5433 - - def test_override_pg0_data_dir(self, monkeypatch): - """MEMORY_PG0_DATA_DIR should set memory.pg0.data_dir.""" - monkeypatch.setenv("MEMORY_PG0_DATA_DIR", "/custom/path") - config = {} - result = apply_memory_env_overrides(config) - assert result["memory"]["pg0"]["data_dir"] == "/custom/path" - - def test_override_enabled(self, monkeypatch): - """MEMORY_ENABLED should set memory.enabled as bool.""" - monkeypatch.setenv("MEMORY_ENABLED", "true") - config = {} - result = apply_memory_env_overrides(config) - assert result["memory"]["enabled"] is True - - def test_override_reflection_module(self, monkeypatch): - """MEMORY_DISTILLER_MODEL should set memory.distiller.model.""" - monkeypatch.setenv("MEMORY_DISTILLER_MODEL", "claude-3-sonnet") + def test_override_memory_hippocampus_distiller_model(self, monkeypatch): + """MEMORY_HIPPOCAMPUS_DISTILLER_MODEL should set the distiller model.""" + monkeypatch.setenv("MEMORY_HIPPOCAMPUS_DISTILLER_MODEL", "claude-3-sonnet") + from codespy.config_utils import apply_env_overrides, build_env_map + from codespy.config_llm import LLMConfig + from codespy.config_dspy import ReviewConfig + from codespy.config_memory import MemoryConfig + + env_map = build_env_map( + sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, + bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", + "aws_region", "aws_access_key_id", "aws_secret_access_key", + "azure_api_key", "azure_api_base", "azure_api_version"}}, + ) config = {} - result = apply_memory_env_overrides(config) - assert result["memory"]["distiller"]["model"] == "claude-3-sonnet" - - def test_no_memory_prefix_ignored(self, monkeypatch): - """Non-MEMORY_ env vars should be ignored.""" - monkeypatch.setenv("OTHER_POSTGRES_HOST", "myhost.example.com") + result = apply_env_overrides(config, env_map) + assert result["memory"]["hippocampus"]["distiller"]["model"] == "claude-3-sonnet" + + def test_override_memory_cerebral_embeddings_model(self, monkeypatch): + """MEMORY_CEREBRAL_EMBEDDINGS_MODEL should set embeddings model.""" + monkeypatch.setenv("MEMORY_CEREBRAL_EMBEDDINGS_MODEL", "openai/text-embedding-3-large") + from codespy.config_utils import apply_env_overrides, build_env_map + from codespy.config_llm import LLMConfig + from codespy.config_dspy import ReviewConfig + from codespy.config_memory import MemoryConfig + + env_map = build_env_map( + sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, + bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", + "aws_region", "aws_access_key_id", "aws_secret_access_key", + "azure_api_key", "azure_api_base", "azure_api_version"}}, + ) config = {} - result = apply_memory_env_overrides(config) - assert "memory" not in result or "postgres" not in result.get("memory", {}) + result = apply_env_overrides(config, env_map) + assert result["memory"]["cerebral"]["embeddings"]["model"] == "openai/text-embedding-3-large" class TestPostgresConfigBuildUri: @@ -149,54 +111,6 @@ def test_build_uri_no_password(self): result = config.build_uri() assert result == "postgresql://u@db.example.com:5432/codespy" - def test_build_uri_no_user_no_password(self): - """Should default to 'postgres' user when user is None.""" - config = PostgresConfig(host="db.example.com") - result = config.build_uri() - assert result == "postgresql://postgres@db.example.com:5432/codespy" - - def test_build_uri_special_chars_in_password(self): - """Should URL-encode special characters in password.""" - config = PostgresConfig( - host="db.example.com", - user="u", - password="p@ss:w/rd" - ) - result = config.build_uri() - assert "p%40ss%3Aw%2Frd" in result - assert result == "postgresql://u:p%40ss%3Aw%2Frd@db.example.com:5432/codespy" - - def test_build_uri_does_not_include_schema(self): - """Schema is handled by each store, not in the URI.""" - config = PostgresConfig( - host="db.example.com", - schema="my_schema" - ) - result = config.build_uri() - assert "search_path" not in result - assert "options" not in result - assert result == "postgresql://postgres@db.example.com:5432/codespy" - - def test_build_uri_custom_port_and_database(self): - """Should use custom port and database.""" - config = PostgresConfig( - host="db.example.com", - port=5433, - database="mydb" - ) - result = config.build_uri() - assert result == "postgresql://postgres@db.example.com:5433/mydb" - - def test_build_uri_empty_password_treated_as_none(self): - """Empty string password should be treated as no password.""" - config = PostgresConfig( - host="db.example.com", - user="u", - password="" - ) - result = config.build_uri() - assert result == "postgresql://u@db.example.com:5432/codespy" - class TestGetEpisodeStore: """Tests for get_episode_store function.""" @@ -224,174 +138,6 @@ def test_returns_none_when_no_config(self): assert result is None - def test_uses_postgres_uri_when_set(self): - """Should use external PostgreSQL when postgres.build_uri() returns a URI.""" - settings = MagicMock() - settings.memory.postgres = MagicMock() - settings.memory.postgres.build_uri.return_value = "postgresql://u:p@host:5432/codespy" - settings.memory.postgres.schema_name = "episodic" - settings.memory.bank_id = "test-bank" - - mock_store = MagicMock() - - with patch("codespy.config_memory._store", None): - with patch("codespy.config_memory._store_built", False): - with patch( - "codespy.agents.memory.postgres.EpisodeStore", - return_value=mock_store, - ) as mock_episode_store: - result = get_episode_store(settings) - - mock_episode_store.assert_called_once_with( - "postgresql://u:p@host:5432/codespy", "test-bank", schema="episodic" - ) - assert result == mock_store - - def test_caching_behavior(self): - """Should cache the store after first call.""" - settings = MagicMock() - settings.memory.postgres = MagicMock() - settings.memory.postgres.build_uri.return_value = "postgresql://localhost:5432/codespy" - settings.memory.bank_id = "test-bank" - - mock_store = MagicMock() - - with patch("codespy.config_memory._store", mock_store): - with patch("codespy.config_memory._store_built", True): - result = get_episode_store(settings) - - # Should return cached store without creating new one - assert result == mock_store - - -class TestResetEpisodeStore: - """Tests for reset_episode_store function.""" - - def test_closes_existing_store(self): - """Should close existing store and reset cache.""" - mock_store = MagicMock() - - with patch("codespy.config_memory._store", mock_store): - with patch("codespy.config_memory._store_built", True): - reset_episode_store() - - mock_store.close.assert_called_once() - - def test_handles_close_exception(self): - """Should handle exceptions during close gracefully.""" - mock_store = MagicMock() - mock_store.close.side_effect = Exception("Close failed") - - with patch("codespy.config_memory._store", mock_store): - with patch("codespy.config_memory._store_built", True): - # Should not raise - reset_episode_store() - - mock_store.close.assert_called_once() - - -class TestVerifyMemoryAccess: - """Tests for verify_memory_access function.""" - - def test_verify_memory_access_all_disabled(self): - """When all signatures disabled, returns success with skip message.""" - settings = MagicMock() - settings.is_signature_enabled.return_value = False - settings.get_memory_enabled.return_value = False - - success, message = verify_memory_access(settings) - - assert success is True - assert "Memory disabled" in message - - def test_verify_memory_access_enabled_sig_disabled(self): - """When signature has memory enabled but signature itself is disabled.""" - settings = MagicMock() - - def is_enabled(sig): - return False # All signatures disabled - - def memory_enabled(sig): - return True # But memory is configured - - settings.is_signature_enabled.side_effect = is_enabled - settings.get_memory_enabled.side_effect = memory_enabled - - success, message = verify_memory_access(settings) - - assert success is True - assert "Memory disabled" in message - - def test_verify_memory_access_store_none(self): - """When memory active but store is None (no PostgreSQL configured).""" - settings = MagicMock() - - def is_enabled(sig): - return sig == "summary" # Only summary enabled - - def memory_enabled(sig): - return sig == "summary" # Memory enabled for summary - - settings.is_signature_enabled.side_effect = is_enabled - settings.get_memory_enabled.side_effect = memory_enabled - - with patch("codespy.config_memory.get_episode_store", return_value=None): - success, message = verify_memory_access(settings) - - assert success is False - assert "not configured" in message - - def test_verify_memory_access_postgres_ok(self): - """When memory active with valid PostgreSQL store.""" - settings = MagicMock() - settings.memory.bank_id = "test-bank" - - def is_enabled(sig): - return sig == "summary" - - def memory_enabled(sig): - return sig == "summary" - - settings.is_signature_enabled.side_effect = is_enabled - settings.get_memory_enabled.side_effect = memory_enabled - - # Mock EpisodeStore that verifies successfully - mock_store = MagicMock() - mock_store.verify_access.return_value = None - - with patch("codespy.config_memory.get_episode_store", return_value=mock_store): - success, message = verify_memory_access(settings) - - assert success is True - assert "verified" in message - assert "PostgreSQL" in message - mock_store.verify_access.assert_called_once() - - def test_verify_memory_access_raises(self): - """When store's verify_access raises an exception.""" - settings = MagicMock() - settings.memory.bank_id = "test-bank" - - def is_enabled(sig): - return sig == "summary" - - def memory_enabled(sig): - return sig == "summary" - - settings.is_signature_enabled.side_effect = is_enabled - settings.get_memory_enabled.side_effect = memory_enabled - - # Mock store that raises on verify_access - mock_store = MagicMock() - mock_store.verify_access.side_effect = ConnectionError("Cannot connect") - - with patch("codespy.config_memory.get_episode_store", return_value=mock_store): - success, message = verify_memory_access(settings) - - assert success is False - assert "not accessible" in message - assert "Cannot connect" in message - class TestCerebralLitellmParams: """Tests for _cerebral_litellm_params function.""" @@ -428,63 +174,9 @@ def test_anthropic_prefix(self, mock_settings): assert api_key == "sk-anthropic-test" assert base_url is None - def test_gemini_prefix(self, mock_settings): - """gemini/ prefix should use Gemini credentials.""" - mock_settings.get_llm_config.return_value.model = "gemini/gemini-pro" - - model, api_key, base_url = _cerebral_litellm_params(mock_settings) - - assert model == "gemini/gemini-pro" - assert api_key == "gemini-test" - assert base_url is None - - def test_azure_prefix(self, mock_settings): - """azure/ prefix should use Azure credentials.""" - mock_settings.get_llm_config.return_value.model = "azure/gpt-4" - - model, api_key, base_url = _cerebral_litellm_params(mock_settings) - - assert model == "azure/gpt-4" - assert api_key == "azure-test" - assert base_url == "https://my-resource.openai.azure.com" - - def test_azure_ai_prefix(self, mock_settings): - """azure_ai/ prefix should use Azure credentials.""" - mock_settings.get_llm_config.return_value.model = "azure_ai/gpt-4" - - model, api_key, base_url = _cerebral_litellm_params(mock_settings) - - assert model == "azure_ai/gpt-4" - assert api_key == "azure-test" - assert base_url == "https://my-resource.openai.azure.com" - - def test_bedrock_prefix(self, mock_settings): - """bedrock/ prefix should use no credentials (AWS env vars).""" - mock_settings.get_llm_config.return_value.model = "bedrock/converse/moonshotai.kimi-k2.5" - - model, api_key, base_url = _cerebral_litellm_params(mock_settings) - - assert model == "bedrock/converse/moonshotai.kimi-k2.5" - assert api_key is None - assert base_url is None - - def test_unknown_prefix(self, mock_settings): - """Unknown prefix should still return model with no credentials.""" - mock_settings.get_llm_config.return_value.model = "custom/model" - - model, api_key, base_url = _cerebral_litellm_params(mock_settings) - - assert model == "custom/model" - assert api_key is None - assert base_url is None - - def test_model_unchanged(self, mock_settings): - """Full litellm model string should be passed unchanged.""" - mock_settings.get_llm_config.return_value.model = "bedrock/converse/moonshotai.kimi-k2.5" - - model, _, _ = _cerebral_litellm_params(mock_settings) - assert model == "bedrock/converse/moonshotai.kimi-k2.5" +class TestEmbeddingModels: + """Tests for EMBEDDING_MODELS constant.""" def test_embedding_models_mapping_bedrock(self): """EMBEDDING_MODELS should have entry for bedrock.""" diff --git a/tests/test_context_safe.py b/tests/test_context_safe.py index 35fbd3b..f89ce56 100644 --- a/tests/test_context_safe.py +++ b/tests/test_context_safe.py @@ -7,10 +7,8 @@ from pydantic import ValidationError from codespy.agents.context_safe import ContextSafe -from codespy.config_dspy import ( - RLMFallbackConfig, - apply_rlm_fallback_env_overrides, -) +from codespy.config_dspy import RLMFallbackConfig +from codespy.config_utils import apply_env_overrides, build_env_map class TestRLMFallbackConfig: @@ -40,50 +38,104 @@ def test_threshold_bounds(self): class TestApplyRLMFallbackEnvOverrides: - """Tests for apply_rlm_fallback_env_overrides function.""" + """Tests for RLM env var overrides using new unified mechanism.""" def test_react_threshold_override(self): - """Test RLM_FALLBACK_REACT_THRESHOLD env var.""" + """Test LLM_RLM_FALLBACK_REACT_THRESHOLD env var.""" + from codespy.config_llm import LLMConfig + from codespy.config_dspy import ReviewConfig + from codespy.config_memory import MemoryConfig + + env_map = build_env_map( + sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, + bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", + "aws_region", "aws_access_key_id", "aws_secret_access_key", + "azure_api_key", "azure_api_base", "azure_api_version"}}, + ) config = {} - with patch.dict(os.environ, {"RLM_FALLBACK_REACT_THRESHOLD": "0.25"}): - result = apply_rlm_fallback_env_overrides(config) - assert result["rlm_fallback"]["react_threshold"] == "0.25" + with patch.dict(os.environ, {"LLM_RLM_FALLBACK_REACT_THRESHOLD": "0.25"}): + result = apply_env_overrides(config, env_map) + assert result["llm"]["rlm_fallback"]["react_threshold"] == "0.25" def test_enabled_override(self): - """Test RLM_FALLBACK_ENABLED env var.""" + """Test LLM_RLM_FALLBACK_ENABLED env var.""" + from codespy.config_llm import LLMConfig + from codespy.config_dspy import ReviewConfig + from codespy.config_memory import MemoryConfig + + env_map = build_env_map( + sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, + bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", + "aws_region", "aws_access_key_id", "aws_secret_access_key", + "azure_api_key", "azure_api_base", "azure_api_version"}}, + ) config = {} - with patch.dict(os.environ, {"RLM_FALLBACK_ENABLED": "false"}): - result = apply_rlm_fallback_env_overrides(config) - assert result["rlm_fallback"]["enabled"] is False + with patch.dict(os.environ, {"LLM_RLM_FALLBACK_ENABLED": "false"}): + result = apply_env_overrides(config, env_map) + assert result["llm"]["rlm_fallback"]["enabled"] is False def test_all_thresholds(self): """Test all threshold env vars.""" + from codespy.config_llm import LLMConfig + from codespy.config_dspy import ReviewConfig + from codespy.config_memory import MemoryConfig + + env_map = build_env_map( + sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, + bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", + "aws_region", "aws_access_key_id", "aws_secret_access_key", + "azure_api_key", "azure_api_base", "azure_api_version"}}, + ) config = {} env_vars = { - "RLM_FALLBACK_REACT_THRESHOLD": "0.35", - "RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD": "0.45", - "RLM_FALLBACK_PREDICT_THRESHOLD": "0.55", + "LLM_RLM_FALLBACK_REACT_THRESHOLD": "0.35", + "LLM_RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD": "0.45", + "LLM_RLM_FALLBACK_PREDICT_THRESHOLD": "0.55", } with patch.dict(os.environ, env_vars): - result = apply_rlm_fallback_env_overrides(config) - assert result["rlm_fallback"]["react_threshold"] == "0.35" - assert result["rlm_fallback"]["chain_of_thought_threshold"] == "0.45" - assert result["rlm_fallback"]["predict_threshold"] == "0.55" + result = apply_env_overrides(config, env_map) + # Values are strings until pydantic coerces them + assert result["llm"]["rlm_fallback"]["react_threshold"] == "0.35" + assert result["llm"]["rlm_fallback"]["chain_of_thought_threshold"] == "0.45" + assert result["llm"]["rlm_fallback"]["predict_threshold"] == "0.55" def test_unrelated_env_vars_ignored(self): """Test that unrelated env vars are ignored.""" + from codespy.config_llm import LLMConfig + from codespy.config_dspy import ReviewConfig + from codespy.config_memory import MemoryConfig + + env_map = build_env_map( + sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, + bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", + "aws_region", "aws_access_key_id", "aws_secret_access_key", + "azure_api_key", "azure_api_base", "azure_api_version"}}, + ) config = {} with patch.dict(os.environ, {"OTHER_VAR": "value"}): - result = apply_rlm_fallback_env_overrides(config) - assert "rlm_fallback" not in result + result = apply_env_overrides(config, env_map) + # rlm_fallback may not exist in result if no env vars matched + if "llm" in result: + assert "rlm_fallback" not in result.get("llm", {}) def test_existing_config_preserved(self): """Test that existing config is preserved.""" - config = {"rlm_fallback": {"enabled": False, "react_threshold": 0.20}} - with patch.dict(os.environ, {"RLM_FALLBACK_REACT_THRESHOLD": "0.35"}): - result = apply_rlm_fallback_env_overrides(config) - assert result["rlm_fallback"]["enabled"] is False - assert result["rlm_fallback"]["react_threshold"] == "0.35" + from codespy.config_llm import LLMConfig + from codespy.config_dspy import ReviewConfig + from codespy.config_memory import MemoryConfig + + env_map = build_env_map( + sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, + bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", + "aws_region", "aws_access_key_id", "aws_secret_access_key", + "azure_api_key", "azure_api_base", "azure_api_version"}}, + ) + config = {"llm": {"rlm_fallback": {"enabled": False, "react_threshold": 0.20}}} + with patch.dict(os.environ, {"LLM_RLM_FALLBACK_REACT_THRESHOLD": "0.35"}): + result = apply_env_overrides(config, env_map) + assert result["llm"]["rlm_fallback"]["enabled"] is False + # Value is a string until pydantic coerces it + assert result["llm"]["rlm_fallback"]["react_threshold"] == "0.35" class MockSignature: @@ -100,10 +152,10 @@ def test_returns_correct_threshold_per_type(self): from codespy.config import Settings settings = Settings() - settings.rlm_fallback.enabled = True - settings.rlm_fallback.react_threshold = 0.30 - settings.rlm_fallback.chain_of_thought_threshold = 0.40 - settings.rlm_fallback.predict_threshold = 0.50 + settings.llm.rlm_fallback.enabled = True + settings.llm.rlm_fallback.react_threshold = 0.30 + settings.llm.rlm_fallback.chain_of_thought_threshold = 0.40 + settings.llm.rlm_fallback.predict_threshold = 0.50 assert settings.get_rlm_threshold("react") == 0.30 assert settings.get_rlm_threshold("chain_of_thought") == 0.40 @@ -114,7 +166,7 @@ def test_returns_1_0_when_disabled(self): from codespy.config import Settings settings = Settings() - settings.rlm_fallback.enabled = False + settings.llm.rlm_fallback.enabled = False assert settings.get_rlm_threshold("react") == 1.0 assert settings.get_rlm_threshold("chain_of_thought") == 1.0 @@ -125,7 +177,7 @@ def test_returns_1_0_for_unknown_type(self): from codespy.config import Settings settings = Settings() - settings.rlm_fallback.enabled = True + settings.llm.rlm_fallback.enabled = True assert settings.get_rlm_threshold("unknown_type") == 1.0 diff --git a/tests/test_patch_utils.py b/tests/test_patch_utils.py deleted file mode 100644 index dce0993..0000000 --- a/tests/test_patch_utils.py +++ /dev/null @@ -1,600 +0,0 @@ -"""Tests for patch_utils module.""" - -from pathlib import Path -from unittest.mock import MagicMock - -from codespy.agents.review.scope.models import ScopeResult, ScopeType -from codespy.tools.git.models import ChangedFile, FileStatus -from codespy.tools.git.patch_utils import ( - _expand_hunk_to_functions, - _merge_hunks, - _parse_hunks, - _rebuild_patch, - _should_compact_file, - compact_patch, - compact_patches, -) -from codespy.tools.parsers.treesitter import FunctionInfo - - -class TestShouldCompactFile: - """Tests for _should_compact_file function.""" - - def test_returns_true_for_code_file_with_patch(self): - file = ChangedFile( - filename="src/main.py", - status=FileStatus.MODIFIED, - patch="@@ -1,3 +1,3 @@\n-old\n+new", - ) - assert _should_compact_file(file) is True - - def test_returns_false_for_no_patch(self): - file = ChangedFile( - filename="src/main.py", - status=FileStatus.MODIFIED, - patch=None, - ) - assert _should_compact_file(file) is False - - def test_returns_false_for_deleted_file(self): - file = ChangedFile( - filename="src/main.py", - status=FileStatus.REMOVED, - patch="@@ -1,3 +0,0 @@\n-old", - ) - assert _should_compact_file(file) is False - - def test_returns_false_for_non_code_file(self): - file = ChangedFile( - filename="README.md", - status=FileStatus.MODIFIED, - patch="@@ -1,3 +1,3 @@\n-old\n+new", - ) - assert _should_compact_file(file) is False - - def test_returns_false_for_binary_file(self): - file = ChangedFile( - filename="image.png", - status=FileStatus.ADDED, - patch=None, - ) - assert _should_compact_file(file) is False - - def test_returns_false_for_lock_file(self): - file = ChangedFile( - filename="package-lock.json", - status=FileStatus.MODIFIED, - patch="@@ -1,3 +1,3 @@\n-old\n+new", - ) - assert _should_compact_file(file) is False - - -class TestParseHunks: - """Tests for _parse_hunks function.""" - - def test_parses_single_hunk(self): - patch = """@@ -1,5 +1,5 @@ - def hello(): -- print("old") -+ print("new") - pass""" - hunks = _parse_hunks(patch) - - assert len(hunks) == 1 - assert hunks[0]["old_start"] == 1 - assert hunks[0]["old_count"] == 5 - assert hunks[0]["new_start"] == 1 - assert hunks[0]["new_count"] == 5 - assert len(hunks[0]["lines"]) == 4 # Includes the context line - - def test_parses_multiple_hunks(self): - patch = """@@ -1,3 +1,3 @@ - line1 --line2 -+line2_changed - line3 -@@ -10,3 +10,3 @@ - line10 --line11 -+line11_changed - line12""" - hunks = _parse_hunks(patch) - - assert len(hunks) == 2 - assert hunks[0]["new_start"] == 1 - assert hunks[1]["new_start"] == 10 - - def test_handles_hunk_without_old_count(self): - patch = """@@ -1 +1 @@ --old -+new""" - hunks = _parse_hunks(patch) - - assert len(hunks) == 1 - assert hunks[0]["old_count"] == 1 - assert hunks[0]["new_count"] == 1 - - def test_tracks_changed_new_lines(self): - patch = """@@ -5,5 +5,6 @@ - context1 --added1 - context2 -+added2 - context3""" - hunks = _parse_hunks(patch) - - # Changed lines in new file: line 7 (added2) - deleted lines aren't in new file - # Line 5: context1 (line 5), Line 6: deleted (not in new), Line 6: context2 (line 6) - # Line 7: added2 (line 7) - assert hunks[0]["changed_new_lines"] == [7] - - def test_returns_empty_list_for_no_hunks(self): - patch = "no hunk headers here" - hunks = _parse_hunks(patch) - assert hunks == [] - - def test_handles_no_newline_at_end_marker(self): - patch = """@@ -1,3 +1,3 @@ - line1 - line2 --line3 -+line3_new -\\ No newline at end of file""" - hunks = _parse_hunks(patch) - - assert len(hunks) == 1 - # The "\ No newline" line is included in hunk lines - # Lines: line1 (context), -line3 (deleted), +line3_new (added), \ marker - assert len(hunks[0]["lines"]) == 5 - - -class TestExpandHunkToFunctions: - """Tests for _expand_hunk_to_functions function.""" - - def test_expands_to_enclosing_function(self): - hunk = { - "header": "@@ -17,7 +17,7 @@", - "old_start": 17, - "old_count": 7, - "new_start": 17, - "new_count": 7, - "lines": [" line1", "- old_line", "+ new_line", " line2"], - "changed_new_lines": [18], - } - # Function from line 10 to 30 - functions = [ - FunctionInfo( - name="test_func", - file="test.py", - line_start=10, - line_end=30, - parameters=[], - ) - ] - source_lines = [f"line {i}" for i in range(1, 35)] - - result = _expand_hunk_to_functions(hunk, functions, source_lines) - - assert result is not None - assert result["expansion_start"] == 10 - assert result["expansion_end"] == 30 - - def test_no_expansion_for_change_outside_function(self): - hunk = { - "header": "@@ -1,3 +1,4 @@", - "old_start": 1, - "old_count": 3, - "new_start": 1, - "new_count": 4, - "lines": ["+import new_module", " import os"], - "changed_new_lines": [1], - } - # Function starts at line 10 - functions = [ - FunctionInfo( - name="test_func", - file="test.py", - line_start=10, - line_end=30, - parameters=[], - ) - ] - source_lines = [f"line {i}" for i in range(1, 35)] - - result = _expand_hunk_to_functions(hunk, functions, source_lines) - - # Should return hunk unchanged (no enclosing function) - assert result == hunk - - def test_uses_innermost_function_for_nested(self): - hunk = { - "header": "@@ -15,3 +15,3 @@", - "old_start": 15, - "old_count": 3, - "new_start": 15, - "new_count": 3, - "lines": ["- old", "+ new", " pass"], - "changed_new_lines": [15], - } - # Outer function: lines 5-25, inner function: lines 12-18 - functions = [ - FunctionInfo(name="outer", file="test.py", line_start=5, line_end=25, parameters=[]), - FunctionInfo(name="inner", file="test.py", line_start=12, line_end=18, parameters=[]), - ] - source_lines = [f"line {i}" for i in range(1, 30)] - - result = _expand_hunk_to_functions(hunk, functions, source_lines) - - # Should use inner function boundaries - assert result["expansion_start"] == 12 - assert result["expansion_end"] == 18 - - def test_expands_multiple_functions_in_one_hunk(self): - hunk = { - "header": "@@ -20,10 +20,12 @@", - "old_start": 20, - "old_count": 10, - "new_start": 20, - "new_count": 12, - "lines": ["-old", "+new"], - "changed_new_lines": [22, 35], - } - functions = [ - FunctionInfo(name="func1", file="test.py", line_start=15, line_end=25, parameters=[]), - FunctionInfo(name="func2", file="test.py", line_start=30, line_end=40, parameters=[]), - ] - source_lines = [f"line {i}" for i in range(1, 50)] - - result = _expand_hunk_to_functions(hunk, functions, source_lines) - - # Should expand to cover both functions - assert result["expansion_start"] == 15 - assert result["expansion_end"] == 40 - - -class TestMergeHunks: - """Tests for _merge_hunks function.""" - - def test_returns_single_hunk_unchanged(self): - hunks = [{"expansion_start": 10, "expansion_end": 30}] - merged = _merge_hunks(hunks) - assert merged == hunks - - def test_merges_overlapping_hunks(self): - hunks = [ - { - "expansion_start": 10, - "expansion_end": 30, - "original_hunk": {"header": "@@ -17,7 +17,7 @@"}, - }, - { - "expansion_start": 25, - "expansion_end": 50, - "original_hunk": {"header": "@@ -40,5 +40,5 @@"}, - }, - ] - merged = _merge_hunks(hunks) - - assert len(merged) == 1 - assert merged[0]["expansion_start"] == 10 - assert merged[0]["expansion_end"] == 50 - - def test_merges_adjacent_hunks(self): - hunks = [ - {"expansion_start": 10, "expansion_end": 20}, - {"expansion_start": 21, "expansion_end": 30}, # Adjacent (gap = 1) - ] - merged = _merge_hunks(hunks) - - assert len(merged) == 1 - assert merged[0]["expansion_start"] == 10 - assert merged[0]["expansion_end"] == 30 - - def test_keeps_separate_non_overlapping_hunks(self): - hunks = [ - {"expansion_start": 10, "expansion_end": 20}, - {"expansion_start": 30, "expansion_end": 40}, # Gap of 9 lines - ] - merged = _merge_hunks(hunks) - - assert len(merged) == 2 - - def test_sorts_hunks_by_start(self): - hunks = [ - {"expansion_start": 50, "expansion_end": 60}, - {"expansion_start": 10, "expansion_end": 20}, - ] - merged = _merge_hunks(hunks) - - assert merged[0]["expansion_start"] == 10 - assert merged[1]["expansion_start"] == 50 - - -class TestRebuildPatch: - """Tests for _rebuild_patch function.""" - - def test_rebuilds_single_expanded_hunk(self): - raw_patch = ( - "@@ -17,7 +17,7 @@ def generate_token(user_id: str, ttl_hours: int = 24) -> str:\n" - " expiry = datetime.utcnow() + timedelta(hours=ttl_hours)\n" - ' payload = f"{user_id}:{expiry.isoformat()}"\n' - ' signature = hashlib.sha256(f"{payload}{SECRET_KEY}".encode()).hexdigest()\n' - '- return f"{payload}:{signature}"\n' - '+ return f"{payload}.{signature}"\n' - "\n" - "\n" - " def verify_token(token: str) -> bool:" - ) - - source_lines = [f"line {i}" for i in range(1, 50)] - merged_hunks = [ - { - "original_hunk": { - "header": "@@ -17,7 +17,7 @@", - "lines": [ - " expiry = datetime.utcnow() + timedelta(hours=ttl_hours)", - ' payload = f"{user_id}:{expiry.isoformat()}"', - ' signature = hashlib.sha256(f"{payload}{SECRET_KEY}".encode())' - ".hexdigest()", - '- return f"{payload}:{signature}"', - '+ return f"{payload}.{signature}"', - "", - "", - " def verify_token(token: str) -> bool:", - ], - }, - "expansion_start": 10, - "expansion_end": 30, - "hunk_start_new": 17, - "hunk_end_new": 24, - } - ] - - result = _rebuild_patch(raw_patch, merged_hunks, source_lines) - - assert result is not None - assert "@@ -10," in result # New header starts at expansion_start - - def test_keeps_header_lines(self): - raw_patch = """diff --git a/src/main.py b/src/main.py -index 123..456 789 ---- a/src/main.py -+++ b/src/main.py -@@ -1,3 +1,3 @@ --old -+new""" - - source_lines = ["line1", "line2", "line3"] - merged_hunks = [ - { - "original_hunk": { - "header": "@@ -1,3 +1,3 @@", - "lines": ["-old", "+new"], - }, - "expansion_start": 1, - "expansion_end": 3, - "hunk_start_new": 1, - "hunk_end_new": 2, - } - ] - - result = _rebuild_patch(raw_patch, merged_hunks, source_lines) - - assert "diff --git" in result - assert "index 123..456" in result - - def test_returns_none_for_empty_hunks(self): - result = _rebuild_patch("patch", [], []) - assert result is None - - -class TestCompactPatch: - """Tests for compact_patch function.""" - - def test_returns_original_for_empty_patch(self): - result = compact_patch("", Path("test.py"), Path("/repo")) - assert result == "" - - def test_returns_original_when_no_functions_found(self, tmp_path: Path): - # Create a Python file without functions - source_file = tmp_path / "test.py" - source_file.write_text("x = 1\ny = 2\n") - - patch = """@@ -1,2 +1,2 @@ --x = 1 -+x = 2 - y = 2""" - - mock_parser = MagicMock() - mock_parser.available = True - mock_parser.find_function_definitions.return_value = [] - - result = compact_patch(patch, Path("test.py"), tmp_path, mock_parser) - assert result == patch - - def test_returns_original_when_treesitter_unavailable(self, tmp_path: Path): - patch = """@@ -1,3 +1,3 @@ --old -+new""" - - mock_parser = MagicMock() - mock_parser.available = False - - result = compact_patch(patch, Path("test.py"), tmp_path, mock_parser) - assert result == patch - - def test_returns_original_for_non_code_file(self, tmp_path: Path): - patch = """@@ -1,3 +1,3 @@ --old -+new""" - - result = compact_patch(patch, Path("README.md"), tmp_path) - assert result == patch - - -class TestCompactPatches: - """Tests for compact_patches function.""" - - def test_processes_multiple_scopes(self, tmp_path: Path, monkeypatch): - # Create source files - (tmp_path / "src").mkdir() - (tmp_path / "src" / "main.py").write_text("x = 1\n") - (tmp_path / "src" / "utils.py").write_text("y = 2\n") - - scope1 = ScopeResult( - repo="owner/repo", - subroot="pkg1", - scope_type=ScopeType.LIBRARY, - reason="test", - changed_files=[ - ChangedFile( - filename="src/main.py", - status=FileStatus.MODIFIED, - patch="@@ -1,3 +1,3 @@\n-old\n+new", - ) - ], - ) - scope2 = ScopeResult( - repo="owner/repo", - subroot="pkg2", - scope_type=ScopeType.SERVICE, - reason="test", - changed_files=[ - ChangedFile( - filename="src/utils.py", - status=FileStatus.MODIFIED, - patch="@@ -5,3 +5,3 @@\n-old\n+new", - ) - ], - ) - scopes = [scope1, scope2] - - # Mock TreeSitterParser to return empty functions - call_count = 0 - - def mock_find_functions(self, file_path): - nonlocal call_count - call_count += 1 - return [] - - monkeypatch.setattr( - "codespy.tools.git.patch_utils.TreeSitterParser.find_function_definitions", - mock_find_functions, - ) - monkeypatch.setattr( - "codespy.tools.git.patch_utils.TreeSitterParser.__init__", lambda self, path: None - ) - monkeypatch.setattr("codespy.tools.git.patch_utils.TreeSitterParser.available", True) - - compact_patches(scopes, tmp_path) - - # Should have tried to find functions in both files - assert call_count == 2 - - def test_skips_deleted_files(self, tmp_path: Path, monkeypatch): - scope = ScopeResult( - repo="owner/repo", - subroot=".", - scope_type=ScopeType.LIBRARY, - reason="test", - changed_files=[ - ChangedFile( - filename="src/deleted.py", - status=FileStatus.REMOVED, - patch="@@ -1,3 +0,0 @@\n-line1\n-line2\n-line3", - ) - ], - ) - - call_count = 0 - - def mock_find_functions(self, file_path): - nonlocal call_count - call_count += 1 - return [] - - monkeypatch.setattr( - "codespy.tools.git.patch_utils.TreeSitterParser.find_function_definitions", - mock_find_functions, - ) - monkeypatch.setattr( - "codespy.tools.git.patch_utils.TreeSitterParser.__init__", lambda self, path: None - ) - monkeypatch.setattr("codespy.tools.git.patch_utils.TreeSitterParser.available", True) - - compact_patches([scope], tmp_path) - - # Should not try to find functions in deleted files - assert call_count == 0 - - def test_skips_files_without_patches(self, tmp_path: Path, monkeypatch): - scope = ScopeResult( - repo="owner/repo", - subroot=".", - scope_type=ScopeType.LIBRARY, - reason="test", - changed_files=[ - ChangedFile( - filename="src/empty.py", - status=FileStatus.MODIFIED, - patch=None, - ) - ], - ) - - call_count = 0 - - def mock_find_functions(self, file_path): - nonlocal call_count - call_count += 1 - return [] - - monkeypatch.setattr( - "codespy.tools.git.patch_utils.TreeSitterParser.find_function_definitions", - mock_find_functions, - ) - monkeypatch.setattr( - "codespy.tools.git.patch_utils.TreeSitterParser.__init__", lambda self, path: None - ) - monkeypatch.setattr("codespy.tools.git.patch_utils.TreeSitterParser.available", True) - - compact_patches([scope], tmp_path) - - assert call_count == 0 - - def test_handles_compaction_failure_gracefully(self, tmp_path: Path, monkeypatch): - # Create source file - source_file = tmp_path / "test.py" - source_file.write_text("def func():\n pass\n") - - scope = ScopeResult( - repo="owner/repo", - subroot=".", - scope_type=ScopeType.LIBRARY, - reason="test", - changed_files=[ - ChangedFile( - filename="test.py", - status=FileStatus.MODIFIED, - patch="@@ -1,3 +1,3 @@\n-old\n+new", - ) - ], - ) - - def mock_find_functions(self, file_path): - raise Exception("parse error") - - monkeypatch.setattr( - "codespy.tools.git.patch_utils.TreeSitterParser.find_function_definitions", - mock_find_functions, - ) - monkeypatch.setattr( - "codespy.tools.git.patch_utils.TreeSitterParser.__init__", lambda self, path: None - ) - monkeypatch.setattr("codespy.tools.git.patch_utils.TreeSitterParser.available", True) - - # Should not raise - compact_patches([scope], tmp_path) - - # Original patch should be preserved - assert scope.changed_files[0].patch == "@@ -1,3 +1,3 @@\n-old\n+new" From 0d71ca798ee4a1713dddfa26bfb4f289f205902c Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Sun, 27 Sep 2026 14:12:06 +0200 Subject: [PATCH 16/39] cost --- .env.example | 85 +++++++-------- CHANGELOG.md | 23 ++++ action.yml | 206 ++++++++++++++++++------------------ codespy.yaml | 78 +++++++------- docs/configuration.md | 31 +++--- docs/memory.md | 17 +-- docs/usage.md | 5 +- src/codespy/cli_local.py | 4 +- src/codespy/cli_remote.py | 2 +- src/codespy/config.py | 26 ++--- src/codespy/config_utils.py | 82 +++++++++----- tests/test_config.py | 30 ++++-- tests/test_config_memory.py | 62 ++++------- tests/test_context_safe.py | 88 +++++---------- 14 files changed, 366 insertions(+), 373 deletions(-) diff --git a/.env.example b/.env.example index 3d95173..84a4d6e 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,8 @@ # Priority: Environment Variables > YAML Config > Defaults # # Environment variables follow the YAML path, uppercased and joined with _. -# Provider credentials keep their standard names. +# The llm., memory.hippocampus. and memory.cerebral. segments are dropped; +# llm.retries and llm.timeout keep LLM_. # ============================================================================= # Git Platform Tokens @@ -45,7 +46,7 @@ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # - gemini/gemini-2.5-pro (Google Gemini) # - gemini/gemini-2.5-flash (Google Gemini) # - ollama/llama-4-70b (Local Ollama) -LLM_DEFAULT_MODEL=anthropic/claude-opus-4-6 +DEFAULT_MODEL=anthropic/claude-opus-4-6 # ------------------------------------------------------------------------------ # Option 1: Anthropic (direct) @@ -85,37 +86,37 @@ LLM_DEFAULT_MODEL=anthropic/claude-opus-4-6 # Auto-Discovery Settings # ============================================================================= # Disable auto-discovery for specific providers (all default to true) -# LLM_AUTO_DISCOVER_OPENAI=false -# LLM_AUTO_DISCOVER_ANTHROPIC=false -# LLM_AUTO_DISCOVER_GEMINI=false -# LLM_AUTO_DISCOVER_AWS=false +# AUTO_DISCOVER_OPENAI=false +# AUTO_DISCOVER_ANTHROPIC=false +# AUTO_DISCOVER_GEMINI=false +# AUTO_DISCOVER_AWS=false # ============================================================================= # Recommended Model Strategy # ============================================================================= # codespy uses a tiered model approach to balance review quality and cost: # -# Smart (LLM_DEFAULT_MODEL): Core analysis — code review (bugs, security, smells), +# Smart (DEFAULT_MODEL): Core analysis — code review (bugs, security, smells), # supply chain, scope identification, doc review. Requires strong reasoning. # Recommended: anthropic/claude-opus-4-6 or equivalent. # -# Mid-tier (LLM_EXTRACTION_MODEL): Structured field extraction. Needs accuracy +# Mid-tier (EXTRACTION_MODEL): Structured field extraction. Needs accuracy # but not deep reasoning. Recommended: anthropic/claude-sonnet-4-5-20250929 # or equivalent. # # Cheap (REVIEW_SUMMARY_MODEL): PR summary generation. Simple synthesis. # Recommended: anthropic/claude-haiku-4-5-20251001 or equivalent. # -# Mid-tier (MEMORY_HIPPOCAMPUS_DISTILLER_MODEL / MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MODEL): +# Mid-tier (MEMORY_DISTILLER_MODEL / MEMORY_CARTOGRAPHER_MODEL): # Memory reflection — summarizing a trajectory and curating the context memory. # Requires nuance. # Recommended: anthropic/claude-sonnet-4-5-20250929 or equivalent. # -# By default, all models fall back to LLM_DEFAULT_MODEL. To optimize costs: -# LLM_EXTRACTION_MODEL=anthropic/claude-sonnet-4-5-20250929 +# By default, all models fall back to DEFAULT_MODEL. To optimize costs: +# EXTRACTION_MODEL=anthropic/claude-sonnet-4-5-20250929 # REVIEW_SUMMARY_MODEL=anthropic/claude-haiku-4-5-20251001 -# MEMORY_HIPPOCAMPUS_DISTILLER_MODEL=anthropic/claude-sonnet-4-5-20250929 -# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MODEL=anthropic/claude-sonnet-4-5-20250929 +# MEMORY_DISTILLER_MODEL=anthropic/claude-sonnet-4-5-20250929 +# MEMORY_CARTOGRAPHER_MODEL=anthropic/claude-sonnet-4-5-20250929 # ============================================================================= @@ -124,15 +125,15 @@ LLM_DEFAULT_MODEL=anthropic/claude-opus-4-6 # These apply to all signatures unless overridden per-signature # Extraction model for TwoStepAdapter -# LLM_EXTRACTION_MODEL=anthropic/claude-sonnet-4-5-20250929 +# EXTRACTION_MODEL=anthropic/claude-sonnet-4-5-20250929 -# LLM_DEFAULT_MAX_ITERS=5 -# LLM_DEFAULT_MAX_LLM_CALLS=8 +# DEFAULT_MAX_ITERS=5 +# DEFAULT_MAX_LLM_CALLS=8 # Provider reasoning budget: minimal | low | medium | high -# LLM_DEFAULT_REASONING_EFFORT=medium -# LLM_DEFAULT_TEMPERATURE=0.2 +# DEFAULT_REASONING_EFFORT=medium +# DEFAULT_TEMPERATURE=0.2 # Output token budget for a single completion (default: 32000) -# LLM_DEFAULT_MAX_TOKENS=32000 +# DEFAULT_MAX_TOKENS=32000 # Global LLM reliability settings # LLM_RETRIES=2 @@ -141,10 +142,10 @@ LLM_DEFAULT_MODEL=anthropic/claude-opus-4-6 # ============================================================================= # RLM Fallback (Context Rot Prevention) # ============================================================================= -# LLM_RLM_FALLBACK_ENABLED=true -# LLM_RLM_FALLBACK_REACT_THRESHOLD=0.30 -# LLM_RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD=0.40 -# LLM_RLM_FALLBACK_PREDICT_THRESHOLD=0.50 +# RLM_FALLBACK_ENABLED=true +# RLM_FALLBACK_REACT_THRESHOLD=0.30 +# RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD=0.40 +# RLM_FALLBACK_PREDICT_THRESHOLD=0.50 # ============================================================================= @@ -184,28 +185,28 @@ LLM_DEFAULT_MODEL=anthropic/claude-opus-4-6 # MEMORY_ENABLED=false # Whether to compact trajectories (head+tail bounding) before distillation. -# MEMORY_HIPPOCAMPUS_COMPACT_TRAJECTORY=true +# MEMORY_COMPACT_TRAJECTORY=true # Token budgets -# MEMORY_HIPPOCAMPUS_MAX_CONTEXT_MEMORY_TOKENS=16384 -# MEMORY_HIPPOCAMPUS_MAX_CONTEXT_ITEM_TOKENS=512 -# MEMORY_HIPPOCAMPUS_MAX_TRAJECTORY_TOKENS=16384 -# MEMORY_HIPPOCAMPUS_MAX_QUESTION_TOKENS=8192 +# MEMORY_MAX_CONTEXT_MEMORY_TOKENS=16384 +# MEMORY_MAX_CONTEXT_ITEM_TOKENS=512 +# MEMORY_MAX_TRAJECTORY_TOKENS=16384 +# MEMORY_MAX_QUESTION_TOKENS=8192 # LLM settings for reflection modules -# MEMORY_HIPPOCAMPUS_DISTILLER_MODEL=anthropic/claude-haiku-4-5-20251001 -# MEMORY_HIPPOCAMPUS_DISTILLER_REASONING_EFFORT=low -# MEMORY_HIPPOCAMPUS_DISTILLER_TEMPERATURE=1 -# MEMORY_HIPPOCAMPUS_DISTILLER_MAX_TOKENS=64000 -# MEMORY_HIPPOCAMPUS_DISTILLER_MAX_ITERS=1 -# MEMORY_HIPPOCAMPUS_DISTILLER_MAX_LLM_CALLS=2 +# MEMORY_DISTILLER_MODEL=anthropic/claude-haiku-4-5-20251001 +# MEMORY_DISTILLER_REASONING_EFFORT=low +# MEMORY_DISTILLER_TEMPERATURE=1 +# MEMORY_DISTILLER_MAX_TOKENS=64000 +# MEMORY_DISTILLER_MAX_ITERS=1 +# MEMORY_DISTILLER_MAX_LLM_CALLS=2 -# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MODEL=anthropic/claude-haiku-4-5-20251001 -# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_REASONING_EFFORT=low -# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_TEMPERATURE=1 -# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_TOKENS=64000 -# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_ITERS=1 -# MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_LLM_CALLS=2 +# MEMORY_CARTOGRAPHER_MODEL=anthropic/claude-haiku-4-5-20251001 +# MEMORY_CARTOGRAPHER_REASONING_EFFORT=low +# MEMORY_CARTOGRAPHER_TEMPERATURE=1 +# MEMORY_CARTOGRAPHER_MAX_TOKENS=64000 +# MEMORY_CARTOGRAPHER_MAX_ITERS=1 +# MEMORY_CARTOGRAPHER_MAX_LLM_CALLS=2 # ============================================================================= @@ -214,8 +215,8 @@ LLM_DEFAULT_MODEL=anthropic/claude-opus-4-6 # Cerebral activates automatically when MEMORY_ENABLED=true. # LLM provider and credentials are derived from the model string and existing # LLM config (e.g. bedrock uses AWS env vars, openai uses OPENAI_API_KEY). -# MEMORY_CEREBRAL_RETAIN_MODEL= # Falls back to LLM_DEFAULT_MODEL -# MEMORY_CEREBRAL_EMBEDDINGS_MODEL= # Auto-derived from provider +# MEMORY_RETAIN_MODEL= # Falls back to DEFAULT_MODEL +# MEMORY_EMBEDDINGS_MODEL= # Auto-derived from provider # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index c07432c..b841838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ ## [Unreleased] ### Changed +- **BREAKING — Environment Variables**: Renamed env vars for consistency with YAML paths + - `llm.*` settings drop the `LLM_` prefix (e.g., `LLM_DEFAULT_MODEL` → `DEFAULT_MODEL`) + - Exceptions: `LLM_RETRIES` and `LLM_TIMEOUT` keep their prefixes + - `memory.hippocampus.*` and `memory.cerebral.*` settings drop `HIPPOCAMPUS_`/`CEREBRAL_` prefixes + - Examples: `MEMORY_HIPPOCAMPUS_DISTILLER_MODEL` → `MEMORY_DISTILLER_MODEL`, `MEMORY_CEREBRAL_RETAIN_MODEL` → `MEMORY_RETAIN_MODEL` + - `review.*` settings keep the `REVIEW_` prefix (e.g., `CODE_REVIEW_MODEL` → `REVIEW_CODE_REVIEW_MODEL`, `MIN_CONFIDENCE` → `REVIEW_MIN_CONFIDENCE`) + - Removed: `ENABLE_PROMPT_CACHING`, `COMPACT_PATCHES`, `MEMORY_CEREBRAL_*` (except `MEMORY_RETAIN_MODEL` and `MEMORY_EMBEDDINGS_MODEL`) + - Migration: Update your `.env` file and GitHub Action inputs. The `llm.*`, `memory.postgres.*`, and `memory.pg0.*` names are unchanged from 1.2.4. +- **GitHub Action**: + - The `enable-prompt-caching` and `compact-patches` inputs are removed (the underlying settings no longer exist) + - All signature env vars now use `REVIEW_*` prefix (e.g., `SCOPE_ENABLED` → `REVIEW_SCOPE_ENABLED`, `CODE_REVIEW_MODEL` → `REVIEW_CODE_REVIEW_MODEL`) + - Memory master switch: `MEMORY_DEFAULT_ENABLED` → `MEMORY_ENABLED` + - Added missing passthrough for `DOC_MAX_ITERS` and `DOC_MAX_LLM_CALLS` + - Migration note: **upgrade the action ref and `codespy-version` together**. An older action with the `latest` image, or the reverse, silently drops review settings. + +### Fixed +- `get_llm_config("cerebral")` no longer raises `AttributeError` — the cerebral branch now uses empty `ReflectionModuleConfig` and falls back to `llm.default_*` values +- `MEMORY_POSTGRES_SCHEMA` env var now works correctly (previously emitted `MEMORY_POSTGRES_SCHEMA_NAME` which was ignored) +- GitHub Action `memory-enabled` input now maps to `MEMORY_ENABLED` (was `MEMORY_DEFAULT_ENABLED`, which didn't match the code) + +### Removed +- **Config**: Removed dead settings `enable_prompt_caching` and `compact_patches` (fields deleted in the config refactor) + - **Dependencies**: MCP Python SDK upgraded from v1.x to v2.x - `mcp` dependency: `>=1.29.0,<2.0.0` → `>=2.2,<3` - All 8 MCP servers migrated from `FastMCP` to `MCPServer` (v2 API) diff --git a/action.yml b/action.yml index 4796d8d..de2c093 100644 --- a/action.yml +++ b/action.yml @@ -92,11 +92,6 @@ inputs: required: false default: '120' - enable-prompt-caching: - description: 'Enable provider-side prompt caching (reduces latency and costs)' - required: false - default: 'true' - default-max-llm-calls: description: 'Maximum LLM calls for RLM fallback (context overflow recovery)' required: false @@ -128,11 +123,6 @@ inputs: required: false default: '0.81' - compact-patches: - description: 'Expand diff context to full function bodies via Tree-sitter before review (default: false)' - required: false - default: 'false' - # ========================================== # SIGNATURE: scope # ========================================== @@ -476,10 +466,9 @@ runs: DEFAULT_MAX_TOKENS: ${{ inputs.default-max-tokens }} LLM_RETRIES: ${{ inputs.llm-retries }} LLM_TIMEOUT: ${{ inputs.llm-timeout }} - ENABLE_PROMPT_CACHING: ${{ inputs.enable-prompt-caching }} DEFAULT_MAX_LLM_CALLS: ${{ inputs.default-max-llm-calls }} - MIN_CONFIDENCE: ${{ inputs.min-confidence }} - COMPACT_PATCHES: ${{ inputs.compact-patches }} + REVIEW_MIN_CONFIDENCE: ${{ inputs.min-confidence }} + REVIEW_EXCLUDED_DIRECTORIES: ${{ inputs.excluded-directories }} # RLM fallback RLM_FALLBACK_ENABLED: ${{ inputs.rlm-fallback-enabled }} @@ -488,59 +477,65 @@ runs: RLM_FALLBACK_PREDICT_THRESHOLD: ${{ inputs.rlm-fallback-predict-threshold }} # Scope identification signature - SCOPE_ENABLED: ${{ inputs.scope-enabled }} - SCOPE_MODEL: ${{ inputs.scope-model }} - SCOPE_MAX_ITERS: ${{ inputs.scope-max-iters }} - SCOPE_MAX_LLM_CALLS: ${{ inputs.scope-max-llm-calls }} - SCOPE_REASONING_EFFORT: ${{ inputs.scope-reasoning-effort }} - SCOPE_TEMPERATURE: ${{ inputs.scope-temperature }} - + REVIEW_SCOPE_ENABLED: ${{ inputs.scope-enabled }} + REVIEW_SCOPE_MODEL: ${{ inputs.scope-model }} + REVIEW_SCOPE_MAX_ITERS: ${{ inputs.scope-max-iters }} + REVIEW_SCOPE_MAX_LLM_CALLS: ${{ inputs.scope-max-llm-calls }} + REVIEW_SCOPE_REASONING_EFFORT: ${{ inputs.scope-reasoning-effort }} + REVIEW_SCOPE_TEMPERATURE: ${{ inputs.scope-temperature }} + REVIEW_SCOPE_MEMORY_ENABLED: ${{ inputs.scope-memory-enabled }} + # Code review signature - CODE_REVIEW_ENABLED: ${{ inputs.code-review-enabled }} - CODE_REVIEW_MODEL: ${{ inputs.code-review-model }} - CODE_REVIEW_MAX_ITERS: ${{ inputs.code-review-max-iters }} - CODE_REVIEW_MAX_LLM_CALLS: ${{ inputs.code-review-max-llm-calls }} - CODE_REVIEW_REASONING_EFFORT: ${{ inputs.code-review-reasoning-effort }} - CODE_REVIEW_TEMPERATURE: ${{ inputs.code-review-temperature }} - + REVIEW_CODE_REVIEW_ENABLED: ${{ inputs.code-review-enabled }} + REVIEW_CODE_REVIEW_MODEL: ${{ inputs.code-review-model }} + REVIEW_CODE_REVIEW_MAX_ITERS: ${{ inputs.code-review-max-iters }} + REVIEW_CODE_REVIEW_MAX_LLM_CALLS: ${{ inputs.code-review-max-llm-calls }} + REVIEW_CODE_REVIEW_REASONING_EFFORT: ${{ inputs.code-review-reasoning-effort }} + REVIEW_CODE_REVIEW_TEMPERATURE: ${{ inputs.code-review-temperature }} + REVIEW_CODE_REVIEW_MEMORY_ENABLED: ${{ inputs.code-review-memory-enabled }} + # Doc review signature - DOC_ENABLED: ${{ inputs.doc-enabled }} - DOC_MODEL: ${{ inputs.doc-model }} - DOC_MAX_ITERS: ${{ inputs.doc-max-iters }} - DOC_MAX_LLM_CALLS: ${{ inputs.doc-max-llm-calls }} - DOC_REASONING_EFFORT: ${{ inputs.doc-reasoning-effort }} - DOC_TEMPERATURE: ${{ inputs.doc-temperature }} - + REVIEW_DOC_ENABLED: ${{ inputs.doc-enabled }} + REVIEW_DOC_MODEL: ${{ inputs.doc-model }} + REVIEW_DOC_MAX_ITERS: ${{ inputs.doc-max-iters }} + REVIEW_DOC_MAX_LLM_CALLS: ${{ inputs.doc-max-llm-calls }} + REVIEW_DOC_REASONING_EFFORT: ${{ inputs.doc-reasoning-effort }} + REVIEW_DOC_TEMPERATURE: ${{ inputs.doc-temperature }} + REVIEW_DOC_MEMORY_ENABLED: ${{ inputs.doc-memory-enabled }} + # Supply chain signature - SUPPLY_CHAIN_ENABLED: ${{ inputs.supply-chain-enabled }} - SUPPLY_CHAIN_MODEL: ${{ inputs.supply-chain-model }} - SUPPLY_CHAIN_MAX_ITERS: ${{ inputs.supply-chain-max-iters }} - SUPPLY_CHAIN_MAX_LLM_CALLS: ${{ inputs.supply-chain-max-llm-calls }} - SUPPLY_CHAIN_REASONING_EFFORT: ${{ inputs.supply-chain-reasoning-effort }} - SUPPLY_CHAIN_TEMPERATURE: ${{ inputs.supply-chain-temperature }} - SUPPLY_CHAIN_SCAN_UNCHANGED: ${{ inputs.supply-chain-scan-unchanged }} - + REVIEW_SUPPLY_CHAIN_ENABLED: ${{ inputs.supply-chain-enabled }} + REVIEW_SUPPLY_CHAIN_MODEL: ${{ inputs.supply-chain-model }} + REVIEW_SUPPLY_CHAIN_MAX_ITERS: ${{ inputs.supply-chain-max-iters }} + REVIEW_SUPPLY_CHAIN_MAX_LLM_CALLS: ${{ inputs.supply-chain-max-llm-calls }} + REVIEW_SUPPLY_CHAIN_REASONING_EFFORT: ${{ inputs.supply-chain-reasoning-effort }} + REVIEW_SUPPLY_CHAIN_TEMPERATURE: ${{ inputs.supply-chain-temperature }} + REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED: ${{ inputs.supply-chain-scan-unchanged }} + REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED: ${{ inputs.supply-chain-memory-enabled }} + # Summary signature - SUMMARY_ENABLED: ${{ inputs.summary-enabled }} - SUMMARY_MAX_ITERS: ${{ inputs.summary-max-iters }} - SUMMARY_MODEL: ${{ inputs.summary-model }} - SUMMARY_MAX_LLM_CALLS: ${{ inputs.summary-max-llm-calls }} - SUMMARY_REASONING_EFFORT: ${{ inputs.summary-reasoning-effort }} - SUMMARY_TEMPERATURE: ${{ inputs.summary-temperature }} + REVIEW_SUMMARY_ENABLED: ${{ inputs.summary-enabled }} + REVIEW_SUMMARY_MAX_ITERS: ${{ inputs.summary-max-iters }} + REVIEW_SUMMARY_MODEL: ${{ inputs.summary-model }} + REVIEW_SUMMARY_MAX_LLM_CALLS: ${{ inputs.summary-max-llm-calls }} + REVIEW_SUMMARY_REASONING_EFFORT: ${{ inputs.summary-reasoning-effort }} + REVIEW_SUMMARY_TEMPERATURE: ${{ inputs.summary-temperature }} + REVIEW_SUMMARY_MEMORY_ENABLED: ${{ inputs.summary-memory-enabled }} # Audit signature - AUDIT_ENABLED: ${{ inputs.audit-enabled }} - AUDIT_MODEL: ${{ inputs.audit-model }} - AUDIT_MAX_ITERS: ${{ inputs.audit-max-iters }} - AUDIT_MAX_LLM_CALLS: ${{ inputs.audit-max-llm-calls }} - AUDIT_REASONING_EFFORT: ${{ inputs.audit-reasoning-effort }} - AUDIT_TEMPERATURE: ${{ inputs.audit-temperature }} + REVIEW_AUDIT_ENABLED: ${{ inputs.audit-enabled }} + REVIEW_AUDIT_MODEL: ${{ inputs.audit-model }} + REVIEW_AUDIT_MAX_ITERS: ${{ inputs.audit-max-iters }} + REVIEW_AUDIT_MAX_LLM_CALLS: ${{ inputs.audit-max-llm-calls }} + REVIEW_AUDIT_REASONING_EFFORT: ${{ inputs.audit-reasoning-effort }} + REVIEW_AUDIT_TEMPERATURE: ${{ inputs.audit-temperature }} + REVIEW_AUDIT_MEMORY_ENABLED: ${{ inputs.audit-memory-enabled }} - # Other settings - EXCLUDED_DIRECTORIES: ${{ inputs.excluded-directories }} + # Output settings + REVIEW_OUTPUT_FORMAT: ${{ inputs.output-format }} # Memory (Hippocampus) - MEMORY_DEFAULT_ENABLED: ${{ inputs.memory-enabled }} + MEMORY_ENABLED: ${{ inputs.memory-enabled }} MEMORY_POSTGRES_HOST: ${{ inputs.memory-postgres-host }} MEMORY_POSTGRES_PORT: ${{ inputs.memory-postgres-port }} MEMORY_POSTGRES_USER: ${{ inputs.memory-postgres-user }} @@ -587,10 +582,9 @@ runs: [ -n "$DEFAULT_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e DEFAULT_MAX_TOKENS" [ -n "$LLM_RETRIES" ] && DOCKER_ARGS="$DOCKER_ARGS -e LLM_RETRIES" [ -n "$LLM_TIMEOUT" ] && DOCKER_ARGS="$DOCKER_ARGS -e LLM_TIMEOUT" - [ -n "$ENABLE_PROMPT_CACHING" ] && DOCKER_ARGS="$DOCKER_ARGS -e ENABLE_PROMPT_CACHING" [ -n "$DEFAULT_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e DEFAULT_MAX_LLM_CALLS" - [ -n "$MIN_CONFIDENCE" ] && DOCKER_ARGS="$DOCKER_ARGS -e MIN_CONFIDENCE" - [ -n "$COMPACT_PATCHES" ] && DOCKER_ARGS="$DOCKER_ARGS -e COMPACT_PATCHES" + [ -n "$REVIEW_MIN_CONFIDENCE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_MIN_CONFIDENCE" + [ -n "$REVIEW_EXCLUDED_DIRECTORIES" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_EXCLUDED_DIRECTORIES" # RLM fallback [ -n "$RLM_FALLBACK_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e RLM_FALLBACK_ENABLED" @@ -599,57 +593,65 @@ runs: [ -n "$RLM_FALLBACK_PREDICT_THRESHOLD" ] && DOCKER_ARGS="$DOCKER_ARGS -e RLM_FALLBACK_PREDICT_THRESHOLD" # Scope identification - [ -n "$SCOPE_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e SCOPE_ENABLED" - [ -n "$SCOPE_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e SCOPE_MODEL" - [ -n "$SCOPE_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e SCOPE_MAX_ITERS" - [ -n "$SCOPE_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e SCOPE_MAX_LLM_CALLS" - [ -n "$SCOPE_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e SCOPE_REASONING_EFFORT" - [ -n "$SCOPE_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e SCOPE_TEMPERATURE" - + [ -n "$REVIEW_SCOPE_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_ENABLED" + [ -n "$REVIEW_SCOPE_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MODEL" + [ -n "$REVIEW_SCOPE_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MAX_ITERS" + [ -n "$REVIEW_SCOPE_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MAX_LLM_CALLS" + [ -n "$REVIEW_SCOPE_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_REASONING_EFFORT" + [ -n "$REVIEW_SCOPE_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_TEMPERATURE" + [ -n "$REVIEW_SCOPE_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MEMORY_ENABLED" + # Code review - [ -n "$CODE_REVIEW_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e CODE_REVIEW_ENABLED" - [ -n "$CODE_REVIEW_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e CODE_REVIEW_MODEL" - [ -n "$CODE_REVIEW_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e CODE_REVIEW_MAX_ITERS" - [ -n "$CODE_REVIEW_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e CODE_REVIEW_MAX_LLM_CALLS" - [ -n "$CODE_REVIEW_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e CODE_REVIEW_REASONING_EFFORT" - [ -n "$CODE_REVIEW_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e CODE_REVIEW_TEMPERATURE" - + [ -n "$REVIEW_CODE_REVIEW_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_ENABLED" + [ -n "$REVIEW_CODE_REVIEW_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_MODEL" + [ -n "$REVIEW_CODE_REVIEW_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_MAX_ITERS" + [ -n "$REVIEW_CODE_REVIEW_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_MAX_LLM_CALLS" + [ -n "$REVIEW_CODE_REVIEW_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_REASONING_EFFORT" + [ -n "$REVIEW_CODE_REVIEW_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_TEMPERATURE" + [ -n "$REVIEW_CODE_REVIEW_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_MEMORY_ENABLED" + # Doc review - [ -n "$DOC_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e DOC_ENABLED" - [ -n "$DOC_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e DOC_MODEL" - [ -n "$DOC_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e DOC_REASONING_EFFORT" - [ -n "$DOC_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e DOC_TEMPERATURE" - + [ -n "$REVIEW_DOC_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_ENABLED" + [ -n "$REVIEW_DOC_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_MODEL" + [ -n "$REVIEW_DOC_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_MAX_ITERS" + [ -n "$REVIEW_DOC_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_MAX_LLM_CALLS" + [ -n "$REVIEW_DOC_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_REASONING_EFFORT" + [ -n "$REVIEW_DOC_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_TEMPERATURE" + [ -n "$REVIEW_DOC_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_MEMORY_ENABLED" + # Supply chain - [ -n "$SUPPLY_CHAIN_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUPPLY_CHAIN_ENABLED" - [ -n "$SUPPLY_CHAIN_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUPPLY_CHAIN_MODEL" - [ -n "$SUPPLY_CHAIN_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUPPLY_CHAIN_MAX_ITERS" - [ -n "$SUPPLY_CHAIN_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUPPLY_CHAIN_MAX_LLM_CALLS" - [ -n "$SUPPLY_CHAIN_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUPPLY_CHAIN_REASONING_EFFORT" - [ -n "$SUPPLY_CHAIN_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUPPLY_CHAIN_TEMPERATURE" - [ -n "$SUPPLY_CHAIN_SCAN_UNCHANGED" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUPPLY_CHAIN_SCAN_UNCHANGED" - + [ -n "$REVIEW_SUPPLY_CHAIN_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_ENABLED" + [ -n "$REVIEW_SUPPLY_CHAIN_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_MODEL" + [ -n "$REVIEW_SUPPLY_CHAIN_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_MAX_ITERS" + [ -n "$REVIEW_SUPPLY_CHAIN_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_MAX_LLM_CALLS" + [ -n "$REVIEW_SUPPLY_CHAIN_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_REASONING_EFFORT" + [ -n "$REVIEW_SUPPLY_CHAIN_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_TEMPERATURE" + [ -n "$REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED" + [ -n "$REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED" + # Summary - [ -n "$SUMMARY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUMMARY_ENABLED" - [ -n "$SUMMARY_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUMMARY_MAX_ITERS" - [ -n "$SUMMARY_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUMMARY_MODEL" - [ -n "$SUMMARY_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUMMARY_MAX_LLM_CALLS" - [ -n "$SUMMARY_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUMMARY_REASONING_EFFORT" - [ -n "$SUMMARY_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUMMARY_TEMPERATURE" + [ -n "$REVIEW_SUMMARY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_ENABLED" + [ -n "$REVIEW_SUMMARY_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_MAX_ITERS" + [ -n "$REVIEW_SUMMARY_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_MODEL" + [ -n "$REVIEW_SUMMARY_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_MAX_LLM_CALLS" + [ -n "$REVIEW_SUMMARY_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_REASONING_EFFORT" + [ -n "$REVIEW_SUMMARY_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_TEMPERATURE" + [ -n "$REVIEW_SUMMARY_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_MEMORY_ENABLED" # Audit - [ -n "$AUDIT_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e AUDIT_ENABLED" - [ -n "$AUDIT_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e AUDIT_MODEL" - [ -n "$AUDIT_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e AUDIT_MAX_ITERS" - [ -n "$AUDIT_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e AUDIT_MAX_LLM_CALLS" - [ -n "$AUDIT_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e AUDIT_REASONING_EFFORT" - [ -n "$AUDIT_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e AUDIT_TEMPERATURE" + [ -n "$REVIEW_AUDIT_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_ENABLED" + [ -n "$REVIEW_AUDIT_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_MODEL" + [ -n "$REVIEW_AUDIT_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_MAX_ITERS" + [ -n "$REVIEW_AUDIT_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_MAX_LLM_CALLS" + [ -n "$REVIEW_AUDIT_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_REASONING_EFFORT" + [ -n "$REVIEW_AUDIT_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_TEMPERATURE" + [ -n "$REVIEW_AUDIT_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_MEMORY_ENABLED" - # Other settings - [ -n "$EXCLUDED_DIRECTORIES" ] && DOCKER_ARGS="$DOCKER_ARGS -e EXCLUDED_DIRECTORIES" + # Output settings + [ -n "$REVIEW_OUTPUT_FORMAT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_OUTPUT_FORMAT" # Memory (Hippocampus) - [ -n "$MEMORY_DEFAULT_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_DEFAULT_ENABLED" + [ -n "$MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_ENABLED" [ -n "$MEMORY_POSTGRES_HOST" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_POSTGRES_HOST" [ -n "$MEMORY_POSTGRES_PORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_POSTGRES_PORT" [ -n "$MEMORY_POSTGRES_USER" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_POSTGRES_USER" diff --git a/codespy.yaml b/codespy.yaml index 5a79203..a15c9a5 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -2,10 +2,14 @@ # # ENVIRONMENT VARIABLE OVERRIDES: # All settings can be overridden via environment variables using the YAML path, -# uppercased and joined with underscores. Examples: -# - llm.default_model → LLM_DEFAULT_MODEL +# uppercased and joined with underscores. The llm., memory.hippocampus. and +# memory.cerebral. segments are dropped; llm.retries and llm.timeout keep LLM_. +# Examples: +# - llm.default_model → DEFAULT_MODEL +# - llm.retries → LLM_RETRIES # - review.code_review.max_iters → REVIEW_CODE_REVIEW_MAX_ITERS -# - memory.hippocampus.distiller.model → MEMORY_HIPPOCAMPUS_DISTILLER_MODEL +# - memory.hippocampus.distiller.model → MEMORY_DISTILLER_MODEL +# - memory.cerebral.retain.model → MEMORY_RETAIN_MODEL # # Provider credentials keep their standard names: # OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AWS_*, AZURE_* @@ -19,22 +23,22 @@ llm: # OpenAI openai_api_key: null # OPENAI_API_KEY openai_api_base: null # OPENAI_API_BASE - auto_discover_openai: true # LLM_AUTO_DISCOVER_OPENAI + auto_discover_openai: true # AUTO_DISCOVER_OPENAI # Anthropic anthropic_api_key: null # ANTHROPIC_API_KEY - auto_discover_anthropic: true # LLM_AUTO_DISCOVER_ANTHROPIC + auto_discover_anthropic: true # AUTO_DISCOVER_ANTHROPIC # Google Gemini gemini_api_key: null # GEMINI_API_KEY - auto_discover_gemini: true # LLM_AUTO_DISCOVER_GEMINI + auto_discover_gemini: true # AUTO_DISCOVER_GEMINI # AWS Bedrock aws_region: us-east-1 # AWS_REGION aws_access_key_id: null # AWS_ACCESS_KEY_ID aws_secret_access_key: null # AWS_SECRET_ACCESS_KEY aws_profile: null # AWS_PROFILE - auto_discover_aws: true # LLM_AUTO_DISCOVER_AWS + auto_discover_aws: true # AUTO_DISCOVER_AWS # Azure OpenAI azure_api_key: null # AZURE_API_KEY @@ -42,13 +46,13 @@ llm: azure_api_version: null # AZURE_API_VERSION # Default model settings - default_model: anthropic/claude-opus-4-6 # LLM_DEFAULT_MODEL - extraction_model: null # LLM_EXTRACTION_MODEL (falls back to default_model) - default_max_iters: 5 # LLM_DEFAULT_MAX_ITERS - default_max_llm_calls: 8 # LLM_DEFAULT_MAX_LLM_CALLS - default_reasoning_effort: medium # LLM_DEFAULT_REASONING_EFFORT - default_temperature: 0.2 # LLM_DEFAULT_TEMPERATURE - default_max_tokens: 32000 # LLM_DEFAULT_MAX_TOKENS + default_model: anthropic/claude-opus-4-6 # DEFAULT_MODEL + extraction_model: null # EXTRACTION_MODEL (falls back to default_model) + default_max_iters: 5 # DEFAULT_MAX_ITERS + default_max_llm_calls: 8 # DEFAULT_MAX_LLM_CALLS + default_reasoning_effort: medium # DEFAULT_REASONING_EFFORT + default_temperature: 0.2 # DEFAULT_TEMPERATURE + default_max_tokens: 32000 # DEFAULT_MAX_TOKENS # Global LLM reliability settings retries: 2 # LLM_RETRIES @@ -56,10 +60,10 @@ llm: # RLM Fallback (context rot prevention) rlm_fallback: - enabled: true # LLM_RLM_FALLBACK_ENABLED - react_threshold: 0.30 # LLM_RLM_FALLBACK_REACT_THRESHOLD - chain_of_thought_threshold: 0.40 # LLM_RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD - predict_threshold: 0.50 # LLM_RLM_FALLBACK_PREDICT_THRESHOLD + enabled: true # RLM_FALLBACK_ENABLED + react_threshold: 0.30 # RLM_FALLBACK_REACT_THRESHOLD + chain_of_thought_threshold: 0.40 # RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD + predict_threshold: 0.50 # RLM_FALLBACK_PREDICT_THRESHOLD # ============================================================================ # GIT PLATFORMS @@ -98,34 +102,34 @@ memory: # Hippocampus (episodic memory) hippocampus: - compact_trajectory: true # MEMORY_HIPPOCAMPUS_COMPACT_TRAJECTORY - max_context_memory_tokens: 16384 # MEMORY_HIPPOCAMPUS_MAX_CONTEXT_MEMORY_TOKENS - max_context_item_tokens: 512 # MEMORY_HIPPOCAMPUS_MAX_CONTEXT_ITEM_TOKENS - max_trajectory_tokens: 16384 # MEMORY_HIPPOCAMPUS_MAX_TRAJECTORY_TOKENS - max_question_tokens: 8192 # MEMORY_HIPPOCAMPUS_MAX_QUESTION_TOKENS + compact_trajectory: true # MEMORY_COMPACT_TRAJECTORY + max_context_memory_tokens: 16384 # MEMORY_MAX_CONTEXT_MEMORY_TOKENS + max_context_item_tokens: 512 # MEMORY_MAX_CONTEXT_ITEM_TOKENS + max_trajectory_tokens: 16384 # MEMORY_MAX_TRAJECTORY_TOKENS + max_question_tokens: 8192 # MEMORY_MAX_QUESTION_TOKENS distiller: - model: null # MEMORY_HIPPOCAMPUS_DISTILLER_MODEL - reasoning_effort: null # MEMORY_HIPPOCAMPUS_DISTILLER_REASONING_EFFORT - temperature: null # MEMORY_HIPPOCAMPUS_DISTILLER_TEMPERATURE - max_tokens: null # MEMORY_HIPPOCAMPUS_DISTILLER_MAX_TOKENS - max_iters: 1 # MEMORY_HIPPOCAMPUS_DISTILLER_MAX_ITERS - max_llm_calls: 2 # MEMORY_HIPPOCAMPUS_DISTILLER_MAX_LLM_CALLS + model: null # MEMORY_DISTILLER_MODEL + reasoning_effort: null # MEMORY_DISTILLER_REASONING_EFFORT + temperature: null # MEMORY_DISTILLER_TEMPERATURE + max_tokens: null # MEMORY_DISTILLER_MAX_TOKENS + max_iters: 1 # MEMORY_DISTILLER_MAX_ITERS + max_llm_calls: 2 # MEMORY_DISTILLER_MAX_LLM_CALLS cartographer: - model: null # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MODEL - reasoning_effort: null # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_REASONING_EFFORT - temperature: null # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_TEMPERATURE - max_tokens: null # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_TOKENS - max_iters: 1 # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_ITERS - max_llm_calls: 2 # MEMORY_HIPPOCAMPUS_CARTOGRAPHER_MAX_LLM_CALLS + model: null # MEMORY_CARTOGRAPHER_MODEL + reasoning_effort: null # MEMORY_CARTOGRAPHER_REASONING_EFFORT + temperature: null # MEMORY_CARTOGRAPHER_TEMPERATURE + max_tokens: null # MEMORY_CARTOGRAPHER_MAX_TOKENS + max_iters: 1 # MEMORY_CARTOGRAPHER_MAX_ITERS + max_llm_calls: 2 # MEMORY_CARTOGRAPHER_MAX_LLM_CALLS # Cerebral (semantic memory) cerebral: retain: - model: null # MEMORY_CEREBRAL_RETAIN_MODEL (falls back to llm.default_model) + model: null # MEMORY_RETAIN_MODEL (falls back to llm.default_model) embeddings: - model: null # MEMORY_CEREBRAL_EMBEDDINGS_MODEL (auto-derived from provider) + model: null # MEMORY_EMBEDDINGS_MODEL (auto-derived from provider) # ============================================================================ # REVIEW diff --git a/docs/configuration.md b/docs/configuration.md index 360b4ca..b39d5e6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -111,8 +111,6 @@ AUTO_DISCOVER_GEMINI=false | Max tokens | `DEFAULT_MAX_TOKENS` | `32000` | Output token budget per completion (reasoning tokens included) | | Temperature | `DEFAULT_TEMPERATURE` | `0.2` | Default temperature for LLM calls | | Max iterations | `DEFAULT_MAX_ITERS` | `5` | Maximum ReAct iterations for tool-using agents | -| Prompt caching | `ENABLE_PROMPT_CACHING` | `true` | Provider-side prompt caching (Anthropic, OpenAI, Bedrock) | -| Compact patches | `COMPACT_PATCHES` | `false` | Expand diff hunks to full function bodies using Tree-sitter | | RLM fallback | `RLM_FALLBACK_ENABLED` | `true` | Proactive RLM fallback for context rot prevention | | RLM react threshold | `RLM_FALLBACK_REACT_THRESHOLD` | `0.30` | Context ratio triggering RLM for ReAct modules | | RLM CoT threshold | `RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD` | `0.40` | Context ratio triggering RLM for ChainOfThought modules | @@ -124,13 +122,13 @@ AUTO_DISCOVER_GEMINI=false |------|------|---------|---------|-------------| | Smart | Core analysis & reasoning | `DEFAULT_MODEL` | `anthropic/claude-opus-4-6` | Claude Opus / GPT-5 | | Mid-tier | Field extraction | `EXTRACTION_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | -| Cheap | PR summary | `SUMMARY_MODEL` | Falls back to DEFAULT_MODEL | Claude Haiku | +| Cheap | PR summary | `REVIEW_SUMMARY_MODEL` | Falls back to DEFAULT_MODEL | Claude Haiku | | Mid-tier | Memory reflection | `MEMORY_DISTILLER_MODEL` / `MEMORY_CARTOGRAPHER_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | -| Mid-tier | Semantic memory (fact extraction) | `MEMORY_CEREBRAL_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | +| Mid-tier | Semantic memory (fact extraction) | `MEMORY_RETAIN_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | ## Per-Signature Configuration -Each signature supports env var overrides: `_` +Each signature supports env var overrides: `REVIEW__` | Signature | Config Key | Available Settings | |-----------|------------|-------------------| @@ -141,7 +139,7 @@ Each signature supports env var overrides: `_` | Supply Chain | `supply_chain` | ENABLED, MAX_ITERS, MAX_LLM_CALLS, MODEL, REASONING_EFFORT, TEMPERATURE, MAX_TOKENS, SCAN_UNCHANGED | | Auditor | `audit` | ENABLED, MAX_ITERS, MAX_LLM_CALLS, MODEL, REASONING_EFFORT, TEMPERATURE, MAX_TOKENS | -Example: `CODE_REVIEW_MODEL=anthropic/claude-sonnet-4-5-20250929` +Example: `REVIEW_CODE_REVIEW_MODEL=anthropic/claude-sonnet-4-5-20250929` ## Advanced Configuration (YAML) @@ -160,11 +158,11 @@ export DEFAULT_MODEL=anthropic/claude-opus-4-6 export DEFAULT_MAX_ITERS=20 # Per-signature settings (use signature name, not module name) -export CODE_REVIEW_MODEL=anthropic/claude-sonnet-4-5-20250929 +export REVIEW_CODE_REVIEW_MODEL=anthropic/claude_sonnet-4-5-20250929 # Output settings -export OUTPUT_STDOUT=false -export OUTPUT_GIT=true +export REVIEW_OUTPUT_STDOUT=false +export REVIEW_OUTPUT_GIT=true ``` ## Memory Configuration @@ -199,9 +197,8 @@ LLM provider and credentials are auto-derived from the model string and existing | Setting | Env Var | Default | Description | |---------|---------|---------|-------------| -| Cerebral Model | `MEMORY_CEREBRAL_MODEL` | `DEFAULT_MODEL` | Model for fact extraction (ReflectionModuleConfig) | -| Cerebral Temperature | `MEMORY_CEREBRAL_TEMPERATURE` | `DEFAULT_TEMPERATURE` | Temperature for fact extraction | -| Cerebral Max Tokens | `MEMORY_CEREBRAL_MAX_TOKENS` | `DEFAULT_MAX_TOKENS` | Output token budget | +| Cerebral Model | `MEMORY_RETAIN_MODEL` | `DEFAULT_MODEL` | Model for fact extraction | +| Embeddings Model | `MEMORY_EMBEDDINGS_MODEL` | Auto-derived | Model for semantic memory embeddings | Hindsight MemoryEngine uses the same PostgreSQL instance under the `semantic` schema (created automatically alongside `episodic`). @@ -209,14 +206,14 @@ Hindsight MemoryEngine uses the same PostgreSQL instance under the `semantic` sc | Setting | Env Var | Default | Description | |---------|---------|---------|-------------| -| Format | `OUTPUT_FORMAT` | `markdown` | `markdown` or `json` | -| Stdout | `OUTPUT_STDOUT` | `true` | Enable stdout output | -| Git | `OUTPUT_GIT` | `true` | Post review to GitHub/GitLab | -| Cache dir | `CACHE_DIR` | `~/.cache/codespy` | Cache directory path | +| Format | `REVIEW_OUTPUT_FORMAT` | `markdown` | `markdown` or `json` | +| Stdout | `REVIEW_OUTPUT_STDOUT` | `true` | Enable stdout output | +| Git | `REVIEW_OUTPUT_GIT` | `true` | Post review to GitHub/GitLab | +| Cache dir | `REVIEW_CACHE_DIR` | `~/.cache/codespy` | Cache directory path | ## File Exclusions -`EXCLUDED_DIRECTORIES` (JSON array in env) — Directories to skip during code review. Binary files, lock files, and minified files are always excluded automatically. +`REVIEW_EXCLUDED_DIRECTORIES` (JSON array in env) — Directories to skip during code review. Binary files, lock files, and minified files are always excluded automatically. Default excluded directories: - Vendor/dependency: `vendor`, `node_modules`, `third_party`, `external`, `deps`, `_vendor`, `vendored` diff --git a/docs/memory.md b/docs/memory.md index cac3a11..ca207f1 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -190,9 +190,10 @@ These tags inform the Cartographer's edit decisions. | Budget | Env Var | Default | Purpose | |--------|---------|---------|---------| | Context memory | `MEMORY_MAX_CONTEXT_MEMORY_TOKENS` | 16384 | Ceiling on persisted ContextMemory (re-sent every iteration) | -| Observation | `MEMORY_MAX_CONTEXT_ITEM_TOKENS` | 512 | Soft per-observation limit (expressed to LLM, not truncated) | +| Observation | `MEMORY_MAX_CONTEXT_ITEM_TOKENS` | 512 | Soft per-observation token limit (expressed to LLM, not truncated) | | Trajectory | `MEMORY_MAX_TRAJECTORY_TOKENS` | 16384 | Head+tail cap on trajectory fed to Distiller | | Question | `MEMORY_MAX_QUESTION_TOKENS` | 8192 | Cap on serialized inputs as reflection question | +| Compact trajectory | `MEMORY_COMPACT_TRAJECTORY` | `true` | Apply head+tail trajectory bounding before distillation | Observation capacity ≈ context_memory_tokens / observation_tokens (16384/512 = 32 observations) @@ -213,24 +214,24 @@ Observation capacity ≈ context_memory_tokens / observation_tokens (16384/512 = | `MEMORY_PG0_DATA_DIR` | `memory.pg0.data_dir` | — | Custom data directory for pg0-embedded | | `MEMORY_BANK_ID` | `memory.bank_id` | `codespy` | Scopes all memory data | | `MEMORY_ENABLED` | `memory.enabled` | `false` | Enable memory globally (episodic + semantic) | -| `MEMORY_COMPACT_TRAJECTORY` | `memory.compact_trajectory` | `true` | Apply head+tail trajectory bounding before distillation | +| `MEMORY_COMPACT_TRAJECTORY` | `memory.hippocampus.compact_trajectory` | `true` | Apply head+tail trajectory bounding before distillation | ### Reflection Module LLM Overrides | Module | Env Var Pattern | YAML Path | |--------|----------------|-----------| -| Distiller | `MEMORY_DISTILLER_{MODEL,REASONING_EFFORT,TEMPERATURE,MAX_TOKENS,MAX_ITERS,MAX_LLM_CALLS}` | `memory.distiller.*` | -| Cartographer | `MEMORY_CARTOGRAPHER_{MODEL,REASONING_EFFORT,TEMPERATURE,MAX_TOKENS,MAX_ITERS,MAX_LLM_CALLS}` | `memory.cartographer.*` | +| Distiller | `MEMORY_DISTILLER_{MODEL,REASONING_EFFORT,TEMPERATURE,MAX_TOKENS,MAX_ITERS,MAX_LLM_CALLS}` | `memory.hippocampus.distiller.*` | +| Cartographer | `MEMORY_CARTOGRAPHER_{MODEL,REASONING_EFFORT,TEMPERATURE,MAX_TOKENS,MAX_ITERS,MAX_LLM_CALLS}` | `memory.hippocampus.cartographer.*` | ### Per-Signature Memory Overrides -Each signature's `memory:` block in YAML (or `_MEMORY_*` env vars): +Each signature's `memory:` block in YAML (or `REVIEW__MEMORY_*` env vars): | Setting | Env Var Suffix | Description | |---------|---------------|-------------| | enabled | `_MEMORY_ENABLED` | Enable/disable memory for this signature | -Example: `CODE_REVIEW_MEMORY_ENABLED=true` +Example: `REVIEW_CODE_REVIEW_MEMORY_ENABLED=true` See [Configuration](configuration.md#recommended-model-strategy) for recommended reflection models. @@ -240,8 +241,8 @@ Enable memory for code review: ```bash MEMORY_ENABLED=true # Or per-signature: -CODE_REVIEW_MEMORY_ENABLED=true -SUMMARY_MEMORY_ENABLED=true +REVIEW_CODE_REVIEW_MEMORY_ENABLED=true +REVIEW_SUMMARY_MEMORY_ENABLED=true ``` Recommended mid-tier reflection model: diff --git a/docs/usage.md b/docs/usage.md index f4d292b..a4ee65c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -257,10 +257,11 @@ codespy review https://github.com/owner/repo/pull/123 --no-stdout --git-comment **Enable via configuration:** ```bash # Environment variable -export OUTPUT_GIT=true +export REVIEW_OUTPUT_GIT=true # Or in codespy.yaml -output_git: true +review: + output_git: true ``` **Features:** diff --git a/src/codespy/cli_local.py b/src/codespy/cli_local.py index f645d84..7de2b42 100644 --- a/src/codespy/cli_local.py +++ b/src/codespy/cli_local.py @@ -51,7 +51,7 @@ def review_local( typer.Option( "--model", "-m", - help="LLM model to use (overrides LLM_DEFAULT_MODEL env var)", + help="LLM model to use (overrides DEFAULT_MODEL env var)", ), ] = None, ) -> None: @@ -174,7 +174,7 @@ def review_uncommitted( typer.Option( "--model", "-m", - help="LLM model to use (overrides LLM_DEFAULT_MODEL env var)", + help="LLM model to use (overrides DEFAULT_MODEL env var)", ), ] = None, ) -> None: diff --git a/src/codespy/cli_remote.py b/src/codespy/cli_remote.py index 26965a4..5423df8 100644 --- a/src/codespy/cli_remote.py +++ b/src/codespy/cli_remote.py @@ -44,7 +44,7 @@ def review( typer.Option( "--model", "-m", - help="LLM model to use (overrides LLM_DEFAULT_MODEL env var)", + help="LLM model to use (overrides DEFAULT_MODEL env var)", ), ] = None, stdout: Annotated[ diff --git a/src/codespy/config.py b/src/codespy/config.py index 2d86e9f..add6d95 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -61,28 +61,16 @@ # Build the env map once at module level -# Define bare fields (credential fields that don't use LLM_ prefix) -_BARE_LLM_FIELDS = { - "openai_api_key", - "openai_api_base", - "anthropic_api_key", - "gemini_api_key", - "aws_region", - "aws_access_key_id", - "aws_secret_access_key", - "aws_profile", - "azure_api_key", - "azure_api_base", - "azure_api_version", -} - +# collapsed_paths drops the prefix from descendant env var names. +# full_name_paths keeps the full name for specific leaves (exceptions). _ENV_MAP = build_env_map( sections={ "llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig, }, - bare_fields={"llm": _BARE_LLM_FIELDS}, + collapsed_paths={("llm",), ("memory", "hippocampus"), ("memory", "cerebral")}, + full_name_paths={("llm", "retries"), ("llm", "timeout")}, ) @@ -190,9 +178,9 @@ def get_llm_config(self, name: str) -> LLMSettings: The fully resolved settings for ``name``. """ if name == "cerebral": - config = self.memory.cerebral.retain - model = config.model or self.llm.default_model - # For cerebral, every other field falls back to llm.default_* values + # Cerebral uses only a model from retain config; all other fields use llm defaults + model = self.memory.cerebral.retain.model or self.llm.default_model + config = ReflectionModuleConfig() # Empty config - all fields fall back to defaults defaults = self.llm elif name in REFLECTION_MODULES: config = getattr(self.memory.hippocampus, name) diff --git a/src/codespy/config_utils.py b/src/codespy/config_utils.py index 63cc074..d7ffe72 100644 --- a/src/codespy/config_utils.py +++ b/src/codespy/config_utils.py @@ -61,61 +61,80 @@ def convert_env_value(value: str) -> Any: def build_env_map( sections: dict[str, type[BaseModel]], - bare_fields: dict[str, set[str]] | None = None, + *, + collapsed_paths: set[tuple[str, ...]] | None = None, + full_name_paths: set[tuple[str, ...]] | None = None, ) -> dict[str, tuple[str, ...]]: """Build a mapping of env var name -> config path tuple. Args: sections: Dict of section name -> BaseModel subclass (e.g., {"llm": LLMConfig}) - bare_fields: Optional dict of section name -> set of field names that - should use bare (non-prefixed) env var names (e.g., {"llm": {"openai_api_key"}}) + collapsed_paths: Set of path prefixes to collapse (drop the prefix segments). + For example, {("llm",)} drops "LLM_" from all descendants of llm. + full_name_paths: Set of full paths that keep their full name (not collapsed). + These take precedence over collapsed_paths. For example, + {("llm", "retries")} keeps "LLM_RETRIES" instead of "RETRIES". Returns: - Dict mapping env var name to tuple path (e.g., {"LLM_DEFAULT_MODEL": ("llm", "default_model")}) + Dict mapping env var name to tuple path (e.g., {"DEFAULT_MODEL": ("llm", "default_model")}) + + Examples: + >>> build_env_map( + ... sections={"llm": LLMConfig, "review": ReviewConfig}, + ... collapsed_paths={("llm",), ("memory", "hippocampus"), ("memory", "cerebral")}, + ... full_name_paths={("llm", "retries"), ("llm", "timeout")}, + ... ) + # Produces: + # { + # "DEFAULT_MODEL": ("llm", "default_model"), # llm prefix dropped + # "LLM_RETRIES": ("llm", "retries"), # full_name_paths keeps LLM_ + # "OPENAI_API_KEY": ("llm", "openai_api_key"), # credentials are bare + # "REVIEW_SCOPE_ENABLED": ("review", "scope", "enabled"), # review prefix kept + # "MEMORY_DISTILLER_MODEL": ("memory", "hippocampus", "distiller", "model"), + # } """ env_map: dict[str, tuple[str, ...]] = {} - bare_fields = bare_fields or {} + collapsed_paths = collapsed_paths or set() + full_name_paths = full_name_paths or set() for section_name, model_cls in sections.items(): - section_bare = bare_fields.get(section_name, set()) _build_env_map_for_model( model_cls, prefix=(section_name,), - bare_fields=section_bare, + collapsed_paths=collapsed_paths, + full_name_paths=full_name_paths, env_map=env_map, ) - # Validate uniqueness - seen: dict[str, str] = {} - for env_name, path in env_map.items(): - if env_name in seen: - raise ValueError( - f"Duplicate env var name '{env_name}' from paths {seen[env_name]} and {path}" - ) - seen[env_name] = str(path) - return env_map def _build_env_map_for_model( model_cls: type[BaseModel], prefix: tuple[str, ...], - bare_fields: set[str], + collapsed_paths: set[tuple[str, ...]], + full_name_paths: set[tuple[str, ...]], env_map: dict[str, tuple[str, ...]], ) -> None: """Recursively build env map for a model and its nested models.""" for field_name, field_info in model_cls.model_fields.items(): annotation = field_info.annotation - # Check if this field is a nested BaseModel (recurse) or a leaf - is_bare = field_name in bare_fields and len(prefix) == 1 # Only direct children + # Use alias if available, otherwise field name + seg = field_info.alias or field_name + p = prefix + (seg,) - if is_bare: - # Use bare field name (e.g., OPENAI_API_KEY instead of LLM_OPENAI_API_KEY) - env_name = field_name.upper() + # Determine the env var name + if p in full_name_paths: + # Use full path (keep all segments) + env_name = "_".join(p).upper() else: - # Use full path (e.g., LLM_DEFAULT_MODEL) - env_name = "_".join(prefix + (field_name,)).upper() + # Apply collapsing: drop each segment for which the path up to that segment is in collapsed_paths + segments = [] + for i, segment in enumerate(p): + if p[: i + 1] not in collapsed_paths: + segments.append(segment) + env_name = "_".join(segments).upper() # Check if this is a nested BaseModel origin = getattr(annotation, "__origin__", None) @@ -131,16 +150,23 @@ def _build_env_map_for_model( # Recurse into nested model, but don't add to env_map directly _build_env_map_for_model( annotation, - prefix=prefix + (field_name,), - bare_fields=set(), # Nested models don't use bare names + prefix=p, + collapsed_paths=collapsed_paths, + full_name_paths=full_name_paths, env_map=env_map, ) continue except TypeError: pass - # This is a leaf field - add to map - env_map[env_name] = prefix + (field_name,) + # This is a leaf field - add to map with duplicate check + if env_name in env_map: + old_path = env_map[env_name] + if old_path != p: + raise ValueError(f"Duplicate env var {env_name}: {old_path} vs {p}") + # Same path, already added - skip + else: + env_map[env_name] = p def apply_env_overrides(config: dict[str, Any], env_map: dict[str, tuple[str, ...]]) -> dict[str, Any]: diff --git a/tests/test_config.py b/tests/test_config.py index e00f9ad..f39bfb5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -83,12 +83,26 @@ def test_old_top_level_fields_removed(self): for field in old_fields: assert not hasattr(settings, field), f"Old field '{field}' should not exist" - def test_env_override_mapping(self): + def test_env_override_mapping(self, monkeypatch, tmp_path): """Test that env vars are mapped correctly to new paths.""" - # Check that LLM_DEFAULT_MODEL works - os.environ["LLM_DEFAULT_MODEL"] = "test-model" - try: - settings = Settings() - assert settings.llm.default_model == "test-model" - finally: - del os.environ["LLM_DEFAULT_MODEL"] + # Apply isolation: change to temp dir and use monkeypatch for env + monkeypatch.chdir(tmp_path) + + # DEFAULT_MODEL works (new name, no LLM_ prefix) + monkeypatch.setenv("DEFAULT_MODEL", "test-model") + settings = Settings() + assert settings.llm.default_model == "test-model" + monkeypatch.delenv("DEFAULT_MODEL") + + # LLM_DEFAULT_MODEL is ignored (old name no longer works) + monkeypatch.setenv("LLM_DEFAULT_MODEL", "ignored-model") + settings = Settings() + # Should use default, not the ignored env var + assert settings.llm.default_model != "ignored-model" + monkeypatch.delenv("LLM_DEFAULT_MODEL") + + # LLM_RETRIES still works (exception that keeps LLM_ prefix) + monkeypatch.setenv("LLM_RETRIES", "7") + settings = Settings() + assert settings.llm.retries == 7 + monkeypatch.delenv("LLM_RETRIES") diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index 7a6fbd1..dba9976 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -26,60 +26,36 @@ def test_default_bank_id_is_codespy(self): class TestApplyMemoryEnvOverrides: - """Tests for memory env var overrides with new paths.""" + """Tests for memory env var overrides with new collapsed paths.""" def test_override_postgres_host(self, monkeypatch): """MEMORY_POSTGRES_HOST should set memory.postgres.host.""" monkeypatch.setenv("MEMORY_POSTGRES_HOST", "myhost.example.com") - from codespy.config_utils import apply_env_overrides, build_env_map - from codespy.config_llm import LLMConfig - from codespy.config_dspy import ReviewConfig - from codespy.config_memory import MemoryConfig - - env_map = build_env_map( - sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, - bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", - "aws_region", "aws_access_key_id", "aws_secret_access_key", - "azure_api_key", "azure_api_base", "azure_api_version"}}, - ) + from codespy.config import _ENV_MAP + from codespy.config_utils import apply_env_overrides + config = {} - result = apply_env_overrides(config, env_map) + result = apply_env_overrides(config, _ENV_MAP) assert result["memory"]["postgres"]["host"] == "myhost.example.com" - def test_override_memory_hippocampus_distiller_model(self, monkeypatch): - """MEMORY_HIPPOCAMPUS_DISTILLER_MODEL should set the distiller model.""" - monkeypatch.setenv("MEMORY_HIPPOCAMPUS_DISTILLER_MODEL", "claude-3-sonnet") - from codespy.config_utils import apply_env_overrides, build_env_map - from codespy.config_llm import LLMConfig - from codespy.config_dspy import ReviewConfig - from codespy.config_memory import MemoryConfig - - env_map = build_env_map( - sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, - bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", - "aws_region", "aws_access_key_id", "aws_secret_access_key", - "azure_api_key", "azure_api_base", "azure_api_version"}}, - ) + def test_override_memory_distiller_model(self, monkeypatch): + """MEMORY_DISTILLER_MODEL should set the distiller model (collapsed path).""" + monkeypatch.setenv("MEMORY_DISTILLER_MODEL", "claude-3-sonnet") + from codespy.config import _ENV_MAP + from codespy.config_utils import apply_env_overrides + config = {} - result = apply_env_overrides(config, env_map) + result = apply_env_overrides(config, _ENV_MAP) assert result["memory"]["hippocampus"]["distiller"]["model"] == "claude-3-sonnet" - def test_override_memory_cerebral_embeddings_model(self, monkeypatch): - """MEMORY_CEREBRAL_EMBEDDINGS_MODEL should set embeddings model.""" - monkeypatch.setenv("MEMORY_CEREBRAL_EMBEDDINGS_MODEL", "openai/text-embedding-3-large") - from codespy.config_utils import apply_env_overrides, build_env_map - from codespy.config_llm import LLMConfig - from codespy.config_dspy import ReviewConfig - from codespy.config_memory import MemoryConfig - - env_map = build_env_map( - sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, - bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", - "aws_region", "aws_access_key_id", "aws_secret_access_key", - "azure_api_key", "azure_api_base", "azure_api_version"}}, - ) + def test_override_memory_embeddings_model(self, monkeypatch): + """MEMORY_EMBEDDINGS_MODEL should set embeddings model (collapsed path).""" + monkeypatch.setenv("MEMORY_EMBEDDINGS_MODEL", "openai/text-embedding-3-large") + from codespy.config import _ENV_MAP + from codespy.config_utils import apply_env_overrides + config = {} - result = apply_env_overrides(config, env_map) + result = apply_env_overrides(config, _ENV_MAP) assert result["memory"]["cerebral"]["embeddings"]["model"] == "openai/text-embedding-3-large" diff --git a/tests/test_context_safe.py b/tests/test_context_safe.py index f89ce56..f512302 100644 --- a/tests/test_context_safe.py +++ b/tests/test_context_safe.py @@ -41,59 +41,35 @@ class TestApplyRLMFallbackEnvOverrides: """Tests for RLM env var overrides using new unified mechanism.""" def test_react_threshold_override(self): - """Test LLM_RLM_FALLBACK_REACT_THRESHOLD env var.""" - from codespy.config_llm import LLMConfig - from codespy.config_dspy import ReviewConfig - from codespy.config_memory import MemoryConfig - - env_map = build_env_map( - sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, - bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", - "aws_region", "aws_access_key_id", "aws_secret_access_key", - "azure_api_key", "azure_api_base", "azure_api_version"}}, - ) + """Test RLM_FALLBACK_REACT_THRESHOLD env var (new name, no LLM_ prefix).""" + from codespy.config import _ENV_MAP + config = {} - with patch.dict(os.environ, {"LLM_RLM_FALLBACK_REACT_THRESHOLD": "0.25"}): - result = apply_env_overrides(config, env_map) + with patch.dict(os.environ, {"RLM_FALLBACK_REACT_THRESHOLD": "0.25"}): + result = apply_env_overrides(config, _ENV_MAP) assert result["llm"]["rlm_fallback"]["react_threshold"] == "0.25" def test_enabled_override(self): - """Test LLM_RLM_FALLBACK_ENABLED env var.""" - from codespy.config_llm import LLMConfig - from codespy.config_dspy import ReviewConfig - from codespy.config_memory import MemoryConfig - - env_map = build_env_map( - sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, - bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", - "aws_region", "aws_access_key_id", "aws_secret_access_key", - "azure_api_key", "azure_api_base", "azure_api_version"}}, - ) + """Test RLM_FALLBACK_ENABLED env var (new name, no LLM_ prefix).""" + from codespy.config import _ENV_MAP + config = {} - with patch.dict(os.environ, {"LLM_RLM_FALLBACK_ENABLED": "false"}): - result = apply_env_overrides(config, env_map) + with patch.dict(os.environ, {"RLM_FALLBACK_ENABLED": "false"}): + result = apply_env_overrides(config, _ENV_MAP) assert result["llm"]["rlm_fallback"]["enabled"] is False def test_all_thresholds(self): - """Test all threshold env vars.""" - from codespy.config_llm import LLMConfig - from codespy.config_dspy import ReviewConfig - from codespy.config_memory import MemoryConfig - - env_map = build_env_map( - sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, - bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", - "aws_region", "aws_access_key_id", "aws_secret_access_key", - "azure_api_key", "azure_api_base", "azure_api_version"}}, - ) + """Test all threshold env vars (new names, no LLM_ prefix).""" + from codespy.config import _ENV_MAP + config = {} env_vars = { - "LLM_RLM_FALLBACK_REACT_THRESHOLD": "0.35", - "LLM_RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD": "0.45", - "LLM_RLM_FALLBACK_PREDICT_THRESHOLD": "0.55", + "RLM_FALLBACK_REACT_THRESHOLD": "0.35", + "RLM_FALLBACK_CHAIN_OF_THOUGHT_THRESHOLD": "0.45", + "RLM_FALLBACK_PREDICT_THRESHOLD": "0.55", } with patch.dict(os.environ, env_vars): - result = apply_env_overrides(config, env_map) + result = apply_env_overrides(config, _ENV_MAP) # Values are strings until pydantic coerces them assert result["llm"]["rlm_fallback"]["react_threshold"] == "0.35" assert result["llm"]["rlm_fallback"]["chain_of_thought_threshold"] == "0.45" @@ -101,38 +77,22 @@ def test_all_thresholds(self): def test_unrelated_env_vars_ignored(self): """Test that unrelated env vars are ignored.""" - from codespy.config_llm import LLMConfig - from codespy.config_dspy import ReviewConfig - from codespy.config_memory import MemoryConfig - - env_map = build_env_map( - sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, - bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", - "aws_region", "aws_access_key_id", "aws_secret_access_key", - "azure_api_key", "azure_api_base", "azure_api_version"}}, - ) + from codespy.config import _ENV_MAP + config = {} with patch.dict(os.environ, {"OTHER_VAR": "value"}): - result = apply_env_overrides(config, env_map) + result = apply_env_overrides(config, _ENV_MAP) # rlm_fallback may not exist in result if no env vars matched if "llm" in result: assert "rlm_fallback" not in result.get("llm", {}) def test_existing_config_preserved(self): """Test that existing config is preserved.""" - from codespy.config_llm import LLMConfig - from codespy.config_dspy import ReviewConfig - from codespy.config_memory import MemoryConfig - - env_map = build_env_map( - sections={"llm": LLMConfig, "review": ReviewConfig, "memory": MemoryConfig}, - bare_fields={"llm": {"openai_api_key", "anthropic_api_key", "gemini_api_key", - "aws_region", "aws_access_key_id", "aws_secret_access_key", - "azure_api_key", "azure_api_base", "azure_api_version"}}, - ) + from codespy.config import _ENV_MAP + config = {"llm": {"rlm_fallback": {"enabled": False, "react_threshold": 0.20}}} - with patch.dict(os.environ, {"LLM_RLM_FALLBACK_REACT_THRESHOLD": "0.35"}): - result = apply_env_overrides(config, env_map) + with patch.dict(os.environ, {"RLM_FALLBACK_REACT_THRESHOLD": "0.35"}): + result = apply_env_overrides(config, _ENV_MAP) assert result["llm"]["rlm_fallback"]["enabled"] is False # Value is a string until pydantic coerces it assert result["llm"]["rlm_fallback"]["react_threshold"] == "0.35" From dba21bb84f36138987e1d6ec227403152f42c7fb Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Sun, 27 Sep 2026 17:37:39 +0200 Subject: [PATCH 17/39] cost --- CHANGELOG.md | 10 +- codespy.yaml | 68 ++++---- src/codespy/agents/cost_tracker.py | 6 +- src/codespy/agents/dspy_config.py | 4 +- src/codespy/agents/memory/cerebral/cost.py | 20 ++- .../agents/memory/hippocampus/cartographer.py | 12 +- .../agents/memory/hippocampus/distiller.py | 12 +- src/codespy/config.py | 18 +- src/codespy/config_memory.py | 44 ++++- tests/test_cerebral_cost.py | 36 ++-- tests/test_config.py | 159 ++++++++++++++++++ tests/test_cost_tracker.py | 40 ++--- 12 files changed, 319 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b841838..733fc7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,16 @@ - Added missing passthrough for `DOC_MAX_ITERS` and `DOC_MAX_LLM_CALLS` - Migration note: **upgrade the action ref and `codespy-version` together**. An older action with the `latest` image, or the reverse, silently drops review settings. +### Changed +- **Internal naming**: Cost table rows and internal unit names were renamed to match the `MEMORY_*` config/env names: + - `distiller` → `memory_distiller` + - `cartographer` → `memory_cartographer` + - `cerebral_retain` → `memory_retain` + - `cerebral_embeddings` → `memory_embeddings` + - `cerebral_other` → `memory_other` + ### Fixed -- `get_llm_config("cerebral")` no longer raises `AttributeError` — the cerebral branch now uses empty `ReflectionModuleConfig` and falls back to `llm.default_*` values +- `get_llm_config("memory_retain")` no longer raises `AttributeError` — the cerebral branch now uses empty `ReflectionModuleConfig` and falls back to `llm.default_*` values - `MEMORY_POSTGRES_SCHEMA` env var now works correctly (previously emitted `MEMORY_POSTGRES_SCHEMA_NAME` which was ignored) - GitHub Action `memory-enabled` input now maps to `MEMORY_ENABLED` (was `MEMORY_DEFAULT_ENABLED`, which didn't match the code) diff --git a/codespy.yaml b/codespy.yaml index a15c9a5..c6ec10d 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -46,17 +46,17 @@ llm: azure_api_version: null # AZURE_API_VERSION # Default model settings - default_model: anthropic/claude-opus-4-6 # DEFAULT_MODEL + default_model: anthropic/claude-opus-4-6 # DEFAULT_MODEL extraction_model: null # EXTRACTION_MODEL (falls back to default_model) - default_max_iters: 5 # DEFAULT_MAX_ITERS - default_max_llm_calls: 8 # DEFAULT_MAX_LLM_CALLS + default_max_iters: 5 # DEFAULT_MAX_ITERS + default_max_llm_calls: 8 # DEFAULT_MAX_LLM_CALLS default_reasoning_effort: medium # DEFAULT_REASONING_EFFORT default_temperature: 0.2 # DEFAULT_TEMPERATURE default_max_tokens: 32000 # DEFAULT_MAX_TOKENS # Global LLM reliability settings retries: 2 # LLM_RETRIES - timeout: 240 # LLM_TIMEOUT + timeout: 240 # LLM_TIMEOUT # RLM Fallback (context rot prevention) rlm_fallback: @@ -113,16 +113,16 @@ memory: reasoning_effort: null # MEMORY_DISTILLER_REASONING_EFFORT temperature: null # MEMORY_DISTILLER_TEMPERATURE max_tokens: null # MEMORY_DISTILLER_MAX_TOKENS - max_iters: 1 # MEMORY_DISTILLER_MAX_ITERS - max_llm_calls: 2 # MEMORY_DISTILLER_MAX_LLM_CALLS + max_iters: 1 # MEMORY_DISTILLER_MAX_ITERS + max_llm_calls: 2 # MEMORY_DISTILLER_MAX_LLM_CALLS cartographer: model: null # MEMORY_CARTOGRAPHER_MODEL reasoning_effort: null # MEMORY_CARTOGRAPHER_REASONING_EFFORT temperature: null # MEMORY_CARTOGRAPHER_TEMPERATURE max_tokens: null # MEMORY_CARTOGRAPHER_MAX_TOKENS - max_iters: 1 # MEMORY_CARTOGRAPHER_MAX_ITERS - max_llm_calls: 2 # MEMORY_CARTOGRAPHER_MAX_LLM_CALLS + max_iters: 1 # MEMORY_CARTOGRAPHER_MAX_ITERS + max_llm_calls: 2 # MEMORY_CARTOGRAPHER_MAX_LLM_CALLS # Cerebral (semantic memory) cerebral: @@ -135,13 +135,13 @@ memory: # REVIEW # ============================================================================ review: - min_confidence: 0.81 # REVIEW_MIN_CONFIDENCE - output_format: markdown # REVIEW_OUTPUT_FORMAT + min_confidence: 0.81 # REVIEW_MIN_CONFIDENCE + output_format: markdown # REVIEW_OUTPUT_FORMAT output_stdout: true # REVIEW_OUTPUT_STDOUT - output_git: true # REVIEW_OUTPUT_GIT - cache_dir: ~/.cache/codespy # REVIEW_CACHE_DIR + output_git: true # REVIEW_OUTPUT_GIT + cache_dir: ~/.cache/codespy # REVIEW_CACHE_DIR - excluded_directories: # REVIEW_EXCLUDED_DIRECTORIES (JSON array in env) + excluded_directories: # REVIEW_EXCLUDED_DIRECTORIES (JSON array in env) - vendor - node_modules - third_party @@ -171,14 +171,14 @@ review: supply_chain: enabled: true # REVIEW_SUPPLY_CHAIN_ENABLED max_iters: null # REVIEW_SUPPLY_CHAIN_MAX_ITERS - max_llm_calls: null # REVIEW_SUPPLY_CHAIN_MAX_LLM_CALLS + max_llm_calls: null # REVIEW_SUPPLY_CHAIN_MAX_LLM_CALLS model: null # REVIEW_SUPPLY_CHAIN_MODEL - reasoning_effort: null # REVIEW_SUPPLY_CHAIN_REASONING_EFFORT - temperature: null # REVIEW_SUPPLY_CHAIN_TEMPERATURE + reasoning_effort: null # REVIEW_SUPPLY_CHAIN_REASONING_EFFORT + temperature: null # REVIEW_SUPPLY_CHAIN_TEMPERATURE max_tokens: null # REVIEW_SUPPLY_CHAIN_MAX_TOKENS - scan_unchanged: false # REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED + scan_unchanged: false # REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED memory: - enabled: null # REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED + enabled: null # REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED # Code Review code_review: @@ -186,11 +186,11 @@ review: max_iters: 5 # REVIEW_CODE_REVIEW_MAX_ITERS max_llm_calls: 8 # REVIEW_CODE_REVIEW_MAX_LLM_CALLS model: null # REVIEW_CODE_REVIEW_MODEL - reasoning_effort: null # REVIEW_CODE_REVIEW_REASONING_EFFORT - temperature: null # REVIEW_CODE_REVIEW_TEMPERATURE + reasoning_effort: null # REVIEW_CODE_REVIEW_REASONING_EFFORT + temperature: null # REVIEW_CODE_REVIEW_TEMPERATURE max_tokens: null # REVIEW_CODE_REVIEW_MAX_TOKENS memory: - enabled: null # REVIEW_CODE_REVIEW_MEMORY_ENABLED + enabled: null # REVIEW_CODE_REVIEW_MEMORY_ENABLED # Documentation Review doc: @@ -198,11 +198,11 @@ review: max_iters: 1 # REVIEW_DOC_MAX_ITERS max_llm_calls: 2 # REVIEW_DOC_MAX_LLM_CALLS model: null # REVIEW_DOC_MODEL - reasoning_effort: null # REVIEW_DOC_REASONING_EFFORT - temperature: null # REVIEW_DOC_TEMPERATURE + reasoning_effort: null # REVIEW_DOC_REASONING_EFFORT + temperature: null # REVIEW_DOC_TEMPERATURE max_tokens: null # REVIEW_DOC_MAX_TOKENS memory: - enabled: null # REVIEW_DOC_MEMORY_ENABLED + enabled: null # REVIEW_DOC_MEMORY_ENABLED # Scope Identification scope: @@ -210,12 +210,12 @@ review: max_iters: 3 # REVIEW_SCOPE_MAX_ITERS max_llm_calls: 5 # REVIEW_SCOPE_MAX_LLM_CALLS model: null # REVIEW_SCOPE_MODEL - reasoning_effort: null # REVIEW_SCOPE_REASONING_EFFORT - temperature: null # REVIEW_SCOPE_TEMPERATURE + reasoning_effort: null # REVIEW_SCOPE_REASONING_EFFORT + temperature: null # REVIEW_SCOPE_TEMPERATURE max_tokens: null # REVIEW_SCOPE_MAX_TOKENS - skip_refinement_when_clean: null # REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN + skip_refinement_when_clean: null # REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN memory: - enabled: null # REVIEW_SCOPE_MEMORY_ENABLED + enabled: null # REVIEW_SCOPE_MEMORY_ENABLED # Summary summary: @@ -223,11 +223,11 @@ review: max_iters: 1 # REVIEW_SUMMARY_MAX_ITERS max_llm_calls: 2 # REVIEW_SUMMARY_MAX_LLM_CALLS model: null # REVIEW_SUMMARY_MODEL - reasoning_effort: null # REVIEW_SUMMARY_REASONING_EFFORT - temperature: null # REVIEW_SUMMARY_TEMPERATURE + reasoning_effort: null # REVIEW_SUMMARY_REASONING_EFFORT + temperature: null # REVIEW_SUMMARY_TEMPERATURE max_tokens: null # REVIEW_SUMMARY_MAX_TOKENS memory: - enabled: null # REVIEW_SUMMARY_MEMORY_ENABLED + enabled: null # REVIEW_SUMMARY_MEMORY_ENABLED # Audit audit: @@ -235,8 +235,8 @@ review: max_iters: 1 # REVIEW_AUDIT_MAX_ITERS max_llm_calls: 2 # REVIEW_AUDIT_MAX_LLM_CALLS model: null # REVIEW_AUDIT_MODEL - reasoning_effort: null # REVIEW_AUDIT_REASONING_EFFORT - temperature: null # REVIEW_AUDIT_TEMPERATURE + reasoning_effort: null # REVIEW_AUDIT_REASONING_EFFORT + temperature: null # REVIEW_AUDIT_TEMPERATURE max_tokens: null # REVIEW_AUDIT_MAX_TOKENS memory: - enabled: null # REVIEW_AUDIT_MEMORY_ENABLED + enabled: null # REVIEW_AUDIT_MEMORY_ENABLED diff --git a/src/codespy/agents/cost_tracker.py b/src/codespy/agents/cost_tracker.py index 44a4742..171d436 100644 --- a/src/codespy/agents/cost_tracker.py +++ b/src/codespy/agents/cost_tracker.py @@ -149,7 +149,7 @@ def add_external_call( bucket (not wall-clock), consistent with how cost/tokens are summed. Args: - name: Bucket name for the external call (e.g., "cerebral_retain") + name: Bucket name for the external call (e.g., "memory_retain") cost: Cost in USD for the call(s) tokens: Total tokens used calls: Number of calls (default 1) @@ -393,8 +393,8 @@ class SignatureContext: Two responsibilities, both keyed off the same name: 1. **LM selection** — applies the model, temperature, and reasoning effort - configured for this name (``signatures.`` or ``memory.``), - falling back to the top-level defaults. + configured for this name (``review.`` or ``memory.hippocampus.`` + for ``memory_``), falling back to the top-level defaults. 2. **Cost attribution** — uses DSPy's LM history to attribute costs reliably, even during parallel execution with dspy.Parallel, by recording history UUIDs on entry and summing only the new entries. diff --git a/src/codespy/agents/dspy_config.py b/src/codespy/agents/dspy_config.py index 26b76dd..95cfbd7 100644 --- a/src/codespy/agents/dspy_config.py +++ b/src/codespy/agents/dspy_config.py @@ -7,7 +7,7 @@ from dspy.adapters.two_step_adapter import TwoStepAdapter # type: ignore[import-untyped] from codespy.config import Settings, get_settings -from codespy.config_memory import REFLECTION_MODULES, LLMSettings +from codespy.config_memory import MEMORY_RETAIN, REFLECTION_MODULES, LLMSettings from codespy.config_utils import secret_value logger = logging.getLogger(__name__) @@ -245,7 +245,7 @@ def verify_model_access(settings: Settings) -> tuple[bool, str]: models_to_check.add(reflection.model) # Cerebral retain model - cerebral = settings.get_llm_config("cerebral") + cerebral = settings.get_llm_config(MEMORY_RETAIN) models_to_check.add(cerebral.model) # Check each model diff --git a/src/codespy/agents/memory/cerebral/cost.py b/src/codespy/agents/memory/cerebral/cost.py index afffec1..f8ed7d0 100644 --- a/src/codespy/agents/memory/cerebral/cost.py +++ b/src/codespy/agents/memory/cerebral/cost.py @@ -12,6 +12,12 @@ import time from typing import TYPE_CHECKING, Any +from codespy.config_memory import ( + MEMORY_EMBEDDINGS, + MEMORY_OTHER, + MEMORY_RETAIN, +) + if TYPE_CHECKING: from hindsight_api.engine.embeddings import LiteLLMSDKEmbeddings @@ -21,10 +27,10 @@ _recorder_lock = threading.Lock() _recorder_registered = False -# Buckets for cost attribution -BUCKET_CEREBRAL_RETAIN = "cerebral_retain" -BUCKET_CEREBRAL_OTHER = "cerebral_other" -BUCKET_CEREBRAL_EMBEDDINGS = "cerebral_embeddings" +# Buckets for cost attribution (using memory_* naming to match config/env names) +BUCKET_MEMORY_RETAIN = MEMORY_RETAIN +BUCKET_MEMORY_OTHER = MEMORY_OTHER +BUCKET_MEMORY_EMBEDDINGS = MEMORY_EMBEDDINGS # Track models we've warned about missing prices (one warning per model) _warned_unpriced_models: set[str] = set() @@ -71,9 +77,9 @@ def record_llm_call( # type: ignore[return] # Determine bucket from scope if scope and scope.startswith("retain"): - bucket = BUCKET_CEREBRAL_RETAIN + bucket = BUCKET_MEMORY_RETAIN else: - bucket = BUCKET_CEREBRAL_OTHER + bucket = BUCKET_MEMORY_OTHER # Price the call using litellm prompt_cost, completion_cost = self._price_call_split(model, input_tokens, output_tokens) @@ -204,7 +210,7 @@ def _meter_embedding(self, response: Any, model: str, duration: float = 0.0) -> tracker = get_cost_tracker() # Embeddings have no output tokens - all tokens are input tracker.add_external_call( - BUCKET_CEREBRAL_EMBEDDINGS, + BUCKET_MEMORY_EMBEDDINGS, cost=cost or 0.0, tokens=tokens, calls=1, diff --git a/src/codespy/agents/memory/hippocampus/cartographer.py b/src/codespy/agents/memory/hippocampus/cartographer.py index adf88e8..ed78563 100644 --- a/src/codespy/agents/memory/hippocampus/cartographer.py +++ b/src/codespy/agents/memory/hippocampus/cartographer.py @@ -147,8 +147,8 @@ class Cartographer(dspy.Module): enforcement is the Evictor's job. """ - # Name this module's settings live under: memory.cartographer. - SIGNATURE = "cartographer" + # Name this module's settings live under: memory.hippocampus.cartographer. + SIGNATURE = "memory_cartographer" def __init__(self): super().__init__() @@ -157,9 +157,9 @@ def __init__(self): self.predict = ContextSafe( dspy.ChainOfThought(CartographerSig), CartographerSig, - name="cartographer", - max_iters=settings.get_max_iters("cartographer"), - max_llm_calls=settings.get_max_llm_calls("cartographer"), + name=self.SIGNATURE, + max_iters=settings.get_max_iters(self.SIGNATURE), + max_llm_calls=settings.get_max_llm_calls(self.SIGNATURE), rlm_threshold=settings.get_rlm_threshold("chain_of_thought"), ) @@ -174,7 +174,7 @@ def forward( current_tokens, max_context_item_tokens, ): - # See Distiller.forward: SignatureContext applies memory.cartographer's + # See Distiller.forward: SignatureContext applies memory.hippocampus.cartographer's # LLM settings and gives this module its own cost line. Entered here # because DSPy's context is thread-scoped and reflection runs in a worker. from codespy.agents import SignatureContext, get_cost_tracker diff --git a/src/codespy/agents/memory/hippocampus/distiller.py b/src/codespy/agents/memory/hippocampus/distiller.py index ccdfd57..a2e0ee2 100644 --- a/src/codespy/agents/memory/hippocampus/distiller.py +++ b/src/codespy/agents/memory/hippocampus/distiller.py @@ -154,8 +154,8 @@ class Distiller(dspy.Module): specific work, tags every existing observation, and proposes new candidates. """ - # Name this module's settings live under: memory.distiller. - SIGNATURE = "distiller" + # Name this module's settings live under: memory.hippocampus.distiller. + SIGNATURE = "memory_distiller" def __init__(self): super().__init__() @@ -164,9 +164,9 @@ def __init__(self): self.predict = ContextSafe( dspy.ChainOfThought(DistillerSig), DistillerSig, - name="distiller", - max_iters=settings.get_max_iters("distiller"), - max_llm_calls=settings.get_max_llm_calls("distiller"), + name=self.SIGNATURE, + max_iters=settings.get_max_iters(self.SIGNATURE), + max_llm_calls=settings.get_max_llm_calls(self.SIGNATURE), rlm_threshold=settings.get_rlm_threshold("chain_of_thought"), ) @@ -177,7 +177,7 @@ def forward( question: str, max_context_item_tokens: int, ): - # SignatureContext applies memory.distiller's model/temperature/reasoning + # SignatureContext applies memory.hippocampus.distiller's model/temperature/reasoning # effort and attributes the cost to this module rather than to whichever # agent triggered the reflection. It must be entered here, not by the # caller: Hippocampus reflects inside an asyncio.to_thread worker and diff --git a/src/codespy/config.py b/src/codespy/config.py index add6d95..69f6839 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -21,10 +21,12 @@ ) from codespy.config_llm import LLMConfig from codespy.config_memory import ( + MEMORY_RETAIN, REFLECTION_MODULES, LLMSettings, MemoryConfig, ReflectionModuleConfig, + reflection_module_config, reset_episode_store, ) from codespy.config_utils import ( @@ -145,7 +147,7 @@ def is_signature_enabled(self, signature_name: str) -> bool: def get_max_iters(self, name: str) -> int: """Get max_iters for a signature or reflection module (name-specific or default).""" if name in REFLECTION_MODULES: - config = getattr(self.memory.hippocampus, name) + config = reflection_module_config(self.memory.hippocampus, name) return config.max_iters or self.llm.default_max_iters config = self.get_signature_config(name) return config.max_iters or self.llm.default_max_iters @@ -153,7 +155,7 @@ def get_max_iters(self, name: str) -> int: def get_max_llm_calls(self, name: str) -> int: """Get max_llm_calls for a signature or reflection module (name-specific or default).""" if name in REFLECTION_MODULES: - config = getattr(self.memory.hippocampus, name) + config = reflection_module_config(self.memory.hippocampus, name) return config.max_llm_calls or self.llm.default_max_llm_calls config = self.get_signature_config(name) return config.max_llm_calls or self.llm.default_max_llm_calls @@ -163,27 +165,27 @@ def get_llm_config(self, name: str) -> LLMSettings: ``name`` addresses either a signature or a memory reflection module:: - "code_review" -> review.code_review - "distiller" -> memory.hippocampus.distiller - "cerebral" -> memory.cerebral.retain + "code_review" -> review.code_review + "memory_distiller" -> memory.hippocampus.distiller + "memory_retain" -> memory.cerebral.retain Every field falls back to its ``llm.default_*`` counterpart, so the result has no ``None`` fields and callers never re-apply fallbacks. Args: name: A signature name, or a reflection module name - (see ``REFLECTION_MODULES``), or "cerebral". + (see ``REFLECTION_MODULES``), or "memory_retain". Returns: The fully resolved settings for ``name``. """ - if name == "cerebral": + if name == MEMORY_RETAIN: # Cerebral uses only a model from retain config; all other fields use llm defaults model = self.memory.cerebral.retain.model or self.llm.default_model config = ReflectionModuleConfig() # Empty config - all fields fall back to defaults defaults = self.llm elif name in REFLECTION_MODULES: - config = getattr(self.memory.hippocampus, name) + config = reflection_module_config(self.memory.hippocampus, name) model = config.model or self.llm.default_model defaults = self.llm elif name == "default": diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 92bd4de..657e4ae 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -87,9 +87,9 @@ class LLMSettings(BaseModel): """Fully resolved LLM settings for one named unit of work. Produced by ``Settings.get_llm_config()`` for either a signature - (``review.``) or a reflection module (``memory.hippocampus.``): - every field is either the name-specific override or the corresponding top-level - default, so consumers never re-apply fallback logic. + (``review.``) or a reflection module (``memory.hippocampus.`` for + ``memory_``): every field is either the name-specific override or the + corresponding top-level default, so consumers never re-apply fallback logic. """ model: str @@ -161,16 +161,50 @@ class MemoryConfig(BaseModel): cerebral: CerebralConfig = Field(default_factory=CerebralConfig) +# Memory unit name prefix for LLM work units +MEMORY_UNIT_PREFIX = "memory_" + +# Memory unit names (used for cost buckets, config lookup, and logging) +MEMORY_DISTILLER = "memory_distiller" +MEMORY_CARTOGRAPHER = "memory_cartographer" +MEMORY_RETAIN = "memory_retain" +MEMORY_EMBEDDINGS = "memory_embeddings" +MEMORY_OTHER = "memory_other" + # The reflection modules, derived from the HippocampusConfig fields that hold a # ReflectionModuleConfig. Iterate this instead of hardcoding module names so # adding a new reflection module only requires declaring its field above. +# Names are prefixed with MEMORY_UNIT_PREFIX to match the config/env naming. REFLECTION_MODULES: tuple[str, ...] = tuple( - name + f"{MEMORY_UNIT_PREFIX}{name}" for name, field in HippocampusConfig.model_fields.items() if field.annotation is ReflectionModuleConfig ) +def reflection_module_config(hippocampus: HippocampusConfig, name: str) -> ReflectionModuleConfig: + """Get the ReflectionModuleConfig for a prefixed module name. + + Removes the MEMORY_UNIT_PREFIX and returns the corresponding config + from the HippocampusConfig. + + Args: + hippocampus: The HippocampusConfig instance. + name: The prefixed module name (e.g., "memory_distiller"). + + Returns: + The ReflectionModuleConfig for that module. + + Raises: + ValueError: If the name doesn't start with the expected prefix. + AttributeError: If the module doesn't exist on HippocampusConfig. + """ + if not name.startswith(MEMORY_UNIT_PREFIX): + raise ValueError(f"Expected name to start with '{MEMORY_UNIT_PREFIX}', got: {name}") + field_name = name[len(MEMORY_UNIT_PREFIX):] + return getattr(hippocampus, field_name) + + # Cached singleton store. Avoids reconstructing the EpisodeStore's connection pool # on every call. _store: EpisodeStore | None = None @@ -298,7 +332,7 @@ def _cerebral_litellm_params(settings: "Settings") -> tuple[str, str | None, str Returns: ``(model, api_key, base_url)`` - model is the full litellm string unchanged """ - llm_config = settings.get_llm_config("cerebral") + llm_config = settings.get_llm_config(MEMORY_RETAIN) model = llm_config.model # e.g. "bedrock/converse/moonshotai.kimi-k2.5" # Parse litellm model string: "provider/model_path" diff --git a/tests/test_cerebral_cost.py b/tests/test_cerebral_cost.py index 766c74a..9427ca1 100644 --- a/tests/test_cerebral_cost.py +++ b/tests/test_cerebral_cost.py @@ -7,9 +7,9 @@ from codespy.agents.cost_tracker import CostTracker, get_cost_tracker from codespy.agents.memory.cerebral.cost import ( - BUCKET_CEREBRAL_EMBEDDINGS, - BUCKET_CEREBRAL_OTHER, - BUCKET_CEREBRAL_RETAIN, + BUCKET_MEMORY_EMBEDDINGS, + BUCKET_MEMORY_OTHER, + BUCKET_MEMORY_RETAIN, CerebralCostRecorder, MeteredLiteLLMSDKEmbeddings, _LiteLLMProxy, @@ -57,7 +57,7 @@ class TestCerebralCostRecorder: """Tests for CerebralCostRecorder class.""" def test_record_llm_call_with_retain_scope(self, mock_tracker, fresh_recorder): - """scope='retain_extract_facts' → cerebral_retain bucket.""" + """scope='retain_extract_facts' → memory_retain bucket.""" with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): with patch.object( CerebralCostRecorder, "_price_call_split", return_value=(0.03, 0.02) @@ -70,7 +70,7 @@ def test_record_llm_call_with_retain_scope(self, mock_tracker, fresh_recorder): output_tokens=50, ) - stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_RETAIN) assert stats is not None assert stats.cost == 0.05 # 0.03 + 0.02 assert stats.tokens == 150 @@ -80,7 +80,7 @@ def test_record_llm_call_with_retain_scope(self, mock_tracker, fresh_recorder): assert stats.output_cost == 0.02 def test_record_llm_call_with_consolidation_scope(self, mock_tracker, fresh_recorder): - """scope='consolidation' → cerebral_other bucket.""" + """scope='consolidation' → memory_other bucket.""" with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.02, 0.01)): recorder = CerebralCostRecorder() @@ -91,13 +91,13 @@ def test_record_llm_call_with_consolidation_scope(self, mock_tracker, fresh_reco output_tokens=50, ) - stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_OTHER) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_OTHER) assert stats is not None assert stats.input_tokens == 100 assert stats.output_tokens == 50 def test_record_llm_call_with_other_scope(self, mock_tracker, fresh_recorder): - """Any other scope → cerebral_other bucket.""" + """Any other scope → memory_other bucket.""" with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.005, 0.005)): recorder = CerebralCostRecorder() @@ -108,7 +108,7 @@ def test_record_llm_call_with_other_scope(self, mock_tracker, fresh_recorder): output_tokens=50, ) - stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_OTHER) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_OTHER) assert stats is not None def test_record_llm_call_skips_error_calls(self, mock_tracker, fresh_recorder): @@ -123,7 +123,7 @@ def test_record_llm_call_skips_error_calls(self, mock_tracker, fresh_recorder): error=Exception("API error"), ) - stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_RETAIN) assert stats is None def test_record_llm_call_skips_zero_tokens(self, mock_tracker, fresh_recorder): @@ -137,7 +137,7 @@ def test_record_llm_call_skips_zero_tokens(self, mock_tracker, fresh_recorder): output_tokens=0, ) - stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_RETAIN) assert stats is None def test_record_llm_call_prices_using_litellm(self, mock_tracker, fresh_recorder): @@ -158,7 +158,7 @@ def test_record_llm_call_prices_using_litellm(self, mock_tracker, fresh_recorder completion_tokens=50, ) - stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_RETAIN) assert stats.cost == 0.03 # 0.01 + 0.02 def test_record_llm_call_forwards_duration(self, mock_tracker, fresh_recorder): @@ -174,7 +174,7 @@ def test_record_llm_call_forwards_duration(self, mock_tracker, fresh_recorder): duration=1.25, ) - stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_RETAIN) assert stats is not None assert stats.external_duration_seconds == 1.25 assert stats.duration_seconds == 1.25 @@ -206,7 +206,7 @@ def test_record_llm_call_logs_warning_for_unpriced_model( output_tokens=50, ) - stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_RETAIN) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_RETAIN) assert stats.cost == 0.0 assert stats.tokens == 300 # 150 * 2 # Should only have one warning in the log @@ -232,7 +232,7 @@ def mock_response(self): @pytest.mark.asyncio async def test_aembedding_meters_usage(self, mock_litellm, mock_response, mock_tracker): - """Embedding call meters usage to cerebral_embeddings bucket.""" + """Embedding call meters usage to memory_embeddings bucket.""" mock_litellm.aembedding.return_value = asyncio.Future() mock_litellm.aembedding.return_value.set_result(mock_response) @@ -241,14 +241,14 @@ async def test_aembedding_meters_usage(self, mock_litellm, mock_response, mock_t proxy = _LiteLLMProxy(mock_litellm, cost_recorder) await proxy.aembedding(model="openai/text-embedding-3-small", input=["test"]) - stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_EMBEDDINGS) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_EMBEDDINGS) assert stats is not None assert stats.tokens == 100 assert stats.cost == 0.01 @pytest.mark.asyncio async def test_aembedding_records_duration(self, mock_litellm, mock_response, mock_tracker): - """Embedding call records nonzero duration to cerebral_embeddings bucket.""" + """Embedding call records nonzero duration to memory_embeddings bucket.""" mock_litellm.aembedding.return_value = asyncio.Future() mock_litellm.aembedding.return_value.set_result(mock_response) @@ -257,7 +257,7 @@ async def test_aembedding_records_duration(self, mock_litellm, mock_response, mo proxy = _LiteLLMProxy(mock_litellm, cost_recorder) await proxy.aembedding(model="openai/text-embedding-3-small", input=["test"]) - stats = mock_tracker.get_signature_stats(BUCKET_CEREBRAL_EMBEDDINGS) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_EMBEDDINGS) assert stats is not None assert stats.external_duration_seconds >= 0.0 # Should have some duration (could be very small) assert stats.duration_seconds == stats.external_duration_seconds diff --git a/tests/test_config.py b/tests/test_config.py index f39bfb5..ef4299a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -106,3 +106,162 @@ def test_env_override_mapping(self, monkeypatch, tmp_path): settings = Settings() assert settings.llm.retries == 7 monkeypatch.delenv("LLM_RETRIES") + + +class TestReflectionModules: + """Tests for memory reflection modules and unit names.""" + + def test_reflection_modules_has_prefixed_names(self): + """REFLECTION_MODULES should use memory_* prefixed names.""" + from codespy.config_memory import REFLECTION_MODULES + + assert REFLECTION_MODULES == ("memory_distiller", "memory_cartographer") + + def test_reflection_module_config_helper(self): + """reflection_module_config removes prefix and returns config.""" + from codespy.config_memory import ( + HippocampusConfig, + reflection_module_config, + ) + + hippocampus = HippocampusConfig() + config = reflection_module_config(hippocampus, "memory_distiller") + assert config is hippocampus.distiller + + config = reflection_module_config(hippocampus, "memory_cartographer") + assert config is hippocampus.cartographer + + def test_get_llm_config_for_memory_distiller(self, monkeypatch, tmp_path): + """get_llm_config('memory_distiller') reads memory.hippocampus.distiller.""" + monkeypatch.chdir(tmp_path) + + # Create a config file with custom distiller model + config_file = tmp_path / "codespy.yaml" + config_file.write_text(""" +llm: + default_model: openai/gpt-4o +memory: + hippocampus: + distiller: + model: anthropic/claude-3-haiku +""") + monkeypatch.setenv("CODESPY_CONFIG", str(config_file)) + + settings = Settings() + llm_config = settings.get_llm_config("memory_distiller") + assert llm_config.model == "anthropic/claude-3-haiku" + + monkeypatch.delenv("CODESPY_CONFIG", raising=False) + + def test_get_llm_config_for_memory_cartographer(self, monkeypatch, tmp_path): + """get_llm_config('memory_cartographer') reads memory.hippocampus.cartographer.""" + monkeypatch.chdir(tmp_path) + + config_file = tmp_path / "codespy.yaml" + config_file.write_text(""" +llm: + default_model: openai/gpt-4o +memory: + hippocampus: + cartographer: + model: anthropic/claude-3-sonnet +""") + monkeypatch.setenv("CODESPY_CONFIG", str(config_file)) + + settings = Settings() + llm_config = settings.get_llm_config("memory_cartographer") + assert llm_config.model == "anthropic/claude-3-sonnet" + + monkeypatch.delenv("CODESPY_CONFIG", raising=False) + + def test_get_llm_config_for_memory_retain(self, monkeypatch, tmp_path): + """get_llm_config('memory_retain') reads memory.cerebral.retain with fallback.""" + monkeypatch.chdir(tmp_path) + + # Test with explicit retain model + config_file = tmp_path / "codespy.yaml" + config_file.write_text(""" +llm: + default_model: openai/gpt-4o +memory: + cerebral: + retain: + model: anthropic/claude-3-opus +""") + monkeypatch.setenv("CODESPY_CONFIG", str(config_file)) + + settings = Settings() + llm_config = settings.get_llm_config("memory_retain") + assert llm_config.model == "anthropic/claude-3-opus" + + monkeypatch.delenv("CODESPY_CONFIG", raising=False) + + def test_get_llm_config_for_memory_retain_fallback(self, monkeypatch, tmp_path): + """get_llm_config('memory_retain') falls back to default_model.""" + monkeypatch.chdir(tmp_path) + + # Test fallback to default_model when retain model is not set + config_file = tmp_path / "codespy.yaml" + config_file.write_text(""" +llm: + default_model: openai/gpt-4o-mini +memory: + cerebral: + retain: + model: null +""") + monkeypatch.setenv("CODESPY_CONFIG", str(config_file)) + + settings = Settings() + llm_config = settings.get_llm_config("memory_retain") + assert llm_config.model == "openai/gpt-4o-mini" + + monkeypatch.delenv("CODESPY_CONFIG", raising=False) + + def test_get_max_iters_for_memory_modules(self, monkeypatch, tmp_path): + """get_max_iters uses memory.hippocampus.* config for memory_* names.""" + monkeypatch.chdir(tmp_path) + + config_file = tmp_path / "codespy.yaml" + config_file.write_text(""" +llm: + default_max_iters: 3 +memory: + hippocampus: + distiller: + max_iters: 5 + cartographer: + max_iters: 7 +""") + monkeypatch.setenv("CODESPY_CONFIG", str(config_file)) + + settings = Settings() + assert settings.get_max_iters("memory_distiller") == 5 + assert settings.get_max_iters("memory_cartographer") == 7 + # Unknown names fall back to signature lookup + assert settings.get_max_iters("code_review") == 3 # default + + monkeypatch.delenv("CODESPY_CONFIG", raising=False) + + def test_get_max_llm_calls_for_memory_modules(self, monkeypatch, tmp_path): + """get_max_llm_calls uses memory.hippocampus.* config for memory_* names.""" + monkeypatch.chdir(tmp_path) + + config_file = tmp_path / "codespy.yaml" + config_file.write_text(""" +llm: + default_max_llm_calls: 5 +memory: + hippocampus: + distiller: + max_llm_calls: 10 + cartographer: + max_llm_calls: 15 +""") + monkeypatch.setenv("CODESPY_CONFIG", str(config_file)) + + settings = Settings() + assert settings.get_max_llm_calls("memory_distiller") == 10 + assert settings.get_max_llm_calls("memory_cartographer") == 15 + + monkeypatch.delenv("CODESPY_CONFIG", raising=False) diff --git a/tests/test_cost_tracker.py b/tests/test_cost_tracker.py index cb47175..3b94479 100644 --- a/tests/test_cost_tracker.py +++ b/tests/test_cost_tracker.py @@ -288,11 +288,11 @@ class TestCostTrackerAddExternalCall: def test_add_external_call_creates_new_entry(self): tracker = CostTracker() - tracker.add_external_call("cerebral_retain", 0.5, 100, 1, input_tokens=100, output_tokens=0, input_cost=0.5, output_cost=0.0) + tracker.add_external_call("memory_retain", 0.5, 100, 1, input_tokens=100, output_tokens=0, input_cost=0.5, output_cost=0.0) - stats = tracker.get_signature_stats("cerebral_retain") + stats = tracker.get_signature_stats("memory_retain") assert stats is not None - assert stats.name == "cerebral_retain" + assert stats.name == "memory_retain" assert stats.cost == 0.5 assert stats.tokens == 100 assert stats.call_count == 1 @@ -303,10 +303,10 @@ def test_add_external_call_creates_new_entry(self): def test_add_external_call_accumulates_existing_entry(self): tracker = CostTracker() - tracker.add_external_call("cerebral_retain", 0.5, 100, 1, input_tokens=100, output_tokens=0, input_cost=0.5, output_cost=0.0) - tracker.add_external_call("cerebral_retain", 0.3, 50, 2, input_tokens=30, output_tokens=20, input_cost=0.2, output_cost=0.1) + tracker.add_external_call("memory_retain", 0.5, 100, 1, input_tokens=100, output_tokens=0, input_cost=0.5, output_cost=0.0) + tracker.add_external_call("memory_retain", 0.3, 50, 2, input_tokens=30, output_tokens=20, input_cost=0.2, output_cost=0.1) - stats = tracker.get_signature_stats("cerebral_retain") + stats = tracker.get_signature_stats("memory_retain") assert stats.cost == 0.8 assert stats.tokens == 150 assert stats.call_count == 3 @@ -317,17 +317,17 @@ def test_add_external_call_accumulates_existing_entry(self): def test_add_external_call_does_not_touch_start_end_time(self): tracker = CostTracker() - tracker.add_external_call("cerebral_retain", 0.5, 100) + tracker.add_external_call("memory_retain", 0.5, 100) - stats = tracker.get_signature_stats("cerebral_retain") + stats = tracker.get_signature_stats("memory_retain") assert stats.start_time is None assert stats.end_time is None def test_add_external_call_default_calls_is_one(self): tracker = CostTracker() - tracker.add_external_call("cerebral_retain", 0.5, 100) # Uses defaults for new fields + tracker.add_external_call("memory_retain", 0.5, 100) # Uses defaults for new fields - stats = tracker.get_signature_stats("cerebral_retain") + stats = tracker.get_signature_stats("memory_retain") assert stats.call_count == 1 # Default values for new fields assert stats.input_tokens == 0 @@ -342,7 +342,7 @@ def test_add_external_call_is_thread_safe(self): def add_call(n): try: - tracker.add_external_call("cerebral_retain", 0.1, 10, 1, input_tokens=8, output_tokens=2, input_cost=0.08, output_cost=0.02) + tracker.add_external_call("memory_retain", 0.1, 10, 1, input_tokens=8, output_tokens=2, input_cost=0.08, output_cost=0.02) except Exception as e: errors.append(e) @@ -353,7 +353,7 @@ def add_call(n): t.join() assert not errors - stats = tracker.get_signature_stats("cerebral_retain") + stats = tracker.get_signature_stats("memory_retain") assert stats.cost == pytest.approx(10.0, rel=0.01) assert stats.tokens == 1000 assert stats.call_count == 100 @@ -365,10 +365,10 @@ def add_call(n): def test_add_external_call_accumulates_duration(self): """add_external_call with duration accumulates external_duration_seconds.""" tracker = CostTracker() - tracker.add_external_call("cerebral_retain", 0.5, 100, duration=1.5) - tracker.add_external_call("cerebral_retain", 0.3, 50, duration=2.5) + tracker.add_external_call("memory_retain", 0.5, 100, duration=1.5) + tracker.add_external_call("memory_retain", 0.3, 50, duration=2.5) - stats = tracker.get_signature_stats("cerebral_retain") + stats = tracker.get_signature_stats("memory_retain") assert stats.external_duration_seconds == 4.0 # 1.5 + 2.5 assert stats.duration_seconds == 4.0 # No start_time, so just external assert stats.start_time is None # Should not touch start_time @@ -377,20 +377,20 @@ def test_add_external_call_accumulates_duration(self): def test_add_external_call_duration_default_is_zero(self): """add_external_call without duration defaults to 0.0.""" tracker = CostTracker() - tracker.add_external_call("cerebral_retain", 0.5, 100) + tracker.add_external_call("memory_retain", 0.5, 100) - stats = tracker.get_signature_stats("cerebral_retain") + stats = tracker.get_signature_stats("memory_retain") assert stats.external_duration_seconds == 0.0 assert stats.duration_seconds == 0.0 def test_get_all_signature_stats_preserves_external_duration(self): """get_all_signature_stats copy preserves external_duration_seconds.""" tracker = CostTracker() - tracker.add_external_call("cerebral_retain", 0.5, 100, duration=2.0) + tracker.add_external_call("memory_retain", 0.5, 100, duration=2.0) all_stats = tracker.get_all_signature_stats() - assert all_stats["cerebral_retain"].external_duration_seconds == 2.0 - assert all_stats["cerebral_retain"].duration_seconds == 2.0 + assert all_stats["memory_retain"].external_duration_seconds == 2.0 + assert all_stats["memory_retain"].duration_seconds == 2.0 class TestSignatureContext: From f0f5daba4bd7678031b191e0f7b20b7723a20c82 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Sun, 27 Sep 2026 18:15:25 +0200 Subject: [PATCH 18/39] cost --- .env.example | 4 ++-- CHANGELOG.md | 4 ++++ codespy.yaml | 4 ++-- docs/configuration.md | 4 ++-- docs/memory.md | 8 +++---- .../agents/memory/cerebral/cerebral.py | 2 +- .../agents/memory/hippocampus/budget.py | 10 ++++----- .../agents/memory/hippocampus/cartographer.py | 8 +++---- .../memory/hippocampus/context_memory.py | 2 +- .../agents/memory/hippocampus/distiller.py | 10 ++++----- .../agents/memory/hippocampus/hippocampus.py | 8 +++---- src/codespy/config.py | 4 ++-- src/codespy/config_memory.py | 4 ++-- tests/test_config_memory.py | 22 +++++++++++++++++++ 14 files changed, 60 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 84a4d6e..d1c2ca0 100644 --- a/.env.example +++ b/.env.example @@ -188,8 +188,8 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # MEMORY_COMPACT_TRAJECTORY=true # Token budgets -# MEMORY_MAX_CONTEXT_MEMORY_TOKENS=16384 -# MEMORY_MAX_CONTEXT_ITEM_TOKENS=512 +# MEMORY_MAX_HIPPOCAMPUS_TOKENS=16384 +# MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS=512 # MEMORY_MAX_TRAJECTORY_TOKENS=16384 # MEMORY_MAX_QUESTION_TOKENS=8192 diff --git a/CHANGELOG.md b/CHANGELOG.md index 733fc7a..67e3790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ - Migration note: **upgrade the action ref and `codespy-version` together**. An older action with the `latest` image, or the reverse, silently drops review settings. ### Changed +- **BREAKING — Memory Token Budgets**: Renamed token budget fields for clarity + - `memory.hippocampus.max_context_memory_tokens` → `max_hippocampus_tokens` (env: `MEMORY_MAX_HIPPOCAMPUS_TOKENS`) + - `memory.hippocampus.max_context_item_tokens` → `max_hippocampus_item_tokens` (env: `MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS`) + - Old keys and env vars are silently ignored; defaults apply. See codespy.yaml for current names. - **Internal naming**: Cost table rows and internal unit names were renamed to match the `MEMORY_*` config/env names: - `distiller` → `memory_distiller` - `cartographer` → `memory_cartographer` diff --git a/codespy.yaml b/codespy.yaml index c6ec10d..f6fc238 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -103,8 +103,8 @@ memory: # Hippocampus (episodic memory) hippocampus: compact_trajectory: true # MEMORY_COMPACT_TRAJECTORY - max_context_memory_tokens: 16384 # MEMORY_MAX_CONTEXT_MEMORY_TOKENS - max_context_item_tokens: 512 # MEMORY_MAX_CONTEXT_ITEM_TOKENS + max_hippocampus_tokens: 16384 # MEMORY_MAX_HIPPOCAMPUS_TOKENS + max_hippocampus_item_tokens: 512 # MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS max_trajectory_tokens: 16384 # MEMORY_MAX_TRAJECTORY_TOKENS max_question_tokens: 8192 # MEMORY_MAX_QUESTION_TOKENS diff --git a/docs/configuration.md b/docs/configuration.md index b39d5e6..a01c4ed 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -182,8 +182,8 @@ Brief overview: | pg0 port | `MEMORY_PG0_PORT` | auto | pg0-embedded port (local dev) | | pg0 data dir | `MEMORY_PG0_DATA_DIR` | — | pg0-embedded data directory (local dev) | | Enabled | `MEMORY_ENABLED` | `false` | Enable memory globally (episodic + semantic) | -| Context memory tokens | `MEMORY_MAX_CONTEXT_MEMORY_TOKENS` | `16384` | Ceiling on persisted context memory | -| Observation tokens | `MEMORY_MAX_CONTEXT_ITEM_TOKENS` | `512` | Soft per-observation token limit | +| Context memory tokens | `MEMORY_MAX_HIPPOCAMPUS_TOKENS` | `16384` | Ceiling on persisted context memory | +| Observation tokens | `MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS` | `512` | Soft per-observation token limit | | Trajectory tokens | `MEMORY_MAX_TRAJECTORY_TOKENS` | `16384` | Cap on trajectory fed to Distiller | | Question tokens | `MEMORY_MAX_QUESTION_TOKENS` | `8192` | Cap on serialized reflection inputs | diff --git a/docs/memory.md b/docs/memory.md index ca207f1..bf506c5 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -174,7 +174,7 @@ After each agent run (at `end_episode()`): - `ADD` — Insert new observation - `REPLACE` — Update existing observation with new knowledge - `DELETE` — Remove outdated/irrelevant observation -3. **Eviction** — If memory exceeds `max_context_memory_tokens`, oldest general observations are evicted first +3. **Eviction** — If memory exceeds `max_hippocampus_tokens`, oldest general observations are evicted first The Distiller also tags each existing context memory observation with an `ObservationTag`: @@ -189,13 +189,13 @@ These tags inform the Cartographer's edit decisions. | Budget | Env Var | Default | Purpose | |--------|---------|---------|---------| -| Context memory | `MEMORY_MAX_CONTEXT_MEMORY_TOKENS` | 16384 | Ceiling on persisted ContextMemory (re-sent every iteration) | -| Observation | `MEMORY_MAX_CONTEXT_ITEM_TOKENS` | 512 | Soft per-observation token limit (expressed to LLM, not truncated) | +| Context memory | `MEMORY_MAX_HIPPOCAMPUS_TOKENS` | 16384 | Ceiling on persisted ContextMemory (re-sent every iteration) | +| Observation | `MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS` | 512 | Soft per-observation token limit (expressed to LLM, not truncated) | | Trajectory | `MEMORY_MAX_TRAJECTORY_TOKENS` | 16384 | Head+tail cap on trajectory fed to Distiller | | Question | `MEMORY_MAX_QUESTION_TOKENS` | 8192 | Cap on serialized inputs as reflection question | | Compact trajectory | `MEMORY_COMPACT_TRAJECTORY` | `true` | Apply head+tail trajectory bounding before distillation | -Observation capacity ≈ context_memory_tokens / observation_tokens (16384/512 = 32 observations) +Observation capacity ≈ max_hippocampus_tokens / max_hippocampus_item_tokens (16384/512 = 32 observations) ## Configuration diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index 97a8e07..76ffd66 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -190,7 +190,7 @@ def _ensure_bank(self) -> None: # update that violates it is rejected wholesale — silently reverting # the bank to defaults (retain_chunk_size=3000). Do not raise this at # or above 64000. The merged observations blob is bounded by - # max_context_memory_tokens (16384 tokens ≈ ~65K chars worst case), so + # max_hippocampus_tokens (16384 tokens ≈ ~65K chars worst case), so # at 12288 a large blob splits into several chunks (graceful, no error). "retain_chunk_size": 12288, "retain_mission": ( diff --git a/src/codespy/agents/memory/hippocampus/budget.py b/src/codespy/agents/memory/hippocampus/budget.py index 85b56e4..e029002 100644 --- a/src/codespy/agents/memory/hippocampus/budget.py +++ b/src/codespy/agents/memory/hippocampus/budget.py @@ -26,14 +26,14 @@ class MemoryBudget: once (see ``Settings.get_memory_budget``) and shared across instances. Attributes: - max_context_memory_tokens: Hard ceiling on the serialized ContextMemory, + max_hippocampus_tokens: Hard ceiling on the serialized ContextMemory, enforced by the Evictor after every reflection. This is the *persisted* artifact and it is prepended to every predictor of the wrapped agent, so it is re-sent on every agent iteration (~``max_iters`` times per run) plus once per reflection call — the - most cost-sensitive of the four. Divided by ``max_context_item_tokens`` it + most cost-sensitive of the four. Divided by ``max_hippocampus_item_tokens`` it gives the memory's approximate item capacity (16384 / 512 = 32 items). - max_context_item_tokens: Budget for a *single* context memory item, passed to the + max_hippocampus_item_tokens: Budget for a *single* context memory item, passed to the Distiller and the Cartographer as a prompt input so they keep each item compact rather than spending the whole memory budget on one verbose entry. Unlike the other three this is a **soft** budget: @@ -65,8 +65,8 @@ class MemoryBudget: input exceeds the model's context window. """ - max_context_memory_tokens: int = 16384 - max_context_item_tokens: int = 512 + max_hippocampus_tokens: int = 16384 + max_hippocampus_item_tokens: int = 512 max_trajectory_tokens: int | None = 16384 max_question_tokens: int | None = 8192 compact_trajectory: bool = True diff --git a/src/codespy/agents/memory/hippocampus/cartographer.py b/src/codespy/agents/memory/hippocampus/cartographer.py index ed78563..3e88b2b 100644 --- a/src/codespy/agents/memory/hippocampus/cartographer.py +++ b/src/codespy/agents/memory/hippocampus/cartographer.py @@ -34,7 +34,7 @@ class CartographerSig(dspy.Signature): Prefer REPLACE over ADD when possible. - Add new observations only when they represent transferable understanding. - Each observation must be short and budget-efficient — stay within the - `max_context_item_tokens` budget given as an input. If a candidate exceeds + `max_hippocampus_item_tokens` budget given as an input. If a candidate exceeds it, rewrite it more compactly or split it. - If nothing new is worth keeping, return an empty operations list. @@ -123,7 +123,7 @@ class CartographerSig(dspy.Signature): question: str = dspy.InputField(desc="Question the agent was answering.") token_budget: int = dspy.InputField(desc="Hard token budget for the context memory.") current_tokens: int = dspy.InputField(desc="Current token count of the context memory.") - max_context_item_tokens: int = dspy.InputField( + max_hippocampus_item_tokens: int = dspy.InputField( desc="Token budget for a SINGLE context memory observation. Every ADD/REPLACE content " "must stay within it." ) @@ -172,7 +172,7 @@ def forward( question, token_budget, current_tokens, - max_context_item_tokens, + max_hippocampus_item_tokens, ): # See Distiller.forward: SignatureContext applies memory.hippocampus.cartographer's # LLM settings and gives this module its own cost line. Entered here @@ -188,5 +188,5 @@ def forward( question=question, token_budget=token_budget, current_tokens=current_tokens, - max_context_item_tokens=max_context_item_tokens, + max_hippocampus_item_tokens=max_hippocampus_item_tokens, ) diff --git a/src/codespy/agents/memory/hippocampus/context_memory.py b/src/codespy/agents/memory/hippocampus/context_memory.py index 91dbaed..c04ae7b 100644 --- a/src/codespy/agents/memory/hippocampus/context_memory.py +++ b/src/codespy/agents/memory/hippocampus/context_memory.py @@ -114,7 +114,7 @@ class CacheCandidate(BaseModel): ), ) value: str = Field( - description="Compact candidate cache observation, within the max_context_item_tokens budget." + description="Compact candidate cache observation, within the max_hippocampus_item_tokens budget." ) transferability: str = Field( default="", diff --git a/src/codespy/agents/memory/hippocampus/distiller.py b/src/codespy/agents/memory/hippocampus/distiller.py index a2e0ee2..824fb42 100644 --- a/src/codespy/agents/memory/hippocampus/distiller.py +++ b/src/codespy/agents/memory/hippocampus/distiller.py @@ -108,7 +108,7 @@ class DistillerSig(dspy.Signature): Each candidate is an object with exactly these fields: - section: one of the six section names above - - value: the compact cached content (stay within max_context_item_tokens) + - value: the compact cached content (stay within max_hippocampus_item_tokens) - transferability: what kinds of future questions this would help - rationale: why this is shared understanding, not a one-off fact @@ -124,7 +124,7 @@ class DistillerSig(dspy.Signature): desc="Current context memory." ) question: str = dspy.InputField(desc="The question the agent was answering.") - max_context_item_tokens: int = dspy.InputField( + max_hippocampus_item_tokens: int = dspy.InputField( desc="Token budget for a SINGLE context memory observation. Keep every candidate within " "it; if one exceeds it, rewrite it more compactly or split it." ) @@ -139,7 +139,7 @@ class DistillerSig(dspy.Signature): "Keys must match existing observation ids exactly." ) cache_candidates: list[CacheCandidate] = dspy.OutputField( - desc="Candidate observations to add. Each within the max_context_item_tokens budget; " + desc="Candidate observations to add. Each within the max_hippocampus_item_tokens budget; " "structural/transferable only. " "Each candidate's `section` must be one of the six section names above." ) @@ -175,7 +175,7 @@ def forward( trajectory: str, context_memory: ContextMemory, question: str, - max_context_item_tokens: int, + max_hippocampus_item_tokens: int, ): # SignatureContext applies memory.hippocampus.distiller's model/temperature/reasoning # effort and attributes the cost to this module rather than to whichever @@ -189,5 +189,5 @@ def forward( trajectory=trajectory, context_memory=context_memory, question=question, - max_context_item_tokens=max_context_item_tokens, + max_hippocampus_item_tokens=max_hippocampus_item_tokens, ) diff --git a/src/codespy/agents/memory/hippocampus/hippocampus.py b/src/codespy/agents/memory/hippocampus/hippocampus.py index 0cdd599..34e2a66 100644 --- a/src/codespy/agents/memory/hippocampus/hippocampus.py +++ b/src/codespy/agents/memory/hippocampus/hippocampus.py @@ -474,7 +474,7 @@ def _distill(self, trajectory: str, question: str) -> None: trajectory=trajectory, context_memory=self.cmem, question=question, - max_context_item_tokens=self.budget.max_context_item_tokens, + max_hippocampus_item_tokens=self.budget.max_hippocampus_item_tokens, ) known = self.cmem.ids() @@ -489,9 +489,9 @@ def _distill(self, trajectory: str, question: str) -> None: question=question, # The Cartographer's input field keeps the generic name: it is prompt # text, already scoped by its description, and pairs with current_tokens. - token_budget=self.budget.max_context_memory_tokens, + token_budget=self.budget.max_hippocampus_tokens, current_tokens=count_tokens(self.cmem.model_dump_json()), - max_context_item_tokens=self.budget.max_context_item_tokens, + max_hippocampus_item_tokens=self.budget.max_hippocampus_item_tokens, ) ops = list(edits.operations or []) @@ -504,7 +504,7 @@ def _distill(self, trajectory: str, question: str) -> None: self.scores[nid] = self.scores.get(nid, 0) + 1 self._distill_step += 1 - self.cmem = evict(self.cmem, self.scores, self.budget.max_context_memory_tokens) + self.cmem = evict(self.cmem, self.scores, self.budget.max_hippocampus_tokens) live = self.cmem.ids() self.scores = {k: v for k, v in self.scores.items() if k in live} diff --git a/src/codespy/config.py b/src/codespy/config.py index 69f6839..9fd883a 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -235,8 +235,8 @@ def get_memory_budget(self, signature_name: str) -> "MemoryBudget": from codespy.agents.memory.hippocampus.budget import MemoryBudget return MemoryBudget( - max_context_memory_tokens=self.memory.hippocampus.max_context_memory_tokens, - max_context_item_tokens=self.memory.hippocampus.max_context_item_tokens, + max_hippocampus_tokens=self.memory.hippocampus.max_hippocampus_tokens, + max_hippocampus_item_tokens=self.memory.hippocampus.max_hippocampus_item_tokens, max_trajectory_tokens=self.memory.hippocampus.max_trajectory_tokens, max_question_tokens=self.memory.hippocampus.max_question_tokens, compact_trajectory=self.memory.hippocampus.compact_trajectory, diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 657e4ae..4a5c617 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -54,8 +54,8 @@ class HippocampusConfig(BaseModel): compact_trajectory: bool = True # Token budgets - max_context_memory_tokens: int = Field(default=16384) - max_context_item_tokens: int = Field(default=512) + max_hippocampus_tokens: int = Field(default=16384) + max_hippocampus_item_tokens: int = Field(default=512) max_trajectory_tokens: int | None = Field(default=16384) max_question_tokens: int | None = Field(default=8192) diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index dba9976..5e46c63 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -58,6 +58,28 @@ def test_override_memory_embeddings_model(self, monkeypatch): result = apply_env_overrides(config, _ENV_MAP) assert result["memory"]["cerebral"]["embeddings"]["model"] == "openai/text-embedding-3-large" + def test_override_memory_max_hippocampus_tokens(self, monkeypatch): + """MEMORY_MAX_HIPPOCAMPUS_TOKENS should set hippocampus token budget (collapsed path).""" + monkeypatch.setenv("MEMORY_MAX_HIPPOCAMPUS_TOKENS", "1234") + from codespy.config import _ENV_MAP + from codespy.config_utils import apply_env_overrides + + config = {} + result = apply_env_overrides(config, _ENV_MAP) + # convert_env_value returns numbers as raw strings + assert result["memory"]["hippocampus"]["max_hippocampus_tokens"] == "1234" + + def test_override_memory_max_hippocampus_item_tokens(self, monkeypatch): + """MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS should set hippocampus item token budget (collapsed path).""" + monkeypatch.setenv("MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS", "256") + from codespy.config import _ENV_MAP + from codespy.config_utils import apply_env_overrides + + config = {} + result = apply_env_overrides(config, _ENV_MAP) + # convert_env_value returns numbers as raw strings + assert result["memory"]["hippocampus"]["max_hippocampus_item_tokens"] == "256" + class TestPostgresConfigBuildUri: """Tests for PostgresConfig.build_uri() method.""" From 99ce32932f7d2efce0f28d0fb6c268ffbe6f5175 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Sun, 27 Sep 2026 21:15:16 +0200 Subject: [PATCH 19/39] cost --- src/codespy/agents/memory/hippocampus/distiller.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/codespy/agents/memory/hippocampus/distiller.py b/src/codespy/agents/memory/hippocampus/distiller.py index 824fb42..053a203 100644 --- a/src/codespy/agents/memory/hippocampus/distiller.py +++ b/src/codespy/agents/memory/hippocampus/distiller.py @@ -38,8 +38,7 @@ class DistillerSig(dspy.Signature): ## Produce three outputs - 1. DIAGNOSIS — Brief (3-5 sentences; it feeds the next module's prompt, - so keep it terse) analysis of: + 1. DIAGNOSIS — Analysis of: - How many iterations the agent spent on orientation vs. question-specific work - Whether the agent re-discovered structural information that was @@ -130,7 +129,7 @@ class DistillerSig(dspy.Signature): ) diagnosis: str = dspy.OutputField( - desc="Brief (3-5 sentence) analysis of orientation vs. question-specific work, " + desc="Analysis of orientation vs. question-specific work, " "whether structural info was re-discovered that should have been cached, and " "what transferable understanding the agent built. Feeds the Cartographer prompt." ) From 9de66ec836438b0a4fb2887cbb063264642ada7d Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Tue, 29 Sep 2026 14:13:40 +0200 Subject: [PATCH 20/39] cost --- CHANGELOG.md | 11 + src/codespy/agents/context_safe.py | 4 +- .../agents/memory/cerebral/cerebral.py | 125 +++++- .../agents/memory/hippocampus/__init__.py | 9 +- .../agents/memory/hippocampus/budget.py | 4 +- .../memory/hippocampus/context_memory.py | 48 ++- .../agents/memory/hippocampus/hippocampus.py | 96 ++--- src/codespy/agents/memory/postgres.py | 304 +++++++------- src/codespy/agents/review/audit/agent.py | 4 +- .../agents/review/code_review/agent.py | 4 +- src/codespy/agents/review/doc/agent.py | 4 +- src/codespy/agents/review/scope/agent.py | 4 +- src/codespy/agents/review/summary/agent.py | 4 +- .../agents/review/supply_chain/agent.py | 4 +- tests/test_cerebral.py | 164 +++++++- tests/test_hippocampus.py | 375 +++++++++++++++++- 16 files changed, 907 insertions(+), 257 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e3790..4a9c8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] ### Changed +- **Agents no longer receive memory input**: `inject_context_memory` and the `context_memory=` argument have been removed from all six review agents (summary, scope, code_review, doc, supply_chain, audit). Memory is learned but not used until the semantic Prefrontal is planned separately. +- **Hippocampus eviction writes tombstones**: When a prior observation is evicted from `context_memory` to stay within `max_hippocampus_tokens`, Hippocampus records a new `EVICT` mutation (`MutationType.EVICT`, separate from the Cartographer's `OpType`, so the LLM cannot emit it). `save_episode` writes an `op_type='EVICT'` tombstone version (`content=NULL`) for persisted observations, and `load_context` excludes both `DELETE` and `EVICT` tombstones. +- **Cerebral DELETE vs EVICT handling**: DELETE produces a `RETRACTED` line marking facts as wrong; EVICT of unchanged prior facts sends nothing; content new this run (ADD→EVICT or REPLACE→EVICT) is retained to avoid losing newly learned facts. The retain mission explicitly states that only `RETRACTED` invalidates a fact and missing facts are unchanged. +- **EpisodeStore.load_context() semantics changed**: Now loads the latest version of every observation from ALL prior episodes (not just the latest episode), excluding tombstones (`TOMBSTONE_TYPES`: `DELETE`, `EVICT`) and any version with no content. Observations are returned oldest-first, so eviction drops the oldest facts first. Topic prefix matching now uses proper LIKE escaping so `owner/repo` no longer matches `owner/repo-other`. +- **Rollback note**: loaders older than this release only skip `op_type='DELETE'` and fail to load a topic that has an `EVICT` row. Before rolling back, run `UPDATE observations SET op_type = 'DELETE' WHERE op_type = 'EVICT';` (both are content-NULL tombstones, so no data is lost). - **BREAKING — Environment Variables**: Renamed env vars for consistency with YAML paths - `llm.*` settings drop the `LLM_` prefix (e.g., `LLM_DEFAULT_MODEL` → `DEFAULT_MODEL`) - Exceptions: `LLM_RETRIES` and `LLM_TIMEOUT` keep their prefixes @@ -38,6 +43,12 @@ ### Removed - **Config**: Removed dead settings `enable_prompt_caching` and `compact_patches` (fields deleted in the config refactor) +- **Cerebral retains mutations, not observations**: Changed from full context_memory to mutation log + - `retain_episode()` now builds observations blob from `episode.mutations` + - ADD → plain line with final content + - REPLACE → `supersedes:` line with previous_content from first mutation + - DELETE → `RETRACTED` line with previous_content and optional reason + - `_mutation_lines()` helper for building lines from mutation groups - **Dependencies**: MCP Python SDK upgraded from v1.x to v2.x - `mcp` dependency: `>=1.29.0,<2.0.0` → `>=2.2,<3` - All 8 MCP servers migrated from `FastMCP` to `MCPServer` (v2 API) diff --git a/src/codespy/agents/context_safe.py b/src/codespy/agents/context_safe.py index 8bf0209..0e5b720 100644 --- a/src/codespy/agents/context_safe.py +++ b/src/codespy/agents/context_safe.py @@ -60,7 +60,7 @@ class ContextSafe(dspy.Module): - Try/catch: if the inner module raises a context overflow error (unknown models where pre-flight can't estimate), catches it and retries with RLM. - Transparent to inject_context_memory — delegates .signature to inner module. + Transparent to signature injection — delegates .signature to inner module. """ def __init__( @@ -186,7 +186,7 @@ def _should_use_rlm(self, kwargs: dict) -> tuple[bool, str]: return False, "" def _get_current_signature(self): - """Get current signature (may include context_memory if inject_context_memory was called).""" + """Get current signature.""" sig = getattr(self.module, "signature", None) if sig is not None: return sig diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index 76ffd66..7597001 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -10,6 +10,7 @@ import logging import os import threading +from collections import defaultdict from typing import TYPE_CHECKING # Module constants (must be defined before the Hindsight import) @@ -196,7 +197,14 @@ def _ensure_bank(self) -> None: "retain_mission": ( "Retain code review observations, analysis results, and artifacts. " "Focus on patterns, architectural decisions, dependency relationships, " - "security findings, and technical insights across repositories." + "security findings, and technical insights across repositories. " + "Observation lines use these markers:\n" + "- A plain line is a newly learned fact.\n" + "- '(supersedes: X)': the line is the current wording of a fact previously worded X. " + "Update that fact; do not keep both.\n" + "- 'RETRACTED — shown incorrect or misleading': the fact was proven wrong or misled " + "the agent. Mark it invalid and do not rely on it.\n" + "A fact missing from a batch is unchanged, not outdated. Only RETRACTED invalidates a fact." ), }, request_context=ctx, @@ -206,33 +214,128 @@ def _ensure_bank(self) -> None: logger.warning("cerebral: failed to configure bank %s", self._bank_id, exc_info=True) self._bank_ensured = True + @staticmethod + def _mutation_lines(episode: Episode) -> list[str]: + """Build observation lines from episode mutations. + + Groups mutations by observation_id, keeping their order. `first` and + `last` are the first and last mutation in the group, and `final` is the + observation's content at the end of the run. + + Line format by mutation group (in order): + + | Group (in order) | Line | + |---|---| + | has ADD, in `context_memory` | `[s] ` | + | has ADD, last is EVICT | `[s] `, where final = `last.previous_content` | + | has ADD, last is DELETE | nothing | + | no ADD, last is REPLACE, in `context_memory` | `[s] (supersedes: )` | + | no ADD, has a REPLACE, last is EVICT | `[s] (supersedes: )` | + | no ADD, EVICT only | nothing | + | no ADD, last is DELETE | `[s] RETRACTED — shown incorrect or misleading, do not rely on: ` | + + Section `s`: for a group whose last mutation is EVICT or DELETE, use + `last.section`. Otherwise use the section `find_observation` returns. + + Returns list of formatted lines for the observations blob. + """ + from codespy.agents.memory.hippocampus.context_memory import MutationType + + if not episode.mutations: + return [] + + # Group mutations by observation_id, preserving order + groups: dict[str, list] = defaultdict(list) + for mut in episode.mutations: + groups[mut.observation_id].append(mut) + + lines: list[str] = [] + context_memory = episode.context_memory + + for obs_id, mutations in groups.items(): + first_mut = mutations[0] + last_mut = mutations[-1] + + # Check if this observation has an ADD + has_add = any(mut.type == MutationType.ADD for mut in mutations) + + if has_add: + # ADD in the mutation group + if last_mut.type == MutationType.DELETE: + # ADD → DELETE: emit nothing + continue + elif last_mut.type == MutationType.EVICT: + # ADD → EVICT: emit line with evicted content (previous_content) + lines.append(f"[{last_mut.section}] {last_mut.previous_content}") + else: + # ADD (possibly with REPLACEs), still in context_memory + found = context_memory.find_observation(obs_id) + if found: + section, obs = found + lines.append(f"[{section}] {obs.content}") + else: + # No ADD in the group: prior observation + if last_mut.type == MutationType.DELETE: + # DELETE only: RETRACTED line + lines.append( + f"[{last_mut.section}] RETRACTED — shown incorrect or misleading, " + f"do not rely on: {first_mut.previous_content}" + ) + elif any(mut.type == MutationType.REPLACE for mut in mutations): + # Has at least one REPLACE (last could be REPLACE or EVICT) + if last_mut.type == MutationType.EVICT: + # REPLACE → EVICT: emit line with evicted content + supersedes + # Find the EVICT to get its previous_content + evict_mut = next( + mut for mut in reversed(mutations) if mut.type == MutationType.EVICT + ) + lines.append( + f"[{last_mut.section}] {evict_mut.previous_content} " + f"(supersedes: {first_mut.previous_content})" + ) + else: + # REPLACE only, still in context_memory + found = context_memory.find_observation(obs_id) + if found: + section, obs = found + lines.append( + f"[{section}] {obs.content} (supersedes: {first_mut.previous_content})" + ) + elif last_mut.type == MutationType.EVICT: + # EVICT only: emit nothing (unchanged prior fact) + pass + + return lines + def retain_episode(self, episode: Episode) -> None: - """Retain all observations and artifacts from an episode.""" + """Retain episode mutations and artifacts in Hindsight semantic memory. + + The observations blob is built from episode.mutations instead of + context_memory, capturing ADD/REPLACE/DELETE operations as: + - Plain lines for ADDs still in context_memory + - "(supersedes:)" lines for REPLACEs + - "RETRACTED" lines for DELETEs + """ self._ensure_bank() tags = self._build_tags(episode) episode_doc_id = f"episode-{episode.id}" # Build contents list: at most TWO items per episode - # One merged observations blob + one merged artifacts blob - # This reduces LLM call count by bundling items that share tags/document_id/event_date contents: list[dict] = [] - # Merge all observations into a single content item - obs_lines: list[str] = [] - for section_name in episode.context_memory.section_names(): - for obs in getattr(episode.context_memory, section_name): - obs_lines.append(f"[{section_name}] {obs.content}") + # Build observations blob from mutations + obs_lines = self._mutation_lines(episode) if obs_lines: contents.append({ "content": "\n\n".join(obs_lines), - "context": f"{episode.task}: {episode.question}: observations", + "context": f"{episode.task}: {episode.question}: observation changes", "tags": tags, "document_id": episode_doc_id, "event_date": episode.timestamp.isoformat(), }) - # Merge all artifacts into a single content item + # Merge all artifacts into a single content item (unchanged) artifact_lines: list[str] = [] for name, content in (episode.artifacts or {}).items(): artifact_lines.append(f"[artifact:{name}] {content}") diff --git a/src/codespy/agents/memory/hippocampus/__init__.py b/src/codespy/agents/memory/hippocampus/__init__.py index bcdfbfa..226948c 100644 --- a/src/codespy/agents/memory/hippocampus/__init__.py +++ b/src/codespy/agents/memory/hippocampus/__init__.py @@ -3,6 +3,7 @@ CacheCandidate, ContextMemory, Mutation, + MutationType, Observation, ObservationTag, Operation, @@ -17,10 +18,7 @@ join_episode_saves, submit_episode_save, ) -from codespy.agents.memory.hippocampus.hippocampus import ( - Hippocampus, - inject_context_memory, -) +from codespy.agents.memory.hippocampus.hippocampus import Hippocampus from codespy.agents.memory.hippocampus.cartographer import Cartographer, CartographerSig from codespy.agents.memory.hippocampus.distiller import Distiller, DistillerSig @@ -35,6 +33,7 @@ "Hippocampus", "MemoryBudget", "Mutation", + "MutationType", "Observation", "ObservationTag", "Operation", @@ -42,7 +41,7 @@ "SectionName", "Topic", "compute_common_ancestor_topic_id", - "inject_context_memory", + "join_episode_saves", "make_topic_id", "submit_episode_save", diff --git a/src/codespy/agents/memory/hippocampus/budget.py b/src/codespy/agents/memory/hippocampus/budget.py index e029002..40c1e32 100644 --- a/src/codespy/agents/memory/hippocampus/budget.py +++ b/src/codespy/agents/memory/hippocampus/budget.py @@ -98,7 +98,7 @@ def count_tokens(s: str) -> int: def format_inputs(kwargs: dict, max_tokens: int | None = None) -> str: - """Serialize call inputs (excluding context_memory) for the Distiller question. + """Serialize call inputs for the Distiller question. All fields are included in full. If max_tokens is set, the joined result is head+tail bounded via _head_tail_text so both the instruction and any @@ -107,8 +107,6 @@ def format_inputs(kwargs: dict, max_tokens: int | None = None) -> str: """ parts: list[str] = [] for k, v in kwargs.items(): - if k == "context_memory": - continue parts.append(f"{k}: {v}") text = "\n".join(parts) if max_tokens is None: diff --git a/src/codespy/agents/memory/hippocampus/context_memory.py b/src/codespy/agents/memory/hippocampus/context_memory.py index c04ae7b..306a20c 100644 --- a/src/codespy/agents/memory/hippocampus/context_memory.py +++ b/src/codespy/agents/memory/hippocampus/context_memory.py @@ -52,6 +52,38 @@ def _missing_(cls, value: object) -> OpType | None: return None +class MutationType(StrEnum): + """Recorded mutation types, persisted as ``observations.op_type``. + + A superset of :class:`OpType`: ``EVICT`` is recorded by Hippocampus (never + by the Cartographer) when a prior observation is dropped to stay within + ``max_hippocampus_tokens``. Both DELETE and EVICT write a tombstone + version; EVICT is a capacity decision, not evidence the fact is wrong. + """ + + ADD = "ADD" + DELETE = "DELETE" + REPLACE = "REPLACE" + EVICT = "EVICT" + + @classmethod + def _missing_(cls, value: object) -> MutationType | None: + if isinstance(value, str): + upper = value.upper() + for member in cls: + if member.value == upper: + return member + return None + + +# Mutation types that write a tombstone version (content NULL). Single source of +# truth: EpisodeStore.save_episode writes tombstones for these types and +# EpisodeStore.load_context excludes them. Add new tombstone types here only. +TOMBSTONE_TYPES: frozenset[MutationType] = frozenset( + {MutationType.DELETE, MutationType.EVICT} +) + + SectionName = Literal[ "context_roadmap", "context_understanding", @@ -139,21 +171,23 @@ class Operation(BaseModel): class Mutation(BaseModel): - """A recorded Cartographer mutation applied to the context memory. + """A recorded mutation applied to the context memory. - Tracks the sequence of ADD/DELETE/REPLACE operations with pre-mutation - state for debugging and audit purposes. + Tracks the sequence of ADD/DELETE/REPLACE operations (from the Cartographer) + and EVICT (from budget eviction) with pre-mutation state. """ step: int = Field(description="Which _distill() pass produced this mutation (0-indexed)") - type: OpType = Field(description="Type of mutation: ADD, DELETE, or REPLACE") - observation_id: str = Field(description="Generated ID (ADD) or existing ID (DELETE/REPLACE)") + type: MutationType = Field(description="Type of mutation: ADD, DELETE, REPLACE, or EVICT") + observation_id: str = Field( + description="Generated ID (ADD) or existing ID (DELETE/REPLACE/EVICT)" + ) section: SectionName = Field(description="Section the observation belongs to") content: str | None = Field( - default=None, description="New content (ADD/REPLACE); None for DELETE" + default=None, description="New content (ADD/REPLACE); None for DELETE/EVICT" ) previous_content: str | None = Field( - default=None, description="Old content (DELETE/REPLACE); None for ADD" + default=None, description="Old content (DELETE/REPLACE/EVICT); None for ADD" ) topic_ids: list[str] = Field( default_factory=list, description="Topic IDs associated with this mutation" diff --git a/src/codespy/agents/memory/hippocampus/hippocampus.py b/src/codespy/agents/memory/hippocampus/hippocampus.py index 34e2a66..704d828 100644 --- a/src/codespy/agents/memory/hippocampus/hippocampus.py +++ b/src/codespy/agents/memory/hippocampus/hippocampus.py @@ -21,6 +21,7 @@ ContextMemory, ObservationTag, Mutation, + MutationType, Operation, OpType, Topic, @@ -36,50 +37,13 @@ logger = logging.getLogger(__name__) -def prepend_context_memory(sig): - """Prepend context_memory field to signature.""" - return sig.prepend( - name="context_memory", - field=dspy.InputField( - desc="Current context memory. Use it before redundant tool calls." - ), - type_=ContextMemory, - ) - - -def inject_context_memory(module: dspy.Module) -> dspy.Module: - """Prepend context_memory input field to a dspy.Module's signatures. - - Idempotent — skips predictors that already have context_memory. - Mutates the module in place and returns it for chaining. - Works through ContextSafe's signature delegation. - """ - top_sig = getattr(module, "signature", None) - if top_sig is not None: - module_inputs = set(top_sig.input_fields) - if "context_memory" not in top_sig.input_fields: - module.signature = prepend_context_memory(top_sig) - for _, pred in module.named_predictors(): - if ( - set(pred.signature.input_fields) & module_inputs - and "context_memory" not in pred.signature.input_fields - ): - pred.signature = prepend_context_memory(pred.signature) - else: - for _, pred in module.named_predictors(): - if "context_memory" not in pred.signature.input_fields: - pred.signature = prepend_context_memory(pred.signature) - return module - - class Hippocampus: """Memory component that evolves via LLM-driven reflection. - The context memory is provided to agents so they start each run - with accumulated orientation knowledge (structure, entities, constants) about - the external context. After calls, the Distiller extracts transferable - understanding and the Cartographer edits the memory — "caching understanding, - not answers." + The context memory accumulates orientation knowledge (structure, entities, + constants) about the external context. After calls, the Distiller extracts + transferable understanding and the Cartographer edits the memory — + "caching understanding, not answers." ## Lifecycle @@ -88,11 +52,8 @@ class Hippocampus: # Construct Hippocampus with task name and optional memory budget hippo = Hippocampus(task_name="...") - # Inject context memory into agent - inject_context_memory(agent) - - # Run agent with context_memory input - pred = agent(context_memory=hippo.context_memory, task="…") + # Run agent (no memory input; agents will receive memory via Prefrontal later) + pred = agent(task="…") # Observe the result (buffers trajectory) hippo.observe(pred) @@ -104,7 +65,7 @@ class Hippocampus: hippo.end_episode(store, artifacts={"key": "value"}) # Async variant (for callers running inside an event loop) - pred = await agent.acall(context_memory=hippo.context_memory, task="…") + pred = await agent.acall(task="…") await hippo.aobserve(pred) await hippo.aend_episode(store, artifacts={"key": "value"}) @@ -294,7 +255,7 @@ def _finalize_episode(self, artifacts: dict[str, str] | None = None) -> None: timestamp=datetime.now(UTC), artifacts=artifacts or {}, run_id=self._run_id, - mutations=self._mutations, + mutations=list(self._mutations), ) self._episode_trajectories.clear() self._episode_question = None @@ -398,7 +359,7 @@ def _record_mutations( mutations.append( Mutation( step=self._distill_step, - type=OpType.DELETE, + type=MutationType.DELETE, observation_id=op.observation_id, section=section, content=None, @@ -413,7 +374,7 @@ def _record_mutations( mutations.append( Mutation( step=self._distill_step, - type=OpType.REPLACE, + type=MutationType.REPLACE, observation_id=op.observation_id, section=section, content=op.content, @@ -429,7 +390,7 @@ def _record_mutations( if section_name: mut = Mutation( step=self._distill_step, - type=OpType.ADD, + type=MutationType.ADD, observation_id="", # back-filled from new_ids section=section_name, content=op.content, @@ -442,7 +403,7 @@ def _record_mutations( elif op.type == OpType.ADD and op.section and op.content: mut = Mutation( step=self._distill_step, - type=OpType.ADD, + type=MutationType.ADD, observation_id="", section=op.section, content=op.content, @@ -503,8 +464,37 @@ def _distill(self, trajectory: str, question: str) -> None: for nid in new_ids: self.scores[nid] = self.scores.get(nid, 0) + 1 - self._distill_step += 1 + pre_evict = self.cmem self.cmem = evict(self.cmem, self.scores, self.budget.max_hippocampus_tokens) + self._record_evictions(pre_evict) + self._distill_step += 1 live = self.cmem.ids() self.scores = {k: v for k, v in self.scores.items() if k in live} + + def _record_evictions(self, pre_evict: ContextMemory) -> None: + """Record an EVICT mutation for every observation that eviction dropped. + + Records an EVICT for every observation no longer in context_memory, + including ones added this run. The log then states every eviction + explicitly, so Cerebral never has to infer one. + """ + post_ids = self.cmem.ids() + for obs in pre_evict.all_observations(): + if obs.id in post_ids: + continue + found = pre_evict.find_observation(obs.id) + if found is None: + continue + section, _ = found + self._mutations.append( + Mutation( + step=self._distill_step, + type=MutationType.EVICT, + observation_id=obs.id, + section=section, + content=None, + previous_content=obs.content, + topic_ids=list(obs.topic_ids), + ) + ) diff --git a/src/codespy/agents/memory/postgres.py b/src/codespy/agents/memory/postgres.py index bf05831..65d180c 100644 --- a/src/codespy/agents/memory/postgres.py +++ b/src/codespy/agents/memory/postgres.py @@ -3,8 +3,6 @@ from __future__ import annotations import logging -import uuid -from datetime import UTC, datetime from typing import TYPE_CHECKING from psycopg import sql @@ -12,12 +10,7 @@ from psycopg_pool import ConnectionPool if TYPE_CHECKING: - from codespy.agents.memory.hippocampus.context_memory import ( - ContextMemory, - Mutation, - Observation, - Topic, - ) + from codespy.agents.memory.hippocampus.context_memory import ContextMemory from codespy.agents.memory.hippocampus.episode import Episode logger = logging.getLogger(__name__) @@ -244,11 +237,10 @@ def save_episode(self, episode: Episode) -> None: """ # Import here to avoid circular imports from codespy.agents.memory.hippocampus.context_memory import ( - ContextMemory, + TOMBSTONE_TYPES, Mutation, Observation, OpType, - Topic, ) with self._pool.connection() as conn: @@ -471,9 +463,12 @@ def save_episode(self, episode: Episode) -> None: ), ) - # 6. Process DELETE mutations (observations not in current context) - for mutation in episode.mutations: - if mutation.type == OpType.DELETE and mutation.observation_id: + # 6. Write tombstones (DELETE / EVICT) for observations not in + # current context. One per id: the id's last mutation decides. + # Skip when next_ver == 1: the id was never persisted, so no + # orphan tombstone row should be written. + for mutation in mutation_map.values(): + if mutation.type in TOMBSTONE_TYPES and mutation.observation_id: if mutation.observation_id not in processed_observation_ids: # Get next version number cur.execute( @@ -485,7 +480,11 @@ def save_episode(self, episode: Episode) -> None: (self.bank_id, mutation.observation_id), ) row = cur.fetchone() - version = row["next_ver"] if row else 1 + next_ver = row["next_ver"] if row else 1 + + # Skip if the id was never saved (next_ver == 1) + if next_ver == 1: + continue cur.execute( """ @@ -496,12 +495,12 @@ def save_episode(self, episode: Episode) -> None: ( self.bank_id, mutation.observation_id, - version, + next_ver, mutation.section, - None, # content is NULL for DELETE + None, # content is NULL for tombstones str(episode.id), mutation.step, - "DELETE", + mutation.type.value, # a TOMBSTONE_TYPES member mutation.previous_content, 0, ), @@ -533,8 +532,10 @@ def load_context( topic_ids: list[str] | None = None, topic_prefix: str | None = None, ) -> ContextMemory | None: - """Load context for a new episode: latest observation versions from the - most recent episode for this (bank, task, topics). + """Load context for a new episode: latest observation versions from all episodes. + + Loads every observation from all prior episodes whose latest version + is not a tombstone (DELETE or EVICT) and bound to any of the specified topics. Args: task: Task name to filter by. @@ -543,165 +544,166 @@ def load_context( matches 'owner/repo', 'owner/repo/pkg', etc.). Mutually exclusive with topic_ids. - Returns ContextMemory (topics + observations + bindings) or None if - no prior episode exists. No mutations, artifacts, or episode - metadata are loaded. + Returns: + ContextMemory (topics + observations) or None if no filter provided + or no observations match. No mutations, artifacts, or episode + metadata are loaded. """ - try: - # Import here to avoid circular imports - from codespy.agents.memory.hippocampus.context_memory import ( - ContextMemory, - Observation, - Topic, - ) + # Import here to avoid circular imports + from codespy.agents.memory.hippocampus.context_memory import ( + TOMBSTONE_TYPES, + ContextMemory, + Observation, + Topic, + ) + + if not topic_ids and not topic_prefix: + logger.debug("load_context: no topic filter provided, returning None") + return None + # Single source of truth for which op_types are tombstones. + tombstone_op_types = sorted(t.value for t in TOMBSTONE_TYPES) + + try: with self._pool.connection() as conn: conn.row_factory = dict_row with conn.cursor() as cur: - # 1. Find the latest episode for this task + topics + # 1. Find observations whose latest version is not a tombstone + # and that are bound to matching topics params: list = [self.bank_id, task] if topic_ids: - filter_clause = sql.SQL("AND et.topic_id = ANY(%s)") - params.append(topic_ids) - elif topic_prefix: - filter_clause = sql.SQL("AND et.topic_id LIKE %s") - params.append(f"{topic_prefix}%") + filter_clause = sql.SQL("ot.topic_id = ANY(%s)") + params.append(list(topic_ids)) else: - filter_clause = sql.SQL("") - + # Escape LIKE wildcards so 'owner/my_repo' does not + # match 'owner/myXrepo/...'. Backslash is the default + # LIKE escape character in PostgreSQL. + escaped = ( + topic_prefix.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + filter_clause = sql.SQL("(ot.topic_id = %s OR ot.topic_id LIKE %s)") + params.extend([topic_prefix, f"{escaped}/%"]) + params.append(tombstone_op_types) + + # Query: get latest non-tombstone observation versions + # - Match topics via observation_topics on ANY version + # - Require version = MAX(version) over ALL versions of the id + # - Require the latest version to be live: op_type not in + # TOMBSTONE_TYPES, and content present. The content check + # also skips tombstones of a type this code does not know, + # instead of failing the whole load on a NULL content. + # - Order oldest-first by the timestamp of the episode that + # wrote the latest version. evict() drops the earliest + # positions first on ties, so the oldest facts are the + # ones evicted (and EVICT-tombstoned), not arbitrary ones. query = sql.SQL(""" - SELECT e.id FROM episodes e - JOIN episode_topics et ON et.bank_id = e.bank_id AND et.episode_id = e.id - WHERE e.bank_id = %s AND e.task = %s {} - ORDER BY e.timestamp DESC - LIMIT 1 - """).format(filter_clause) + SELECT * FROM ( + SELECT DISTINCT ON (o.id) + o.id, + o.version, + o.type as section, + o.content, + o.episode_id, + e.timestamp + FROM observations o + JOIN episodes e ON e.bank_id = o.bank_id AND e.id = o.episode_id + WHERE o.bank_id = %s + AND e.task = %s + AND EXISTS ( + SELECT 1 FROM observation_topics ot + WHERE ot.bank_id = o.bank_id AND ot.observation_id = o.id + AND {filter_clause} + ) + AND o.version = ( + SELECT MAX(o2.version) FROM observations o2 + WHERE o2.bank_id = o.bank_id AND o2.id = o.id + ) + AND o.op_type <> ALL(%s) + AND o.content IS NOT NULL + ORDER BY o.id, o.version DESC + ) latest + ORDER BY latest.timestamp ASC, latest.id ASC + """).format(filter_clause=filter_clause) cur.execute(query, params) - row = cur.fetchone() - if not row: - # Diagnostic: how many episodes exist for this bank+task (ignoring topic filter)? - cur.execute( - "SELECT COUNT(*) AS cnt FROM episodes WHERE bank_id = %s AND task = %s", - (self.bank_id, task), - ) - cnt = cur.fetchone()["cnt"] - logger.info( - "load_context: no episode matched (bank=%s, task=%s, topics=%s, prefix=%s); " - "total episodes for bank+task: %d", - self.bank_id, task, topic_ids, topic_prefix, cnt, - ) - return None + rows = cur.fetchall() - episode_id = row["id"] - logger.debug("load_context: found episode %s (bank=%s, task=%s)", episode_id, self.bank_id, task) + if not rows: + logger.debug("load_context: no observations found (bank=%s, task=%s)", self.bank_id, task) + return None - # 2. Load topics from that episode + # 2. Load topic bindings of exactly the versions loaded above cur.execute( """ - SELECT t.id, t.type, t.description FROM episode_topics et - JOIN topics t ON t.bank_id = et.bank_id AND t.id = et.topic_id - WHERE et.bank_id = %s AND et.episode_id = %s + SELECT ot.observation_id, ot.topic_id + FROM observation_topics ot + JOIN unnest(%s::text[], %s::int[]) AS v(id, version) + ON v.id = ot.observation_id AND v.version = ot.observation_version + WHERE ot.bank_id = %s """, - (self.bank_id, episode_id), + ( + [row["id"] for row in rows], + [row["version"] for row in rows], + self.bank_id, + ), ) - topics = [ - Topic(id=row["id"], type=row["type"], description=row["description"]) - for row in cur.fetchall() - ] - - # 3. Load observations at their LATEST version via topic bindings + task filter - # Exclude observations whose latest version is a DELETE tombstone - topic_id_list = [t.id for t in topics] - cur.execute( - """ - SELECT o.id, o.type, o.content, o.version - FROM observations o - JOIN episodes e ON e.bank_id = o.bank_id AND e.id = o.episode_id - WHERE o.bank_id = %s - AND e.task = %s - AND EXISTS ( - SELECT 1 FROM observation_topics ot - WHERE ot.bank_id = o.bank_id AND ot.observation_id = o.id - AND ot.topic_id = ANY(%s) - ) - AND o.version = ( - SELECT MAX(o2.version) FROM observations o2 - WHERE o2.bank_id = o.bank_id AND o2.id = o.id - ) - AND o.op_type != 'DELETE' - """, - (self.bank_id, task, topic_id_list), - ) - observations_by_id: dict[str, dict] = {} + + obs_topics_map: dict[str, list[str]] = {} for row in cur.fetchall(): - observations_by_id[row["id"]] = { - "section": row["type"], - "content": row["content"], - "version": row["version"], - } - - # 4. Load observation-topic bindings for those latest versions - if observations_by_id: + obs_id = row["observation_id"] + if obs_id not in obs_topics_map: + obs_topics_map[obs_id] = [] + obs_topics_map[obs_id].append(row["topic_id"]) + + # 3. Build ContextMemory + ctx = ContextMemory(topics=[]) + + # Add observations to sections + for row in rows: + section = row["section"] + if section not in ctx.section_names(): + section = "context_understanding" # fallback + + obs = Observation( + id=row["id"], + content=row["content"], + topic_ids=obs_topics_map.get(row["id"], []), + ) + ctx.section(section).append(obs) + + # 4. Load Topics referenced by these observations + all_topic_ids = set() + for obs in ctx.all_observations(): + all_topic_ids.update(obs.topic_ids) + + if all_topic_ids: cur.execute( """ - SELECT ot.observation_id, ot.topic_id, ot.observation_occurrence, ot.version_occurrence - FROM observation_topics ot - WHERE ot.bank_id = %s - AND ot.observation_id = ANY(%s) - AND ot.observation_version = ( - SELECT MAX(o2.version) - FROM observations o2 - WHERE o2.bank_id = ot.bank_id - AND o2.id = ot.observation_id - AND o2.op_type != 'DELETE' - ) + SELECT t.id, t.type, t.description + FROM topics t + WHERE t.bank_id = %s AND t.id = ANY(%s) """, - (self.bank_id, list(observations_by_id.keys())), + (self.bank_id, list(all_topic_ids)), ) - observation_topics_map: dict[str, list[str]] = {} for row in cur.fetchall(): - observation_id = row["observation_id"] - if observation_id not in observation_topics_map: - observation_topics_map[observation_id] = [] - observation_topics_map[observation_id].append(row["topic_id"]) - - # Build ContextMemory sections - sections: dict[str, list[Observation]] = { - "context_roadmap": [], - "context_understanding": [], - "domain_constants": [], - "actions": [], - "parsing_schema": [], - "reusable_results": [], - } - - for observation_id, observation_data in observations_by_id.items(): - section = observation_data["section"] - if section not in sections: - section = "context_understanding" # fallback - - obs = Observation( - id=observation_id, - content=observation_data["content"], - topic_ids=observation_topics_map.get(observation_id, []), + ctx.topics.append( + Topic(id=row["id"], type=row["type"], description=row["description"]) ) - sections[section].append(obs) - - # Build ContextMemory - ctx = ContextMemory(topics=topics) - for section_name, observations in sections.items(): - setattr(ctx, section_name, observations) - total_observations = sum(len(observations) for observations in sections.values()) - logger.debug("load_context: loaded %d topics, %d observations from episode %s", len(topics), total_observations, episode_id) + total_observations = len(ctx.all_observations()) + logger.debug( + "load_context: loaded %d topics, %d observations (bank=%s, task=%s)", + len(ctx.topics), total_observations, self.bank_id, task + ) - return ctx + return ctx - # No observations - return empty ContextMemory with topics - logger.debug("load_context: loaded %d topics, 0 observations from episode %s", len(topics), episode_id) - return ContextMemory(topics=topics) except Exception: - logger.warning("load_context failed (bank=%s, task=%s)", self.bank_id, task, exc_info=True) + logger.warning( + "load_context failed (bank=%s, task=%s)", + self.bank_id, task, exc_info=True + ) return None diff --git a/src/codespy/agents/review/audit/agent.py b/src/codespy/agents/review/audit/agent.py index 00dfe03..dc330d9 100644 --- a/src/codespy/agents/review/audit/agent.py +++ b/src/codespy/agents/review/audit/agent.py @@ -7,7 +7,7 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.context_memory import Topic from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.review.models import Issue, ReviewContext @@ -101,7 +101,6 @@ def _call_auditor( if scope_topic: scope_topics.append(scope_topic) - inject_context_memory(auditor) hippo = Hippocampus( task_name="audit", budget=self._settings.get_memory_budget("audit"), @@ -111,7 +110,6 @@ def _call_auditor( topics=scope_topics if scope_topics else None, ) result = auditor( - context_memory=hippo.context_memory, pr_title=review_context.pr_context.pr_title, summary=review_context.pr_context.summary, all_issues=all_issues, diff --git a/src/codespy/agents/review/code_review/agent.py b/src/codespy/agents/review/code_review/agent.py index 563c338..ca7f926 100644 --- a/src/codespy/agents/review/code_review/agent.py +++ b/src/codespy/agents/review/code_review/agent.py @@ -11,7 +11,7 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult @@ -315,7 +315,6 @@ async def _review_scope( ) pr_ctx = review_context.pr_context topics = [scope.topic(pr.repo_full_name), pr_ctx.to_topic()] if pr else [] - inject_context_memory(agent) hippo = Hippocampus( task_name="code_review", budget=self._settings.get_memory_budget("code_review"), @@ -325,7 +324,6 @@ async def _review_scope( topics=topics, ) result = await agent.acall( - context_memory=hippo.context_memory, scope=scoped, categories=categories, ) diff --git a/src/codespy/agents/review/doc/agent.py b/src/codespy/agents/review/doc/agent.py index f86db8d..094f9dd 100644 --- a/src/codespy/agents/review/doc/agent.py +++ b/src/codespy/agents/review/doc/agent.py @@ -11,7 +11,7 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult @@ -237,7 +237,6 @@ async def _review_scope( ) pr_ctx = review_context.pr_context topics = [scope.topic(pr.repo_full_name), pr_ctx.to_topic()] if pr else [] - inject_context_memory(reviewer) hippo = Hippocampus( task_name="doc", budget=self._settings.get_memory_budget("doc"), @@ -247,7 +246,6 @@ async def _review_scope( topics=topics, ) result = await reviewer.aforward( - context_memory=hippo.context_memory, patches=patches, documentation=documentation, categories=[IssueCategory.DOCUMENTATION], diff --git a/src/codespy/agents/review/scope/agent.py b/src/codespy/agents/review/scope/agent.py index 0644391..ced5109 100644 --- a/src/codespy/agents/review/scope/agent.py +++ b/src/codespy/agents/review/scope/agent.py @@ -19,7 +19,7 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.review.models import ReviewContext from codespy.agents.review.scope.manifest_parser import extract_package_name @@ -1090,7 +1090,6 @@ async def _refine_scopes( logger.info("Loaded prior scope episode for %s", repo_topic_id) else: logger.info("No prior scope episode for %s", repo_topic_id) - inject_context_memory(agent) hippo = Hippocampus( task_name="scope", budget=self._settings.get_memory_budget("scope"), @@ -1099,7 +1098,6 @@ async def _refine_scopes( initial_memory=scope_initial_memory, ) result = await agent.acall( - context_memory=hippo.context_memory, candidates=candidates_str, orphan_files=[f.filename for f in orphans], pr_title=pr.title or "No title", diff --git a/src/codespy/agents/review/summary/agent.py b/src/codespy/agents/review/summary/agent.py index fac78d9..99f865c 100644 --- a/src/codespy/agents/review/summary/agent.py +++ b/src/codespy/agents/review/summary/agent.py @@ -7,7 +7,7 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.memory.hippocampus.context_memory import Topic from codespy.agents.review.helpers import deepest_common_folder @@ -117,7 +117,6 @@ def forward( if scope_topic: scope_topics.append(scope_topic) - inject_context_memory(summarizer) hippo = Hippocampus( task_name="summary", budget=self._settings.get_memory_budget("summary"), @@ -127,7 +126,6 @@ def forward( topics=scope_topics if scope_topics else topics, ) result = summarizer( - context_memory=hippo.context_memory, pr_title=pr_context.pr_title, pr_description=pr_context.pr_description, changed_file_paths=changed_file_paths, diff --git a/src/codespy/agents/review/supply_chain/agent.py b/src/codespy/agents/review/supply_chain/agent.py index 4f91dcd..b260a5d 100644 --- a/src/codespy/agents/review/supply_chain/agent.py +++ b/src/codespy/agents/review/supply_chain/agent.py @@ -11,7 +11,7 @@ from codespy.agents import SignatureContext, get_cost_tracker from codespy.agents.context_safe import ContextSafe -from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus, inject_context_memory +from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult @@ -415,7 +415,6 @@ async def _review_scope( ) pr_ctx = review_context.pr_context topics = [scope.topic(pr.repo_full_name), pr_ctx.to_topic()] if pr else [] - inject_context_memory(supply_chain_agent) hippo = Hippocampus( task_name="supply_chain", budget=self._settings.get_memory_budget("supply_chain"), @@ -425,7 +424,6 @@ async def _review_scope( topics=topics, ) result = await supply_chain_agent.acall( - context_memory=hippo.context_memory, manifest_path=manifest_path, lock_file_path=lock_file_path, package_manager=package_manager, diff --git a/tests/test_cerebral.py b/tests/test_cerebral.py index 80a4140..2213fea 100644 --- a/tests/test_cerebral.py +++ b/tests/test_cerebral.py @@ -13,7 +13,9 @@ from codespy.agents.memory.cerebral.cost import MeteredLiteLLMSDKEmbeddings from codespy.agents.memory.hippocampus.context_memory import ( ContextMemory, + Mutation, Observation, + OpType, Topic, ) from codespy.agents.memory.hippocampus.episode import Episode @@ -100,6 +102,26 @@ def episode(): question="Review PR #1", artifacts={"review": "# Review Results\n\nFound 2 issues."}, context_memory=context_memory, + mutations=[ + Mutation( + step=0, + type=OpType.ADD, + observation_id="cu-test-1", + section="context_understanding", + content="This is a test observation", + previous_content=None, + topic_ids=["test/repo/package"], + ), + Mutation( + step=0, + type=OpType.ADD, + observation_id="ac-test-1", + section="actions", + content="Performed tool call", + previous_content=None, + topic_ids=["test/repo/package"], + ), + ], ) @@ -178,6 +200,37 @@ def test_init_handles_none_base_url(self, mock_memory_engine_class, mock_litellm class TestCerebralBank: """Tests for Cerebral bank management.""" + def test_ensure_bank_mission_contains_retracted_invalidates(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that retain_mission contains 'Only RETRACTED invalidates a fact'.""" + captured_mission = None + + async def mock_update_bank_config(bank_id, updates, request_context): + nonlocal captured_mission + captured_mission = updates.get("retain_mission") + return None + + async def mock_coro(*args, **kwargs): + return None + + def mock_memory_engine(*args, **kwargs): + engine = MagicMock() + engine.initialize = mock_coro + engine.ensure_bank_profile = mock_coro + engine.update_bank_config = mock_update_bank_config + engine.close = mock_coro + return engine + + with patch("codespy.agents.memory.cerebral.cerebral.MemoryEngine", mock_memory_engine): + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="litellm", + ) + cerebral._bank_ensured = False + cerebral._ensure_bank() + + assert captured_mission is not None + assert "Only RETRACTED invalidates a fact" in captured_mission + def test_ensure_bank_called_once(self, mock_memory_engine_class, mock_engine, mock_litellm): """Test that _ensure_bank is only called once.""" with patch.object(Cerebral, "_run_async") as mock_run_async: @@ -365,7 +418,7 @@ def mock_memory_engine(*args, **kwargs): obs_item = None art_item = None for item in captured_contents: - if "observations" in item.get("context", ""): + if "observation changes" in item.get("context", ""): obs_item = item elif "artifacts" in item.get("context", ""): art_item = item @@ -535,3 +588,112 @@ def test_build_tags_with_no_topics(self): assert f"episode:{episode.id}" in tags assert "task:code_review" in tags assert "run_id:run-456" in tags + + +def _mut(type_, obs_id, *, content=None, previous=None, section="context_understanding"): + from codespy.agents.memory.hippocampus.context_memory import MutationType + + return Mutation( + step=0, + type=MutationType(type_), + observation_id=obs_id, + section=section, + content=content, + previous_content=previous, + ) + + +def _episode_with(mutations, observations=()): + return Episode( + id=uuid.uuid4(), + run_id="r", + timestamp=datetime.now(UTC), + task="code_review", + module="m", + question="q", + artifacts={}, + context_memory=ContextMemory( + context_understanding=[Observation(id=i, content=c) for i, c in observations] + ), + mutations=list(mutations), + ) + + +class TestMutationLines: + """Cerebral retains the mutation log only.""" + + def test_add(self): + ep = _episode_with([_mut("ADD", "cu-a", content="new")], [("cu-a", "new")]) + assert Cerebral._mutation_lines(ep) == ["[context_understanding] new"] + + def test_add_then_replace_uses_final_content(self): + ep = _episode_with( + [_mut("ADD", "cu-a", content="v1"), _mut("REPLACE", "cu-a", content="v2", previous="v1")], + [("cu-a", "v2")], + ) + assert Cerebral._mutation_lines(ep) == ["[context_understanding] v2"] + + def test_add_then_delete_emits_nothing(self): + ep = _episode_with( + [_mut("ADD", "cu-a", content="v1"), _mut("DELETE", "cu-a", previous="v1")] + ) + assert Cerebral._mutation_lines(ep) == [] + + def test_prior_replace_supersedes(self): + ep = _episode_with( + [_mut("REPLACE", "cu-p", content="new", previous="old")], [("cu-p", "new")] + ) + assert Cerebral._mutation_lines(ep) == ["[context_understanding] new (supersedes: old)"] + + def test_replace_then_replace_uses_first_previous(self): + ep = _episode_with( + [ + _mut("REPLACE", "cu-p", content="mid", previous="old"), + _mut("REPLACE", "cu-p", content="new", previous="mid"), + ], + [("cu-p", "new")], + ) + assert Cerebral._mutation_lines(ep) == ["[context_understanding] new (supersedes: old)"] + + def test_prior_delete_retracted(self): + ep = _episode_with([_mut("DELETE", "cu-p", previous="wrong fact")]) + assert Cerebral._mutation_lines(ep) == [ + "[context_understanding] RETRACTED — shown incorrect or misleading, do not rely on: wrong fact" + ] + + def test_prior_evict_emits_nothing(self): + ep = _episode_with([_mut("EVICT", "cu-p", previous="still true")]) + assert Cerebral._mutation_lines(ep) == [] + + def test_replace_then_evict_emits_supersedes(self): + ep = _episode_with( + [ + _mut("REPLACE", "cu-p", content="new", previous="old"), + _mut("EVICT", "cu-p", previous="new"), + ] + ) + assert Cerebral._mutation_lines(ep) == [ + "[context_understanding] new (supersedes: old)" + ] + + def test_inherited_unchanged_observation_not_retained(self): + ep = _episode_with([], [("cu-p", "unchanged prior")]) + assert Cerebral._mutation_lines(ep) == [] + + def test_add_replace_evict_emits_last_content(self): + """ADD → REPLACE → EVICT gives a plain line with the last content.""" + ep = _episode_with( + [ + _mut("ADD", "cu-a", content="v1"), + _mut("REPLACE", "cu-a", content="v2", previous="v1"), + _mut("EVICT", "cu-a", previous="v2"), + ] + ) + assert Cerebral._mutation_lines(ep) == ["[context_understanding] v2"] + + def test_evicted_add_emits_content(self): + """ADD → EVICT gives a plain line with the evicted content.""" + ep = _episode_with( + [_mut("ADD", "cu-a", content="gone"), _mut("EVICT", "cu-a", previous="gone")] + ) + assert Cerebral._mutation_lines(ep) == ["[context_understanding] gone"] diff --git a/tests/test_hippocampus.py b/tests/test_hippocampus.py index ddb8a62..bc391c6 100644 --- a/tests/test_hippocampus.py +++ b/tests/test_hippocampus.py @@ -19,7 +19,6 @@ Operation, OpType, Topic, - inject_context_memory, ) from codespy.agents.memory.hippocampus.episode import Episode from codespy.agents.memory.postgres import EpisodeStore @@ -337,8 +336,8 @@ def forward(self, **kwargs): topics=[Topic(id="test/topic", type="project_scope", description="Test topic")], ) - # Make a call (MockAgent accepts any kwargs, so no inject needed for this test) - result = agent(context_memory=hippo.context_memory) + # Agents no longer receive context_memory as input + result = agent() hippo.observe(result) # End episode with the store @@ -470,9 +469,8 @@ def test_delete_creates_tombstone(self, episode_store): topic_ids=[topic_id], ) - # Should have no observations (deleted) - assert loaded_ctx is not None - assert len(loaded_ctx.context_understanding) == 0 + # When all observations are deleted, load_context returns None + assert loaded_ctx is None class TestRecordMutations: @@ -596,3 +594,368 @@ def test_neutral_preserves_existing(self): hip.scores = {"a": 5} hip._update_observation_scores({"a": ObservationTag.NEUTRAL}) assert hip.scores == {"a": 5} + + +def _stub_reflection(hippo, operations=None): + """Replace the Distiller/Cartographer with no-LLM stubs.""" + import dspy + + hippo.distill = lambda **kw: dspy.Prediction( + diagnosis="", observation_tags={}, cache_candidates=[] + ) + hippo.cartograph = lambda **kw: dspy.Prediction( + justification="", operations=list(operations or []) + ) + + +class TestEviction: + """Eviction of prior observations records an EVICT mutation (not DELETE).""" + + TOPIC = "owner/repo/evict-unit" + + def _prior(self, n: int, size: int = 400) -> ContextMemory: + return ContextMemory( + topics=[Topic(id=self.TOPIC, type="project_scope", description="t")], + context_understanding=[ + Observation( + id=f"cu-prior{i}", content=f"{i} " + "x" * size, topic_ids=[self.TOPIC] + ) + for i in range(n) + ], + ) + + def test_evicted_prior_records_evict_mutation(self): + from codespy.agents.memory.hippocampus import MemoryBudget, MutationType + + prior = self._prior(4) + hippo = Hippocampus( + task_name="evict_test", + budget=MemoryBudget(max_hippocampus_tokens=300), + initial_memory=prior, + ) + _stub_reflection(hippo) + hippo._distill("trajectory", "question") + + survivors = hippo.cmem.ids() + evicted_ids = {o.id for o in prior.all_observations()} - survivors + assert evicted_ids, "budget should force eviction" + + evicts = [m for m in hippo._mutations if m.type == MutationType.EVICT] + assert {m.observation_id for m in evicts} == evicted_ids + assert not any(m.type == MutationType.DELETE for m in hippo._mutations) + prior_by_id = {o.id: o for o in prior.all_observations()} + for m in evicts: + assert m.content is None + assert m.section == "context_understanding" + assert m.previous_content == prior_by_id[m.observation_id].content + assert m.topic_ids == [self.TOPIC] + assert m.step == 0 + + def test_no_eviction_records_nothing(self): + from codespy.agents.memory.hippocampus import MemoryBudget + + hippo = Hippocampus( + task_name="evict_test", + budget=MemoryBudget(max_hippocampus_tokens=100_000), + initial_memory=self._prior(2), + ) + _stub_reflection(hippo) + hippo._distill("trajectory", "question") + assert hippo._mutations == [] + + def test_evicted_own_add_records_evict(self): + from codespy.agents.memory.hippocampus import MemoryBudget, MutationType + + hippo = Hippocampus( + task_name="evict_test", + budget=MemoryBudget(max_hippocampus_tokens=200), + topics=[Topic(id=self.TOPIC, type="project_scope", description="t")], + ) + ops = [ + Operation(type=OpType.ADD, section="context_understanding", content=f"{i} " + "y" * 400) + for i in range(3) + ] + _stub_reflection(hippo, ops) + hippo._distill("trajectory", "question") + + assert len(hippo.cmem.all_observations()) < 3, "budget should force eviction" + # Now records EVICT for own ADDs too, with ADD's content as previous_content + evicts = [m for m in hippo._mutations if m.type == MutationType.EVICT] + assert len(evicts) > 0, "should record EVICT for evicted own ADDs" + for m in evicts: + assert m.content is None + assert m.previous_content is not None + assert m.previous_content.startswith("0 ") or m.previous_content.startswith("1 ") or m.previous_content.startswith("2 ") + + def test_episode_keeps_mutations_after_end_episode(self): + from codespy.agents.memory.hippocampus import MemoryBudget, MutationType + + hippo = Hippocampus( + task_name="evict_test", + budget=MemoryBudget(max_hippocampus_tokens=300), + initial_memory=self._prior(4), + ) + _stub_reflection(hippo) + hippo.observe("trajectory") + hippo.end_episode() + assert hippo.episode is not None + assert any(m.type == MutationType.EVICT for m in hippo.episode.mutations) + assert hippo._mutations == [] + + def test_cartographer_cannot_emit_evict(self): + """EVICT is a MutationType only; Operation (Cartographer output) rejects it.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError): + Operation(type="EVICT", observation_id="cu-x") + + +class TestEvictTombstone: + """Store round-trip: EVICT writes a tombstone version and hides the observation.""" + + def _row_versions(self, store, obs_id): + from psycopg.rows import dict_row + + with store._pool.connection() as conn: + conn.row_factory = dict_row + with conn.cursor() as cur: + cur.execute( + "SELECT version, op_type, content, previous_content FROM observations " + "WHERE bank_id = %s AND id = %s ORDER BY version", + (store.bank_id, obs_id), + ) + return cur.fetchall() + + def test_evict_tombstone_round_trip(self, episode_store): + import uuid + + from codespy.agents.memory.hippocampus import MutationType + + topic_id = f"owner/repo/evict-{uuid.uuid4().hex[:8]}" + topic = Topic(id=topic_id, type="project_scope", description="t") + keep_id, evict_id = f"cu-keep{uuid.uuid4().hex}", f"cu-evict{uuid.uuid4().hex}" + + ctx1 = ContextMemory( + topics=[topic], + context_understanding=[ + Observation(id=keep_id, content="keep me", topic_ids=[topic_id]), + Observation(id=evict_id, content="evict me", topic_ids=[topic_id]), + ], + ) + episode_store.save_episode(Episode( + id=uuid.uuid4(), task="code_review", module="m", question="q1", + context_memory=ctx1, timestamp=datetime.now(UTC), run_id="r1", + mutations=[ + Mutation(step=0, type=MutationType.ADD, observation_id=oid, + section="context_understanding", content=c, topic_ids=[topic_id]) + for oid, c in ((keep_id, "keep me"), (evict_id, "evict me")) + ], + artifacts={}, + )) + + # Episode 2: the prior observation was evicted (not in context_memory) + ctx2 = ContextMemory( + topics=[topic], + context_understanding=[Observation(id=keep_id, content="keep me", topic_ids=[topic_id])], + ) + episode_store.save_episode(Episode( + id=uuid.uuid4(), task="code_review", module="m", question="q2", + context_memory=ctx2, timestamp=datetime.now(UTC), run_id="r2", + mutations=[ + Mutation(step=0, type=MutationType.EVICT, observation_id=evict_id, + section="context_understanding", content=None, + previous_content="evict me", topic_ids=[topic_id]) + ], + artifacts={}, + )) + + rows = self._row_versions(episode_store, evict_id) + assert [(r["version"], r["op_type"]) for r in rows] == [(1, "ADD"), (2, "EVICT")] + assert rows[1]["content"] is None + assert rows[1]["previous_content"] == "evict me" + + loaded = episode_store.load_context(task="code_review", topic_ids=[topic_id]) + assert loaded is not None + assert loaded.ids() == {keep_id} + + def test_add_then_evict_same_run_no_orphan_row(self, episode_store): + """ADD then EVICT of a new id in same run → no row in observations.""" + import uuid + + from codespy.agents.memory.hippocampus import MutationType + + topic_id = f"owner/repo/add-evict-{uuid.uuid4().hex[:8]}" + topic = Topic(id=topic_id, type="project_scope", description="t") + new_id = f"cu-new{uuid.uuid4().hex}" + + # Single episode: ADD then EVICT the same observation + ctx = ContextMemory( + topics=[topic], + context_understanding=[], # Evicted, so not in context + ) + episode_store.save_episode(Episode( + id=uuid.uuid4(), task="code_review", module="m", question="q", + context_memory=ctx, timestamp=datetime.now(UTC), run_id="r", + mutations=[ + Mutation(step=0, type=MutationType.ADD, observation_id=new_id, + section="context_understanding", content="new content", topic_ids=[topic_id]), + Mutation(step=1, type=MutationType.EVICT, observation_id=new_id, + section="context_understanding", content=None, + previous_content="new content", topic_ids=[topic_id]), + ], + artifacts={}, + )) + + # No rows for this observation (ADD was never persisted, EVICT skipped tombstone) + rows = self._row_versions(episode_store, new_id) + assert rows == [], f"expected no rows for ADD→EVICT in same run, got {rows}" + + def test_load_orders_oldest_first_so_eviction_drops_oldest(self, episode_store): + """Load order follows episode time, not id; evict() then drops the oldest.""" + import uuid + from datetime import timedelta + + from codespy.agents.memory.hippocampus import MemoryBudget, MutationType + + suffix = uuid.uuid4().hex + topic_id = f"owner/repo/order-{suffix[:8]}" + topic = Topic(id=topic_id, type="project_scope", description="t") + # Id order is the reverse of time order: the oldest has the largest id. + ids_oldest_first = [f"cu-z{suffix}", f"cu-m{suffix}", f"cu-a{suffix}"] + now = datetime.now(UTC) + for age, oid in zip((3, 2, 1), ids_oldest_first, strict=True): + content = f"{oid} " + "x" * 400 + episode_store.save_episode(Episode( + id=uuid.uuid4(), task="code_review", module="m", question="q", + context_memory=ContextMemory( + topics=[topic], + context_understanding=[ + Observation(id=oid, content=content, topic_ids=[topic_id]) + ], + ), + timestamp=now - timedelta(days=age), run_id=f"r-{age}", + mutations=[ + Mutation(step=0, type=MutationType.ADD, observation_id=oid, + section="context_understanding", content=content, + topic_ids=[topic_id]) + ], + artifacts={}, + )) + + loaded = episode_store.load_context(task="code_review", topic_ids=[topic_id]) + assert loaded is not None + assert [o.id for o in loaded.context_understanding] == ids_oldest_first + + # Budget fits only the newest observation: the two oldest are evicted. + hippo = Hippocampus( + task_name="code_review", + budget=MemoryBudget(max_hippocampus_tokens=250), + initial_memory=loaded, + ) + _stub_reflection(hippo) + hippo._distill("trajectory", "question") + assert hippo.cmem.ids() == {ids_oldest_first[-1]} + assert [m.observation_id for m in hippo._mutations] == ids_oldest_first[:2] + + +class TestLoadContextTombstones: + """load_context never fails on tombstones and uses bindings of the loaded version.""" + + @staticmethod + def _save_add(store, topic, oid, content, topic_ids=None): + import uuid + + from codespy.agents.memory.hippocampus import MutationType + + ep_id = uuid.uuid4() + store.save_episode(Episode( + id=ep_id, task="code_review", module="m", question="q", + context_memory=ContextMemory( + topics=[topic], + context_understanding=[ + Observation(id=oid, content=content, topic_ids=topic_ids or [topic.id]) + ], + ), + timestamp=datetime.now(UTC), run_id="r", + mutations=[ + Mutation(step=0, type=MutationType.ADD, observation_id=oid, + section="context_understanding", content=content, + topic_ids=topic_ids or [topic.id]) + ], + artifacts={}, + )) + return ep_id + + def test_unknown_tombstone_type_is_skipped_not_fatal(self, episode_store): + """A NULL-content latest version of an unknown op_type hides only that observation.""" + import uuid + + suffix = uuid.uuid4().hex + topic = Topic(id=f"owner/repo/tomb-{suffix[:8]}", type="project_scope", description="t") + live_id, dead_id = f"cu-live{suffix}", f"cu-dead{suffix}" + self._save_add(episode_store, topic, live_id, "live") + ep_id = self._save_add(episode_store, topic, dead_id, "dead") + + with episode_store._pool.connection() as conn, conn.cursor() as cur: + cur.execute( + "INSERT INTO observations " + "(bank_id, id, version, type, content, episode_id, step, op_type, " + "previous_content, ordinal) " + "VALUES (%s, %s, 2, 'context_understanding', NULL, %s, 0, 'ARCHIVE', 'dead', 0)", + (episode_store.bank_id, dead_id, str(ep_id)), + ) + conn.commit() + + loaded = episode_store.load_context(task="code_review", topic_ids=[topic.id]) + assert loaded is not None + assert loaded.ids() == {live_id} + + def test_tombstone_types_come_from_tombstone_types(self, episode_store, monkeypatch): + """load_context reads the tombstone list from TOMBSTONE_TYPES.""" + import uuid + + from codespy.agents.memory.hippocampus import context_memory as cm + + suffix = uuid.uuid4().hex + topic = Topic(id=f"owner/repo/types-{suffix[:8]}", type="project_scope", description="t") + oid = f"cu-add{suffix}" + self._save_add(episode_store, topic, oid, "content") + + # Treat ADD as a tombstone type: the ADD-only observation must disappear. + monkeypatch.setattr( + cm, "TOMBSTONE_TYPES", frozenset(cm.TOMBSTONE_TYPES | {cm.MutationType.ADD}) + ) + assert episode_store.load_context(task="code_review", topic_ids=[topic.id]) is None + + def test_bindings_come_from_loaded_version(self, episode_store): + """After a REPLACE rebinds topics, only the latest version's bindings load.""" + import uuid + + from codespy.agents.memory.hippocampus import MutationType + + suffix = uuid.uuid4().hex + t1 = Topic(id=f"owner/repo/b1-{suffix[:8]}", type="project_scope", description="t1") + t2 = Topic(id=f"owner/repo/b2-{suffix[:8]}", type="project_scope", description="t2") + oid = f"cu-rebind{suffix}" + self._save_add(episode_store, t1, oid, "v1", topic_ids=[t1.id]) + + episode_store.save_episode(Episode( + id=uuid.uuid4(), task="code_review", module="m", question="q", + context_memory=ContextMemory( + topics=[t1, t2], + context_understanding=[Observation(id=oid, content="v2", topic_ids=[t2.id])], + ), + timestamp=datetime.now(UTC), run_id="r", + mutations=[ + Mutation(step=0, type=MutationType.REPLACE, observation_id=oid, + section="context_understanding", content="v2", + previous_content="v1", topic_ids=[t2.id]) + ], + artifacts={}, + )) + + loaded = episode_store.load_context(task="code_review", topic_ids=[t1.id]) + assert loaded is not None + obs = loaded.find_observation(oid)[1] + assert obs.content == "v2" + assert obs.topic_ids == [t2.id] From 2082fe3fd9010cbc98b9eb98d1064c7b045d0511 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Tue, 29 Sep 2026 14:18:55 +0200 Subject: [PATCH 21/39] cost --- .env.example | 1 + CHANGELOG.md | 6 ++ action.yml | 21 +++++++ codespy.yaml | 1 + docs/configuration.md | 1 + .../agents/memory/cerebral/cerebral.py | 29 +++++----- src/codespy/config_memory.py | 11 ++++ tests/test_config_memory.py | 56 +++++++++++++++++++ 8 files changed, 111 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index d1c2ca0..fb38885 100644 --- a/.env.example +++ b/.env.example @@ -217,6 +217,7 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # LLM config (e.g. bedrock uses AWS env vars, openai uses OPENAI_API_KEY). # MEMORY_RETAIN_MODEL= # Falls back to DEFAULT_MODEL # MEMORY_EMBEDDINGS_MODEL= # Auto-derived from provider +# MEMORY_RETAIN_CHUNK_SIZE=12288 # Chars per extraction chunk (< 64000) # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a9c8f7..555964c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ ## [Unreleased] +### Added +- **Cerebral (Semantic Memory)**: + - New `memory.cerebral.retain.chunk_size` configuration (default: 12288, env var: `MEMORY_RETAIN_CHUNK_SIZE`). Chars per fact-extraction chunk; must be < 64000. See `CerebralRetainConfig` in `config_memory.py`. + - New GitHub Action inputs: `memory-retain-model`, `memory-embeddings-model`, `memory-retain-chunk-size`. + ### Changed +- **Cerebral `retain_mission` is now domain-agnostic**: Replaced code-review-specific wording with general guidance for "observations, decisions, relationships, findings, insights" across any domain. The marker block (plain / `supersedes` / `RETRACTED`) semantics remain unchanged. - **Agents no longer receive memory input**: `inject_context_memory` and the `context_memory=` argument have been removed from all six review agents (summary, scope, code_review, doc, supply_chain, audit). Memory is learned but not used until the semantic Prefrontal is planned separately. - **Hippocampus eviction writes tombstones**: When a prior observation is evicted from `context_memory` to stay within `max_hippocampus_tokens`, Hippocampus records a new `EVICT` mutation (`MutationType.EVICT`, separate from the Cartographer's `OpType`, so the LLM cannot emit it). `save_episode` writes an `op_type='EVICT'` tombstone version (`content=NULL`) for persisted observations, and `load_context` excludes both `DELETE` and `EVICT` tombstones. - **Cerebral DELETE vs EVICT handling**: DELETE produces a `RETRACTED` line marking facts as wrong; EVICT of unchanged prior facts sends nothing; content new this run (ADD→EVICT or REPLACE→EVICT) is retained to avoid losing newly learned facts. The retain mission explicitly states that only `RETRACTED` invalidates a fact and missing facts are unchanged. diff --git a/action.yml b/action.yml index de2c093..83bc6da 100644 --- a/action.yml +++ b/action.yml @@ -370,6 +370,21 @@ inputs: description: 'Max RLM LLM calls for Cartographer (default: 2)' required: false + # ========================================== + # MEMORY (Cerebral — semantic memory) + # ========================================== + memory-retain-model: + description: 'Model for Cerebral fact extraction (falls back to model / DEFAULT_MODEL)' + required: false + + memory-embeddings-model: + description: 'litellm embedding model for Cerebral (auto-derived from the provider when unset)' + required: false + + memory-retain-chunk-size: + description: 'Chars per Cerebral fact-extraction chunk (default: 12288, must be < 64000)' + required: false + # Per-signature memory overrides scope-memory-enabled: description: 'Enable memory for scope identification (overrides global memory-enabled)' @@ -553,6 +568,9 @@ runs: MEMORY_CARTOGRAPHER_TEMPERATURE: ${{ inputs.memory-cartographer-temperature }} MEMORY_CARTOGRAPHER_MAX_ITERS: ${{ inputs.memory-cartographer-max-iters }} MEMORY_CARTOGRAPHER_MAX_LLM_CALLS: ${{ inputs.memory-cartographer-max-llm-calls }} + MEMORY_RETAIN_MODEL: ${{ inputs.memory-retain-model }} + MEMORY_EMBEDDINGS_MODEL: ${{ inputs.memory-embeddings-model }} + MEMORY_RETAIN_CHUNK_SIZE: ${{ inputs.memory-retain-chunk-size }} SCOPE_MEMORY_ENABLED: ${{ inputs.scope-memory-enabled }} CODE_REVIEW_MEMORY_ENABLED: ${{ inputs.code-review-memory-enabled }} DOC_MEMORY_ENABLED: ${{ inputs.doc-memory-enabled }} @@ -669,6 +687,9 @@ runs: [ -n "$MEMORY_CARTOGRAPHER_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CARTOGRAPHER_TEMPERATURE" [ -n "$MEMORY_CARTOGRAPHER_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CARTOGRAPHER_MAX_ITERS" [ -n "$MEMORY_CARTOGRAPHER_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CARTOGRAPHER_MAX_LLM_CALLS" + [ -n "$MEMORY_RETAIN_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RETAIN_MODEL" + [ -n "$MEMORY_EMBEDDINGS_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_EMBEDDINGS_MODEL" + [ -n "$MEMORY_RETAIN_CHUNK_SIZE" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RETAIN_CHUNK_SIZE" [ -n "$SCOPE_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e SCOPE_MEMORY_ENABLED" [ -n "$CODE_REVIEW_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e CODE_REVIEW_MEMORY_ENABLED" [ -n "$DOC_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e DOC_MEMORY_ENABLED" diff --git a/codespy.yaml b/codespy.yaml index f6fc238..71dd32e 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -128,6 +128,7 @@ memory: cerebral: retain: model: null # MEMORY_RETAIN_MODEL (falls back to llm.default_model) + chunk_size: 12288 # MEMORY_RETAIN_CHUNK_SIZE (chars, must be < 64000) embeddings: model: null # MEMORY_EMBEDDINGS_MODEL (auto-derived from provider) diff --git a/docs/configuration.md b/docs/configuration.md index a01c4ed..710efdc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -199,6 +199,7 @@ LLM provider and credentials are auto-derived from the model string and existing |---------|---------|---------|-------------| | Cerebral Model | `MEMORY_RETAIN_MODEL` | `DEFAULT_MODEL` | Model for fact extraction | | Embeddings Model | `MEMORY_EMBEDDINGS_MODEL` | Auto-derived | Model for semantic memory embeddings | +| Retain Chunk Size | `MEMORY_RETAIN_CHUNK_SIZE` | `12288` | Chars per extraction chunk (< 64000) | Hindsight MemoryEngine uses the same PostgreSQL instance under the `semantic` schema (created automatically alongside `episodic`). diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index 7597001..a07ea4e 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -86,6 +86,7 @@ def __init__( llm_base_url: str | None = None, bank_id: str = "codespy", embeddings_model: str = "openai/text-embedding-3-small", + retain_chunk_size: int = 12288, ): # Fail fast: test the embedding model before building MemoryEngine. # A bad model name, missing creds, or unavailable region surfaces here @@ -143,10 +144,11 @@ def __init__( self._bank_id = bank_id self._bank_ensured = False + self._retain_chunk_size = retain_chunk_size logger.info( - "Cerebral MemoryEngine initialized (schema=%s, bank=%s, embeddings=%s, provider=%s)", - HINDSIGHT_SCHEMA, bank_id, embeddings_model, llm_provider, + "Cerebral MemoryEngine initialized (schema=%s, bank=%s, embeddings=%s, provider=%s, chunk_size=%s)", + HINDSIGHT_SCHEMA, bank_id, embeddings_model, llm_provider, retain_chunk_size, ) def _run_async(self, coro): @@ -184,20 +186,17 @@ def _ensure_bank(self) -> None: self._bank_id, updates={ "retain_extraction_mode": "concise", - # retain_chunk_size (chars) MUST be strictly less than - # retain_max_completion_tokens (default 64000): Hindsight's - # validate_retain_completion_token_budget compares the two values - # directly (chars vs tokens is NOT reconciled), and a bank-config - # update that violates it is rejected wholesale — silently reverting - # the bank to defaults (retain_chunk_size=3000). Do not raise this at - # or above 64000. The merged observations blob is bounded by - # max_hippocampus_tokens (16384 tokens ≈ ~65K chars worst case), so - # at 12288 a large blob splits into several chunks (graceful, no error). - "retain_chunk_size": 12288, + # See CerebralRetainConfig.chunk_size for constraint details. + "retain_chunk_size": self._retain_chunk_size, "retain_mission": ( - "Retain code review observations, analysis results, and artifacts. " - "Focus on patterns, architectural decisions, dependency relationships, " - "security findings, and technical insights across repositories. " + "Retain durable knowledge learned while working on a task, whatever the domain. " + "Focus on how the subject is structured, the entities involved and how they relate, " + "domain rules, constraints and constants, procedures that proved effective, " + "data formats, and findings or results worth reusing later. " + "Each line starts with a [section] label naming the kind of knowledge, " + "or [artifact:name] for a produced output. " + "State each fact about the subject itself, keep names, identifiers, quantities " + "and references exact, and skip transient process notes that will not hold beyond this run.\n" "Observation lines use these markers:\n" "- A plain line is a newly learned fact.\n" "- '(supersedes: X)': the line is the current wording of a fact previously worded X. " diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 4a5c617..2b03b9a 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -9,6 +9,12 @@ from codespy.config_dspy import ReasoningEffort +# Hindsight constraint: validate_retain_completion_token_budget requires +# retain_max_completion_tokens (default 64000) > retain_chunk_size. +# If chunk_size >= 64000, update_bank_config rejects the update and the bank +# silently falls back to defaults (chunk 3000, no mission). +_HINDSIGHT_RETAIN_MAX_COMPLETION_TOKENS = 64000 + if TYPE_CHECKING: from codespy.agents.memory.postgres import EpisodeStore from codespy.config import Settings @@ -68,6 +74,10 @@ class CerebralRetainConfig(BaseModel): """Cerebral (semantic memory) LLM configuration for fact extraction.""" model: str | None = None # Falls back to llm.default_model + # Value is in chars. The merged observations blob is bounded by + # max_hippocampus_tokens (~65K chars worst case), so large blobs split + # into several chunks. Must be < _HINDSIGHT_RETAIN_MAX_COMPLETION_TOKENS. + chunk_size: int = Field(default=12288, gt=0, lt=_HINDSIGHT_RETAIN_MAX_COMPLETION_TOKENS) class CerebralEmbeddingsConfig(BaseModel): @@ -431,6 +441,7 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: llm_base_url=base_url, bank_id=bank_id, embeddings_model=embeddings_model, + retain_chunk_size=settings.memory.cerebral.retain.chunk_size, ) except Exception: logger.error( diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index 5e46c63..069cf21 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -9,10 +9,12 @@ PostgresConfig, Pg0Config, _cerebral_litellm_params, + CerebralRetainConfig, get_episode_store, reset_episode_store, verify_memory_access, ) +from pydantic import ValidationError class TestGenerateBankId: @@ -197,3 +199,57 @@ def test_embedding_models_mapping_gemini(self): def test_embedding_models_mapping_azure(self): """EMBEDDING_MODELS should have entry for azure.""" assert "azure" in EMBEDDING_MODELS + + +class TestCerebralRetainConfig: + """Tests for CerebralRetainConfig chunk_size validation.""" + + def test_default_chunk_size(self): + """Default chunk_size should be 12288.""" + config = CerebralRetainConfig() + assert config.chunk_size == 12288 + + def test_chunk_size_accepted_values(self): + """Valid chunk sizes should be accepted.""" + config = CerebralRetainConfig(chunk_size=5000) + assert config.chunk_size == 5000 + + config = CerebralRetainConfig(chunk_size=63999) + assert config.chunk_size == 63999 + + config = CerebralRetainConfig(chunk_size=1) + assert config.chunk_size == 1 + + def test_chunk_size_rejects_zero(self): + """chunk_size=0 should raise ValidationError.""" + with pytest.raises(ValidationError): + CerebralRetainConfig(chunk_size=0) + + def test_chunk_size_rejects_negative(self): + """Negative chunk_size should raise ValidationError.""" + with pytest.raises(ValidationError): + CerebralRetainConfig(chunk_size=-1) + + def test_chunk_size_rejects_64000(self): + """chunk_size=64000 should raise ValidationError (must be < 64000).""" + with pytest.raises(ValidationError): + CerebralRetainConfig(chunk_size=64000) + + def test_chunk_size_rejects_too_large(self): + """chunk_size > 64000 should raise ValidationError.""" + with pytest.raises(ValidationError): + CerebralRetainConfig(chunk_size=100000) + + +class TestCerebralRetainChunkSizeEnvOverride: + """Tests for MEMORY_RETAIN_CHUNK_SIZE env var override.""" + + def test_override_memory_retain_chunk_size(self, monkeypatch): + """MEMORY_RETAIN_CHUNK_SIZE should set memory.cerebral.retain.chunk_size.""" + monkeypatch.setenv("MEMORY_RETAIN_CHUNK_SIZE", "5000") + from codespy.config import _ENV_MAP + from codespy.config_utils import apply_env_overrides + + config = {} + result = apply_env_overrides(config, _ENV_MAP) + assert result["memory"]["cerebral"]["retain"]["chunk_size"] == "5000" From 29e28059df58986e535aa9822f4b23f0650446ad Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Wed, 30 Sep 2026 20:41:33 +0200 Subject: [PATCH 22/39] recall --- .env.example | 22 + CHANGELOG.md | 25 +- action.yml | 6 + codespy.yaml | 19 +- docs/configuration.md | 24 + docs/memory.md | 258 ++++++- src/codespy/agents/dspy_config.py | 7 +- .../agents/memory/cerebral/cerebral.py | 555 ++++++++++++++- src/codespy/agents/memory/cerebral/cost.py | 43 +- .../agents/memory/hippocampus/episode.py | 97 ++- .../agents/memory/hippocampus/hippocampus.py | 21 +- src/codespy/agents/memory/postgres.py | 69 +- .../agents/memory/prefrontal/__init__.py | 46 ++ .../agents/memory/prefrontal/facets.py | 213 ++++++ .../agents/memory/prefrontal/format.py | 206 ++++++ .../agents/memory/prefrontal/prefrontal.py | 590 ++++++++++++++++ src/codespy/agents/memory/prefrontal/query.py | 161 +++++ src/codespy/agents/memory/prefrontal/reach.py | 183 +++++ src/codespy/agents/memory/recall.py | 132 ++++ src/codespy/agents/review/audit/agent.py | 45 +- .../agents/review/code_review/agent.py | 44 +- src/codespy/agents/review/doc/agent.py | 38 +- src/codespy/agents/review/helpers.py | 10 +- src/codespy/agents/review/scope/agent.py | 41 +- src/codespy/agents/review/summary/agent.py | 38 +- .../agents/review/supply_chain/agent.py | 45 +- src/codespy/config.py | 30 +- src/codespy/config_memory.py | 260 ++++++- tests/test_cerebral.py | 652 ++++++++++++++++++ tests/test_cerebral_cost.py | 78 +++ tests/test_config_memory.py | 294 ++++++++ tests/test_episode_saves.py | 200 ++++++ tests/test_hippocampus.py | 121 ++++ 33 files changed, 4466 insertions(+), 107 deletions(-) create mode 100644 src/codespy/agents/memory/prefrontal/__init__.py create mode 100644 src/codespy/agents/memory/prefrontal/facets.py create mode 100644 src/codespy/agents/memory/prefrontal/format.py create mode 100644 src/codespy/agents/memory/prefrontal/prefrontal.py create mode 100644 src/codespy/agents/memory/prefrontal/query.py create mode 100644 src/codespy/agents/memory/prefrontal/reach.py create mode 100644 src/codespy/agents/memory/recall.py create mode 100644 tests/test_episode_saves.py diff --git a/.env.example b/.env.example index fb38885..5adcb2e 100644 --- a/.env.example +++ b/.env.example @@ -220,6 +220,28 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # MEMORY_RETAIN_CHUNK_SIZE=12288 # Chars per extraction chunk (< 64000) +# ============================================================================= +# Prefrontal (Semantic Recall into Agent Context) +# ============================================================================= +# Reads Cerebral before each agent call (briefings + recalled facts) and gives +# RLM agents a recall_memory tool. Active per signature when memory is enabled +# and Cerebral is available. Read-only: never writes to Cerebral. +# Every recall is logged (INFO summary) and stored in the episodic `recalls` table. +# +# Both Prefrontal and briefing refreshes run at Hindsight's LOW budget with a +# doubled global cap (2 × reflects), so each gets exactly ``reflects`` iterations. +# A raw HINDSIGHT_API_REFLECT_MAX_ITERATIONS env value is used as-is (not doubled). +# MEMORY_PREFRONTAL_MODEL= # Reflect + briefings; falls back to MEMORY_RETAIN_MODEL → DEFAULT_MODEL +# MEMORY_PREFRONTAL_REACH=org # local | org | bank (bank crosses organisations) +# MEMORY_PREFRONTAL_REFLECTS=3 # Reflect iterations; 0 = raw facts, no LLM, no briefings +# MEMORY_MAX_MENTAL_MODEL_TOKENS=2048 # Briefing length +# MEMORY_MAX_PREFRONTAL_TOKENS=8192 # Pre-call context budget +# MEMORY_MAX_PREFRONTAL_TOOL_TOKENS=2048 # Tool result budget +# MEMORY_MAX_PREFRONTAL_TOOL_CALLS=0 # Per agent call (0 = tool off) +# Reflect LLM timeout/retries default to codespy's LLM_TIMEOUT / LLM_RETRIES. +# Override via HINDSIGHT_API_REFLECT_LLM_TIMEOUT / HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES. + + # ============================================================================= # Output Settings # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index 555964c..d1472a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,35 @@ ## [Unreleased] +### Fixed +- Cerebral consolidation no longer logs `relation "webhooks" does not exist`. Inline (`SyncTaskBackend`) tasks now carry the `semantic` schema, so consolidation completion no longer rolls back and falls back. +- Cerebral consolidation no longer fails at exit with `cannot schedule new futures after shutdown`. Background episode saves now finish before Python shuts down its thread pools, and the pipeline waits for them without the old 120 s cap. Runs can end later, because posting the review now waits for the consolidation to finish. + ### Added +- **Prefrontal reflect hard caps**: programmatic reflect configuration with new caps: + - `memory.prefrontal.max_reflect_completion_tokens` (env `MEMORY_MAX_REFLECT_COMPLETION_TOKENS`, default `4096`): per-LLM-call output token cap. Reasoning tokens count against this first; keep it large enough for briefing rewrites (~2048 target). + - `memory.prefrontal.max_reflect_context_tokens` (env `MEMORY_MAX_REFLECT_CONTEXT_TOKENS`, default `null`): when `null`, the cap is auto-derived from `5 × max_mental_model_tokens + max_reflect_fetch_tokens + 2048` overhead, divided by 0.8, so worst-case history fits in one chunk and no map calls occur. With defaults this is ~16000. An explicit value still wins. + - Reflect caps are set programmatically on the Hindsight `_raw_config` after import, so they do not require a process restart. Env vars still win as operator overrides. + - Load INFO line now includes `maps=N` (split synthesis map-call count), `rewrite` flag, and `thoughts=N` (reasoning tokens) for monitoring. +- **Prefrontal embeddings bucket**: `memory_prefrontal_embeddings` cost bucket for embeddings during Prefrontal recall (separate from `memory_prefrontal` LLM calls). +- **Reflect summary**: `areflect` returns `(text, ReflectSummary)` with iterations, LLM calls, map calls, rewrite flag, tools used, usage, and empty flag. Stored in `RecallRecord.details["reflect"]` for monitoring. +- **Prefrontal reflect cost caps**: new settings to cap the Hindsight reflect loop cost: + - `memory.prefrontal.max_reflect_fetch_tokens` (env `MEMORY_MAX_REFLECT_FETCH_TOKENS`, default `2048`): per-call fetch limit for recall and search_observations tools. This is the default size used when the LLM omits one; the LLM can still ask for more up to the ceiling. Also disables chunk retrieval (`recall_include_chunks=false`). +- **Prefrontal (semantic recall)**: new `codespy/agents/memory/prefrontal/` package. Before each agent call it reads Cerebral and injects a read-only `prefrontal_memory` input (mental-model briefings, five question-shaped recall facets — context, seen before, decisions, recurring patterns, what changed — and facts from other repositories). The RLM agents (code_review, scope, supply_chain) also get an async `recall_memory(query, reach)` tool. Configured under `memory.prefrontal` (`MEMORY_PREFRONTAL_REACH`, `MEMORY_PREFRONTAL_REFLECTS`, `MEMORY_MENTAL_MODELS`, `MEMORY_MAX_MENTAL_MODEL_TOKENS`, `MEMORY_MAX_PREFRONTAL_TOKENS`, `MEMORY_PREFRONTAL_REMOTE_SHARE`, `MEMORY_PREFRONTAL_RECALL_BUDGET`, `MEMORY_PREFRONTAL_TOOL`, `MEMORY_MAX_PREFRONTAL_TOOL_TOKENS`, `MEMORY_MAX_PREFRONTAL_TOOL_CALLS`). Active only when the signature's memory is enabled and Cerebral is available; otherwise agents behave exactly as before. Prefrontal never writes to Cerebral and never feeds Hippocampus. +- **Cerebral read API**: `arecall`/`recall`, `areflect`/`reflect` (`budget=mid`), `aget_mental_models`, `aget_observation_history`. +- **Prefrontal model setting**: `memory.prefrontal.model` (env `MEMORY_PREFRONTAL_MODEL`, Action input `memory-prefrontal-model`) selects the model for Hindsight's reflect loop and mental-model refresh. It falls back to `memory.cerebral.retain.model` → `llm.default_model`, so behaviour is unchanged when unset. It is checked at startup when `reflects > 0`. +- **Prefrontal recall monitoring**: every pre-call load and `recall_memory` call is logged as one INFO line (status, model, LLM calls, input/output tokens, cost, latency) and persisted to a new episodic `recalls` table (`Episode.recalls`, `RecallRecord` in `codespy/agents/memory/recall.py`). Rows hold the exact text the agent received and the measured token usage and cost. The table is additive (`CREATE TABLE IF NOT EXISTS`); recalls are never retained into Cerebral nor given to the Distiller/Cartographer. +- **`memory_prefrontal` cost bucket**: Prefrontal read calls (reflect LLM calls and query embeddings) are attributed to it through a context variable. Previously they were counted in `memory_other` / `memory_embeddings`. +- **Cerebral mental models**: one briefing per observation scope and per repo, created on first retain and refreshed after consolidation (`memory.prefrontal.mental_models`). `memory.prefrontal.reflects > 0` sets `HINDSIGHT_API_REFLECT_MAX_ITERATIONS`, which also caps mental-model refresh loops. - **Cerebral (Semantic Memory)**: - New `memory.cerebral.retain.chunk_size` configuration (default: 12288, env var: `MEMORY_RETAIN_CHUNK_SIZE`). Chars per fact-extraction chunk; must be < 64000. See `CerebralRetainConfig` in `config_memory.py`. - New GitHub Action inputs: `memory-retain-model`, `memory-embeddings-model`, `memory-retain-chunk-size`. ### Changed +- **Cerebral consolidation now actually runs**: `MemoryEngine` uses `SyncTaskBackend`, so consolidation and mental-model refreshes run inline in `retain_episode` (background save thread). Previously `BrokerTaskBackend` only queued `pending` rows for a worker codespy never started. The first retain after upgrading consolidates the whole backlog (slow, costs LLM tokens); start and end are logged. Every retain now costs extra LLM calls (`memory_other` bucket) and background saves take longer. +- **Cerebral tags and observation scopes**: retained items get `repo:` and `org:` tags and an explicit `observation_scopes` list (`[org:, repo:, project_scope:]` per scope, or `[org:, repo:]`), so observations merge across tasks and runs. `task:`/`episode:`/`run_id:`/`pull_request:` never enter a scope. The bank also gets an `observations_mission`. `retain_episode` takes `repo_full_name`; without it the old behaviour is kept. Old data is not backfilled. - **Cerebral `retain_mission` is now domain-agnostic**: Replaced code-review-specific wording with general guidance for "observations, decisions, relationships, findings, insights" across any domain. The marker block (plain / `supersedes` / `RETRACTED`) semantics remain unchanged. -- **Agents no longer receive memory input**: `inject_context_memory` and the `context_memory=` argument have been removed from all six review agents (summary, scope, code_review, doc, supply_chain, audit). Memory is learned but not used until the semantic Prefrontal is planned separately. +- **Agents no longer receive memory input**: `inject_context_memory` and the `context_memory=` argument have been removed from all six review agents (summary, scope, code_review, doc, supply_chain, audit). Memory is learned but not used directly; prior knowledge now reaches agents only through Prefrontal (see Added). - **Hippocampus eviction writes tombstones**: When a prior observation is evicted from `context_memory` to stay within `max_hippocampus_tokens`, Hippocampus records a new `EVICT` mutation (`MutationType.EVICT`, separate from the Cartographer's `OpType`, so the LLM cannot emit it). `save_episode` writes an `op_type='EVICT'` tombstone version (`content=NULL`) for persisted observations, and `load_context` excludes both `DELETE` and `EVICT` tombstones. - **Cerebral DELETE vs EVICT handling**: DELETE produces a `RETRACTED` line marking facts as wrong; EVICT of unchanged prior facts sends nothing; content new this run (ADD→EVICT or REPLACE→EVICT) is retained to avoid losing newly learned facts. The retain mission explicitly states that only `RETRACTED` invalidates a fact and missing facts are unchanged. - **EpisodeStore.load_context() semantics changed**: Now loads the latest version of every observation from ALL prior episodes (not just the latest episode), excluding tombstones (`TOMBSTONE_TYPES`: `DELETE`, `EVICT`) and any version with no content. Observations are returned oldest-first, so eviction drops the oldest facts first. Topic prefix matching now uses proper LIKE escaping so `owner/repo` no longer matches `owner/repo-other`. @@ -42,6 +63,8 @@ - `cerebral_other` → `memory_other` ### Fixed +- Prefrontal reads on a bank with nothing retained yet (a new `MEMORY_BANK_ID`) now return empty results instead of failing with `Bank '' not found` (hindsight 0.10.1 returns 404 for reads on missing banks). Reflect is skipped there. +- `Cerebral.__init__()` no longer receives unexpected `reflect_llm_provider` kwarg — the parameter is derived internally by Cerebral from `llm_provider`, so callers only pass `reflect_llm_model/api_key/base_url` when `memory.prefrontal.model` is set - `get_llm_config("memory_retain")` no longer raises `AttributeError` — the cerebral branch now uses empty `ReflectionModuleConfig` and falls back to `llm.default_*` values - `MEMORY_POSTGRES_SCHEMA` env var now works correctly (previously emitted `MEMORY_POSTGRES_SCHEMA_NAME` which was ignored) - GitHub Action `memory-enabled` input now maps to `MEMORY_ENABLED` (was `MEMORY_DEFAULT_ENABLED`, which didn't match the code) diff --git a/action.yml b/action.yml index 83bc6da..0774513 100644 --- a/action.yml +++ b/action.yml @@ -377,6 +377,10 @@ inputs: description: 'Model for Cerebral fact extraction (falls back to model / DEFAULT_MODEL)' required: false + memory-prefrontal-model: + description: 'Model for Prefrontal reflect and briefing refresh (falls back to memory-retain-model / model)' + required: false + memory-embeddings-model: description: 'litellm embedding model for Cerebral (auto-derived from the provider when unset)' required: false @@ -569,6 +573,7 @@ runs: MEMORY_CARTOGRAPHER_MAX_ITERS: ${{ inputs.memory-cartographer-max-iters }} MEMORY_CARTOGRAPHER_MAX_LLM_CALLS: ${{ inputs.memory-cartographer-max-llm-calls }} MEMORY_RETAIN_MODEL: ${{ inputs.memory-retain-model }} + MEMORY_PREFRONTAL_MODEL: ${{ inputs.memory-prefrontal-model }} MEMORY_EMBEDDINGS_MODEL: ${{ inputs.memory-embeddings-model }} MEMORY_RETAIN_CHUNK_SIZE: ${{ inputs.memory-retain-chunk-size }} SCOPE_MEMORY_ENABLED: ${{ inputs.scope-memory-enabled }} @@ -688,6 +693,7 @@ runs: [ -n "$MEMORY_CARTOGRAPHER_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CARTOGRAPHER_MAX_ITERS" [ -n "$MEMORY_CARTOGRAPHER_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CARTOGRAPHER_MAX_LLM_CALLS" [ -n "$MEMORY_RETAIN_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RETAIN_MODEL" + [ -n "$MEMORY_PREFRONTAL_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_PREFRONTAL_MODEL" [ -n "$MEMORY_EMBEDDINGS_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_EMBEDDINGS_MODEL" [ -n "$MEMORY_RETAIN_CHUNK_SIZE" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RETAIN_CHUNK_SIZE" [ -n "$SCOPE_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e SCOPE_MEMORY_ENABLED" diff --git a/codespy.yaml b/codespy.yaml index 71dd32e..44491b2 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -2,14 +2,19 @@ # # ENVIRONMENT VARIABLE OVERRIDES: # All settings can be overridden via environment variables using the YAML path, -# uppercased and joined with underscores. The llm., memory.hippocampus. and -# memory.cerebral. segments are dropped; llm.retries and llm.timeout keep LLM_. +# uppercased and joined with underscores. The llm., memory.hippocampus., +# memory.cerebral. and memory.prefrontal. segments are dropped; llm.retries, +# llm.timeout, memory.prefrontal.reflects and memory.prefrontal.model keep their +# full names. # Examples: # - llm.default_model → DEFAULT_MODEL # - llm.retries → LLM_RETRIES # - review.code_review.max_iters → REVIEW_CODE_REVIEW_MAX_ITERS # - memory.hippocampus.distiller.model → MEMORY_DISTILLER_MODEL # - memory.cerebral.retain.model → MEMORY_RETAIN_MODEL +# - memory.prefrontal.model → MEMORY_PREFRONTAL_MODEL +# - memory.prefrontal.prefrontal_reach → MEMORY_PREFRONTAL_REACH +# - memory.prefrontal.reflects → MEMORY_PREFRONTAL_REFLECTS # # Provider credentials keep their standard names: # OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AWS_*, AZURE_* @@ -132,6 +137,16 @@ memory: embeddings: model: null # MEMORY_EMBEDDINGS_MODEL (auto-derived from provider) + # Prefrontal (semantic recall from Cerebral into agent context: read-only) + prefrontal: + model: null # MEMORY_PREFRONTAL_MODEL (reflect + briefings; falls back to retain model → default) + prefrontal_reach: org # MEMORY_PREFRONTAL_REACH (local | org | bank) + reflects: 3 # MEMORY_PREFRONTAL_REFLECTS (reflect iterations for recall and briefings; 0 = raw-fact recall, no LLM, no briefings) + max_mental_model_tokens: 2048 # MEMORY_MAX_MENTAL_MODEL_TOKENS (briefing length) + max_prefrontal_tokens: 8192 # MEMORY_MAX_PREFRONTAL_TOKENS (pre-call context budget) + max_prefrontal_tool_tokens: 2048 # MEMORY_MAX_PREFRONTAL_TOOL_TOKENS + max_prefrontal_tool_calls: 0 # MEMORY_MAX_PREFRONTAL_TOOL_CALLS (0 = recall_memory tool off) + # ============================================================================ # REVIEW # ============================================================================ diff --git a/docs/configuration.md b/docs/configuration.md index 710efdc..16e9c94 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -125,6 +125,7 @@ AUTO_DISCOVER_GEMINI=false | Cheap | PR summary | `REVIEW_SUMMARY_MODEL` | Falls back to DEFAULT_MODEL | Claude Haiku | | Mid-tier | Memory reflection | `MEMORY_DISTILLER_MODEL` / `MEMORY_CARTOGRAPHER_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | | Mid-tier | Semantic memory (fact extraction) | `MEMORY_RETAIN_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | +| Mid-tier | Semantic recall (reflect + briefings) | `MEMORY_PREFRONTAL_MODEL` | Falls back to MEMORY_RETAIN_MODEL → DEFAULT_MODEL | Claude Sonnet | ## Per-Signature Configuration @@ -202,6 +203,29 @@ LLM provider and credentials are auto-derived from the model string and existing | Retain Chunk Size | `MEMORY_RETAIN_CHUNK_SIZE` | `12288` | Chars per extraction chunk (< 64000) | Hindsight MemoryEngine uses the same PostgreSQL instance under the `semantic` schema (created automatically alongside `episodic`). +Consolidation and mental-model refreshes run inline in the background save thread. + +## Prefrontal Semantic Recall + +Prefrontal reads Cerebral before each agent call and injects a read-only `prefrontal_memory` +input (briefings, recalled facts, facts from other repositories); the RLM agents also get a +`recall_memory` tool. It is active per signature when memory is enabled and Cerebral is available. + +| Setting | Env Var | Default | Description | +|---------|---------|---------|-------------| +| Model | `MEMORY_PREFRONTAL_MODEL` | `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for the reflect loop and mental-model (briefing) refresh; unused when reflects = 0 | +| Reach | `MEMORY_PREFRONTAL_REACH` | `org` | `local`, `org` or `bank` | +| Reflects | `MEMORY_PREFRONTAL_REFLECTS` | `3` | Reflect iterations; `0` = raw facts, no LLM, no briefings | +| Mental model tokens | `MEMORY_MAX_MENTAL_MODEL_TOKENS` | `2048` | Briefing size | +| Pre-call tokens | `MEMORY_MAX_PREFRONTAL_TOKENS` | `8192` | Pre-call context budget | +| Tool tokens | `MEMORY_MAX_PREFRONTAL_TOOL_TOKENS` | `2048` | Tool result budget | +| Tool calls | `MEMORY_MAX_PREFRONTAL_TOOL_CALLS` | `0` | Per agent call (0 = tool off) | + +Every recall (pre-call load and each `recall_memory` call) is logged as one INFO line and +stored with its full text, model, token usage and cost in the episodic `recalls` table. Its +cost appears in the `memory_prefrontal` line of the cost report. + +See [Memory System](memory.md#semantic-memory-cerebral-and-prefrontal) for details. ## Output Settings diff --git a/docs/memory.md b/docs/memory.md index bf506c5..3db7f94 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -13,7 +13,7 @@ parsing schemas — and reuse it in subsequent reviews of the same code area. ### Banks A bank is the top-level data partition. All episodes, topics, observations, -and artifacts cascade-delete from a bank. +artifacts and recalls cascade-delete from a bank. - Configured via `MEMORY_BANK_ID` (default: `codespy`) - Typical values: service name, team name, org identifier @@ -27,7 +27,7 @@ and artifacts cascade-delete from a bank. ### Episodes -- An Episode captures one agent's run: task, context_memory, mutations, artifacts, timestamp +- An Episode captures one agent's run: task, context_memory, mutations, artifacts, recalls, timestamp - Stored in PostgreSQL (auto-created tables). pg0-embedded auto-starts a local instance when no `MEMORY_POSTGRES_HOST` is set. - `EpisodeStore.load_context(task, topic_ids, topic_prefix)` retrieves @@ -50,25 +50,35 @@ the observation to its relevant scopes. ### Observations Observations are the versioned audit trail of context memory items in the database. -Each time the Cartographer ADDs, REPLACEs, or DELETEs an observation, a new -observation row is inserted with an incremented `version` number. +Each time the Cartographer ADDs, REPLACEs, or DELETEs an observation, or eviction drops +one, a new observation row is inserted with an incremented `version` number. -- `content` holds the new value (`NULL` for DELETE) -- `previous_content` preserves the prior state (for REPLACE / DELETE) -- `op_type` records the operation: `ADD`, `REPLACE`, or `DELETE` +- `content` holds the new value (`NULL` for the DELETE and EVICT tombstones) +- `previous_content` preserves the prior state (for REPLACE / DELETE / EVICT) +- `op_type` records the operation: `ADD`, `REPLACE`, `DELETE` or `EVICT` - Linked to the episode that produced the mutation and the topics it belongs to -Observations in `ContextMemory` map 1:1 to the latest non-deleted observation version. +Observations in `ContextMemory` map 1:1 to the latest observation version that is not a +tombstone. ### Artifacts Named text outputs attached to an episode — for example, the final review markdown or the PR summary text. Stored as `(episode_id, name) → content`. +### Recalls + +One row per Prefrontal recall made during the agent call: the pre-call load +(`kind = load`) and each `recall_memory` tool call (`kind = tool`). A row holds the exact +text the agent received, plus the query, reach, status, model, LLM calls, input/output +tokens and cost, and latency. Recalls are for monitoring only: they are never given to the +Distiller or the Cartographer and never retained into Cerebral. See +[Monitoring recalls](#monitoring-recalls). + ## Database Schema Auto-created by `EpisodeStore.ensure_schema()` on first connect. -Source: `src/codespy/agents/memory/postgres.py:61-211`. +Source: `EpisodeStore.ensure_schema()` in `src/codespy/agents/memory/postgres.py`. ### Entity Relationship Diagram @@ -112,10 +122,10 @@ erDiagram varchar(48) id PK int version PK "DEFAULT 1" varchar(32) type - text content + text content "NULL for tombstones" uuid episode_id FK int step "DEFAULT 0" - varchar(8) op_type "ADD | REPLACE | DELETE" + varchar(8) op_type "ADD | REPLACE | DELETE | EVICT" text previous_content int ordinal "DEFAULT 0" } @@ -136,6 +146,27 @@ erDiagram text content } + recalls { + varchar(64) bank_id PK, FK + uuid episode_id PK, FK + int ordinal PK + varchar(8) kind "load | tool" + timestamptz timestamp + text query "DEFAULT ''" + varchar(8) reach "local | org | bank" + int reflects "DEFAULT 0" + varchar(8) status "ok | empty | limit | error" + text text "DEFAULT ''" + text model "DEFAULT ''" + int llm_calls "DEFAULT 0" + int input_tokens "DEFAULT 0" + int output_tokens "DEFAULT 0" + double input_cost "DEFAULT 0" + double output_cost "DEFAULT 0" + int latency_ms "DEFAULT 0" + jsonb details "DEFAULT '{}'" + } + banks ||--o{ topics : "has" banks ||--o{ episodes : "has" banks ||--o{ observations : "has" @@ -143,6 +174,7 @@ erDiagram topics ||--o{ episode_topics : "tags" episodes ||--o{ observations : "creates" episodes ||--o{ artifacts : "produces" + episodes ||--o{ recalls : "recalled" observations ||--o{ observation_topics : "scoped to" topics ||--o{ observation_topics : "scopes" ``` @@ -156,13 +188,15 @@ erDiagram | `idx_episode_topics_reverse` | episode_topics | `(bank_id, topic_id)` | | `idx_observations_episode` | observations | `(bank_id, episode_id)` | | `idx_observation_topics_reverse` | observation_topics | `(bank_id, topic_id)` | +| `idx_recalls_time` | recalls | `(bank_id, timestamp DESC)` | ### Notes - All tables cascade-delete from `banks` -- `observations` is versioned: PK `(bank_id, id, version)` tracks ADD/REPLACE/DELETE history per observation +- `observations` is versioned: PK `(bank_id, id, version)` tracks ADD/REPLACE/DELETE/EVICT history per observation - `episode_topics` and `observation_topics` are M:N junction tables - `observation_topics.observation_occurrence` counts cumulative topic associations across all versions; `version_occurrence` counts within one version +- `recalls` is episodic-only (never retained into Cerebral); PK `(bank_id, episode_id, ordinal)` makes saves idempotent - Requires PostgreSQL extension: `pg_trgm` ## Reflection Pipeline @@ -185,6 +219,192 @@ The Distiller also tags each existing context memory observation with an `Observ These tags inform the Cartographer's edit decisions. +## Semantic Memory: Cerebral and Prefrontal + +Three components, fully independent: + +| Component | Role | Reads | Writes | +|-----------|------|-------|--------| +| **Hippocampus** | Learns from the current episode. Reloads prior episodes (`load_context` → `initial_memory`) only so it does not relearn existing facts. | episodic store | episodic store | +| **Cerebral** | Retains Hippocampus episodes into the Hindsight bank, consolidates them into observations and keeps the mental models (briefings) fresh. | — | Hindsight bank | +| **Prefrontal** | Gives the agent useful prior knowledge so it does not rediscover it. | Hindsight bank | nothing | + +The reloaded Hippocampus episode is **never** passed to the agent, and +`prefrontal_memory` is never given to the Distiller or the Cartographer. +Prefrontal and Hippocampus never feed each other. + +### Data flow + +``` +agent call ──► Prefrontal.aload() / recall_memory ──reads──► Cerebral (Hindsight bank) + │ │ prefrontal_memory (read-only input) + recall_memory tool + │ └──► RecallRecord[] ──► Episode.recalls ──► episodic store (recalls table) + ▼ +trajectory ──► Hippocampus (Distiller → Cartographer) ──► episodic store + └──► Cerebral.retain_episode() (background save thread; recalls excluded) + ├─ retain_batch_async (fact extraction) + ├─ consolidation (inline, SyncTaskBackend) + └─ mental-model upkeep +``` + +### Tags and observation scopes + +Every retained item carries its topic tags (`project_scope:`, `pull_request:`), +`episode:`, `task:`, `run_id:`, and — new — `repo:` and `org:`. + +Consolidation uses an explicit `observation_scopes` list: one +`[org:, repo:, project_scope:]` scope per `project_scope` topic of the episode, or +`[org:, repo:]` when there is none. `task:`, `episode:`, `run_id:` and +`pull_request:` never enter a scope, so facts about the same scope merge across tasks +and runs, and a correction made by one task reaches all of them. Task relevance is +handled at read time (the task-specific query ranks; raw facts keep their `task:` label). + +Cerebral runs Hindsight with `SyncTaskBackend`: consolidation and mental-model refreshes +run inline inside `retain_episode`, on the background save thread. (Previously +`BrokerTaskBackend` only queued them for a worker codespy never started, so they never ran.) +The first retain after upgrading consolidates the whole backlog, which is slow and costs +LLM tokens; Cerebral logs when consolidation starts and ends. The process waits for +background saves (retain + consolidation) to complete before exiting. + +Old data has no `repo:`/`org:` tags and per-episode observations. It stays reachable +through `project_scope:` tags; briefings cover only new data. There is no backfill and +no automatic drop. + +### Mental models (briefings) + +Mental models are the abstraction layer ("when X happens, consider Y because Z"). +Cerebral keeps one per observation scope of each episode, plus one per repo +(`[org:, repo:]`), when `memory.prefrontal.reflects > 0` (briefings enabled): + +- id: `mm-` + first 32 hex chars of `sha1("|".join(sorted(tags)))` +- name: `Briefing: ` +- source query: *"What should an agent starting work here know so it does not rediscover it: structure and where things live, conventions, invariants and constants, dependencies and integrations, pitfalls and recurring problems, facts shown to be wrong."* +- `max_tokens = max_mental_model_tokens`, trigger `refresh_after_consolidation` + +A missing model is created and refreshed once; consolidation keeps it fresh afterwards. +Prefrontal reads the briefings of the agent's scopes (and repo) with no LLM call; +missing models and models still holding the placeholder content are skipped. + +### Reach + +`memory.prefrontal.prefrontal_reach` (`local` | `org` | `bank`, default `org`): + +- **Local pool** (always): `project_scope:` for code_review, doc and + supply_chain; `repo:` for scope, summary and audit (summary and audit + also OR in their scopes' `project_scope:` ids). +- **Remote pool** (`org`: `org:` AND NOT `repo:`; `bank`: NOT `repo:`): + always raw facts, labelled with their source repo. It takes a fixed 25% share + of `max_prefrontal_tokens` (and of `max_prefrontal_tool_tokens` for the tool); + the local facets split the rest. With `local` reach the local pool gets the + whole budget. `bank` crosses organisations — opt-in only. + +### Pre-call context + +Before each call, the agent gets a read-only `prefrontal_memory` input: + +1. **Briefings** — one block per mental model. +2. **Five facets** — the semantic layer, which answers the messier questions a briefing + cannot. They always run, even when briefings exist: + + | Facet | Question | `fact_type` | Share | + |-------|----------|-------------|-------| + | `context` | What happened around this work? | experience, world | 30% | + | `seen_before` | Have we met this file, symbol, package or problem before? | world, observation | 20% | + | `decisions` | What was decided about it? | world, observation | 20% | + | `patterns` | What keeps recurring? | observation | 20% | + | `belief_changes` | What did we believe before, and what changed? | (history) | 10% | + + Facets 1–4 run concurrently (`question_date=now`). `belief_changes` reads the history + of the top 3 observations of facets 2–4 (2 entries each) and collects + `RETRACTED` / `supersedes:` facts — no recall, no embedding. Facts are deduplicated + across sections by id, then by exact text. +3. **Other repositories** — remote facts, "verify before relying on it". + +Empty sections are omitted; with nothing at all the input is `""`. The field tells the +agent the content may be stale or wrong and that issues must be verified with tools. + +### `reflects` + +`memory.prefrontal.reflects` (env `MEMORY_PREFRONTAL_REFLECTS`, default `3`): + +- `0`: raw facts only — no LLM at read time, no briefings. The recall_memory tool is + also disabled. +- `N > 0`: both Prefrontal and briefing refreshes run at Hindsight's LOW budget with + a doubled global cap (2 × N), so each gets exactly N iterations. The LOW budget + uses a 0.5× multiplier, so max(1, int(2N × 0.5)) = N iterations. + +**Environment override**: if `HINDSIGHT_API_REFLECT_MAX_ITERATIONS` is set, it is used +as-is (not doubled). Both Prefrontal and briefing refreshes then get half of that +value (with LOW budget). + +**Reflect LLM timeout/retries**: reuse codespy's `llm.timeout` / `llm.retries`. Override +via `HINDSIGHT_API_REFLECT_LLM_TIMEOUT` / `HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES` (or +the generic `HINDSIGHT_API_LLM_TIMEOUT` / `HINDSIGHT_API_LLM_MAX_RETRIES`). + +**Monitoring**: the load INFO line includes `maps=N` (split synthesis map-call count), +`rewrite` (if a rewrite occurred), `thoughts=N` (reasoning tokens), and `tools=...` +(per-tool token sizes). A WARNING is logged when `maps > 0` (split synthesis detected) +or when LLM calls exceed `reflects + 1` (unexpected split synthesis or rewrite). + +### `recall_memory` tool + +The RLM agents (code_review, scope, supply_chain) get +`async recall_memory(query: str, reach: str = "local") -> str` for follow-up questions. +A reach above the configured one is clamped; at most `max_prefrontal_tool_calls` +calls per agent call (then `"recall limit reached"`); errors return `"memory unavailable"`. + +### Model + +`memory.prefrontal.model` (env `MEMORY_PREFRONTAL_MODEL`, Action input +`memory-prefrontal-model`) is the model Hindsight's reflect loop uses. It falls back to +`memory.cerebral.retain.model`, then `llm.default_model`. It is passed to `MemoryEngine` +as its reflect LLM, so it **also refreshes the mental models (briefings)**. It is unused +when `reflects = 0`, and it is checked at startup only when `reflects > 0`. Retain and +consolidation keep using the retain model. + +### Monitoring recalls + +Every recall is logged and stored. + +- **Logs** — one INFO line per recall, without the recalled text: + ``` + Prefrontal[code_review]: load status=ok 1 briefings, facets context=1 seen_before=3 ... model=openai/gpt-4o-mini calls=3 in=5120 out=410 cost=$0.0042 latency=6400ms + Prefrontal[code_review]: recall_memory reach=local status=ok mode=reflect model=openai/gpt-4o-mini calls=2 in=2210 out=180 cost=$0.0017 latency=3100ms query='where is session refresh' + ``` +- **Episodic store** — one `recalls` row per recall, attached to the agent's episode. + `text` is exactly what the agent received. `status` is `ok`, `empty`, `limit` (tool cap + reached) or `error`, so an empty load and a failed load can be told apart. `details` + holds per-facet queries, counts and failures for loads, and requested reach, mode and + fact counts for tool calls. +- **Usage** — measured, not estimated: `input_tokens` counts LLM prompt tokens plus query + embeddings, `output_tokens` counts completions, and costs use `litellm.cost_per_token` + (0 for unpriced models). With `reflects = 0`, only embedding tokens are recorded. +- **Cost report** — Prefrontal reads are billed to the `memory_prefrontal` bucket. + Mental-model refreshes during retain stay in `memory_other`. +- Recalls are **not** retained into Cerebral and are never shown to the Distiller or the + Cartographer. When the episodic store is unavailable, recalls are only logged. + +```sql +SELECT e.task, e.run_id, r.kind, r.status, r.model, r.llm_calls, + r.input_tokens, r.output_tokens, r.input_cost + r.output_cost AS cost, + r.latency_ms, r.query, r.text +FROM recalls r JOIN episodes e ON e.bank_id = r.bank_id AND e.id = r.episode_id +ORDER BY r.timestamp DESC LIMIT 50; +``` + +### Gating and failure + +Prefrontal is active for a signature only when its memory is enabled and Cerebral is +available; otherwise the agent behaves exactly as before. Every failure in Prefrontal or +in mental-model upkeep logs a warning and degrades to less context, or to `""`. + +### Cost and latency + +Every retain now runs consolidation and refreshes the touched scopes' models (several LLM +calls per episode, in the `memory_other` cost bucket). `reflects > 0` adds a nested loop to +every agent call and tool call; those Prefrontal reads are billed to `memory_prefrontal`. +Background saves take longer, and process exit waits on them. + ## Token Budgets | Budget | Env Var | Default | Purpose | @@ -216,6 +436,18 @@ Observation capacity ≈ max_hippocampus_tokens / max_hippocampus_item_tokens (1 | `MEMORY_ENABLED` | `memory.enabled` | `false` | Enable memory globally (episodic + semantic) | | `MEMORY_COMPACT_TRAJECTORY` | `memory.hippocampus.compact_trajectory` | `true` | Apply head+tail trajectory bounding before distillation | +### Prefrontal Settings + +| Env Var | YAML Path | Default | Description | +|---------|-----------|---------|-------------| +| `MEMORY_PREFRONTAL_MODEL` | `memory.prefrontal.model` | `memory.cerebral.retain.model` → `llm.default_model` | Reflect loop + mental-model refresh; unused when reflects=0 | +| `MEMORY_PREFRONTAL_REACH` | `memory.prefrontal.prefrontal_reach` | `org` | `local`, `org` or `bank` | +| `MEMORY_PREFRONTAL_REFLECTS` | `memory.prefrontal.reflects` | `3` | Reflect iterations; `0` = raw facts, no LLM, no briefings | +| `MEMORY_MAX_MENTAL_MODEL_TOKENS` | `memory.prefrontal.max_mental_model_tokens` | `2048` | Briefing size | +| `MEMORY_MAX_PREFRONTAL_TOKENS` | `memory.prefrontal.max_prefrontal_tokens` | `8192` | Pre-call context budget | +| `MEMORY_MAX_PREFRONTAL_TOOL_TOKENS` | `memory.prefrontal.max_prefrontal_tool_tokens` | `2048` | Tool result budget | +| `MEMORY_MAX_PREFRONTAL_TOOL_CALLS` | `memory.prefrontal.max_prefrontal_tool_calls` | `0` | Tool calls per agent call (0 = tool off) | + ### Reflection Module LLM Overrides | Module | Env Var Pattern | YAML Path | @@ -274,6 +506,8 @@ MEMORY_CARTOGRAPHER_MODEL=anthropic/claude-sonnet-4-5-20250929 memory-postgres-password: ${{ secrets.MEMORY_POSTGRES_PASSWORD }} memory-distiller-model: 'anthropic/claude-haiku-4-5-20251001' memory-cartographer-model: 'anthropic/claude-haiku-4-5-20251001' + # memory-retain-model: 'anthropic/claude-sonnet-4-5-20250929' + # memory-prefrontal-model: 'anthropic/claude-sonnet-4-5-20250929' ``` > **Note:** pg0-embedded is included in the Docker image. For persistent memory diff --git a/src/codespy/agents/dspy_config.py b/src/codespy/agents/dspy_config.py index 95cfbd7..a9991fe 100644 --- a/src/codespy/agents/dspy_config.py +++ b/src/codespy/agents/dspy_config.py @@ -7,7 +7,7 @@ from dspy.adapters.two_step_adapter import TwoStepAdapter # type: ignore[import-untyped] from codespy.config import Settings, get_settings -from codespy.config_memory import MEMORY_RETAIN, REFLECTION_MODULES, LLMSettings +from codespy.config_memory import MEMORY_PREFRONTAL, MEMORY_RETAIN, REFLECTION_MODULES, LLMSettings from codespy.config_utils import secret_value logger = logging.getLogger(__name__) @@ -248,6 +248,11 @@ def verify_model_access(settings: Settings) -> tuple[bool, str]: cerebral = settings.get_llm_config(MEMORY_RETAIN) models_to_check.add(cerebral.model) + # Prefrontal reflect model (only when reflects > 0, since with reflects=0 no LLM runs) + if settings.memory.prefrontal.reflects > 0: + prefrontal = settings.get_llm_config(MEMORY_PREFRONTAL) + models_to_check.add(prefrontal.model) + # Check each model verified: list[str] = [] failed: list[str] = [] diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index a07ea4e..7a85cdb 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -2,6 +2,16 @@ Uses MemoryEngine (direct Python API) sharing the same PostgreSQL instance as the episodic store, under the ``semantic`` schema. + +Write side: ``retain_episode`` retains Hippocampus episodes, tagged with +``repo:``/``org:`` and consolidated per observation scope, then keeps one +mental model ("briefing") per scope. With ``SyncTaskBackend`` consolidation +and mental-model refreshes run inline, on the caller's (background save) +thread. + +Read side (used by Prefrontal only): ``arecall``/``recall``, +``areflect``/``reflect``, ``aget_mental_models`` and +``aget_observation_history``. Cerebral never reads Prefrontal output. """ from __future__ import annotations @@ -10,8 +20,12 @@ import logging import os import threading +import time from collections import defaultdict -from typing import TYPE_CHECKING +from collections.abc import Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING, Any # Module constants (must be defined before the Hindsight import) HINDSIGHT_SCHEMA_DEFAULT = "semantic" @@ -41,23 +55,98 @@ from hindsight_api import MemoryEngine from hindsight_api.engine.cross_encoder import RRFPassthroughCrossEncoder from hindsight_api.engine.embeddings import LiteLLMSDKEmbeddings +from hindsight_api.engine.memory_engine import Budget +from hindsight_api.engine.task_backend import SyncTaskBackend +from hindsight_api.extensions import OperationValidationError from hindsight_api.extensions.builtin.tenant import DefaultTenantExtension from hindsight_api.models import RequestContext + +class _SchemaSyncTaskBackend(SyncTaskBackend): + """SyncTaskBackend that tags inline tasks with Cerebral's schema. + + Hindsight only sets ``_schema`` in the worker poller. Without it, + ``_execute_task`` passes ``schema=None`` to the consolidation webhook, + which then queries bare ``webhooks`` (search_path -> public) and fails with + 'relation "webhooks" does not exist'. + + The root cause upstream is ``fq_table_explicit(table, None)`` returning + a bare table name instead of falling back to ``database_schema`` like + ``fq_table`` and ``fq_routine`` do. This subclass can be removed once + Hindsight falls back to ``database_schema`` there. + """ + + def __init__(self, schema: str): + super().__init__() + self._schema = schema + + async def submit_task(self, task_dict): + if "_schema" not in task_dict: + task_dict = {**task_dict, "_schema": self._schema} + await super().submit_task(task_dict) + from codespy.agents.memory.cerebral.cost import ( CerebralCostRecorder, MeteredLiteLLMSDKEmbeddings, register_cerebral_cost_recorder, ) from codespy.agents.memory.cerebral.routines import ensure_maintenance_routines +from codespy.agents.memory.prefrontal.reach import ( + TAG_PROJECT_SCOPE, + mental_model_id, + repo_tags, + scope_tags, +) if TYPE_CHECKING: from codespy.agents.memory.hippocampus.episode import Episode logger = logging.getLogger(__name__) + +@dataclass +class ReflectSummary: + """Summary of a reflect call for monitoring and debugging. + + Attributes: + iterations: Number of reflect iterations performed. + llm_calls: Total LLM calls (map calls + reduce + rewrite). + map_calls: Number of map calls in split synthesis (0 when history fits in one chunk). + rewrite: Whether a final rewrite occurred. + tools: List of (tool_name, output_tokens) tuples. Output size is estimated + in tokens (chars/4 heuristic) from the JSON-serialized tool output. + usage: Token usage dict with input_tokens, output_tokens, thoughts_tokens. + empty: Whether the result text is empty. + """ + + iterations: int = 0 + llm_calls: int = 0 + map_calls: int = 0 + rewrite: bool = False + tools: list[tuple[str, int]] = field(default_factory=list) + usage: dict[str, int] = field(default_factory=dict) + empty: bool = True + HINDSIGHT_SCHEMA = HINDSIGHT_SCHEMA_DEFAULT +# Content Hindsight's HTTP API gives a mental model before its first refresh. +# Prefrontal skips briefings that still hold it. +MENTAL_MODEL_PLACEHOLDER = "Generating content..." + +# Generic (not code-review specific) question each briefing answers. +MENTAL_MODEL_SOURCE_QUERY = ( + "What should an agent starting work here know so it does not rediscover it: " + "structure and where things live, conventions, invariants and constants, " + "dependencies and integrations, pitfalls and recurring problems, facts shown to be wrong." +) + +OBSERVATIONS_MISSION = ( + "Merge facts about the same subject into one observation. Keep details that matter only " + "to one kind of work (e.g. documentation, security, dependencies) instead of generalizing " + "them away. When a fact is marked 'supersedes:', update the observation; when marked " + "'RETRACTED', mark the observation invalid." +) + # Warn if the operator has overridden HINDSIGHT_API_DATABASE_SCHEMA to a different # value than our schema — the routines would then be missing again (upstream #2638). if os.environ.get("HINDSIGHT_API_DATABASE_SCHEMA") != HINDSIGHT_SCHEMA: @@ -87,6 +176,11 @@ def __init__( bank_id: str = "codespy", embeddings_model: str = "openai/text-embedding-3-small", retain_chunk_size: int = 12288, + mental_models: bool = True, + max_mental_model_tokens: int = 2048, + reflect_llm_model: str | None = None, + reflect_llm_api_key: str | None = None, + reflect_llm_base_url: str | None = None, ): # Fail fast: test the embedding model before building MemoryEngine. # A bad model name, missing creds, or unavailable region surfaces here @@ -124,16 +218,32 @@ def __init__( cost_recorder=cost_recorder, ) + # Separate reflect LLM (Prefrontal reflect + mental-model refresh), only + # when configured; otherwise Hindsight falls back to memory_llm_*. + reflect_kwargs: dict[str, str | None] = {} + if reflect_llm_model: + reflect_kwargs = { + "reflect_llm_provider": llm_provider, + "reflect_llm_model": reflect_llm_model, + "reflect_llm_api_key": reflect_llm_api_key, + "reflect_llm_base_url": reflect_llm_base_url or None, + } + self._engine = MemoryEngine( db_url=database_url, memory_llm_provider=llm_provider, memory_llm_model=llm_model, memory_llm_api_key=llm_api_key, memory_llm_base_url=llm_base_url or None, + **reflect_kwargs, embeddings=metered_embeddings, cross_encoder=RRFPassthroughCrossEncoder(), tenant_extension=DefaultTenantExtension(config={"schema": HINDSIGHT_SCHEMA}), skip_llm_verification=True, + # Run consolidation and mental-model refreshes inline. The default + # BrokerTaskBackend only queues rows for a WorkerPoller, which + # codespy never starts, so they would never run. + task_backend=_SchemaSyncTaskBackend(HINDSIGHT_SCHEMA), ) self._run_async(self._engine.initialize()) @@ -144,7 +254,15 @@ def __init__( self._bank_id = bank_id self._bank_ensured = False + self._bank_exists = False + self._missing_bank_logged = False self._retain_chunk_size = retain_chunk_size + self._mental_models = mental_models + self._max_mental_model_tokens = max_mental_model_tokens + # Mental-model ids already ensured by this process. + self._ensured_mental_models: set[str] = set() + # Lock for consolidation serialization + self._consolidation_lock = threading.Lock() logger.info( "Cerebral MemoryEngine initialized (schema=%s, bank=%s, embeddings=%s, provider=%s, chunk_size=%s)", @@ -156,6 +274,20 @@ def _run_async(self, coro): future = asyncio.run_coroutine_threadsafe(coro, self._loop) return future.result() + async def _await(self, coro): + """Await a coroutine on the dedicated loop without blocking the caller's loop. + + When already running on the dedicated loop, the coroutine is awaited + directly (``run_coroutine_threadsafe`` + wait would deadlock there). + """ + try: + running = asyncio.get_running_loop() + except RuntimeError: + running = None + if running is self._loop: + return await coro + return await asyncio.wrap_future(asyncio.run_coroutine_threadsafe(coro, self._loop)) + def close(self) -> None: """Shut down the MemoryEngine and stop the event loop.""" try: @@ -177,6 +309,7 @@ def _ensure_bank(self) -> None: request_context=ctx, ) ) + self._bank_exists = True except Exception: logger.debug("cerebral: bank %s profile ensure failed", self._bank_id) @@ -205,6 +338,11 @@ def _ensure_bank(self) -> None: "the agent. Mark it invalid and do not rely on it.\n" "A fact missing from a batch is unchanged, not outdated. Only RETRACTED invalidates a fact." ), + "observations_mission": OBSERVATIONS_MISSION, + # Disable auto-consolidation; Cerebral triggers it explicitly + # after retain with proper scope scoping (prevents unscoped + # pending rows from blocking scoped submissions). + "enable_auto_consolidation": False, }, request_context=ctx, ) @@ -306,7 +444,7 @@ def _mutation_lines(episode: Episode) -> list[str]: return lines - def retain_episode(self, episode: Episode) -> None: + def retain_episode(self, episode: Episode, repo_full_name: str | None = None) -> None: """Retain episode mutations and artifacts in Hindsight semantic memory. The observations blob is built from episode.mutations instead of @@ -314,10 +452,18 @@ def retain_episode(self, episode: Episode) -> None: - Plain lines for ADDs still in context_memory - "(supersedes:)" lines for REPLACEs - "RETRACTED" lines for DELETEs + + With ``repo_full_name``, items also get ``repo:``/``org:`` tags and an + explicit ``observation_scopes`` list (one scope per ``project_scope`` + topic, never ``task:``/``episode:``/``run_id:``/``pull_request:``), so + observations merge across tasks and runs. Consolidation runs inline + (``SyncTaskBackend``), then the mental models of the touched scopes + are ensured. Without a repo, today's behaviour is kept. """ self._ensure_bank() - tags = self._build_tags(episode) + tags = self._build_tags(episode, repo_full_name) + scopes = self._observation_scopes(episode, repo_full_name) episode_doc_id = f"episode-{episode.id}" # Build contents list: at most TWO items per episode @@ -351,6 +497,28 @@ def retain_episode(self, episode: Episode) -> None: logger.debug("cerebral: no items for episode %s", episode.id) return + if scopes is not None: + for item in contents: + item["observation_scopes"] = [list(sc) for sc in scopes] + + # Sync briefing triggers BEFORE retain+consolidation so consolidation + # uses the correct refresh settings from the start. + mental_model_scopes = self._mental_model_scopes(scopes or [], repo_full_name) if repo_full_name else [] + self._sync_briefing_triggers(mental_model_scopes) + + started = time.monotonic() + logger.info( + "cerebral: retain + consolidation started for episode %s (bank=%s, task=%s)", + episode.id, self._bank_id, episode.task, + ) + + # Capture baseline memory_other stats for delta calculation + from codespy.agents.memory.cerebral.cost import BUCKET_MEMORY_OTHER + from codespy.agents.cost_tracker import get_cost_tracker + tracker = get_cost_tracker() + baseline_stats = tracker.get_signature_stats(BUCKET_MEMORY_OTHER) + baseline_calls = baseline_stats.call_count if baseline_stats else 0 + try: self._run_async( self._engine.retain_batch_async( @@ -359,21 +527,392 @@ def retain_episode(self, episode: Episode) -> None: request_context=RequestContext(), ) ) - logger.info( - "cerebral: retained %d content blobs for episode %s (bank=%s, task=%s)", - len(contents), episode.id, self._bank_id, episode.task, - ) except Exception: logger.warning( "cerebral: failed to retain episode %s", episode.id, exc_info=True, ) + return + + # Scoped consolidation (serialized) - prevents unscoped pending rows + # from blocking subsequent submissions. + with self._consolidation_lock: + for scope_tags in mental_model_scopes: + self._submit_scoped_consolidation(scope_tags) + + # Log consolidation completion with delta + end_stats = tracker.get_signature_stats(BUCKET_MEMORY_OTHER) + end_calls = end_stats.call_count if end_stats else baseline_calls + delta_calls = end_calls - baseline_calls + + logger.info( + "cerebral: retained %d content blobs for episode %s (bank=%s, task=%s); " + "consolidation finished in %.1fs, memory_other +%d calls", + len(contents), episode.id, self._bank_id, episode.task, + time.monotonic() - started, + delta_calls, + ) + + @staticmethod + def _observation_scopes(episode: Episode, repo_full_name: str | None) -> list[list[str]] | None: + """Observation scopes for consolidation, or None without a repo (Hindsight default). + + One ``[org:, repo:, project_scope:]`` scope per ``project_scope`` topic, + or ``[org:, repo:]`` when the episode has none. + """ + if not repo_full_name: + return None + scopes = [ + scope_tags(repo_full_name, topic.id) + for topic in episode.context_memory.topics + if topic.type == "project_scope" + ] + return scopes or [repo_tags(repo_full_name)] + + @staticmethod + def _mental_model_scopes( + scopes: Sequence[Sequence[str]], repo_full_name: str + ) -> list[list[str]]: + """Observation scopes plus the repo scope ``[org:, repo:]`` (always added, deduped).""" + out: list[list[str]] = [] + seen: set[tuple[str, ...]] = set() + for sc in [*scopes, repo_tags(repo_full_name)]: + key = tuple(sorted(sc)) + if key not in seen: + seen.add(key) + out.append(list(sc)) + return out + + @staticmethod + def _mental_model_name(tags: Sequence[str]) -> str: + for tag in tags: + if tag.startswith(TAG_PROJECT_SCOPE): + return f"Briefing: {tag[len(TAG_PROJECT_SCOPE):]}" + for tag in tags: + if tag.startswith("repo:"): + return f"Briefing: {tag[len('repo:'):]}" + return "Briefing" + + def _sync_briefing_triggers(self, scopes: list[list[str]]) -> None: + """Sync briefing triggers to match current settings before consolidation. + + Runs before retain+consolidation so that consolidation uses the correct + refresh settings. The desired trigger is: + - ``refresh_after_consolidation``: True when mental_models is enabled + - ``exclude_mental_models``: True (briefings exclude other briefings) + + For existing briefings, updates the trigger if it differs. For missing + briefings, creates them only when mental_models is enabled. + """ + if not scopes: + return + + desired_trigger = { + "refresh_after_consolidation": self._mental_models, + "exclude_mental_models": True, + } + + for tags in scopes: + mm_id = mental_model_id(tags) + if mm_id in self._ensured_mental_models: + continue + + try: + ctx = RequestContext() + existing = self._run_async( + self._engine.get_mental_model(self._bank_id, mm_id, request_context=ctx) + ) + + if existing is None: + # Create missing briefing only when mental_models is enabled + if not self._mental_models: + self._ensured_mental_models.add(mm_id) + continue + + try: + self._run_async( + self._engine.create_mental_model( + self._bank_id, + self._mental_model_name(tags), + MENTAL_MODEL_SOURCE_QUERY, + MENTAL_MODEL_PLACEHOLDER, + mental_model_id=mm_id, + tags=sorted(tags), + max_tokens=self._max_mental_model_tokens, + trigger=desired_trigger, + request_context=ctx, + ) + ) + logger.info( + "cerebral: created briefing %s (%s) refresh=%s", + mm_id, + self._mental_model_name(tags), + "on" if self._mental_models else "off", + ) + except OperationValidationError as exc: + if getattr(exc, "status_code", None) != 409: + raise + # 409 means another process created it; fall through to update + existing = self._run_async( + self._engine.get_mental_model(self._bank_id, mm_id, request_context=ctx) + ) + else: + # Existing briefing: update trigger if it differs + current_trigger = existing.get("trigger") or {} + needs_update = ( + current_trigger.get("refresh_after_consolidation") != self._mental_models + or current_trigger.get("exclude_mental_models") is not True + ) + if needs_update: + self._run_async( + self._engine.update_mental_model( + self._bank_id, + mm_id, + trigger=desired_trigger, + request_context=ctx, + ) + ) + logger.info( + "cerebral: briefing %s refresh %s -> %s", + mm_id, + "on" if current_trigger.get("refresh_after_consolidation") else "off", + "on" if self._mental_models else "off", + ) + + self._ensured_mental_models.add(mm_id) + except Exception: + logger.warning("cerebral: failed to sync briefing trigger for %s", mm_id, exc_info=True) + + def _submit_scoped_consolidation(self, scope_tags: list[str]) -> None: + """Submit a scoped consolidation for one scope under the process lock. + + Scoped consolidation is never skipped, unlike unscoped submissions which + are deduplicated when a pending unscoped row exists. + """ + ctx = RequestContext() + try: + self._run_async( + self._engine.submit_async_consolidation( + self._bank_id, + request_context=ctx, + observation_scopes=[scope_tags], + ) + ) + except Exception: + logger.warning( + "cerebral: scoped consolidation failed for scope %s", + scope_tags, + exc_info=True, + ) + + # ------------------------------------------------------------------ + # Read API (Prefrontal) + # ------------------------------------------------------------------ + + async def _abank_exists(self) -> bool: + """Check whether the bank exists in Hindsight. + + Returns True immediately if already known to exist. Otherwise queries + the engine and caches the result. "Missing" is not cached: a later + retain creates the bank, and subsequent reads must see it. + """ + if self._bank_exists: + return True + profile = await self._await( + self._engine.get_bank_profile(self._bank_id, request_context=RequestContext()) + ) + if profile is not None: + self._bank_exists = True + return True + if not self._missing_bank_logged: + self._missing_bank_logged = True + logger.info( + "cerebral: bank %s not created yet (nothing retained); reads return empty", + self._bank_id, + ) + return False + + async def arecall( + self, + query: str, + *, + tag_groups: list[Any] | None = None, + max_tokens: int = 4096, + fact_type: list[str] | None = None, + prefer_observations: bool = False, + budget: str | Budget = Budget.MID, + question_date: datetime | None = None, + ) -> list[Any]: + """Raw recall (one embedding call, no LLM). Returns ``MemoryFact`` list.""" + if not await self._abank_exists(): + return [] + result = await self._await( + self._engine.recall_async( + self._bank_id, + query, + budget=Budget(budget), + max_tokens=max_tokens, + fact_type=list(fact_type) if fact_type else None, + prefer_observations=prefer_observations, + question_date=question_date, + tag_groups=tag_groups, + request_context=RequestContext(), + ) + ) + return list(getattr(result, "results", None) or []) + + def recall(self, query: str, **kwargs: Any) -> list[Any]: + """Blocking variant of :meth:`arecall`.""" + return self._run_async(self.arecall(query, **kwargs)) + + async def areflect( + self, + query: str, + *, + tag_groups: list[Any] | None = None, + max_tokens: int = 4096, + context: str | None = None, + fact_types: list[str] | None = None, + ) -> tuple[str, ReflectSummary]: + """Hindsight reflect loop (LLM). ``budget=LOW`` so the iteration cap is exactly + ``HINDSIGHT_API_REFLECT_MAX_ITERATIONS // 2`` (the LOW budget multiplier is 0.5). + + Args: + query: The query to reflect on. + tag_groups: Optional tag groups to filter by. + max_tokens: Maximum tokens for the response. + context: Optional context string for the reflection. + fact_types: Optional list of fact types to consider. + + Returns: + Tuple of (text, ReflectSummary). The summary records iterations, LLM calls, + map-call count (for split synthesis detection), rewrite, tools used, usage, + and whether the text is empty. + """ + if not await self._abank_exists(): + return "", ReflectSummary() + reflect_kwargs: dict[str, Any] = { + "budget": Budget.LOW, + "context": context, + "max_tokens": max_tokens, + "tag_groups": tag_groups, + "fact_types": list(fact_types) if fact_types else None, + "request_context": RequestContext(), + } + + result = await self._await( + self._engine.reflect_async(self._bank_id, query, **reflect_kwargs) + ) + text = (getattr(result, "text", None) or "").strip() + summary = self._build_reflect_summary(result) + return text, summary + + def _build_reflect_summary(self, result: Any) -> ReflectSummary: + """Build ReflectSummary from Hindsight ReflectResult. + + The ReflectResult shape from hindsight_api: + - llm_trace: list[LLMCallTrace(scope, duration_ms)] + - tool_trace: list[ToolCallTrace(tool, input, output: dict, iteration, ...)] + - usage: TokenUsage(input_tokens, output_tokens, thoughts_tokens) + """ + import json + + summary = ReflectSummary() + if result is None: + return summary + + # LLM trace is a list of LLMCallTrace objects with 'scope' attribute + llm_trace = getattr(result, "llm_trace", None) or [] + scope_names: list[str] = [] + for call in llm_trace: + scope = getattr(call, "scope", None) + if scope: + scope_names.append(str(scope)) + summary.llm_calls += 1 + if str(scope).startswith("final_map_"): + summary.map_calls += 1 + elif str(scope) == "final_rewrite": + summary.rewrite = True + + # Count iterations from agent_ scopes (excluding _err variants) + # +1 if there's any final scope (map, reduce, or rewrite) + agent_scopes = {s for s in scope_names if s.startswith("agent_") and not s.endswith("_err")} + summary.iterations = len(agent_scopes) + if any(s.startswith("final") for s in scope_names): + summary.iterations += 1 + + # Tools from tool_trace (list of ToolCallTrace with output dict) + tool_trace = getattr(result, "tool_trace", None) or [] + for tc in tool_trace: + tool_name = getattr(tc, "tool", "") or "" + output = getattr(tc, "output", None) or {} + # Estimate tokens from serialized output (same heuristic as prefrontal.format) + try: + output_text = json.dumps(output) if output else "" + except Exception: + output_text = str(output) + output_tokens = (len(output_text) + 3) // 4 # chars/4 heuristic + summary.tools.append((tool_name, output_tokens)) + + # Usage from TokenUsage + usage = getattr(result, "usage", None) + if usage: + summary.usage = { + "input_tokens": getattr(usage, "input_tokens", 0) or 0, + "output_tokens": getattr(usage, "output_tokens", 0) or 0, + "thoughts_tokens": getattr(usage, "thoughts_tokens", 0) or 0, + } + + summary.empty = not (getattr(result, "text", None) or "").strip() + return summary + + def reflect(self, query: str, **kwargs: Any) -> tuple[str, ReflectSummary]: + """Blocking variant of :meth:`areflect`.""" + return self._run_async(self.areflect(query, **kwargs)) + + async def aget_mental_models(self, ids: Sequence[str]) -> list[dict]: + """Fetch mental models by id (no LLM). Missing ids are skipped; order is kept.""" + if not await self._abank_exists(): + return [] + async def _get_all() -> list[Any]: + return await asyncio.gather( + *( + self._engine.get_mental_model( + self._bank_id, mm_id, request_context=RequestContext() + ) + for mm_id in ids + ), + return_exceptions=True, + ) + + results = await self._await(_get_all()) + models: list[dict] = [] + for mm_id, res in zip(ids, results, strict=True): + if isinstance(res, BaseException): + logger.debug("cerebral: get_mental_model %s failed: %s", mm_id, res) + continue + if res: + models.append(res) + return models + + async def aget_observation_history(self, memory_id: str) -> list[dict]: + """History of an observation, most recent first (no LLM, no embedding).""" + if not await self._abank_exists(): + return [] + result = await self._await( + self._engine.get_observation_history( + self._bank_id, memory_id, request_context=RequestContext() + ) + ) + return list(result or []) @staticmethod - def _build_tags(episode: Episode) -> list[str]: + def _build_tags(episode: Episode, repo_full_name: str | None = None) -> list[str]: tags: list[str] = [] for topic in episode.context_memory.topics: tags.append(f"{topic.type}:{topic.id}") + if repo_full_name: + tags.extend(repo_tags(repo_full_name)) tags.append(f"episode:{episode.id}") tags.append(f"task:{episode.task}") tags.append(f"run_id:{episode.run_id}") diff --git a/src/codespy/agents/memory/cerebral/cost.py b/src/codespy/agents/memory/cerebral/cost.py index f8ed7d0..05819f5 100644 --- a/src/codespy/agents/memory/cerebral/cost.py +++ b/src/codespy/agents/memory/cerebral/cost.py @@ -12,9 +12,11 @@ import time from typing import TYPE_CHECKING, Any +from codespy.agents.memory.recall import current_recall_usage from codespy.config_memory import ( MEMORY_EMBEDDINGS, MEMORY_OTHER, + MEMORY_PREFRONTAL, MEMORY_RETAIN, ) @@ -31,6 +33,10 @@ BUCKET_MEMORY_RETAIN = MEMORY_RETAIN BUCKET_MEMORY_OTHER = MEMORY_OTHER BUCKET_MEMORY_EMBEDDINGS = MEMORY_EMBEDDINGS +# Prefrontal reads (recall/reflect inside a track_recall_usage() block) +BUCKET_MEMORY_PREFRONTAL = MEMORY_PREFRONTAL +# Prefrontal embeddings (separate bucket for embeddings during Prefrontal recall) +BUCKET_MEMORY_PREFRONTAL_EMBEDDINGS = f"{MEMORY_PREFRONTAL}_embeddings" # Track models we've warned about missing prices (one warning per model) _warned_unpriced_models: set[str] = set() @@ -65,27 +71,33 @@ def record_llm_call( # type: ignore[return] error: Exception if the call failed duration: Duration of the call in seconds **_: Ignored extra kwargs for forward compatibility + + Note: + Provider-internal retries are not counted — only successful calls + and errored calls that reached the provider (with billed usage). """ try: - # Skip failed calls - if error is not None: - return - - # Skip calls with no tokens + # Skip calls with no tokens (includes failed calls that never reached provider) if input_tokens == 0 and output_tokens == 0: return - # Determine bucket from scope - if scope and scope.startswith("retain"): - bucket = BUCKET_MEMORY_RETAIN - else: - bucket = BUCKET_MEMORY_OTHER - # Price the call using litellm prompt_cost, completion_cost = self._price_call_split(model, input_tokens, output_tokens) cost = prompt_cost + completion_cost tokens = input_tokens + output_tokens + # Determine bucket: an active Prefrontal recall wins over the scope, + # because Hindsight scopes cannot tell a Prefrontal reflect apart + # from a mental-model refresh. + usage = current_recall_usage.get() + if usage is not None: + usage.add_llm(model, input_tokens, output_tokens, prompt_cost, completion_cost) + bucket = BUCKET_MEMORY_PREFRONTAL + elif scope and scope.startswith("retain"): + bucket = BUCKET_MEMORY_RETAIN + else: + bucket = BUCKET_MEMORY_OTHER + # Import here to avoid circular imports at module load from codespy.agents.cost_tracker import get_cost_tracker @@ -207,10 +219,17 @@ def _meter_embedding(self, response: Any, model: str, duration: float = 0.0) -> # Import here to avoid circular imports from codespy.agents.cost_tracker import get_cost_tracker + usage = current_recall_usage.get() + if usage is not None: + usage.add_embedding(tokens, cost or 0.0) + bucket = BUCKET_MEMORY_PREFRONTAL_EMBEDDINGS + else: + bucket = BUCKET_MEMORY_EMBEDDINGS + tracker = get_cost_tracker() # Embeddings have no output tokens - all tokens are input tracker.add_external_call( - BUCKET_MEMORY_EMBEDDINGS, + bucket, cost=cost or 0.0, tokens=tokens, calls=1, diff --git a/src/codespy/agents/memory/hippocampus/episode.py b/src/codespy/agents/memory/hippocampus/episode.py index 2d7b061..67cbfc8 100644 --- a/src/codespy/agents/memory/hippocampus/episode.py +++ b/src/codespy/agents/memory/hippocampus/episode.py @@ -1,16 +1,25 @@ -"""Episode record and persistence helpers for Hippocampus.""" +"""Episode record and persistence helpers for Hippocampus. + +Background episode saves (retain + consolidation) are tracked as non-daemon threads. +The module registers an at-exit hook that runs BEFORE thread pool executors are +shut down, ensuring saves complete before the Python interpreter destroys the +executors. This prevents "cannot schedule new futures after shutdown" errors +from embedding calls during Cerebral consolidation. +""" from __future__ import annotations +import concurrent.futures.thread # noqa: F401 - ensures thread module loaded import logging import threading import uuid from collections.abc import Callable -from datetime import UTC, datetime +from datetime import datetime from pydantic import BaseModel, Field from codespy.agents.memory.hippocampus.context_memory import ContextMemory, Mutation +from codespy.agents.memory.recall import RecallRecord logger = logging.getLogger(__name__) @@ -29,10 +38,18 @@ def submit_episode_save(target: Callable[[], object], *, name: str = "episode-save") -> None: """Start *target* in a non-daemon background thread and track it. - The thread is **non-daemon**: even without an explicit - ``join_episode_saves()`` call, the Python interpreter will wait for it - to finish before exiting. This guarantees episode persistence when the - main thread returns quickly (e.g. CLI publishes the review and exits). + The thread is **non-daemon**, so the Python interpreter will wait for it at + exit. However, non-daemon alone is not enough: atexit handlers run BEFORE + non-daemon threads are joined, and concurrent.futures registers a handler + that shuts down thread pools. Cerebral consolidation uses those pools for + embeddings, so if the main thread exits first, embedding calls fail with + "cannot schedule new futures after shutdown". + + To prevent this, episode.py registers its own at-exit hook via + threading._register_atexit(_join_episode_saves_at_exit). Hooks run in + reverse order, so this hook runs BEFORE the executor-shutdown hook. + Therefore, saves finish while the executors are still alive, even when + ReviewPipeline.forward() raised or the caller never joined explicitly. Args: target: A zero-arg callable that performs the episode save. @@ -44,23 +61,59 @@ def submit_episode_save(target: Callable[[], object], *, name: str = "episode-sa t.start() -def join_episode_saves(timeout_per_thread: float = 120) -> None: +def join_episode_saves(timeout_per_thread: float | None = None) -> None: """Block until every tracked episode-save thread has finished. Called once at the end of ``ReviewPipeline.forward()`` to guarantee episodes are persisted before the process may exit. Args: - timeout_per_thread: Max seconds to wait per thread (default 120). - If a thread exceeds the timeout it is abandoned with a warning. + timeout_per_thread: Max seconds to wait per thread (default None = wait + forever). If a timeout is given and a thread exceeds it, the thread + is abandoned with a warning. If the timeout is None, we wait + indefinitely for all saves to complete. """ - with _save_lock: - threads = list(_save_threads) - _save_threads.clear() - for t in threads: - t.join(timeout=timeout_per_thread) - if t.is_alive(): - logger.warning("Episode save thread %r did not finish in time", t.name) + while True: + with _save_lock: + # Take a snapshot and clear the registry for the next iteration + threads = list(_save_threads) + _save_threads.clear() + + if not threads: + # No more saves in flight + break + + # Log once if we're about to wait for any live threads + alive_count = sum(1 for t in threads if t.is_alive()) + if alive_count: + logger.info("waiting for %d background episode save(s)", alive_count) + + for t in threads: + t.join(timeout=timeout_per_thread) + if t.is_alive() and timeout_per_thread is not None: + # Only warn when a timeout was specified + logger.warning("Episode save thread %r did not finish in time", t.name) + + +def _join_episode_saves_at_exit() -> None: + """At-exit hook: join episode saves before thread pool executors shut down. + + Runs before concurrent.futures.thread._python_exit thanks to atexit hook + ordering (reverse registration order). This ensures Cerebral consolidations + that rely on ThreadPoolExecutor can still submit embedding tasks. + """ + try: + join_episode_saves() + except BaseException: + # Log at debug level so Ctrl+C during exit doesn't print a traceback + logger.debug("Exception during episode save join at exit", exc_info=True) + + +# Register the at-exit hook to run BEFORE thread pool executors are shut down. +# This runs after concurrent.futures.thread is imported (at module top). +_register_atexit = getattr(threading, "_register_atexit", None) +if _register_atexit is not None: + _register_atexit(_join_episode_saves_at_exit) class Episode(BaseModel): @@ -93,6 +146,11 @@ class Episode(BaseModel): ``ReviewPipeline.forward()`` call, so all episodes from one review run can be correlated. mutations: Ordered sequence of Cartographer mutations applied during this episode. + recalls: Prefrontal recalls made during the agent call (pre-call load and + ``recall_memory`` tool calls), with the exact text the agent received, + model, token usage and cost. For monitoring only: persisted to the + ``recalls`` table, never given to the Distiller/Cartographer and never + retained into Cerebral. """ id: uuid.UUID = Field(description="Unique episode identifier (caller-provided)") @@ -123,3 +181,10 @@ class Episode(BaseModel): default_factory=list, description="Ordered sequence of Cartographer mutations applied during this episode", ) + recalls: list[RecallRecord] = Field( + default_factory=list, + description=( + "Prefrontal recalls of this agent call " + "(monitoring only; never distilled or retained)" + ), + ) diff --git a/src/codespy/agents/memory/hippocampus/hippocampus.py b/src/codespy/agents/memory/hippocampus/hippocampus.py index 704d828..47499c7 100644 --- a/src/codespy/agents/memory/hippocampus/hippocampus.py +++ b/src/codespy/agents/memory/hippocampus/hippocampus.py @@ -5,6 +5,7 @@ import asyncio import logging import uuid +from collections.abc import Sequence from datetime import UTC, datetime from typing import TYPE_CHECKING @@ -33,6 +34,7 @@ if TYPE_CHECKING: from codespy.agents.memory.postgres import EpisodeStore + from codespy.agents.memory.recall import RecallRecord logger = logging.getLogger(__name__) @@ -238,13 +240,19 @@ def _consolidate(self) -> str | None: ) return combined - def _finalize_episode(self, artifacts: dict[str, str] | None = None) -> None: + def _finalize_episode( + self, + artifacts: dict[str, str] | None = None, + recalls: Sequence[RecallRecord] | None = None, + ) -> None: """Record the consolidated Episode snapshot and clear the buffer. Args: artifacts: Named output artifacts to attach to the recorded episode (e.g. ``{"review": ""}``). Defaults to an empty dict when omitted. + recalls: Prefrontal recall records to attach (monitoring only; + never passed to the Distiller/Cartographer). """ self.episode = Episode( id=uuid.uuid4(), @@ -256,6 +264,7 @@ def _finalize_episode(self, artifacts: dict[str, str] | None = None) -> None: artifacts=artifacts or {}, run_id=self._run_id, mutations=list(self._mutations), + recalls=list(recalls or []), ) self._episode_trajectories.clear() self._episode_question = None @@ -266,6 +275,7 @@ def end_episode( self, store: EpisodeStore | None = None, artifacts: dict[str, str] | None = None, + recalls: Sequence[RecallRecord] | None = None, ) -> None: """Consolidate the buffered trajectories into the memory and record an Episode snapshot. @@ -287,6 +297,9 @@ def end_episode( episode (e.g. ``{"review": ""}``). Agent-agnostic — any caller can attach whatever markdown/text output it produced under a key of its choosing. + recalls: Prefrontal recall records of this agent call, stored on + the Episode and persisted to the ``recalls`` table. Never + given to the Distiller/Cartographer. Raises: OSError: If persistence is requested and the write fails. @@ -294,7 +307,7 @@ def end_episode( combined = self._consolidate() if combined is None: return - self._finalize_episode(artifacts) + self._finalize_episode(artifacts, recalls) if store is not None: store.save_episode(self.episode) @@ -302,6 +315,7 @@ async def aend_episode( self, store: EpisodeStore | None = None, artifacts: dict[str, str] | None = None, + recalls: Sequence[RecallRecord] | None = None, ) -> None: """Async counterpart of :meth:`end_episode`. @@ -313,11 +327,12 @@ async def aend_episode( store: Optional ``EpisodeStore`` to persist the episode after consolidation. artifacts: Named output artifacts to attach to the recorded episode (e.g. ``{"review": ""}``). + recalls: Prefrontal recall records (see :meth:`end_episode`). """ combined = await asyncio.to_thread(self._consolidate) if combined is None: return - await asyncio.to_thread(self._finalize_episode, artifacts) + await asyncio.to_thread(self._finalize_episode, artifacts, recalls) if store is not None: await asyncio.to_thread(store.save_episode, self.episode) diff --git a/src/codespy/agents/memory/postgres.py b/src/codespy/agents/memory/postgres.py index 65d180c..2e05a15 100644 --- a/src/codespy/agents/memory/postgres.py +++ b/src/codespy/agents/memory/postgres.py @@ -7,6 +7,7 @@ from psycopg import sql from psycopg.rows import dict_row +from psycopg.types.json import Jsonb from psycopg_pool import ConnectionPool if TYPE_CHECKING: @@ -219,6 +220,38 @@ def ensure_schema(self) -> None: ) """) + # Recalls table: one row per Prefrontal recall (monitoring only; + # never read back into memory, never retained into Cerebral). + cur.execute(""" + CREATE TABLE IF NOT EXISTS recalls ( + bank_id VARCHAR(64) NOT NULL, + episode_id UUID NOT NULL, + ordinal INT NOT NULL, + kind VARCHAR(8) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + query TEXT NOT NULL DEFAULT '', + reach VARCHAR(8) NOT NULL, + reflects INT NOT NULL DEFAULT 0, + status VARCHAR(8) NOT NULL, + text TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + llm_calls INT NOT NULL DEFAULT 0, + input_tokens INT NOT NULL DEFAULT 0, + output_tokens INT NOT NULL DEFAULT 0, + input_cost DOUBLE PRECISION NOT NULL DEFAULT 0, + output_cost DOUBLE PRECISION NOT NULL DEFAULT 0, + latency_ms INT NOT NULL DEFAULT 0, + details JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (bank_id, episode_id, ordinal), + FOREIGN KEY (bank_id, episode_id) + REFERENCES episodes(bank_id, id) ON DELETE CASCADE + ) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS idx_recalls_time + ON recalls (bank_id, timestamp DESC) + """) + conn.commit() def verify_access(self) -> None: @@ -517,13 +550,47 @@ def save_episode(self, episode: Episode) -> None: (self.bank_id, str(episode.id), name, content), ) + # 8. Insert recalls (Prefrontal monitoring) + for rec in episode.recalls or []: + cur.execute( + """ + INSERT INTO recalls + (bank_id, episode_id, ordinal, kind, timestamp, query, reach, + reflects, status, text, model, llm_calls, input_tokens, + output_tokens, input_cost, output_cost, latency_ms, details) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (bank_id, episode_id, ordinal) DO NOTHING + """, + ( + self.bank_id, + str(episode.id), + rec.ordinal, + rec.kind, + rec.timestamp, + rec.query, + rec.reach, + rec.reflects, + rec.status, + rec.text, + rec.model, + rec.llm_calls, + rec.input_tokens, + rec.output_tokens, + rec.input_cost, + rec.output_cost, + rec.latency_ms, + Jsonb(rec.details), + ), + ) + conn.commit() logger.info( - "save_episode: persisted episode %s (bank=%s, task=%s, topics=%d, observations=%d)", + "save_episode: persisted episode %s (bank=%s, task=%s, topics=%d, observations=%d, recalls=%d)", episode.id, self.bank_id, episode.task, len(episode.context_memory.topics), len(episode.context_memory.all_observations()), + len(episode.recalls or []), ) def load_context( diff --git a/src/codespy/agents/memory/prefrontal/__init__.py b/src/codespy/agents/memory/prefrontal/__init__.py new file mode 100644 index 0000000..50e3086 --- /dev/null +++ b/src/codespy/agents/memory/prefrontal/__init__.py @@ -0,0 +1,46 @@ +"""Prefrontal – semantic recall from Cerebral into agent context.""" + +from codespy.agents.memory.prefrontal.facets import run_facets +from codespy.agents.memory.prefrontal.format import BeliefChange, FacetResult, format_context +from codespy.agents.memory.prefrontal.prefrontal import ( + MEMORY_UNAVAILABLE, + NO_RELEVANT_MEMORY, + PREFRONTAL_FIELD, + PREFRONTAL_FIELD_DESC, + RECALL_LIMIT_REACHED, + Prefrontal, + with_prefrontal_memory, +) +from codespy.agents.memory.prefrontal.query import Facet, build_facets +from codespy.agents.memory.prefrontal.reach import ( + REACHES, + build_local_groups, + build_remote_groups, + clamp_reach, + mental_model_id, + mental_model_ids, + org_of, +) + +__all__ = [ + "BeliefChange", + "Facet", + "FacetResult", + "MEMORY_UNAVAILABLE", + "NO_RELEVANT_MEMORY", + "PREFRONTAL_FIELD", + "PREFRONTAL_FIELD_DESC", + "Prefrontal", + "REACHES", + "RECALL_LIMIT_REACHED", + "build_facets", + "build_local_groups", + "build_remote_groups", + "clamp_reach", + "format_context", + "mental_model_id", + "mental_model_ids", + "org_of", + "run_facets", + "with_prefrontal_memory", +] diff --git a/src/codespy/agents/memory/prefrontal/facets.py b/src/codespy/agents/memory/prefrontal/facets.py new file mode 100644 index 0000000..2dd058f --- /dev/null +++ b/src/codespy/agents/memory/prefrontal/facets.py @@ -0,0 +1,213 @@ +"""Run the pre-call facets against Cerebral.""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from codespy.agents.memory.prefrontal.format import ( + BeliefChange, + FacetResult, + estimate_tokens, + fget, + render_change, + render_fact, +) +from codespy.agents.memory.prefrontal.query import ( + FACET_BELIEF_CHANGES, + FACET_CONTEXT, + FACET_DECISIONS, + FACET_PATTERNS, + FACET_SEEN_BEFORE, + Facet, +) + +if TYPE_CHECKING: + from codespy.agents.memory.cerebral import Cerebral + +logger = logging.getLogger(__name__) + +# belief_changes: observations taken from facets 2–4, and history entries kept per observation. +BELIEF_TOP_OBSERVATIONS = 3 +BELIEF_HISTORY_ENTRIES = 2 +BELIEF_SOURCE_FACETS: tuple[str, ...] = (FACET_SEEN_BEFORE, FACET_DECISIONS, FACET_PATTERNS) +CHANGE_MARKERS: tuple[str, ...] = ("RETRACTED", "supersedes:") + + +def facet_tokens(max_tokens: int, facet: Facet) -> int: + """Token budget of one facet: its fixed share of the pre-call budget.""" + return max(1, int(max_tokens * facet.share)) + + +async def _run_recall_facet( + cerebral: Cerebral, + facet: Facet, + *, + tag_groups: list[Any], + max_tokens: int, + budget: str, + question_date: datetime | None, + reflects: int, + reflect_context: str | None, +) -> FacetResult: + if not facet.query: + return FacetResult(facet.name) + tokens = facet_tokens(max_tokens, facet) + try: + if reflects > 0 and facet.name == FACET_CONTEXT: + text, summary = await cerebral.areflect( + facet.query, + tag_groups=tag_groups, + max_tokens=tokens, + context=reflect_context, + ) + return FacetResult(facet.name, text=text or "", reflect_summary=summary) + facts = await cerebral.arecall( + facet.query, + tag_groups=tag_groups, + max_tokens=tokens, + fact_type=list(facet.fact_types) if facet.fact_types else None, + prefer_observations=facet.prefer_observations, + budget=budget, + question_date=question_date, + ) + return FacetResult(facet.name, facts=list(facts or [])) + except Exception: + logger.warning("Prefrontal: facet %s failed", facet.name, exc_info=True) + return FacetResult(facet.name, failed=True) + + +def _is_change_fact(fact: Any) -> bool: + if fget(fact, "fact_type") != "world": + return False + text = fget(fact, "text") or "" + return any(marker in text for marker in CHANGE_MARKERS) + + +async def _belief_changes( + cerebral: Cerebral, + results: Mapping[str, FacetResult], + facet: Facet, + max_tokens: int, +) -> FacetResult: + """Facet 5: what did we believe before, and what changed? + + No recall and no embedding: reads the history of the top observations + of facets 2–4 and moves ``RETRACTED``/``supersedes:`` world facts out + of facets 1–4 into this section (so the merge's by-id dedup does not + drop them). Capped at the facet's share. + """ + out = FacetResult(facet.name) + budget = facet_tokens(max_tokens, facet) + + # Top observations from facets 2–4, by facet order then result order. + observations: list[Any] = [] + seen: set[str] = set() + for name in BELIEF_SOURCE_FACETS: + for fact in (results.get(name) or FacetResult(name)).facts: + fid = fget(fact, "id") + if fget(fact, "fact_type") == "observation" and fid and fid not in seen: + seen.add(fid) + observations.append(fact) + observations = observations[:BELIEF_TOP_OBSERVATIONS] + + async def _history(obs: Any) -> list[dict]: + try: + return await cerebral.aget_observation_history(fget(obs, "id")) + except Exception: + logger.debug( + "Prefrontal: observation history failed for %s", fget(obs, "id"), exc_info=True + ) + return [] + + histories = await asyncio.gather(*(_history(o) for o in observations)) + + used = 0 + full = False + for obs, history in zip(observations, histories, strict=True): + if full: + break + for i, entry in enumerate((history or [])[:BELIEF_HISTORY_ENTRIES]): + previous = (entry or {}).get("previous_text") + if not previous: + continue + changed = (entry or {}).get("changed_at") + change = BeliefChange( + id=f"history:{fget(obs, 'id')}:{i}", + current=fget(obs, "text") or "", + previous=previous, + changed_at=str(changed)[:10] if changed else None, + ) + cost = estimate_tokens(render_change(change)) + if used + cost > budget: + full = True + break + used += cost + out.changes.append(change) + + # Move RETRACTED / supersedes: world facts out of facets 1–4 (those that + # do not fit the share stay in their source facet). + for name in (FACET_CONTEXT, *BELIEF_SOURCE_FACETS): + source = results.get(name) + if source is None: + continue + kept: list[Any] = [] + for fact in source.facts: + if _is_change_fact(fact): + cost = estimate_tokens(render_fact(fact)) + if used + cost <= budget: + used += cost + out.facts.append(fact) + continue + kept.append(fact) + source.facts = kept + return out + + +async def run_facets( + cerebral: Cerebral, + facets: Sequence[Facet], + *, + tag_groups: list[Any], + max_tokens: int, + budget: str = "mid", + question_date: datetime | None = None, + reflects: int = 0, + reflect_context: str | None = None, +) -> dict[str, FacetResult]: + """Run facets 1–4 concurrently, then facet 5 from their observations. + + With ``reflects > 0`` only the ``context`` facet runs Hindsight's reflect + loop; the others stay raw recall. Each facet fails on its own and yields + an empty section. + """ + recall_facets = [f for f in facets if f.name != FACET_BELIEF_CHANGES] + gathered = await asyncio.gather( + *( + _run_recall_facet( + cerebral, + f, + tag_groups=tag_groups, + max_tokens=max_tokens, + budget=budget, + question_date=question_date, + reflects=reflects, + reflect_context=reflect_context, + ) + for f in recall_facets + ) + ) + results: dict[str, FacetResult] = {r.name: r for r in gathered} + belief = next((f for f in facets if f.name == FACET_BELIEF_CHANGES), None) + if belief is not None: + try: + results[FACET_BELIEF_CHANGES] = await _belief_changes( + cerebral, results, belief, max_tokens + ) + except Exception: + logger.warning("Prefrontal: facet %s failed", FACET_BELIEF_CHANGES, exc_info=True) + results[FACET_BELIEF_CHANGES] = FacetResult(FACET_BELIEF_CHANGES, failed=True) + return results diff --git a/src/codespy/agents/memory/prefrontal/format.py b/src/codespy/agents/memory/prefrontal/format.py new file mode 100644 index 0000000..8e9806d --- /dev/null +++ b/src/codespy/agents/memory/prefrontal/format.py @@ -0,0 +1,206 @@ +"""Render Prefrontal context as one deterministic plain-text block.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any + +from codespy.agents.memory.prefrontal.query import ( + FACET_BELIEF_CHANGES, + FACET_CONTEXT, + FACET_DECISIONS, + FACET_ORDER, + FACET_PATTERNS, + FACET_SEEN_BEFORE, +) +from codespy.agents.memory.prefrontal.reach import ( + REACH_BANK, + TAG_REPO, + TAG_TASK, + org_of, + tag_value, +) + +# Content a mental model holds until its first refresh completes (kept in sync +# with cerebral.MENTAL_MODEL_PLACEHOLDER; not imported to avoid loading Hindsight). +MENTAL_MODEL_PLACEHOLDER = "Generating content..." + +SECTION_TITLES: dict[str, str] = { + FACET_CONTEXT: "Around this work", + FACET_SEEN_BEFORE: "Seen before", + FACET_DECISIONS: "Decisions", + FACET_PATTERNS: "Recurring patterns", + FACET_BELIEF_CHANGES: "What changed", +} + + +@dataclass(frozen=True) +class BeliefChange: + """One past wording of an observation.""" + + id: str + current: str + previous: str + changed_at: str | None = None + + +@dataclass +class FacetResult: + """Outcome of one facet. ``text`` holds a reflect answer (reflects > 0).""" + + name: str + facts: list[Any] = field(default_factory=list) + text: str = "" + changes: list[BeliefChange] = field(default_factory=list) + failed: bool = False + reflect_summary: Any = None # ReflectSummary from areflect, when reflects > 0 + + @property + def count(self) -> int: + return len(self.facts) + len(self.changes) + (1 if self.text else 0) + + +def estimate_tokens(text: str) -> int: + """Cheap token estimate (~4 chars per token), no tokenizer call.""" + return (len(text or "") + 3) // 4 + + +def fget(fact: Any, name: str, default: Any = None) -> Any: + """Read a field from a MemoryFact-like object or a dict.""" + if isinstance(fact, Mapping): + return fact.get(name, default) + return getattr(fact, name, default) + + +def _clean(text: str | None) -> str: + return " ".join((text or "").split()) + + +def fact_date(fact: Any) -> str | None: + """``YYYY-MM-DD`` of when the fact occurred, else when it was learned.""" + raw = fget(fact, "occurred_start") or fget(fact, "mentioned_at") + if not raw: + return None + return str(raw)[:10] + + +def render_fact(fact: Any, *, with_repo: bool = False) -> str: + """Render one fact as a bullet. + + - observation: ``- [observation] `` (``[repo · observation]`` for remote) + - otherwise: ``- [ · ] `` (``[repo · date · task]`` for remote) + """ + tags = fget(fact, "tags") or [] + parts: list[str] = [] + if with_repo: + repo = tag_value(tags, TAG_REPO) + if repo: + parts.append(repo) + if fget(fact, "fact_type") == "observation": + parts.append("observation") + else: + date = fact_date(fact) + if date: + parts.append(date) + task = tag_value(tags, TAG_TASK) + if task: + parts.append(task) + text = _clean(fget(fact, "text")) + label = f"[{' · '.join(parts)}] " if parts else "" + return f"- {label}{text}" + + +def render_change(change: BeliefChange) -> str: + """``- (was: , changed )``.""" + when = f", changed {change.changed_at}" if change.changed_at else "" + return f"- {_clean(change.current)} (was: {_clean(change.previous)}{when})" + + +def _briefing_sections(briefings: Iterable[Mapping[str, Any]]) -> list[str]: + sections: list[str] = [] + for mm in briefings or []: + content = (mm.get("content") or "").strip() + if not content or content == MENTAL_MODEL_PLACEHOLDER: + continue + name = (mm.get("name") or "Briefing").strip() + if not name.startswith("Briefing"): + name = f"Briefing: {name}" + sections.append(f"## {name}\n{content}") + return sections + + +class _Dedup: + """Keeps the first occurrence: by id, then by exact text.""" + + def __init__(self) -> None: + self.ids: set[str] = set() + self.texts: set[str] = set() + + def admit(self, key_id: str | None, text: str) -> bool: + text = _clean(text) + if key_id and key_id in self.ids: + return False + if text and text in self.texts: + return False + if key_id: + self.ids.add(key_id) + if text: + self.texts.add(text) + return True + + +def format_context( + briefings: Sequence[Mapping[str, Any]], + facet_results: Mapping[str, FacetResult], + remote_facts: Sequence[Any], + repo: str, + reach: str = "org", +) -> str: + """Render briefings, facet sections (order 1 → 5) and remote facts. + + Facts are deduplicated across sections by id, then by exact text, keeping + the first occurrence. Empty sections are omitted; nothing at all → ``""``. + """ + sections = _briefing_sections(briefings) + dedup = _Dedup() + + for name in FACET_ORDER: + result = facet_results.get(name) + if result is None: + continue + lines: list[str] = [] + if result.text: + lines.append(result.text.strip()) + for fact in result.facts: + if dedup.admit(fget(fact, "id"), fget(fact, "text") or ""): + lines.append(render_fact(fact)) + for change in result.changes: + if dedup.admit(change.id, f"{change.current} (was: {change.previous})"): + lines.append(render_change(change)) + if lines: + sections.append(f"## {SECTION_TITLES[name]}\n" + "\n".join(lines)) + + remote_lines = [ + render_fact(fact, with_repo=True) + for fact in remote_facts or [] + if dedup.admit(fget(fact, "id"), fget(fact, "text") or "") + ] + if remote_lines: + where = "" if reach == REACH_BANK else f" in {org_of(repo)}" + sections.append( + f"## Other repositories{where} — verify before relying on it\n" + + "\n".join(remote_lines) + ) + + return "\n\n".join(sections) + + +def format_facts(facts: Sequence[Any], *, with_repo: bool = False) -> str: + """Render a flat, deduplicated bullet list (used by the recall tool).""" + dedup = _Dedup() + return "\n".join( + render_fact(f, with_repo=with_repo) + for f in facts or [] + if dedup.admit(fget(f, "id"), fget(f, "text") or "") + ) diff --git a/src/codespy/agents/memory/prefrontal/prefrontal.py b/src/codespy/agents/memory/prefrontal/prefrontal.py new file mode 100644 index 0000000..0ce0252 --- /dev/null +++ b/src/codespy/agents/memory/prefrontal/prefrontal.py @@ -0,0 +1,590 @@ +"""Prefrontal – hands useful prior knowledge from Cerebral to an agent. + +Reads Cerebral (the Hindsight bank) and never writes to it. Delivery is +hybrid: + +1. a pre-call context injected as the read-only ``prefrontal_memory`` input + (mental-model briefings, then five facets, then remote facts), and +2. a ``recall_memory`` tool the RLM agents can call for follow-ups. + +Prefrontal and Hippocampus never feed each other: the Distiller and the +Cartographer never see ``prefrontal_memory``. + +Every failure logs a warning and degrades to less context, or to ``""``. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import logging +import time +from collections.abc import Callable, Sequence +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any, Literal + +import dspy # type: ignore[import-untyped] + +from codespy.agents.memory.prefrontal.facets import run_facets +from codespy.agents.memory.prefrontal.format import ( + MENTAL_MODEL_PLACEHOLDER, + estimate_tokens, + fget, + format_context, + format_facts, +) +from codespy.agents.memory.prefrontal.query import ( + FACET_BELIEF_CHANGES, + FACET_CONTEXT, + FACET_DECISIONS, + FACET_PATTERNS, + FACET_SEEN_BEFORE, + Facet, + truncate_words, +) +from codespy.agents.memory.prefrontal.reach import ( + REACH_LOCAL, + build_local_groups, + build_reflect_groups, + build_remote_groups, + clamp_reach, + is_own_repo_fact, + mental_model_ids, +) +from codespy.agents.memory.recall import RecallRecord, RecallUsage, track_recall_usage + +if TYPE_CHECKING: + from codespy.agents.memory.cerebral import Cerebral + from codespy.config_memory import PrefrontalConfig + +logger = logging.getLogger(__name__) + +PREFRONTAL_FIELD = "prefrontal_memory" +PREFRONTAL_FIELD_DESC = ( + "Prior knowledge from earlier runs (read-only, may be stale or wrong). " + "Use it to skip rediscovery and to direct tool calls. " + "Verify anything you report as an issue with tools; never report an issue from memory alone." +) + +RECALL_LIMIT_REACHED = "recall limit reached" +MEMORY_UNAVAILABLE = "memory unavailable" +NO_RELEVANT_MEMORY = "no relevant memory" +REMOTE_REFLECT_SUFFIX = ( + " For every fact that comes from another repository, name its source repository." +) +REMOTE_TOOL_HEADER = "Other repositories — verify before relying on it:" +# Max query chars in the per-recall INFO log line. +LOG_QUERY_CHARS = 120 + +RecallStatus = Literal["ok", "empty", "limit", "error"] + + +def _tool_status(text: str) -> RecallStatus: + """Classify a recall_memory result string.""" + if text == RECALL_LIMIT_REACHED: + return "limit" + if text == NO_RELEVANT_MEMORY: + return "empty" + if text == MEMORY_UNAVAILABLE: + return "error" + return "ok" + +_SIGNATURES: dict[type, type] = {} + + +def with_prefrontal_memory(signature: type[dspy.Signature]) -> type[dspy.Signature]: + """Return ``signature`` with a leading ``prefrontal_memory: str`` input (idempotent). + + Must be applied before the module is built: ``dspy.RLM`` builds its + predictors from the signature in ``__init__``. Use the same returned + signature for ``ContextSafe`` so its RLM fallback has the field too. + """ + if PREFRONTAL_FIELD in signature.input_fields: + return signature + cached = _SIGNATURES.get(signature) + if cached is not None: + return cached + injected = signature.prepend( + PREFRONTAL_FIELD, + dspy.InputField(desc=PREFRONTAL_FIELD_DESC), + type_=str, + ) + _SIGNATURES[signature] = injected + return injected + + +class Prefrontal: + """Per-agent-call reader of Cerebral. + + Args: + settings: ``memory.prefrontal`` config. + cerebral: The Cerebral instance to read. + task_name: Agent task (e.g. ``code_review``). + repo_full_name: ``owner/repo``. + scope_topic_ids: ``project_scope`` topic ids of the agent's scope(s). + include_repo: Also read repo-level facts (``repo:``) and briefing. + """ + + def __init__( + self, + settings: PrefrontalConfig, + cerebral: Cerebral, + task_name: str, + repo_full_name: str, + scope_topic_ids: Sequence[str] | None = None, + include_repo: bool = False, + ) -> None: + self._cfg = settings + self._cerebral = cerebral + self._task = task_name + self._repo = repo_full_name + self._scope_ids = [s for s in (scope_topic_ids or []) if s] + self._include_repo = include_repo + self._tool_calls = 0 + # One record per recall (pre-call load and each tool call), for monitoring. + # Kept across aload() calls: one Prefrontal serves one agent call. + self._records: list[RecallRecord] = [] + + @property + def recalls(self) -> list[RecallRecord]: + """Recall records so far (a copy). Persisted with the episode, never distilled.""" + return list(self._records) + + def _record( + self, + *, + kind: Literal["load", "tool"], + query: str, + reach: str, + started_at: datetime, + t0: float, + text: str, + status: RecallStatus, + usage: RecallUsage, + details: dict[str, Any], + ) -> RecallRecord | None: + """Append one RecallRecord. Never raises.""" + try: + rec = RecallRecord( + ordinal=len(self._records), + kind=kind, + timestamp=started_at, + query=query, + reach=reach, + reflects=self._cfg.reflects, + status=status, + text=text, + model=usage.to_model_field(), + llm_calls=usage.llm_calls, + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + input_cost=usage.input_cost, + output_cost=usage.output_cost, + latency_ms=int((time.monotonic() - t0) * 1000), + details=details, + ) + self._records.append(rec) + return rec + except Exception: + logger.debug("Prefrontal[%s]: recall record failed", self._task, exc_info=True) + return None + + # Remote share constant: fraction of token budget for other repos when reach is org/bank + REMOTE_SHARE = 0.25 + # Recall budget constant: fixed at "mid" for raw recall + RECALL_BUDGET = "mid" + + @property + def reach(self) -> str: + return self._cfg.prefrontal_reach + + @property + def reflects(self) -> int: + return self._cfg.reflects + + def _split_tokens(self, total: int, reach: str) -> tuple[int, int]: + """Split a token budget into (local, remote) by ``REMOTE_SHARE``. + + The remote share only applies when ``reach`` has a remote pool; otherwise + the local pool gets the whole budget. + """ + if build_remote_groups(self._repo, reach) is None: + return total, 0 + remote = int(total * self.REMOTE_SHARE) + return total - remote, remote + + def _reflect_context(self) -> str: + if len(self._scope_ids) == 1 and not self._include_repo: + target = self._scope_ids[0] + if not target.startswith(self._repo): + target = f"{self._repo}/{target}" + else: + target = self._repo + return f"{self._task} on {target}" + + # ------------------------------------------------------------------ + # Pre-call context + # ------------------------------------------------------------------ + + def load(self, facets: Sequence[Facet]) -> str: + """Blocking variant of :meth:`aload` (for the sync ChainOfThought agents).""" + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(self.aload(facets)) + # A loop is running in this thread: run on a helper thread instead. + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(asyncio.run, self.aload(facets)).result() + + async def aload(self, facets: Sequence[Facet]) -> str: + """Build the pre-call context: briefings, facets 1 → 5, then remote facts. + + Records one ``kind="load"`` RecallRecord. Usage tracking is entered here, + inside the coroutine, so it also works for :meth:`load`, whose helper + thread does not copy the caller's context. + """ + self._tool_calls = 0 + facet_list = list(facets) + context_query = next((f.query for f in facet_list if f.name == FACET_CONTEXT), "") + started_at = datetime.now(UTC) + t0 = time.monotonic() + text = "" + status: RecallStatus = "error" + details: dict[str, Any] = {} + with track_recall_usage() as usage: + try: + text, details = await self._aload(facet_list) + status = "ok" if text else "empty" + except Exception: + logger.warning("Prefrontal[%s]: load failed", self._task, exc_info=True) + text = "" + rec = self._record( + kind="load", + query=context_query, + reach=self._cfg.prefrontal_reach, + started_at=started_at, + t0=t0, + text=text, + status=status, + usage=usage, + details=details, + ) + facet_counts = details.get("facets", {}) + + def _n(name: str) -> int: + return int((facet_counts.get(name) or {}).get("count", 0)) + + # Reflect signals for monitoring split synthesis and quality + reflect_info = details.get("reflect") or {} + maps = reflect_info.get("map_calls", 0) + rewrite = "rewrite" if reflect_info.get("rewrite") else "" + thoughts = reflect_info.get("usage", {}).get("thoughts_tokens", 0) + llm_calls = usage.llm_calls + expected_max = self._cfg.reflects + 1 # +1 for potential rewrite + + # Warn if split synthesis occurred (map_calls > 0) or calls exceeded expected max + if maps > 0: + logger.warning("Prefrontal[%s]: split synthesis detected (maps=%d)", self._task, maps) + if llm_calls > expected_max: + logger.warning( + "Prefrontal[%s]: reflect used %d LLM calls (> %d): maps=%d rewrite=%s tools=%s", + self._task, + llm_calls, + expected_max, + maps, + rewrite or "no", + ",".join(f"{name}:{size}" for name, size in reflect_info.get("tools", [])) or "-", + ) + + # Per-tool sizes for context calibration (e.g., "search_observations:1830,recall:2410") + tool_sizes = reflect_info.get("tools", []) + tools_str = ",".join(f"{name}:{size}" for name, size in tool_sizes) if tool_sizes else "-" + + logger.info( + "Prefrontal[%s]: load status=%s %d briefings, facets context=%d seen_before=%d " + "decisions=%d patterns=%d belief_changes=%d, remote=%d (%d tokens, reach=%s, " + "reflects=%d) model=%s calls=%d in=%d out=%d cost=$%.4f latency=%dms maps=%d%s thoughts=%d tools=%s", + self._task, + status, + int(details.get("briefings", 0)), + _n(FACET_CONTEXT), + _n(FACET_SEEN_BEFORE), + _n(FACET_DECISIONS), + _n(FACET_PATTERNS), + _n(FACET_BELIEF_CHANGES), + int(details.get("remote", 0)), + estimate_tokens(text), + self._cfg.prefrontal_reach, + self._cfg.reflects, + usage.to_model_field() or "-", + llm_calls, + usage.input_tokens, + usage.output_tokens, + usage.input_cost + usage.output_cost, + rec.latency_ms if rec else int((time.monotonic() - t0) * 1000), + maps, + f" {rewrite}" if rewrite else "", + thoughts, + tools_str, + ) + return text + + async def _aload(self, facets: list[Facet]) -> tuple[str, dict[str, Any]]: + """Return the rendered context and the load ``details`` for the RecallRecord.""" + cfg = self._cfg + now = datetime.now(UTC) + local_groups = build_local_groups(self._repo, self._scope_ids, self._include_repo) + context_query = next((f.query for f in facets if f.name == FACET_CONTEXT), "") + local_tokens, remote_tokens = self._split_tokens( + cfg.max_prefrontal_tokens, cfg.prefrontal_reach + ) + + async def _briefings() -> list[dict]: + if cfg.reflects <= 0: + return [] + ids = mental_model_ids(self._repo, self._scope_ids, self._include_repo) + if not ids: + return [] + try: + return await self._cerebral.aget_mental_models(ids) + except Exception: + logger.warning("Prefrontal[%s]: briefings failed", self._task, exc_info=True) + return [] + + async def _facets() -> dict: + if not local_groups: + return {} + return await run_facets( + self._cerebral, + facets, + tag_groups=local_groups, + max_tokens=local_tokens, + budget="mid", + question_date=now, + reflects=cfg.reflects, + reflect_context=self._reflect_context(), + ) + + def _extract_reflect_summary(results: dict) -> dict | None: + """Extract reflect summary from context facet for monitoring.""" + from codespy.agents.memory.prefrontal.query import FACET_CONTEXT + + ctx = results.get(FACET_CONTEXT) + if ctx is None or ctx.reflect_summary is None: + return None + s = ctx.reflect_summary + return { + "iterations": s.iterations, + "llm_calls": s.llm_calls, + "map_calls": s.map_calls, + "rewrite": s.rewrite, + "tools": s.tools, + "usage": s.usage, + "empty": s.empty, + } + + async def _remote() -> list[Any]: + groups = build_remote_groups(self._repo, cfg.prefrontal_reach) + if groups is None or remote_tokens <= 0 or not context_query: + return [] + try: + facts = await self._cerebral.arecall( + context_query, + tag_groups=groups, + max_tokens=remote_tokens, + fact_type=["world", "observation"], + prefer_observations=True, + budget=self.RECALL_BUDGET, + question_date=now, + ) + except Exception: + logger.warning("Prefrontal[%s]: remote recall failed", self._task, exc_info=True) + return [] + return [f for f in facts or [] if not is_own_repo_fact(fget(f, "tags"), self._repo)] + + briefings, results, remote = await asyncio.gather(_briefings(), _facets(), _remote()) + text = format_context(briefings, results, remote, self._repo, cfg.prefrontal_reach) + + n_briefings = sum( + 1 + for mm in briefings + if (mm.get("content") or "").strip() not in ("", MENTAL_MODEL_PLACEHOLDER) + ) + facet_details: dict[str, dict[str, Any]] = {} + for f in facets: + r = results.get(f.name) + facet_details[f.name] = { + "query": f.query, + "count": r.count if r is not None else 0, + "failed": bool(r.failed) if r is not None else False, + } + details: dict[str, Any] = { + "briefings": n_briefings, + "remote": len(remote), + "facets": facet_details, + "reflect": _extract_reflect_summary(results), + } + return text, details + + # ------------------------------------------------------------------ + # Tool + # ------------------------------------------------------------------ + + def recall_tool(self) -> Callable[..., Any] | None: + """Async ``recall_memory`` tool for RLM agents, or None when disabled.""" + if self._cfg.max_prefrontal_tool_calls <= 0: + return None + + async def recall_memory(query: str, reach: str = "local") -> str: + """Search prior knowledge learned in earlier runs (read-only, may be stale or wrong). + + Args: + query: What to look up, e.g. a file, symbol, package, problem or decision. + reach: "local" (this scope/repository), "org" (also other repositories + of the same owner) or "bank" (all repositories). Clamped to the + configured reach. + + Returns: + Matching facts as text. Verify with tools before reporting any issue. + """ + return await self._recall(query, reach) + + return recall_memory + + async def _recall(self, query: str, reach: str = "local") -> str: + """Run one recall_memory call and record it (every exit path, exactly once).""" + cfg = self._cfg + started_at = datetime.now(UTC) + t0 = time.monotonic() + # str(): RLM agents may pass non-string arguments; this must never raise. + q = truncate_words(str(query or "")) + effective = clamp_reach(str(reach or ""), cfg.prefrontal_reach) + details: dict[str, Any] = { + "requested_reach": str(reach or ""), + "mode": "reflect" if cfg.reflects > 0 else "recall", + } + with track_recall_usage() as usage: + if self._tool_calls >= cfg.max_prefrontal_tool_calls: + text = RECALL_LIMIT_REACHED + else: + self._tool_calls += 1 + text = await self._recall_impl(q, effective, details) + status = _tool_status(text) + rec = self._record( + kind="tool", + query=q, + reach=effective, + started_at=started_at, + t0=t0, + text=text, + status=status, + usage=usage, + details=details, + ) + logger.info( + "Prefrontal[%s]: recall_memory reach=%s status=%s mode=%s model=%s calls=%d " + "in=%d out=%d cost=$%.4f latency=%dms query=%r", + self._task, + effective, + status, + details["mode"], + usage.to_model_field() or "-", + usage.llm_calls, + usage.input_tokens, + usage.output_tokens, + usage.input_cost + usage.output_cost, + rec.latency_ms if rec else int((time.monotonic() - t0) * 1000), + q[:LOG_QUERY_CHARS], + ) + return text + + async def _recall_impl(self, q: str, effective: str, details: dict[str, Any]) -> str: + """recall_memory body. Fills fact counts into ``details`` (recall mode).""" + cfg = self._cfg + try: + if not q: + return NO_RELEVANT_MEMORY + now = datetime.now(UTC) + + if cfg.reflects > 0: + groups = build_reflect_groups( + self._repo, self._scope_ids, self._include_repo, effective + ) + if groups == []: + return NO_RELEVANT_MEMORY + prompt = q if effective == REACH_LOCAL else q + REMOTE_REFLECT_SUFFIX + text, summary = await self._cerebral.areflect( + prompt, + tag_groups=groups, + max_tokens=cfg.max_prefrontal_tool_tokens, + context=self._reflect_context(), + ) + details["reflect"] = { + "iterations": summary.iterations, + "llm_calls": summary.llm_calls, + "map_calls": summary.map_calls, + "rewrite": summary.rewrite, + "tools": summary.tools, + "usage": summary.usage, + "empty": summary.empty, + } + # Warn if calls exceeded expected max (reflects + 1 for potential rewrite) + expected_max = cfg.reflects + 1 + if summary.llm_calls > expected_max: + logger.warning( + "Prefrontal[%s]: reflect used %d LLM calls (> %d): maps=%d rewrite=%s tools=%s", + self._task, + summary.llm_calls, + expected_max, + summary.map_calls, + "yes" if summary.rewrite else "no", + ",".join(f"{name}:{size}" for name, size in summary.tools) or "-", + ) + # Warn if split synthesis occurred + if summary.map_calls > 0: + logger.warning( + "Prefrontal[%s]: split synthesis detected (maps=%d)", + self._task, + summary.map_calls, + ) + return text or NO_RELEVANT_MEMORY + + parts: list[str] = [] + local_tokens, remote_tokens = self._split_tokens( + cfg.max_prefrontal_tool_tokens, effective + ) + local_groups = build_local_groups(self._repo, self._scope_ids, self._include_repo) + if local_groups: + facts = await self._cerebral.arecall( + q, + tag_groups=local_groups, + max_tokens=local_tokens, + prefer_observations=True, + budget=self.RECALL_BUDGET, + question_date=now, + ) + details["local"] = len(facts or []) + parts.append(format_facts(facts)) + remote_groups = build_remote_groups(self._repo, effective) + if remote_groups is not None and remote_tokens > 0: + facts = await self._cerebral.arecall( + q, + tag_groups=remote_groups, + max_tokens=remote_tokens, + fact_type=["world", "observation"], + prefer_observations=True, + budget=self.RECALL_BUDGET, + question_date=now, + ) + facts = [ + f for f in facts or [] if not is_own_repo_fact(fget(f, "tags"), self._repo) + ] + details["remote"] = len(facts) + rendered = format_facts(facts, with_repo=True) + if rendered: + parts.append(f"{REMOTE_TOOL_HEADER}\n{rendered}") + text = "\n\n".join(p for p in parts if p) + return text or NO_RELEVANT_MEMORY + except Exception: + logger.warning("Prefrontal[%s]: recall_memory failed", self._task, exc_info=True) + return MEMORY_UNAVAILABLE diff --git a/src/codespy/agents/memory/prefrontal/query.py b/src/codespy/agents/memory/prefrontal/query.py new file mode 100644 index 0000000..b437901 --- /dev/null +++ b/src/codespy/agents/memory/prefrontal/query.py @@ -0,0 +1,161 @@ +"""Pre-call facet queries: five question-shaped recalls over the local pool. + +Mental models (briefings) are the abstraction layer ("when X happens, +consider Y because Z"). The facets are the semantic layer: they answer the +messier questions a briefing cannot, so they always run, even when +briefings exist. + +None of these queries is the Hippocampus ``question``. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +# Facet names, in merge/render order (1 → 5). +FACET_CONTEXT = "context" +FACET_SEEN_BEFORE = "seen_before" +FACET_DECISIONS = "decisions" +FACET_PATTERNS = "patterns" +FACET_BELIEF_CHANGES = "belief_changes" +FACET_ORDER: tuple[str, ...] = ( + FACET_CONTEXT, + FACET_SEEN_BEFORE, + FACET_DECISIONS, + FACET_PATTERNS, + FACET_BELIEF_CHANGES, +) + +# Share of memory.prefrontal.max_prefrontal_tokens per facet (sums to 1.0). +SHARE_CONTEXT = 0.30 +SHARE_SEEN_BEFORE = 0.20 +SHARE_DECISIONS = 0.20 +SHARE_PATTERNS = 0.20 +SHARE_BELIEF_CHANGES = 0.10 + +# Query caps. +MAX_QUERY_WORDS = 120 +MAX_SUMMARY_CHARS = 400 +MAX_CONTEXT_PATHS = 8 +MAX_ENTITY_TERMS = 12 + + +@dataclass(frozen=True) +class Facet: + """One pre-call recall. + + ``fact_types`` is None and ``query`` empty for ``belief_changes``, which + is derived from the other facets' results (no recall). + """ + + name: str + query: str + fact_types: tuple[str, ...] | None + prefer_observations: bool + share: float + + +def truncate_chars(text: str, limit: int) -> str: + """Cut ``text`` to at most ``limit`` chars, at a word boundary.""" + text = " ".join((text or "").split()) + if len(text) <= limit: + return text + cut = text[: limit + 1] + space = cut.rfind(" ") + return (cut[:space] if space > 0 else text[:limit]).rstrip(" ,;:.") + + +def truncate_words(text: str, limit: int = MAX_QUERY_WORDS) -> str: + """Keep at most ``limit`` whitespace-separated words.""" + words = (text or "").split() + return " ".join(words[:limit]) + + +def _join(parts: Sequence[str], sep: str) -> str: + """Join non-empty parts; with a sentence separator, drop each part's final period.""" + strip = sep.strip() + cleaned = (s.strip() for s in parts) + if strip: + cleaned = (s.rstrip(strip).rstrip() for s in cleaned) + return sep.join(p for p in cleaned if p) + + +def _dedup(items: Sequence[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for item in items: + item = (item or "").strip() + if item and item not in seen: + seen.add(item) + out.append(item) + return out + + +def build_facets( + task: str, + target: str, + description: str = "", + pr_title: str = "", + paths: Sequence[str] = (), + symbols: Sequence[str] = (), + summary: str = "", + packages: Sequence[str] = (), +) -> list[Facet]: + """Build the five pre-call facets for one agent call. + + Args: + task: Agent task name (e.g. ``code_review``). + target: Scope topic id, or the repo for repo-level agents. + description: Scope description (empty for repo-level agents). + pr_title: Pull request title. + paths: Changed file paths. + symbols: Changed function/class names, when already known. + summary: PR summary (or truncated PR description); capped at 400 chars. + packages: Package names of the scope (manifest identity). + """ + paths = _dedup(paths) + summary = truncate_chars(summary, MAX_SUMMARY_CHARS) + head = _join([task, target], " ") + if description: + head = f"{head} — {truncate_chars(description, MAX_SUMMARY_CHARS)}" + + context_q = _join( + [ + head, + f"PR: {pr_title.strip()}" if pr_title and pr_title.strip() else "", + summary, + f"Files: {', '.join(paths[:MAX_CONTEXT_PATHS])}" if paths else "", + ], + ". ", + ) + entities = _dedup([*paths, *symbols])[:MAX_ENTITY_TERMS] + seen_q = _join([target, *_dedup(packages), *entities], " ") + decisions = ( + "decisions, trade-offs, chosen approaches, rejected alternatives, agreed conventions" + ) + patterns = "recurring issues, repeated findings, patterns across changes" + decisions_q = f"{target}: {decisions}" if target else decisions + patterns_q = f"{target}: {patterns}" if target else patterns + + return [ + Facet( + FACET_CONTEXT, truncate_words(context_q), ("experience", "world"), False, SHARE_CONTEXT + ), + Facet( + FACET_SEEN_BEFORE, + truncate_words(seen_q), + ("world", "observation"), + True, + SHARE_SEEN_BEFORE, + ), + Facet( + FACET_DECISIONS, + truncate_words(decisions_q), + ("world", "observation"), + True, + SHARE_DECISIONS, + ), + Facet(FACET_PATTERNS, truncate_words(patterns_q), ("observation",), False, SHARE_PATTERNS), + Facet(FACET_BELIEF_CHANGES, "", None, False, SHARE_BELIEF_CHANGES), + ] diff --git a/src/codespy/agents/memory/prefrontal/reach.py b/src/codespy/agents/memory/prefrontal/reach.py new file mode 100644 index 0000000..eb806c5 --- /dev/null +++ b/src/codespy/agents/memory/prefrontal/reach.py @@ -0,0 +1,183 @@ +"""Tag scopes and reach filters shared by Cerebral (write side) and Prefrontal (read side). + +This module is deliberately lightweight: it never imports ``hindsight_api`` at +module level. Hindsight builds and caches its config at import time, and +``get_cerebral`` must set ``HINDSIGHT_API_*`` env vars before that happens, so +the Hindsight tag-group classes are imported lazily inside the functions that +build them (only ever called once a Cerebral exists). +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Iterable, Sequence +from typing import Any + +# Reach levels, from narrowest to widest. +REACH_LOCAL = "local" +REACH_ORG = "org" +REACH_BANK = "bank" +REACHES: tuple[str, ...] = (REACH_LOCAL, REACH_ORG, REACH_BANK) + +# Tag prefixes written by Cerebral.retain_episode. +TAG_ORG = "org:" +TAG_REPO = "repo:" +TAG_PROJECT_SCOPE = "project_scope:" +TAG_TASK = "task:" + + +def org_of(repo_full_name: str) -> str: + """Return the owner (first path segment) of ``owner/repo``.""" + return (repo_full_name or "").strip("/").split("/", 1)[0] + + +def repo_tags(repo_full_name: str) -> list[str]: + """Tags identifying a repository: ``[org:, repo:]``.""" + return [f"{TAG_ORG}{org_of(repo_full_name)}", f"{TAG_REPO}{repo_full_name}"] + + +def scope_tags(repo_full_name: str, scope_topic_id: str) -> list[str]: + """Tags identifying one project scope: ``[org:, repo:, project_scope:]``.""" + return [*repo_tags(repo_full_name), f"{TAG_PROJECT_SCOPE}{scope_topic_id}"] + + +def mental_model_id(tags: Iterable[str]) -> str: + """Deterministic mental-model id for a tag scope. + + ``mm-`` plus the first 32 hex chars of ``sha1("|".join(sorted(tags)))``. + """ + digest = hashlib.sha1("|".join(sorted(tags)).encode("utf-8")).hexdigest() + return f"mm-{digest[:32]}" + + +def mental_model_scopes( + repo_full_name: str, + scope_topic_ids: Sequence[str] | None, + include_repo: bool, +) -> list[list[str]]: + """Tag scopes whose briefings an agent reads: its scopes first, then the repo.""" + scopes = [scope_tags(repo_full_name, sid) for sid in (scope_topic_ids or [])] + if include_repo: + scopes.append(repo_tags(repo_full_name)) + return scopes + + +def mental_model_ids( + repo_full_name: str, + scope_topic_ids: Sequence[str] | None, + include_repo: bool, +) -> list[str]: + """Mental-model ids an agent reads, in briefing order (scopes, then repo).""" + return [ + mental_model_id(t) + for t in mental_model_scopes(repo_full_name, scope_topic_ids, include_repo) + ] + + +def clamp_reach(requested: str | None, configured: str) -> str: + """Clamp a requested reach to the configured one. Unknown values mean ``local``.""" + req = (requested or REACH_LOCAL).strip().lower() + if req not in REACHES: + req = REACH_LOCAL + cfg = configured if configured in REACHES else REACH_LOCAL + return REACHES[min(REACHES.index(req), REACHES.index(cfg))] + + +def local_tags( + repo_full_name: str, + scope_topic_ids: Sequence[str] | None, + include_repo: bool, +) -> list[str]: + """Tags of the local pool (OR-ed): ``project_scope:`` ids, plus ``repo:`` if requested.""" + tags = [f"{TAG_PROJECT_SCOPE}{sid}" for sid in (scope_topic_ids or [])] + if include_repo: + tags.insert(0, f"{TAG_REPO}{repo_full_name}") + return tags + + +def build_local_groups( + repo_full_name: str, + scope_topic_ids: Sequence[str] | None, + include_repo: bool, +) -> list[Any]: + """Hindsight tag groups for the local pool. + + One ``any_strict`` leaf: a fact matches when it carries any of the local + tags (and untagged facts never match). Returns ``[]`` when there is no + local tag at all — callers must then skip the local pool rather than + search the whole bank. + """ + tags = local_tags(repo_full_name, scope_topic_ids, include_repo) + if not tags: + return [] + from hindsight_api.engine.search.tags import TagGroupLeaf + + return [TagGroupLeaf(tags=tags, match="any_strict")] + + +def build_remote_groups(repo_full_name: str, reach: str) -> list[Any] | None: + """Hindsight tag groups for the remote pool, or ``None`` for ``local`` reach. + + - ``org``: ``org:`` AND NOT ``repo:`` + - ``bank``: NOT ``repo:`` + """ + if reach not in (REACH_ORG, REACH_BANK): + return None + from hindsight_api.engine.search.tags import TagGroupLeaf, TagGroupNot + + not_own = TagGroupNot( + filter=TagGroupLeaf(tags=[f"{TAG_REPO}{repo_full_name}"], match="any_strict") + ) + if reach == REACH_ORG: + return [ + TagGroupLeaf(tags=[f"{TAG_ORG}{org_of(repo_full_name)}"], match="any_strict"), + not_own, + ] + return [not_own] + + +def build_reflect_groups( + repo_full_name: str, + scope_topic_ids: Sequence[str] | None, + include_repo: bool, + reach: str, +) -> list[Any] | None: + """Tag groups for a tool-side reflect over ``reach``. + + - ``local``: the local pool + - ``org``: the local pool OR ``org:`` + - ``bank``: no filter (the whole bank) + """ + if reach == REACH_BANK: + return None + tags = local_tags(repo_full_name, scope_topic_ids, include_repo) + if reach == REACH_ORG: + tags = [*tags, f"{TAG_ORG}{org_of(repo_full_name)}"] + if not tags: + return [] + from hindsight_api.engine.search.tags import TagGroupLeaf + + return [TagGroupLeaf(tags=tags, match="any_strict")] + + +def tag_value(tags: Iterable[str] | None, prefix: str) -> str | None: + """Return the value of the first tag with ``prefix`` (e.g. ``task:``), or None.""" + for tag in tags or (): + if tag.startswith(prefix): + return tag[len(prefix) :] + return None + + +def is_own_repo_fact(tags: Iterable[str] | None, repo_full_name: str) -> bool: + """True when a fact belongs to ``repo_full_name``. + + Covers old data that has no ``repo:`` tag but a ``project_scope:`` id under + the repo, which ``NOT repo:`` cannot exclude. + """ + own_scope = f"{TAG_PROJECT_SCOPE}{repo_full_name}" + for tag in tags or (): + if tag == f"{TAG_REPO}{repo_full_name}": + return True + if tag == own_scope or tag.startswith(own_scope + "/"): + return True + return False diff --git a/src/codespy/agents/memory/recall.py b/src/codespy/agents/memory/recall.py new file mode 100644 index 0000000..8335ec0 --- /dev/null +++ b/src/codespy/agents/memory/recall.py @@ -0,0 +1,132 @@ +"""Recall monitoring: record what Prefrontal gave to the agent. + +This module has no dependencies on hippocampus, prefrontal or cerebral, +so all of them can import it without circular import risks. +""" + +from __future__ import annotations + +import contextvars +import threading +from collections.abc import Iterator, Set +from contextlib import contextmanager +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class RecallRecord(BaseModel): + """One Prefrontal recall (pre-call load or recall_memory tool call). + + Attributes: + ordinal: Order within one Prefrontal instance (0 = first recall). + kind: "load" for pre-call aload, "tool" for recall_memory. + timestamp: UTC time the recall started. + query: The query used (context facet for load, truncated query for tool). + reach: Effective reach (local/org/bank; clamped for tool calls). + reflects: The reflects config at call time. + status: Outcome classification. + text: Exact string returned to the agent ("" on load error). + model: LLM model(s) that answered, comma-joined; "" = no LLM used. + llm_calls: Number of LLM calls (reflect iterations + rewrite). + input_tokens: LLM prompt tokens + embedding tokens. + output_tokens: LLM completion tokens. + input_cost: Cost in USD for input tokens. + output_cost: Cost in USD for output tokens. + latency_ms: Wall time from start to result, in milliseconds. + details: JSON-serializable extra data per kind. + """ + + ordinal: int + kind: Literal["load", "tool"] + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) + query: str = Field(default="") + reach: str = Field(default="local") + reflects: int = Field(default=0) + status: Literal["ok", "empty", "limit", "error"] = Field(default="ok") + text: str = Field(default="") + model: str = Field(default="") + llm_calls: int = Field(default=0) + input_tokens: int = Field(default=0) + output_tokens: int = Field(default=0) + input_cost: float = Field(default=0.0) + output_cost: float = Field(default=0.0) + latency_ms: int = Field(default=0) + details: dict[str, Any] = Field(default_factory=dict) + + +@dataclass +class RecallUsage: + """Thread-safe accumulator for one recall's usage. + + Used via `current_recall_usage` contextvar so that concurrent recalls + (e.g., per-scope in asyncio.gather) don't mix their metrics. + """ + + input_tokens: int = 0 + output_tokens: int = 0 + input_cost: float = 0.0 + output_cost: float = 0.0 + llm_calls: int = 0 + _models: set[str] = field(default_factory=set) + _lock: threading.Lock = field(default_factory=threading.Lock) + + def add_llm( + self, + model: str, + input_tokens: int, + output_tokens: int, + input_cost: float, + output_cost: float, + ) -> None: + """Record one LLM call.""" + with self._lock: + self.input_tokens += input_tokens + self.output_tokens += output_tokens + self.input_cost += input_cost + self.output_cost += output_cost + self.llm_calls += 1 + if model: + self._models.add(model) + + def add_embedding(self, tokens: int, cost: float) -> None: + """Record one embedding call (counts as input only).""" + with self._lock: + self.input_tokens += tokens + self.input_cost += cost + + @property + def models(self) -> Set[str]: + """Return the set of models used.""" + with self._lock: + return set(self._models) + + def to_model_field(self) -> str: + """Comma-joined models, empty if none.""" + with self._lock: + return ",".join(sorted(self._models)) + + +# Context variable to attribute LLM/embedding calls to the current recall. +current_recall_usage: contextvars.ContextVar[RecallUsage | None] = contextvars.ContextVar( + "current_recall_usage", default=None +) + + +@contextmanager +def track_recall_usage() -> Iterator[RecallUsage]: + """Set current_recall_usage for the duration of the context manager. + + Usage: + with track_recall_usage() as usage: + ... # LLM calls here will add to usage + # usage now holds the accumulated metrics + """ + usage = RecallUsage() + token = current_recall_usage.set(usage) + try: + yield usage + finally: + current_recall_usage.reset(token) diff --git a/src/codespy/agents/review/audit/agent.py b/src/codespy/agents/review/audit/agent.py index dc330d9..4df6823 100644 --- a/src/codespy/agents/review/audit/agent.py +++ b/src/codespy/agents/review/audit/agent.py @@ -10,10 +10,11 @@ from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.context_memory import Topic from codespy.agents.memory.hippocampus.episode import submit_episode_save +from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory from codespy.agents.review.models import Issue, ReviewContext from codespy.agents.review.helpers import deepest_common_folder from codespy.config import get_settings -from codespy.config_memory import get_cerebral, get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store, get_prefrontal if TYPE_CHECKING: from codespy.agents.review.scope.models import ScopeResult @@ -59,6 +60,7 @@ def _call_auditor( run_id: str | None, scopes: list["ScopeResult"] | None, topics: list["ScopeResult"] | None = None, + prefrontal_memory: str | None = None, ) -> tuple[dspy.Prediction, Hippocampus | None, "EpisodeStore | None", dict[str, str] | None]: """Execute the auditor predictor (with or without Hippocampus memory). @@ -66,6 +68,10 @@ def _call_auditor( Tuple of (result, hippo, store, artifacts) where hippo/store/artifacts are None when memory is disabled. """ + # Prefrontal context (None when inactive); never given to Hippocampus. + pf_kwargs: dict[str, str] = ( + {"prefrontal_memory": prefrontal_memory} if prefrontal_memory is not None else {} + ) question = ( f"final audit of {review_context.pr_context.repo_slug}: " f"pull request {review_context.pr_context.pr_number} " @@ -113,6 +119,7 @@ def _call_auditor( pr_title=review_context.pr_context.pr_title, summary=review_context.pr_context.summary, all_issues=all_issues, + **pf_kwargs, ) hippo.observe(result) _artifacts = { @@ -127,6 +134,7 @@ def _call_auditor( pr_title=review_context.pr_context.pr_title, summary=review_context.pr_context.summary, all_issues=all_issues, + **pf_kwargs, ) return result, None, None, None @@ -157,9 +165,19 @@ def forward( "NEEDS_DISCUSSION" if all_issues else "APPROVE", ) + repo_full_name = review_context.pr_context.repo_full_name + scope_topic_ids = [scope.topic(repo_full_name).id for scope in scopes or []] + pf = get_prefrontal( + self._settings, + "audit", + repo_full_name, + scope_topic_ids=scope_topic_ids, + include_repo=True, + ) + sig = with_prefrontal_memory(AuditSignature) if pf else AuditSignature auditor = ContextSafe( - dspy.ChainOfThought(AuditSignature), - AuditSignature, + dspy.ChainOfThought(sig), + sig, name="audit", max_iters=self._settings.get_max_iters("audit"), max_llm_calls=self._settings.get_max_llm_calls("audit"), @@ -168,6 +186,18 @@ def forward( logger.info("Running audit...") with SignatureContext("audit", self._cost_tracker): + prefrontal_memory = ( + pf.load( + build_facets( + "audit", + repo_full_name, + pr_title=review_context.pr_context.pr_title, + summary=review_context.pr_context.summary, + ) + ) + if pf is not None + else None + ) result, hippo, store, _artifacts = self._call_auditor( auditor, review_context, @@ -175,6 +205,7 @@ def forward( run_id, scopes, topics, + prefrontal_memory=prefrontal_memory, ) # Background episode save (after SignatureContext closes) @@ -183,12 +214,16 @@ def forward( _cerebral = cerebral def _persist(): try: - hippo.end_episode(store, artifacts=_artifacts) + hippo.end_episode( + store, + artifacts=_artifacts, + recalls=pf.recalls if pf else None, + ) except Exception: logger.warning("Audit episode save failed", exc_info=True) if _cerebral is not None and hippo.episode is not None: try: - _cerebral.retain_episode(hippo.episode) + _cerebral.retain_episode(hippo.episode, repo_full_name=repo_full_name) except Exception: logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="audit-episode-save") diff --git a/src/codespy/agents/review/code_review/agent.py b/src/codespy/agents/review/code_review/agent.py index ca7f926..a4e3a05 100644 --- a/src/codespy/agents/review/code_review/agent.py +++ b/src/codespy/agents/review/code_review/agent.py @@ -13,6 +13,7 @@ from codespy.agents.context_safe import ContextSafe from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save +from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult from codespy.agents.review.helpers import ( @@ -20,9 +21,10 @@ make_scope_relative, resolve_scope_root, restore_repo_paths, + scope_package_names, ) from codespy.config import get_settings -from codespy.config_memory import get_cerebral, get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store, get_prefrontal from codespy.tools.mcp_utils import cleanup_mcp_contexts, connect_mcp_server logger = logging.getLogger(__name__) @@ -271,15 +273,23 @@ async def _review_scope( scope_root = resolve_scope_root(repo_path, scope.subroot) tools, contexts = await self._create_tools(scope_root) try: + repo_full_name = review_context.pr_context.repo_full_name + scope_topic_id = scope.topic(repo_full_name).id + pf = get_prefrontal( + self._settings, "code_review", repo_full_name, scope_topic_ids=[scope_topic_id] + ) + sig = with_prefrontal_memory(CodeReviewSignature) if pf else CodeReviewSignature + recall_tool = pf.recall_tool() if pf else None + agent_tools = [*tools, recall_tool] if recall_tool else tools agent = ContextSafe( dspy.RLM( - CodeReviewSignature, - tools=tools, + sig, + tools=agent_tools, max_iters=max_iters, max_llm_calls=self._settings.get_max_llm_calls("code_review"), ), - CodeReviewSignature, - tools=tools, + sig, + tools=agent_tools, name="code_review", max_iters=max_iters, max_llm_calls=self._settings.get_max_llm_calls("code_review"), @@ -291,6 +301,20 @@ async def _review_scope( ) hippo: Hippocampus | None = None async with SignatureContext("code_review", self._cost_tracker): + # Prefrontal: prior knowledge from Cerebral (never given to Hippocampus) + pf_kwargs: dict[str, Any] = {} + if pf is not None: + pf_kwargs["prefrontal_memory"] = await pf.aload( + build_facets( + "code_review", + scope_topic_id, + description=scope.description, + pr_title=review_context.pr_context.pr_title, + paths=[f.filename for f in scope.changed_files], + summary=review_context.pr_context.summary, + packages=scope_package_names(scope), + ) + ) # Load own prior "code_review" episode for this scope scope_initial_memory: ContextMemory | None = None store = None @@ -326,6 +350,7 @@ async def _review_scope( result = await agent.acall( scope=scoped, categories=categories, + **pf_kwargs, ) await hippo.aobserve(result) issues = [ @@ -336,14 +361,16 @@ async def _review_scope( # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} cerebral = get_cerebral(self._settings) - def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): + def _persist( + h=hippo, s=store, a=_artifacts, c=cerebral, r=repo_full_name, p=pf + ): try: - h.end_episode(s, artifacts=a) + h.end_episode(s, artifacts=a, recalls=p.recalls if p else None) except Exception: logger.warning("Background code_review episode save failed", exc_info=True) if c is not None and h.episode is not None: try: - c.retain_episode(h.episode) + c.retain_episode(h.episode, repo_full_name=r) except Exception: logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="code-review-episode-save") @@ -351,6 +378,7 @@ def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): result = await agent.acall( scope=scoped, categories=categories, + **pf_kwargs, ) issues = [ issue diff --git a/src/codespy/agents/review/doc/agent.py b/src/codespy/agents/review/doc/agent.py index 094f9dd..1aa7fe1 100644 --- a/src/codespy/agents/review/doc/agent.py +++ b/src/codespy/agents/review/doc/agent.py @@ -13,6 +13,7 @@ from codespy.agents.context_safe import ContextSafe from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save +from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult from codespy.agents.review.doc.doc_extractor import extract_documentation @@ -22,9 +23,10 @@ make_scope_relative, resolve_scope_root, restore_repo_paths, + scope_package_names, ) from codespy.config import get_settings -from codespy.config_memory import get_cerebral, get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store, get_prefrontal logger = logging.getLogger(__name__) @@ -200,9 +202,15 @@ async def _review_scope( logger.debug(f" No patches in {scope.subroot}, skipping doc review") return [] try: + repo_full_name = review_context.pr_context.repo_full_name + scope_topic_id = scope.topic(repo_full_name).id + pf = get_prefrontal( + self._settings, "doc", repo_full_name, scope_topic_ids=[scope_topic_id] + ) + sig = with_prefrontal_memory(DocReviewSignature) if pf else DocReviewSignature reviewer = ContextSafe( - dspy.ChainOfThought(DocReviewSignature), - DocReviewSignature, + dspy.ChainOfThought(sig), + sig, name="doc", max_iters=max_iters, max_llm_calls=self._settings.get_max_llm_calls("doc"), @@ -213,6 +221,20 @@ async def _review_scope( ) hippo: Hippocampus | None = None async with SignatureContext("doc", self._cost_tracker): + # Prefrontal: prior knowledge from Cerebral (never given to Hippocampus) + pf_kwargs: dict[str, Any] = {} + if pf is not None: + pf_kwargs["prefrontal_memory"] = await pf.aload( + build_facets( + "doc", + scope_topic_id, + description=scope.description, + pr_title=review_context.pr_context.pr_title, + paths=[f.filename for f in scope.changed_files], + summary=review_context.pr_context.summary, + packages=scope_package_names(scope), + ) + ) # Load own prior "doc" episode for this scope scope_initial_memory: ContextMemory | None = None store = None @@ -249,6 +271,7 @@ async def _review_scope( patches=patches, documentation=documentation, categories=[IssueCategory.DOCUMENTATION], + **pf_kwargs, ) await hippo.aobserve(result) issues = [ @@ -259,14 +282,16 @@ async def _review_scope( # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} cerebral = get_cerebral(self._settings) - def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): + def _persist( + h=hippo, s=store, a=_artifacts, c=cerebral, r=repo_full_name, p=pf + ): try: - h.end_episode(s, artifacts=a) + h.end_episode(s, artifacts=a, recalls=p.recalls if p else None) except Exception: logger.warning("Background doc episode save failed", exc_info=True) if c is not None and h.episode is not None: try: - c.retain_episode(h.episode) + c.retain_episode(h.episode, repo_full_name=r) except Exception: logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="doc-episode-save") @@ -276,6 +301,7 @@ def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): patches=patches, documentation=documentation, categories=[IssueCategory.DOCUMENTATION], + **pf_kwargs, ) issues = [ issue diff --git a/src/codespy/agents/review/helpers.py b/src/codespy/agents/review/helpers.py index 646acae..3dffd09 100644 --- a/src/codespy/agents/review/helpers.py +++ b/src/codespy/agents/review/helpers.py @@ -6,7 +6,7 @@ import os from collections.abc import Sequence from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from codespy.agents.review.models import Issue from codespy.tools.git.models import ChangedFile @@ -259,3 +259,11 @@ def issues_to_markdown(issues: list[Issue]) -> str: lines.append("---") lines.append("") return "\n".join(lines) + + +def scope_package_names(scope: ScopeResult | Any) -> list[str]: + """Package identity of a scope, when its manifest declares one (Prefrontal queries).""" + manifest = getattr(scope, "package_manifest", None) + if manifest is not None and getattr(manifest, "package_name", None): + return [manifest.package_name] + return [] diff --git a/src/codespy/agents/review/scope/agent.py b/src/codespy/agents/review/scope/agent.py index ced5109..6cddab9 100644 --- a/src/codespy/agents/review/scope/agent.py +++ b/src/codespy/agents/review/scope/agent.py @@ -21,11 +21,12 @@ from codespy.agents.context_safe import ContextSafe from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save +from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory from codespy.agents.review.models import ReviewContext from codespy.agents.review.scope.manifest_parser import extract_package_name from codespy.agents.review.scope.models import PackageManifest, ScopeResult, ScopeType from codespy.config import get_settings -from codespy.config_memory import get_cerebral, get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store, get_prefrontal from codespy.tools.git.client import get_client from codespy.tools.git.models import ChangedFile, PullRequest, should_review_file from codespy.tools.mcp_utils import cleanup_mcp_contexts, connect_mcp_server @@ -1051,15 +1052,21 @@ async def _refine_scopes( max_iters = self._settings.get_max_iters("scope") tools, contexts = await self._create_tools(repo_path) try: + pf = get_prefrontal(self._settings, "scope", pr.repo_full_name, include_repo=True) + sig = ( + with_prefrontal_memory(ScopeRefinementSignature) if pf else ScopeRefinementSignature + ) + recall_tool = pf.recall_tool() if pf else None + agent_tools = [*tools, recall_tool] if recall_tool else tools agent = ContextSafe( dspy.RLM( - ScopeRefinementSignature, - tools=tools, + sig, + tools=agent_tools, max_iters=max_iters, max_llm_calls=self._settings.get_max_llm_calls("scope"), ), - ScopeRefinementSignature, - tools=tools, + sig, + tools=agent_tools, name="scope", max_iters=max_iters, max_llm_calls=self._settings.get_max_llm_calls("scope"), @@ -1068,6 +1075,19 @@ async def _refine_scopes( hippo: Hippocampus | None = None async with SignatureContext("scope", self._cost_tracker): + # Prefrontal: prior knowledge from Cerebral (never given to Hippocampus) + pf_kwargs: dict[str, Any] = {} + if pf is not None: + pf_kwargs["prefrontal_memory"] = await pf.aload( + build_facets( + "scope", + pr.repo_full_name, + pr_title=pr.title or "", + paths=[f.filename for s in scopes for f in s.changed_files] + + [f.filename for f in orphans], + summary=pr.body or "", + ) + ) if self._settings.get_memory_enabled("scope"): question = ( f"refine scopes of {review_context.pr_context.repo_slug}: " @@ -1103,6 +1123,7 @@ async def _refine_scopes( pr_title=pr.title or "No title", pr_description=pr.body or "No description", project_instructions=project_instructions, + **pf_kwargs, ) await hippo.aobserve(result) else: @@ -1112,6 +1133,7 @@ async def _refine_scopes( pr_title=pr.title or "No title", pr_description=pr.body or "No description", project_instructions=project_instructions, + **pf_kwargs, ) # Collect all changed files (from scopes + orphans) @@ -1170,14 +1192,19 @@ async def _refine_scopes( for s in final_scopes ) _cerebral = cerebral + _repo_full_name = pr.repo_full_name def _persist(): try: - hippo.end_episode(store, artifacts={"scopes": scope_desc}) + hippo.end_episode( + store, + artifacts={"scopes": scope_desc}, + recalls=pf.recalls if pf else None, + ) except Exception: logger.warning("Background scope episode save failed", exc_info=True) if _cerebral is not None and hippo.episode is not None: try: - _cerebral.retain_episode(hippo.episode) + _cerebral.retain_episode(hippo.episode, repo_full_name=_repo_full_name) except Exception: logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="scope-episode-save") diff --git a/src/codespy/agents/review/summary/agent.py b/src/codespy/agents/review/summary/agent.py index 99f865c..9320529 100644 --- a/src/codespy/agents/review/summary/agent.py +++ b/src/codespy/agents/review/summary/agent.py @@ -10,9 +10,10 @@ from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.memory.hippocampus.context_memory import Topic +from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory from codespy.agents.review.helpers import deepest_common_folder from codespy.config import get_settings -from codespy.config_memory import get_cerebral, get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store, get_prefrontal if TYPE_CHECKING: from codespy.agents.review.models import PRContext @@ -95,9 +96,19 @@ def forward( else: logger.info("No prior summary episode found") + repo_full_name = pr_context.repo_full_name + scope_topic_ids = [scope.topic(repo_full_name).id for scope in scopes or []] + pf = get_prefrontal( + self._settings, + "summary", + repo_full_name, + scope_topic_ids=scope_topic_ids, + include_repo=True, + ) + sig = with_prefrontal_memory(PRSummarySignature) if pf else PRSummarySignature summarizer = ContextSafe( - dspy.ChainOfThought(PRSummarySignature), - PRSummarySignature, + dspy.ChainOfThought(sig), + sig, name="summary", max_iters=self._settings.get_max_iters("summary"), max_llm_calls=self._settings.get_max_llm_calls("summary"), @@ -109,6 +120,17 @@ def forward( hippo: Hippocampus | None = None with SignatureContext("summary", self._cost_tracker): + # Prefrontal: prior knowledge from Cerebral (never given to Hippocampus) + pf_kwargs: dict[str, str] = {} + if pf is not None: + pf_kwargs["prefrontal_memory"] = pf.load( + build_facets( + "summary", + repo_full_name, + pr_title=pr_context.pr_title, + paths=changed_file_paths, + ) + ) if self._settings.get_memory_enabled("summary") and store is not None: # Build topics list for Hippocampus scope_topics: list[Topic] = [] @@ -130,6 +152,7 @@ def forward( pr_description=pr_context.pr_description, changed_file_paths=changed_file_paths, patches=patches, + **pf_kwargs, ) hippo.observe(result) # Fire-and-forget episode save @@ -138,12 +161,16 @@ def forward( _cerebral = cerebral def _persist(): try: - hippo.end_episode(store, artifacts={"summary": _summary_text}) + hippo.end_episode( + store, + artifacts={"summary": _summary_text}, + recalls=pf.recalls if pf else None, + ) except Exception: logger.warning("Background summary episode save failed", exc_info=True) if _cerebral is not None and hippo.episode is not None: try: - _cerebral.retain_episode(hippo.episode) + _cerebral.retain_episode(hippo.episode, repo_full_name=repo_full_name) except Exception: logger.warning("Background summary cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="summary-episode-save") @@ -153,6 +180,7 @@ def _persist(): pr_description=pr_context.pr_description, changed_file_paths=changed_file_paths, patches=patches, + **pf_kwargs, ) logger.info(f"PR summary: {result.summary[:80]}...") diff --git a/src/codespy/agents/review/supply_chain/agent.py b/src/codespy/agents/review/supply_chain/agent.py index b260a5d..30ab963 100644 --- a/src/codespy/agents/review/supply_chain/agent.py +++ b/src/codespy/agents/review/supply_chain/agent.py @@ -13,16 +13,18 @@ from codespy.agents.context_safe import ContextSafe from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save +from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult from codespy.agents.review.helpers import ( issues_to_markdown, resolve_scope_root, restore_repo_paths, + scope_package_names, strip_prefix, ) from codespy.config import get_settings -from codespy.config_memory import get_cerebral, get_episode_store +from codespy.config_memory import get_cerebral, get_episode_store, get_prefrontal from codespy.tools.mcp_utils import cleanup_mcp_contexts, connect_mcp_server logger = logging.getLogger(__name__) @@ -369,14 +371,27 @@ async def _review_scope( try: # Combine scoped filesystem tools with shared OSV tools all_tools = scoped_tools + osv_tools + repo_full_name = review_context.pr_context.repo_full_name + scope_topic_id = scope.topic(repo_full_name).id + pf = get_prefrontal( + self._settings, "supply_chain", repo_full_name, scope_topic_ids=[scope_topic_id] + ) + sig = ( + with_prefrontal_memory(SupplyChainSecuritySignature) + if pf + else SupplyChainSecuritySignature + ) + recall_tool = pf.recall_tool() if pf else None + if recall_tool: + all_tools = [*all_tools, recall_tool] supply_chain_agent = ContextSafe( dspy.RLM( - SupplyChainSecuritySignature, + sig, tools=all_tools, max_iters=supply_chain_max_iters, max_llm_calls=self._settings.get_max_llm_calls("supply_chain"), ), - SupplyChainSecuritySignature, + sig, tools=all_tools, name="supply_chain", max_iters=supply_chain_max_iters, @@ -391,6 +406,20 @@ async def _review_scope( # Track supply_chain signature costs separately hippo: Hippocampus | None = None async with SignatureContext("supply_chain", self._cost_tracker): + # Prefrontal: prior knowledge from Cerebral (never given to Hippocampus) + pf_kwargs: dict[str, Any] = {} + if pf is not None: + pf_kwargs["prefrontal_memory"] = await pf.aload( + build_facets( + "supply_chain", + scope_topic_id, + description=scope.description, + pr_title=review_context.pr_context.pr_title, + paths=[f.filename for f in scope.changed_files], + summary=review_context.pr_context.summary, + packages=scope_package_names(scope), + ) + ) # Load own prior "supply_chain" episode for this scope scope_initial_memory: ContextMemory | None = None store = None @@ -428,6 +457,7 @@ async def _review_scope( lock_file_path=lock_file_path, package_manager=package_manager, category=IssueCategory.SECURITY, + **pf_kwargs, ) await hippo.aobserve(result) issues = [ @@ -438,14 +468,16 @@ async def _review_scope( # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} cerebral = get_cerebral(self._settings) - def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): + def _persist( + h=hippo, s=store, a=_artifacts, c=cerebral, r=repo_full_name, p=pf + ): try: - h.end_episode(s, artifacts=a) + h.end_episode(s, artifacts=a, recalls=p.recalls if p else None) except Exception: logger.warning("Background supply_chain episode save failed", exc_info=True) if c is not None and h.episode is not None: try: - c.retain_episode(h.episode) + c.retain_episode(h.episode, repo_full_name=r) except Exception: logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="supply-chain-episode-save") @@ -455,6 +487,7 @@ def _persist(h=hippo, s=store, a=_artifacts, c=cerebral): lock_file_path=lock_file_path, package_manager=package_manager, category=IssueCategory.SECURITY, + **pf_kwargs, ) issues = [ issue diff --git a/src/codespy/config.py b/src/codespy/config.py index 9fd883a..164ebea 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -21,6 +21,7 @@ ) from codespy.config_llm import LLMConfig from codespy.config_memory import ( + MEMORY_PREFRONTAL, MEMORY_RETAIN, REFLECTION_MODULES, LLMSettings, @@ -71,8 +72,18 @@ "review": ReviewConfig, "memory": MemoryConfig, }, - collapsed_paths={("llm",), ("memory", "hippocampus"), ("memory", "cerebral")}, - full_name_paths={("llm", "retries"), ("llm", "timeout")}, + collapsed_paths={ + ("llm",), + ("memory", "hippocampus"), + ("memory", "cerebral"), + ("memory", "prefrontal"), + }, + full_name_paths={ + ("llm", "retries"), + ("llm", "timeout"), + ("memory", "prefrontal", "reflects"), + ("memory", "prefrontal", "model"), + }, ) @@ -184,6 +195,15 @@ def get_llm_config(self, name: str) -> LLMSettings: model = self.memory.cerebral.retain.model or self.llm.default_model config = ReflectionModuleConfig() # Empty config - all fields fall back to defaults defaults = self.llm + elif name == MEMORY_PREFRONTAL: + # Prefrontal reflect model: falls back to retain model, then default + model = ( + self.memory.prefrontal.model + or self.memory.cerebral.retain.model + or self.llm.default_model + ) + config = ReflectionModuleConfig() # Empty config - all fields fall back to defaults + defaults = self.llm elif name in REFLECTION_MODULES: config = reflection_module_config(self.memory.hippocampus, name) model = config.model or self.llm.default_model @@ -296,6 +316,12 @@ def log_signature_configs(self) -> None: f"reasoning_effort={llm.reasoning_effort}, temperature={llm.temperature}, " f"max_tokens={llm.max_tokens}" ) + # Prefrontal reflect model (also refreshes briefings) + pf_llm = self.get_llm_config(MEMORY_PREFRONTAL) + logger.info( + f" {MEMORY_PREFRONTAL}: model={pf_llm.model}, " + f"reflects={self.memory.prefrontal.reflects}" + ) @model_validator(mode="before") @classmethod diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 2b03b9a..9fc44aa 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -1,9 +1,11 @@ -"""Memory (Hippocampus) configuration and storage factory.""" +"""Memory (Hippocampus, Cerebral, Prefrontal) configuration and storage factory.""" from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +import os +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Literal from pydantic import BaseModel, Field @@ -15,8 +17,18 @@ # silently falls back to defaults (chunk 3000, no mission). _HINDSIGHT_RETAIN_MAX_COMPLETION_TOKENS = 64000 +# Hindsight reads its reflect-loop iteration cap from this env var once, when +# its config is first built (at import). See get_cerebral(). +HINDSIGHT_REFLECT_MAX_ITERATIONS_ENV = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS" + +# Hindsight reads its reflect context token cap from this env var once, when +# its config is first built. Also bounds briefing refreshes (LOW budget). +HINDSIGHT_REFLECT_MAX_CONTEXT_TOKENS_ENV = "HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS" + if TYPE_CHECKING: + from codespy.agents.memory.cerebral import Cerebral from codespy.agents.memory.postgres import EpisodeStore + from codespy.agents.memory.prefrontal import Prefrontal from codespy.config import Settings logger = logging.getLogger(__name__) @@ -93,6 +105,31 @@ class CerebralConfig(BaseModel): embeddings: CerebralEmbeddingsConfig = Field(default_factory=CerebralEmbeddingsConfig) +class PrefrontalConfig(BaseModel): + """Prefrontal (semantic recall into agent context) configuration.""" + + # Model for Hindsight reflect (pre-call context and recall_memory tool). + # Falls back to cerebral.retain.model → llm.default_model. + # Unused when reflects=0 (no LLM at read time). + model: str | None = None + # local: this scope/repo only. org: also other repos of the same owner. + # bank: every repo in the bank (crosses organisations — opt-in only). + prefrontal_reach: Literal["local", "org", "bank"] = "org" + # 0: raw facts only, no LLM at read time and no briefings (no mental models). + # N > 0: Hindsight reflect loop, capped at N iterations (both Prefrontal and + # briefing refreshes). Uses Hindsight's LOW budget (0.5× multiplier), so the + # global cap is set to 2× reflects to achieve exactly reflects iterations. + reflects: int = Field(default=3, ge=0) + # Briefing size. Only used when reflects > 0. + max_mental_model_tokens: int = Field(default=2048, gt=0) + # Token budget for the pre-call context (local facets + remote facts). + max_prefrontal_tokens: int = Field(default=8192, gt=0) + # Token budget for recall_memory tool results. + max_prefrontal_tool_tokens: int = Field(default=2048, gt=0) + # recall_memory tool call limit (0 = tool disabled). + max_prefrontal_tool_calls: int = Field(default=0, ge=0) + + class LLMSettings(BaseModel): """Fully resolved LLM settings for one named unit of work. @@ -170,6 +207,9 @@ class MemoryConfig(BaseModel): # Cerebral (semantic memory) configuration cerebral: CerebralConfig = Field(default_factory=CerebralConfig) + # Prefrontal (semantic recall into agent context) configuration + prefrontal: PrefrontalConfig = Field(default_factory=PrefrontalConfig) + # Memory unit name prefix for LLM work units MEMORY_UNIT_PREFIX = "memory_" @@ -180,6 +220,7 @@ class MemoryConfig(BaseModel): MEMORY_RETAIN = "memory_retain" MEMORY_EMBEDDINGS = "memory_embeddings" MEMORY_OTHER = "memory_other" +MEMORY_PREFRONTAL = "memory_prefrontal" # The reflection modules, derived from the HippocampusConfig fields that hold a # ReflectionModuleConfig. Iterate this instead of hardcoding module names so @@ -333,26 +374,26 @@ def verify_memory_access(settings: Settings) -> tuple[bool, str]: _cerebral_built = False -def _cerebral_litellm_params(settings: "Settings") -> tuple[str, str | None, str | None]: - """Derive MemoryEngine litellm params from the cerebral model config + LLM credentials. +def _litellm_credentials(settings: "Settings", model: str) -> tuple[str | None, str | None]: + """Derive API key and base_url from a litellm model string. All Cerebral LLM calls go through litellm. This function parses the litellm model string and extracts credentials based on the provider prefix. + Args: + settings: Application settings containing LLM credentials. + model: Full litellm model string (e.g., "bedrock/converse/moonshotai.kimi-k2.5"). + Returns: - ``(model, api_key, base_url)`` - model is the full litellm string unchanged + ``(api_key, base_url)`` - None values indicate no special handling. """ - llm_config = settings.get_llm_config(MEMORY_RETAIN) - model = llm_config.model # e.g. "bedrock/converse/moonshotai.kimi-k2.5" - # Parse litellm model string: "provider/model_path" parts = model.split("/", 1) provider = parts[0] if len(parts) > 1 else "openai" - # Map credentials from Settings.llm + llm = settings.llm api_key: str | None = None base_url: str | None = None - llm = settings.llm if provider == "bedrock": pass # Uses AWS env vars (AWS_ACCESS_KEY_ID, etc.) - litellm reads these @@ -369,6 +410,20 @@ def _cerebral_litellm_params(settings: "Settings") -> tuple[str, str | None, str # Azure API version comes from env var AZURE_API_VERSION, same as DSPy # For any other provider, let litellm resolve from env/globals + return api_key, base_url + + +def _cerebral_litellm_params(settings: "Settings") -> tuple[str, str | None, str | None]: + """Derive MemoryEngine litellm params from the cerebral model config + LLM credentials. + + Convenience wrapper around _litellm_credentials for the retain model. + + Returns: + ``(model, api_key, base_url)`` - model is the full litellm string unchanged. + """ + llm_config = settings.get_llm_config(MEMORY_RETAIN) + model = llm_config.model + api_key, base_url = _litellm_credentials(settings, model) return model, api_key, base_url @@ -432,6 +487,21 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: or EMBEDDING_MODELS.get(provider_prefix, "openai/text-embedding-3-small") ) + # Prefrontal reflect model (optional) + pf_model = settings.memory.prefrontal.model + reflect_kwargs: dict[str, str | None] = {} + if pf_model: + pf_api_key, pf_base_url = _litellm_credentials(settings, pf_model) + reflect_kwargs = { + "reflect_llm_model": pf_model, + "reflect_llm_api_key": pf_api_key, + "reflect_llm_base_url": pf_base_url, + } + + # Apply reflect LLM defaults BEFORE Cerebral construction (these are read + # during MemoryEngine.__init__). reflect_config is applied after construction. + _apply_reflect_llm_call_defaults(settings) + try: _cerebral = Cerebral( database_url=pg_uri, @@ -442,6 +512,9 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: bank_id=bank_id, embeddings_model=embeddings_model, retain_chunk_size=settings.memory.cerebral.retain.chunk_size, + mental_models=settings.memory.prefrontal.reflects > 0, + max_mental_model_tokens=settings.memory.prefrontal.max_mental_model_tokens, + **reflect_kwargs, ) except Exception: logger.error( @@ -454,15 +527,180 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: _cerebral_built = True return None + reflect_model_str = f", reflect_model={pf_model}" if pf_model else "" logger.info( - "Cerebral initialized (bank=%s, provider=litellm, model=%s, schema=semantic)", + "Cerebral initialized (bank=%s, provider=litellm, model=%s%s, schema=semantic)", bank_id, model, + reflect_model_str, ) + # Apply reflect caps programmatically (after Cerebral construction, before first use) + _apply_reflect_config(settings.memory.prefrontal) _cerebral_built = True return _cerebral +# Inverse of Hindsight's LOW budget multiplier (0.5) to double the raw +# iteration cap so both Prefrontal and briefing refresh get exactly +# ``reflects`` iterations: max(1, int(2*reflects * 0.5)) = reflects. +# See memory_engine.py:15134-15138 for the multiplier logic. +_LOW_BUDGET_MULTIPLIER_INVERSE = 2 + + +def _apply_reflect_llm_call_defaults(settings: "Settings") -> None: + """Apply reflect LLM timeout and retry settings from codespy config. + + Reflect LLM timeout and retries reuse codespy's llm.timeout / llm.retries. + Operator env (HINDSIGHT_API_REFLECT_LLM_TIMEOUT, HINDSIGHT_API_LLM_TIMEOUT, + HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES, HINDSIGHT_API_LLM_MAX_RETRIES) wins. + + Called in get_cerebral() after Cerebral import but before Cerebral() construction. + This must run before Cerebral is built because the defaults are resolved in + MemoryEngine.__init__. + + Args: + settings: Application settings. + """ + try: + import hindsight_api.config as ha_cfg + + raw = ha_cfg._get_raw_config() + except Exception as e: + logger.warning("Could not access Hindsight raw config for LLM defaults: %s", e) + return + + # Determine effective timeout: env wins, then settings.llm.timeout + timeout: float | None = None + env_timeout = os.environ.get("HINDSIGHT_API_REFLECT_LLM_TIMEOUT") or os.environ.get("HINDSIGHT_API_LLM_TIMEOUT") + if env_timeout is not None: + try: + timeout = float(env_timeout) + logger.info("Using env HINDSIGHT_API_REFLECT_LLM_TIMEOUT=%s (operator override)", timeout) + except ValueError: + pass + if timeout is None and hasattr(raw, "reflect_llm_timeout"): + timeout = float(settings.llm.timeout) + if hasattr(raw, "reflect_llm_timeout"): + raw.reflect_llm_timeout = timeout + + # Determine effective retries: env wins, then settings.llm.retries + retries: int | None = None + env_retries = os.environ.get("HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES") or os.environ.get("HINDSIGHT_API_LLM_MAX_RETRIES") + if env_retries is not None: + try: + retries = int(env_retries) + logger.info("Using env HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES=%s (operator override)", retries) + except ValueError: + pass + if retries is None and hasattr(raw, "reflect_llm_max_retries"): + retries = int(settings.llm.retries) + if hasattr(raw, "reflect_llm_max_retries"): + raw.reflect_llm_max_retries = retries + + if timeout is not None or retries is not None: + logger.info( + "Hindsight reflect LLM defaults: timeout=%s, max_retries=%s", + timeout if timeout is not None else "-", + retries if retries is not None else "-", + ) + + +def _apply_reflect_config(cfg: PrefrontalConfig) -> dict[str, Any]: + """Apply reflect caps to Hindsight config programmatically. + + Called in get_cerebral() after Cerebral import. Hindsight re-reads + reflect_max_iterations at call time (not in _CONFIGURABLE_FIELDS), so + setting it on _get_raw_config() after import takes effect. + + The global cap is set to 2 × reflects (when reflects > 0) so that with + Hindsight's LOW budget multiplier (0.5×), both Prefrontal and briefing + refresh get exactly ``reflects`` iterations. + + Args: + cfg: PrefrontalConfig with reflects. + + Returns: + Dict of effective values applied (iterations). Empty dict when reflects == 0. + """ + # Set a minimal cap of 2 even when reflects == 0 to bound any Hindsight reflect + raw_cap = max(2 * max(cfg.reflects, 1), 1) + + try: + import hindsight_api.config as ha_cfg + + raw = ha_cfg._get_raw_config() + except Exception as e: + logger.warning("Could not access Hindsight raw config: %s", e) + return {} + + applied: dict[str, Any] = {} + + # Apply iteration cap: env wins as-is (operator raw cap), else double reflects + env_iters = os.environ.get(HINDSIGHT_REFLECT_MAX_ITERATIONS_ENV) + if env_iters is not None: + try: + applied["iterations"] = int(env_iters) + applied["budget"] = "low" + logger.info("Using env %s=%s (operator override)", HINDSIGHT_REFLECT_MAX_ITERATIONS_ENV, env_iters) + except ValueError: + pass + elif cfg.reflects > 0: + if hasattr(raw, "reflect_max_iterations"): + raw.reflect_max_iterations = raw_cap + applied["iterations"] = raw_cap + applied["budget"] = "low" + + # Hindsight's reflect_max_context_tokens default is 100000; we no longer + # override it. The default is used which allows full context without + # triggering split synthesis. + + if applied: + effective_iters = applied.get("iterations", 0) + # With LOW budget (0.5×), iterations = max(1, int(cap * 0.5)) + effective_reflects = max(1, int(effective_iters * 0.5)) + logger.info( + "Prefrontal reflect caps: iterations=%d (hindsight cap=%d, budget=low) context_tokens=100000", + effective_reflects, + effective_iters, + ) + + return applied + + +def get_prefrontal( + settings: Settings, + task_name: str, + repo_full_name: str | None, + scope_topic_ids: Sequence[str] | None = None, + include_repo: bool = False, +) -> Prefrontal | None: + """Return a Prefrontal for one agent call, or None when inactive. + + Active only when ``get_memory_enabled(task_name)`` is true, a repo is + known and ``get_cerebral`` returns a Cerebral. Otherwise the agent + behaves exactly as without Prefrontal. Never raises. + """ + try: + if not settings.get_memory_enabled(task_name) or not repo_full_name: + return None + cerebral = get_cerebral(settings) + if cerebral is None: + return None + from codespy.agents.memory.prefrontal import Prefrontal + + return Prefrontal( + settings.memory.prefrontal, + cerebral, + task_name, + repo_full_name, + scope_topic_ids=scope_topic_ids, + include_repo=include_repo, + ) + except Exception: + logger.warning("Prefrontal unavailable for %s", task_name, exc_info=True) + return None + + def reset_cerebral() -> None: """Clear the cached Cerebral instance so it is rebuilt on next access. diff --git a/tests/test_cerebral.py b/tests/test_cerebral.py index 2213fea..cdd52eb 100644 --- a/tests/test_cerebral.py +++ b/tests/test_cerebral.py @@ -149,6 +149,7 @@ def test_init_creates_engine_with_correct_params(self, mock_memory_engine_class, cross_encoder=mock_memory_engine_class.call_args.kwargs["cross_encoder"], tenant_extension=mock_memory_engine_class.call_args.kwargs["tenant_extension"], skip_llm_verification=mock_memory_engine_class.call_args.kwargs["skip_llm_verification"], + task_backend=mock_memory_engine_class.call_args.kwargs["task_backend"], ) def test_init_uses_metered_embeddings(self, mock_memory_engine_class, mock_litellm): @@ -194,6 +195,7 @@ def test_init_handles_none_base_url(self, mock_memory_engine_class, mock_litellm cross_encoder=mock_memory_engine_class.call_args.kwargs["cross_encoder"], tenant_extension=mock_memory_engine_class.call_args.kwargs["tenant_extension"], skip_llm_verification=mock_memory_engine_class.call_args.kwargs["skip_llm_verification"], + task_backend=mock_memory_engine_class.call_args.kwargs["task_backend"], ) @@ -231,6 +233,101 @@ def mock_memory_engine(*args, **kwargs): assert captured_mission is not None assert "Only RETRACTED invalidates a fact" in captured_mission + def test_ensure_bank_mission_is_domain_agnostic(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that retain_mission does not contain code-review-specific wording.""" + captured_mission = None + + async def mock_update_bank_config(bank_id, updates, request_context): + nonlocal captured_mission + captured_mission = updates.get("retain_mission") + return None + + async def mock_coro(*args, **kwargs): + return None + + def mock_memory_engine(*args, **kwargs): + engine = MagicMock() + engine.initialize = mock_coro + engine.ensure_bank_profile = mock_coro + engine.update_bank_config = mock_update_bank_config + engine.close = mock_coro + return engine + + with patch("codespy.agents.memory.cerebral.cerebral.MemoryEngine", mock_memory_engine): + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="litellm", + ) + cerebral._bank_ensured = False + cerebral._ensure_bank() + + assert captured_mission is not None + assert "code review" not in captured_mission.lower() + assert "architectural decisions" not in captured_mission.lower() + assert "dependency relationships" not in captured_mission.lower() + assert "repositories" not in captured_mission.lower() + + def test_ensure_bank_chunk_size_default(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that _ensure_bank sends retain_chunk_size == 12288 by default.""" + captured_chunk_size = None + + async def mock_update_bank_config(bank_id, updates, request_context): + nonlocal captured_chunk_size + captured_chunk_size = updates.get("retain_chunk_size") + return None + + async def mock_coro(*args, **kwargs): + return None + + def mock_memory_engine(*args, **kwargs): + engine = MagicMock() + engine.initialize = mock_coro + engine.ensure_bank_profile = mock_coro + engine.update_bank_config = mock_update_bank_config + engine.close = mock_coro + return engine + + with patch("codespy.agents.memory.cerebral.cerebral.MemoryEngine", mock_memory_engine): + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="litellm", + ) + cerebral._bank_ensured = False + cerebral._ensure_bank() + + assert captured_chunk_size == 12288 + + def test_ensure_bank_chunk_size_custom(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that _ensure_bank sends custom retain_chunk_size when provided.""" + captured_chunk_size = None + + async def mock_update_bank_config(bank_id, updates, request_context): + nonlocal captured_chunk_size + captured_chunk_size = updates.get("retain_chunk_size") + return None + + async def mock_coro(*args, **kwargs): + return None + + def mock_memory_engine(*args, **kwargs): + engine = MagicMock() + engine.initialize = mock_coro + engine.ensure_bank_profile = mock_coro + engine.update_bank_config = mock_update_bank_config + engine.close = mock_coro + return engine + + with patch("codespy.agents.memory.cerebral.cerebral.MemoryEngine", mock_memory_engine): + cerebral = Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="litellm", + retain_chunk_size=5000, + ) + cerebral._bank_ensured = False + cerebral._ensure_bank() + + assert captured_chunk_size == 5000 + def test_ensure_bank_called_once(self, mock_memory_engine_class, mock_engine, mock_litellm): """Test that _ensure_bank is only called once.""" with patch.object(Cerebral, "_run_async") as mock_run_async: @@ -697,3 +794,558 @@ def test_evicted_add_emits_content(self): [_mut("ADD", "cu-a", content="gone"), _mut("EVICT", "cu-a", previous="gone")] ) assert Cerebral._mutation_lines(ep) == ["[context_understanding] gone"] + + +# --------------------------------------------------------------------------- +# Prefrontal-related Cerebral behaviour +# --------------------------------------------------------------------------- + +from unittest.mock import AsyncMock # noqa: E402 + +from hindsight_api.engine.memory_engine import Budget # noqa: E402 +from hindsight_api.engine.task_backend import SyncTaskBackend # noqa: E402 +from hindsight_api.extensions import OperationValidationError # noqa: E402 + +from codespy.agents.memory.cerebral.cerebral import ( # noqa: E402 + MENTAL_MODEL_PLACEHOLDER, + MENTAL_MODEL_SOURCE_QUERY, + OBSERVATIONS_MISSION, +) +from codespy.agents.memory.prefrontal.reach import mental_model_id # noqa: E402 + + +@pytest.fixture +def async_engine(): + """A MemoryEngine mock whose coroutine methods are AsyncMocks.""" + engine = MagicMock() + for name in ( + "initialize", + "ensure_bank_profile", + "update_bank_config", + "retain_batch_async", + "recall_async", + "reflect_async", + "get_mental_model", + "create_mental_model", + "submit_async_refresh_mental_model", + "get_observation_history", + "get_bank_profile", + "close", + ): + setattr(engine, name, AsyncMock(return_value=None)) + # Default get_bank_profile to return a profile so existing tests pass + engine.get_bank_profile.return_value = {"bank_id": "codespy"} + return engine + + +@pytest.fixture +def cerebral_async(async_engine, mock_litellm): + with patch("codespy.agents.memory.cerebral.cerebral.MemoryEngine", return_value=async_engine) as cls, \ + patch("codespy.agents.memory.cerebral.cerebral.ensure_maintenance_routines", AsyncMock()): + c = Cerebral(database_url="postgresql://localhost:5432/test", llm_provider="litellm") + c._bank_ensured = True + c._engine_cls = cls + yield c + c.close() + + +class TestCerebralTaskBackend: + def test_sync_task_backend_passed_to_engine(self, mock_memory_engine_class, mock_litellm): + Cerebral(database_url="postgresql://localhost:5432/test", llm_provider="litellm") + assert isinstance(mock_memory_engine_class.call_args.kwargs["task_backend"], SyncTaskBackend) + # Check that the subclass has the schema set correctly + backend = mock_memory_engine_class.call_args.kwargs["task_backend"] + assert backend._schema == "semantic" + + def test_schema_sync_task_backend_injects_schema(self): + """Test that _SchemaSyncTaskBackend injects _schema into task dicts.""" + from codespy.agents.memory.cerebral.cerebral import _SchemaSyncTaskBackend, HINDSIGHT_SCHEMA + + backend = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) + received_task = {} + + async def mock_executor(task_dict): + received_task.update(task_dict) + + backend.set_executor(mock_executor) + + import asyncio + asyncio.run(backend.submit_task({"type": "consolidation"})) + + assert received_task.get("_schema") == "semantic" + + def test_schema_sync_task_backend_preserves_existing_schema(self): + """Test that _SchemaSyncTaskBackend preserves existing _schema values.""" + from codespy.agents.memory.cerebral.cerebral import _SchemaSyncTaskBackend, HINDSIGHT_SCHEMA + + backend = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) + received_task = {} + + async def mock_executor(task_dict): + received_task.update(task_dict) + + backend.set_executor(mock_executor) + + import asyncio + asyncio.run(backend.submit_task({"type": "consolidation", "_schema": "other"})) + + assert received_task.get("_schema") == "other" + + def test_schema_sync_task_backend_does_not_mutate_original(self): + """Test that _SchemaSyncTaskBackend does not mutate the caller's task dict.""" + from codespy.agents.memory.cerebral.cerebral import _SchemaSyncTaskBackend, HINDSIGHT_SCHEMA + + backend = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) + received_task = {} + + async def mock_executor(task_dict): + received_task.update(task_dict) + + backend.set_executor(mock_executor) + + original = {"type": "consolidation"} + original_copy = dict(original) + + import asyncio + asyncio.run(backend.submit_task(original)) + + assert original == original_copy # Original should be unchanged + assert received_task.get("_schema") == "semantic" + + +class TestCerebralReflectModel: + def test_reflect_llm_forwarded_when_set(self, mock_memory_engine_class, mock_litellm): + Cerebral( + database_url="postgresql://localhost:5432/test", + llm_provider="litellm", + llm_model="openai/gpt-4o", + reflect_llm_model="anthropic/claude-haiku", + reflect_llm_api_key="sk-ant", + ) + kwargs = mock_memory_engine_class.call_args.kwargs + assert kwargs["memory_llm_model"] == "openai/gpt-4o" + assert kwargs["reflect_llm_provider"] == "litellm" + assert kwargs["reflect_llm_model"] == "anthropic/claude-haiku" + assert kwargs["reflect_llm_api_key"] == "sk-ant" + assert kwargs["reflect_llm_base_url"] is None + + def test_reflect_llm_omitted_by_default(self, mock_memory_engine_class, mock_litellm): + Cerebral(database_url="postgresql://localhost:5432/test", llm_provider="litellm") + kwargs = mock_memory_engine_class.call_args.kwargs + assert not any(k.startswith("reflect_llm_") for k in kwargs) + + +class TestCerebralObservationsMission: + def test_observations_mission_sent(self, mock_litellm): + captured = {} + + async def update_bank_config(bank_id, updates, request_context): + captured.update(updates) + + engine = MagicMock() + engine.initialize = AsyncMock() + engine.ensure_bank_profile = AsyncMock() + engine.update_bank_config = update_bank_config + engine.close = AsyncMock() + with patch("codespy.agents.memory.cerebral.cerebral.MemoryEngine", return_value=engine), \ + patch("codespy.agents.memory.cerebral.cerebral.ensure_maintenance_routines", AsyncMock()): + c = Cerebral(database_url="postgresql://x/y", llm_provider="litellm") + c._ensure_bank() + c.close() + assert captured["observations_mission"] == OBSERVATIONS_MISSION + assert "RETRACTED" in OBSERVATIONS_MISSION and "supersedes:" in OBSERVATIONS_MISSION + + +class TestCerebralRetainScopes: + def test_tags_with_repo(self, episode): + tags = Cerebral._build_tags(episode, "test/repo") + assert "repo:test/repo" in tags + assert "org:test" in tags + assert tags.index("repo:test/repo") < tags.index(f"episode:{episode.id}") + + def test_tags_without_repo_unchanged(self, episode): + assert Cerebral._build_tags(episode) == Cerebral._build_tags(episode, None) + assert not any(t.startswith(("repo:", "org:")) for t in Cerebral._build_tags(episode)) + + def test_observation_scopes_per_project_scope(self, episode): + scopes = Cerebral._observation_scopes(episode, "test/repo") + assert scopes == [["org:test", "repo:test/repo", "project_scope:test/repo/package"]] + + def test_observation_scopes_without_project_scope(self): + ep = Episode( + id=uuid.uuid4(), run_id="r", timestamp=datetime.now(UTC), task="summary", + module="m", question="q", artifacts={}, context_memory=ContextMemory(), + ) + assert Cerebral._observation_scopes(ep, "o/r") == [["org:o", "repo:o/r"]] + + def test_observation_scopes_none_without_repo(self, episode): + assert Cerebral._observation_scopes(episode, None) is None + + def test_scope_excludes_run_tags(self, episode): + for scope in Cerebral._observation_scopes(episode, "test/repo"): + assert not any(t.startswith(("task:", "episode:", "run_id:", "pull_request:")) for t in scope) + + def test_retain_sets_observation_scopes_on_both_items(self, cerebral_async, async_engine, episode): + cerebral_async._mental_models = False + cerebral_async.retain_episode(episode, repo_full_name="test/repo") + contents = async_engine.retain_batch_async.await_args.kwargs["contents"] + assert len(contents) == 2 + for item in contents: + assert item["observation_scopes"] == [ + ["org:test", "repo:test/repo", "project_scope:test/repo/package"] + ] + assert "repo:test/repo" in item["tags"] and "org:test" in item["tags"] + + def test_retain_without_repo_has_no_scopes(self, cerebral_async, async_engine, episode): + cerebral_async.retain_episode(episode) + contents = async_engine.retain_batch_async.await_args.kwargs["contents"] + assert all("observation_scopes" not in item for item in contents) + async_engine.get_mental_model.assert_not_awaited() + + def test_recalls_never_retained(self, cerebral_async, async_engine, episode): + """Episode.recalls is monitoring data: retain_episode must ignore it.""" + from codespy.agents.memory.recall import RecallRecord + + cerebral_async._mental_models = False + cerebral_async.retain_episode(episode, repo_full_name="test/repo") + without = async_engine.retain_batch_async.await_args.kwargs["contents"] + + with_recalls = episode.model_copy( + update={ + "recalls": [ + RecallRecord(ordinal=0, kind="load", text="RECALLED-TEXT", status="ok") + ] + } + ) + cerebral_async.retain_episode(with_recalls, repo_full_name="test/repo") + with_ = async_engine.retain_batch_async.await_args.kwargs["contents"] + + assert with_ == without + assert all("RECALLED-TEXT" not in item["content"] for item in with_) + + +class TestEnsureMentalModels: + def test_ids_are_deterministic(self): + a = mental_model_id(["repo:o/r", "org:o"]) + b = mental_model_id(["org:o", "repo:o/r"]) + assert a == b + assert a.startswith("mm-") and len(a) == 3 + 32 + assert a != mental_model_id(["org:o", "repo:o/other"]) + + def test_retain_ensures_scope_and_repo_models(self, cerebral_async, async_engine, episode): + cerebral_async.retain_episode(episode, repo_full_name="test/repo") + created = [c.kwargs["mental_model_id"] for c in async_engine.create_mental_model.await_args_list] + assert created == [ + mental_model_id(["org:test", "repo:test/repo", "project_scope:test/repo/package"]), + mental_model_id(["org:test", "repo:test/repo"]), + ] + first = async_engine.create_mental_model.await_args_list[0] + assert first.args[1] == "Briefing: test/repo/package" + assert first.args[2] == MENTAL_MODEL_SOURCE_QUERY + assert first.args[3] == MENTAL_MODEL_PLACEHOLDER + assert first.kwargs["trigger"] == {"refresh_after_consolidation": True, "exclude_mental_models": True} + assert first.kwargs["max_tokens"] == 2048 + second = async_engine.create_mental_model.await_args_list[1] + assert second.args[1] == "Briefing: test/repo" + # No longer calls submit_async_refresh_mental_model - consolidation handles refresh + + def test_existing_model_not_created(self, cerebral_async, async_engine): + async_engine.get_mental_model.return_value = {"id": "x", "content": "c"} + cerebral_async._sync_briefing_triggers([["org:o", "repo:o/r"]]) + async_engine.create_mental_model.assert_not_awaited() + + def test_409_tolerated(self, cerebral_async, async_engine): + async_engine.create_mental_model.side_effect = OperationValidationError("exists", status_code=409) + cerebral_async._sync_briefing_triggers([["org:o", "repo:o/r"]]) + assert mental_model_id(["org:o", "repo:o/r"]) in cerebral_async._ensured_mental_models + + def test_second_call_served_from_cache(self, cerebral_async, async_engine): + cerebral_async._sync_briefing_triggers([["org:o", "repo:o/r"]]) + cerebral_async._sync_briefing_triggers([["org:o", "repo:o/r"]]) + assert async_engine.get_mental_model.await_count == 1 + + def test_disabled(self, cerebral_async, async_engine, episode): + cerebral_async._mental_models = False + cerebral_async._sync_briefing_triggers([["org:test", "repo:test/repo"]]) + # When mental_models is False, get_mental_model is still called to check + # for existing briefings that need their trigger updated + async_engine.get_mental_model.assert_awaited() + # But create is not called when mental_models is False + async_engine.create_mental_model.assert_not_awaited() + + def test_failures_only_log(self, cerebral_async, async_engine): + async_engine.get_mental_model.side_effect = RuntimeError("db down") + cerebral_async._sync_briefing_triggers([["org:o", "repo:o/r"]]) # no raise + assert not cerebral_async._ensured_mental_models + + def test_non_409_validation_error_logged(self, cerebral_async, async_engine): + async_engine.create_mental_model.side_effect = OperationValidationError("forbidden", status_code=403) + cerebral_async._sync_briefing_triggers([["org:o", "repo:o/r"]]) # no raise + assert not cerebral_async._ensured_mental_models + + +class TestCerebralReadApi: + def test_arecall_forwards_arguments(self, cerebral_async, async_engine): + import asyncio as _asyncio + + fact = MagicMock() + async_engine.recall_async.return_value = MagicMock(results=[fact]) + when = datetime(2026, 9, 29, tzinfo=UTC) + groups = [object()] + facts = _asyncio.run( + cerebral_async.arecall( + "q", + tag_groups=groups, + max_tokens=123, + fact_type=["world", "observation"], + prefer_observations=True, + budget="high", + question_date=when, + ) + ) + assert facts == [fact] + call = async_engine.recall_async.await_args + assert call.args == ("codespy", "q") + assert call.kwargs["tag_groups"] is groups + assert call.kwargs["max_tokens"] == 123 + assert call.kwargs["fact_type"] == ["world", "observation"] + assert call.kwargs["prefer_observations"] is True + assert call.kwargs["budget"] == Budget.HIGH + assert call.kwargs["question_date"] == when + + def test_recall_sync(self, cerebral_async, async_engine): + async_engine.recall_async.return_value = MagicMock(results=[]) + assert cerebral_async.recall("q") == [] + + def test_areflect_uses_mid_budget(self, cerebral_async, async_engine): + import asyncio as _asyncio + + async_engine.reflect_async.return_value = MagicMock(text=" answer ") + text, summary = _asyncio.run( + cerebral_async.areflect("q", tag_groups=None, max_tokens=50, context="code_review on o/r") + ) + assert text == "answer" + assert summary.empty is False + call = async_engine.reflect_async.await_args + assert call.kwargs["budget"] == Budget.LOW + assert call.kwargs["max_tokens"] == 50 + assert call.kwargs["context"] == "code_review on o/r" + + def test_areflect_uses_low_budget(self, cerebral_async, async_engine): + """Test that areflect uses LOW budget (with 0.5x multiplier, cap is doubled).""" + import asyncio as _asyncio + + async_engine.reflect_async.return_value = MagicMock(text="answer") + text, summary = _asyncio.run( + cerebral_async.areflect( + "q", + tag_groups=None, + max_tokens=50, + context="code_review", + ) + ) + call = async_engine.reflect_async.await_args + assert call.kwargs["budget"] == Budget.LOW + + def test_areflect_basic(self, cerebral_async, async_engine): + """Test basic areflect call.""" + import asyncio as _asyncio + + async_engine.reflect_async.return_value = MagicMock(text="answer") + text, summary = _asyncio.run( + cerebral_async.areflect( + "q", + tag_groups=None, + max_tokens=50, + context="code_review", + ) + ) + assert text == "answer" + assert summary.empty is False + + def test_areflect_returns_reflect_summary(self, cerebral_async, async_engine): + import asyncio as _asyncio + + # Create a mock result with the real hindsight_api shape + # llm_trace is a list of objects with 'scope' attribute + # tool_trace is a list of objects with 'tool', 'input', 'output' attributes + from unittest.mock import Mock + + mock_result = MagicMock() + mock_result.text = "reflected answer" + + # Mock llm_trace as list of objects with 'scope' attribute + mock_result.llm_trace = [ + Mock(scope="agent_1"), + Mock(scope="final_map_1"), + Mock(scope="final_map_2"), + Mock(scope="final"), + Mock(scope="final_rewrite"), + ] + + # Mock tool_trace as list of objects with 'tool', 'output' attributes + mock_result.tool_trace = [ + Mock(tool="search_observations", input="q", output={"results": ["a"] * 50}), + Mock(tool="recall", input="q", output={"text": "x" * 100}), + ] + + # Mock usage as TokenUsage-like object + mock_result.usage = Mock(input_tokens=500, output_tokens=200, thoughts_tokens=50) + + async_engine.reflect_async.return_value = mock_result + text, summary = _asyncio.run(cerebral_async.areflect("q")) + + assert text == "reflected answer" + # iterations = agent_ scopes (1: agent_1) + 1 for final = 2 + assert summary.iterations == 2 + # llm_calls = 5 (all scopes) + assert summary.llm_calls == 5 + # map_calls = scopes starting with final_map_ (2) + assert summary.map_calls == 2 + # rewrite = True (has final_rewrite scope) + assert summary.rewrite is True + assert summary.empty is False + # tools = [(name, output_tokens)] from tool_trace + assert len(summary.tools) == 2 + assert summary.tools[0][0] == "search_observations" + assert summary.tools[1][0] == "recall" + # usage contains token counts + assert summary.usage["input_tokens"] == 500 + assert summary.usage["output_tokens"] == 200 + assert summary.usage["thoughts_tokens"] == 50 + + def test_aget_mental_models_skips_missing(self, cerebral_async, async_engine): + import asyncio as _asyncio + + async def get(bank_id, mm_id, request_context): + return None if mm_id == "b" else {"id": mm_id} + + async_engine.get_mental_model.side_effect = get + models = _asyncio.run(cerebral_async.aget_mental_models(["a", "b", "c"])) + assert [m["id"] for m in models] == ["a", "c"] + + def test_aget_observation_history(self, cerebral_async, async_engine): + import asyncio as _asyncio + + async_engine.get_observation_history.return_value = [{"previous_text": "x"}] + assert _asyncio.run(cerebral_async.aget_observation_history("id-1")) == [{"previous_text": "x"}] + assert async_engine.get_observation_history.await_args.args == ("codespy", "id-1") + async_engine.get_observation_history.return_value = None + assert _asyncio.run(cerebral_async.aget_observation_history("id-2")) == [] + + +class TestCerebralMissingBank: + """Tests for Cerebral read behavior when bank does not exist yet.""" + + def test_arecall_returns_empty_when_bank_missing(self, cerebral_async, async_engine): + """arecall returns [] and does not call recall_async when bank is missing.""" + import asyncio as _asyncio + + async_engine.get_bank_profile.return_value = None + cerebral_async._bank_exists = False + cerebral_async._missing_bank_logged = False + + facts = _asyncio.run(cerebral_async.arecall("q")) + assert facts == [] + async_engine.recall_async.assert_not_awaited() + + def test_areflect_returns_empty_when_bank_missing(self, cerebral_async, async_engine): + """areflect returns ("", summary) with empty=True when bank is missing.""" + import asyncio as _asyncio + + async_engine.get_bank_profile.return_value = None + cerebral_async._bank_exists = False + cerebral_async._missing_bank_logged = False + + text, summary = _asyncio.run(cerebral_async.areflect("q")) + assert text == "" + assert summary.empty is True + assert summary.llm_calls == 0 + async_engine.reflect_async.assert_not_awaited() + + def test_aget_mental_models_returns_empty_when_bank_missing(self, cerebral_async, async_engine): + """aget_mental_models returns [] when bank is missing.""" + import asyncio as _asyncio + + async_engine.get_bank_profile.return_value = None + cerebral_async._bank_exists = False + cerebral_async._missing_bank_logged = False + + models = _asyncio.run(cerebral_async.aget_mental_models(["a", "b"])) + assert models == [] + async_engine.get_mental_model.assert_not_awaited() + + def test_aget_observation_history_returns_empty_when_bank_missing(self, cerebral_async, async_engine): + """aget_observation_history returns [] when bank is missing.""" + import asyncio as _asyncio + + async_engine.get_bank_profile.return_value = None + cerebral_async._bank_exists = False + cerebral_async._missing_bank_logged = False + + history = _asyncio.run(cerebral_async.aget_observation_history("id-1")) + assert history == [] + async_engine.get_observation_history.assert_not_awaited() + + def test_missing_not_cached_exists_becomes_true(self, cerebral_async, async_engine): + """Missing is not cached: when bank is created, subsequent reads work.""" + import asyncio as _asyncio + + # First call: bank missing + async_engine.get_bank_profile.return_value = None + cerebral_async._bank_exists = False + cerebral_async._missing_bank_logged = False + + facts = _asyncio.run(cerebral_async.arecall("q")) + assert facts == [] + assert async_engine.get_bank_profile.await_count == 1 + + # Second call: bank now exists (e.g., retain_episode was called) + async_engine.get_bank_profile.return_value = {"bank_id": "codespy"} + # _bank_exists is still False because we didn't call _ensure_bank + cerebral_async._bank_exists = False + + # Reset recall_async mock to track new calls + async_engine.recall_async.return_value = MagicMock(results=[MagicMock()]) + + facts = _asyncio.run(cerebral_async.arecall("q")) + # Should have called get_bank_profile again and now recall_async + assert async_engine.get_bank_profile.await_count == 2 + async_engine.recall_async.assert_awaited_once() + + def test_exists_is_cached_no_duplicate_checks(self, cerebral_async, async_engine): + """Exists is cached: multiple arecalls only check once.""" + import asyncio as _asyncio + + async_engine.get_bank_profile.return_value = {"bank_id": "codespy"} + cerebral_async._bank_exists = False + cerebral_async._missing_bank_logged = False + + async_engine.recall_async.return_value = MagicMock(results=[]) + + # Two arecalls + _asyncio.run(cerebral_async.arecall("q1")) + _asyncio.run(cerebral_async.arecall("q2")) + + # get_bank_profile should only be awaited once + assert async_engine.get_bank_profile.await_count == 1 + # recall_async should be awaited twice + assert async_engine.recall_async.await_count == 2 + + def test_ensure_bank_sets_bank_exists(self, cerebral_async, async_engine): + """_ensure_bank success sets _bank_exists so get_bank_profile is not called.""" + cerebral_async._bank_exists = False + cerebral_async._bank_ensured = False + + cerebral_async._ensure_bank() + + assert cerebral_async._bank_exists is True + # After _ensure_bank, arecall should not call get_bank_profile + async_engine.get_bank_profile.reset_mock() + async_engine.recall_async.return_value = MagicMock(results=[]) + + import asyncio as _asyncio + _asyncio.run(cerebral_async.arecall("q")) + + async_engine.get_bank_profile.assert_not_awaited() + async_engine.recall_async.assert_awaited_once() diff --git a/tests/test_cerebral_cost.py b/tests/test_cerebral_cost.py index 9427ca1..3698519 100644 --- a/tests/test_cerebral_cost.py +++ b/tests/test_cerebral_cost.py @@ -9,6 +9,8 @@ from codespy.agents.memory.cerebral.cost import ( BUCKET_MEMORY_EMBEDDINGS, BUCKET_MEMORY_OTHER, + BUCKET_MEMORY_PREFRONTAL, + BUCKET_MEMORY_PREFRONTAL_EMBEDDINGS, BUCKET_MEMORY_RETAIN, CerebralCostRecorder, MeteredLiteLLMSDKEmbeddings, @@ -17,6 +19,7 @@ register_cerebral_cost_recorder, unregister_cerebral_cost_recorder, ) +from codespy.agents.memory.recall import current_recall_usage, track_recall_usage @pytest.fixture @@ -213,6 +216,81 @@ def test_record_llm_call_logs_warning_for_unpriced_model( assert caplog.text.count("No litellm price for model") == 1 +class TestRecallUsageAttribution: + """LLM/embedding calls inside track_recall_usage() are billed to memory_prefrontal.""" + + def test_llm_call_inside_recall_goes_to_prefrontal(self, mock_tracker, fresh_recorder): + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.03, 0.02)): + with track_recall_usage() as usage: + CerebralCostRecorder().record_llm_call( + model="openai/gpt-4o", + scope="reflect", + input_tokens=100, + output_tokens=40, + ) + assert usage.llm_calls == 1 + assert usage.input_tokens == 100 and usage.output_tokens == 40 + assert usage.input_cost == 0.03 and usage.output_cost == 0.02 + assert usage.to_model_field() == "openai/gpt-4o" + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_PREFRONTAL).cost == 0.05 + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_OTHER) is None + + def test_llm_call_outside_recall_unchanged(self, mock_tracker, fresh_recorder): + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.01, 0.01)): + CerebralCostRecorder().record_llm_call( + model="openai/gpt-4o", scope="reflect", input_tokens=10, output_tokens=5 + ) + assert current_recall_usage.get() is None + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_OTHER) is not None + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_PREFRONTAL) is None + + @pytest.mark.asyncio + async def test_embedding_inside_recall_goes_to_prefrontal_embeddings(self, mock_tracker): + response = MagicMock() + response.usage = MagicMock() + response.usage.prompt_tokens = 12 + response._hidden_params = {"response_cost": 0.001} + real = MagicMock() + real.aembedding.return_value = asyncio.Future() + real.aembedding.return_value.set_result(response) + + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + proxy = _LiteLLMProxy(real, CerebralCostRecorder()) + with track_recall_usage() as usage: + await proxy.aembedding(model="openai/text-embedding-3-small", input=["q"]) + + assert usage.input_tokens == 12 and usage.output_tokens == 0 + assert usage.input_cost == 0.001 + assert usage.llm_calls == 0 # embeddings are not LLM calls + # Embeddings during active recall go to the new prefrontal_embeddings bucket + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_PREFRONTAL_EMBEDDINGS).tokens == 12 + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_PREFRONTAL) is None + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_EMBEDDINGS) is None + + def test_usage_propagates_to_cross_thread_loop(self): + """Cerebral._await submits to its own loop thread: the contextvar must follow.""" + import threading + + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + try: + + async def read_usage(): + return current_recall_usage.get() + + with track_recall_usage() as usage: + seen = asyncio.run_coroutine_threadsafe(read_usage(), loop).result(timeout=5) + assert seen is usage + after = asyncio.run_coroutine_threadsafe(read_usage(), loop).result(timeout=5) + assert after is None + finally: + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=5) + + class TestLiteLLMProxy: """Tests for _LiteLLMProxy class.""" diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index 069cf21..c8f9f24 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -253,3 +253,297 @@ def test_override_memory_retain_chunk_size(self, monkeypatch): config = {} result = apply_env_overrides(config, _ENV_MAP) assert result["memory"]["cerebral"]["retain"]["chunk_size"] == "5000" + + +class TestPrefrontalConfig: + """Tests for memory.prefrontal config, env mapping and get_cerebral wiring.""" + + ENV = { + "MEMORY_PREFRONTAL_MODEL": ("model", "openai/gpt-4o-mini"), + "MEMORY_PREFRONTAL_REACH": ("prefrontal_reach", "bank"), + "MEMORY_PREFRONTAL_REFLECTS": ("reflects", "3"), + "MEMORY_MAX_MENTAL_MODEL_TOKENS": ("max_mental_model_tokens", "1000"), + "MEMORY_MAX_PREFRONTAL_TOKENS": ("max_prefrontal_tokens", "2000"), + "MEMORY_MAX_PREFRONTAL_TOOL_TOKENS": ("max_prefrontal_tool_tokens", "700"), + "MEMORY_MAX_PREFRONTAL_TOOL_CALLS": ("max_prefrontal_tool_calls", "2"), + } + + def test_defaults(self): + from codespy.config_memory import MemoryConfig + + pf = MemoryConfig().prefrontal + assert pf.model is None + assert pf.prefrontal_reach == "org" + assert pf.reflects == 3 # Changed from 5 to 3 + assert pf.max_mental_model_tokens == 2048 + assert pf.max_prefrontal_tokens == 8192 # Changed from 16384 to 8192 + assert pf.max_prefrontal_tool_tokens == 2048 + assert pf.max_prefrontal_tool_calls == 0 + + def test_env_mapping(self, monkeypatch): + from codespy.config import _ENV_MAP + from codespy.config_memory import PrefrontalConfig + from codespy.config_utils import apply_env_overrides + + for env, (_, value) in self.ENV.items(): + monkeypatch.setenv(env, value) + result = apply_env_overrides({}, _ENV_MAP)["memory"]["prefrontal"] + for env, (field, _) in self.ENV.items(): + assert _ENV_MAP[env] == ("memory", "prefrontal", field) + assert field in result + cfg = PrefrontalConfig(**result) + assert cfg.prefrontal_reach == "bank" + assert cfg.reflects == 3 + assert cfg.max_prefrontal_tool_calls == 2 + + def test_rejects_negative_reflects(self): + from codespy.config_memory import PrefrontalConfig + + with pytest.raises(ValidationError): + PrefrontalConfig(reflects=-1) + + def test_rejects_invalid_reach(self): + from codespy.config_memory import PrefrontalConfig + + with pytest.raises(ValidationError): + PrefrontalConfig(prefrontal_reach="world") + + def test_reflection_modules_unchanged(self): + from codespy.config_memory import REFLECTION_MODULES + + assert REFLECTION_MODULES == ("memory_distiller", "memory_cartographer") + + def test_apply_reflect_config_returns_empty_when_reflects_zero(self): + from codespy.config_memory import PrefrontalConfig, _apply_reflect_config + + cfg = PrefrontalConfig(reflects=0) + result = _apply_reflect_config(cfg) + assert result == {} + + def test_apply_reflect_config_applies_values(self, monkeypatch): + pytest = __import__("pytest") + ha = pytest.importorskip("hindsight_api") + from codespy.config_memory import PrefrontalConfig, _apply_reflect_config + + cfg = PrefrontalConfig(reflects=3) + # Clear env vars to ensure config values are applied + monkeypatch.delenv("HINDSIGHT_API_REFLECT_MAX_ITERATIONS", raising=False) + + result = _apply_reflect_config(cfg) + + # With reflects=3, the cap is set to 2*3=6 (doubled for LOW budget) + assert result["iterations"] == 6 + assert result["budget"] == "low" + + def test_apply_reflect_config_env_wins_over_config(self, monkeypatch): + pytest = __import__("pytest") + pytest.importorskip("hindsight_api") + from codespy.config_memory import ( + HINDSIGHT_REFLECT_MAX_ITERATIONS_ENV, + HINDSIGHT_REFLECT_MAX_CONTEXT_TOKENS_ENV, + PrefrontalConfig, + _apply_reflect_config, + ) + + monkeypatch.setenv(HINDSIGHT_REFLECT_MAX_ITERATIONS_ENV, "10") + monkeypatch.setenv(HINDSIGHT_REFLECT_MAX_CONTEXT_TOKENS_ENV, "20000") + + cfg = PrefrontalConfig(reflects=3) + result = _apply_reflect_config(cfg) + + assert result["iterations"] == 10 # env wins (used as-is, not doubled) + assert result["budget"] == "low" + + def test_apply_reflect_llm_call_defaults(self, monkeypatch): + """Test _apply_reflect_llm_call_defaults sets timeout/retries from settings.""" + pytest = __import__("pytest") + pytest.importorskip("hindsight_api") + from codespy.config_memory import ( + PrefrontalConfig, + _apply_reflect_llm_call_defaults, + _apply_reflect_config, + ) + + # Clear env vars + monkeypatch.delenv("HINDSIGHT_API_REFLECT_LLM_TIMEOUT", raising=False) + monkeypatch.delenv("HINDSIGHT_API_LLM_TIMEOUT", raising=False) + monkeypatch.delenv("HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES", raising=False) + monkeypatch.delenv("HINDSIGHT_API_LLM_MAX_RETRIES", raising=False) + + settings = MagicMock() + settings.llm.timeout = 240.0 + settings.llm.retries = 3 + + _apply_reflect_llm_call_defaults(settings) + + # Verify raw config was updated (if hindsight_api is available) + try: + import hindsight_api.config as ha_cfg + + raw = ha_cfg._get_raw_config() + if hasattr(raw, "reflect_llm_timeout"): + assert raw.reflect_llm_timeout == 240.0 + if hasattr(raw, "reflect_llm_max_retries"): + assert raw.reflect_llm_max_retries == 3 + except Exception: + pass # If hindsight_api not available, test passes by skipping + + def test_apply_reflect_llm_call_defaults_env_wins(self, monkeypatch): + """Test that env vars override settings for reflect LLM defaults.""" + pytest = __import__("pytest") + pytest.importorskip("hindsight_api") + + monkeypatch.setenv("HINDSIGHT_API_REFLECT_LLM_TIMEOUT", "60") + monkeypatch.setenv("HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES", "5") + + from codespy.config_memory import _apply_reflect_llm_call_defaults + + settings = MagicMock() + settings.llm.timeout = 240.0 + settings.llm.retries = 3 + + _apply_reflect_llm_call_defaults(settings) + + try: + import hindsight_api.config as ha_cfg + + raw = ha_cfg._get_raw_config() + if hasattr(raw, "reflect_llm_timeout"): + assert raw.reflect_llm_timeout == 60.0 + if hasattr(raw, "reflect_llm_max_retries"): + assert raw.reflect_llm_max_retries == 5 + except Exception: + pass + + def _settings(self, reflects): + from codespy.config_memory import MemoryConfig + + settings = MagicMock() + settings.memory = MemoryConfig() + settings.memory.prefrontal.reflects = reflects + settings.memory.postgres.host = "db" + settings.get_llm_config.return_value = MagicMock(model="openai/gpt-4o") + settings.llm.openai_api_key = None + settings.llm.openai_api_base = None + return settings + + # Note: Removed old test_get_cerebral_sets_env_before_import - now using programmatic + # config via _apply_reflect_config instead of env vars. See tests for _apply_reflect_config. + + def _capture_cerebral_kwargs(self, monkeypatch, settings) -> dict: + import codespy.config_memory as cm + from codespy.agents.memory import cerebral as cerebral_module + + seen: dict = {} + + def fake_cerebral(**kwargs): + seen.update(kwargs) + return MagicMock() + + monkeypatch.setattr(cm, "_cerebral", None) + monkeypatch.setattr(cm, "_cerebral_built", False) + monkeypatch.setattr(cerebral_module, "Cerebral", fake_cerebral) + assert cm.get_cerebral(settings) is not None + monkeypatch.setattr(cm, "_cerebral", None) + monkeypatch.setattr(cm, "_cerebral_built", False) + return seen + + def test_get_cerebral_without_prefrontal_model_passes_no_reflect_llm(self, monkeypatch): + pytest = __import__("pytest") + pytest.importorskip("hindsight_api") # Skip if hindsight_api not available + kwargs = self._capture_cerebral_kwargs(monkeypatch, self._settings(0)) + assert not any(k.startswith("reflect_llm_") for k in kwargs) + + def test_get_cerebral_passes_prefrontal_model_as_reflect_llm(self, monkeypatch): + pytest = __import__("pytest") + pytest.importorskip("hindsight_api") # Skip if hindsight_api not available + from pydantic import SecretStr + + settings = self._settings(3) + settings.memory.prefrontal.model = "anthropic/claude-sonnet-4-5" + settings.llm.anthropic_api_key = SecretStr("sk-ant") + kwargs = self._capture_cerebral_kwargs(monkeypatch, settings) + # Engine model is unchanged; reflect gets its own model and credentials. + assert kwargs["llm_model"] == "openai/gpt-4o" + # Cerebral derives reflect_llm_provider from llm_provider itself; not passed by caller + assert "reflect_llm_provider" not in kwargs + assert kwargs["reflect_llm_model"] == "anthropic/claude-sonnet-4-5" + assert kwargs["reflect_llm_api_key"] == "sk-ant" + assert kwargs["reflect_llm_base_url"] is None + + def test_get_cerebral_reflect_kwargs_match_cerebral_signature(self, monkeypatch): + """Guard against signature drift: reflect kwargs must bind to Cerebral.__init__. + + Skipped when hindsight_api is not installed (e.g., in CI without the dependency). + """ + import inspect + + from pydantic import SecretStr + + # Skip if hindsight_api is not available (Cerebral requires it) + pytest = __import__("pytest") + pytest.importorskip("hindsight_api") + from codespy.agents.memory.cerebral.cerebral import Cerebral + + settings = self._settings(3) + + # Test without prefrontal model + settings_no_pf = self._settings(3) + settings_no_pf.memory.prefrontal.model = None + kwargs_no_pf = self._capture_cerebral_kwargs(monkeypatch, settings_no_pf) + # Should bind without error + inspect.signature(Cerebral.__init__).bind(None, **kwargs_no_pf) + + # Test with prefrontal model + settings.memory.prefrontal.model = "anthropic/claude-sonnet-4-5" + settings.llm.anthropic_api_key = SecretStr("sk-ant") + kwargs_with_pf = self._capture_cerebral_kwargs(monkeypatch, settings) + # Should bind without error + inspect.signature(Cerebral.__init__).bind(None, **kwargs_with_pf) + + +class TestPrefrontalModelResolution: + """get_llm_config('memory_prefrontal') fallback chain. + + Settings are built with explicit sections so a local ``.env`` cannot + override them. + """ + + def _settings(self, *, prefrontal=None, retain=None): + from codespy.config import Settings + from codespy.config_llm import LLMConfig + from codespy.config_memory import ( + CerebralConfig, + CerebralRetainConfig, + MemoryConfig, + PrefrontalConfig, + ) + + return Settings( + llm=LLMConfig(default_model="openai/gpt-4o"), + memory=MemoryConfig( + cerebral=CerebralConfig(retain=CerebralRetainConfig(model=retain)), + prefrontal=PrefrontalConfig(model=prefrontal), + ), + ) + + def test_explicit_prefrontal_model(self): + from codespy.config_memory import MEMORY_PREFRONTAL + + s = self._settings(prefrontal="anthropic/claude-haiku", retain="openai/gpt-4o-mini") + assert s.get_llm_config(MEMORY_PREFRONTAL).model == "anthropic/claude-haiku" + + def test_falls_back_to_retain_model(self): + from codespy.config_memory import MEMORY_PREFRONTAL + + s = self._settings(retain="openai/gpt-4o-mini") + assert s.get_llm_config(MEMORY_PREFRONTAL).model == "openai/gpt-4o-mini" + + def test_falls_back_to_default_model(self): + from codespy.config_memory import MEMORY_PREFRONTAL + + s = self._settings() + assert s.get_llm_config(MEMORY_PREFRONTAL).model == s.llm.default_model + + # Note: _warn_reflect_mismatch removed - now using programmatic _apply_reflect_config + # which sets values directly instead of warning about mismatches diff --git a/tests/test_episode_saves.py b/tests/test_episode_saves.py new file mode 100644 index 0000000..d444ef8 --- /dev/null +++ b/tests/test_episode_saves.py @@ -0,0 +1,200 @@ +"""Tests for episode save threading and at-exit behavior. + +These tests verify that background episode saves complete before the process +exits, preventing "cannot schedule new futures after shutdown" errors from +Cerebral consolidation embedding calls. +""" + +from __future__ import annotations + +import asyncio +import subprocess +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from codespy.agents.memory.hippocampus.episode import ( + join_episode_saves, + submit_episode_save, +) + + +class TestJoinEpisodeSaves: + """Tests for join_episode_saves behavior.""" + + def test_join_no_timeout_waits_for_long_thread(self) -> None: + """join_episode_saves with no timeout should wait for slow threads.""" + result: list[str] = [] + + def slow_save() -> None: + # Sleep longer than the old default timeout (120s) + time.sleep(0.3) + result.append("done") + + submit_episode_save(slow_save, name="slow-save") + join_episode_saves(timeout_per_thread=None) + + assert result == ["done"] + + def test_join_with_timeout_returns_early(self) -> None: + """join_episode_saves with explicit timeout should return early and warn.""" + result: list[str] = [] + + def slow_save() -> None: + time.sleep(1.0) # Longer than our timeout + result.append("done") + + submit_episode_save(slow_save, name="slow-timeout-save") + + # Should return immediately after timeout with warning + join_episode_saves(timeout_per_thread=0.1) + + # Result should not be set yet (thread still running) + assert result == [] + + def test_join_catches_saves_submitted_during_join(self) -> None: + """Saves submitted during join should also be joined.""" + results: list[str] = [] + + def first_save() -> None: + # This save submits another save + def second_save() -> None: + time.sleep(0.05) + results.append("second") + + submit_episode_save(second_save, name="second-save") + time.sleep(0.1) + results.append("first") + + submit_episode_save(first_save, name="first-save") + join_episode_saves(timeout_per_thread=None) + + assert "first" in results + assert "second" in results + + +class TestSubprocessRegression: + """Subprocess regression test for the executor shutdown race condition.""" + + def test_executor_not_shutdown_before_save_completes(self) -> None: + """Verify that ThreadPoolExecutor works during episode save at exit. + + This test runs in a subprocess to simulate the actual interpreter exit + scenario. It creates a daemon event loop thread (like Cerebral does), + submits an episode save that uses run_in_executor, and exits without + explicitly joining. The subprocess should complete successfully without + raising "cannot schedule new futures after shutdown". + """ + test_code = ''' +import asyncio +import sys +import threading +import time + +# Import episode module to trigger the at-exit hook registration +from codespy.agents.memory.hippocampus.episode import submit_episode_save + +# Create a daemon event loop thread like Cerebral does +loop = asyncio.new_event_loop() + +def run_loop(): + asyncio.set_event_loop(loop) + loop.run_forever() + +loop_thread = threading.Thread(target=run_loop, daemon=True) +loop_thread.start() + +result = [] + +def save_target(): + async def async_task(): + # This uses the loop's default executor (ThreadPoolExecutor) + # This is what litellm.aembedding does internally + return await loop.run_in_executor(None, lambda: 42) + + # Submit to the loop and wait for result + future = asyncio.run_coroutine_threadsafe(async_task(), loop) + val = future.result(timeout=5.0) + result.append(val) + print("OK", flush=True) + +submit_episode_save(save_target, name="test-save") + +# Main thread exits WITHOUT calling join_episode_saves() +# The at-exit hook should ensure the save completes +''' + # Run the test code in a subprocess + proc = subprocess.run( + [sys.executable, "-c", test_code], + capture_output=True, + text=True, + timeout=30, # Overall timeout for the subprocess + ) + + stdout = proc.stdout + stderr = proc.stderr + + # Should succeed + assert proc.returncode == 0, ( + f"Subprocess failed with return code {proc.returncode}.\n" + f"stdout: {stdout}\nstderr: {stderr}" + ) + + # Should print OK + assert "OK" in stdout, f"Expected 'OK' in stdout, got: {stdout}" + + # Should NOT have the executor shutdown error + assert "cannot schedule new futures after shutdown" not in stderr, ( + f"Got executor shutdown error: {stderr}" + ) + assert "cannot schedule new futures after shutdown" not in stdout, ( + f"Got executor shutdown error in stdout: {stdout}" + ) + + +class TestThreadPoolExecutorDuringSave: + """Tests verifying ThreadPoolExecutor behavior during episode saves.""" + + def test_loop_default_executor_works_in_save(self) -> None: + """ThreadPoolExecutor calls should work inside episode save threads.""" + results: list[int] = [] + + # Create an event loop in a daemon thread (simulating Cerebral's loop) + loop = asyncio.new_event_loop() + + def run_loop() -> None: + asyncio.set_event_loop(loop) + # Set up a default executor + executor = ThreadPoolExecutor(max_workers=2) + loop.set_default_executor(executor) + loop.run_forever() + # Cleanup + executor.shutdown(wait=True) + + loop_thread = threading.Thread(target=run_loop, daemon=True) + loop_thread.start() + + def save_with_executor() -> None: + async def async_work() -> int: + # This should work - uses the loop's default executor + return await loop.run_in_executor(None, lambda: 42) + + future = asyncio.run_coroutine_threadsafe(async_work(), loop) + val = future.result(timeout=5.0) + results.append(val) + + submit_episode_save(save_with_executor, name="executor-save") + join_episode_saves(timeout_per_thread=None) + + # Stop the loop + loop.call_soon_threadsafe(loop.stop) + loop_thread.join(timeout=2.0) + + assert results == [42] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_hippocampus.py b/tests/test_hippocampus.py index bc391c6..9f90d6e 100644 --- a/tests/test_hippocampus.py +++ b/tests/test_hippocampus.py @@ -959,3 +959,124 @@ def test_bindings_come_from_loaded_version(self, episode_store): obs = loaded.find_observation(oid)[1] assert obs.content == "v2" assert obs.topic_ids == [t2.id] + + +def _recall_records(): + from codespy.agents.memory.recall import RecallRecord + + return [ + RecallRecord( + ordinal=0, + kind="load", + query="context query", + reach="org", + reflects=5, + status="ok", + text="## Around this work\nRECALLED-SECRET-TEXT", + model="openai/gpt-4o-mini", + llm_calls=3, + input_tokens=1200, + output_tokens=300, + input_cost=0.012, + output_cost=0.006, + latency_ms=4200, + details={"briefings": 1, "remote": 2, "facets": {"context": {"count": 1}}}, + ), + RecallRecord( + ordinal=1, + kind="tool", + query="where is login", + reach="local", + status="limit", + text="recall limit reached", + details={"requested_reach": "bank", "mode": "reflect"}, + ), + ] + + +class TestEpisodeStoreRecalls: + """Prefrontal recalls are persisted to the recalls table, never distilled.""" + + def _episode(self, recalls): + import uuid + + return Episode( + id=uuid.uuid4(), + task="code_review", + module="m", + question="q", + context_memory=ContextMemory( + topics=[Topic(id="owner/repo/recalls", type="project_scope", description="t")] + ), + timestamp=datetime.now(UTC), + run_id="run-recalls", + recalls=recalls, + ) + + def _rows(self, store, episode_id): + from psycopg.rows import dict_row + + with store._pool.connection() as conn: + conn.row_factory = dict_row + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM recalls WHERE bank_id = %s AND episode_id = %s ORDER BY ordinal", + (store.bank_id, str(episode_id)), + ) + return cur.fetchall() + + def test_save_writes_recall_rows(self, episode_store): + episode = self._episode(_recall_records()) + episode_store.save_episode(episode) + + rows = self._rows(episode_store, episode.id) + assert [r["ordinal"] for r in rows] == [0, 1] + load, tool = rows + assert load["kind"] == "load" and load["status"] == "ok" + assert load["text"].endswith("RECALLED-SECRET-TEXT") + assert load["model"] == "openai/gpt-4o-mini" + assert load["llm_calls"] == 3 + assert load["input_tokens"] == 1200 and load["output_tokens"] == 300 + assert load["input_cost"] == pytest.approx(0.012) + assert load["output_cost"] == pytest.approx(0.006) + assert load["latency_ms"] == 4200 + assert load["reflects"] == 5 and load["reach"] == "org" + assert load["details"]["facets"]["context"]["count"] == 1 + assert tool["status"] == "limit" + assert tool["details"] == {"requested_reach": "bank", "mode": "reflect"} + + def test_save_is_idempotent(self, episode_store): + episode = self._episode(_recall_records()) + episode_store.save_episode(episode) + episode_store.save_episode(episode) + assert len(self._rows(episode_store, episode.id)) == 2 + + def test_episode_without_recalls(self, episode_store): + episode = self._episode([]) + episode_store.save_episode(episode) + assert self._rows(episode_store, episode.id) == [] + + +class TestHippocampusRecalls: + def test_end_episode_attaches_recalls_without_distilling_them(self): + hippo = Hippocampus(task_name="code_review", run_id="r", question="question") + hippo.observe("trajectory text") + seen: list[tuple[str, str]] = [] + hippo._distill = lambda trajectory, question: seen.append((trajectory, question)) + + recalls = _recall_records() + hippo.end_episode(recalls=recalls) + + assert hippo.episode is not None + assert [r.ordinal for r in hippo.episode.recalls] == [0, 1] + assert len(seen) == 1 + trajectory, question = seen[0] + assert "RECALLED-SECRET-TEXT" not in trajectory + assert "RECALLED-SECRET-TEXT" not in question + + def test_end_episode_defaults_to_no_recalls(self): + hippo = Hippocampus(task_name="code_review", run_id="r") + hippo.observe("trajectory text") + hippo._distill = lambda trajectory, question: None + hippo.end_episode() + assert hippo.episode is not None and hippo.episode.recalls == [] From 87bba0103533703389c68fff8018f7f72fafc4c3 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Wed, 30 Sep 2026 23:25:59 +0200 Subject: [PATCH 23/39] recall --- .env.example | 1 + CHANGELOG.md | 2 + codespy.yaml | 1 + .../agents/memory/cerebral/cerebral.py | 98 ++++++++++- src/codespy/agents/memory/cerebral/cost.py | 121 +++++++++++++- src/codespy/agents/memory/cerebral/orphans.py | 107 ++++++++++++ src/codespy/config_memory.py | 6 + tests/test_cerebral.py | 36 ++++ tests/test_cerebral_cost.py | 156 +++++++++++++++++ tests/test_cerebral_orphans.py | 157 ++++++++++++++++++ 10 files changed, 673 insertions(+), 12 deletions(-) create mode 100644 src/codespy/agents/memory/cerebral/orphans.py create mode 100644 tests/test_cerebral_orphans.py diff --git a/.env.example b/.env.example index 5adcb2e..c30be80 100644 --- a/.env.example +++ b/.env.example @@ -217,6 +217,7 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # LLM config (e.g. bedrock uses AWS env vars, openai uses OPENAI_API_KEY). # MEMORY_RETAIN_MODEL= # Falls back to DEFAULT_MODEL # MEMORY_EMBEDDINGS_MODEL= # Auto-derived from provider +# MEMORY_EMBEDDINGS_MAX_INPUT_CHARS= # null = auto per model (2048 for Bedrock Cohere v3), 0 = off # MEMORY_RETAIN_CHUNK_SIZE=12288 # Chars per extraction chunk (< 64000) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1472a0..673d744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed - Cerebral consolidation no longer logs `relation "webhooks" does not exist`. Inline (`SyncTaskBackend`) tasks now carry the `semantic` schema, so consolidation completion no longer rolls back and falls back. - Cerebral consolidation no longer fails at exit with `cannot schedule new futures after shutdown`. Background episode saves now finish before Python shuts down its thread pools, and the pipeline waits for them without the old 120 s cap. Runs can end later, because posting the review now waits for the consolidation to finish. +- Briefings no longer stay stuck on "Generating content..." after a failed refresh. The `_SchemaSyncTaskBackend` now marks failed operations as failed, and startup repair marks orphaned pending operations older than 1 hour as failed so subsequent refreshes can proceed. +- Embedding inputs are now capped for Bedrock Cohere v3 (2048 characters) to prevent `maxLength: 2048, actual: 2639` errors. Use `memory.cerebral.embeddings.max_input_chars` (env: `MEMORY_EMBEDDINGS_MAX_INPUT_CHARS`) to override: `null` = auto per model, `0` = disabled, `N` = custom cap. ### Added - **Prefrontal reflect hard caps**: programmatic reflect configuration with new caps: diff --git a/codespy.yaml b/codespy.yaml index 44491b2..aa95269 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -136,6 +136,7 @@ memory: chunk_size: 12288 # MEMORY_RETAIN_CHUNK_SIZE (chars, must be < 64000) embeddings: model: null # MEMORY_EMBEDDINGS_MODEL (auto-derived from provider) + max_input_chars: null # MEMORY_EMBEDDINGS_MAX_INPUT_CHARS (null=auto per model, e.g. 2048 for Bedrock Cohere v3; 0=off) # Prefrontal (semantic recall from Cerebral into agent context: read-only) prefrontal: diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index 7a85cdb..cbd51a9 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -74,16 +74,73 @@ class _SchemaSyncTaskBackend(SyncTaskBackend): a bare table name instead of falling back to ``database_schema`` like ``fq_table`` and ``fq_routine`` do. This subclass can be removed once Hindsight falls back to ``database_schema`` there. + + This backend also stands in for the worker poller's fail-marking: when a + task fails, it marks the operation row as failed so that deduplication + no longer blocks subsequent submissions. """ def __init__(self, schema: str): super().__init__() self._schema = schema + self._engine: MemoryEngine | None = None + + def attach(self, engine: MemoryEngine) -> None: + """Attach a MemoryEngine for DB operations on task failure.""" + self._engine = engine async def submit_task(self, task_dict): - if "_schema" not in task_dict: - task_dict = {**task_dict, "_schema": self._schema} - await super().submit_task(task_dict) + # Work on a copy to avoid mutating the caller's dict + task = dict(task_dict) + if "_schema" not in task: + task["_schema"] = self._schema + + try: + await super().submit_task(task) + except Exception as exc: + # Mark the operation as failed if it has an operation_id + await self._mark_failed_if_pending(task, exc) + raise + + async def _mark_failed_if_pending(self, task: dict, exc: Exception) -> None: + """Mark the operation as failed if it's still pending.""" + op_id = task.get("operation_id") + if not op_id or self._engine is None: + return + + from hindsight_api.engine.db_utils import acquire_with_retry + + try: + # Format error message (truncate to 5000 chars) + try: + from hindsight_api.worker.exceptions import format_task_error + + error_msg = format_task_error(exc) + except Exception: + error_msg = f"{type(exc).__name__}: {exc}" + if len(error_msg) > 5000: + error_msg = error_msg[:5000] + + async with acquire_with_retry(self._engine._backend, max_retries=1) as conn: + await conn.execute( + f""" + UPDATE "{self._schema}".async_operations + SET status = 'failed', + error_message = $2, + updated_at = NOW() + WHERE operation_id = $1 + AND status = 'pending' + """, + op_id, + error_msg, + ) + except Exception: + # Log but don't propagate - the original exception is more important + logger.warning( + "Failed to mark operation %s as failed", + op_id, + exc_info=True, + ) from codespy.agents.memory.cerebral.cost import ( CerebralCostRecorder, @@ -181,6 +238,7 @@ def __init__( reflect_llm_model: str | None = None, reflect_llm_api_key: str | None = None, reflect_llm_base_url: str | None = None, + embeddings_max_input_chars: int | None = None, ): # Fail fast: test the embedding model before building MemoryEngine. # A bad model name, missing creds, or unavailable region surfaces here @@ -191,7 +249,7 @@ def __init__( except Exception as exc: raise RuntimeError( f"Cerebral: embedding model {embeddings_model!r} is not available. " - f"Set MEMORY_HINDSIGHT_EMBEDDINGS_MODEL to a valid litellm embedding model. " + f"Set MEMORY_EMBEDDINGS_MODEL to a valid litellm embedding model. " f"Error: {exc}" ) from exc @@ -208,6 +266,13 @@ def __init__( # all LLM calls (including the probe) are captured. cost_recorder = register_cerebral_cost_recorder() + # Resolve embedding input limits (auto-detect for Bedrock Cohere v3) + from codespy.agents.memory.cerebral.cost import resolve_embedding_input_limits + + max_input_chars, extra_kwargs = resolve_embedding_input_limits( + embeddings_model, embeddings_max_input_chars + ) + # Use metered embeddings that capture usage costs base_embeddings = LiteLLMSDKEmbeddings( model=embeddings_model, @@ -216,6 +281,8 @@ def __init__( metered_embeddings = MeteredLiteLLMSDKEmbeddings( base=base_embeddings, cost_recorder=cost_recorder, + max_input_chars=max_input_chars, + extra_kwargs=extra_kwargs, ) # Separate reflect LLM (Prefrontal reflect + mental-model refresh), only @@ -229,6 +296,10 @@ def __init__( "reflect_llm_base_url": reflect_llm_base_url or None, } + # Run consolidation and mental-model refreshes inline. The default + # BrokerTaskBackend only queues rows for a WorkerPoller, which + # codespy never starts, so they would never run. + task_backend = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) self._engine = MemoryEngine( db_url=database_url, memory_llm_provider=llm_provider, @@ -240,11 +311,12 @@ def __init__( cross_encoder=RRFPassthroughCrossEncoder(), tenant_extension=DefaultTenantExtension(config={"schema": HINDSIGHT_SCHEMA}), skip_llm_verification=True, - # Run consolidation and mental-model refreshes inline. The default - # BrokerTaskBackend only queues rows for a WorkerPoller, which - # codespy never starts, so they would never run. - task_backend=_SchemaSyncTaskBackend(HINDSIGHT_SCHEMA), + task_backend=task_backend, ) + + # Attach engine to task backend so it can mark failed operations + task_backend.attach(self._engine) + self._run_async(self._engine.initialize()) # One-time repair: install maintenance routines if missing. @@ -252,6 +324,13 @@ def __init__( # before HINDSIGHT_API_DATABASE_SCHEMA was set to "semantic" (upstream #2638). self._run_async(ensure_maintenance_routines(self._engine, HINDSIGHT_SCHEMA)) + # Repair orphaned pending operations left by inline SyncTaskBackend + from codespy.agents.memory.cerebral.orphans import repair_orphaned_operations + + self._run_async( + repair_orphaned_operations(self._engine, HINDSIGHT_SCHEMA, bank_id) + ) + self._bank_id = bank_id self._bank_ensured = False self._bank_exists = False @@ -265,8 +344,9 @@ def __init__( self._consolidation_lock = threading.Lock() logger.info( - "Cerebral MemoryEngine initialized (schema=%s, bank=%s, embeddings=%s, provider=%s, chunk_size=%s)", + "Cerebral MemoryEngine initialized (schema=%s, bank=%s, embeddings=%s, provider=%s, chunk_size=%s, max_input_chars=%s)", HINDSIGHT_SCHEMA, bank_id, embeddings_model, llm_provider, retain_chunk_size, + max_input_chars, ) def _run_async(self, coro): diff --git a/src/codespy/agents/memory/cerebral/cost.py b/src/codespy/agents/memory/cerebral/cost.py index 05819f5..1220bc6 100644 --- a/src/codespy/agents/memory/cerebral/cost.py +++ b/src/codespy/agents/memory/cerebral/cost.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +import re import threading import time from typing import TYPE_CHECKING, Any @@ -25,6 +26,9 @@ logger = logging.getLogger(__name__) +# Track if we've warned about truncating inputs (once per process) +_warned_truncation: bool = False + # Module-level lock for thread-safe registration _recorder_lock = threading.Lock() _recorder_registered = False @@ -168,18 +172,75 @@ class _LiteLLMProxy: returning the response. All other attributes forward to the real litellm. """ - def __init__(self, real_litellm: Any, cost_recorder: "CerebralCostRecorder") -> None: + def __init__( + self, + real_litellm: Any, + cost_recorder: "CerebralCostRecorder", + max_input_chars: int | None = None, + extra_kwargs: dict[str, Any] | None = None, + ) -> None: self._real = real_litellm self._recorder = cost_recorder + self._max_input_chars = max_input_chars + self._extra_kwargs = extra_kwargs or {} async def aembedding(self, **kwargs: Any) -> Any: """Call litellm.aembedding and meter the usage.""" + # Apply character cap to input if configured + if self._max_input_chars is not None: + kwargs = self._apply_input_cap(kwargs) + + # Merge extra_kwargs (explicit caller values win) + for key, value in self._extra_kwargs.items(): + kwargs.setdefault(key, value) + start = time.perf_counter() response = await self._real.aembedding(**kwargs) elapsed = time.perf_counter() - start self._meter_embedding(response, kwargs.get("model", ""), elapsed) return response + def _apply_input_cap(self, kwargs: dict[str, Any]) -> dict[str, Any]: + """Apply character cap to input strings. + + Creates a new input list with each string longer than the cap cut to + text[:cap]. Non-str items are left as-is. Logs a warning on first + truncation, then debug. + """ + global _warned_truncation + + input_data = kwargs.get("input") + if not isinstance(input_data, list): + return kwargs + + new_input: list[Any] = [] + max_seen = 0 + truncated_count = 0 + + for item in input_data: + if isinstance(item, str) and len(item) > self._max_input_chars: + new_input.append(item[: self._max_input_chars]) + max_seen = max(max_seen, len(item)) + truncated_count += 1 + else: + new_input.append(item) + if isinstance(item, str): + max_seen = max(max_seen, len(item)) + + if truncated_count > 0: + level = logging.WARNING if not _warned_truncation else logging.DEBUG + _warned_truncation = True + logger.log( + level, + "Truncated %d embedding input(s) to %d chars (largest was %d)", + truncated_count, + self._max_input_chars, + max_seen, + ) + + # Return new kwargs with capped input (don't mutate caller's dict) + return {**kwargs, "input": new_input} + def __getattr__(self, name: str) -> Any: """Forward all other attributes to the real litellm.""" return getattr(self._real, name) @@ -252,10 +313,19 @@ class MeteredLiteLLMSDKEmbeddings: ``initialize()`` is intentionally unmetered. """ - def __init__(self, *, base: LiteLLMSDKEmbeddings, cost_recorder: CerebralCostRecorder) -> None: + def __init__( + self, + *, + base: LiteLLMSDKEmbeddings, + cost_recorder: CerebralCostRecorder, + max_input_chars: int | None = None, + extra_kwargs: dict[str, Any] | None = None, + ) -> None: self._base = base self._recorder = cost_recorder self._proxy_set = False + self._max_input_chars = max_input_chars + self._extra_kwargs = extra_kwargs or {} async def initialize(self) -> None: """Initialize the base embeddings, then wrap _litellm.""" @@ -266,7 +336,12 @@ async def initialize(self) -> None: # calls, not the probe. Must check if already wrapped because # initialize() may be called multiple times. if not self._proxy_set and hasattr(self._base, "_litellm") and self._base._litellm is not None: - self._base._litellm = _LiteLLMProxy(self._base._litellm, self._recorder) + self._base._litellm = _LiteLLMProxy( + self._base._litellm, + self._recorder, + max_input_chars=self._max_input_chars, + extra_kwargs=self._extra_kwargs, + ) self._proxy_set = True def __getattr__(self, name: str) -> Any: @@ -338,3 +413,43 @@ def unregister_cerebral_cost_recorder(recorder: CerebralCostRecorder | None = No logger.debug("CerebralCostRecorder unregistered") except Exception as e: logger.debug("Failed to unregister CerebralCostRecorder: %s", e) + + +def resolve_embedding_input_limits( + model: str, override: int | None = None +) -> tuple[int | None, dict[str, Any]]: + """Resolve the maximum input characters and extra kwargs for embeddings. + + Bedrock Cohere embed v3 has a 2048 character limit that Hindsight does not + handle. This function returns the cap and any extra kwargs (e.g., truncate) + needed to stay within the limit. + + Args: + model: The litellm model string (e.g., "bedrock/cohere.embed-multilingual-v3"). + override: User override for the character cap: + - None (default): Auto-detect based on model (2048 for Bedrock Cohere v3). + - 0: Disable character capping (use Hindsight's token-based limit). + - N > 0: Use this specific character cap. + + Returns: + Tuple of (max_input_chars, extra_kwargs): + - max_input_chars: The character cap (None means no cap), or the override value. + - extra_kwargs: Dict of extra kwargs to pass to the embedding call + (e.g., {"truncate": "END"} for Cohere models). + """ + # Handle override + if override == 0: + return None, {} + if override is not None and override > 0: + # For Bedrock Cohere models, still add truncate even with custom cap + if re.search(r"bedrock/.*cohere\.embed-(english|multilingual)-v3", model): + return override, {"truncate": "END"} + return override, {} + + # Auto-detect based on model + # Match Bedrock Cohere v3 models (including region-prefixed like eu.cohere...) + if re.search(r"bedrock/.*cohere\.embed-(english|multilingual)-v3", model): + return 2048, {"truncate": "END"} + + # Default: no character cap (Hindsight's token-based limit applies) + return None, {} diff --git a/src/codespy/agents/memory/cerebral/orphans.py b/src/codespy/agents/memory/cerebral/orphans.py new file mode 100644 index 0000000..cff8b29 --- /dev/null +++ b/src/codespy/agents/memory/cerebral/orphans.py @@ -0,0 +1,107 @@ +"""Orphaned async operation repair for Hindsight semantic memory. + +When SyncTaskBackend runs tasks inline, failures leave rows in `pending` status +because there's no worker poller to mark them failed or reschedule them. This +module provides startup repair to mark old pending operations as failed so that +deduplication no longer blocks new submissions. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from hindsight_api import MemoryEngine + +logger = logging.getLogger(__name__) + +# Operation types that can be orphaned by inline SyncTaskBackend execution +_ORPHAN_OPERATION_TYPES = ["refresh_mental_model", "consolidation"] + +# Default age threshold for considering an operation orphaned (1 hour) +_DEFAULT_OLDER_THAN_SECONDS = 3600 + +# Max error message length for the error_message column +_MAX_ERROR_MESSAGE_LENGTH = 5000 + + +async def repair_orphaned_operations( + engine: MemoryEngine, + schema: str, + bank_id: str, + older_than_s: int = _DEFAULT_OLDER_THAN_SECONDS, +) -> int: + """Mark orphaned pending operations as failed. + + Pending operations older than the threshold are marked failed with a + diagnostic message. This allows new submissions to proceed (the refresh + deduplication only blocks when a pending row exists). + + Args: + engine: The MemoryEngine instance. + schema: The schema name (e.g., "semantic"). + bank_id: The bank ID to repair operations for. + older_than_s: Age threshold in seconds (default 3600 = 1 hour). + + Returns: + Number of rows marked as failed. + """ + from hindsight_api.engine.db_utils import acquire_with_retry + + error_message = ( + "orphaned: left pending by inline SyncTaskBackend (repaired by codespy)" + ) + # Truncate if needed (though this message is well under the limit) + if len(error_message) > _MAX_ERROR_MESSAGE_LENGTH: + error_message = error_message[:_MAX_ERROR_MESSAGE_LENGTH] + + try: + async with acquire_with_retry(engine._backend, max_retries=1) as conn: + rows = await conn.fetch( + f""" + UPDATE "{schema}".async_operations + SET status = 'failed', + error_message = $2, + updated_at = NOW() + WHERE bank_id = $1 + AND status = 'pending' + AND operation_type = ANY($3::text[]) + AND updated_at < NOW() - MAKE_INTERVAL(SECS => $4) + RETURNING operation_id, operation_type + """, + bank_id, + error_message, + _ORPHAN_OPERATION_TYPES, + older_than_s, + ) + + if rows: + by_type: dict[str, int] = {} + for row in rows: + op_type = row["operation_type"] + by_type[op_type] = by_type.get(op_type, 0) + 1 + + logger.info( + "Repaired %d orphaned operation(s) in bank %r: %s", + len(rows), + bank_id, + ", ".join(f"{count} {op_type}" for op_type, count in by_type.items()), + ) + else: + logger.debug( + "No orphaned operations found in bank %r (older than %ds)", + bank_id, + older_than_s, + ) + + return len(rows) + + except Exception as exc: + logger.warning( + "Failed to repair orphaned operations in bank %r: %s", + bank_id, + exc, + exc_info=True, + ) + return 0 diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 9fc44aa..b07b51c 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -96,6 +96,11 @@ class CerebralEmbeddingsConfig(BaseModel): """Cerebral (semantic memory) embeddings configuration.""" model: str | None = None # Auto-derived from provider if unset + max_input_chars: int | None = Field( + default=None, + ge=0, + description="Maximum characters per embedding input. null=auto (2048 for Bedrock Cohere v3), 0=off, N=cap", + ) class CerebralConfig(BaseModel): @@ -514,6 +519,7 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: retain_chunk_size=settings.memory.cerebral.retain.chunk_size, mental_models=settings.memory.prefrontal.reflects > 0, max_mental_model_tokens=settings.memory.prefrontal.max_mental_model_tokens, + embeddings_max_input_chars=settings.memory.cerebral.embeddings.max_input_chars, **reflect_kwargs, ) except Exception: diff --git a/tests/test_cerebral.py b/tests/test_cerebral.py index cdd52eb..2b333ec 100644 --- a/tests/test_cerebral.py +++ b/tests/test_cerebral.py @@ -912,6 +912,42 @@ async def mock_executor(task_dict): assert original == original_copy # Original should be unchanged assert received_task.get("_schema") == "semantic" + def test_schema_sync_task_backend_has_attach_method(self): + """Test that _SchemaSyncTaskBackend has an attach method for engine binding.""" + from codespy.agents.memory.cerebral.cerebral import _SchemaSyncTaskBackend, HINDSIGHT_SCHEMA + + backend = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) + mock_engine = MagicMock() + + # attach should set _engine + backend.attach(mock_engine) + assert backend._engine is mock_engine + + def test_schema_sync_task_backend_catches_exception_and_re_raises(self): + """Test that _SchemaSyncTaskBackend catches exceptions, tries to mark failed, and re-raises.""" + from codespy.agents.memory.cerebral.cerebral import _SchemaSyncTaskBackend, HINDSIGHT_SCHEMA + + backend = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) + + async def failing_executor(task_dict): + raise RuntimeError("task failed") + + backend.set_executor(failing_executor) + + import asyncio + with pytest.raises(RuntimeError, match="task failed"): + asyncio.run(backend.submit_task({"type": "consolidation", "operation_id": "op-123"})) + + def test_error_message_uses_correct_env_var(self, mock_memory_engine_class, mock_litellm): + """Test that embedding probe error mentions MEMORY_EMBEDDINGS_MODEL.""" + import litellm + + with patch.object(litellm, "embedding", side_effect=Exception("API error")): + with pytest.raises(RuntimeError) as exc_info: + Cerebral(database_url="postgresql://localhost:5432/test", llm_provider="litellm") + + assert "MEMORY_EMBEDDINGS_MODEL" in str(exc_info.value) + class TestCerebralReflectModel: def test_reflect_llm_forwarded_when_set(self, mock_memory_engine_class, mock_litellm): diff --git a/tests/test_cerebral_cost.py b/tests/test_cerebral_cost.py index 3698519..dc4562b 100644 --- a/tests/test_cerebral_cost.py +++ b/tests/test_cerebral_cost.py @@ -5,6 +5,8 @@ import pytest +pytest.importorskip("hindsight_api") + from codespy.agents.cost_tracker import CostTracker, get_cost_tracker from codespy.agents.memory.cerebral.cost import ( BUCKET_MEMORY_EMBEDDINGS, @@ -508,3 +510,157 @@ def test_llm_span_recorder_accepts_duration_param(self): sig = inspect.signature(LLMSpanRecorder.record_llm_call) assert "duration" in sig.parameters, "record_llm_call must accept 'duration' parameter" + + +class TestResolveEmbeddingInputLimits: + """Tests for resolve_embedding_input_limits function.""" + + def test_bedrock_cohere_multilingual_v3_returns_2048_and_truncate(self): + """Bedrock Cohere multilingual v3 returns 2048 and truncate=END.""" + from codespy.agents.memory.cerebral.cost import resolve_embedding_input_limits + + max_chars, extra = resolve_embedding_input_limits("bedrock/cohere.embed-multilingual-v3") + assert max_chars == 2048 + assert extra == {"truncate": "END"} + + def test_bedrock_cohere_english_v3_returns_2048_and_truncate(self): + """Bedrock Cohere english v3 returns 2048 and truncate=END.""" + from codespy.agents.memory.cerebral.cost import resolve_embedding_input_limits + + max_chars, extra = resolve_embedding_input_limits("bedrock/cohere.embed-english-v3") + assert max_chars == 2048 + assert extra == {"truncate": "END"} + + def test_region_prefixed_bedrock_cohere_matches(self): + """Region-prefixed Bedrock Cohere models also match.""" + from codespy.agents.memory.cerebral.cost import resolve_embedding_input_limits + + max_chars, extra = resolve_embedding_input_limits("bedrock/eu.cohere.embed-multilingual-v3") + assert max_chars == 2048 + assert extra == {"truncate": "END"} + + def test_openai_returns_none_and_empty(self): + """OpenAI models return None and empty dict.""" + from codespy.agents.memory.cerebral.cost import resolve_embedding_input_limits + + max_chars, extra = resolve_embedding_input_limits("openai/text-embedding-3-small") + assert max_chars is None + assert extra == {} + + def test_override_0_disables_cap(self): + """Override=0 disables character capping.""" + from codespy.agents.memory.cerebral.cost import resolve_embedding_input_limits + + max_chars, extra = resolve_embedding_input_limits("bedrock/cohere.embed-multilingual-v3", override=0) + assert max_chars is None + assert extra == {} + + def test_override_n_uses_custom_cap(self): + """Override=N uses custom character cap.""" + from codespy.agents.memory.cerebral.cost import resolve_embedding_input_limits + + max_chars, extra = resolve_embedding_input_limits("bedrock/cohere.embed-multilingual-v3", override=3000) + assert max_chars == 3000 + assert extra == {"truncate": "END"} + + def test_custom_cap_for_non_cohere_returns_empty_extra(self): + """Custom cap for non-Cohere models returns empty extra.""" + from codespy.agents.memory.cerebral.cost import resolve_embedding_input_limits + + max_chars, extra = resolve_embedding_input_limits("openai/text-embedding-3-small", override=3000) + assert max_chars == 3000 + assert extra == {} + + +class TestLiteLLMProxyInputCapping: + """Tests for _LiteLLMProxy input capping.""" + + @pytest.fixture + def mock_litellm(self): + """Create a mock litellm module.""" + return MagicMock() + + @pytest.fixture + def mock_response(self): + """Create a mock embedding response.""" + response = MagicMock() + response.usage = MagicMock() + response.usage.prompt_tokens = 100 + response._hidden_params = {"response_cost": 0.01} + return response + + @pytest.mark.asyncio + async def test_no_truncation_when_under_cap(self, mock_litellm, mock_response): + """Input under cap is not truncated.""" + mock_litellm.aembedding.return_value = asyncio.Future() + mock_litellm.aembedding.return_value.set_result(mock_response) + + from codespy.agents.memory.cerebral.cost import _LiteLLMProxy, CerebralCostRecorder + + proxy = _LiteLLMProxy(mock_litellm, CerebralCostRecorder(), max_input_chars=100) + await proxy.aembedding(model="test", input=["short"]) + + call_args = mock_litellm.aembedding.call_args + assert call_args.kwargs["input"] == ["short"] + + @pytest.mark.asyncio + async def test_truncation_when_over_cap(self, mock_litellm, mock_response): + """Input over cap is truncated.""" + mock_litellm.aembedding.return_value = asyncio.Future() + mock_litellm.aembedding.return_value.set_result(mock_response) + + from codespy.agents.memory.cerebral.cost import _LiteLLMProxy, CerebralCostRecorder + + proxy = _LiteLLMProxy(mock_litellm, CerebralCostRecorder(), max_input_chars=5) + await proxy.aembedding(model="test", input=["this is a long text"]) + + call_args = mock_litellm.aembedding.call_args + assert call_args.kwargs["input"] == ["this "] + + @pytest.mark.asyncio + async def test_does_not_mutate_original_list(self, mock_litellm, mock_response): + """Original input list is not mutated.""" + mock_litellm.aembedding.return_value = asyncio.Future() + mock_litellm.aembedding.return_value.set_result(mock_response) + + from codespy.agents.memory.cerebral.cost import _LiteLLMProxy, CerebralCostRecorder + + original = ["this is a long text"] + original_copy = list(original) + + proxy = _LiteLLMProxy(mock_litellm, CerebralCostRecorder(), max_input_chars=5) + await proxy.aembedding(model="test", input=original) + + assert original == original_copy + + @pytest.mark.asyncio + async def test_extra_kwargs_forwarded(self, mock_litellm, mock_response): + """Extra kwargs are forwarded to the embedding call.""" + mock_litellm.aembedding.return_value = asyncio.Future() + mock_litellm.aembedding.return_value.set_result(mock_response) + + from codespy.agents.memory.cerebral.cost import _LiteLLMProxy, CerebralCostRecorder + + proxy = _LiteLLMProxy( + mock_litellm, CerebralCostRecorder(), max_input_chars=100, extra_kwargs={"truncate": "END"} + ) + await proxy.aembedding(model="test", input=["short"]) + + call_args = mock_litellm.aembedding.call_args + assert call_args.kwargs["truncate"] == "END" + + @pytest.mark.asyncio + async def test_explicit_caller_kwargs_win_over_extra(self, mock_litellm, mock_response): + """Explicit caller kwargs win over extra_kwargs.""" + mock_litellm.aembedding.return_value = asyncio.Future() + mock_litellm.aembedding.return_value.set_result(mock_response) + + from codespy.agents.memory.cerebral.cost import _LiteLLMProxy, CerebralCostRecorder + + proxy = _LiteLLMProxy( + mock_litellm, CerebralCostRecorder(), max_input_chars=100, extra_kwargs={"truncate": "END"} + ) + await proxy.aembedding(model="test", input=["short"], truncate="START") + + call_args = mock_litellm.aembedding.call_args + assert call_args.kwargs["truncate"] == "START" diff --git a/tests/test_cerebral_orphans.py b/tests/test_cerebral_orphans.py new file mode 100644 index 0000000..053d66d --- /dev/null +++ b/tests/test_cerebral_orphans.py @@ -0,0 +1,157 @@ +"""Tests for the orphaned async operation repair module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +pytest.importorskip("hindsight_api") + +from codespy.agents.memory.cerebral.orphans import ( + _ORPHAN_OPERATION_TYPES, + repair_orphaned_operations, +) + + +class TestRepairOrphanedOperations: + """Tests for repair_orphaned_operations function.""" + + @pytest.fixture + def mock_engine(self): + """Create a mock MemoryEngine with a mock _backend.""" + engine = MagicMock() + engine._backend = MagicMock() + return engine + + @pytest.fixture + def mock_conn(self): + """Create a mock async connection.""" + conn = AsyncMock() + return conn + + @pytest.mark.asyncio + async def test_marks_pending_operations_as_failed(self, mock_engine, mock_conn): + """Test that pending operations older than threshold are marked failed.""" + # Set up mock rows to return (simulating 2 orphaned operations) + mock_rows = [ + {"operation_id": "op-1", "operation_type": "refresh_mental_model"}, + {"operation_id": "op-2", "operation_type": "consolidation"}, + ] + mock_conn.fetch.return_value = mock_rows + + with patch( + "codespy.agents.memory.cerebral.orphans.acquire_with_retry" + ) as mock_acquire: + mock_acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + result = await repair_orphaned_operations( + mock_engine, "semantic", "test-bank", older_than_s=3600 + ) + + assert result == 2 + + # Verify the SQL was called with correct parameters + call_args = mock_conn.fetch.call_args + assert call_args[0][1] == "test-bank" # bank_id + assert "orphaned: left pending by inline SyncTaskBackend" in call_args[0][2] + assert call_args[0][3] == _ORPHAN_OPERATION_TYPES + assert call_args[0][4] == 3600 # older_than_s + + @pytest.mark.asyncio + async def test_no_orphans_found(self, mock_engine, mock_conn): + """Test when no orphaned operations are found.""" + mock_conn.fetch.return_value = [] + + with patch( + "codespy.agents.memory.cerebral.orphans.acquire_with_retry" + ) as mock_acquire: + mock_acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + result = await repair_orphaned_operations( + mock_engine, "semantic", "test-bank" + ) + + assert result == 0 + + @pytest.mark.asyncio + async def test_db_error_returns_zero(self, mock_engine): + """Test that database errors are caught and return 0.""" + with patch( + "codespy.agents.memory.cerebral.orphans.acquire_with_retry" + ) as mock_acquire: + mock_acquire.side_effect = Exception("connection failed") + + result = await repair_orphaned_operations( + mock_engine, "semantic", "test-bank" + ) + + assert result == 0 + + @pytest.mark.asyncio + async def test_uses_default_age_threshold(self, mock_engine, mock_conn): + """Test that default age threshold (3600s) is used when not specified.""" + mock_conn.fetch.return_value = [] + + with patch( + "codespy.agents.memory.cerebral.orphans.acquire_with_retry" + ) as mock_acquire: + mock_acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + await repair_orphaned_operations(mock_engine, "semantic", "test-bank") + + call_args = mock_conn.fetch.call_args + assert call_args[0][4] == 3600 # Default older_than_s + + @pytest.mark.asyncio + async def test_custom_age_threshold(self, mock_engine, mock_conn): + """Test that custom age threshold is passed through.""" + mock_conn.fetch.return_value = [] + + with patch( + "codespy.agents.memory.cerebral.orphans.acquire_with_retry" + ) as mock_acquire: + mock_acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + await repair_orphaned_operations( + mock_engine, "semantic", "test-bank", older_than_s=7200 + ) + + call_args = mock_conn.fetch.call_args + assert call_args[0][4] == 7200 + + @pytest.mark.asyncio + async def test_only_affects_specified_bank(self, mock_engine, mock_conn): + """Test that only operations for the specified bank are affected.""" + mock_conn.fetch.return_value = [] + + with patch( + "codespy.agents.memory.cerebral.orphans.acquire_with_retry" + ) as mock_acquire: + mock_acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + await repair_orphaned_operations( + mock_engine, "semantic", "specific-bank" + ) + + call_args = mock_conn.fetch.call_args + assert call_args[0][1] == "specific-bank" + + +class TestOrphanOperationTypes: + """Tests for the operation types constant.""" + + def test_includes_refresh_mental_model(self): + """Test that refresh_mental_model is included.""" + assert "refresh_mental_model" in _ORPHAN_OPERATION_TYPES + + def test_includes_consolidation(self): + """Test that consolidation is included.""" + assert "consolidation" in _ORPHAN_OPERATION_TYPES + + def test_has_exactly_two_types(self): + """Test that there are exactly two operation types.""" + assert len(_ORPHAN_OPERATION_TYPES) == 2 From 4825792bdf04fc9ceffb659a9e5b227345820754 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Wed, 30 Sep 2026 23:50:56 +0200 Subject: [PATCH 24/39] recall --- CHANGELOG.md | 6 + docs/memory.md | 7 +- .../agents/memory/cerebral/cerebral.py | 52 ++++++-- src/codespy/agents/memory/cerebral/cost.py | 62 +++++++++- src/codespy/config_memory.py | 2 + tests/test_cerebral.py | 114 +++++++++++++++++ tests/test_cerebral_cost.py | 116 +++++++++++++++++- 7 files changed, 340 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 673d744..039bb28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Changed +- **Cerebral cost bucket split**: Consolidation and mental-model refresh LLM calls are now billed to + `memory_consolidation` and `memory_mental_models` respectively, instead of `memory_other`. + The `memory_other` bucket now only holds uncategorized LLM calls (e.g., standalone reflect calls). + Embeddings remain in `memory_embeddings` / `memory_prefrontal_embeddings` (unchanged). + ### Fixed - Cerebral consolidation no longer logs `relation "webhooks" does not exist`. Inline (`SyncTaskBackend`) tasks now carry the `semantic` schema, so consolidation completion no longer rolls back and falls back. - Cerebral consolidation no longer fails at exit with `cannot schedule new futures after shutdown`. Background episode saves now finish before Python shuts down its thread pools, and the pipeline waits for them without the old 120 s cap. Runs can end later, because posting the review now waits for the consolidation to finish. diff --git a/docs/memory.md b/docs/memory.md index 3db7f94..c820653 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -380,7 +380,8 @@ Every recall is logged and stored. embeddings, `output_tokens` counts completions, and costs use `litellm.cost_per_token` (0 for unpriced models). With `reflects = 0`, only embedding tokens are recorded. - **Cost report** — Prefrontal reads are billed to the `memory_prefrontal` bucket. - Mental-model refreshes during retain stay in `memory_other`. + Mental-model refreshes during retain are billed to `memory_mental_models`. + Consolidation LLM calls are billed to `memory_consolidation`. - Recalls are **not** retained into Cerebral and are never shown to the Distiller or the Cartographer. When the episodic store is unavailable, recalls are only logged. @@ -400,8 +401,8 @@ in mental-model upkeep logs a warning and degrades to less context, or to `""`. ### Cost and latency -Every retain now runs consolidation and refreshes the touched scopes' models (several LLM -calls per episode, in the `memory_other` cost bucket). `reflects > 0` adds a nested loop to +Every retain now runs consolidation (billed to `memory_consolidation`) and refreshes the touched +scopes' models (billed to `memory_mental_models`). `reflects > 0` adds a nested loop to every agent call and tool call; those Prefrontal reads are billed to `memory_prefrontal`. Background saves take longer, and process exit waits on them. diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index cbd51a9..09d6572 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -78,6 +78,10 @@ class _SchemaSyncTaskBackend(SyncTaskBackend): This backend also stands in for the worker poller's fail-marking: when a task fails, it marks the operation row as failed so that deduplication no longer blocks subsequent submissions. + + Additionally, this backend sets a ContextVar (``current_memory_task``) around + task execution to enable cost attribution to ``memory_consolidation`` and + ``memory_mental_models`` buckets. """ def __init__(self, schema: str): @@ -95,12 +99,26 @@ async def submit_task(self, task_dict): if "_schema" not in task: task["_schema"] = self._schema + # Set contextvar for cost attribution when running consolidation or refresh + # Nested tasks: the innermost task wins; outer tag restored in finally + task_type = task.get("type") + token = None + if task_type in ("consolidation", "refresh_mental_model"): + from codespy.agents.memory.cerebral.cost import current_memory_task + + token = current_memory_task.set(task_type) + try: await super().submit_task(task) except Exception as exc: # Mark the operation as failed if it has an operation_id await self._mark_failed_if_pending(task, exc) raise + finally: + if token is not None: + from codespy.agents.memory.cerebral.cost import current_memory_task + + current_memory_task.reset(token) async def _mark_failed_if_pending(self, task: dict, exc: Exception) -> None: """Mark the operation as failed if it's still pending.""" @@ -592,12 +610,21 @@ def retain_episode(self, episode: Episode, repo_full_name: str | None = None) -> episode.id, self._bank_id, episode.task, ) - # Capture baseline memory_other stats for delta calculation - from codespy.agents.memory.cerebral.cost import BUCKET_MEMORY_OTHER + # Capture baseline stats for delta calculation (all three buckets) from codespy.agents.cost_tracker import get_cost_tracker + from codespy.agents.memory.cerebral.cost import ( + BUCKET_MEMORY_CONSOLIDATION, + BUCKET_MEMORY_MENTAL_MODELS, + BUCKET_MEMORY_OTHER, + ) + tracker = get_cost_tracker() - baseline_stats = tracker.get_signature_stats(BUCKET_MEMORY_OTHER) - baseline_calls = baseline_stats.call_count if baseline_stats else 0 + baseline_consolidation = tracker.get_signature_stats(BUCKET_MEMORY_CONSOLIDATION) + baseline_mental_models = tracker.get_signature_stats(BUCKET_MEMORY_MENTAL_MODELS) + baseline_other = tracker.get_signature_stats(BUCKET_MEMORY_OTHER) + baseline_calls_consolidation = baseline_consolidation.call_count if baseline_consolidation else 0 + baseline_calls_mental_models = baseline_mental_models.call_count if baseline_mental_models else 0 + baseline_calls_other = baseline_other.call_count if baseline_other else 0 try: self._run_async( @@ -620,17 +647,22 @@ def retain_episode(self, episode: Episode, repo_full_name: str | None = None) -> for scope_tags in mental_model_scopes: self._submit_scoped_consolidation(scope_tags) - # Log consolidation completion with delta - end_stats = tracker.get_signature_stats(BUCKET_MEMORY_OTHER) - end_calls = end_stats.call_count if end_stats else baseline_calls - delta_calls = end_calls - baseline_calls + # Log consolidation completion with per-bucket deltas + end_consolidation = tracker.get_signature_stats(BUCKET_MEMORY_CONSOLIDATION) + end_mental_models = tracker.get_signature_stats(BUCKET_MEMORY_MENTAL_MODELS) + end_other = tracker.get_signature_stats(BUCKET_MEMORY_OTHER) + delta_calls_consolidation = (end_consolidation.call_count if end_consolidation else baseline_calls_consolidation) - baseline_calls_consolidation + delta_calls_mental_models = (end_mental_models.call_count if end_mental_models else baseline_calls_mental_models) - baseline_calls_mental_models + delta_calls_other = (end_other.call_count if end_other else baseline_calls_other) - baseline_calls_other logger.info( "cerebral: retained %d content blobs for episode %s (bank=%s, task=%s); " - "consolidation finished in %.1fs, memory_other +%d calls", + "consolidation finished in %.1fs, memory_consolidation +%d calls, memory_mental_models +%d calls, memory_other +%d calls", len(contents), episode.id, self._bank_id, episode.task, time.monotonic() - started, - delta_calls, + delta_calls_consolidation, + delta_calls_mental_models, + delta_calls_other, ) @staticmethod diff --git a/src/codespy/agents/memory/cerebral/cost.py b/src/codespy/agents/memory/cerebral/cost.py index 1220bc6..38b18ac 100644 --- a/src/codespy/agents/memory/cerebral/cost.py +++ b/src/codespy/agents/memory/cerebral/cost.py @@ -11,11 +11,14 @@ import re import threading import time +from contextvars import ContextVar from typing import TYPE_CHECKING, Any from codespy.agents.memory.recall import current_recall_usage from codespy.config_memory import ( + MEMORY_CONSOLIDATION, MEMORY_EMBEDDINGS, + MEMORY_MENTAL_MODELS, MEMORY_OTHER, MEMORY_PREFRONTAL, MEMORY_RETAIN, @@ -33,6 +36,14 @@ _recorder_lock = threading.Lock() _recorder_registered = False +# Task type constants for contextvar tagging +TASK_CONSOLIDATION = "consolidation" +TASK_REFRESH_MENTAL_MODEL = "refresh_mental_model" + +# ContextVar to tag the current memory task (consolidation, refresh_mental_model, etc.) +# This is set by _SchemaSyncTaskBackend.submit_task around inline task execution. +current_memory_task: ContextVar[str | None] = ContextVar("current_memory_task", default=None) + # Buckets for cost attribution (using memory_* naming to match config/env names) BUCKET_MEMORY_RETAIN = MEMORY_RETAIN BUCKET_MEMORY_OTHER = MEMORY_OTHER @@ -41,11 +52,56 @@ BUCKET_MEMORY_PREFRONTAL = MEMORY_PREFRONTAL # Prefrontal embeddings (separate bucket for embeddings during Prefrontal recall) BUCKET_MEMORY_PREFRONTAL_EMBEDDINGS = f"{MEMORY_PREFRONTAL}_embeddings" +# Consolidation task LLM calls +BUCKET_MEMORY_CONSOLIDATION = MEMORY_CONSOLIDATION +# Mental model refresh task LLM calls +BUCKET_MEMORY_MENTAL_MODELS = MEMORY_MENTAL_MODELS # Track models we've warned about missing prices (one warning per model) _warned_unpriced_models: set[str] = set() +def _llm_bucket(scope: str) -> str: + """Determine the cost bucket for an LLM call based on scope and context. + + Precedence (first match wins): + 1. current_recall_usage set → memory_prefrontal (handled in record_llm_call) + 2. current_memory_task == "refresh_mental_model" → memory_mental_models + 3. current_memory_task == "consolidation" → memory_consolidation + 4. scope starts with "retain" → memory_retain + 5. Scope fallback when no task tag is set: + - consolidation* → memory_consolidation + - mental_model_* → memory_mental_models + 6. Otherwise → memory_other + + Args: + scope: The Hindsight scope string (e.g., "retain_extract_facts", "consolidation") + + Returns: + The cost bucket name (one of the BUCKET_MEMORY_* constants) + """ + # Check current_memory_task first (set by _SchemaSyncTaskBackend for inline tasks) + task = current_memory_task.get() + if task == TASK_REFRESH_MENTAL_MODEL: + return BUCKET_MEMORY_MENTAL_MODELS + if task == TASK_CONSOLIDATION: + return BUCKET_MEMORY_CONSOLIDATION + + # Scope-based routing + if scope and scope.startswith("retain"): + return BUCKET_MEMORY_RETAIN + + # Scope fallback for known patterns when no task tag is set + # (e.g., if Hindsight runs refresh via create_task without context copy) + if scope: + if scope.startswith("consolidation"): + return BUCKET_MEMORY_CONSOLIDATION + if scope.startswith("mental_model_"): + return BUCKET_MEMORY_MENTAL_MODELS + + return BUCKET_MEMORY_OTHER + + class CerebralCostRecorder: """Records Hindsight LLM calls into the CostTracker. @@ -90,17 +146,15 @@ def record_llm_call( # type: ignore[return] cost = prompt_cost + completion_cost tokens = input_tokens + output_tokens - # Determine bucket: an active Prefrontal recall wins over the scope, + # Determine bucket: an active Prefrontal recall wins over the scope/task tag, # because Hindsight scopes cannot tell a Prefrontal reflect apart # from a mental-model refresh. usage = current_recall_usage.get() if usage is not None: usage.add_llm(model, input_tokens, output_tokens, prompt_cost, completion_cost) bucket = BUCKET_MEMORY_PREFRONTAL - elif scope and scope.startswith("retain"): - bucket = BUCKET_MEMORY_RETAIN else: - bucket = BUCKET_MEMORY_OTHER + bucket = _llm_bucket(scope) # Import here to avoid circular imports at module load from codespy.agents.cost_tracker import get_cost_tracker diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index b07b51c..14ecd08 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -226,6 +226,8 @@ class MemoryConfig(BaseModel): MEMORY_EMBEDDINGS = "memory_embeddings" MEMORY_OTHER = "memory_other" MEMORY_PREFRONTAL = "memory_prefrontal" +MEMORY_CONSOLIDATION = "memory_consolidation" +MEMORY_MENTAL_MODELS = "memory_mental_models" # The reflection modules, derived from the HippocampusConfig fields that hold a # ReflectionModuleConfig. Iterate this instead of hardcoding module names so diff --git a/tests/test_cerebral.py b/tests/test_cerebral.py index 2b333ec..c8a6d29 100644 --- a/tests/test_cerebral.py +++ b/tests/test_cerebral.py @@ -948,6 +948,120 @@ def test_error_message_uses_correct_env_var(self, mock_memory_engine_class, mock assert "MEMORY_EMBEDDINGS_MODEL" in str(exc_info.value) + def test_schema_sync_task_backend_sets_contextvar_for_consolidation(self): + """Test that _SchemaSyncTaskBackend sets current_memory_task for consolidation.""" + from codespy.agents.memory.cerebral.cerebral import _SchemaSyncTaskBackend, HINDSIGHT_SCHEMA + from codespy.agents.memory.cerebral.cost import current_memory_task + + backend = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) + received_task_type = [] + + async def mock_executor(task_dict): + received_task_type.append(current_memory_task.get()) + + backend.set_executor(mock_executor) + + import asyncio + asyncio.run(backend.submit_task({"type": "consolidation"})) + + assert received_task_type == ["consolidation"] + # After the call, the contextvar should be reset + assert current_memory_task.get() is None + + def test_schema_sync_task_backend_sets_contextvar_for_refresh_mental_model(self): + """Test that _SchemaSyncTaskBackend sets current_memory_task for refresh_mental_model.""" + from codespy.agents.memory.cerebral.cerebral import _SchemaSyncTaskBackend, HINDSIGHT_SCHEMA + from codespy.agents.memory.cerebral.cost import current_memory_task + + backend = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) + received_task_type = [] + + async def mock_executor(task_dict): + received_task_type.append(current_memory_task.get()) + + backend.set_executor(mock_executor) + + import asyncio + asyncio.run(backend.submit_task({"type": "refresh_mental_model"})) + + assert received_task_type == ["refresh_mental_model"] + # After the call, the contextvar should be reset + assert current_memory_task.get() is None + + def test_schema_sync_task_backend_no_contextvar_for_batch_retain(self): + """Test that _SchemaSyncTaskBackend does not set current_memory_task for batch_retain.""" + from codespy.agents.memory.cerebral.cerebral import _SchemaSyncTaskBackend, HINDSIGHT_SCHEMA + from codespy.agents.memory.cerebral.cost import current_memory_task + + backend = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) + received_task_type = [] + + async def mock_executor(task_dict): + received_task_type.append(current_memory_task.get()) + + backend.set_executor(mock_executor) + + import asyncio + asyncio.run(backend.submit_task({"type": "batch_retain"})) + + assert received_task_type == [None] + # Contextvar should remain None + assert current_memory_task.get() is None + + def test_schema_sync_task_backend_resets_contextvar_on_exception(self): + """Test that _SchemaSyncTaskBackend resets current_memory_task even when executor raises.""" + from codespy.agents.memory.cerebral.cerebral import _SchemaSyncTaskBackend, HINDSIGHT_SCHEMA + from codespy.agents.memory.cerebral.cost import current_memory_task + + backend = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) + + async def failing_executor(task_dict): + raise RuntimeError("task failed") + + backend.set_executor(failing_executor) + + import asyncio + # Pre-set a value to verify it's restored + token = current_memory_task.set("outer_task") + try: + with pytest.raises(RuntimeError, match="task failed"): + asyncio.run(backend.submit_task({"type": "consolidation"})) + finally: + current_memory_task.reset(token) + + # After the call, the contextvar should be reset (back to None since we cleaned up) + assert current_memory_task.get() is None + + def test_schema_sync_task_backend_nested_tasks_innermost_wins(self): + """Test nested tasks: innermost task type wins, outer restored after inner returns.""" + from codespy.agents.memory.cerebral.cerebral import _SchemaSyncTaskBackend, HINDSIGHT_SCHEMA + from codespy.agents.memory.cerebral.cost import current_memory_task + + backend_outer = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) + backend_inner = _SchemaSyncTaskBackend(HINDSIGHT_SCHEMA) + + inner_received = [] + + async def inner_executor(task_dict): + inner_received.append(current_memory_task.get()) + + async def outer_executor(task_dict): + # This simulates a consolidation task submitting a refresh_mental_model + if task_dict.get("type") == "consolidation": + backend_inner.set_executor(inner_executor) + await backend_inner.submit_task({"type": "refresh_mental_model"}) + # After inner returns, outer's task should be restored + + backend_outer.set_executor(outer_executor) + + import asyncio + asyncio.run(backend_outer.submit_task({"type": "consolidation"})) + + # Inner should see refresh_mental_model + assert inner_received == ["refresh_mental_model"] + # After everything, contextvar should be reset + assert current_memory_task.get() is None + class TestCerebralReflectModel: def test_reflect_llm_forwarded_when_set(self, mock_memory_engine_class, mock_litellm): diff --git a/tests/test_cerebral_cost.py b/tests/test_cerebral_cost.py index dc4562b..86865dc 100644 --- a/tests/test_cerebral_cost.py +++ b/tests/test_cerebral_cost.py @@ -9,7 +9,9 @@ from codespy.agents.cost_tracker import CostTracker, get_cost_tracker from codespy.agents.memory.cerebral.cost import ( + BUCKET_MEMORY_CONSOLIDATION, BUCKET_MEMORY_EMBEDDINGS, + BUCKET_MEMORY_MENTAL_MODELS, BUCKET_MEMORY_OTHER, BUCKET_MEMORY_PREFRONTAL, BUCKET_MEMORY_PREFRONTAL_EMBEDDINGS, @@ -17,7 +19,9 @@ CerebralCostRecorder, MeteredLiteLLMSDKEmbeddings, _LiteLLMProxy, + _llm_bucket, _recorder_registered, + current_memory_task, register_cerebral_cost_recorder, unregister_cerebral_cost_recorder, ) @@ -85,7 +89,7 @@ def test_record_llm_call_with_retain_scope(self, mock_tracker, fresh_recorder): assert stats.output_cost == 0.02 def test_record_llm_call_with_consolidation_scope(self, mock_tracker, fresh_recorder): - """scope='consolidation' → memory_other bucket.""" + """scope='consolidation' → memory_consolidation bucket (scope fallback, no tag).""" with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.02, 0.01)): recorder = CerebralCostRecorder() @@ -96,10 +100,118 @@ def test_record_llm_call_with_consolidation_scope(self, mock_tracker, fresh_reco output_tokens=50, ) - stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_OTHER) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_CONSOLIDATION) + assert stats is not None + assert stats.input_tokens == 100 + assert stats.output_tokens == 50 + + def test_record_llm_call_with_mental_model_scope(self, mock_tracker, fresh_recorder): + """scope='mental_model_delta_ops' → memory_mental_models bucket (scope fallback, no tag).""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.02, 0.01)): + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="mental_model_delta_ops", + input_tokens=100, + output_tokens=50, + ) + + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_MENTAL_MODELS) + assert stats is not None + assert stats.input_tokens == 100 + assert stats.output_tokens == 50 + + def test_record_llm_call_with_consolidation_task_tag(self, mock_tracker, fresh_recorder): + """current_memory_task='consolidation' with scope 'reflect' → memory_consolidation bucket.""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.02, 0.01)): + token = current_memory_task.set("consolidation") + try: + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="reflect", + input_tokens=100, + output_tokens=50, + ) + finally: + current_memory_task.reset(token) + + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_CONSOLIDATION) + assert stats is not None + assert stats.input_tokens == 100 + assert stats.output_tokens == 50 + + def test_record_llm_call_with_refresh_mental_model_task_tag(self, mock_tracker, fresh_recorder): + """current_memory_task='refresh_mental_model' with scope 'reflect' → memory_mental_models bucket.""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.02, 0.01)): + token = current_memory_task.set("refresh_mental_model") + try: + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="reflect", + input_tokens=100, + output_tokens=50, + ) + finally: + current_memory_task.reset(token) + + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_MENTAL_MODELS) + assert stats is not None + assert stats.input_tokens == 100 + assert stats.output_tokens == 50 + + def test_record_llm_call_recall_wins_over_task_tag(self, mock_tracker, fresh_recorder): + """current_recall_usage set AND current_memory_task set → memory_prefrontal (recall wins).""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.02, 0.01)): + with track_recall_usage() as usage: + token = current_memory_task.set("consolidation") + try: + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="reflect", + input_tokens=100, + output_tokens=50, + ) + finally: + current_memory_task.reset(token) + + # Should go to prefrontal, not consolidation + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_PREFRONTAL) + assert stats is not None + assert stats.input_tokens == 100 + assert stats.output_tokens == 50 + # Consolidation should be empty + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_CONSOLIDATION) is None + + def test_record_llm_call_task_tag_wins_over_retain_scope(self, mock_tracker, fresh_recorder): + """current_memory_task='consolidation' with scope 'retain_extract_facts' → memory_consolidation (tag wins).""" + with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): + with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.02, 0.01)): + token = current_memory_task.set("consolidation") + try: + recorder = CerebralCostRecorder() + recorder.record_llm_call( + model="openai/gpt-4", + scope="retain_extract_facts", + input_tokens=100, + output_tokens=50, + ) + finally: + current_memory_task.reset(token) + + # Task tag should win over scope + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_CONSOLIDATION) assert stats is not None assert stats.input_tokens == 100 assert stats.output_tokens == 50 + # Retain should be empty + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_RETAIN) is None def test_record_llm_call_with_other_scope(self, mock_tracker, fresh_recorder): """Any other scope → memory_other bucket.""" From ded1dda808d94b3e1332b1511fc6c06fd54b421e Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Thu, 1 Oct 2026 11:33:18 +0200 Subject: [PATCH 25/39] recall --- CHANGELOG.md | 8 + .../agents/memory/cerebral/cerebral.py | 136 ++++++++------- src/codespy/agents/memory/postgres.py | 156 +++++++++++++----- .../agents/memory/prefrontal/prefrontal.py | 2 +- src/codespy/agents/memory/recall.py | 9 +- src/codespy/agents/review/audit/agent.py | 23 +-- .../agents/review/code_review/agent.py | 25 +-- src/codespy/agents/review/doc/agent.py | 25 +-- src/codespy/agents/review/models.py | 9 +- src/codespy/agents/review/summary/agent.py | 29 ++-- .../agents/review/supply_chain/agent.py | 25 +-- src/codespy/config_memory.py | 56 +++++++ src/codespy/workflows/review/pipeline.py | 84 +++++++++- tests/test_cerebral.py | 50 +++++- tests/test_hippocampus.py | 96 ++++++++++- 15 files changed, 535 insertions(+), 198 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 039bb28..79c4299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ ## [Unreleased] ### Changed +- **Run-level Prefrontal recall**: One Prefrontal recall per run, shared by summary, code_review, doc, supply_chain, and audit agents. Previously each agent call ran its own `Prefrontal.aload()`, paying for the reflect loop separately. The scope agent still loads its own memory. +- **One consolidation per run**: Consolidation and mental-model refresh run once per run, after all background episode saves complete. Previously every `retain_episode` consolidated each scope plus the repo scope, refreshing the repo briefing N times per run. +- **Recalls table schema**: The `recalls` table is redesigned for run-level tracking: + - `ordinal` (INT) replaced by `id` (UUID) + - New columns: `run_id` (VARCHAR), `task` (VARCHAR) + - `episode_id` is now nullable (NULL for the shared run-level load) + - Primary key changed from `(bank_id, episode_id, ordinal)` to `(bank_id, id)` + - New index: `idx_recalls_run (bank_id, run_id)` - **Cerebral cost bucket split**: Consolidation and mental-model refresh LLM calls are now billed to `memory_consolidation` and `memory_mental_models` respectively, instead of `memory_other`. The `memory_other` bucket now only holds uncategorized LLM calls (e.g., standalone reflect calls). diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index 09d6572..1f1b088 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -4,10 +4,9 @@ instance as the episodic store, under the ``semantic`` schema. Write side: ``retain_episode`` retains Hippocampus episodes, tagged with -``repo:``/``org:`` and consolidated per observation scope, then keeps one -mental model ("briefing") per scope. With ``SyncTaskBackend`` consolidation -and mental-model refreshes run inline, on the caller's (background save) -thread. +``repo:``/``org:``. Scopes are recorded per run_id for later consolidation. +``consolidate_run`` submits one consolidation after all episode saves complete, +which refreshes mental models ("briefings") once per touched scope. Read side (used by Prefrontal only): ``arecall``/``recall``, ``areflect``/``reflect``, ``aget_mental_models`` and @@ -161,6 +160,9 @@ async def _mark_failed_if_pending(self, task: dict, exc: Exception) -> None: ) from codespy.agents.memory.cerebral.cost import ( + BUCKET_MEMORY_CONSOLIDATION, + BUCKET_MEMORY_MENTAL_MODELS, + BUCKET_MEMORY_OTHER, CerebralCostRecorder, MeteredLiteLLMSDKEmbeddings, register_cerebral_cost_recorder, @@ -360,6 +362,10 @@ def __init__( self._ensured_mental_models: set[str] = set() # Lock for consolidation serialization self._consolidation_lock = threading.Lock() + # Pending scopes per run_id for one-consolidation-per-run (dict[str, list[list[str]]]) + self._pending_scopes: dict[str, list[list[str]]] = {} + # Lock for _pending_scopes (written from background save threads, popped on pipeline thread) + self._pending_scopes_lock = threading.Lock() logger.info( "Cerebral MemoryEngine initialized (schema=%s, bank=%s, embeddings=%s, provider=%s, chunk_size=%s, max_input_chars=%s)", @@ -605,27 +611,6 @@ def retain_episode(self, episode: Episode, repo_full_name: str | None = None) -> self._sync_briefing_triggers(mental_model_scopes) started = time.monotonic() - logger.info( - "cerebral: retain + consolidation started for episode %s (bank=%s, task=%s)", - episode.id, self._bank_id, episode.task, - ) - - # Capture baseline stats for delta calculation (all three buckets) - from codespy.agents.cost_tracker import get_cost_tracker - from codespy.agents.memory.cerebral.cost import ( - BUCKET_MEMORY_CONSOLIDATION, - BUCKET_MEMORY_MENTAL_MODELS, - BUCKET_MEMORY_OTHER, - ) - - tracker = get_cost_tracker() - baseline_consolidation = tracker.get_signature_stats(BUCKET_MEMORY_CONSOLIDATION) - baseline_mental_models = tracker.get_signature_stats(BUCKET_MEMORY_MENTAL_MODELS) - baseline_other = tracker.get_signature_stats(BUCKET_MEMORY_OTHER) - baseline_calls_consolidation = baseline_consolidation.call_count if baseline_consolidation else 0 - baseline_calls_mental_models = baseline_mental_models.call_count if baseline_mental_models else 0 - baseline_calls_other = baseline_other.call_count if baseline_other else 0 - try: self._run_async( self._engine.retain_batch_async( @@ -641,28 +626,25 @@ def retain_episode(self, episode: Episode, repo_full_name: str | None = None) -> ) return - # Scoped consolidation (serialized) - prevents unscoped pending rows - # from blocking subsequent submissions. - with self._consolidation_lock: - for scope_tags in mental_model_scopes: - self._submit_scoped_consolidation(scope_tags) - - # Log consolidation completion with per-bucket deltas - end_consolidation = tracker.get_signature_stats(BUCKET_MEMORY_CONSOLIDATION) - end_mental_models = tracker.get_signature_stats(BUCKET_MEMORY_MENTAL_MODELS) - end_other = tracker.get_signature_stats(BUCKET_MEMORY_OTHER) - delta_calls_consolidation = (end_consolidation.call_count if end_consolidation else baseline_calls_consolidation) - baseline_calls_consolidation - delta_calls_mental_models = (end_mental_models.call_count if end_mental_models else baseline_calls_mental_models) - baseline_calls_mental_models - delta_calls_other = (end_other.call_count if end_other else baseline_calls_other) - baseline_calls_other + # Record scopes for one-consolidation-per-run instead of submitting inline + # Skip when repo_full_name is None (matches today's behavior: no scopes means no consolidation) + if repo_full_name and mental_model_scopes: + with self._pending_scopes_lock: + # Get existing scopes for this run_id (deduped by sorted tuple) + existing = self._pending_scopes.get(episode.run_id, []) + seen: set[tuple[str, ...]] = {tuple(sorted(s)) for s in existing} + for scope_tags in mental_model_scopes: + key = tuple(sorted(scope_tags)) + if key not in seen: + seen.add(key) + existing.append(scope_tags) + self._pending_scopes[episode.run_id] = existing logger.info( - "cerebral: retained %d content blobs for episode %s (bank=%s, task=%s); " - "consolidation finished in %.1fs, memory_consolidation +%d calls, memory_mental_models +%d calls, memory_other +%d calls", + "cerebral: retained %d content blobs for episode %s (bank=%s, task=%s) in %.1fs; " + "consolidation deferred to end of run %s", len(contents), episode.id, self._bank_id, episode.task, - time.monotonic() - started, - delta_calls_consolidation, - delta_calls_mental_models, - delta_calls_other, + time.monotonic() - started, episode.run_id, ) @staticmethod @@ -795,25 +777,67 @@ def _sync_briefing_triggers(self, scopes: list[list[str]]) -> None: except Exception: logger.warning("cerebral: failed to sync briefing trigger for %s", mm_id, exc_info=True) - def _submit_scoped_consolidation(self, scope_tags: list[str]) -> None: - """Submit a scoped consolidation for one scope under the process lock. + def consolidate_run(self, run_id: str) -> None: + """Trigger one consolidation for all scopes touched during a run. - Scoped consolidation is never skipped, unlike unscoped submissions which - are deduplicated when a pending unscoped row exists. + Called by the pipeline after join_episode_saves() completes. + Submits a single consolidation with the union of all scopes, + which Hindsight ORs and refreshes affected mental models once. + Never raises. + + Args: + run_id: The run identifier to consolidate scopes for. """ - ctx = RequestContext() + # Pop first so the run's entry is always released, even on failure. + with self._pending_scopes_lock: + scopes = self._pending_scopes.pop(run_id, None) + + if not scopes: + logger.info("consolidate_run: no scopes pending for run %s; skipped", run_id) + return + try: - self._run_async( - self._engine.submit_async_consolidation( - self._bank_id, - request_context=ctx, - observation_scopes=[scope_tags], + from codespy.agents.cost_tracker import get_cost_tracker + + tracker = get_cost_tracker() + + def _calls(bucket: str) -> int: + stats = tracker.get_signature_stats(bucket) + return stats.call_count if stats else 0 + + buckets = (BUCKET_MEMORY_CONSOLIDATION, BUCKET_MEMORY_MENTAL_MODELS, BUCKET_MEMORY_OTHER) + baseline = {b: _calls(b) for b in buckets} + + logger.info( + "consolidate_run: starting for run %s with %d scope(s)", + run_id, len(scopes), + ) + started = time.monotonic() + + with self._consolidation_lock: + # Submit one consolidation with all scopes (Hindsight ORs them) + self._run_async( + self._engine.submit_async_consolidation( + self._bank_id, + request_context=RequestContext(), + observation_scopes=scopes, + ) ) + + delta = {b: _calls(b) - baseline[b] for b in buckets} + logger.info( + "consolidate_run: finished for run %s in %.1fs, " + "memory_consolidation +%d calls, memory_mental_models +%d calls, memory_other +%d calls", + run_id, + time.monotonic() - started, + delta[BUCKET_MEMORY_CONSOLIDATION], + delta[BUCKET_MEMORY_MENTAL_MODELS], + delta[BUCKET_MEMORY_OTHER], ) except Exception: logger.warning( - "cerebral: scoped consolidation failed for scope %s", - scope_tags, + "consolidate_run: consolidation failed for run %s", + run_id, exc_info=True, ) diff --git a/src/codespy/agents/memory/postgres.py b/src/codespy/agents/memory/postgres.py index 2e05a15..802416e 100644 --- a/src/codespy/agents/memory/postgres.py +++ b/src/codespy/agents/memory/postgres.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import uuid from typing import TYPE_CHECKING from psycopg import sql @@ -13,6 +14,7 @@ if TYPE_CHECKING: from codespy.agents.memory.hippocampus.context_memory import ContextMemory from codespy.agents.memory.hippocampus.episode import Episode + from codespy.agents.memory.recall import RecallRecord logger = logging.getLogger(__name__) @@ -220,13 +222,26 @@ def ensure_schema(self) -> None: ) """) + # Check for legacy recalls table (without 'id' column) and drop it + cur.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'recalls' AND table_schema = COALESCE(current_schema(), 'public') + """) + existing_columns = {row[0] for row in cur.fetchall()} + if existing_columns and 'id' not in existing_columns: + logger.warning("Dropping legacy recalls table (missing 'id' column)") + cur.execute("DROP TABLE IF EXISTS recalls") + # Recalls table: one row per Prefrontal recall (monitoring only; # never read back into memory, never retained into Cerebral). + # Redesigned: run-scoped with optional episode_id (NULL for run-level load). cur.execute(""" CREATE TABLE IF NOT EXISTS recalls ( - bank_id VARCHAR(64) NOT NULL, - episode_id UUID NOT NULL, - ordinal INT NOT NULL, + bank_id VARCHAR(64) NOT NULL REFERENCES banks(id) ON DELETE CASCADE, + id UUID NOT NULL, + run_id VARCHAR(48) NOT NULL, + episode_id UUID NULL, + task VARCHAR(64) NOT NULL, kind VARCHAR(8) NOT NULL, timestamp TIMESTAMPTZ NOT NULL, query TEXT NOT NULL DEFAULT '', @@ -242,11 +257,13 @@ def ensure_schema(self) -> None: output_cost DOUBLE PRECISION NOT NULL DEFAULT 0, latency_ms INT NOT NULL DEFAULT 0, details JSONB NOT NULL DEFAULT '{}'::jsonb, - PRIMARY KEY (bank_id, episode_id, ordinal), - FOREIGN KEY (bank_id, episode_id) - REFERENCES episodes(bank_id, id) ON DELETE CASCADE + PRIMARY KEY (bank_id, id), + FOREIGN KEY (bank_id, episode_id) REFERENCES episodes(bank_id, id) ON DELETE CASCADE ) """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS idx_recalls_run ON recalls (bank_id, run_id) + """) cur.execute(""" CREATE INDEX IF NOT EXISTS idx_recalls_time ON recalls (bank_id, timestamp DESC) @@ -550,38 +567,10 @@ def save_episode(self, episode: Episode) -> None: (self.bank_id, str(episode.id), name, content), ) - # 8. Insert recalls (Prefrontal monitoring) - for rec in episode.recalls or []: - cur.execute( - """ - INSERT INTO recalls - (bank_id, episode_id, ordinal, kind, timestamp, query, reach, - reflects, status, text, model, llm_calls, input_tokens, - output_tokens, input_cost, output_cost, latency_ms, details) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (bank_id, episode_id, ordinal) DO NOTHING - """, - ( - self.bank_id, - str(episode.id), - rec.ordinal, - rec.kind, - rec.timestamp, - rec.query, - rec.reach, - rec.reflects, - rec.status, - rec.text, - rec.model, - rec.llm_calls, - rec.input_tokens, - rec.output_tokens, - rec.input_cost, - rec.output_cost, - rec.latency_ms, - Jsonb(rec.details), - ), - ) + # 8. Insert recalls (Prefrontal monitoring) - now with episode_id set + self._insert_recalls( + cur, episode.recalls or [], run_id=episode.run_id, episode_id=episode.id, task=episode.task + ) conn.commit() @@ -774,3 +763,94 @@ def load_context( self.bank_id, task, exc_info=True ) return None + + def _insert_recalls( + self, + cur, + records: list[RecallRecord], + *, + run_id: str, + episode_id: uuid.UUID | None, + task: str, + ) -> None: + """Insert recall records with run_id, episode_id, and task filled in. + + Args: + cur: Database cursor + records: List of RecallRecord to insert + run_id: Run identifier + episode_id: Episode UUID (None for run-level recalls) + task: Task name (consumer task or "review" for run-level) + """ + import uuid as uuid_module + + for rec in records: + # Ensure the record has the correct run_id, episode_id, and task + rec.run_id = run_id + rec.task = task + cur.execute( + """ + INSERT INTO recalls + (bank_id, id, run_id, episode_id, task, kind, timestamp, query, reach, + reflects, status, text, model, llm_calls, input_tokens, + output_tokens, input_cost, output_cost, latency_ms, details) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (bank_id, id) DO NOTHING + """, + ( + self.bank_id, + str(rec.id) if rec.id else str(uuid_module.uuid4()), + rec.run_id, + str(episode_id) if episode_id else None, + rec.task, + rec.kind, + rec.timestamp, + rec.query, + rec.reach, + rec.reflects, + rec.status, + rec.text, + rec.model, + rec.llm_calls, + rec.input_tokens, + rec.output_tokens, + rec.input_cost, + rec.output_cost, + rec.latency_ms, + Jsonb(rec.details), + ), + ) + + def save_recalls( + self, + records: list[RecallRecord], + *, + run_id: str, + episode_id: uuid.UUID | None = None, + task: str = "", + ) -> None: + """Persist recall records for a run or episode. + + Args: + records: List of RecallRecord to persist + run_id: Run identifier + episode_id: Optional episode UUID (None for run-level recalls) + task: Task name (consumer task or "review" for run-level load) + """ + if not records: + return + + with self._pool.connection() as conn: + with conn.cursor() as cur: + # Ensure bank exists + cur.execute( + "INSERT INTO banks (id) VALUES (%s) ON CONFLICT (id) DO NOTHING", + (self.bank_id,), + ) + self._insert_recalls(cur, records, run_id=run_id, episode_id=episode_id, task=task) + conn.commit() + + logger.info( + "save_recalls: persisted %d recalls (bank=%s, run_id=%s, task=%s)", + len(records), self.bank_id, run_id, task, + ) diff --git a/src/codespy/agents/memory/prefrontal/prefrontal.py b/src/codespy/agents/memory/prefrontal/prefrontal.py index 0ce0252..96531c8 100644 --- a/src/codespy/agents/memory/prefrontal/prefrontal.py +++ b/src/codespy/agents/memory/prefrontal/prefrontal.py @@ -166,7 +166,7 @@ def _record( """Append one RecallRecord. Never raises.""" try: rec = RecallRecord( - ordinal=len(self._records), + task=self._task, kind=kind, timestamp=started_at, query=query, diff --git a/src/codespy/agents/memory/recall.py b/src/codespy/agents/memory/recall.py index 8335ec0..2e24362 100644 --- a/src/codespy/agents/memory/recall.py +++ b/src/codespy/agents/memory/recall.py @@ -8,6 +8,7 @@ import contextvars import threading +import uuid from collections.abc import Iterator, Set from contextlib import contextmanager from dataclasses import dataclass, field @@ -21,7 +22,9 @@ class RecallRecord(BaseModel): """One Prefrontal recall (pre-call load or recall_memory tool call). Attributes: - ordinal: Order within one Prefrontal instance (0 = first recall). + id: UUID identifying this recall record. + run_id: Run identifier this recall belongs to. + task: Consumer task name (e.g. "code_review", "review" for run-level load). kind: "load" for pre-call aload, "tool" for recall_memory. timestamp: UTC time the recall started. query: The query used (context facet for load, truncated query for tool). @@ -39,7 +42,9 @@ class RecallRecord(BaseModel): details: JSON-serializable extra data per kind. """ - ordinal: int + id: uuid.UUID = Field(default_factory=uuid.uuid4) + run_id: str = Field(default="") + task: str = Field(default="") kind: Literal["load", "tool"] timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) query: str = Field(default="") diff --git a/src/codespy/agents/review/audit/agent.py b/src/codespy/agents/review/audit/agent.py index 4df6823..eedee80 100644 --- a/src/codespy/agents/review/audit/agent.py +++ b/src/codespy/agents/review/audit/agent.py @@ -10,7 +10,7 @@ from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.context_memory import Topic from codespy.agents.memory.hippocampus.episode import submit_episode_save -from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory +from codespy.agents.memory.prefrontal import with_prefrontal_memory from codespy.agents.review.models import Issue, ReviewContext from codespy.agents.review.helpers import deepest_common_folder from codespy.config import get_settings @@ -166,14 +166,13 @@ def forward( ) repo_full_name = review_context.pr_context.repo_full_name - scope_topic_ids = [scope.topic(repo_full_name).id for scope in scopes or []] + # Get Prefrontal for tool calls only (prefrontal_memory is already loaded by pipeline) pf = get_prefrontal( self._settings, "audit", repo_full_name, - scope_topic_ids=scope_topic_ids, - include_repo=True, - ) + scope_topic_ids=None, + ) if self._settings.get_memory_enabled("audit") else None sig = with_prefrontal_memory(AuditSignature) if pf else AuditSignature auditor = ContextSafe( dspy.ChainOfThought(sig), @@ -186,18 +185,8 @@ def forward( logger.info("Running audit...") with SignatureContext("audit", self._cost_tracker): - prefrontal_memory = ( - pf.load( - build_facets( - "audit", - repo_full_name, - pr_title=review_context.pr_context.pr_title, - summary=review_context.pr_context.summary, - ) - ) - if pf is not None - else None - ) + # Use shared run-level recall (already loaded by pipeline) + prefrontal_memory = review_context.prefrontal_memory or None result, hippo, store, _artifacts = self._call_auditor( auditor, review_context, diff --git a/src/codespy/agents/review/code_review/agent.py b/src/codespy/agents/review/code_review/agent.py index a4e3a05..7cd865f 100644 --- a/src/codespy/agents/review/code_review/agent.py +++ b/src/codespy/agents/review/code_review/agent.py @@ -13,7 +13,7 @@ from codespy.agents.context_safe import ContextSafe from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save -from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory +from codespy.agents.memory.prefrontal import with_prefrontal_memory from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult from codespy.agents.review.helpers import ( @@ -21,7 +21,6 @@ make_scope_relative, resolve_scope_root, restore_repo_paths, - scope_package_names, ) from codespy.config import get_settings from codespy.config_memory import get_cerebral, get_episode_store, get_prefrontal @@ -274,10 +273,10 @@ async def _review_scope( tools, contexts = await self._create_tools(scope_root) try: repo_full_name = review_context.pr_context.repo_full_name - scope_topic_id = scope.topic(repo_full_name).id + # Get Prefrontal for tool calls only (prefrontal_memory is already loaded by pipeline) pf = get_prefrontal( - self._settings, "code_review", repo_full_name, scope_topic_ids=[scope_topic_id] - ) + self._settings, "code_review", repo_full_name, scope_topic_ids=None + ) if self._settings.get_memory_enabled("code_review") else None sig = with_prefrontal_memory(CodeReviewSignature) if pf else CodeReviewSignature recall_tool = pf.recall_tool() if pf else None agent_tools = [*tools, recall_tool] if recall_tool else tools @@ -301,20 +300,10 @@ async def _review_scope( ) hippo: Hippocampus | None = None async with SignatureContext("code_review", self._cost_tracker): - # Prefrontal: prior knowledge from Cerebral (never given to Hippocampus) + # Prefrontal: use shared run-level recall (never given to Hippocampus) pf_kwargs: dict[str, Any] = {} - if pf is not None: - pf_kwargs["prefrontal_memory"] = await pf.aload( - build_facets( - "code_review", - scope_topic_id, - description=scope.description, - pr_title=review_context.pr_context.pr_title, - paths=[f.filename for f in scope.changed_files], - summary=review_context.pr_context.summary, - packages=scope_package_names(scope), - ) - ) + if review_context.prefrontal_memory: + pf_kwargs["prefrontal_memory"] = review_context.prefrontal_memory # Load own prior "code_review" episode for this scope scope_initial_memory: ContextMemory | None = None store = None diff --git a/src/codespy/agents/review/doc/agent.py b/src/codespy/agents/review/doc/agent.py index 1aa7fe1..a01684a 100644 --- a/src/codespy/agents/review/doc/agent.py +++ b/src/codespy/agents/review/doc/agent.py @@ -13,7 +13,7 @@ from codespy.agents.context_safe import ContextSafe from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save -from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory +from codespy.agents.memory.prefrontal import with_prefrontal_memory from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult from codespy.agents.review.doc.doc_extractor import extract_documentation @@ -23,7 +23,6 @@ make_scope_relative, resolve_scope_root, restore_repo_paths, - scope_package_names, ) from codespy.config import get_settings from codespy.config_memory import get_cerebral, get_episode_store, get_prefrontal @@ -203,10 +202,10 @@ async def _review_scope( return [] try: repo_full_name = review_context.pr_context.repo_full_name - scope_topic_id = scope.topic(repo_full_name).id + # Get Prefrontal for tool calls only (prefrontal_memory is already loaded by pipeline) pf = get_prefrontal( - self._settings, "doc", repo_full_name, scope_topic_ids=[scope_topic_id] - ) + self._settings, "doc", repo_full_name, scope_topic_ids=None + ) if self._settings.get_memory_enabled("doc") else None sig = with_prefrontal_memory(DocReviewSignature) if pf else DocReviewSignature reviewer = ContextSafe( dspy.ChainOfThought(sig), @@ -221,20 +220,10 @@ async def _review_scope( ) hippo: Hippocampus | None = None async with SignatureContext("doc", self._cost_tracker): - # Prefrontal: prior knowledge from Cerebral (never given to Hippocampus) + # Prefrontal: use shared run-level recall (never given to Hippocampus) pf_kwargs: dict[str, Any] = {} - if pf is not None: - pf_kwargs["prefrontal_memory"] = await pf.aload( - build_facets( - "doc", - scope_topic_id, - description=scope.description, - pr_title=review_context.pr_context.pr_title, - paths=[f.filename for f in scope.changed_files], - summary=review_context.pr_context.summary, - packages=scope_package_names(scope), - ) - ) + if review_context.prefrontal_memory: + pf_kwargs["prefrontal_memory"] = review_context.prefrontal_memory # Load own prior "doc" episode for this scope scope_initial_memory: ContextMemory | None = None store = None diff --git a/src/codespy/agents/review/models.py b/src/codespy/agents/review/models.py index b8e7b08..8845c53 100644 --- a/src/codespy/agents/review/models.py +++ b/src/codespy/agents/review/models.py @@ -91,16 +91,21 @@ class ReviewContext(BaseModel): """Evolving pipeline state threaded through review stages. Carries the immutable PR identity and runtime pipeline metadata. - Context memory is loaded independently by each module from its own prior episodes. + The prefrontal_memory field holds the shared run-level recall text + that is loaded once and passed to all consumer agents. """ pr_context: PRContext = Field( description="Immutable PR identity (repo, number, title, summary)" ) memory: ContextMemory | None = Field( - default=None, description="Unused — each module loads its own prior episodes. Kept for API compatibility." + default=None, description="Deprecated — use prefrontal_memory instead. Kept for API compatibility." ) metadata: ReviewMetadata | None = Field(default=None, description="Runtime pipeline state") + prefrontal_memory: str = Field( + default="", + description="Shared run-level Prefrontal recall text, loaded once and passed to all consumer agents", + ) class Issue(BaseModel): diff --git a/src/codespy/agents/review/summary/agent.py b/src/codespy/agents/review/summary/agent.py index 9320529..abf7a34 100644 --- a/src/codespy/agents/review/summary/agent.py +++ b/src/codespy/agents/review/summary/agent.py @@ -10,7 +10,7 @@ from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save from codespy.agents.memory.hippocampus.context_memory import Topic -from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory +from codespy.agents.memory.prefrontal import with_prefrontal_memory from codespy.agents.review.helpers import deepest_common_folder from codespy.config import get_settings from codespy.config_memory import get_cerebral, get_episode_store, get_prefrontal @@ -56,6 +56,7 @@ def forward( run_id: str | None = None, scopes: list["ScopeResult"] | None = None, topics: list[Topic] | None = None, + prefrontal_memory: str = "", ) -> str: """Generate a PR summary. @@ -66,6 +67,7 @@ def forward( run_id: Pipeline run identifier scopes: List of resolved scopes for per-scope episode persistence topics: Optional list of Topic objects for auto-tagging + prefrontal_memory: Shared run-level Prefrontal recall text (already loaded by pipeline) Returns: Summary string @@ -97,14 +99,14 @@ def forward( logger.info("No prior summary episode found") repo_full_name = pr_context.repo_full_name - scope_topic_ids = [scope.topic(repo_full_name).id for scope in scopes or []] + # Get Prefrontal for tool calls only (prefrontal_memory is already loaded) pf = get_prefrontal( self._settings, "summary", repo_full_name, - scope_topic_ids=scope_topic_ids, - include_repo=True, - ) + scope_topic_ids=None, + include_repo=False, + ) if self._settings.get_memory_enabled("summary") else None sig = with_prefrontal_memory(PRSummarySignature) if pf else PRSummarySignature summarizer = ContextSafe( dspy.ChainOfThought(sig), @@ -120,17 +122,10 @@ def forward( hippo: Hippocampus | None = None with SignatureContext("summary", self._cost_tracker): - # Prefrontal: prior knowledge from Cerebral (never given to Hippocampus) + # Prefrontal: use shared run-level recall (never given to Hippocampus) pf_kwargs: dict[str, str] = {} - if pf is not None: - pf_kwargs["prefrontal_memory"] = pf.load( - build_facets( - "summary", - repo_full_name, - pr_title=pr_context.pr_title, - paths=changed_file_paths, - ) - ) + if prefrontal_memory: + pf_kwargs["prefrontal_memory"] = prefrontal_memory if self._settings.get_memory_enabled("summary") and store is not None: # Build topics list for Hippocampus scope_topics: list[Topic] = [] @@ -159,12 +154,14 @@ def forward( _summary_text = result.summary cerebral = get_cerebral(self._settings) _cerebral = cerebral + # Get recalls from Prefrontal for tool calls only + _recalls = pf.recalls if pf else None def _persist(): try: hippo.end_episode( store, artifacts={"summary": _summary_text}, - recalls=pf.recalls if pf else None, + recalls=_recalls, ) except Exception: logger.warning("Background summary episode save failed", exc_info=True) diff --git a/src/codespy/agents/review/supply_chain/agent.py b/src/codespy/agents/review/supply_chain/agent.py index 30ab963..2edc7bf 100644 --- a/src/codespy/agents/review/supply_chain/agent.py +++ b/src/codespy/agents/review/supply_chain/agent.py @@ -13,14 +13,13 @@ from codespy.agents.context_safe import ContextSafe from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus from codespy.agents.memory.hippocampus.episode import submit_episode_save -from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory +from codespy.agents.memory.prefrontal import with_prefrontal_memory from codespy.agents.review.models import Issue, IssueCategory, ReviewContext from codespy.agents.review.scope.models import ScopeResult from codespy.agents.review.helpers import ( issues_to_markdown, resolve_scope_root, restore_repo_paths, - scope_package_names, strip_prefix, ) from codespy.config import get_settings @@ -372,10 +371,10 @@ async def _review_scope( # Combine scoped filesystem tools with shared OSV tools all_tools = scoped_tools + osv_tools repo_full_name = review_context.pr_context.repo_full_name - scope_topic_id = scope.topic(repo_full_name).id + # Get Prefrontal for tool calls only (prefrontal_memory is already loaded by pipeline) pf = get_prefrontal( - self._settings, "supply_chain", repo_full_name, scope_topic_ids=[scope_topic_id] - ) + self._settings, "supply_chain", repo_full_name, scope_topic_ids=None + ) if self._settings.get_memory_enabled("supply_chain") else None sig = ( with_prefrontal_memory(SupplyChainSecuritySignature) if pf @@ -406,20 +405,10 @@ async def _review_scope( # Track supply_chain signature costs separately hippo: Hippocampus | None = None async with SignatureContext("supply_chain", self._cost_tracker): - # Prefrontal: prior knowledge from Cerebral (never given to Hippocampus) + # Prefrontal: use shared run-level recall (never given to Hippocampus) pf_kwargs: dict[str, Any] = {} - if pf is not None: - pf_kwargs["prefrontal_memory"] = await pf.aload( - build_facets( - "supply_chain", - scope_topic_id, - description=scope.description, - pr_title=review_context.pr_context.pr_title, - paths=[f.filename for f in scope.changed_files], - summary=review_context.pr_context.summary, - packages=scope_package_names(scope), - ) - ) + if review_context.prefrontal_memory: + pf_kwargs["prefrontal_memory"] = review_context.prefrontal_memory # Load own prior "supply_chain" episode for this scope scope_initial_memory: ContextMemory | None = None store = None diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 14ecd08..35b790b 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -709,6 +709,62 @@ def get_prefrontal( return None +# Consumer signatures that share the run-level Prefrontal load +_RUN_PREFRONTAL_CONSUMERS = ("summary", "code_review", "doc", "supply_chain", "audit") + + +def get_run_prefrontal( + settings: Settings, + repo_full_name: str, + scope_topic_ids: Sequence[str], +) -> Prefrontal | None: + """Return a Prefrontal for the shared run-level recall, or None when inactive. + + Active only when: + - A repo is known + - Cerebral is available + - At least one consumer (summary, code_review, doc, supply_chain, audit) + has both is_signature_enabled and get_memory_enabled True + + When active, returns a Prefrontal with task_name="review", include_repo=True, + and all scope_topic_ids. The run-level recall is shared by all consumer agents. + + Never raises (same soft-fail behavior as get_prefrontal). + """ + try: + if not repo_full_name: + return None + + # Check if any consumer has memory enabled + from codespy.config_dspy import SIGNATURE_NAMES + + has_memory_consumer = any( + settings.is_signature_enabled(sig) and settings.get_memory_enabled(sig) + for sig in SIGNATURE_NAMES + if sig in _RUN_PREFRONTAL_CONSUMERS + ) + if not has_memory_consumer: + return None + + cerebral = get_cerebral(settings) + if cerebral is None: + return None + + from codespy.agents.memory.prefrontal import Prefrontal + + return Prefrontal( + settings.memory.prefrontal, + cerebral, + task_name="review", + repo_full_name=repo_full_name, + scope_topic_ids=scope_topic_ids, + include_repo=True, + ) + except Exception: + logger.warning("Run Prefrontal unavailable for %s", repo_full_name, exc_info=True) + return None + + def reset_cerebral() -> None: """Clear the cached Cerebral instance so it is rebuilt on next access. diff --git a/src/codespy/workflows/review/pipeline.py b/src/codespy/workflows/review/pipeline.py index bec4a2b..3f1c884 100644 --- a/src/codespy/workflows/review/pipeline.py +++ b/src/codespy/workflows/review/pipeline.py @@ -10,6 +10,8 @@ from codespy.agents import configure_dspy, get_cost_tracker, verify_model_access from codespy.agents.memory.hippocampus.context_memory import Topic +from codespy.agents.memory.prefrontal import build_facets +from codespy.agents.memory.prefrontal.query import MAX_CONTEXT_PATHS, MAX_ENTITY_TERMS from codespy.agents.review.models import Issue, PRContext, ReviewContext, ReviewMetadata from codespy.agents.review import ( Auditor, @@ -23,7 +25,12 @@ from codespy.agents.review.helpers import build_patches from codespy.agents.review.scope import MANIFEST_FILES, MANIFEST_GLOBS, build_sparse_patterns from codespy.config import Settings, get_settings -from codespy.config_memory import verify_memory_access +from codespy.config_memory import ( + get_cerebral, + get_episode_store, + get_run_prefrontal, + verify_memory_access, +) from codespy.tools.git import ChangedFile, GitClient, PullRequest, get_client from codespy.tools.git.local_diff import build_pr_from_diff @@ -214,10 +221,49 @@ def forward(self, config: ReviewConfig) -> ReviewResult: if not is_local: self._expand_sparse_for_scopes(scopes, repo_path, changed_file_paths) patches = build_patches(pr.changed_files) - # Step 2: Run Summarizer (now receives scopes for per-scope episode persistence) + # Build all scope topics (scope topics + PR topic) all_scope_topics = [s.topic(pr.repo_full_name) for s in scopes] all_scope_topics.append(pr_context.to_topic()) + + # Build scope topic IDs for Prefrontal + scope_topic_ids = [t.id for t in all_scope_topics if t.type == "project_scope"] + + # Run-level Prefrontal recall: one load shared by all consumer agents + run_pf = get_run_prefrontal(self.settings, pr.repo_full_name, scope_topic_ids) + pf_text = "" + if run_pf: + # Build facets for the run-level load + # Collect paths, packages from all scopes + all_paths = list({f.filename for s in scopes for f in s.changed_files}) + all_packages = list({ + s.package_manifest.package_name + for s in scopes + if s.package_manifest and s.package_manifest.package_name + }) + facets = build_facets( + task="review", + target=pr.repo_full_name, + pr_title=pr.title, + summary=pr.body or "", + paths=all_paths[:MAX_CONTEXT_PATHS], + packages=all_packages[:MAX_ENTITY_TERMS], + ) + pf_text = run_pf.load(facets) + # Persist run-level recalls + try: + store = get_episode_store(self.settings) + if store and run_pf.recalls: + store.save_recalls( + run_pf.recalls, + run_id=run_id, + episode_id=None, + task="review", + ) + except Exception as e: + logger.warning(f"Failed to save run-level recalls: {e}") + + # Step 2: Run Summarizer (now receives scopes for per-scope episode persistence) pr_summary = self.summarizer( pr_context=pr_context, changed_file_paths=changed_file_paths, @@ -225,10 +271,13 @@ def forward(self, config: ReviewConfig) -> ReviewResult: run_id=run_id, scopes=scopes, topics=all_scope_topics, + prefrontal_memory=pf_text, ) - # Enrich review_ctx with actual summary + # Enrich review_ctx with actual summary and prefrontal_memory pr_context.summary = pr_summary - review_ctx = ReviewContext(pr_context=pr_context, memory=None, metadata=metadata) + review_ctx = ReviewContext( + pr_context=pr_context, memory=None, metadata=metadata, prefrontal_memory=pf_text + ) # Step 3: Run review modules concurrently via asyncio.gather module_names = ["code_reviewer", "doc_reviewer", "supply_chain_auditor"] logger.info(f"Running review modules concurrently: {', '.join(module_names)}...") @@ -247,7 +296,9 @@ def forward(self, config: ReviewConfig) -> ReviewResult: # Ensure all background episode saves complete before stats collection # (audit's episode save runs in background and contains distiller/cartographer calls) join_episode_saves() - # Collect per-signature statistics (after all saves complete) + # One consolidation per run (after all episode saves complete) + self._consolidate_run(run_id) + # Collect per-signature statistics (after all saves complete, includes consolidation costs) signature_stats_list = self._collect_signature_stats() return ReviewResult( pr_number=pr.number, @@ -266,6 +317,29 @@ def forward(self, config: ReviewConfig) -> ReviewResult: signature_stats=signature_stats_list, ) + def _consolidate_run(self, run_id: str) -> None: + """Trigger one consolidation per run after all episode saves complete. + + Consolidation runs only when: + - Cerebral is available + - At least one memory-enabled signature ran in this run + """ + from codespy.config_dspy import SIGNATURE_NAMES + + # Check if any consumer has memory enabled (reuse the check from verify_memory_access) + if not any( + self.settings.is_signature_enabled(sig) and self.settings.get_memory_enabled(sig) + for sig in SIGNATURE_NAMES + ): + return + + try: + cerebral = get_cerebral(self.settings) + if cerebral: + cerebral.consolidate_run(run_id) + except Exception: + logger.warning("Consolidation failed for run %s", run_id, exc_info=True) + def _collect_signature_stats(self) -> list[SignatureStatsResult]: """Collect statistics from all signatures that executed. diff --git a/tests/test_cerebral.py b/tests/test_cerebral.py index c8a6d29..b95ef51 100644 --- a/tests/test_cerebral.py +++ b/tests/test_cerebral.py @@ -172,8 +172,8 @@ def test_init_calls_initialize_and_routine_repair(self, mock_memory_engine_class database_url="postgresql://localhost:5432/test", llm_provider="litellm", ) - # Now called twice: once for initialize(), once for ensure_maintenance_routines() - assert mock_run_async.call_count == 2 + # initialize(), ensure_maintenance_routines(), repair_orphaned_operations() + assert mock_run_async.call_count == 3 def test_init_handles_none_base_url(self, mock_memory_engine_class, mock_litellm): """Test that None base_url is handled correctly.""" @@ -1174,6 +1174,52 @@ def test_recalls_never_retained(self, cerebral_async, async_engine, episode): assert all("RECALLED-TEXT" not in item["content"] for item in with_) +class TestConsolidateRun: + """One consolidation per run, submitted after all retains.""" + + def _engine(self, async_engine): + async_engine.submit_async_consolidation = AsyncMock(return_value={"operation_id": "op"}) + return async_engine + + def test_retain_does_not_consolidate(self, cerebral_async, async_engine, episode): + engine = self._engine(async_engine) + cerebral_async.retain_episode(episode, repo_full_name="test/repo") + engine.submit_async_consolidation.assert_not_awaited() + assert episode.run_id in cerebral_async._pending_scopes + + def test_one_submit_with_union_of_scopes(self, cerebral_async, async_engine, episode): + engine = self._engine(async_engine) + other = episode.model_copy(update={"id": uuid.uuid4(), "task": "doc"}) + cerebral_async.retain_episode(episode, repo_full_name="test/repo") + cerebral_async.retain_episode(other, repo_full_name="test/repo") + + cerebral_async.consolidate_run(episode.run_id) + + engine.submit_async_consolidation.assert_awaited_once() + scopes = engine.submit_async_consolidation.await_args.kwargs["observation_scopes"] + assert sorted(map(sorted, scopes)) == sorted( + map(sorted, [ + ["org:test", "repo:test/repo", "project_scope:test/repo/package"], + ["org:test", "repo:test/repo"], + ]) + ) + assert episode.run_id not in cerebral_async._pending_scopes + + def test_other_run_untouched(self, cerebral_async, async_engine, episode): + engine = self._engine(async_engine) + cerebral_async.retain_episode(episode, repo_full_name="test/repo") + cerebral_async.consolidate_run("another-run") + engine.submit_async_consolidation.assert_not_awaited() + assert episode.run_id in cerebral_async._pending_scopes + + def test_failure_swallowed_and_pending_released(self, cerebral_async, async_engine, episode): + engine = self._engine(async_engine) + engine.submit_async_consolidation.side_effect = RuntimeError("boom") + cerebral_async.retain_episode(episode, repo_full_name="test/repo") + cerebral_async.consolidate_run(episode.run_id) # must not raise + assert episode.run_id not in cerebral_async._pending_scopes + + class TestEnsureMentalModels: def test_ids_are_deterministic(self): a = mental_model_id(["repo:o/r", "org:o"]) diff --git a/tests/test_hippocampus.py b/tests/test_hippocampus.py index 9f90d6e..2f0c1ae 100644 --- a/tests/test_hippocampus.py +++ b/tests/test_hippocampus.py @@ -966,7 +966,6 @@ def _recall_records(): return [ RecallRecord( - ordinal=0, kind="load", query="context query", reach="org", @@ -983,7 +982,6 @@ def _recall_records(): details={"briefings": 1, "remote": 2, "facets": {"context": {"count": 1}}}, ), RecallRecord( - ordinal=1, kind="tool", query="where is login", reach="local", @@ -1020,7 +1018,7 @@ def _rows(self, store, episode_id): conn.row_factory = dict_row with conn.cursor() as cur: cur.execute( - "SELECT * FROM recalls WHERE bank_id = %s AND episode_id = %s ORDER BY ordinal", + "SELECT * FROM recalls WHERE bank_id = %s AND episode_id = %s ORDER BY timestamp", (store.bank_id, str(episode_id)), ) return cur.fetchall() @@ -1030,7 +1028,7 @@ def test_save_writes_recall_rows(self, episode_store): episode_store.save_episode(episode) rows = self._rows(episode_store, episode.id) - assert [r["ordinal"] for r in rows] == [0, 1] + assert len(rows) == 2 load, tool = rows assert load["kind"] == "load" and load["status"] == "ok" assert load["text"].endswith("RECALLED-SECRET-TEXT") @@ -1042,6 +1040,9 @@ def test_save_writes_recall_rows(self, episode_store): assert load["latency_ms"] == 4200 assert load["reflects"] == 5 and load["reach"] == "org" assert load["details"]["facets"]["context"]["count"] == 1 + # New schema: run_id and task are populated from episode + assert load["run_id"] == "run-recalls" + assert load["task"] == "code_review" assert tool["status"] == "limit" assert tool["details"] == {"requested_reach": "bank", "mode": "reflect"} @@ -1056,6 +1057,88 @@ def test_episode_without_recalls(self, episode_store): episode_store.save_episode(episode) assert self._rows(episode_store, episode.id) == [] + def test_save_recalls_with_null_episode_id(self, episode_store): + """Run-level recalls have episode_id=NULL.""" + import uuid + from codespy.agents.memory.recall import RecallRecord + + recalls = [ + RecallRecord( + id=uuid.uuid4(), + run_id="run-level-test", + task="review", + kind="load", + timestamp=datetime.now(UTC), + query="test query", + reach="local", + status="ok", + text="test text", + model="test-model", + ), + ] + episode_store.save_recalls(recalls, run_id="run-level-test", episode_id=None, task="review") + # Query by run_id + from psycopg.rows import dict_row + with episode_store._pool.connection() as conn: + conn.row_factory = dict_row + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM recalls WHERE bank_id = %s AND run_id = %s", + (episode_store.bank_id, "run-level-test"), + ) + rows = cur.fetchall() + assert len(rows) == 1 + assert rows[0]["episode_id"] is None + assert rows[0]["task"] == "review" + + def test_legacy_table_is_dropped_and_recreated(self, episode_store): + """If recalls table exists without 'id' column, it should be dropped and recreated.""" + from psycopg.rows import dict_row + + # Manually create a legacy table (without 'id' column, with 'ordinal') + with episode_store._pool.connection() as conn: + with conn.cursor() as cur: + cur.execute("DROP TABLE IF EXISTS recalls") + cur.execute(""" + CREATE TABLE recalls ( + bank_id VARCHAR(64) NOT NULL, + episode_id UUID NOT NULL, + ordinal INT NOT NULL, + kind VARCHAR(8) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + query TEXT NOT NULL DEFAULT '', + reach VARCHAR(8) NOT NULL, + reflects INT NOT NULL DEFAULT 0, + status VARCHAR(8) NOT NULL, + text TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + llm_calls INT NOT NULL DEFAULT 0, + input_tokens INT NOT NULL DEFAULT 0, + output_tokens INT NOT NULL DEFAULT 0, + input_cost DOUBLE PRECISION NOT NULL DEFAULT 0, + output_cost DOUBLE PRECISION NOT NULL DEFAULT 0, + latency_ms INT NOT NULL DEFAULT 0, + details JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (bank_id, episode_id, ordinal) + ) + """) + conn.commit() + + # Call ensure_schema which should detect and drop the legacy table + episode_store.ensure_schema() + + # Verify the new schema has 'id' column + with episode_store._pool.connection() as conn: + conn.row_factory = dict_row + with conn.cursor() as cur: + cur.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'recalls' AND table_schema = COALESCE(current_schema(), 'public') + """) + columns = {row["column_name"] for row in cur.fetchall()} + assert "id" in columns + assert "ordinal" not in columns + class TestHippocampusRecalls: def test_end_episode_attaches_recalls_without_distilling_them(self): @@ -1068,7 +1151,10 @@ def test_end_episode_attaches_recalls_without_distilling_them(self): hippo.end_episode(recalls=recalls) assert hippo.episode is not None - assert [r.ordinal for r in hippo.episode.recalls] == [0, 1] + # New schema: recalls have id (UUID) and task instead of ordinal + assert len(hippo.episode.recalls) == 2 + assert all(r.task == "" for r in hippo.episode.recalls) # Not set yet + assert all(r.id is not None for r in hippo.episode.recalls) assert len(seen) == 1 trajectory, question = seen[0] assert "RECALLED-SECRET-TEXT" not in trajectory From 98fafb2b809bd873d6a7645ee7a59ca5d4672555 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Thu, 1 Oct 2026 16:25:12 +0200 Subject: [PATCH 26/39] recall --- .env.example | 2 + CHANGELOG.md | 15 + codespy.yaml | 1 + docs/memory.md | 35 ++- src/codespy/agents/cost_tracker.py | 179 +++++++++-- src/codespy/agents/dspy_config.py | 106 ++++++- .../agents/memory/cerebral/cerebral.py | 77 ++++- .../agents/review/doc/doc_extractor.py | 37 ++- src/codespy/config_memory.py | 117 ++++++-- src/codespy/workflows/review/models.py | 7 +- src/codespy/workflows/review/pipeline.py | 2 + src/codespy/workflows/review/reporters/git.py | 5 +- tests/test_config_memory.py | 171 ++++++++--- tests/test_cost_tracker.py | 171 ++++++++--- tests/test_doc_extractor.py | 280 ++++++++++++++++++ 15 files changed, 1025 insertions(+), 180 deletions(-) create mode 100644 tests/test_doc_extractor.py diff --git a/.env.example b/.env.example index c30be80..c1d7120 100644 --- a/.env.example +++ b/.env.example @@ -239,6 +239,8 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # MEMORY_MAX_PREFRONTAL_TOKENS=8192 # Pre-call context budget # MEMORY_MAX_PREFRONTAL_TOOL_TOKENS=2048 # Tool result budget # MEMORY_MAX_PREFRONTAL_TOOL_CALLS=0 # Per agent call (0 = tool off) +# Minimum seconds between automatic briefing refreshes (0 = refresh after every consolidation; N = at most one automatic refresh per N seconds). Delta mode means nothing is lost. +# MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS=0 # Reflect LLM timeout/retries default to codespy's LLM_TIMEOUT / LLM_RETRIES. # Override via HINDSIGHT_API_REFLECT_LLM_TIMEOUT / HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES. diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c4299..6c6b319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,27 @@ `memory_consolidation` and `memory_mental_models` respectively, instead of `memory_other`. The `memory_other` bucket now only holds uncategorized LLM calls (e.g., standalone reflect calls). Embeddings remain in `memory_embeddings` / `memory_prefrontal_embeddings` (unchanged). +- **Briefing refresh trigger**: Now defaults to delta mode with reduced evidence caps: + - `mode=delta` (was `full`): only new facts since last refresh + - `include_chunks=false` (was `true`): observation text only, no file chunks + - `reflect_search_observations_max_tokens=3000` (was 5000) + - `reflect_search_observations_include_entities=false` (was `true`) +- **Doc review CHANGELOG trim**: CHANGELOG files are now trimmed to preamble + 2 newest sections (keeps `[Unreleased]` + latest release). Saves ~25KB (21%) of the ~118KB doc payload in this repo. Non-CHANGELOG docs are unchanged. +- **Cost table columns**: Added `Cache Read` and `Cache Write` columns to per-signature cost breakdown. Cache tokens are extracted from provider usage (Anthropic/Bedrock style or OpenAI style). + +### Added +- **`memory.prefrontal.min_mental_model_refresh_seconds`** (env `MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS`, default `0`): Minimum seconds between automatic briefing refreshes. `0` = refresh after every consolidation; `N` = at most one automatic refresh per N seconds. Delta mode means nothing is lost — the refresh covers all new facts since `last_memory_seen_at` when the interval expires. Deferred refreshes are marked `cancelled` (not `failed`) and logged at INFO level. +- **Cost tracking for extraction LM**: `TwoStepAdapter` extraction calls are now counted and attributed to the calling signature. Previously these were missing from cost tracking. ### Fixed +- Cerebral consolidation and retain LLM calls now use `llm.timeout` / `llm.retries`. Previously only reflect did, so consolidation timed out at Hindsight's 120 s default. - Cerebral consolidation no longer logs `relation "webhooks" does not exist`. Inline (`SyncTaskBackend`) tasks now carry the `semantic` schema, so consolidation completion no longer rolls back and falls back. - Cerebral consolidation no longer fails at exit with `cannot schedule new futures after shutdown`. Background episode saves now finish before Python shuts down its thread pools, and the pipeline waits for them without the old 120 s cap. Runs can end later, because posting the review now waits for the consolidation to finish. - Briefings no longer stay stuck on "Generating content..." after a failed refresh. The `_SchemaSyncTaskBackend` now marks failed operations as failed, and startup repair marks orphaned pending operations older than 1 hour as failed so subsequent refreshes can proceed. - Embedding inputs are now capped for Bedrock Cohere v3 (2048 characters) to prevent `maxLength: 2048, actual: 2639` errors. Use `memory.cerebral.embeddings.max_input_chars` (env: `MEMORY_EMBEDDINGS_MAX_INPUT_CHARS`) to override: `null` = auto per model, `0` = disabled, `N` = custom cap. +- **Cost calculation**: `TwoStepAdapter` extraction calls are now included in signature cost tracking. Previously these calls were invisible because `SignatureContext` only read the main LM's history. +- **Cost billing**: DSPy cache hits (`response.cache_hit`) are now skipped in cost accounting, as they are not billed by the provider. +- **Cost split calculation**: When `entry["cost"]` is available (billed cost), input cost is now calculated as `billed_cost - output_cost` (list price). Previously rows could sum to more than Total when cache discounts applied. ### Added - **Prefrontal reflect hard caps**: programmatic reflect configuration with new caps: diff --git a/codespy.yaml b/codespy.yaml index aa95269..fafe724 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -147,6 +147,7 @@ memory: max_prefrontal_tokens: 8192 # MEMORY_MAX_PREFRONTAL_TOKENS (pre-call context budget) max_prefrontal_tool_tokens: 2048 # MEMORY_MAX_PREFRONTAL_TOOL_TOKENS max_prefrontal_tool_calls: 0 # MEMORY_MAX_PREFRONTAL_TOOL_CALLS (0 = recall_memory tool off) + min_mental_model_refresh_seconds: 0 # MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS (0 = refresh after every consolidation; N = at most one automatic briefing refresh per N seconds, nothing lost thanks to delta) # ============================================================================ # REVIEW diff --git a/docs/memory.md b/docs/memory.md index c820653..0dbd77e 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -285,6 +285,25 @@ A missing model is created and refreshed once; consolidation keeps it fresh afte Prefrontal reads the briefings of the agent's scopes (and repo) with no LLM call; missing models and models still holding the placeholder content are skipped. +#### Trigger configuration + +Briefing triggers are configured automatically with these settings: + +| Trigger Field | Value | Description | +|-------------|-------|-------------| +| `mode` | `delta` | Only new facts since last refresh (with structured delta-ops call) | +| `exclude_mental_models` | `true` | Briefings exclude other briefings | +| `include_chunks` | `false` | Observation text only (no file chunks) | +| `reflect_search_observations_max_tokens` | `3000` | Down from 5000 default | +| `reflect_search_observations_include_entities` | `false` | Exclude entity metadata | +| `min_refresh_interval_seconds` | `0` | Configurable via `MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS` | + +**Delta mode**: After the first full refresh, subsequent refreshes only process new facts since `last_memory_seen_at`. One structured delta-ops call edits the stored document. Falls back to full refresh when content is the placeholder or when the source query changed. Falls back to candidate markdown if the delta call fails. + +**Deferral**: When `min_refresh_interval_seconds > 0`, automatic refreshes inside the window are deferred (operation marked `cancelled`, not `failed`). The deferral is logged at INFO level. The next consolidation after the interval expires covers everything since `last_memory_seen_at`. + +**Quality trade-off**: The reduced caps (`include_chunks=False`, entities off, 3000-token observation budget) shrink evidence per briefing. Monitor briefing quality in the first runs after enabling. + ### Reach `memory.prefrontal.prefrontal_reach` (`local` | `org` | `bank`, default `org`): @@ -337,9 +356,18 @@ agent the content may be stale or wrong and that issues must be verified with to as-is (not doubled). Both Prefrontal and briefing refreshes then get half of that value (with LOW budget). -**Reflect LLM timeout/retries**: reuse codespy's `llm.timeout` / `llm.retries`. Override -via `HINDSIGHT_API_REFLECT_LLM_TIMEOUT` / `HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES` (or -the generic `HINDSIGHT_API_LLM_TIMEOUT` / `HINDSIGHT_API_LLM_MAX_RETRIES`). +**Hindsight LLM timeout/retries**: `llm.timeout` / `llm.retries` apply to **all** +Hindsight calls — retain, consolidation, reflect, and mental-model refresh. + +Operator overrides (precedence: env > codespy settings): +- **Global**: `HINDSIGHT_API_LLM_TIMEOUT` / `HINDSIGHT_API_LLM_MAX_RETRIES` — affect all operations +- **Per-operation**: + - `HINDSIGHT_API_RETAIN_LLM_TIMEOUT` / `HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES` + - `HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT` / `HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES` + - `HINDSIGHT_API_REFLECT_LLM_TIMEOUT` / `HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES` (also sets reflect-specific defaults) + - `HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_TIMEOUT` / `HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_MAX_RETRIES` + +Per-operation env vars win automatically because Hindsight prefers non-None per-op values. **Monitoring**: the load INFO line includes `maps=N` (split synthesis map-call count), `rewrite` (if a rewrite occurred), `thoughts=N` (reasoning tokens), and `tools=...` @@ -448,6 +476,7 @@ Observation capacity ≈ max_hippocampus_tokens / max_hippocampus_item_tokens (1 | `MEMORY_MAX_PREFRONTAL_TOKENS` | `memory.prefrontal.max_prefrontal_tokens` | `8192` | Pre-call context budget | | `MEMORY_MAX_PREFRONTAL_TOOL_TOKENS` | `memory.prefrontal.max_prefrontal_tool_tokens` | `2048` | Tool result budget | | `MEMORY_MAX_PREFRONTAL_TOOL_CALLS` | `memory.prefrontal.max_prefrontal_tool_calls` | `0` | Tool calls per agent call (0 = tool off) | +| `MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS` | `memory.prefrontal.min_mental_model_refresh_seconds` | `0` | Minimum seconds between automatic briefing refreshes (0 = refresh after every consolidation; N = at most one automatic refresh per N seconds, nothing lost thanks to delta) | ### Reflection Module LLM Overrides diff --git a/src/codespy/agents/cost_tracker.py b/src/codespy/agents/cost_tracker.py index 171d436..4d82883 100644 --- a/src/codespy/agents/cost_tracker.py +++ b/src/codespy/agents/cost_tracker.py @@ -8,13 +8,15 @@ import sys import threading import time -from contextlib import AbstractContextManager from dataclasses import dataclass from types import TracebackType -from typing import Any +from typing import TYPE_CHECKING, Any import dspy # type: ignore[import-untyped] +if TYPE_CHECKING: + from codespy.agents.dspy_config import LMScope + logger = logging.getLogger(__name__) @@ -33,6 +35,8 @@ class SignatureStats: input_cost: float = 0.0 output_cost: float = 0.0 external_duration_seconds: float = 0.0 + cache_read_tokens: int = 0 + cache_write_tokens: int = 0 @property def duration_seconds(self) -> float: @@ -62,6 +66,8 @@ def to_dict(self) -> dict: "input_cost": self.input_cost, "output_cost": self.output_cost, "external_duration_seconds": self.external_duration_seconds, + "cache_read_tokens": self.cache_read_tokens, + "cache_write_tokens": self.cache_write_tokens, } @@ -104,6 +110,8 @@ def end_signature( output_tokens: int = 0, input_cost: float = 0.0, output_cost: float = 0.0, + cache_read_tokens: int = 0, + cache_write_tokens: int = 0, ) -> None: """Mark the end of a signature's execution with its costs. @@ -116,6 +124,8 @@ def end_signature( output_tokens: Output/completion tokens used input_cost: Cost for input tokens output_cost: Cost for output tokens + cache_read_tokens: Cache read tokens (from provider-side caching) + cache_write_tokens: Cache write tokens (from provider-side caching) """ with self._lock: if signature_name not in self._signature_stats: @@ -129,6 +139,8 @@ def end_signature( stats.output_tokens += output_tokens stats.input_cost += input_cost stats.output_cost += output_cost + stats.cache_read_tokens += cache_read_tokens + stats.cache_write_tokens += cache_write_tokens def add_external_call( self, @@ -223,19 +235,25 @@ def get_all_signature_stats(self) -> dict[str, SignatureStats]: input_cost=v.input_cost, output_cost=v.output_cost, external_duration_seconds=v.external_duration_seconds, + cache_read_tokens=v.cache_read_tokens, + cache_write_tokens=v.cache_write_tokens, ) for k, v in self._signature_stats.items() } -def _get_history_entries() -> list[dict]: +def _get_history_entries(lm: Any | None = None) -> list[dict]: """Get current LM history entries from DSPy. + Args: + lm: Optional LM to read history from. If None, uses dspy.settings.lm. + Returns: List of history entries, or empty list if LM not configured """ try: - lm = dspy.settings.lm + if lm is None: + lm = dspy.settings.lm if lm is not None and hasattr(lm, "history"): return lm.history except Exception: @@ -243,16 +261,55 @@ def _get_history_entries() -> list[dict]: return [] -def _get_history_uuids() -> set[str]: +def _get_history_uuids(lm: Any | None = None) -> set[str]: """Get UUIDs of current history entries. + Args: + lm: Optional LM to read history from. If None, uses dspy.settings.lm. + Returns: Set of UUIDs from current history """ - entries = _get_history_entries() + entries = _get_history_entries(lm) return {entry.get("uuid", "") for entry in entries if entry.get("uuid")} +def _get_cache_tokens(entry: dict) -> tuple[int, int]: + """Extract cache read and write tokens from a history entry. + + Handles multiple provider-specific usage shapes: + - Anthropic/Bedrock: cache_read_input_tokens, cache_creation_input_tokens + - OpenAI: prompt_tokens_details.cached_tokens (object or dict) + + Args: + entry: History entry dict with usage information. + + Returns: + Tuple of (cache_read_tokens, cache_write_tokens). + """ + usage = entry.get("usage") + if not isinstance(usage, dict): + return 0, 0 + + # Anthropic/Bedrock style + cache_read = int(_as_number(usage.get("cache_read_input_tokens"))) + cache_write = int(_as_number(usage.get("cache_creation_input_tokens"))) + if cache_read or cache_write: + return cache_read, cache_write + + # OpenAI style: prompt_tokens_details.cached_tokens + details = usage.get("prompt_tokens_details") + if isinstance(details, dict): + cached = int(_as_number(details.get("cached_tokens"))) + return cached, 0 + # Handle object-style access (some providers return objects) + if hasattr(details, "cached_tokens"): + cached = int(_as_number(getattr(details, "cached_tokens", 0))) + return cached, 0 + + return 0, 0 + + def _as_number(value: object) -> float: """Coerce a history field to a number, yielding 0.0 for anything unusable. @@ -278,7 +335,7 @@ def _as_number(value: object) -> float: def _calculate_costs_from_entries( entries: list[dict], exclude_uuids: set[str] -) -> tuple[float, int, int, int, int, float, float]: +) -> tuple[float, int, int, int, int, float, float, int, int]: """Calculate costs from history entries, excluding specific UUIDs. Every field is read defensively: cost accounting is observability, so a @@ -291,7 +348,7 @@ def _calculate_costs_from_entries( Returns: Tuple of (total_cost, total_tokens, call_count, input_tokens, output_tokens, - input_cost, output_cost) + input_cost, output_cost, cache_read_tokens, cache_write_tokens) """ total_cost = 0.0 total_tokens = 0 @@ -300,6 +357,8 @@ def _calculate_costs_from_entries( output_tokens = 0 input_cost = 0.0 output_cost = 0.0 + cache_read_tokens = 0 + cache_write_tokens = 0 for entry in entries: if not isinstance(entry, dict): @@ -307,6 +366,11 @@ def _calculate_costs_from_entries( entry_uuid = entry.get("uuid", "") if entry_uuid and entry_uuid not in exclude_uuids: + # Skip DSPy cache hits (not billed) + response = entry.get("response") + if response and response.get("cache_hit"): + continue + entry_cost = _as_number(entry.get("cost")) total_cost += entry_cost @@ -321,6 +385,11 @@ def _calculate_costs_from_entries( output_tokens += entry_output_tokens total_tokens += entry_input_tokens + entry_output_tokens + # Get cache tokens + entry_cache_read, entry_cache_write = _get_cache_tokens(entry) + cache_read_tokens += entry_cache_read + cache_write_tokens += entry_cache_write + # Calculate split costs using litellm if model is available entry_input_cost, entry_output_cost = _calculate_split_cost( entry, entry_input_tokens, entry_output_tokens, entry_cost @@ -330,7 +399,17 @@ def _calculate_costs_from_entries( call_count += 1 - return total_cost, total_tokens, call_count, input_tokens, output_tokens, input_cost, output_cost + return ( + total_cost, + total_tokens, + call_count, + input_tokens, + output_tokens, + input_cost, + output_cost, + cache_read_tokens, + cache_write_tokens, + ) def _calculate_split_cost( @@ -338,8 +417,10 @@ def _calculate_split_cost( ) -> tuple[float, float]: """Calculate split input/output cost for a history entry. - Uses litellm.cost_per_token if model is available, otherwise falls back - to proportional split of the fallback_cost. + Uses litellm.cost_per_token if model is available. When entry["cost"] is + a number, we compute output_cost from list price and input_cost = cost - + output_cost (so row sums to Total). When cost is None, we use the list + split for both and the row cost equals the list split. Args: entry: History entry dict @@ -363,13 +444,35 @@ def _calculate_split_cost( prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, ) - return float(prompt_cost), float(completion_cost) + list_input_cost = float(prompt_cost) + list_output_cost = float(completion_cost) + list_total = list_input_cost + list_output_cost + + # Check if we have a billed cost (could be 0.0 which is falsy, so check for None explicitly) + billed_cost = entry.get("cost") + if billed_cost is None: + # No billed cost: use list split (row will sum to list_total) + return list_input_cost, list_output_cost + + # Have billed cost: output_cost is list price, input_cost = billed - output + billed = float(billed_cost) + # If list_total is 0 (rare), use proportional split + if list_total == 0: + return _proportional_split(prompt_tokens, completion_tokens, billed) + + # Cap output_cost at billed (don't let input_cost go negative) + output_cost = min(list_output_cost, billed) + input_cost = billed - output_cost + return input_cost, output_cost + except Exception: # Model may not have pricing or be available; use proportional split return _proportional_split(prompt_tokens, completion_tokens, fallback_cost) -def _proportional_split(input_tokens: int, output_tokens: int, total_cost: float) -> tuple[float, float]: +def _proportional_split( + input_tokens: int, output_tokens: int, total_cost: float +) -> tuple[float, float]: """Split cost proportionally by token count. Args: @@ -415,9 +518,10 @@ def __init__(self, signature_name: str, tracker: "CostTracker") -> None: self.signature_name = signature_name self.tracker = tracker self._before_uuids: set[str] = set() + self._before_extraction_uuids: set[str] = set() # Annotated so the None default doesn't narrow the attribute to # ``None``, which would hide the enter/exit calls from type checking. - self._lm_context: AbstractContextManager[Any] | None = None + self._lm_scope: LMScope | None = None def __enter__(self) -> "SignatureContext": """Enter the context, applying the LM and capturing history state. @@ -433,14 +537,16 @@ def __enter__(self) -> "SignatureContext": # Apply this name's LM first, so the history we snapshot below belongs # to the LM that will actually serve the enclosed calls. - self._lm_context = lm_context(self.signature_name) - self._lm_context.__enter__() + self._lm_scope = lm_context(self.signature_name) + self._lm_scope.__enter__() try: - self._before_uuids = _get_history_uuids() + # Snapshot UUIDs from both main LM and extraction LM + self._before_uuids = _get_history_uuids(self._lm_scope.lm) + self._before_extraction_uuids = _get_history_uuids(self._lm_scope.extraction_lm) self.tracker.start_signature(self.signature_name) except BaseException: - self._lm_context.__exit__(*sys.exc_info()) - self._lm_context = None + self._lm_scope.__exit__(*sys.exc_info()) + self._lm_scope = None raise return self @@ -463,12 +569,29 @@ def __exit__( instead of quiet and global. """ try: - # Read history before leaving the LM context, so dspy.settings.lm - # still points at the LM whose history we need. - entries = _get_history_entries() - cost, tokens, call_count, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries( - entries, self._before_uuids + # Read history from both LMs before leaving the context + main_entries = _get_history_entries(self._lm_scope.lm if self._lm_scope else None) + extraction_entries = _get_history_entries( + self._lm_scope.extraction_lm if self._lm_scope else None + ) + + # Calculate costs from both histories + main_result = _calculate_costs_from_entries(main_entries, self._before_uuids) + extraction_result = _calculate_costs_from_entries( + extraction_entries, self._before_extraction_uuids ) + + # Combine results: sum all numeric fields + cost = main_result[0] + extraction_result[0] + tokens = main_result[1] + extraction_result[1] + call_count = main_result[2] + extraction_result[2] + input_tokens = main_result[3] + extraction_result[3] + output_tokens = main_result[4] + extraction_result[4] + input_cost = main_result[5] + extraction_result[5] + output_cost = main_result[6] + extraction_result[6] + cache_read_tokens = main_result[7] + extraction_result[7] + cache_write_tokens = main_result[8] + extraction_result[8] + self.tracker.end_signature( self.signature_name, cost=cost, @@ -478,13 +601,15 @@ def __exit__( output_tokens=output_tokens, input_cost=input_cost, output_cost=output_cost, + cache_read_tokens=cache_read_tokens, + cache_write_tokens=cache_write_tokens, ) except Exception as e: logger.warning("Cost calculation failed for %s: %s", self.signature_name, e) finally: - if self._lm_context is not None: - self._lm_context.__exit__(exc_type, exc_val, exc_tb) - self._lm_context = None + if self._lm_scope is not None: + self._lm_scope.__exit__(exc_type, exc_val, exc_tb) + self._lm_scope = None async def __aenter__(self) -> "SignatureContext": """Async enter the context.""" diff --git a/src/codespy/agents/dspy_config.py b/src/codespy/agents/dspy_config.py index a9991fe..6b62492 100644 --- a/src/codespy/agents/dspy_config.py +++ b/src/codespy/agents/dspy_config.py @@ -1,6 +1,9 @@ """DSPy and LiteLLM configuration utilities.""" import logging +from dataclasses import dataclass +from types import TracebackType +from typing import Any import dspy # type: ignore[import-untyped] import litellm # type: ignore[import-untyped] @@ -13,6 +16,85 @@ logger = logging.getLogger(__name__) +@dataclass +class LMScope: + """Container for LM context with both main and extraction LMs. + + Returned by lm_context() to expose both the main LM (used by DSPy predictors) + and the extraction LM (used by TwoStepAdapter) for cost attribution. + The ctx is the actual dspy.context that must be exited to release resources. + + Attributes: + ctx: The dspy.context manager (must call __exit__ to release). + lm: The main LM used for reasoning/generation. + extraction_lm: The extraction LM used for structured field extraction. + """ + + ctx: Any + lm: Any + extraction_lm: Any + + def __enter__(self) -> "LMScope": + """Enter the context.""" + self.ctx.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit the context, releasing the LM.""" + self.ctx.__exit__(exc_type, exc_val, exc_tb) + + async def __aenter__(self) -> "LMScope": + """Async enter the context.""" + return self.__enter__() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Async exit the context.""" + self.__exit__(exc_type, exc_val, exc_tb) + + +def new_extraction_lm(settings: Settings) -> dspy.LM: + """Build a fresh extraction LM for per-context TwoStepAdapter use. + + The extraction LM is used by TwoStepAdapter for the second stage: + deterministic field extraction from the main LM's free-form response. + A fresh instance per context ensures correct cost attribution and + thread isolation. + + The extraction model is taken from the "default" LLMSettings via + ``settings.get_llm_config("default")``, using its ``extraction_model`` + (which falls back to ``default_model``). This matches the global fallback + in ``configure_dspy()`` and does NOT use per-signature overrides. + + Args: + settings: Application settings. + + Returns: + A configured dspy.LM with temperature=0.0 and no reasoning. + """ + defaults = settings.get_llm_config("default") + extraction_model = defaults.extraction_model + return new_lm( + settings, + defaults.model_copy( + update={ + "model": extraction_model, + "reasoning_effort": None, + "temperature": 0.0, + } + ), + ) + + def _resolve_max_tokens(model: str, max_tokens: int) -> int: """Clamp a configured output budget to the model's real output ceiling. @@ -113,7 +195,7 @@ def new_lm(settings: Settings, config: LLMSettings) -> dspy.LM: return dspy.LM(**lm_kwargs) -def lm_context(name: str): +def lm_context(name: str) -> LMScope: """Return a ``dspy.context`` applying the LM configured for ``name``. ``name`` is a signature or reflection module name — see @@ -121,16 +203,21 @@ def lm_context(name: str): named unit of work runs on its own configured model, falling back to the top-level defaults when it declares no overrides. + Also creates a per-context extraction LM so that TwoStepAdapter's extraction + calls can be attributed correctly to the calling signature. + Args: name: The signature or reflection module name. Returns: - A context manager that scopes the LM to the enclosed block. + An LMScope wrapping the context manager, main LM, and extraction LM. """ settings = get_settings() llm_config = settings.get_llm_config(name) lm = new_lm(settings, llm_config) - return dspy.context(lm=lm) + extraction_lm = new_extraction_lm(settings) + ctx = dspy.context(lm=lm, adapter=TwoStepAdapter(extraction_lm)) + return LMScope(ctx=ctx, lm=lm, extraction_lm=extraction_lm) def configure_dspy(settings: Settings) -> None: @@ -179,17 +266,7 @@ def configure_dspy(settings: Settings) -> None: # Extraction LM for TwoStepAdapter's second stage: deterministic field extraction # from the main LM's free-form response. Never uses reasoning; temperature=0.0 for # deterministic output. - extraction_model = defaults.extraction_model - extraction_lm = new_lm( - settings, - defaults.model_copy( - update={ - "model": extraction_model, - "reasoning_effort": None, - "temperature": 0.0, - } - ), - ) + extraction_lm = new_extraction_lm(settings) dspy.settings.configure( lm=lm, @@ -205,6 +282,7 @@ def configure_dspy(settings: Settings) -> None: prompt_cache_status = "enabled (cache_control markers)" else: prompt_cache_status = "enabled (provider-automatic, no markers)" + extraction_model = defaults.extraction_model logger.info( f"Configured DSPy with model: {model} " f"(TwoStepAdapter with extraction_model={extraction_model}, " diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index 1f1b088..37ac10c 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -59,6 +59,7 @@ from hindsight_api.extensions import OperationValidationError from hindsight_api.extensions.builtin.tenant import DefaultTenantExtension from hindsight_api.models import RequestContext +from hindsight_api.worker.exceptions import DeferOperation class _SchemaSyncTaskBackend(SyncTaskBackend): @@ -109,6 +110,18 @@ async def submit_task(self, task_dict): try: await super().submit_task(task) + except DeferOperation as exc: + # Handle deferral: mark refresh_mental_model as cancelled (not failed) + if task_type == "refresh_mental_model": + await self._mark_cancelled_if_pending(task, exc) + logger.info( + "cerebral: briefing %s refresh deferred until %s", + task.get("mental_model_id", "unknown"), + exc.exec_date.isoformat() if hasattr(exc, "exec_date") else "unknown", + ) + return # Don't re-raise - deferral is expected, not an error + # Other task types: re-raise as normal (fallback to existing behavior) + raise except Exception as exc: # Mark the operation as failed if it has an operation_id await self._mark_failed_if_pending(task, exc) @@ -159,6 +172,44 @@ async def _mark_failed_if_pending(self, task: dict, exc: Exception) -> None: exc_info=True, ) + async def _mark_cancelled_if_pending(self, task: dict, exc: DeferOperation) -> None: + """Mark the operation as cancelled if it's still pending (deferral). + + Deferral from min_refresh_interval_seconds is not a failure - the + operation is effectively skipped and will be retried on the next + consolidation after the interval expires. + """ + op_id = task.get("operation_id") + if not op_id or self._engine is None: + return + + from hindsight_api.engine.db_utils import acquire_with_retry + + try: + exec_date = exc.exec_date.isoformat() if hasattr(exc, "exec_date") else "unknown" + error_msg = f"deferred by min_refresh_interval_seconds until {exec_date}" + + async with acquire_with_retry(self._engine._backend, max_retries=1) as conn: + await conn.execute( + f""" + UPDATE "{self._schema}".async_operations + SET status = 'cancelled', + error_message = $2, + updated_at = NOW() + WHERE operation_id = $1 + AND status = 'pending' + """, + op_id, + error_msg, + ) + except Exception: + # Log but don't propagate - the original deferral is more important + logger.warning( + "Failed to mark operation %s as cancelled", + op_id, + exc_info=True, + ) + from codespy.agents.memory.cerebral.cost import ( BUCKET_MEMORY_CONSOLIDATION, BUCKET_MEMORY_MENTAL_MODELS, @@ -206,6 +257,21 @@ class ReflectSummary: HINDSIGHT_SCHEMA = HINDSIGHT_SCHEMA_DEFAULT +# Static briefing trigger configuration (hardcoded per plan) +# Delta mode with reduced evidence caps to cut cost while preserving quality. +# - mode=delta: Only new facts since last refresh (structured delta-ops call) +# - exclude_mental_models=True: Briefings exclude other briefings +# - include_chunks=False: Observation text only (no file chunks) +# - reflect_search_observations_max_tokens=3000: Down from 5000 default +# - reflect_search_observations_include_entities=False: Exclude entity metadata +_BRIEFING_TRIGGER_STATIC = { + "mode": "delta", + "exclude_mental_models": True, + "include_chunks": False, + "reflect_search_observations_max_tokens": 3000, + "reflect_search_observations_include_entities": False, +} + # Content Hindsight's HTTP API gives a mental model before its first refresh. # Prefrontal skips briefings that still hold it. MENTAL_MODEL_PLACEHOLDER = "Generating content..." @@ -259,6 +325,7 @@ def __init__( reflect_llm_api_key: str | None = None, reflect_llm_base_url: str | None = None, embeddings_max_input_chars: int | None = None, + min_mental_model_refresh_seconds: int = 0, ): # Fail fast: test the embedding model before building MemoryEngine. # A bad model name, missing creds, or unavailable region surfaces here @@ -358,6 +425,7 @@ def __init__( self._retain_chunk_size = retain_chunk_size self._mental_models = mental_models self._max_mental_model_tokens = max_mental_model_tokens + self._min_mental_model_refresh_seconds = min_mental_model_refresh_seconds # Mental-model ids already ensured by this process. self._ensured_mental_models: set[str] = set() # Lock for consolidation serialization @@ -691,9 +759,9 @@ def _sync_briefing_triggers(self, scopes: list[list[str]]) -> None: """Sync briefing triggers to match current settings before consolidation. Runs before retain+consolidation so that consolidation uses the correct - refresh settings. The desired trigger is: - - ``refresh_after_consolidation``: True when mental_models is enabled - - ``exclude_mental_models``: True (briefings exclude other briefings) + refresh settings. The desired trigger combines: + - Static caps from _BRIEFING_TRIGGER_STATIC (delta mode, chunk/entity caps) + - Dynamic settings: refresh_after_consolidation, min_refresh_interval_seconds For existing briefings, updates the trigger if it differs. For missing briefings, creates them only when mental_models is enabled. @@ -702,8 +770,9 @@ def _sync_briefing_triggers(self, scopes: list[list[str]]) -> None: return desired_trigger = { + **_BRIEFING_TRIGGER_STATIC, "refresh_after_consolidation": self._mental_models, - "exclude_mental_models": True, + "min_refresh_interval_seconds": self._min_mental_model_refresh_seconds, } for tags in scopes: diff --git a/src/codespy/agents/review/doc/doc_extractor.py b/src/codespy/agents/review/doc/doc_extractor.py index 1e63ed9..db07d3f 100644 --- a/src/codespy/agents/review/doc/doc_extractor.py +++ b/src/codespy/agents/review/doc/doc_extractor.py @@ -6,6 +6,9 @@ from codespy.tools.storage import EntryType, FileSystem, TreeNode +# CHANGELOG heading pattern for trimming (3rd `## ` heading marks cutoff) +_CHANGELOG_HEADING_RE = re.compile(r"^## ", re.MULTILINE) + logger = logging.getLogger(__name__) # Filename patterns recognised as documentation (case-insensitive). @@ -50,10 +53,35 @@ def _collect_doc_paths(node: TreeNode, prefix: str = "") -> list[str]: return paths +def _trim_changelog(content: str) -> str: + """Trim CHANGELOG content to keep only preamble + 2 newest sections. + + Keeps text before the 3rd line matching ``^## `` (preamble + 2 newest + sections, e.g. ``[Unreleased]`` + latest release). If fewer than 3 such + headings, keeps the file unchanged. Appends ``\n[older entries omitted]`` + when trimmed. + + Args: + content: The full CHANGELOG content. + + Returns: + Trimmed content if 3+ headings found, else original content. + """ + matches = list(_CHANGELOG_HEADING_RE.finditer(content)) + if len(matches) < 3: + return content + + # Keep everything up to the 3rd heading (exclusive) + cutoff_pos = matches[2].start() + trimmed = content[:cutoff_pos].rstrip() + return trimmed + "\n[older entries omitted]" + + def extract_documentation(scope_root: Path) -> str: """Extract documentation content from a scope directory. Single tree scan at depth 2, fast-fail if no doc files found. + CHANGELOG files are trimmed to preamble + 2 newest sections. Returns: Concatenated documentation with ``=== filename ===`` headers, @@ -89,10 +117,15 @@ def extract_documentation(scope_root: Path) -> str: logger.debug(f"Skipping unreadable doc file {path}: {content.error}") continue - if not content.content.strip(): + text = content.content + if not text.strip(): logger.debug(f"Skipping empty doc file {path}") continue - parts.append(f"=== {path} ===\n{content.content}") + # Trim CHANGELOG files to reduce token volume + if path.lower().startswith("changelog"): + text = _trim_changelog(text) + + parts.append(f"=== {path} ===\n{text}") return "\n\n".join(parts) diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 35b790b..4c08e3a 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -133,6 +133,11 @@ class PrefrontalConfig(BaseModel): max_prefrontal_tool_tokens: int = Field(default=2048, gt=0) # recall_memory tool call limit (0 = tool disabled). max_prefrontal_tool_calls: int = Field(default=0, ge=0) + # Minimum seconds between automatic briefing refreshes (0 = refresh after + # every consolidation; N = at most one automatic refresh per N seconds). + # Delta mode means nothing is lost - the refresh covers all new facts since + # the last successful refresh when the interval expires. + min_mental_model_refresh_seconds: int = Field(default=0, ge=0) class LLMSettings(BaseModel): @@ -505,9 +510,9 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: "reflect_llm_base_url": pf_base_url, } - # Apply reflect LLM defaults BEFORE Cerebral construction (these are read + # Apply Hindsight LLM defaults BEFORE Cerebral construction (these are read # during MemoryEngine.__init__). reflect_config is applied after construction. - _apply_reflect_llm_call_defaults(settings) + _apply_hindsight_llm_call_defaults(settings) try: _cerebral = Cerebral( @@ -522,6 +527,7 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: mental_models=settings.memory.prefrontal.reflects > 0, max_mental_model_tokens=settings.memory.prefrontal.max_mental_model_tokens, embeddings_max_input_chars=settings.memory.cerebral.embeddings.max_input_chars, + min_mental_model_refresh_seconds=settings.memory.prefrontal.min_mental_model_refresh_seconds, **reflect_kwargs, ) except Exception: @@ -555,12 +561,19 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: _LOW_BUDGET_MULTIPLIER_INVERSE = 2 -def _apply_reflect_llm_call_defaults(settings: "Settings") -> None: - """Apply reflect LLM timeout and retry settings from codespy config. +def _apply_hindsight_llm_call_defaults(settings: "Settings") -> None: + """Apply Hindsight LLM timeout and retry settings from codespy config. - Reflect LLM timeout and retries reuse codespy's llm.timeout / llm.retries. - Operator env (HINDSIGHT_API_REFLECT_LLM_TIMEOUT, HINDSIGHT_API_LLM_TIMEOUT, - HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES, HINDSIGHT_API_LLM_MAX_RETRIES) wins. + Sets global ``llm_timeout`` and ``llm_max_retries`` so all Hindsight operations + (retain, consolidation, reflect, mental-model refresh) inherit codespy's + ``llm.timeout`` / ``llm.retries``. Per-operation env vars still win automatically + because Hindsight prefers non-None per-op values. + + Also sets ``reflect_llm_timeout`` / ``reflect_llm_max_retries`` to override + Hindsight's 30s reflect default when no operator env is set. + + Operator env (HINDSIGHT_API_LLM_TIMEOUT, HINDSIGHT_API_LLM_MAX_RETRIES, + HINDSIGHT_API_REFLECT_LLM_TIMEOUT, HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES) wins. Called in get_cerebral() after Cerebral import but before Cerebral() construction. This must run before Cerebral is built because the defaults are resolved in @@ -577,39 +590,79 @@ def _apply_reflect_llm_call_defaults(settings: "Settings") -> None: logger.warning("Could not access Hindsight raw config for LLM defaults: %s", e) return - # Determine effective timeout: env wins, then settings.llm.timeout - timeout: float | None = None - env_timeout = os.environ.get("HINDSIGHT_API_REFLECT_LLM_TIMEOUT") or os.environ.get("HINDSIGHT_API_LLM_TIMEOUT") - if env_timeout is not None: + # --- Global settings (retain, consolidation, reflect, mental-model refresh) --- + + # Determine global timeout: env wins, then settings.llm.timeout + global_timeout: float | None = None + global_env_timeout = os.environ.get("HINDSIGHT_API_LLM_TIMEOUT") + if global_env_timeout is not None: + try: + global_timeout = float(global_env_timeout) + logger.info("Using env HINDSIGHT_API_LLM_TIMEOUT=%s (operator override)", global_timeout) + except ValueError: + pass + if global_timeout is None and hasattr(raw, "llm_timeout"): + global_timeout = float(settings.llm.timeout) + raw.llm_timeout = global_timeout + + # Determine global retries: env wins, then settings.llm.retries + global_retries: int | None = None + global_env_retries = os.environ.get("HINDSIGHT_API_LLM_MAX_RETRIES") + if global_env_retries is not None: + try: + global_retries = int(global_env_retries) + logger.info("Using env HINDSIGHT_API_LLM_MAX_RETRIES=%s (operator override)", global_retries) + except ValueError: + pass + if global_retries is None and hasattr(raw, "llm_max_retries"): + global_retries = int(settings.llm.retries) + raw.llm_max_retries = global_retries + + # --- Reflect-specific settings (override Hindsight's 30s default) --- + + # Determine reflect timeout: env wins, then settings.llm.timeout + reflect_timeout: float | None = None + reflect_env_timeout = os.environ.get("HINDSIGHT_API_REFLECT_LLM_TIMEOUT") or os.environ.get("HINDSIGHT_API_LLM_TIMEOUT") + if reflect_env_timeout is not None: try: - timeout = float(env_timeout) - logger.info("Using env HINDSIGHT_API_REFLECT_LLM_TIMEOUT=%s (operator override)", timeout) + reflect_timeout = float(reflect_env_timeout) + # Log the actual env var that was used + used_env = "HINDSIGHT_API_REFLECT_LLM_TIMEOUT" if os.environ.get("HINDSIGHT_API_REFLECT_LLM_TIMEOUT") else "HINDSIGHT_API_LLM_TIMEOUT" + logger.info("Using env %s=%s (operator override)", used_env, reflect_timeout) except ValueError: pass - if timeout is None and hasattr(raw, "reflect_llm_timeout"): - timeout = float(settings.llm.timeout) - if hasattr(raw, "reflect_llm_timeout"): - raw.reflect_llm_timeout = timeout - - # Determine effective retries: env wins, then settings.llm.retries - retries: int | None = None - env_retries = os.environ.get("HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES") or os.environ.get("HINDSIGHT_API_LLM_MAX_RETRIES") - if env_retries is not None: + if reflect_timeout is None and hasattr(raw, "reflect_llm_timeout"): + reflect_timeout = float(settings.llm.timeout) + raw.reflect_llm_timeout = reflect_timeout + + # Determine reflect retries: env wins, then settings.llm.retries + reflect_retries: int | None = None + reflect_env_retries = os.environ.get("HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES") or os.environ.get("HINDSIGHT_API_LLM_MAX_RETRIES") + if reflect_env_retries is not None: try: - retries = int(env_retries) - logger.info("Using env HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES=%s (operator override)", retries) + reflect_retries = int(reflect_env_retries) + # Log the actual env var that was used + used_env = "HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES" if os.environ.get("HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES") else "HINDSIGHT_API_LLM_MAX_RETRIES" + logger.info("Using env %s=%s (operator override)", used_env, reflect_retries) except ValueError: pass - if retries is None and hasattr(raw, "reflect_llm_max_retries"): - retries = int(settings.llm.retries) - if hasattr(raw, "reflect_llm_max_retries"): - raw.reflect_llm_max_retries = retries + if reflect_retries is None and hasattr(raw, "reflect_llm_max_retries"): + reflect_retries = int(settings.llm.retries) + raw.reflect_llm_max_retries = reflect_retries + + # Summary log + global_timeout_str = str(global_timeout) if global_timeout is not None else "-" + global_retries_str = str(global_retries) if global_retries is not None else "-" + reflect_timeout_str = str(reflect_timeout) if reflect_timeout is not None else "-" + reflect_retries_str = str(reflect_retries) if reflect_retries is not None else "-" - if timeout is not None or retries is not None: + if global_timeout is not None or global_retries is not None: logger.info( - "Hindsight reflect LLM defaults: timeout=%s, max_retries=%s", - timeout if timeout is not None else "-", - retries if retries is not None else "-", + "Hindsight LLM defaults: timeout=%s, max_retries=%s (reflect timeout=%s, max_retries=%s)", + global_timeout_str, + global_retries_str, + reflect_timeout_str, + reflect_retries_str, ) diff --git a/src/codespy/workflows/review/models.py b/src/codespy/workflows/review/models.py index eddd633..ec6f7a7 100644 --- a/src/codespy/workflows/review/models.py +++ b/src/codespy/workflows/review/models.py @@ -24,6 +24,8 @@ class SignatureStatsResult(BaseModel): output_tokens: int = Field(default=0, description="Output/completion tokens used") input_cost: float = Field(default=0.0, description="Cost for input tokens") output_cost: float = Field(default=0.0, description="Cost for output tokens") + cache_read_tokens: int = Field(default=0, description="Cache read tokens from provider-side caching") + cache_write_tokens: int = Field(default=0, description="Cache write tokens from provider-side caching") @property def cost_per_call(self) -> float: @@ -184,14 +186,15 @@ def to_markdown(self) -> str: [ "### Per-Signature Breakdown", "", - "| Signature | In Tokens | Out Tokens | In Cost | Out Cost | Calls | Duration |", - "|-----------|-----------|------------|---------|----------|-------|----------|", + "| Signature | In Tokens | Out Tokens | Cache Read | Cache Write | In Cost | Out Cost | Calls | Duration |", + "|-----------|-----------|------------|------------|-------------|---------|----------|-------|----------|", ] ) for stats in sorted(self.signature_stats, key=lambda x: x.cost, reverse=True): duration_str = f"{stats.duration_seconds:.1f}s" lines.append( f"| {stats.name} | {stats.input_tokens:,} | {stats.output_tokens:,} | " + f"{stats.cache_read_tokens:,} | {stats.cache_write_tokens:,} | " f"${stats.input_cost:.4f} | ${stats.output_cost:.4f} | {stats.call_count} | {duration_str} |" ) lines.append("") diff --git a/src/codespy/workflows/review/pipeline.py b/src/codespy/workflows/review/pipeline.py index 3f1c884..ea6e545 100644 --- a/src/codespy/workflows/review/pipeline.py +++ b/src/codespy/workflows/review/pipeline.py @@ -361,6 +361,8 @@ def _collect_signature_stats(self) -> list[SignatureStatsResult]: output_tokens=stats.output_tokens, input_cost=stats.input_cost, output_cost=stats.output_cost, + cache_read_tokens=stats.cache_read_tokens, + cache_write_tokens=stats.cache_write_tokens, ) ) diff --git a/src/codespy/workflows/review/reporters/git.py b/src/codespy/workflows/review/reporters/git.py index 2668eb4..4df9da0 100644 --- a/src/codespy/workflows/review/reporters/git.py +++ b/src/codespy/workflows/review/reporters/git.py @@ -167,14 +167,15 @@ def _build_review_body( if result.signature_stats: lines.extend( [ - "| Signature | In Tokens | Out Tokens | In Cost | Out Cost | Calls | Duration |", - "|-----------|-----------|------------|---------|----------|-------|----------|", + "| Signature | In Tokens | Out Tokens | Cache Read | Cache Write | In Cost | Out Cost | Calls | Duration |", + "|-----------|-----------|------------|------------|-------------|---------|----------|-------|----------|", ] ) for stats in sorted(result.signature_stats, key=lambda x: x.cost, reverse=True): duration_str = f"{stats.duration_seconds:.1f}s" lines.append( f"| {stats.name} | {stats.input_tokens:,} | {stats.output_tokens:,} | " + f"{stats.cache_read_tokens:,} | {stats.cache_write_tokens:,} | " f"${stats.input_cost:.4f} | ${stats.output_cost:.4f} | {stats.call_count} | {duration_str} |" ) lines.append("") diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index c8f9f24..bd9693c 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -1,20 +1,19 @@ """Tests for memory storage configuration and access verification.""" +import os from unittest.mock import MagicMock, patch import pytest +from pydantic import ValidationError from codespy.config_memory import ( EMBEDDING_MODELS, + CerebralRetainConfig, PostgresConfig, - Pg0Config, + _apply_hindsight_llm_call_defaults, _cerebral_litellm_params, - CerebralRetainConfig, get_episode_store, - reset_episode_store, - verify_memory_access, ) -from pydantic import ValidationError class TestGenerateBankId: @@ -354,66 +353,142 @@ def test_apply_reflect_config_env_wins_over_config(self, monkeypatch): assert result["iterations"] == 10 # env wins (used as-is, not doubled) assert result["budget"] == "low" - def test_apply_reflect_llm_call_defaults(self, monkeypatch): - """Test _apply_reflect_llm_call_defaults sets timeout/retries from settings.""" + def test_apply_hindsight_llm_call_defaults(self, monkeypatch): + """Test _apply_hindsight_llm_call_defaults sets global/reflect timeout/retries.""" pytest = __import__("pytest") pytest.importorskip("hindsight_api") - from codespy.config_memory import ( - PrefrontalConfig, - _apply_reflect_llm_call_defaults, - _apply_reflect_config, - ) + import hindsight_api.config as ha_cfg - # Clear env vars - monkeypatch.delenv("HINDSIGHT_API_REFLECT_LLM_TIMEOUT", raising=False) - monkeypatch.delenv("HINDSIGHT_API_LLM_TIMEOUT", raising=False) - monkeypatch.delenv("HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES", raising=False) - monkeypatch.delenv("HINDSIGHT_API_LLM_MAX_RETRIES", raising=False) + # Clear all relevant env vars + for env in [ + "HINDSIGHT_API_LLM_TIMEOUT", + "HINDSIGHT_API_LLM_MAX_RETRIES", + "HINDSIGHT_API_REFLECT_LLM_TIMEOUT", + "HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES", + ]: + monkeypatch.delenv(env, raising=False) - settings = MagicMock() - settings.llm.timeout = 240.0 - settings.llm.retries = 3 + raw = ha_cfg._get_raw_config() - _apply_reflect_llm_call_defaults(settings) + # Save original values to restore after test + orig_llm_timeout = raw.llm_timeout + orig_llm_max_retries = raw.llm_max_retries + orig_reflect_llm_timeout = raw.reflect_llm_timeout + orig_reflect_llm_max_retries = raw.reflect_llm_max_retries - # Verify raw config was updated (if hindsight_api is available) try: - import hindsight_api.config as ha_cfg + raw.llm_timeout = 120.0 + raw.llm_max_retries = 3 + raw.reflect_llm_timeout = 30.0 + raw.reflect_llm_max_retries = 3 + + settings = MagicMock() + settings.llm.timeout = 240.0 + settings.llm.retries = 2 + + _apply_hindsight_llm_call_defaults(settings) + + # Verify global and reflect settings were updated + assert raw.llm_timeout == 240.0 + assert raw.llm_max_retries == 2 + assert raw.reflect_llm_timeout == 240.0 + assert raw.reflect_llm_max_retries == 2 + finally: + # Restore original values + raw.llm_timeout = orig_llm_timeout + raw.llm_max_retries = orig_llm_max_retries + raw.reflect_llm_timeout = orig_reflect_llm_timeout + raw.reflect_llm_max_retries = orig_reflect_llm_max_retries + + def test_apply_hindsight_llm_call_defaults_with_env_vars(self, monkeypatch): + """Test that when env vars are set, function uses those values and does not overwrite. + + Note: Hindsight loads env vars at import time and caches the config. + This test verifies that when env vars are present, the function + correctly identifies them and does not overwrite the values. + """ + pytest = __import__("pytest") + pytest.importorskip("hindsight_api") + import hindsight_api.config as ha_cfg + + raw = ha_cfg._get_raw_config() - raw = ha_cfg._get_raw_config() - if hasattr(raw, "reflect_llm_timeout"): + # Save original values + orig_llm_timeout = raw.llm_timeout + orig_llm_max_retries = raw.llm_max_retries + orig_reflect_llm_timeout = raw.reflect_llm_timeout + orig_reflect_llm_max_retries = raw.reflect_llm_max_retries + + try: + # Set up values that would be overwritten if env is not checked + raw.llm_timeout = 120.0 + raw.llm_max_retries = 3 + raw.reflect_llm_timeout = 30.0 + raw.reflect_llm_max_retries = 3 + + settings = MagicMock() + settings.llm.timeout = 240.0 + settings.llm.retries = 3 + + _apply_hindsight_llm_call_defaults(settings) + + # Verify the function logic based on env state + if os.environ.get("HINDSIGHT_API_REFLECT_LLM_TIMEOUT"): + # If env is set, function should not overwrite + assert raw.reflect_llm_timeout == 30.0 # unchanged + else: + # If env is not set, function should set from settings assert raw.reflect_llm_timeout == 240.0 - if hasattr(raw, "reflect_llm_max_retries"): - assert raw.reflect_llm_max_retries == 3 - except Exception: - pass # If hindsight_api not available, test passes by skipping - def test_apply_reflect_llm_call_defaults_env_wins(self, monkeypatch): - """Test that env vars override settings for reflect LLM defaults.""" + if os.environ.get("HINDSIGHT_API_LLM_TIMEOUT"): + # If global env is set, function should not overwrite + assert raw.llm_timeout == 120.0 # unchanged + else: + # If env is not set, function should set from settings + assert raw.llm_timeout == 240.0 + finally: + # Restore original values + raw.llm_timeout = orig_llm_timeout + raw.llm_max_retries = orig_llm_max_retries + raw.reflect_llm_timeout = orig_reflect_llm_timeout + raw.reflect_llm_max_retries = orig_reflect_llm_max_retries + + def test_consolidation_inherits_global_llm_timeout(self, monkeypatch): + """Test that consolidation inherits global llm_timeout when per-op value is None.""" pytest = __import__("pytest") pytest.importorskip("hindsight_api") + import hindsight_api.config as ha_cfg - monkeypatch.setenv("HINDSIGHT_API_REFLECT_LLM_TIMEOUT", "60") - monkeypatch.setenv("HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES", "5") + # Clear all env vars + for env in [ + "HINDSIGHT_API_LLM_TIMEOUT", + "HINDSIGHT_API_LLM_MAX_RETRIES", + "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT", + "HINDSIGHT_API_REFLECT_LLM_TIMEOUT", + ]: + monkeypatch.delenv(env, raising=False) - from codespy.config_memory import _apply_reflect_llm_call_defaults - - settings = MagicMock() - settings.llm.timeout = 240.0 - settings.llm.retries = 3 + raw = ha_cfg._get_raw_config() - _apply_reflect_llm_call_defaults(settings) + # Save original values + orig_llm_timeout = raw.llm_timeout + orig_consolidation_llm_timeout = raw.consolidation_llm_timeout try: - import hindsight_api.config as ha_cfg - - raw = ha_cfg._get_raw_config() - if hasattr(raw, "reflect_llm_timeout"): - assert raw.reflect_llm_timeout == 60.0 - if hasattr(raw, "reflect_llm_max_retries"): - assert raw.reflect_llm_max_retries == 5 - except Exception: - pass + # Simulate Hindsight's behavior: per-op is None, falls back to global + # Set consolidation_llm_timeout to None (as Hindsight would when env is unset) + raw.consolidation_llm_timeout = None + raw.llm_timeout = 240.0 + + # Hindsight's _op_defaults returns: per-op if non-None, else global + # This is what MemoryEngine does for consolidation + per_op = raw.consolidation_llm_timeout + effective = per_op if per_op is not None else raw.llm_timeout + assert effective == 240.0 + finally: + # Restore original values + raw.llm_timeout = orig_llm_timeout + raw.consolidation_llm_timeout = orig_consolidation_llm_timeout def _settings(self, reflects): from codespy.config_memory import MemoryConfig diff --git a/tests/test_cost_tracker.py b/tests/test_cost_tracker.py index 3b94479..1194181 100644 --- a/tests/test_cost_tracker.py +++ b/tests/test_cost_tracker.py @@ -92,40 +92,45 @@ class TestGetHistoryEntries: """Tests for _get_history_entries helper.""" def test_returns_empty_list_when_no_lm(self): - with patch("dspy.settings.lm", None): - result = _get_history_entries() - assert result == [] + # When LM is None, _get_history_entries should return empty list + result = _get_history_entries(None) + assert result == [] def test_returns_empty_list_when_no_history_attr(self): mock_lm = MagicMock() del mock_lm.history - with patch("dspy.settings.lm", mock_lm): - result = _get_history_entries() - assert result == [] + result = _get_history_entries(mock_lm) + assert result == [] - def test_returns_history_when_available(self): + def test_returns_history_for_provided_lm(self): + """Test _get_history_entries with explicitly provided LM.""" mock_history = [{"uuid": "test-uuid", "cost": 0.5}] mock_lm = MagicMock() mock_lm.history = mock_history - with patch("dspy.settings.lm", mock_lm): - result = _get_history_entries() - assert result == mock_history + result = _get_history_entries(mock_lm) + assert result == mock_history + + def test_returns_empty_list_for_provided_lm_no_history(self): + """Test _get_history_entries with LM that has no history attribute.""" + mock_lm = MagicMock() + del mock_lm.history + result = _get_history_entries(mock_lm) + assert result == [] class TestGetHistoryUuids: """Tests for _get_history_uuids helper.""" - def test_returns_set_of_uuids(self): + def test_returns_set_of_uuids_for_provided_lm(self): + """Test _get_history_uuids with explicitly provided LM.""" mock_history = [ {"uuid": "uuid-1", "cost": 0.5}, {"uuid": "uuid-2", "cost": 0.3}, - {"cost": 0.1}, # No uuid ] mock_lm = MagicMock() mock_lm.history = mock_history - with patch("dspy.settings.lm", mock_lm): - result = _get_history_uuids() - assert result == {"uuid-1", "uuid-2"} + result = _get_history_uuids(mock_lm) + assert result == {"uuid-1", "uuid-2"} class TestCalculateCostsFromEntries: @@ -146,7 +151,7 @@ def test_calculates_costs_and_tokens(self): ] exclude = set() - cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) assert cost == 0.8 assert tokens == 450 @@ -154,6 +159,8 @@ def test_calculates_costs_and_tokens(self): assert input_tokens == 300 assert output_tokens == 150 assert input_cost + output_cost == cost # Costs should sum correctly + assert cache_read == 0 + assert cache_write == 0 def test_excludes_specified_uuids(self): entries = [ @@ -162,22 +169,26 @@ def test_excludes_specified_uuids(self): ] exclude = {"uuid-1"} - cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) assert cost == 0.3 assert tokens == 200 assert calls == 1 assert input_tokens == 200 # Only uuid-2's tokens assert output_tokens == 0 + assert cache_read == 0 + assert cache_write == 0 def test_handles_non_dict_entries(self): entries = ["not a dict", {"uuid": "uuid-1", "cost": 0.5, "usage": {}}] exclude = set() - cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) assert cost == 0.5 assert calls == 1 + assert cache_read == 0 + assert cache_write == 0 def test_handles_missing_usage(self): entries = [ @@ -185,13 +196,69 @@ def test_handles_missing_usage(self): ] exclude = set() - cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) assert cost == 0.5 assert tokens == 0 assert calls == 1 assert input_tokens == 0 assert output_tokens == 0 + assert cache_read == 0 + assert cache_write == 0 + + def test_skips_cache_hit_entries(self): + """Entries with cache_hit=True should be skipped.""" + entries = [ + {"uuid": "uuid-1", "cost": 0.5, "usage": {"prompt_tokens": 100}, "response": {"cache_hit": True}}, + {"uuid": "uuid-2", "cost": 0.3, "usage": {"prompt_tokens": 200}, "response": {"cache_hit": False}}, + ] + exclude = set() + + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) + + assert cost == 0.3 + assert tokens == 200 + assert calls == 1 + assert input_tokens == 200 + + def test_extracts_cache_tokens_bedrock_style(self): + """Test extraction of cache_read_input_tokens and cache_creation_input_tokens.""" + entries = [ + { + "uuid": "uuid-1", + "cost": 0.5, + "usage": { + "prompt_tokens": 100, + "cache_read_input_tokens": 50, + "cache_creation_input_tokens": 25, + }, + }, + ] + exclude = set() + + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) + + assert cache_read == 50 + assert cache_write == 25 + + def test_extracts_cache_tokens_openai_style(self): + """Test extraction of cached_tokens from prompt_tokens_details.""" + entries = [ + { + "uuid": "uuid-1", + "cost": 0.5, + "usage": { + "prompt_tokens": 100, + "prompt_tokens_details": {"cached_tokens": 30}, + }, + }, + ] + exclude = set() + + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) + + assert cache_read == 30 + assert cache_write == 0 class TestCostTracker: @@ -397,53 +464,65 @@ class TestSignatureContext: """Tests for SignatureContext context manager.""" @pytest.fixture - def mock_lm_context(self): + def mock_lm_scope(self): + """Create a mock LMScope with main and extraction LMs.""" with patch("codespy.agents.dspy_config.lm_context") as mock: - ctx = MagicMock() - ctx.__enter__ = MagicMock(return_value=ctx) - ctx.__exit__ = MagicMock(return_value=None) - mock.return_value = ctx + from codespy.agents.dspy_config import LMScope + + mock_lm = MagicMock() + mock_lm.history = [] + mock_extraction_lm = MagicMock() + mock_extraction_lm.history = [] + + scope = MagicMock() + scope.lm = mock_lm + scope.extraction_lm = mock_extraction_lm + scope.__enter__ = MagicMock(return_value=scope) + scope.__exit__ = MagicMock(return_value=None) + mock.return_value = scope yield mock - def test_enter_applies_lm_and_starts_tracking(self, mock_lm_context): + def test_enter_applies_lm_and_starts_tracking(self, mock_lm_scope): tracker = CostTracker() - with patch("dspy.settings.lm", MagicMock(history=[])): - with SignatureContext("test_sig", tracker): - pass + with SignatureContext("test_sig", tracker): + pass stats = tracker.get_signature_stats("test_sig") assert stats is not None - def test_exit_calculates_costs(self, mock_lm_context): + def test_exit_calculates_costs(self, mock_lm_scope): tracker = CostTracker() mock_history = [ {"uuid": "new-uuid", "cost": 0.5, "usage": {"prompt_tokens": 100}}, ] - with patch("dspy.settings.lm", MagicMock(history=mock_history)): - with patch.object(tracker, "end_signature") as mock_end: - with SignatureContext("test_sig", tracker): - pass + # Set history on both LMs + scope = mock_lm_scope.return_value + scope.lm.history = mock_history + scope.extraction_lm.history = [] - mock_end.assert_called_once() - args = mock_end.call_args - assert args[0][0] == "test_sig" + with patch.object(tracker, "end_signature") as mock_end: + with SignatureContext("test_sig", tracker): + pass - def test_exit_always_releases_lm_context(self, mock_lm_context): + mock_end.assert_called_once() + args = mock_end.call_args + assert args[0][0] == "test_sig" + + def test_exit_always_releases_lm_context(self, mock_lm_scope): tracker = CostTracker() - mock_lm = MagicMock() - mock_lm.history = [] - with patch("dspy.settings.lm", mock_lm): - try: - with SignatureContext("test_sig", tracker): - raise ValueError("Test error") - except ValueError: - pass + try: + with SignatureContext("test_sig", tracker): + raise ValueError("Test error") + except ValueError: + pass # lm_context.__exit__ should have been called - assert mock_lm_context.return_value.__exit__.called + assert mock_lm_scope.return_value.__exit__.called + + class TestGetCostTracker: diff --git a/tests/test_doc_extractor.py b/tests/test_doc_extractor.py new file mode 100644 index 0000000..a0cef55 --- /dev/null +++ b/tests/test_doc_extractor.py @@ -0,0 +1,280 @@ +"""Tests for the documentation extractor module.""" + +import tempfile +from pathlib import Path + +import pytest + +from codespy.agents.review.doc.doc_extractor import _trim_changelog, extract_documentation + + +class TestTrimChangelog: + """Tests for _trim_changelog function.""" + + def test_keeps_unreleased_plus_two_releases(self): + """Trim keeps [Unreleased] + two release sections.""" + content = """# Changelog + +## [Unreleased] + +### Added +- New feature + +## [1.0.2] - 2024-01-15 + +### Fixed +- Bug fix + +## [1.0.1] - 2024-01-10 + +### Fixed +- Another bug + +## [1.0.0] - 2024-01-01 + +### Added +- Initial release +""" + result = _trim_changelog(content) + assert "[Unreleased]" in result + assert "[1.0.2]" in result + assert "[1.0.1]" in result + assert "[1.0.0]" not in result + assert "[older entries omitted]" in result + + def test_no_trim_with_two_headings(self): + """No trim when fewer than 3 headings.""" + content = """# Changelog + +## [1.0.0] - 2024-01-01 + +### Added +- Initial release +""" + result = _trim_changelog(content) + assert result == content + assert "[older entries omitted]" not in result + + def test_no_trim_with_one_heading(self): + """No trim with only 1 heading.""" + content = """# Changelog + +## [Unreleased] + +### Added +- Feature +""" + result = _trim_changelog(content) + assert result == content + + def test_no_trim_with_no_headings(self): + """No trim with no headings.""" + content = """# Changelog + +Some text without proper headings. +""" + result = _trim_changelog(content) + assert result == content + + def test_exactly_three_headings(self): + """Trim at exactly 3 headings.""" + content = """# Changelog + +## [Unreleased] + +### Added +- New + +## [1.0.1] + +### Fixed +- Fix + +## [1.0.0] + +### Added +- Initial +""" + result = _trim_changelog(content) + assert "[Unreleased]" in result + assert "[1.0.1]" in result + assert "[1.0.0]" not in result + assert result.endswith("\n[older entries omitted]") + + def test_preserves_preamble(self): + """Preamble before first heading is preserved.""" + content = """# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog]. + +## [Unreleased] + +### Added +- Feature + +## [1.0.0] + +### Added +- Initial + +## [0.9.0] + +### Added +- Beta +""" + result = _trim_changelog(content) + assert "# Changelog" in result + assert "All notable changes" in result + assert "Keep a Changelog" in result + assert "[Unreleased]" in result + assert "[1.0.0]" in result + assert "[0.9.0]" not in result + + +class TestExtractDocumentation: + """Tests for extract_documentation function.""" + + def test_extracts_readme(self): + """README files are extracted.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "README.md").write_text("# README\n\nHello world") + + result = extract_documentation(root) + + assert "=== README.md ===" in result + assert "# README" in result + assert "Hello world" in result + + def test_extracts_changelog_and_trims(self): + """CHANGELOG files are extracted and trimmed.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + changelog_content = """# Changelog + +## [Unreleased] + +### Added +- New + +## [1.0.1] + +### Fixed +- Fix + +## [1.0.0] + +### Added +- Initial + +## [0.9.0] + +### Added +- Old +""" + (root / "CHANGELOG.md").write_text(changelog_content) + + result = extract_documentation(root) + + assert "=== CHANGELOG.md ===" in result + assert "[Unreleased]" in result + assert "[1.0.1]" in result + assert "[1.0.0]" in result + assert "[0.9.0]" not in result + assert "[older entries omitted]" in result + + def test_non_changelog_not_trimmed(self): + """Non-CHANGELOG files are not trimmed.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + content = """# README + +## Section 1 +Text 1 + +## Section 2 +Text 2 + +## Section 3 +Text 3 +""" + (root / "README.md").write_text(content) + + result = extract_documentation(root) + + # README should NOT be trimmed - all sections preserved + assert "Section 1" in result + assert "Section 2" in result + assert "Section 3" in result + assert "[older entries omitted]" not in result + + def test_changelog_case_insensitive(self): + """CHANGELOG matching is case-insensitive.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + changelog_content = """# Changelog + +## [Unreleased] +### Added +- New + +## [1.0.0] +### Added +- Initial + +## [0.9.0] +### Added +- Old +""" + (root / "changelog").write_text(changelog_content) + + result = extract_documentation(root) + + # Should be trimmed because filename matches case-insensitively + assert "[older entries omitted]" in result + assert "[0.9.0]" not in result + + def test_header_unchanged_when_trimmed(self): + """The `=== path ===` header is unchanged when trimming.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + changelog_content = """# Changelog + +## [Unreleased] + +### Added +- New + +## [1.0.0] + +### Added +- Initial + +## [0.9.0] + +### Added +- Old +""" + (root / "CHANGELOG.md").write_text(changelog_content) + + result = extract_documentation(root) + + # Header should be exactly as expected for filename detection + assert "=== CHANGELOG.md ===" in result + + def test_returns_empty_for_no_docs(self): + """Returns empty string when no documentation exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "main.py").write_text("print('hello')") + + result = extract_documentation(root) + + assert result == "" + + def test_returns_empty_for_nonexistent_path(self): + """Returns empty string when scope root does not exist.""" + result = extract_documentation(Path("/nonexistent/path")) + assert result == "" From ee219f14d2948c960beb32f63aa8ba1e27b2386c Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Thu, 1 Oct 2026 23:38:11 +0200 Subject: [PATCH 27/39] recall --- .env.example | 27 ++- CHANGELOG.md | 12 +- action.yml | 52 ++++- codespy.yaml | 32 ++- docs/configuration.md | 21 +- docs/memory.md | 29 ++- src/codespy/agents/dspy_config.py | 9 +- .../agents/memory/cerebral/cerebral.py | 15 ++ .../agents/memory/prefrontal/prefrontal.py | 22 +- src/codespy/config.py | 41 +++- src/codespy/config_memory.py | 211 ++++++++++++++--- tests/test_config.py | 136 +++++++++++ tests/test_config_memory.py | 215 +++++++++++++++--- 13 files changed, 706 insertions(+), 116 deletions(-) diff --git a/.env.example b/.env.example index c1d7120..2b06979 100644 --- a/.env.example +++ b/.env.example @@ -216,11 +216,22 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # LLM provider and credentials are derived from the model string and existing # LLM config (e.g. bedrock uses AWS env vars, openai uses OPENAI_API_KEY). # MEMORY_RETAIN_MODEL= # Falls back to DEFAULT_MODEL +# MEMORY_CONSOLIDATION_MODEL= # Falls back to MEMORY_RETAIN_MODEL → DEFAULT_MODEL # MEMORY_EMBEDDINGS_MODEL= # Auto-derived from provider # MEMORY_EMBEDDINGS_MAX_INPUT_CHARS= # null = auto per model (2048 for Bedrock Cohere v3), 0 = off # MEMORY_RETAIN_CHUNK_SIZE=12288 # Chars per extraction chunk (< 64000) +# ============================================================================= +# Cerebral Mental Models (Briefing Refresh) +# ============================================================================= +# Mental-model refresh synthesizes briefings after consolidation. +# Falls back to prefrontal.recall.model → cerebral.retain.model → default_model. +# Only used when prefrontal.reflects > 0. +# MEMORY_MENTAL_MODELS_MODEL= # Briefing refresh model +# MEMORY_MENTAL_MODELS_MAX_TOKENS=2048 # Briefing length +# MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS=0 # Minimum seconds between refreshes + # ============================================================================= # Prefrontal (Semantic Recall into Agent Context) # ============================================================================= @@ -232,15 +243,15 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # Both Prefrontal and briefing refreshes run at Hindsight's LOW budget with a # doubled global cap (2 × reflects), so each gets exactly ``reflects`` iterations. # A raw HINDSIGHT_API_REFLECT_MAX_ITERATIONS env value is used as-is (not doubled). -# MEMORY_PREFRONTAL_MODEL= # Reflect + briefings; falls back to MEMORY_RETAIN_MODEL → DEFAULT_MODEL -# MEMORY_PREFRONTAL_REACH=org # local | org | bank (bank crosses organisations) # MEMORY_PREFRONTAL_REFLECTS=3 # Reflect iterations; 0 = raw facts, no LLM, no briefings -# MEMORY_MAX_MENTAL_MODEL_TOKENS=2048 # Briefing length -# MEMORY_MAX_PREFRONTAL_TOKENS=8192 # Pre-call context budget -# MEMORY_MAX_PREFRONTAL_TOOL_TOKENS=2048 # Tool result budget -# MEMORY_MAX_PREFRONTAL_TOOL_CALLS=0 # Per agent call (0 = tool off) -# Minimum seconds between automatic briefing refreshes (0 = refresh after every consolidation; N = at most one automatic refresh per N seconds). Delta mode means nothing is lost. -# MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS=0 + +# Recall settings (read-time context injection) +# MEMORY_RECALL_MODEL= # Reflect model; also briefings fallback unless cerebral.mental_models.model set +# MEMORY_RECALL_REACH=org # local | org | bank (bank crosses organisations) +# MEMORY_RECALL_MAX_TOKENS=8192 # Pre-call context budget +# MEMORY_RECALL_MAX_TOOL_TOKENS=2048 # Tool result budget +# MEMORY_RECALL_MAX_TOOL_CALLS=0 # Per agent call (0 = recall_memory tool off) + # Reflect LLM timeout/retries default to codespy's LLM_TIMEOUT / LLM_RETRIES. # Override via HINDSIGHT_API_REFLECT_LLM_TIMEOUT / HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6b319..8cd21b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Changed +- **Mental-model configuration moved to `memory.cerebral.mental_models`**: Mental-model settings (`model`, `max_tokens`, `min_refresh_seconds`) now live under `memory.cerebral.mental_models` instead of `memory.prefrontal`. This better reflects that mental models are produced by Cerebral (briefing refresh after consolidation), not Prefrontal (read-only). Old env vars `MEMORY_MAX_MENTAL_MODEL_TOKENS` and `MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS` are removed (no aliases, as they were unreleased). New env vars: `MEMORY_MENTAL_MODELS_MAX_TOKENS`, `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS`. + +### Added +- **Cerebral consolidation and mental-model refresh model config**: Separate model configuration for consolidation (`memory.cerebral.consolidation.model`, env `MEMORY_CONSOLIDATION_MODEL`) and mental-model refresh (`memory.cerebral.mental_models.model`, env `MEMORY_MENTAL_MODELS_MODEL`). Both fall back through their respective chains: consolidation → retain → default; mental_models → prefrontal → retain → default. When unset, behavior is unchanged (consolidation uses retain model, mental-model refresh uses reflect model). Operator env vars (`HINDSIGHT_API_CONSOLIDATION_LLM_MODEL`, `HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_*`) still win when set. +- **Cerebral mental-models settings**: `memory.cerebral.mental_models.max_tokens` (env `MEMORY_MENTAL_MODELS_MAX_TOKENS`, default `2048`) for briefing size, and `memory.cerebral.mental_models.min_refresh_seconds` (env `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS`, default `0`) for minimum seconds between automatic briefing refreshes. Delta mode means nothing is lost — the refresh covers all new facts since `last_memory_seen_at` when the interval expires. + ### Changed - **Run-level Prefrontal recall**: One Prefrontal recall per run, shared by summary, code_review, doc, supply_chain, and audit agents. Previously each agent call ran its own `Prefrontal.aload()`, paying for the reflect loop separately. The scope agent still loads its own memory. - **One consolidation per run**: Consolidation and mental-model refresh run once per run, after all background episode saves complete. Previously every `retain_episode` consolidated each scope plus the repo scope, refreshing the repo briefing N times per run. @@ -24,7 +31,6 @@ - **Cost table columns**: Added `Cache Read` and `Cache Write` columns to per-signature cost breakdown. Cache tokens are extracted from provider usage (Anthropic/Bedrock style or OpenAI style). ### Added -- **`memory.prefrontal.min_mental_model_refresh_seconds`** (env `MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS`, default `0`): Minimum seconds between automatic briefing refreshes. `0` = refresh after every consolidation; `N` = at most one automatic refresh per N seconds. Delta mode means nothing is lost — the refresh covers all new facts since `last_memory_seen_at` when the interval expires. Deferred refreshes are marked `cancelled` (not `failed`) and logged at INFO level. - **Cost tracking for extraction LM**: `TwoStepAdapter` extraction calls are now counted and attributed to the calling signature. Previously these were missing from cost tracking. ### Fixed @@ -47,12 +53,12 @@ - **Reflect summary**: `areflect` returns `(text, ReflectSummary)` with iterations, LLM calls, map calls, rewrite flag, tools used, usage, and empty flag. Stored in `RecallRecord.details["reflect"]` for monitoring. - **Prefrontal reflect cost caps**: new settings to cap the Hindsight reflect loop cost: - `memory.prefrontal.max_reflect_fetch_tokens` (env `MEMORY_MAX_REFLECT_FETCH_TOKENS`, default `2048`): per-call fetch limit for recall and search_observations tools. This is the default size used when the LLM omits one; the LLM can still ask for more up to the ceiling. Also disables chunk retrieval (`recall_include_chunks=false`). -- **Prefrontal (semantic recall)**: new `codespy/agents/memory/prefrontal/` package. Before each agent call it reads Cerebral and injects a read-only `prefrontal_memory` input (mental-model briefings, five question-shaped recall facets — context, seen before, decisions, recurring patterns, what changed — and facts from other repositories). The RLM agents (code_review, scope, supply_chain) also get an async `recall_memory(query, reach)` tool. Configured under `memory.prefrontal` (`MEMORY_PREFRONTAL_REACH`, `MEMORY_PREFRONTAL_REFLECTS`, `MEMORY_MENTAL_MODELS`, `MEMORY_MAX_MENTAL_MODEL_TOKENS`, `MEMORY_MAX_PREFRONTAL_TOKENS`, `MEMORY_PREFRONTAL_REMOTE_SHARE`, `MEMORY_PREFRONTAL_RECALL_BUDGET`, `MEMORY_PREFRONTAL_TOOL`, `MEMORY_MAX_PREFRONTAL_TOOL_TOKENS`, `MEMORY_MAX_PREFRONTAL_TOOL_CALLS`). Active only when the signature's memory is enabled and Cerebral is available; otherwise agents behave exactly as before. Prefrontal never writes to Cerebral and never feeds Hippocampus. +- **Prefrontal (semantic recall)**: new `codespy/agents/memory/prefrontal/` package. Before each agent call it reads Cerebral and injects a read-only `prefrontal_memory` input (mental-model briefings, five question-shaped recall facets — context, seen before, decisions, recurring patterns, what changed — and facts from other repositories). The RLM agents (code_review, scope, supply_chain) also get an async `recall_memory(query, reach)` tool. Configured under `memory.prefrontal` (`MEMORY_PREFRONTAL_REACH`, `MEMORY_PREFRONTAL_REFLECTS`, `MEMORY_MAX_MENTAL_MODEL_TOKENS`, `MEMORY_MAX_PREFRONTAL_TOKENS`, `MEMORY_PREFRONTAL_REMOTE_SHARE`, `MEMORY_PREFRONTAL_RECALL_BUDGET`, `MEMORY_PREFRONTAL_TOOL`, `MEMORY_MAX_PREFRONTAL_TOOL_TOKENS`, `MEMORY_MAX_PREFRONTAL_TOOL_CALLS`). Active only when the signature's memory is enabled and Cerebral is available; otherwise agents behave exactly as before. Prefrontal never writes to Cerebral and never feeds Hippocampus. - **Cerebral read API**: `arecall`/`recall`, `areflect`/`reflect` (`budget=mid`), `aget_mental_models`, `aget_observation_history`. - **Prefrontal model setting**: `memory.prefrontal.model` (env `MEMORY_PREFRONTAL_MODEL`, Action input `memory-prefrontal-model`) selects the model for Hindsight's reflect loop and mental-model refresh. It falls back to `memory.cerebral.retain.model` → `llm.default_model`, so behaviour is unchanged when unset. It is checked at startup when `reflects > 0`. - **Prefrontal recall monitoring**: every pre-call load and `recall_memory` call is logged as one INFO line (status, model, LLM calls, input/output tokens, cost, latency) and persisted to a new episodic `recalls` table (`Episode.recalls`, `RecallRecord` in `codespy/agents/memory/recall.py`). Rows hold the exact text the agent received and the measured token usage and cost. The table is additive (`CREATE TABLE IF NOT EXISTS`); recalls are never retained into Cerebral nor given to the Distiller/Cartographer. - **`memory_prefrontal` cost bucket**: Prefrontal read calls (reflect LLM calls and query embeddings) are attributed to it through a context variable. Previously they were counted in `memory_other` / `memory_embeddings`. -- **Cerebral mental models**: one briefing per observation scope and per repo, created on first retain and refreshed after consolidation (`memory.prefrontal.mental_models`). `memory.prefrontal.reflects > 0` sets `HINDSIGHT_API_REFLECT_MAX_ITERATIONS`, which also caps mental-model refresh loops. +- **Cerebral mental models**: one briefing per observation scope and per repo, created on first retain and refreshed after consolidation. `memory.prefrontal.reflects > 0` sets `HINDSIGHT_API_REFLECT_MAX_ITERATIONS`, which also caps mental-model refresh loops. - **Cerebral (Semantic Memory)**: - New `memory.cerebral.retain.chunk_size` configuration (default: 12288, env var: `MEMORY_RETAIN_CHUNK_SIZE`). Chars per fact-extraction chunk; must be < 64000. See `CerebralRetainConfig` in `config_memory.py`. - New GitHub Action inputs: `memory-retain-model`, `memory-embeddings-model`, `memory-retain-chunk-size`. diff --git a/action.yml b/action.yml index 0774513..3e72330 100644 --- a/action.yml +++ b/action.yml @@ -377,8 +377,40 @@ inputs: description: 'Model for Cerebral fact extraction (falls back to model / DEFAULT_MODEL)' required: false + memory-consolidation-model: + description: 'Model for Cerebral consolidation (falls back to memory-retain-model / model)' + required: false + + memory-mental-models-model: + description: 'Model for mental-model briefing refresh (falls back to memory-prefrontal-model / memory-retain-model / model)' + required: false + + memory-recall-model: + description: 'Model for Prefrontal recall and briefing fallback (falls back to memory-retain-model / model)' + required: false + + memory-recall-reach: + description: 'Recall reach: local (this repo only), org (same owner), or bank (all repos)' + required: false + default: 'org' + + memory-recall-max-tokens: + description: 'Pre-call context token budget' + required: false + default: '8192' + + memory-recall-max-tool-tokens: + description: 'Recall memory tool result token budget' + required: false + default: '2048' + + memory-recall-max-tool-calls: + description: 'Recall memory tool calls per agent (0 = tool off)' + required: false + default: '0' + memory-prefrontal-model: - description: 'Model for Prefrontal reflect and briefing refresh (falls back to memory-retain-model / model)' + description: 'DEPRECATED: Use memory-recall-model instead' required: false memory-embeddings-model: @@ -573,7 +605,14 @@ runs: MEMORY_CARTOGRAPHER_MAX_ITERS: ${{ inputs.memory-cartographer-max-iters }} MEMORY_CARTOGRAPHER_MAX_LLM_CALLS: ${{ inputs.memory-cartographer-max-llm-calls }} MEMORY_RETAIN_MODEL: ${{ inputs.memory-retain-model }} - MEMORY_PREFRONTAL_MODEL: ${{ inputs.memory-prefrontal-model }} + MEMORY_CONSOLIDATION_MODEL: ${{ inputs.memory-consolidation-model }} + MEMORY_MENTAL_MODELS_MODEL: ${{ inputs.memory-mental-models-model }} + MEMORY_PREFRONTAL_REFLECTS: ${{ inputs.memory-prefrontal-reflects }} + MEMORY_RECALL_MODEL: ${{ inputs.memory-recall-model }} + MEMORY_RECALL_REACH: ${{ inputs.memory-recall-reach }} + MEMORY_RECALL_MAX_TOKENS: ${{ inputs.memory-recall-max-tokens }} + MEMORY_RECALL_MAX_TOOL_TOKENS: ${{ inputs.memory-recall-max-tool-tokens }} + MEMORY_RECALL_MAX_TOOL_CALLS: ${{ inputs.memory-recall-max-tool-calls }} MEMORY_EMBEDDINGS_MODEL: ${{ inputs.memory-embeddings-model }} MEMORY_RETAIN_CHUNK_SIZE: ${{ inputs.memory-retain-chunk-size }} SCOPE_MEMORY_ENABLED: ${{ inputs.scope-memory-enabled }} @@ -693,7 +732,14 @@ runs: [ -n "$MEMORY_CARTOGRAPHER_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CARTOGRAPHER_MAX_ITERS" [ -n "$MEMORY_CARTOGRAPHER_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CARTOGRAPHER_MAX_LLM_CALLS" [ -n "$MEMORY_RETAIN_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RETAIN_MODEL" - [ -n "$MEMORY_PREFRONTAL_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_PREFRONTAL_MODEL" + [ -n "$MEMORY_CONSOLIDATION_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CONSOLIDATION_MODEL" + [ -n "$MEMORY_MENTAL_MODELS_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_MENTAL_MODELS_MODEL" + [ -n "$MEMORY_PREFRONTAL_REFLECTS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_PREFRONTAL_REFLECTS" + [ -n "$MEMORY_RECALL_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RECALL_MODEL" + [ -n "$MEMORY_RECALL_REACH" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RECALL_REACH" + [ -n "$MEMORY_RECALL_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RECALL_MAX_TOKENS" + [ -n "$MEMORY_RECALL_MAX_TOOL_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RECALL_MAX_TOOL_TOKENS" + [ -n "$MEMORY_RECALL_MAX_TOOL_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RECALL_MAX_TOOL_CALLS" [ -n "$MEMORY_EMBEDDINGS_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_EMBEDDINGS_MODEL" [ -n "$MEMORY_RETAIN_CHUNK_SIZE" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RETAIN_CHUNK_SIZE" [ -n "$SCOPE_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e SCOPE_MEMORY_ENABLED" diff --git a/codespy.yaml b/codespy.yaml index fafe724..7fcf784 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -12,9 +12,16 @@ # - review.code_review.max_iters → REVIEW_CODE_REVIEW_MAX_ITERS # - memory.hippocampus.distiller.model → MEMORY_DISTILLER_MODEL # - memory.cerebral.retain.model → MEMORY_RETAIN_MODEL -# - memory.prefrontal.model → MEMORY_PREFRONTAL_MODEL -# - memory.prefrontal.prefrontal_reach → MEMORY_PREFRONTAL_REACH +# - memory.cerebral.consolidation.model → MEMORY_CONSOLIDATION_MODEL +# - memory.cerebral.mental_models.model → MEMORY_MENTAL_MODELS_MODEL +# - memory.cerebral.mental_models.max_tokens → MEMORY_MENTAL_MODELS_MAX_TOKENS +# - memory.cerebral.mental_models.min_refresh_seconds → MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS # - memory.prefrontal.reflects → MEMORY_PREFRONTAL_REFLECTS +# - memory.prefrontal.recall.model → MEMORY_RECALL_MODEL +# - memory.prefrontal.recall.reach → MEMORY_RECALL_REACH +# - memory.prefrontal.recall.max_tokens → MEMORY_RECALL_MAX_TOKENS +# - memory.prefrontal.recall.max_tool_tokens → MEMORY_RECALL_MAX_TOOL_TOKENS +# - memory.prefrontal.recall.max_tool_calls → MEMORY_RECALL_MAX_TOOL_CALLS # # Provider credentials keep their standard names: # OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AWS_*, AZURE_* @@ -112,7 +119,6 @@ memory: max_hippocampus_item_tokens: 512 # MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS max_trajectory_tokens: 16384 # MEMORY_MAX_TRAJECTORY_TOKENS max_question_tokens: 8192 # MEMORY_MAX_QUESTION_TOKENS - distiller: model: null # MEMORY_DISTILLER_MODEL reasoning_effort: null # MEMORY_DISTILLER_REASONING_EFFORT @@ -120,7 +126,6 @@ memory: max_tokens: null # MEMORY_DISTILLER_MAX_TOKENS max_iters: 1 # MEMORY_DISTILLER_MAX_ITERS max_llm_calls: 2 # MEMORY_DISTILLER_MAX_LLM_CALLS - cartographer: model: null # MEMORY_CARTOGRAPHER_MODEL reasoning_effort: null # MEMORY_CARTOGRAPHER_REASONING_EFFORT @@ -134,20 +139,25 @@ memory: retain: model: null # MEMORY_RETAIN_MODEL (falls back to llm.default_model) chunk_size: 12288 # MEMORY_RETAIN_CHUNK_SIZE (chars, must be < 64000) + consolidation: + model: null # MEMORY_CONSOLIDATION_MODEL (falls back to retain model → default) + mental_models: + model: null # MEMORY_MENTAL_MODELS_MODEL (briefing refresh; falls back to prefrontal model → retain → default) + max_tokens: 2048 # MEMORY_MENTAL_MODELS_MAX_TOKENS (briefing length) + min_refresh_seconds: 0 # MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS (0 = refresh after every consolidation; N = at most one automatic briefing refresh per N seconds, nothing lost thanks to delta) embeddings: model: null # MEMORY_EMBEDDINGS_MODEL (auto-derived from provider) max_input_chars: null # MEMORY_EMBEDDINGS_MAX_INPUT_CHARS (null=auto per model, e.g. 2048 for Bedrock Cohere v3; 0=off) # Prefrontal (semantic recall from Cerebral into agent context: read-only) prefrontal: - model: null # MEMORY_PREFRONTAL_MODEL (reflect + briefings; falls back to retain model → default) - prefrontal_reach: org # MEMORY_PREFRONTAL_REACH (local | org | bank) reflects: 3 # MEMORY_PREFRONTAL_REFLECTS (reflect iterations for recall and briefings; 0 = raw-fact recall, no LLM, no briefings) - max_mental_model_tokens: 2048 # MEMORY_MAX_MENTAL_MODEL_TOKENS (briefing length) - max_prefrontal_tokens: 8192 # MEMORY_MAX_PREFRONTAL_TOKENS (pre-call context budget) - max_prefrontal_tool_tokens: 2048 # MEMORY_MAX_PREFRONTAL_TOOL_TOKENS - max_prefrontal_tool_calls: 0 # MEMORY_MAX_PREFRONTAL_TOOL_CALLS (0 = recall_memory tool off) - min_mental_model_refresh_seconds: 0 # MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS (0 = refresh after every consolidation; N = at most one automatic briefing refresh per N seconds, nothing lost thanks to delta) + recall: + model: null # MEMORY_RECALL_MODEL (reflect; also briefings fallback unless cerebral.mental_models.model set) + reach: org # MEMORY_RECALL_REACH (local | org | bank) + max_tokens: 8192 # MEMORY_RECALL_MAX_TOKENS (pre-call context budget) + max_tool_tokens: 2048 # MEMORY_RECALL_MAX_TOOL_TOKENS (tool result budget) + max_tool_calls: 0 # MEMORY_RECALL_MAX_TOOL_CALLS (0 = recall_memory tool off) # ============================================================================ # REVIEW diff --git a/docs/configuration.md b/docs/configuration.md index 16e9c94..51b6185 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -125,7 +125,9 @@ AUTO_DISCOVER_GEMINI=false | Cheap | PR summary | `REVIEW_SUMMARY_MODEL` | Falls back to DEFAULT_MODEL | Claude Haiku | | Mid-tier | Memory reflection | `MEMORY_DISTILLER_MODEL` / `MEMORY_CARTOGRAPHER_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | | Mid-tier | Semantic memory (fact extraction) | `MEMORY_RETAIN_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | -| Mid-tier | Semantic recall (reflect + briefings) | `MEMORY_PREFRONTAL_MODEL` | Falls back to MEMORY_RETAIN_MODEL → DEFAULT_MODEL | Claude Sonnet | +| Mid-tier | Semantic consolidation | `MEMORY_CONSOLIDATION_MODEL` | Falls back to MEMORY_RETAIN_MODEL → DEFAULT_MODEL | Claude Sonnet | +| Mid-tier | Mental-model refresh (briefings) | `MEMORY_MENTAL_MODELS_MODEL` | Falls back to MEMORY_PREFRONTAL_MODEL → MEMORY_RETAIN_MODEL → DEFAULT_MODEL | Claude Sonnet | +| Mid-tier | Semantic recall (reflect) | `MEMORY_RECALL_MODEL` | Falls back to MEMORY_RETAIN_MODEL → DEFAULT_MODEL | Claude Sonnet | ## Per-Signature Configuration @@ -198,7 +200,11 @@ LLM provider and credentials are auto-derived from the model string and existing | Setting | Env Var | Default | Description | |---------|---------|---------|-------------| -| Cerebral Model | `MEMORY_RETAIN_MODEL` | `DEFAULT_MODEL` | Model for fact extraction | +| Retain Model | `MEMORY_RETAIN_MODEL` | `DEFAULT_MODEL` | Model for fact extraction | +| Consolidation Model | `MEMORY_CONSOLIDATION_MODEL` | `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for consolidation | +| Mental-Models Model | `MEMORY_MENTAL_MODELS_MODEL` | `MEMORY_PREFRONTAL_MODEL` → `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for briefing refresh | +| Mental-Models Max Tokens | `MEMORY_MENTAL_MODELS_MAX_TOKENS` | `2048` | Briefing size | +| Mental-Models Min Refresh Seconds | `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS` | `0` | Minimum seconds between refreshes (0 = after every consolidation) | | Embeddings Model | `MEMORY_EMBEDDINGS_MODEL` | Auto-derived | Model for semantic memory embeddings | | Retain Chunk Size | `MEMORY_RETAIN_CHUNK_SIZE` | `12288` | Chars per extraction chunk (< 64000) | @@ -213,13 +219,12 @@ input (briefings, recalled facts, facts from other repositories); the RLM agents | Setting | Env Var | Default | Description | |---------|---------|---------|-------------| -| Model | `MEMORY_PREFRONTAL_MODEL` | `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for the reflect loop and mental-model (briefing) refresh; unused when reflects = 0 | -| Reach | `MEMORY_PREFRONTAL_REACH` | `org` | `local`, `org` or `bank` | | Reflects | `MEMORY_PREFRONTAL_REFLECTS` | `3` | Reflect iterations; `0` = raw facts, no LLM, no briefings | -| Mental model tokens | `MEMORY_MAX_MENTAL_MODEL_TOKENS` | `2048` | Briefing size | -| Pre-call tokens | `MEMORY_MAX_PREFRONTAL_TOKENS` | `8192` | Pre-call context budget | -| Tool tokens | `MEMORY_MAX_PREFRONTAL_TOOL_TOKENS` | `2048` | Tool result budget | -| Tool calls | `MEMORY_MAX_PREFRONTAL_TOOL_CALLS` | `0` | Per agent call (0 = tool off) | +| Recall Model | `MEMORY_RECALL_MODEL` | `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for the reflect loop; also briefing refresh fallback when `cerebral.mental_models.model` is unset; unused when reflects = 0 | +| Recall Reach | `MEMORY_RECALL_REACH` | `org` | `local`, `org` or `bank` | +| Recall Max Tokens | `MEMORY_RECALL_MAX_TOKENS` | `8192` | Pre-call context budget | +| Recall Max Tool Tokens | `MEMORY_RECALL_MAX_TOOL_TOKENS` | `2048` | Tool result budget | +| Recall Max Tool Calls | `MEMORY_RECALL_MAX_TOOL_CALLS` | `0` | Per agent call (0 = tool off) | Every recall (pre-call load and each `recall_memory` call) is logged as one INFO line and stored with its full text, model, token usage and cost in the episodic `recalls` table. Its diff --git a/docs/memory.md b/docs/memory.md index 0dbd77e..4906f58 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -279,7 +279,7 @@ Cerebral keeps one per observation scope of each episode, plus one per repo - id: `mm-` + first 32 hex chars of `sha1("|".join(sorted(tags)))` - name: `Briefing: ` - source query: *"What should an agent starting work here know so it does not rediscover it: structure and where things live, conventions, invariants and constants, dependencies and integrations, pitfalls and recurring problems, facts shown to be wrong."* -- `max_tokens = max_mental_model_tokens`, trigger `refresh_after_consolidation` +- `max_tokens = cerebral.mental_models.max_tokens`, trigger `refresh_after_consolidation` A missing model is created and refreshed once; consolidation keeps it fresh afterwards. Prefrontal reads the briefings of the agent's scopes (and repo) with no LLM call; @@ -296,7 +296,7 @@ Briefing triggers are configured automatically with these settings: | `include_chunks` | `false` | Observation text only (no file chunks) | | `reflect_search_observations_max_tokens` | `3000` | Down from 5000 default | | `reflect_search_observations_include_entities` | `false` | Exclude entity metadata | -| `min_refresh_interval_seconds` | `0` | Configurable via `MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS` | +| `min_refresh_interval_seconds` | `0` | Configurable via `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS` | **Delta mode**: After the first full refresh, subsequent refreshes only process new facts since `last_memory_seen_at`. One structured delta-ops call edits the stored document. Falls back to full refresh when content is the placeholder or when the source query changed. Falls back to candidate markdown if the delta call fails. @@ -465,18 +465,29 @@ Observation capacity ≈ max_hippocampus_tokens / max_hippocampus_item_tokens (1 | `MEMORY_ENABLED` | `memory.enabled` | `false` | Enable memory globally (episodic + semantic) | | `MEMORY_COMPACT_TRAJECTORY` | `memory.hippocampus.compact_trajectory` | `true` | Apply head+tail trajectory bounding before distillation | +### Cerebral Mental-Models Settings + +| Env Var | YAML Path | Default | Description | +|---------|-----------|---------|-------------| +| `MEMORY_MENTAL_MODELS_MODEL` | `memory.cerebral.mental_models.model` | `memory.prefrontal.recall.model` → `memory.cerebral.retain.model` → `llm.default_model` | Briefing refresh model; unused when reflects=0 | +| `MEMORY_MENTAL_MODELS_MAX_TOKENS` | `memory.cerebral.mental_models.max_tokens` | `2048` | Briefing size | +| `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS` | `memory.cerebral.mental_models.min_refresh_seconds` | `0` | Minimum seconds between automatic briefing refreshes (0 = refresh after every consolidation; N = at most one automatic refresh per N seconds, nothing lost thanks to delta) | + ### Prefrontal Settings | Env Var | YAML Path | Default | Description | |---------|-----------|---------|-------------| -| `MEMORY_PREFRONTAL_MODEL` | `memory.prefrontal.model` | `memory.cerebral.retain.model` → `llm.default_model` | Reflect loop + mental-model refresh; unused when reflects=0 | -| `MEMORY_PREFRONTAL_REACH` | `memory.prefrontal.prefrontal_reach` | `org` | `local`, `org` or `bank` | | `MEMORY_PREFRONTAL_REFLECTS` | `memory.prefrontal.reflects` | `3` | Reflect iterations; `0` = raw facts, no LLM, no briefings | -| `MEMORY_MAX_MENTAL_MODEL_TOKENS` | `memory.prefrontal.max_mental_model_tokens` | `2048` | Briefing size | -| `MEMORY_MAX_PREFRONTAL_TOKENS` | `memory.prefrontal.max_prefrontal_tokens` | `8192` | Pre-call context budget | -| `MEMORY_MAX_PREFRONTAL_TOOL_TOKENS` | `memory.prefrontal.max_prefrontal_tool_tokens` | `2048` | Tool result budget | -| `MEMORY_MAX_PREFRONTAL_TOOL_CALLS` | `memory.prefrontal.max_prefrontal_tool_calls` | `0` | Tool calls per agent call (0 = tool off) | -| `MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS` | `memory.prefrontal.min_mental_model_refresh_seconds` | `0` | Minimum seconds between automatic briefing refreshes (0 = refresh after every consolidation; N = at most one automatic refresh per N seconds, nothing lost thanks to delta) | + +### Recall Settings + +| Env Var | YAML Path | Default | Description | +|---------|-----------|---------|-------------| +| `MEMORY_RECALL_MODEL` | `memory.prefrontal.recall.model` | `memory.cerebral.retain.model` → `llm.default_model` | Reflect loop model; also mental-model fallback when unset; unused when reflects=0 | +| `MEMORY_RECALL_REACH` | `memory.prefrontal.recall.reach` | `org` | `local`, `org` or `bank` | +| `MEMORY_RECALL_MAX_TOKENS` | `memory.prefrontal.recall.max_tokens` | `8192` | Pre-call context budget | +| `MEMORY_RECALL_MAX_TOOL_TOKENS` | `memory.prefrontal.recall.max_tool_tokens` | `2048` | Tool result budget | +| `MEMORY_RECALL_MAX_TOOL_CALLS` | `memory.prefrontal.recall.max_tool_calls` | `0` | Tool calls per agent call (0 = tool off) | ### Reflection Module LLM Overrides diff --git a/src/codespy/agents/dspy_config.py b/src/codespy/agents/dspy_config.py index 6b62492..f0bd8fc 100644 --- a/src/codespy/agents/dspy_config.py +++ b/src/codespy/agents/dspy_config.py @@ -10,7 +10,7 @@ from dspy.adapters.two_step_adapter import TwoStepAdapter # type: ignore[import-untyped] from codespy.config import Settings, get_settings -from codespy.config_memory import MEMORY_PREFRONTAL, MEMORY_RETAIN, REFLECTION_MODULES, LLMSettings +from codespy.config_memory import MEMORY_CONSOLIDATION, MEMORY_MENTAL_MODELS, MEMORY_PREFRONTAL, MEMORY_RETAIN, REFLECTION_MODULES, LLMSettings from codespy.config_utils import secret_value logger = logging.getLogger(__name__) @@ -326,10 +326,17 @@ def verify_model_access(settings: Settings) -> tuple[bool, str]: cerebral = settings.get_llm_config(MEMORY_RETAIN) models_to_check.add(cerebral.model) + # Cerebral consolidation model + consolidation = settings.get_llm_config(MEMORY_CONSOLIDATION) + models_to_check.add(consolidation.model) + # Prefrontal reflect model (only when reflects > 0, since with reflects=0 no LLM runs) if settings.memory.prefrontal.reflects > 0: prefrontal = settings.get_llm_config(MEMORY_PREFRONTAL) models_to_check.add(prefrontal.model) + # Mental-models refresh model (only when reflects > 0) + mental_models = settings.get_llm_config(MEMORY_MENTAL_MODELS) + models_to_check.add(mental_models.model) # Check each model verified: list[str] = [] diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index 37ac10c..9f1d341 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -324,6 +324,9 @@ def __init__( reflect_llm_model: str | None = None, reflect_llm_api_key: str | None = None, reflect_llm_base_url: str | None = None, + consolidation_llm_model: str | None = None, + consolidation_llm_api_key: str | None = None, + consolidation_llm_base_url: str | None = None, embeddings_max_input_chars: int | None = None, min_mental_model_refresh_seconds: int = 0, ): @@ -383,6 +386,17 @@ def __init__( "reflect_llm_base_url": reflect_llm_base_url or None, } + # Separate consolidation LLM, only when configured; otherwise Hindsight + # falls back to memory_llm_*. + consolidation_kwargs: dict[str, str | None] = {} + if consolidation_llm_model: + consolidation_kwargs = { + "consolidation_llm_provider": llm_provider, + "consolidation_llm_model": consolidation_llm_model, + "consolidation_llm_api_key": consolidation_llm_api_key, + "consolidation_llm_base_url": consolidation_llm_base_url or None, + } + # Run consolidation and mental-model refreshes inline. The default # BrokerTaskBackend only queues rows for a WorkerPoller, which # codespy never starts, so they would never run. @@ -394,6 +408,7 @@ def __init__( memory_llm_api_key=llm_api_key, memory_llm_base_url=llm_base_url or None, **reflect_kwargs, + **consolidation_kwargs, embeddings=metered_embeddings, cross_encoder=RRFPassthroughCrossEncoder(), tenant_extension=DefaultTenantExtension(config={"schema": HINDSIGHT_SCHEMA}), diff --git a/src/codespy/agents/memory/prefrontal/prefrontal.py b/src/codespy/agents/memory/prefrontal/prefrontal.py index 96531c8..9980a15 100644 --- a/src/codespy/agents/memory/prefrontal/prefrontal.py +++ b/src/codespy/agents/memory/prefrontal/prefrontal.py @@ -196,7 +196,7 @@ def _record( @property def reach(self) -> str: - return self._cfg.prefrontal_reach + return self._cfg.recall.reach @property def reflects(self) -> int: @@ -261,7 +261,7 @@ async def aload(self, facets: Sequence[Facet]) -> str: rec = self._record( kind="load", query=context_query, - reach=self._cfg.prefrontal_reach, + reach=self._cfg.recall.reach, started_at=started_at, t0=t0, text=text, @@ -314,7 +314,7 @@ def _n(name: str) -> int: _n(FACET_BELIEF_CHANGES), int(details.get("remote", 0)), estimate_tokens(text), - self._cfg.prefrontal_reach, + self._cfg.recall.reach, self._cfg.reflects, usage.to_model_field() or "-", llm_calls, @@ -336,7 +336,7 @@ async def _aload(self, facets: list[Facet]) -> tuple[str, dict[str, Any]]: local_groups = build_local_groups(self._repo, self._scope_ids, self._include_repo) context_query = next((f.query for f in facets if f.name == FACET_CONTEXT), "") local_tokens, remote_tokens = self._split_tokens( - cfg.max_prefrontal_tokens, cfg.prefrontal_reach + cfg.recall.max_tokens, cfg.recall.reach ) async def _briefings() -> list[dict]: @@ -384,7 +384,7 @@ def _extract_reflect_summary(results: dict) -> dict | None: } async def _remote() -> list[Any]: - groups = build_remote_groups(self._repo, cfg.prefrontal_reach) + groups = build_remote_groups(self._repo, cfg.recall.reach) if groups is None or remote_tokens <= 0 or not context_query: return [] try: @@ -403,7 +403,7 @@ async def _remote() -> list[Any]: return [f for f in facts or [] if not is_own_repo_fact(fget(f, "tags"), self._repo)] briefings, results, remote = await asyncio.gather(_briefings(), _facets(), _remote()) - text = format_context(briefings, results, remote, self._repo, cfg.prefrontal_reach) + text = format_context(briefings, results, remote, self._repo, cfg.recall.reach) n_briefings = sum( 1 @@ -432,7 +432,7 @@ async def _remote() -> list[Any]: def recall_tool(self) -> Callable[..., Any] | None: """Async ``recall_memory`` tool for RLM agents, or None when disabled.""" - if self._cfg.max_prefrontal_tool_calls <= 0: + if self._cfg.recall.max_tool_calls <= 0: return None async def recall_memory(query: str, reach: str = "local") -> str: @@ -458,13 +458,13 @@ async def _recall(self, query: str, reach: str = "local") -> str: t0 = time.monotonic() # str(): RLM agents may pass non-string arguments; this must never raise. q = truncate_words(str(query or "")) - effective = clamp_reach(str(reach or ""), cfg.prefrontal_reach) + effective = clamp_reach(str(reach or ""), cfg.recall.reach) details: dict[str, Any] = { "requested_reach": str(reach or ""), "mode": "reflect" if cfg.reflects > 0 else "recall", } with track_recall_usage() as usage: - if self._tool_calls >= cfg.max_prefrontal_tool_calls: + if self._tool_calls >= cfg.recall.max_tool_calls: text = RECALL_LIMIT_REACHED else: self._tool_calls += 1 @@ -516,7 +516,7 @@ async def _recall_impl(self, q: str, effective: str, details: dict[str, Any]) -> text, summary = await self._cerebral.areflect( prompt, tag_groups=groups, - max_tokens=cfg.max_prefrontal_tool_tokens, + max_tokens=cfg.recall.max_tool_tokens, context=self._reflect_context(), ) details["reflect"] = { @@ -551,7 +551,7 @@ async def _recall_impl(self, q: str, effective: str, details: dict[str, Any]) -> parts: list[str] = [] local_tokens, remote_tokens = self._split_tokens( - cfg.max_prefrontal_tool_tokens, effective + cfg.recall.max_tool_tokens, effective ) local_groups = build_local_groups(self._repo, self._scope_ids, self._include_repo) if local_groups: diff --git a/src/codespy/config.py b/src/codespy/config.py index 164ebea..2d07059 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -21,6 +21,8 @@ ) from codespy.config_llm import LLMConfig from codespy.config_memory import ( + MEMORY_CONSOLIDATION, + MEMORY_MENTAL_MODELS, MEMORY_PREFRONTAL, MEMORY_RETAIN, REFLECTION_MODULES, @@ -82,7 +84,7 @@ ("llm", "retries"), ("llm", "timeout"), ("memory", "prefrontal", "reflects"), - ("memory", "prefrontal", "model"), + ("memory", "prefrontal", "recall"), }, ) @@ -179,13 +181,16 @@ def get_llm_config(self, name: str) -> LLMSettings: "code_review" -> review.code_review "memory_distiller" -> memory.hippocampus.distiller "memory_retain" -> memory.cerebral.retain + "memory_consolidation" -> memory.cerebral.consolidation + "memory_mental_models" -> memory.cerebral.mental_models Every field falls back to its ``llm.default_*`` counterpart, so the result has no ``None`` fields and callers never re-apply fallbacks. Args: name: A signature name, or a reflection module name - (see ``REFLECTION_MODULES``), or "memory_retain". + (see ``REFLECTION_MODULES``), or "memory_retain", + "memory_consolidation", "memory_mental_models". Returns: The fully resolved settings for ``name``. @@ -195,10 +200,29 @@ def get_llm_config(self, name: str) -> LLMSettings: model = self.memory.cerebral.retain.model or self.llm.default_model config = ReflectionModuleConfig() # Empty config - all fields fall back to defaults defaults = self.llm + elif name == MEMORY_CONSOLIDATION: + # Consolidation model: falls back to retain model, then default + model = ( + self.memory.cerebral.consolidation.model + or self.memory.cerebral.retain.model + or self.llm.default_model + ) + config = ReflectionModuleConfig() # Empty config - all fields fall back to defaults + defaults = self.llm elif name == MEMORY_PREFRONTAL: - # Prefrontal reflect model: falls back to retain model, then default + # Prefrontal recall model: falls back to retain model, then default + model = ( + self.memory.prefrontal.recall.model + or self.memory.cerebral.retain.model + or self.llm.default_model + ) + config = ReflectionModuleConfig() # Empty config - all fields fall back to defaults + defaults = self.llm + elif name == MEMORY_MENTAL_MODELS: + # Mental-model refresh model: falls back to prefrontal.recall → retain → default model = ( - self.memory.prefrontal.model + self.memory.cerebral.mental_models.model + or self.memory.prefrontal.recall.model or self.memory.cerebral.retain.model or self.llm.default_model ) @@ -316,12 +340,19 @@ def log_signature_configs(self) -> None: f"reasoning_effort={llm.reasoning_effort}, temperature={llm.temperature}, " f"max_tokens={llm.max_tokens}" ) - # Prefrontal reflect model (also refreshes briefings) + # Prefrontal recall model (also refreshes briefings) pf_llm = self.get_llm_config(MEMORY_PREFRONTAL) logger.info( f" {MEMORY_PREFRONTAL}: model={pf_llm.model}, " f"reflects={self.memory.prefrontal.reflects}" ) + # Consolidation model + cons_llm = self.get_llm_config(MEMORY_CONSOLIDATION) + logger.info(f" {MEMORY_CONSOLIDATION}: model={cons_llm.model}") + # Mental-models refresh model (only when reflects > 0) + if self.memory.prefrontal.reflects > 0: + mm_llm = self.get_llm_config(MEMORY_MENTAL_MODELS) + logger.info(f" {MEMORY_MENTAL_MODELS}: model={mm_llm.model}") @model_validator(mode="before") @classmethod diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 4c08e3a..504b50d 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -92,6 +92,32 @@ class CerebralRetainConfig(BaseModel): chunk_size: int = Field(default=12288, gt=0, lt=_HINDSIGHT_RETAIN_MAX_COMPLETION_TOKENS) +class CerebralConsolidationConfig(BaseModel): + """Cerebral (semantic memory) LLM configuration for consolidation. + + Consolidation merges facts after episode retention. Falls back to + cerebral.retain.model → llm.default_model when unset. + """ + + model: str | None = None # Falls back to cerebral.retain.model → llm.default_model + + +class MentalModelsConfig(BaseModel): + """Mental-model (briefing) refresh LLM configuration. + + Mental-model refresh synthesizes briefings after consolidation. + Falls back to prefrontal.model → cerebral.retain.model → llm.default_model + when unset. Only used when reflects > 0. + + Delta mode means nothing is lost - the refresh covers all new facts since + the last successful refresh when the interval expires. + """ + + model: str | None = None # Falls back to prefrontal.model → retain → default + max_tokens: int = Field(default=2048, gt=0) # Briefing size + min_refresh_seconds: int = Field(default=0, ge=0) # Minimum seconds between refreshes + + class CerebralEmbeddingsConfig(BaseModel): """Cerebral (semantic memory) embeddings configuration.""" @@ -107,11 +133,17 @@ class CerebralConfig(BaseModel): """Cerebral (semantic memory) configuration.""" retain: CerebralRetainConfig = Field(default_factory=CerebralRetainConfig) + consolidation: CerebralConsolidationConfig = Field(default_factory=CerebralConsolidationConfig) + mental_models: MentalModelsConfig = Field(default_factory=MentalModelsConfig) embeddings: CerebralEmbeddingsConfig = Field(default_factory=CerebralEmbeddingsConfig) -class PrefrontalConfig(BaseModel): - """Prefrontal (semantic recall into agent context) configuration.""" +class RecallConfig(BaseModel): + """Prefrontal recall (read-time context injection) configuration. + + Controls the read-time recall into agent context. Only used when + memory.prefrontal.reflects > 0. + """ # Model for Hindsight reflect (pre-call context and recall_memory tool). # Falls back to cerebral.retain.model → llm.default_model. @@ -119,25 +151,25 @@ class PrefrontalConfig(BaseModel): model: str | None = None # local: this scope/repo only. org: also other repos of the same owner. # bank: every repo in the bank (crosses organisations — opt-in only). - prefrontal_reach: Literal["local", "org", "bank"] = "org" + reach: Literal["local", "org", "bank"] = "org" + # Token budget for the pre-call context (local facets + remote facts). + max_tokens: int = Field(default=8192, gt=0) + # Token budget for recall_memory tool results. + max_tool_tokens: int = Field(default=2048, gt=0) + # recall_memory tool call limit (0 = tool disabled). + max_tool_calls: int = Field(default=0, ge=0) + + +class PrefrontalConfig(BaseModel): + """Prefrontal (semantic recall into agent context) configuration.""" + # 0: raw facts only, no LLM at read time and no briefings (no mental models). # N > 0: Hindsight reflect loop, capped at N iterations (both Prefrontal and # briefing refreshes). Uses Hindsight's LOW budget (0.5× multiplier), so the # global cap is set to 2× reflects to achieve exactly reflects iterations. reflects: int = Field(default=3, ge=0) - # Briefing size. Only used when reflects > 0. - max_mental_model_tokens: int = Field(default=2048, gt=0) - # Token budget for the pre-call context (local facets + remote facts). - max_prefrontal_tokens: int = Field(default=8192, gt=0) - # Token budget for recall_memory tool results. - max_prefrontal_tool_tokens: int = Field(default=2048, gt=0) - # recall_memory tool call limit (0 = tool disabled). - max_prefrontal_tool_calls: int = Field(default=0, ge=0) - # Minimum seconds between automatic briefing refreshes (0 = refresh after - # every consolidation; N = at most one automatic refresh per N seconds). - # Delta mode means nothing is lost - the refresh covers all new facts since - # the last successful refresh when the interval expires. - min_mental_model_refresh_seconds: int = Field(default=0, ge=0) + # Recall settings (read-time context injection). + recall: RecallConfig = Field(default_factory=RecallConfig) class LLMSettings(BaseModel): @@ -499,21 +531,36 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: or EMBEDDING_MODELS.get(provider_prefix, "openai/text-embedding-3-small") ) - # Prefrontal reflect model (optional) - pf_model = settings.memory.prefrontal.model + # Prefrontal recall model (optional) + recall_model = settings.memory.prefrontal.recall.model reflect_kwargs: dict[str, str | None] = {} - if pf_model: - pf_api_key, pf_base_url = _litellm_credentials(settings, pf_model) + if recall_model: + recall_api_key, recall_base_url = _litellm_credentials(settings, recall_model) reflect_kwargs = { - "reflect_llm_model": pf_model, - "reflect_llm_api_key": pf_api_key, - "reflect_llm_base_url": pf_base_url, + "reflect_llm_model": recall_model, + "reflect_llm_api_key": recall_api_key, + "reflect_llm_base_url": recall_base_url, + } + + # Consolidation model (optional) - pass to Cerebral only when explicitly set + # and no operator env override is present (env wins over config) + cons_model = settings.memory.cerebral.consolidation.model + consolidation_kwargs: dict[str, str | None] = {} + if cons_model and not os.environ.get("HINDSIGHT_API_CONSOLIDATION_LLM_MODEL"): + cons_api_key, cons_base_url = _litellm_credentials(settings, cons_model) + consolidation_kwargs = { + "consolidation_llm_model": cons_model, + "consolidation_llm_api_key": cons_api_key, + "consolidation_llm_base_url": cons_base_url, } # Apply Hindsight LLM defaults BEFORE Cerebral construction (these are read # during MemoryEngine.__init__). reflect_config is applied after construction. _apply_hindsight_llm_call_defaults(settings) + # Apply mental-model refresh LLM config to Hindsight raw config BEFORE Cerebral construction + _apply_mental_model_refresh_llm(settings) + try: _cerebral = Cerebral( database_url=pg_uri, @@ -525,10 +572,11 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: embeddings_model=embeddings_model, retain_chunk_size=settings.memory.cerebral.retain.chunk_size, mental_models=settings.memory.prefrontal.reflects > 0, - max_mental_model_tokens=settings.memory.prefrontal.max_mental_model_tokens, + max_mental_model_tokens=settings.memory.cerebral.mental_models.max_tokens, embeddings_max_input_chars=settings.memory.cerebral.embeddings.max_input_chars, - min_mental_model_refresh_seconds=settings.memory.prefrontal.min_mental_model_refresh_seconds, + min_mental_model_refresh_seconds=settings.memory.cerebral.mental_models.min_refresh_seconds, **reflect_kwargs, + **consolidation_kwargs, ) except Exception: logger.error( @@ -541,12 +589,24 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: _cerebral_built = True return None - reflect_model_str = f", reflect_model={pf_model}" if pf_model else "" + reflect_model_str = f", reflect_model={recall_model}" if recall_model else "" + consolidation_model_str = ( + f", consolidation_model={consolidation_kwargs.get('consolidation_llm_model')}" + if consolidation_kwargs.get("consolidation_llm_model") + else "" + ) + mental_models_model_str = ( + f", mental_models_model={settings.memory.cerebral.mental_models.model}" + if settings.memory.cerebral.mental_models.model and settings.memory.prefrontal.reflects > 0 + else "" + ) logger.info( - "Cerebral initialized (bank=%s, provider=litellm, model=%s%s, schema=semantic)", + "Cerebral initialized (bank=%s, provider=litellm, model=%s%s%s%s, schema=semantic)", bank_id, model, reflect_model_str, + consolidation_model_str, + mental_models_model_str, ) # Apply reflect caps programmatically (after Cerebral construction, before first use) _apply_reflect_config(settings.memory.prefrontal) @@ -818,13 +878,95 @@ def get_run_prefrontal( return None +# Snapshot of raw config mental_model_refresh fields before mutation, +# restored by reset_cerebral() to avoid leaking settings across rebuilds. +_mental_model_refresh_snapshot: dict[str, Any] | None = None + + +def _apply_mental_model_refresh_llm(settings: "Settings") -> None: + """Apply mental-model refresh LLM config to Hindsight raw config programmatically. + + Hindsight reads mental_model_refresh_llm_* from raw config (populated from + HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_* env vars). This function sets those + fields when codespy's config has an explicit mental_models.model and no env + override is present. + + The snapshot is stored in _mental_model_refresh_snapshot so reset_cerebral() + can restore the original values. + + Args: + settings: Application settings. + """ + global _mental_model_refresh_snapshot + + # Only apply when reflects > 0 and a mental_models model is configured + # reflects gates mental models; the model itself lives in cerebral.mental_models + if settings.memory.prefrontal.reflects == 0: + return + mm_cfg = settings.memory.cerebral.mental_models + if not mm_cfg.model: + return + + # Operator env wins - if any HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_* is set, + # do not touch the raw config (the env values are already there) + env_vars = [ + "HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_PROVIDER", + "HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_MODEL", + "HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_API_KEY", + "HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_BASE_URL", + ] + if any(os.environ.get(v) for v in env_vars): + logger.info("Using HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_* env vars (operator override)") + return + + try: + import hindsight_api.config as ha_cfg + + raw = ha_cfg._get_raw_config() + except Exception as e: + logger.warning("Could not access Hindsight raw config for mental-model refresh: %s", e) + return + + # Snapshot current values before mutation + _mental_model_refresh_snapshot = {} + for field in ("mental_model_refresh_llm_provider", "mental_model_refresh_llm_model", + "mental_model_refresh_llm_api_key", "mental_model_refresh_llm_base_url"): + if hasattr(raw, field): + _mental_model_refresh_snapshot[field] = getattr(raw, field) + + # Get credentials for the configured model + model = mm_cfg.model + api_key, base_url = _litellm_credentials(settings, model) + + try: + # Set the raw config fields (guarded by hasattr for forward compatibility) + if hasattr(raw, "mental_model_refresh_llm_provider"): + raw.mental_model_refresh_llm_provider = "litellm" + if hasattr(raw, "mental_model_refresh_llm_model"): + raw.mental_model_refresh_llm_model = model + if hasattr(raw, "mental_model_refresh_llm_api_key"): + raw.mental_model_refresh_llm_api_key = api_key + if hasattr(raw, "mental_model_refresh_llm_base_url"): + # Use "" for no base URL (not None), so Hindsight doesn't inherit reflect's base URL + raw.mental_model_refresh_llm_base_url = base_url or "" + + logger.info("Mental-model refresh LLM: model=%s", model) + except Exception as e: + logger.warning("Failed to set mental-model refresh LLM config: %s", e) + + def reset_cerebral() -> None: """Clear the cached Cerebral instance so it is rebuilt on next access. Call this after reloading settings (e.g. ``reload_settings()``) so a changed ``memory`` configuration takes effect. + + Also restores the Hindsight raw config mental_model_refresh fields from + the snapshot taken by _apply_mental_model_refresh_llm to avoid leaking + settings across rebuilds. """ - global _cerebral, _cerebral_built + global _cerebral, _cerebral_built, _mental_model_refresh_snapshot + if _cerebral is not None: try: _cerebral.close() @@ -832,3 +974,16 @@ def reset_cerebral() -> None: pass _cerebral = None _cerebral_built = False + + # Restore raw config snapshot if present + if _mental_model_refresh_snapshot: + try: + import hindsight_api.config as ha_cfg + + raw = ha_cfg._get_raw_config() + for field, value in _mental_model_refresh_snapshot.items(): + if hasattr(raw, field): + setattr(raw, field, value) + except Exception: + pass + _mental_model_refresh_snapshot = None diff --git a/tests/test_config.py b/tests/test_config.py index ef4299a..42f4253 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -265,3 +265,139 @@ def test_get_max_llm_calls_for_memory_modules(self, monkeypatch, tmp_path): assert settings.get_max_llm_calls("memory_cartographer") == 15 monkeypatch.delenv("CODESPY_CONFIG", raising=False) + + def test_get_llm_config_for_memory_consolidation(self, monkeypatch, tmp_path): + """get_llm_config('memory_consolidation') reads memory.cerebral.consolidation with fallback.""" + from codespy.config_memory import MEMORY_CONSOLIDATION + + monkeypatch.chdir(tmp_path) + + # Test with explicit consolidation model + config_file = tmp_path / "codespy.yaml" + config_file.write_text(""" +llm: + default_model: openai/gpt-4o +memory: + cerebral: + retain: + model: anthropic/claude-3-opus + consolidation: + model: openai/gpt-4o-mini +""") + monkeypatch.setenv("CODESPY_CONFIG", str(config_file)) + + settings = Settings() + llm_config = settings.get_llm_config(MEMORY_CONSOLIDATION) + assert llm_config.model == "openai/gpt-4o-mini" + + monkeypatch.delenv("CODESPY_CONFIG", raising=False) + + def test_get_llm_config_for_memory_consolidation_fallback(self, monkeypatch, tmp_path): + """get_llm_config('memory_consolidation') falls back to retain model → default.""" + from codespy.config_memory import MEMORY_CONSOLIDATION + + monkeypatch.chdir(tmp_path) + + # Test fallback to retain model + config_file = tmp_path / "codespy.yaml" + config_file.write_text(""" +llm: + default_model: openai/gpt-4o +memory: + cerebral: + retain: + model: anthropic/claude-3-opus + consolidation: + model: null +""") + monkeypatch.setenv("CODESPY_CONFIG", str(config_file)) + + settings = Settings() + llm_config = settings.get_llm_config(MEMORY_CONSOLIDATION) + assert llm_config.model == "anthropic/claude-3-opus" + + monkeypatch.delenv("CODESPY_CONFIG", raising=False) + + def test_get_llm_config_for_memory_consolidation_fallback_to_default(self, monkeypatch, tmp_path): + """get_llm_config('memory_consolidation') falls back to default when retain is also null.""" + from codespy.config_memory import MEMORY_CONSOLIDATION + + monkeypatch.chdir(tmp_path) + + # Test fallback to default when both consolidation and retain are null + config_file = tmp_path / "codespy.yaml" + config_file.write_text(""" +llm: + default_model: openai/gpt-4o-mini +memory: + cerebral: + retain: + model: null + consolidation: + model: null +""") + monkeypatch.setenv("CODESPY_CONFIG", str(config_file)) + + settings = Settings() + llm_config = settings.get_llm_config(MEMORY_CONSOLIDATION) + assert llm_config.model == "openai/gpt-4o-mini" + + monkeypatch.delenv("CODESPY_CONFIG", raising=False) + + def test_get_llm_config_for_memory_mental_models(self, monkeypatch, tmp_path): + """get_llm_config('memory_mental_models') reads memory.cerebral.mental_models with fallback.""" + from codespy.config_memory import MEMORY_MENTAL_MODELS + + monkeypatch.chdir(tmp_path) + + # Test with explicit mental_models model + config_file = tmp_path / "codespy.yaml" + config_file.write_text(""" +llm: + default_model: openai/gpt-4o +memory: + cerebral: + retain: + model: anthropic/claude-3-opus + mental_models: + model: anthropic/claude-3-haiku + prefrontal: + recall: + model: openai/gpt-4o-mini +""") + monkeypatch.setenv("CODESPY_CONFIG", str(config_file)) + + settings = Settings() + llm_config = settings.get_llm_config(MEMORY_MENTAL_MODELS) + assert llm_config.model == "anthropic/claude-3-haiku" + + monkeypatch.delenv("CODESPY_CONFIG", raising=False) + + def test_get_llm_config_for_memory_mental_models_fallback(self, monkeypatch, tmp_path): + """get_llm_config('memory_mental_models') falls back to prefrontal.recall → retain → default.""" + from codespy.config_memory import MEMORY_MENTAL_MODELS + + monkeypatch.chdir(tmp_path) + + # Test fallback chain + config_file = tmp_path / "codespy.yaml" + config_file.write_text(""" +llm: + default_model: openai/gpt-4o +memory: + cerebral: + retain: + model: anthropic/claude-3-opus + mental_models: + model: null + prefrontal: + recall: + model: openai/gpt-4o-mini +""") + monkeypatch.setenv("CODESPY_CONFIG", str(config_file)) + + settings = Settings() + llm_config = settings.get_llm_config(MEMORY_MENTAL_MODELS) + assert llm_config.model == "openai/gpt-4o-mini" + + monkeypatch.delenv("CODESPY_CONFIG", raising=False) diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index bd9693c..4332a10 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -8,7 +8,9 @@ from codespy.config_memory import ( EMBEDDING_MODELS, + CerebralConsolidationConfig, CerebralRetainConfig, + MentalModelsConfig, PostgresConfig, _apply_hindsight_llm_call_defaults, _cerebral_litellm_params, @@ -258,26 +260,24 @@ class TestPrefrontalConfig: """Tests for memory.prefrontal config, env mapping and get_cerebral wiring.""" ENV = { - "MEMORY_PREFRONTAL_MODEL": ("model", "openai/gpt-4o-mini"), - "MEMORY_PREFRONTAL_REACH": ("prefrontal_reach", "bank"), - "MEMORY_PREFRONTAL_REFLECTS": ("reflects", "3"), - "MEMORY_MAX_MENTAL_MODEL_TOKENS": ("max_mental_model_tokens", "1000"), - "MEMORY_MAX_PREFRONTAL_TOKENS": ("max_prefrontal_tokens", "2000"), - "MEMORY_MAX_PREFRONTAL_TOOL_TOKENS": ("max_prefrontal_tool_tokens", "700"), - "MEMORY_MAX_PREFRONTAL_TOOL_CALLS": ("max_prefrontal_tool_calls", "2"), + "MEMORY_PREFRONTAL_REFLECTS": ("reflects", "5"), + "MEMORY_RECALL_MODEL": ("model", "openai/gpt-4o-mini"), + "MEMORY_RECALL_REACH": ("reach", "bank"), + "MEMORY_RECALL_MAX_TOKENS": ("max_tokens", "2000"), + "MEMORY_RECALL_MAX_TOOL_TOKENS": ("max_tool_tokens", "700"), + "MEMORY_RECALL_MAX_TOOL_CALLS": ("max_tool_calls", "2"), } def test_defaults(self): from codespy.config_memory import MemoryConfig pf = MemoryConfig().prefrontal - assert pf.model is None - assert pf.prefrontal_reach == "org" assert pf.reflects == 3 # Changed from 5 to 3 - assert pf.max_mental_model_tokens == 2048 - assert pf.max_prefrontal_tokens == 8192 # Changed from 16384 to 8192 - assert pf.max_prefrontal_tool_tokens == 2048 - assert pf.max_prefrontal_tool_calls == 0 + assert pf.recall.model is None + assert pf.recall.reach == "org" + assert pf.recall.max_tokens == 8192 + assert pf.recall.max_tool_tokens == 2048 + assert pf.recall.max_tool_calls == 0 def test_env_mapping(self, monkeypatch): from codespy.config import _ENV_MAP @@ -287,13 +287,23 @@ def test_env_mapping(self, monkeypatch): for env, (_, value) in self.ENV.items(): monkeypatch.setenv(env, value) result = apply_env_overrides({}, _ENV_MAP)["memory"]["prefrontal"] - for env, (field, _) in self.ENV.items(): - assert _ENV_MAP[env] == ("memory", "prefrontal", field) - assert field in result + + # Verify env mapping for reflects + assert _ENV_MAP["MEMORY_PREFRONTAL_REFLECTS"] == ("memory", "prefrontal", "reflects") + assert "reflects" in result + + # Verify env mapping for recall settings + assert _ENV_MAP["MEMORY_RECALL_MODEL"] == ("memory", "prefrontal", "recall", "model") + assert _ENV_MAP["MEMORY_RECALL_REACH"] == ("memory", "prefrontal", "recall", "reach") + assert _ENV_MAP["MEMORY_RECALL_MAX_TOKENS"] == ("memory", "prefrontal", "recall", "max_tokens") + assert "recall" in result + assert result["recall"]["model"] == "openai/gpt-4o-mini" + assert result["recall"]["reach"] == "bank" + cfg = PrefrontalConfig(**result) - assert cfg.prefrontal_reach == "bank" - assert cfg.reflects == 3 - assert cfg.max_prefrontal_tool_calls == 2 + assert cfg.recall.reach == "bank" + assert cfg.reflects == 5 + assert cfg.recall.max_tool_calls == 2 def test_rejects_negative_reflects(self): from codespy.config_memory import PrefrontalConfig @@ -301,12 +311,6 @@ def test_rejects_negative_reflects(self): with pytest.raises(ValidationError): PrefrontalConfig(reflects=-1) - def test_rejects_invalid_reach(self): - from codespy.config_memory import PrefrontalConfig - - with pytest.raises(ValidationError): - PrefrontalConfig(prefrontal_reach="world") - def test_reflection_modules_unchanged(self): from codespy.config_memory import REFLECTION_MODULES @@ -584,7 +588,7 @@ class TestPrefrontalModelResolution: override them. """ - def _settings(self, *, prefrontal=None, retain=None): + def _settings(self, *, recall_model=None, retain=None): from codespy.config import Settings from codespy.config_llm import LLMConfig from codespy.config_memory import ( @@ -592,20 +596,21 @@ def _settings(self, *, prefrontal=None, retain=None): CerebralRetainConfig, MemoryConfig, PrefrontalConfig, + RecallConfig, ) return Settings( llm=LLMConfig(default_model="openai/gpt-4o"), memory=MemoryConfig( cerebral=CerebralConfig(retain=CerebralRetainConfig(model=retain)), - prefrontal=PrefrontalConfig(model=prefrontal), + prefrontal=PrefrontalConfig(recall=RecallConfig(model=recall_model)), ), ) - def test_explicit_prefrontal_model(self): + def test_explicit_recall_model(self): from codespy.config_memory import MEMORY_PREFRONTAL - s = self._settings(prefrontal="anthropic/claude-haiku", retain="openai/gpt-4o-mini") + s = self._settings(recall_model="anthropic/claude-haiku", retain="openai/gpt-4o-mini") assert s.get_llm_config(MEMORY_PREFRONTAL).model == "anthropic/claude-haiku" def test_falls_back_to_retain_model(self): @@ -622,3 +627,155 @@ def test_falls_back_to_default_model(self): # Note: _warn_reflect_mismatch removed - now using programmatic _apply_reflect_config # which sets values directly instead of warning about mismatches + + +class TestConsolidationConfig: + """Tests for memory.cerebral.consolidation config and env mapping.""" + + def test_defaults(self): + from codespy.config_memory import CerebralConfig + + cfg = CerebralConfig().consolidation + assert cfg.model is None + + def test_env_mapping(self, monkeypatch): + from codespy.config import _ENV_MAP + from codespy.config_memory import CerebralConfig + from codespy.config_utils import apply_env_overrides + + monkeypatch.setenv("MEMORY_CONSOLIDATION_MODEL", "openai/gpt-4o-mini") + result = apply_env_overrides({}, _ENV_MAP)["memory"]["cerebral"]["consolidation"] + assert _ENV_MAP["MEMORY_CONSOLIDATION_MODEL"] == ("memory", "cerebral", "consolidation", "model") + assert result["model"] == "openai/gpt-4o-mini" + cfg = CerebralConsolidationConfig(**result) + assert cfg.model == "openai/gpt-4o-mini" + + +class TestRecallConfig: + """Tests for memory.prefrontal.recall config and env mapping.""" + + def test_defaults(self): + from codespy.config_memory import RecallConfig + + cfg = RecallConfig() + assert cfg.model is None + assert cfg.reach == "org" + assert cfg.max_tokens == 8192 + assert cfg.max_tool_tokens == 2048 + assert cfg.max_tool_calls == 0 + + def test_env_mapping_model(self, monkeypatch): + from codespy.config import _ENV_MAP + from codespy.config_memory import RecallConfig + from codespy.config_utils import apply_env_overrides + + monkeypatch.setenv("MEMORY_RECALL_MODEL", "openai/gpt-4o") + result = apply_env_overrides({}, _ENV_MAP)["memory"]["prefrontal"]["recall"] + assert _ENV_MAP["MEMORY_RECALL_MODEL"] == ("memory", "prefrontal", "recall", "model") + assert result["model"] == "openai/gpt-4o" + cfg = RecallConfig(**result) + assert cfg.model == "openai/gpt-4o" + + def test_env_mapping_reach(self, monkeypatch): + from codespy.config import _ENV_MAP + from codespy.config_memory import RecallConfig + from codespy.config_utils import apply_env_overrides + + monkeypatch.setenv("MEMORY_RECALL_REACH", "bank") + result = apply_env_overrides({}, _ENV_MAP)["memory"]["prefrontal"]["recall"] + assert _ENV_MAP["MEMORY_RECALL_REACH"] == ("memory", "prefrontal", "recall", "reach") + assert result["reach"] == "bank" + cfg = RecallConfig(**result) + assert cfg.reach == "bank" + + def test_env_mapping_max_tokens(self, monkeypatch): + from codespy.config import _ENV_MAP + from codespy.config_memory import RecallConfig + from codespy.config_utils import apply_env_overrides + + monkeypatch.setenv("MEMORY_RECALL_MAX_TOKENS", "4096") + result = apply_env_overrides({}, _ENV_MAP)["memory"]["prefrontal"]["recall"] + assert _ENV_MAP["MEMORY_RECALL_MAX_TOKENS"] == ("memory", "prefrontal", "recall", "max_tokens") + assert result["max_tokens"] == "4096" + cfg = RecallConfig(**result) + assert cfg.max_tokens == 4096 + + def test_rejects_invalid_reach(self): + from codespy.config_memory import RecallConfig + from pydantic import ValidationError + + with pytest.raises(ValidationError): + RecallConfig(reach="world") + + +class TestMentalModelsConfig: + """Tests for memory.cerebral.mental_models config and env mapping.""" + + def test_defaults(self): + from codespy.config_memory import CerebralConfig + + cfg = CerebralConfig().mental_models + assert cfg.model is None + assert cfg.max_tokens == 2048 + assert cfg.min_refresh_seconds == 0 + + def test_env_mapping(self, monkeypatch): + from codespy.config import _ENV_MAP + from codespy.config_memory import CerebralConfig + from codespy.config_utils import apply_env_overrides + + monkeypatch.setenv("MEMORY_MENTAL_MODELS_MODEL", "anthropic/claude-3-haiku") + result = apply_env_overrides({}, _ENV_MAP)["memory"]["cerebral"]["mental_models"] + assert _ENV_MAP["MEMORY_MENTAL_MODELS_MODEL"] == ("memory", "cerebral", "mental_models", "model") + assert result["model"] == "anthropic/claude-3-haiku" + cfg = MentalModelsConfig(**result) + assert cfg.model == "anthropic/claude-3-haiku" + + def test_env_mapping_max_tokens(self, monkeypatch): + from codespy.config import _ENV_MAP + from codespy.config_memory import CerebralConfig + from codespy.config_utils import apply_env_overrides + + monkeypatch.setenv("MEMORY_MENTAL_MODELS_MAX_TOKENS", "1024") + result = apply_env_overrides({}, _ENV_MAP)["memory"]["cerebral"]["mental_models"] + assert _ENV_MAP["MEMORY_MENTAL_MODELS_MAX_TOKENS"] == ("memory", "cerebral", "mental_models", "max_tokens") + assert result["max_tokens"] == "1024" + cfg = MentalModelsConfig(**result) + assert cfg.max_tokens == 1024 + + def test_env_mapping_min_refresh_seconds(self, monkeypatch): + from codespy.config import _ENV_MAP + from codespy.config_memory import CerebralConfig + from codespy.config_utils import apply_env_overrides + + monkeypatch.setenv("MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS", "60") + result = apply_env_overrides({}, _ENV_MAP)["memory"]["cerebral"]["mental_models"] + assert _ENV_MAP["MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS"] == ("memory", "cerebral", "mental_models", "min_refresh_seconds") + assert result["min_refresh_seconds"] == "60" + cfg = MentalModelsConfig(**result) + assert cfg.min_refresh_seconds == 60 + + def test_rejects_zero_max_tokens(self): + from codespy.config_memory import MentalModelsConfig + + with pytest.raises(ValidationError): + MentalModelsConfig(max_tokens=0) + + def test_rejects_negative_max_tokens(self): + from codespy.config_memory import MentalModelsConfig + + with pytest.raises(ValidationError): + MentalModelsConfig(max_tokens=-1) + + def test_rejects_negative_min_refresh_seconds(self): + from codespy.config_memory import MentalModelsConfig + + with pytest.raises(ValidationError): + MentalModelsConfig(min_refresh_seconds=-1) + + def test_old_env_vars_not_in_env_map(self): + """Old mental_models env vars should not be in _ENV_MAP.""" + from codespy.config import _ENV_MAP + + assert "MEMORY_MAX_MENTAL_MODEL_TOKENS" not in _ENV_MAP + assert "MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS" not in _ENV_MAP From 4a1605c3e8181bd4e18fc5bd7e0f7805976ed431 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 2 Oct 2026 08:46:41 +0200 Subject: [PATCH 28/39] recall --- .env.example | 1 + CHANGELOG.md | 7 + action.yml | 7 + codespy.yaml | 1 + docs/configuration.md | 1 + docs/memory.md | 20 +- .../agents/memory/cerebral/cerebral.py | 110 +++++++--- src/codespy/agents/review/audit/agent.py | 6 +- .../agents/review/code_review/agent.py | 2 +- src/codespy/agents/review/doc/agent.py | 2 +- src/codespy/agents/review/summary/agent.py | 2 +- .../agents/review/supply_chain/agent.py | 2 +- src/codespy/config_memory.py | 4 + tests/test_cerebral.py | 128 +++++++++++- tests/test_config_memory.py | 42 ++++ tests/test_prefrontal_signature.py | 196 ++++++++++++++++++ 16 files changed, 474 insertions(+), 57 deletions(-) create mode 100644 tests/test_prefrontal_signature.py diff --git a/.env.example b/.env.example index 2b06979..a318288 100644 --- a/.env.example +++ b/.env.example @@ -220,6 +220,7 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # MEMORY_EMBEDDINGS_MODEL= # Auto-derived from provider # MEMORY_EMBEDDINGS_MAX_INPUT_CHARS= # null = auto per model (2048 for Bedrock Cohere v3), 0 = off # MEMORY_RETAIN_CHUNK_SIZE=12288 # Chars per extraction chunk (< 64000) +# MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE=100 # Per-scope observation cap (-1 = unlimited, 0 = no new observations) # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cd21b6..340de90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ ## [Unreleased] +### Added +- **Cerebral per-scope observation cap**: New `max_observations_per_scope` setting under `memory.cerebral.consolidation` (env `MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE`, default `100`). Limits observations per `[org:*, repo:*, project_scope:*]` scope; `-1` = unlimited, `0` = no new observations. Scopes that hit the cap only allow UPDATE/DELETE. + ### Changed +- **Cerebral tags**: Removed `episode:` and `run_id:` tags — they were not used for scoping or reach and bloated the tag set. `pull_request:` tags are also no longer added. Retained items now only carry `project_scope:`, `repo:`, `org:`, and `task:` tags. +- **Cerebral retain framing**: Moved from `context` to `metadata`. The `context` field is no longer set on retain items; instead `metadata` carries `task`, `repo` (when known), `kind` (`"observation changes"` / `"artifacts"`), and `question`. This reduces consolidation input size (context was repeated for every fact). +- **Cerebral fallback scope**: Episodes without a `project_scope` topic now consolidate into `[org:, repo:, project_scope:]` (the repo-root project scope) instead of `[org:, repo:]`. This ensures every write scope matches the observation cap rule and creates/updates the repo-root briefing. - **Mental-model configuration moved to `memory.cerebral.mental_models`**: Mental-model settings (`model`, `max_tokens`, `min_refresh_seconds`) now live under `memory.cerebral.mental_models` instead of `memory.prefrontal`. This better reflects that mental models are produced by Cerebral (briefing refresh after consolidation), not Prefrontal (read-only). Old env vars `MEMORY_MAX_MENTAL_MODEL_TOKENS` and `MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS` are removed (no aliases, as they were unreleased). New env vars: `MEMORY_MENTAL_MODELS_MAX_TOKENS`, `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS`. ### Added @@ -34,6 +40,7 @@ - **Cost tracking for extraction LM**: `TwoStepAdapter` extraction calls are now counted and attributed to the calling signature. Previously these were missing from cost tracking. ### Fixed +- **`Missing required inputs: ['prefrontal_memory']` regression**: Fixed a bug where agents failed with `dspy.RLM._validate_inputs` error when the Prefrontal instance existed but the shared run-level `prefrontal_memory` text was empty (fresh DB). The signature is now chosen based on whether there is text to pass, not on the presence of the Prefrontal instance. Affects: summary, code_review, doc, supply_chain, audit. - Cerebral consolidation and retain LLM calls now use `llm.timeout` / `llm.retries`. Previously only reflect did, so consolidation timed out at Hindsight's 120 s default. - Cerebral consolidation no longer logs `relation "webhooks" does not exist`. Inline (`SyncTaskBackend`) tasks now carry the `semantic` schema, so consolidation completion no longer rolls back and falls back. - Cerebral consolidation no longer fails at exit with `cannot schedule new futures after shutdown`. Background episode saves now finish before Python shuts down its thread pools, and the pipeline waits for them without the old 120 s cap. Runs can end later, because posting the review now waits for the consolidation to finish. diff --git a/action.yml b/action.yml index 3e72330..37ee398 100644 --- a/action.yml +++ b/action.yml @@ -381,6 +381,11 @@ inputs: description: 'Model for Cerebral consolidation (falls back to memory-retain-model / model)' required: false + memory-consolidation-max-observations-per-scope: + description: 'Per-scope observation cap (-1 = unlimited, 0 = no new observations)' + required: false + default: '100' + memory-mental-models-model: description: 'Model for mental-model briefing refresh (falls back to memory-prefrontal-model / memory-retain-model / model)' required: false @@ -606,6 +611,7 @@ runs: MEMORY_CARTOGRAPHER_MAX_LLM_CALLS: ${{ inputs.memory-cartographer-max-llm-calls }} MEMORY_RETAIN_MODEL: ${{ inputs.memory-retain-model }} MEMORY_CONSOLIDATION_MODEL: ${{ inputs.memory-consolidation-model }} + MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE: ${{ inputs.memory-consolidation-max-observations-per-scope }} MEMORY_MENTAL_MODELS_MODEL: ${{ inputs.memory-mental-models-model }} MEMORY_PREFRONTAL_REFLECTS: ${{ inputs.memory-prefrontal-reflects }} MEMORY_RECALL_MODEL: ${{ inputs.memory-recall-model }} @@ -733,6 +739,7 @@ runs: [ -n "$MEMORY_CARTOGRAPHER_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CARTOGRAPHER_MAX_LLM_CALLS" [ -n "$MEMORY_RETAIN_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RETAIN_MODEL" [ -n "$MEMORY_CONSOLIDATION_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CONSOLIDATION_MODEL" + [ -n "$MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE" [ -n "$MEMORY_MENTAL_MODELS_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_MENTAL_MODELS_MODEL" [ -n "$MEMORY_PREFRONTAL_REFLECTS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_PREFRONTAL_REFLECTS" [ -n "$MEMORY_RECALL_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RECALL_MODEL" diff --git a/codespy.yaml b/codespy.yaml index 7fcf784..dff813b 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -141,6 +141,7 @@ memory: chunk_size: 12288 # MEMORY_RETAIN_CHUNK_SIZE (chars, must be < 64000) consolidation: model: null # MEMORY_CONSOLIDATION_MODEL (falls back to retain model → default) + max_observations_per_scope: 100 # MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE (-1 = unlimited, 0 = no new observations) mental_models: model: null # MEMORY_MENTAL_MODELS_MODEL (briefing refresh; falls back to prefrontal model → retain → default) max_tokens: 2048 # MEMORY_MENTAL_MODELS_MAX_TOKENS (briefing length) diff --git a/docs/configuration.md b/docs/configuration.md index 51b6185..deb95e6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -202,6 +202,7 @@ LLM provider and credentials are auto-derived from the model string and existing |---------|---------|---------|-------------| | Retain Model | `MEMORY_RETAIN_MODEL` | `DEFAULT_MODEL` | Model for fact extraction | | Consolidation Model | `MEMORY_CONSOLIDATION_MODEL` | `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for consolidation | +| Consolidation Observation Cap | `MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE` | `100` | Per-scope observation limit (-1 = unlimited, 0 = no new observations) | | Mental-Models Model | `MEMORY_MENTAL_MODELS_MODEL` | `MEMORY_PREFRONTAL_MODEL` → `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for briefing refresh | | Mental-Models Max Tokens | `MEMORY_MENTAL_MODELS_MAX_TOKENS` | `2048` | Briefing size | | Mental-Models Min Refresh Seconds | `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS` | `0` | Minimum seconds between refreshes (0 = after every consolidation) | diff --git a/docs/memory.md b/docs/memory.md index 4906f58..099c71d 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -249,15 +249,23 @@ trajectory ──► Hippocampus (Distiller → Cartographer) ──► episodic ### Tags and observation scopes -Every retained item carries its topic tags (`project_scope:`, `pull_request:`), -`episode:`, `task:`, `run_id:`, and — new — `repo:` and `org:`. +Every retained item carries its `project_scope:` topic tags, `task:`, and +`repo:`/`org:`. `pull_request:`, `episode:`, and `run_id:` tags are +no longer added — they were not used for scoping or reach and bloated the tag set. Consolidation uses an explicit `observation_scopes` list: one `[org:, repo:, project_scope:]` scope per `project_scope` topic of the episode, or -`[org:, repo:]` when there is none. `task:`, `episode:`, `run_id:` and -`pull_request:` never enter a scope, so facts about the same scope merge across tasks -and runs, and a correction made by one task reaches all of them. Task relevance is -handled at read time (the task-specific query ranks; raw facts keep their `task:` label). +`[org:, repo:, project_scope:]` (the repo-root scope) when there is none. +The repo-root scope matches what `make_topic_id(repo, ".")` returns, so episodes without +a project scope consolidate into the same scope as a root-level PR. `task:` never +enters a scope, so facts about the same scope merge across tasks and runs, and a +correction made by one task reaches all of them. Task relevance is handled at read time +(the task-specific query ranks; raw facts keep their `task:` label). + +**Per-scope observation cap**: Hindsight supports `observation_scope_limits` to cap +observations per scope. codespy sets one rule: `[org:*, repo:*, project_scope:*]` +with limit `max_observations_per_scope` (default 100, -1 = unlimited, 0 = no new +observations). Scopes that hit the cap only allow UPDATE/DELETE; nothing is trimmed. Cerebral runs Hindsight with `SyncTaskBackend`: consolidation and mental-model refreshes run inline inside `retain_episode`, on the background save thread. (Previously diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index 9f1d341..ab4b12c 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -220,7 +220,9 @@ async def _mark_cancelled_if_pending(self, task: dict, exc: DeferOperation) -> N ) from codespy.agents.memory.cerebral.routines import ensure_maintenance_routines from codespy.agents.memory.prefrontal.reach import ( + TAG_ORG, TAG_PROJECT_SCOPE, + TAG_REPO, mental_model_id, repo_tags, scope_tags, @@ -329,6 +331,7 @@ def __init__( consolidation_llm_base_url: str | None = None, embeddings_max_input_chars: int | None = None, min_mental_model_refresh_seconds: int = 0, + max_observations_per_scope: int = -1, ): # Fail fast: test the embedding model before building MemoryEngine. # A bad model name, missing creds, or unavailable region surfaces here @@ -441,6 +444,7 @@ def __init__( self._mental_models = mental_models self._max_mental_model_tokens = max_mental_model_tokens self._min_mental_model_refresh_seconds = min_mental_model_refresh_seconds + self._max_observations_per_scope = max_observations_per_scope # Mental-model ids already ensured by this process. self._ensured_mental_models: set[str] = set() # Lock for consolidation serialization @@ -501,36 +505,45 @@ def _ensure_bank(self) -> None: logger.debug("cerebral: bank %s profile ensure failed", self._bank_id) try: + updates: dict[str, Any] = { + "retain_extraction_mode": "concise", + # See CerebralRetainConfig.chunk_size for constraint details. + "retain_chunk_size": self._retain_chunk_size, + "retain_mission": ( + "Retain durable knowledge learned while working on a task, whatever the domain. " + "Focus on how the subject is structured, the entities involved and how they relate, " + "domain rules, constraints and constants, procedures that proved effective, " + "data formats, and findings or results worth reusing later. " + "Each line starts with a [section] label naming the kind of knowledge, " + "or [artifact:name] for a produced output. " + "State each fact about the subject itself, keep names, identifiers, quantities " + "and references exact, and skip transient process notes that will not hold beyond this run.\n" + "Observation lines use these markers:\n" + "- A plain line is a newly learned fact.\n" + "- '(supersedes: X)': the line is the current wording of a fact previously worded X. " + "Update that fact; do not keep both.\n" + "- 'RETRACTED — shown incorrect or misleading': the fact was proven wrong or misled " + "the agent. Mark it invalid and do not rely on it.\n" + "A fact missing from a batch is unchanged, not outdated. Only RETRACTED invalidates a fact." + ), + "observations_mission": OBSERVATIONS_MISSION, + # Disable auto-consolidation; Cerebral triggers it explicitly + # after retain with proper scope scoping (prevents unscoped + # pending rows from blocking scoped submissions). + "enable_auto_consolidation": False, + } + # Per-scope observation cap (not bank-wide; -1 means unlimited) + if self._max_observations_per_scope >= 0: + updates["observation_scope_limits"] = [ + { + "scope": [f"{TAG_ORG}*", f"{TAG_REPO}*", f"{TAG_PROJECT_SCOPE}*"], + "limit": self._max_observations_per_scope, + } + ] self._run_async( self._engine.update_bank_config( self._bank_id, - updates={ - "retain_extraction_mode": "concise", - # See CerebralRetainConfig.chunk_size for constraint details. - "retain_chunk_size": self._retain_chunk_size, - "retain_mission": ( - "Retain durable knowledge learned while working on a task, whatever the domain. " - "Focus on how the subject is structured, the entities involved and how they relate, " - "domain rules, constraints and constants, procedures that proved effective, " - "data formats, and findings or results worth reusing later. " - "Each line starts with a [section] label naming the kind of knowledge, " - "or [artifact:name] for a produced output. " - "State each fact about the subject itself, keep names, identifiers, quantities " - "and references exact, and skip transient process notes that will not hold beyond this run.\n" - "Observation lines use these markers:\n" - "- A plain line is a newly learned fact.\n" - "- '(supersedes: X)': the line is the current wording of a fact previously worded X. " - "Update that fact; do not keep both.\n" - "- 'RETRACTED — shown incorrect or misleading': the fact was proven wrong or misled " - "the agent. Mark it invalid and do not rely on it.\n" - "A fact missing from a batch is unchanged, not outdated. Only RETRACTED invalidates a fact." - ), - "observations_mission": OBSERVATIONS_MISSION, - # Disable auto-consolidation; Cerebral triggers it explicitly - # after retain with proper scope scoping (prevents unscoped - # pending rows from blocking scoped submissions). - "enable_auto_consolidation": False, - }, + updates=updates, request_context=ctx, ) ) @@ -631,6 +644,27 @@ def _mutation_lines(episode: Episode) -> list[str]: return lines + @staticmethod + def _retain_metadata(episode: Episode, repo_full_name: str | None, kind: str) -> dict[str, str]: + """Build metadata dict for a retain item. + + Args: + episode: The episode being retained. + repo_full_name: Full repo name (owner/repo) or None. + kind: "observation changes" or "artifacts". + + Returns: + Dict with task, repo (when known), kind, and question (episode.question). + """ + meta: dict[str, str] = { + "task": episode.task, + "kind": kind, + "question": episode.question, + } + if repo_full_name: + meta["repo"] = repo_full_name + return meta + def retain_episode(self, episode: Episode, repo_full_name: str | None = None) -> None: """Retain episode mutations and artifacts in Hindsight semantic memory. @@ -661,7 +695,7 @@ def retain_episode(self, episode: Episode, repo_full_name: str | None = None) -> if obs_lines: contents.append({ "content": "\n\n".join(obs_lines), - "context": f"{episode.task}: {episode.question}: observation changes", + "metadata": self._retain_metadata(episode, repo_full_name, "observation changes"), "tags": tags, "document_id": episode_doc_id, "event_date": episode.timestamp.isoformat(), @@ -674,7 +708,7 @@ def retain_episode(self, episode: Episode, repo_full_name: str | None = None) -> if artifact_lines: contents.append({ "content": "\n\n".join(artifact_lines), - "context": f"{episode.task}: {episode.question}: artifacts", + "metadata": self._retain_metadata(episode, repo_full_name, "artifacts"), "tags": tags, "document_id": episode_doc_id, "event_date": episode.timestamp.isoformat(), @@ -735,7 +769,9 @@ def _observation_scopes(episode: Episode, repo_full_name: str | None) -> list[li """Observation scopes for consolidation, or None without a repo (Hindsight default). One ``[org:, repo:, project_scope:]`` scope per ``project_scope`` topic, - or ``[org:, repo:]`` when the episode has none. + or ``[org:, repo:, project_scope:]`` (repo-root scope) when the + episode has none. The repo-root scope is what ``make_topic_id(repo, '.')`` + returns, so it matches the scope a root-level PR would use. """ if not repo_full_name: return None @@ -744,7 +780,8 @@ def _observation_scopes(episode: Episode, repo_full_name: str | None) -> list[li for topic in episode.context_memory.topics if topic.type == "project_scope" ] - return scopes or [repo_tags(repo_full_name)] + # Fallback to repo-root project scope instead of bare [org:, repo:] + return scopes or [scope_tags(repo_full_name, repo_full_name)] @staticmethod def _mental_model_scopes( @@ -1128,12 +1165,17 @@ async def aget_observation_history(self, memory_id: str) -> list[dict]: @staticmethod def _build_tags(episode: Episode, repo_full_name: str | None = None) -> list[str]: + """Build tags for retain items. + + Includes project_scope topics and repo/org tags. Excludes episode:, + run_id:, and pull_request: tags — they are not used for scoping or + reach and bloat the tag set. + """ tags: list[str] = [] for topic in episode.context_memory.topics: - tags.append(f"{topic.type}:{topic.id}") + if topic.type == "project_scope": + tags.append(f"{topic.type}:{topic.id}") if repo_full_name: tags.extend(repo_tags(repo_full_name)) - tags.append(f"episode:{episode.id}") tags.append(f"task:{episode.task}") - tags.append(f"run_id:{episode.run_id}") return tags diff --git a/src/codespy/agents/review/audit/agent.py b/src/codespy/agents/review/audit/agent.py index eedee80..a37e1eb 100644 --- a/src/codespy/agents/review/audit/agent.py +++ b/src/codespy/agents/review/audit/agent.py @@ -166,6 +166,8 @@ def forward( ) repo_full_name = review_context.pr_context.repo_full_name + # Use shared run-level recall (already loaded by pipeline) + prefrontal_memory = review_context.prefrontal_memory or None # Get Prefrontal for tool calls only (prefrontal_memory is already loaded by pipeline) pf = get_prefrontal( self._settings, @@ -173,7 +175,7 @@ def forward( repo_full_name, scope_topic_ids=None, ) if self._settings.get_memory_enabled("audit") else None - sig = with_prefrontal_memory(AuditSignature) if pf else AuditSignature + sig = with_prefrontal_memory(AuditSignature) if prefrontal_memory else AuditSignature auditor = ContextSafe( dspy.ChainOfThought(sig), sig, @@ -185,8 +187,6 @@ def forward( logger.info("Running audit...") with SignatureContext("audit", self._cost_tracker): - # Use shared run-level recall (already loaded by pipeline) - prefrontal_memory = review_context.prefrontal_memory or None result, hippo, store, _artifacts = self._call_auditor( auditor, review_context, diff --git a/src/codespy/agents/review/code_review/agent.py b/src/codespy/agents/review/code_review/agent.py index 7cd865f..603cce0 100644 --- a/src/codespy/agents/review/code_review/agent.py +++ b/src/codespy/agents/review/code_review/agent.py @@ -277,7 +277,7 @@ async def _review_scope( pf = get_prefrontal( self._settings, "code_review", repo_full_name, scope_topic_ids=None ) if self._settings.get_memory_enabled("code_review") else None - sig = with_prefrontal_memory(CodeReviewSignature) if pf else CodeReviewSignature + sig = with_prefrontal_memory(CodeReviewSignature) if review_context.prefrontal_memory else CodeReviewSignature recall_tool = pf.recall_tool() if pf else None agent_tools = [*tools, recall_tool] if recall_tool else tools agent = ContextSafe( diff --git a/src/codespy/agents/review/doc/agent.py b/src/codespy/agents/review/doc/agent.py index a01684a..7b99549 100644 --- a/src/codespy/agents/review/doc/agent.py +++ b/src/codespy/agents/review/doc/agent.py @@ -206,7 +206,7 @@ async def _review_scope( pf = get_prefrontal( self._settings, "doc", repo_full_name, scope_topic_ids=None ) if self._settings.get_memory_enabled("doc") else None - sig = with_prefrontal_memory(DocReviewSignature) if pf else DocReviewSignature + sig = with_prefrontal_memory(DocReviewSignature) if review_context.prefrontal_memory else DocReviewSignature reviewer = ContextSafe( dspy.ChainOfThought(sig), sig, diff --git a/src/codespy/agents/review/summary/agent.py b/src/codespy/agents/review/summary/agent.py index abf7a34..86b3b4a 100644 --- a/src/codespy/agents/review/summary/agent.py +++ b/src/codespy/agents/review/summary/agent.py @@ -107,7 +107,7 @@ def forward( scope_topic_ids=None, include_repo=False, ) if self._settings.get_memory_enabled("summary") else None - sig = with_prefrontal_memory(PRSummarySignature) if pf else PRSummarySignature + sig = with_prefrontal_memory(PRSummarySignature) if prefrontal_memory else PRSummarySignature summarizer = ContextSafe( dspy.ChainOfThought(sig), sig, diff --git a/src/codespy/agents/review/supply_chain/agent.py b/src/codespy/agents/review/supply_chain/agent.py index 2edc7bf..87e2516 100644 --- a/src/codespy/agents/review/supply_chain/agent.py +++ b/src/codespy/agents/review/supply_chain/agent.py @@ -377,7 +377,7 @@ async def _review_scope( ) if self._settings.get_memory_enabled("supply_chain") else None sig = ( with_prefrontal_memory(SupplyChainSecuritySignature) - if pf + if review_context.prefrontal_memory else SupplyChainSecuritySignature ) recall_tool = pf.recall_tool() if pf else None diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 504b50d..0e8f8a9 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -100,6 +100,9 @@ class CerebralConsolidationConfig(BaseModel): """ model: str | None = None # Falls back to cerebral.retain.model → llm.default_model + # Hindsight observation_scope_limits cap. -1 = unlimited, 0 = no new observations. + # Applied per scope as: scope=[org:*, repo:*, project_scope:*], limit=N + max_observations_per_scope: int = Field(default=100, ge=-1) class MentalModelsConfig(BaseModel): @@ -575,6 +578,7 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: max_mental_model_tokens=settings.memory.cerebral.mental_models.max_tokens, embeddings_max_input_chars=settings.memory.cerebral.embeddings.max_input_chars, min_mental_model_refresh_seconds=settings.memory.cerebral.mental_models.min_refresh_seconds, + max_observations_per_scope=settings.memory.cerebral.consolidation.max_observations_per_scope, **reflect_kwargs, **consolidation_kwargs, ) diff --git a/tests/test_cerebral.py b/tests/test_cerebral.py index b95ef51..c489d8b 100644 --- a/tests/test_cerebral.py +++ b/tests/test_cerebral.py @@ -364,28 +364,110 @@ def test_ensure_bank_calls_profile_and_config(self, mock_memory_engine_class, mo # Should call ensure_bank_profile and update_bank_config assert mock_run_async.call_count == 2 + def test_ensure_bank_sends_observation_scope_limits_default(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that _ensure_bank sends observation_scope_limits with default cap (100).""" + captured_updates = None + + async def mock_update_bank_config(bank_id, updates, request_context): + nonlocal captured_updates + captured_updates = updates + return None + + mock_engine.update_bank_config = mock_update_bank_config + + with patch("codespy.agents.memory.cerebral.cerebral.MemoryEngine", return_value=mock_engine), \ + patch("codespy.agents.memory.cerebral.cerebral.ensure_maintenance_routines", AsyncMock()): + c = Cerebral(database_url="postgresql://x/y", llm_provider="litellm") + c._ensure_bank() + c.close() + + assert captured_updates is not None + assert "observation_scope_limits" in captured_updates + limits = captured_updates["observation_scope_limits"] + assert len(limits) == 1 + assert limits[0]["scope"] == ["org:*", "repo:*", "project_scope:*"] + assert limits[0]["limit"] == 100 # default + + def test_ensure_bank_sends_observation_scope_limits_custom(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that _ensure_bank sends observation_scope_limits with custom cap.""" + captured_updates = None + + async def mock_update_bank_config(bank_id, updates, request_context): + nonlocal captured_updates + captured_updates = updates + return None + + mock_engine.update_bank_config = mock_update_bank_config + + with patch("codespy.agents.memory.cerebral.cerebral.MemoryEngine", return_value=mock_engine), \ + patch("codespy.agents.memory.cerebral.cerebral.ensure_maintenance_routines", AsyncMock()): + c = Cerebral( + database_url="postgresql://x/y", + llm_provider="litellm", + max_observations_per_scope=50, + ) + c._ensure_bank() + c.close() + + assert captured_updates is not None + assert "observation_scope_limits" in captured_updates + limits = captured_updates["observation_scope_limits"] + assert limits[0]["limit"] == 50 + + def test_ensure_bank_no_observation_scope_limits_when_unlimited(self, mock_memory_engine_class, mock_engine, mock_litellm): + """Test that _ensure_bank skips observation_scope_limits when cap is -1 (unlimited).""" + captured_updates = None + + async def mock_update_bank_config(bank_id, updates, request_context): + nonlocal captured_updates + captured_updates = updates + return None + + mock_engine.update_bank_config = mock_update_bank_config + + with patch("codespy.agents.memory.cerebral.cerebral.MemoryEngine", return_value=mock_engine), \ + patch("codespy.agents.memory.cerebral.cerebral.ensure_maintenance_routines", AsyncMock()): + c = Cerebral( + database_url="postgresql://x/y", + llm_provider="litellm", + max_observations_per_scope=-1, + ) + c._ensure_bank() + c.close() + + assert captured_updates is not None + # Should NOT include observation_scope_limits when -1 + assert "observation_scope_limits" not in captured_updates + class TestCerebralRetainEpisode: """Tests for Cerebral retain_episode method.""" def test_retain_episode_builds_correct_tags(self, mock_memory_engine_class, episode, mock_litellm): - """Test that tags are built correctly from episode.""" + """Test that tags are built correctly from episode. + + Only project_scope topics are included; pull_request, episode, and + run_id tags are no longer added. Repo/org tags are added when repo is known. + """ with patch.object(Cerebral, "_run_async"): cerebral = Cerebral( database_url="postgresql://localhost:5432/test", llm_provider="litellm", ) - tags = cerebral._build_tags(episode) + tags = cerebral._build_tags(episode, repo_full_name="test/repo") expected_tags = [ "project_scope:test/repo/package", - "pull_request:https://github.com/test/repo/pull/1", - f"episode:{episode.id}", + "org:test", + "repo:test/repo", "task:code_review", - "run_id:test-run-123", ] assert tags == expected_tags + # episode: and run_id: should NOT be present + assert not any(t.startswith(("episode:", "run_id:")) for t in tags) + # pull_request: should NOT be present + assert not any(t.startswith("pull_request:") for t in tags) def test_retain_episode_creates_contents_for_observations(self, mock_memory_engine_class, episode, mock_engine, mock_litellm): """Test that retain_episode creates a single merged content item for all observations.""" @@ -515,9 +597,10 @@ def mock_memory_engine(*args, **kwargs): obs_item = None art_item = None for item in captured_contents: - if "observation changes" in item.get("context", ""): + # Check metadata.kind instead of context + if item.get("metadata", {}).get("kind") == "observation changes": obs_item = item - elif "artifacts" in item.get("context", ""): + elif item.get("metadata", {}).get("kind") == "artifacts": art_item = item # Verify observations item @@ -526,14 +609,25 @@ def mock_memory_engine(*args, **kwargs): assert "[actions]" in obs_item["content"], "Missing actions header" assert "This is a test observation" in obs_item["content"], "Missing observation content" assert "Performed tool call" in obs_item["content"], "Missing action content" + # Verify metadata instead of context + assert "metadata" in obs_item, "Observations should have metadata" + assert "context" not in obs_item, "Observations should not have context" + assert obs_item["metadata"]["task"] == "code_review" + assert obs_item["metadata"]["kind"] == "observation changes" + assert obs_item["metadata"]["question"] == episode.question # Verify artifacts item assert art_item is not None, "Artifacts content item not found" assert "[artifact:review]" in art_item["content"], "Missing artifact header" assert "# Review Results" in art_item["content"], "Missing artifact content" + # Verify metadata + assert "metadata" in art_item, "Artifacts should have metadata" + assert "context" not in art_item, "Artifacts should not have context" + assert art_item["metadata"]["task"] == "code_review" + assert art_item["metadata"]["kind"] == "artifacts" # Verify shared fields - expected_tags = cerebral._build_tags(episode) + expected_tags = cerebral._build_tags(episode, repo_full_name="test/repo") expected_doc_id = f"episode-{episode.id}" expected_event_date = episode.timestamp.isoformat() @@ -1111,28 +1205,42 @@ def test_tags_with_repo(self, episode): tags = Cerebral._build_tags(episode, "test/repo") assert "repo:test/repo" in tags assert "org:test" in tags - assert tags.index("repo:test/repo") < tags.index(f"episode:{episode.id}") + # episode: no longer added + assert f"episode:{episode.id}" not in tags + # pull_request: no longer added + assert "pull_request:https://github.com/test/repo/pull/1" not in tags + # task: still present + assert "task:code_review" in tags def test_tags_without_repo_unchanged(self, episode): assert Cerebral._build_tags(episode) == Cerebral._build_tags(episode, None) assert not any(t.startswith(("repo:", "org:")) for t in Cerebral._build_tags(episode)) + # episode: and run_id: should not be present + assert not any(t.startswith(("episode:", "run_id:")) for t in Cerebral._build_tags(episode)) def test_observation_scopes_per_project_scope(self, episode): scopes = Cerebral._observation_scopes(episode, "test/repo") assert scopes == [["org:test", "repo:test/repo", "project_scope:test/repo/package"]] def test_observation_scopes_without_project_scope(self): + """Fallback is now repo-root project scope, not bare [org:, repo:].""" ep = Episode( id=uuid.uuid4(), run_id="r", timestamp=datetime.now(UTC), task="summary", module="m", question="q", artifacts={}, context_memory=ContextMemory(), ) - assert Cerebral._observation_scopes(ep, "o/r") == [["org:o", "repo:o/r"]] + # Fallback is now [org:, repo:, project_scope:o/r] (repo-root scope) + assert Cerebral._observation_scopes(ep, "o/r") == [ + ["org:o", "repo:o/r", "project_scope:o/r"] + ] def test_observation_scopes_none_without_repo(self, episode): assert Cerebral._observation_scopes(episode, None) is None def test_scope_excludes_run_tags(self, episode): for scope in Cerebral._observation_scopes(episode, "test/repo"): + # Scopes should only contain org:, repo:, project_scope: + assert all(t.startswith(("org:", "repo:", "project_scope:")) for t in scope) + # task:, episode:, run_id:, pull_request: should NOT be in scope assert not any(t.startswith(("task:", "episode:", "run_id:", "pull_request:")) for t in scope) def test_retain_sets_observation_scopes_on_both_items(self, cerebral_async, async_engine, episode): diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index 4332a10..353e100 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -580,6 +580,16 @@ def test_get_cerebral_reflect_kwargs_match_cerebral_signature(self, monkeypatch) # Should bind without error inspect.signature(Cerebral.__init__).bind(None, **kwargs_with_pf) + def test_get_cerebral_passes_max_observations_per_scope(self, monkeypatch): + """Test that get_cerebral passes max_observations_per_scope to Cerebral.""" + pytest = __import__("pytest") + pytest.importorskip("hindsight_api") # Skip if hindsight_api not available + + settings = self._settings(0) + settings.memory.cerebral.consolidation.max_observations_per_scope = 42 + kwargs = self._capture_cerebral_kwargs(monkeypatch, settings) + assert kwargs["max_observations_per_scope"] == 42 + class TestPrefrontalModelResolution: """get_llm_config('memory_prefrontal') fallback chain. @@ -637,6 +647,7 @@ def test_defaults(self): cfg = CerebralConfig().consolidation assert cfg.model is None + assert cfg.max_observations_per_scope == 100 # default def test_env_mapping(self, monkeypatch): from codespy.config import _ENV_MAP @@ -650,6 +661,37 @@ def test_env_mapping(self, monkeypatch): cfg = CerebralConsolidationConfig(**result) assert cfg.model == "openai/gpt-4o-mini" + def test_max_observations_per_scope_default(self): + """Default max_observations_per_scope is 100.""" + cfg = CerebralConsolidationConfig() + assert cfg.max_observations_per_scope == 100 + + def test_max_observations_per_scope_custom(self): + """Custom max_observations_per_scope is accepted.""" + cfg = CerebralConsolidationConfig(max_observations_per_scope=50) + assert cfg.max_observations_per_scope == 50 + + def test_max_observations_per_scope_unlimited(self): + """-1 means unlimited.""" + cfg = CerebralConsolidationConfig(max_observations_per_scope=-1) + assert cfg.max_observations_per_scope == -1 + + def test_max_observations_per_scope_rejects_less_than_minus_one(self): + """Values < -1 are rejected.""" + with pytest.raises(ValidationError): + CerebralConsolidationConfig(max_observations_per_scope=-2) + + def test_max_observations_per_scope_env_mapping(self, monkeypatch): + """MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE env var works.""" + from codespy.config import _ENV_MAP + from codespy.config_utils import apply_env_overrides + + monkeypatch.setenv("MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE", "200") + result = apply_env_overrides({}, _ENV_MAP)["memory"]["cerebral"]["consolidation"] + assert result["max_observations_per_scope"] == "200" + cfg = CerebralConsolidationConfig(**result) + assert cfg.max_observations_per_scope == 200 + class TestRecallConfig: """Tests for memory.prefrontal.recall config and env mapping.""" diff --git a/tests/test_prefrontal_signature.py b/tests/test_prefrontal_signature.py new file mode 100644 index 0000000..b4d19ff --- /dev/null +++ b/tests/test_prefrontal_signature.py @@ -0,0 +1,196 @@ +"""Tests for prefrontal_memory signature handling in audit and summary agents.""" + +from unittest.mock import MagicMock, patch + +import dspy +import pytest + +from codespy.agents.review.models import PRContext, ReviewContext + + +class TestSummarizerPrefrontalMemory: + """Tests for Summarizer handling of prefrontal_memory kwarg and signature field.""" + + def test_empty_prefrontal_memory_no_kwarg(self): + """Empty prefrontal_memory: kwarg absent from call.""" + pr_context = PRContext( + repo_slug="github.com/o/r", + pr_number=1, + pr_title="t", + pr_url="https://github.com/o/r/pull/1", + summary="s", + ) + mock_settings = MagicMock() + mock_settings.is_signature_enabled.return_value = True + mock_settings.get_memory_enabled.return_value = False # Disable memory to simplify + + captured_kwargs = {} + + def capture_ctxsafe(predictor, sig, **kwargs): + # Return a callable that captures its call kwargs + def capture_call(**call_kwargs): + captured_kwargs.update(call_kwargs) + return dspy.Prediction(summary="test summary") + + return capture_call + + with patch("codespy.agents.review.summary.agent.get_settings", return_value=mock_settings): + with patch("codespy.agents.review.summary.agent.get_cost_tracker", return_value=MagicMock()): + with patch("codespy.agents.review.summary.agent.ContextSafe", side_effect=capture_ctxsafe): + from codespy.agents.review.summary.agent import Summarizer + + summarizer = Summarizer() + result = summarizer.forward( + pr_context=pr_context, + changed_file_paths=["a.py"], + patches="diff", + run_id="test-run", + scopes=None, + topics=None, + prefrontal_memory="", + ) + + assert result is not None + # Empty string means no prefrontal_memory kwarg + assert "prefrontal_memory" not in captured_kwargs + + def test_with_prefrontal_memory_kwarg_present(self): + """With prefrontal_memory: kwarg equals text.""" + pr_context = PRContext( + repo_slug="github.com/o/r", + pr_number=1, + pr_title="t", + pr_url="https://github.com/o/r/pull/1", + summary="s", + ) + mock_settings = MagicMock() + mock_settings.is_signature_enabled.return_value = True + mock_settings.get_memory_enabled.return_value = False # Disable memory to simplify + + captured_kwargs = {} + + def capture_ctxsafe(predictor, sig, **kwargs): + def capture_call(**call_kwargs): + captured_kwargs.update(call_kwargs) + return dspy.Prediction(summary="test summary") + + return capture_call + + with patch("codespy.agents.review.summary.agent.get_settings", return_value=mock_settings): + with patch("codespy.agents.review.summary.agent.get_cost_tracker", return_value=MagicMock()): + with patch("codespy.agents.review.summary.agent.ContextSafe", side_effect=capture_ctxsafe): + from codespy.agents.review.summary.agent import Summarizer + + summarizer = Summarizer() + result = summarizer.forward( + pr_context=pr_context, + changed_file_paths=["a.py"], + patches="diff", + run_id="test-run", + scopes=None, + topics=None, + prefrontal_memory="some recall text", + ) + + assert result is not None + # Verify prefrontal_memory is in call kwargs + assert "prefrontal_memory" in captured_kwargs + assert captured_kwargs["prefrontal_memory"] == "some recall text" + + +class TestAuditorPrefrontalMemory: + """Tests for Auditor handling of prefrontal_memory. + + The critical test: no UnboundLocalError when prefrontal_memory is used + before being assigned (bug fixed by moving assignment before use). + """ + + def test_empty_prefrontal_memory_no_exception(self): + """Empty prefrontal_memory: no exception raised (was UnboundLocalError before fix).""" + pr_context = PRContext( + repo_slug="github.com/o/r", + pr_number=1, + pr_title="t", + pr_url="https://github.com/o/r/pull/1", + summary="s", + ) + review_context = ReviewContext(pr_context=pr_context, prefrontal_memory="") + mock_settings = MagicMock() + mock_settings.is_signature_enabled.return_value = True + mock_settings.get_memory_enabled.return_value = False # Disable memory to simplify + + # Mock ContextSafe to return a callable that returns a Prediction with the right attrs + def capture_ctxsafe(predictor, sig, **kwargs): + def capture_call(**call_kwargs): + return dspy.Prediction(quality_assessment="ok", recommendation="APPROVE") + + return capture_call + + with patch("codespy.agents.review.audit.agent.get_settings", return_value=mock_settings): + with patch("codespy.agents.review.audit.agent.get_cost_tracker", return_value=MagicMock()): + with patch("codespy.agents.review.audit.agent.ContextSafe", side_effect=capture_ctxsafe): + with patch("codespy.agents.review.audit.agent.SignatureContext"): + with patch("codespy.agents.review.audit.agent.get_cerebral", return_value=None): + with patch("codespy.agents.review.audit.agent.get_episode_store", return_value=None): + from codespy.agents.review.audit.agent import Auditor + + auditor = Auditor() + # This would raise UnboundLocalError before the fix + result = auditor.forward( + review_context=review_context, + all_issues=[], + run_id="test-run", + scopes=None, + topics=None, + ) + + # Should return a tuple (quality_assessment, recommendation) + assert result is not None + assert len(result) == 2 + assert result[0] == "ok" + assert result[1] == "APPROVE" + + def test_with_prefrontal_memory_kwarg_present(self): + """With prefrontal_memory: kwarg passed to auditor call.""" + pr_context = PRContext( + repo_slug="github.com/o/r", + pr_number=1, + pr_title="t", + pr_url="https://github.com/o/r/pull/1", + summary="s", + ) + review_context = ReviewContext(pr_context=pr_context, prefrontal_memory="some text") + mock_settings = MagicMock() + mock_settings.is_signature_enabled.return_value = True + mock_settings.get_memory_enabled.return_value = False # Disable memory to simplify + + captured_kwargs = {} + + def capture_ctxsafe(predictor, sig, **kwargs): + def capture_call(**call_kwargs): + captured_kwargs.update(call_kwargs) + return dspy.Prediction(quality_assessment="ok", recommendation="APPROVE") + + return capture_call + + with patch("codespy.agents.review.audit.agent.get_settings", return_value=mock_settings): + with patch("codespy.agents.review.audit.agent.get_cost_tracker", return_value=MagicMock()): + with patch("codespy.agents.review.audit.agent.ContextSafe", side_effect=capture_ctxsafe): + with patch("codespy.agents.review.audit.agent.SignatureContext"): + with patch("codespy.agents.review.audit.agent.get_cerebral", return_value=None): + with patch("codespy.agents.review.audit.agent.get_episode_store", return_value=None): + from codespy.agents.review.audit.agent import Auditor + + auditor = Auditor() + result = auditor.forward( + review_context=review_context, + all_issues=[], + run_id="test-run", + scopes=None, + topics=None, + ) + + assert result is not None + # Verify prefrontal_memory is in call kwargs + assert "prefrontal_memory" in captured_kwargs + assert captured_kwargs["prefrontal_memory"] == "some text" From 88743af68d5751f592b8767f8d881bc36ffc4221 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 2 Oct 2026 11:10:15 +0200 Subject: [PATCH 29/39] recall --- CHANGELOG.md | 3 + codespy.yaml | 6 +- .../agents/memory/cerebral/cerebral.py | 42 ++ .../memory/cerebral/delta_ops_compat.py | 197 ++++++ .../memory/cerebral/structured_output.py | 611 +++++++++++++++++ src/codespy/config_memory.py | 12 + tests/test_cerebral_delta_ops.py | 346 ++++++++++ tests/test_cerebral_structured_output.py | 636 ++++++++++++++++++ 8 files changed, 1850 insertions(+), 3 deletions(-) create mode 100644 src/codespy/agents/memory/cerebral/delta_ops_compat.py create mode 100644 src/codespy/agents/memory/cerebral/structured_output.py create mode 100644 tests/test_cerebral_delta_ops.py create mode 100644 tests/test_cerebral_structured_output.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 340de90..3dc9aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- **Cerebral structured output support**: Native structured output registration for Bedrock models (e.g., Nvidia Nemotron Super) that support it but lack the flag in litellm's model map. Two-step structured output fallback for memory models litellm cannot schema-enforce, using `llm.extraction_model`. - **Cerebral per-scope observation cap**: New `max_observations_per_scope` setting under `memory.cerebral.consolidation` (env `MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE`, default `100`). Limits observations per `[org:*, repo:*, project_scope:*]` scope; `-1` = unlimited, `0` = no new observations. Scopes that hit the cap only allow UPDATE/DELETE. ### Changed @@ -40,6 +41,8 @@ - **Cost tracking for extraction LM**: `TwoStepAdapter` extraction calls are now counted and attributed to the calling signature. Previously these were missing from cost tracking. ### Fixed +- **Retain no longer fails with "all N facts returned by the LLM were unusable" on Nemotron 3 Super (Bedrock)**. litellm lacked its native structured-output flag, so it fell back to forced tool calls with string-encoded arrays. Cerebral now registers the capability and strips JSON Schema keywords Bedrock's native structured output rejects (e.g., `minimum`/`maximum`, `maxItems`). +- **Briefing (mental model) delta refreshes no longer fail with "operations must be a list, got "** (`delta_ops_failed`). Models without native structured output on Bedrock (e.g., Nvidia Nemotron) return the operations array JSON-encoded as a string; Cerebral now decodes it before Hindsight validates it. - **`Missing required inputs: ['prefrontal_memory']` regression**: Fixed a bug where agents failed with `dspy.RLM._validate_inputs` error when the Prefrontal instance existed but the shared run-level `prefrontal_memory` text was empty (fresh DB). The signature is now chosen based on whether there is text to pass, not on the presence of the Prefrontal instance. Affects: summary, code_review, doc, supply_chain, audit. - Cerebral consolidation and retain LLM calls now use `llm.timeout` / `llm.retries`. Previously only reflect did, so consolidation timed out at Hindsight's 120 s default. - Cerebral consolidation no longer logs `relation "webhooks" does not exist`. Inline (`SyncTaskBackend`) tasks now carry the `semantic` schema, so consolidation completion no longer rolls back and falls back. diff --git a/codespy.yaml b/codespy.yaml index dff813b..257c7ec 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -59,7 +59,7 @@ llm: # Default model settings default_model: anthropic/claude-opus-4-6 # DEFAULT_MODEL - extraction_model: null # EXTRACTION_MODEL (falls back to default_model) + extraction_model: null # EXTRACTION_MODEL (falls back to default_model; also used by Cerebral two-step structured output for memory models litellm cannot schema-enforce) default_max_iters: 5 # DEFAULT_MAX_ITERS default_max_llm_calls: 8 # DEFAULT_MAX_LLM_CALLS default_reasoning_effort: medium # DEFAULT_REASONING_EFFORT @@ -224,8 +224,8 @@ review: # Documentation Review doc: enabled: true # REVIEW_DOC_ENABLED - max_iters: 1 # REVIEW_DOC_MAX_ITERS - max_llm_calls: 2 # REVIEW_DOC_MAX_LLM_CALLS + max_iters: 2 # REVIEW_DOC_MAX_ITERS + max_llm_calls: 4 # REVIEW_DOC_MAX_LLM_CALLS model: null # REVIEW_DOC_MODEL reasoning_effort: null # REVIEW_DOC_REASONING_EFFORT temperature: null # REVIEW_DOC_TEMPERATURE diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index ab4b12c..3a1e247 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -61,6 +61,21 @@ from hindsight_api.models import RequestContext from hindsight_api.worker.exceptions import DeferOperation +# Install delta operations coercion shim for string-encoded operations +# (see delta_ops_compat.py for details) +from codespy.agents.memory.cerebral.delta_ops_compat import install_delta_ops_coercion + +install_delta_ops_coercion() + +# Install native structured output support and Bedrock schema sanitizer +from codespy.agents.memory.cerebral.structured_output import ( + install_bedrock_schema_sanitizer, + register_native_structured_output_overrides, +) + +register_native_structured_output_overrides() +install_bedrock_schema_sanitizer() + class _SchemaSyncTaskBackend(SyncTaskBackend): """SyncTaskBackend that tags inline tasks with Cerebral's schema. @@ -329,6 +344,9 @@ def __init__( consolidation_llm_model: str | None = None, consolidation_llm_api_key: str | None = None, consolidation_llm_base_url: str | None = None, + extraction_llm_model: str | None = None, + extraction_llm_api_key: str | None = None, + extraction_llm_base_url: str | None = None, embeddings_max_input_chars: int | None = None, min_mental_model_refresh_seconds: int = 0, max_observations_per_scope: int = -1, @@ -422,6 +440,30 @@ def __init__( # Attach engine to task backend so it can mark failed operations task_backend.attach(self._engine) + # Install two-step structured output fallback when extraction model is configured + if extraction_llm_model: + try: + from hindsight_api.engine.llm_wrapper import LLMConfig + from codespy.agents.memory.cerebral.structured_output import ( + install_two_step_structured_output, + ) + + extraction_cfg = LLMConfig( + provider="litellm", + model=extraction_llm_model, + api_key=extraction_llm_api_key, + base_url=extraction_llm_base_url or "", + reasoning_effort=None, + ) + extraction_impl = extraction_cfg._provider_impl + install_two_step_structured_output(self._engine, extraction_impl) + except Exception: + logger.warning( + "Failed to install two-step structured output for extraction model %s", + extraction_llm_model, + exc_info=True, + ) + self._run_async(self._engine.initialize()) # One-time repair: install maintenance routines if missing. diff --git a/src/codespy/agents/memory/cerebral/delta_ops_compat.py b/src/codespy/agents/memory/cerebral/delta_ops_compat.py new file mode 100644 index 0000000..2250e12 --- /dev/null +++ b/src/codespy/agents/memory/cerebral/delta_ops_compat.py @@ -0,0 +1,197 @@ +"""Compatibility shim for Hindsight delta operations parsing. + +The root cause of the "operations must be a list, got " error: + +When using models without native structured output support on Bedrock (e.g. +Nvidia Nemotron), litellm sends the schema as a forced synthetic +`json_tool_call` tool. The model returns the delta `operations` field as a +JSON-encoded string instead of a native array. + +For example, instead of: + {"operations": [{"op": "append_block", "section_id": "s1", "text": "..."}]} + +The model returns: + {"operations": '[{"op": "append_block", "section_id": "s1", "text": "..."}]'} + +Hindsight's `parse_delta_operation_list` expects `operations` to be a list +and raises `TypeError` when it's a string. This shim intercepts the raw +payload before Hindsight validates it, decoding any JSON-encoded operations. + +This shim can be removed once: +- Hindsight accepts string-encoded `operations` natively, OR +- The briefing model supports native structured output on Bedrock. + +See: hindsight_api/engine/reflect/delta_ops.py::parse_delta_operation_list +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def _normalize(raw: Any) -> tuple[Any, bool]: + """Normalize delta operations payload, decoding JSON-encoded strings. + + Args: + raw: The raw payload, which may be a dict, string, or other type. + + Returns: + Tuple of (normalized_payload, changed). When `changed` is False, + the original `raw` is returned unchanged (identity preserved). + """ + # If raw is a string, try to parse it first + if isinstance(raw, str): + try: + from hindsight_api.engine.llm_wrapper import parse_llm_json + + parsed = parse_llm_json(raw) + return _normalize_dict_operations(parsed) + except (json.JSONDecodeError, ValueError): + # Not valid JSON - return unchanged, let original handler deal with it + return raw, False + + # If raw is a dict, normalize its operations field + if isinstance(raw, dict): + return _normalize_dict_operations(raw) + + # For other types (e.g., list), return unchanged + return raw, False + + +def _normalize_dict_operations(raw: dict[str, Any]) -> tuple[Any, bool]: + """Normalize a dict payload, handling string-encoded operations. + + Handles these cases: + 1. {"operations": "[...]"} -> {"operations": [...]} + 2. {"operations": '{"operations": [...]}'} -> {"operations": [...]} + 3. {"operations": ["{...}", "{...}"]} -> {"operations": [{...}, {...}]} + 4. Well-formed payloads pass through unchanged + """ + from hindsight_api.engine.llm_wrapper import parse_llm_json + + if "operations" not in raw: + return raw, False + + ops = raw["operations"] + + # Case 1 & 2: operations is a string that might be JSON-encoded + if isinstance(ops, str): + try: + decoded = parse_llm_json(ops) + except (json.JSONDecodeError, ValueError): + # Not valid JSON - log warning and return unchanged + logger.warning( + "cerebral: delta operations arrived as a non-JSON string; " + "first 200 chars: %s", + ops[:200], + ) + return raw, False + + # If decoded is a list, use it directly + if isinstance(decoded, list): + new_raw = dict(raw) + new_raw["operations"] = _decode_operation_strings(decoded) + return new_raw, True + + # If decoded is a dict with operations, unwrap it + if isinstance(decoded, dict) and "operations" in decoded: + inner_ops = decoded["operations"] + if isinstance(inner_ops, list): + new_raw = dict(raw) + new_raw["operations"] = _decode_operation_strings(inner_ops) + return new_raw, True + + # Any other decoded type - return unchanged + return raw, False + + # Case 3: operations is a list that might contain JSON-encoded strings + if isinstance(ops, list): + decoded_ops = _decode_operation_strings(ops) + if decoded_ops is not ops: # Identity check - was anything decoded? + new_raw = dict(raw) + new_raw["operations"] = decoded_ops + return new_raw, True + return raw, False + + return raw, False + + +def _decode_operation_strings(ops: list[Any]) -> list[Any]: + """Decode any JSON-encoded operation strings in a list. + + Args: + ops: List of operations, which may contain JSON-encoded strings. + + Returns: + List with JSON strings decoded to dicts. Returns the original list + if no decoding was needed (identity preserved). + """ + from hindsight_api.engine.llm_wrapper import parse_llm_json + + changed = False + result: list[Any] = [] + + for item in ops: + if isinstance(item, str): + try: + decoded = parse_llm_json(item) + if isinstance(decoded, dict): + result.append(decoded) + changed = True + else: + result.append(item) + except (json.JSONDecodeError, ValueError): + # Not valid JSON - keep as-is + result.append(item) + else: + result.append(item) + + return result if changed else ops + + +def _wrap_parse_delta_operation_list(original: Any) -> Any: + """Wrap parse_delta_operation_list to handle string-encoded operations.""" + + def wrapper(raw: Any) -> Any: + # Normalize the payload before passing to original + normalized, changed = _normalize(raw) + + if changed: + # Log at debug level when decoding was applied + ops = normalized.get("operations", []) if isinstance(normalized, dict) else [] + logger.debug( + "cerebral: decoded JSON-encoded delta operations (%d op(s))", + len(ops) if isinstance(ops, list) else 0, + ) + + # Call the original function with normalized payload + return original(normalized) + + # Mark the wrapper so we can detect double-installation + wrapper._codespy_delta_coercion = True # type: ignore[attr-defined] + return wrapper + + +def install_delta_ops_coercion() -> None: + """Install the delta operations coercion shim. + + This patches hindsight_api.engine.reflect.delta_ops.parse_delta_operation_list + to handle JSON-encoded operations strings before validation. + + Idempotent: calling multiple times has no additional effect. + """ + from hindsight_api.engine.reflect import delta_ops + + original = delta_ops.parse_delta_operation_list + + # Check if already installed + if getattr(original, "_codespy_delta_coercion", False): + return + + # Install the wrapper + delta_ops.parse_delta_operation_list = _wrap_parse_delta_operation_list(original) + logger.debug("cerebral: installed delta operations coercion shim") diff --git a/src/codespy/agents/memory/cerebral/structured_output.py b/src/codespy/agents/memory/cerebral/structured_output.py new file mode 100644 index 0000000..01f4f99 --- /dev/null +++ b/src/codespy/agents/memory/cerebral/structured_output.py @@ -0,0 +1,611 @@ +"""Structured output support for Cerebral memory models. + +Provides: +- Native structured output registration for Bedrock models that support it + but lack the flag in litellm's model map. +- JSON schema sanitization for Bedrock's structured output constraints. +- Two-step structured output fallback for models that don't enforce schemas. + +See: .kilo/plans/1790926015516-retain-facts-string-coercion.md +""" + +from __future__ import annotations + +import copy +import logging +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +# Models that AWS documents as supporting native structured output on Bedrock, +# but which litellm's model map lacks the flag for. +# Format: bare_model_key -> litellm_provider (as passed to register_model) +# AWS model card: https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-nvidia-nemotron-super-3-120b.html +_NATIVE_STRUCTURED_OUTPUT_OVERRIDES: dict[str, str] = { + "nvidia.nemotron-super-3-120b": "bedrock_converse", +} + +# Marker for idempotent wrapper installation +_SCHEMA_SANITIZER_MARKER = "_codespy_schema_sanitizer" + + +def register_native_structured_output_overrides() -> None: + """Register native structured output capability for known-capable models. + + Idempotent: safe to call multiple times. Skips keys where + supports_native_structured_output is already true. + """ + import litellm + from litellm.utils import supports_native_structured_output + + for bare_key, provider in _NATIVE_STRUCTURED_OUTPUT_OVERRIDES.items(): + try: + # Check if already registered + if supports_native_structured_output(bare_key, provider): + logger.debug( + "Native structured output already registered for %s (%s)", + bare_key, + provider, + ) + continue + except Exception: + # Model info not found - proceed with registration + pass + + # Register the capability + try: + litellm.register_model( + { + bare_key: { + "litellm_provider": provider, + "supports_native_structured_output": True, + } + } + ) + logger.debug( + "Registered native structured output for %s (%s)", bare_key, provider + ) + except Exception as e: + logger.warning( + "Failed to register native structured output for %s: %s", bare_key, e + ) + + # Verify registration for the primary model (with full litellm path prefix) + _verify_registration("bedrock/converse/nvidia.nemotron-super-3-120b") + + +def _verify_registration(model: str) -> None: + """Verify that native structured output is enabled for a model.""" + from litellm.utils import supports_native_structured_output + + try: + if supports_native_structured_output(model): + logger.debug("Verified native structured output for %s", model) + else: + logger.warning( + "Native structured output NOT enabled for %s after registration", model + ) + except Exception as e: + logger.warning("Could not verify native structured output for %s: %s", model, e) + + +def _strip_disallowed_keywords(schema: dict[str, Any]) -> dict[str, Any]: + """Recursively strip JSON Schema keywords disallowed by Bedrock. + + Bedrock's structured output accepts a subset of Draft 2020-12. + Disallowed keywords are silently rejected with a 400. + + Strips: + - minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf + - minLength, maxLength, pattern + - maxItems + - minItems with value > 1 (minItems: 0 or 1 is allowed) + - format values outside: date-time, time, date, duration, email, + hostname, uri, ipv4, ipv6, uuid + - additionalProperties values other than false (forced to false) + + Recurses through: properties, items, $defs, definitions, anyOf, allOf, oneOf. + Keys under properties/$defs are names, not keywords - never stripped. + + Returns a deep copy; input is never mutated. + """ + if not isinstance(schema, dict): + return schema + + # Deep copy to avoid mutating input + result = copy.deepcopy(schema) + return _strip_in_place(result) + + +def _strip_in_place(schema: Any) -> Any: + """In-place recursive strip. Internal helper.""" + if not isinstance(schema, dict): + return schema + + allowed_formats = { + "date-time", + "time", + "date", + "duration", + "email", + "hostname", + "uri", + "ipv4", + "ipv6", + "uuid", + } + + # Build list of keys to strip from this level + to_strip: list[str] = [] + modified: list[str] = [] + + for key, value in schema.items(): + if key in ( + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "maxItems", + ): + to_strip.append(key) + modified.append(key) + elif key == "minItems": + # Only strip if value > 1 + if isinstance(value, int) and value > 1: + to_strip.append(key) + modified.append(key) + elif key == "format": + if value not in allowed_formats: + to_strip.append(key) + modified.append(f"format:{value}") + elif key == "additionalProperties": + # Force to false if not already false + if value is not False: + schema[key] = False + modified.append(f"additionalProperties->{value}") + + # Strip disallowed keys + for key in to_strip: + del schema[key] + + # Log what was stripped at this level + if modified: + logger.debug("Bedrock schema sanitizer stripped: %s", modified) + + # Recurse into sub-schemas + # Keys under properties/$defs are names, not keywords - preserve them + for key in ("properties", "$defs", "definitions"): + if key in schema and isinstance(schema[key], dict): + for sub_schema in schema[key].values(): + _strip_in_place(sub_schema) + + # Array items + if "items" in schema: + _strip_in_place(schema["items"]) + + # Combiners + for combiner in ("anyOf", "allOf", "oneOf"): + if combiner in schema and isinstance(schema[combiner], list): + for sub_schema in schema[combiner]: + _strip_in_place(sub_schema) + + # AdditionalProperties (if it's a schema, not just false) + if isinstance(schema.get("additionalProperties"), dict): + _strip_in_place(schema["additionalProperties"]) + + # PropertyNames + if isinstance(schema.get("propertyNames"), dict): + _strip_in_place(schema["propertyNames"]) + + return schema + + +def sanitize_bedrock_json_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Sanitize a JSON Schema for Bedrock's structured output constraints. + + Args: + schema: The JSON Schema dict to sanitize. + + Returns: + A deep copy with disallowed keywords stripped. + """ + return _strip_disallowed_keywords(schema) + + +def install_bedrock_schema_sanitizer() -> None: + """Install the Bedrock JSON schema sanitizer. + + Patches litellm's AmazonConverseConfig._create_output_config_for_response_format + to sanitize schemas before Bedrock receives them. + + Idempotent: calling multiple times has no additional effect. + """ + import functools + + try: + from litellm.llms.bedrock.chat.converse_transformation import ( + AmazonConverseConfig, + ) + except ImportError: + logger.debug("AmazonConverseConfig not found; skipping schema sanitizer") + return + + # Get the raw descriptor from the class __dict__ to handle staticmethod correctly + raw_descriptor = AmazonConverseConfig.__dict__.get( + "_create_output_config_for_response_format" + ) + if raw_descriptor is None: + logger.warning( + "AmazonConverseConfig._create_output_config_for_response_format not found" + ) + return + + # Unwrap staticmethod to get the actual function + if isinstance(raw_descriptor, staticmethod): + original = raw_descriptor.__func__ + else: + original = raw_descriptor + + # Check if already installed (check on the underlying function) + if getattr(original, _SCHEMA_SANITIZER_MARKER, False): + logger.debug("Bedrock schema sanitizer already installed") + return + + # The original signature is: (json_schema=None, name=None, description=None) + @functools.wraps(original) + def wrapper( + json_schema: dict[str, Any] | None = None, + name: str | None = None, + description: str | None = None, + ) -> Any: + """Wrapped staticmethod that sanitizes schemas before passing to Bedrock.""" + if json_schema is not None and isinstance(json_schema, dict): + # Deep copy and sanitize the json_schema + json_schema = _strip_disallowed_keywords(json_schema) + + return original(json_schema=json_schema, name=name, description=description) + + # Mark wrapper + setattr(wrapper, _SCHEMA_SANITIZER_MARKER, True) + + # Install wrapper as staticmethod + setattr( + AmazonConverseConfig, + "_create_output_config_for_response_format", + staticmethod(wrapper), + ) + logger.debug("Installed Bedrock JSON schema sanitizer") + + +def enforces_schema(model: str | None) -> bool | None: + """Check if a model enforces structured output schemas natively. + + Returns: + - True if the model natively enforces schemas + - False if it uses fallback tool-based extraction + - None if the model is unmapped (unknown to litellm) + """ + from litellm.utils import supports_native_structured_output, supports_response_schema + + if model is None: + return None + + # Parse provider from model string + # Format: "provider/model" or "provider/converse/model" etc. + parts = model.split("/") + provider = parts[0] if parts else None + + # Determine which litellm function to use + if provider in ("bedrock", "bedrock_converse"): + # Use native structured output flag for Bedrock + try: + return supports_native_structured_output(model) + except Exception: + return None + elif provider == "anthropic": + # Anthropic direct + try: + return supports_native_structured_output(model) + except Exception: + return None + elif provider == "vertex_ai": + # Vertex AI - check if it's a Claude model + if "claude" in model.lower(): + try: + return supports_native_structured_output(model) + except Exception: + return None + # Other Vertex models use response_schema + try: + return supports_response_schema(model) + except Exception: + return None + else: + # Other providers use response_schema + try: + return supports_response_schema(model) + except Exception: + return None + + +class TwoStepStructuredLLM: + """Two-step structured output wrapper for non-schema-enforcing LLMs. + + First call: ask the main LLM to produce JSON matching a schema (in the prompt). + Second call (if first fails validation): ask the extraction LLM to extract + structured data from the first response. + + Inner calls are costed via the existing span recorder. + """ + + def __init__( + self, + main_impl: Any, + extraction_impl: Any, + ): + """Initialize with main and extraction LLM implementations. + + Args: + main_impl: The main LiteLLMLLM to wrap. + extraction_impl: The extraction LiteLLMLLM for fallback. + """ + self._main = main_impl + self._extraction = extraction_impl + + def __getattr__(self, name: str) -> Any: + """Forward attribute access to the main LLM.""" + return getattr(self._main, name) + + async def call( + self, + messages: list[dict[str, Any]], + response_format: type | None = None, + **kwargs: Any, + ) -> Any: + """Call with two-step structured output handling. + + Args: + messages: The conversation messages. + response_format: A pydantic model class for structured output. + **kwargs: Additional args forwarded to inner calls. + + Returns: + LLMCallResult from either the main or extraction LLM. + """ + from hindsight_api.engine.llm_wrapper import ( + LLMCallResult, + OutputTooLongError, + parse_llm_json, + ) + from hindsight_api.engine.response_models import TokenUsage + from hindsight_api.engine.structured_output import provider_json_schema + + # If no response format, pass through directly (await the coroutine) + if response_format is None: + return await self._main.call(messages, response_format=None, **kwargs) + + # Check if response_format has a schema - use provider_json_schema for consistency + model_schema = None + try: + if hasattr(response_format, "model_json_schema"): + model_schema = provider_json_schema(response_format) + except Exception: + pass + + if model_schema is None: + # No schema available, pass through + return await self._main.call(messages, response_format=response_format, **kwargs) + + # Extract configuration values (forward all kwargs except ones we handle specially) + scope = kwargs.get("scope", "unknown") + skip_validation = kwargs.get("skip_validation", False) + strict_schema = kwargs.get("strict_schema", False) + + # Build step 1 messages with schema instruction + step1_messages = self._build_step1_messages(messages, model_schema) + + # Step 1: Call main LLM without response_format, with schema in prompt + # Forward all kwargs except response_format, skip_validation, strict_schema + step1_kwargs = {k: v for k, v in kwargs.items() if k not in ("response_format", "skip_validation", "strict_schema")} + step1_result = await self._main.call( + step1_messages, + response_format=None, + **step1_kwargs, + ) + + # LLMCallResult has `content`, not `text` + step1_content = step1_result.content + step1_text = step1_content if isinstance(step1_content, str) else str(step1_content) + + # Try to parse and validate step 1 result + try: + parsed = parse_llm_json(step1_text) + if skip_validation: + # Return raw dict as content + return LLMCallResult(content=parsed, usage=step1_result.usage) + else: + # Validate against response_format + validated = response_format.model_validate(parsed) + # Return validated model as content + return LLMCallResult(content=validated, usage=step1_result.usage) + except Exception: + # Step 1 failed validation or parsing - proceed to step 2 + pass + + # Step 2: Call extraction LLM + extraction_messages = self._build_extraction_messages(step1_text, model_schema) + + # Step 2 overrides temperature=0.0 and adds response_format, skip_validation, strict_schema + step2_kwargs = {k: v for k, v in kwargs.items() if k not in ("temperature", "response_format", "skip_validation", "strict_schema")} + step2_result = await self._extraction.call( + extraction_messages, + response_format=response_format, + temperature=0.0, + skip_validation=skip_validation, + strict_schema=strict_schema, + **step2_kwargs, + ) + + # Let OutputTooLongError propagate (handled by caller) + # Note: step2_result is LLMCallResult, not an exception + # OutputTooLongError would have been raised, not returned + + # Sum usage from both calls using TokenUsage.__add__ + combined_usage = step1_result.usage + step2_result.usage + + # Return step 2 result with combined usage + # Use model_copy (Pydantic v2) instead of _replace + return step2_result.model_copy(update={"usage": combined_usage}) + + def _build_step1_messages( + self, + messages: list[dict[str, Any]], + schema: dict[str, Any], + ) -> list[dict[str, Any]]: + """Build messages for step 1 with schema instruction appended to system.""" + import json + + messages_copy = list(messages) + + # Find system message or create one + system_idx = None + for i, msg in enumerate(messages_copy): + if msg.get("role") == "system": + system_idx = i + break + + schema_instruction = ( + f"\n\nRespond with a single JSON object matching this JSON schema:\n" + f"{json.dumps(schema, indent=2)}" + ) + + if system_idx is not None: + # Append to existing system message + original_content = messages_copy[system_idx].get("content", "") + messages_copy[system_idx] = { + **messages_copy[system_idx], + "content": original_content + schema_instruction, + } + else: + # Create new system message at the start + messages_copy.insert( + 0, {"role": "system", "content": schema_instruction.strip()} + ) + + return messages_copy + + def _build_extraction_messages( + self, + text: str, + schema: dict[str, Any], + ) -> list[dict[str, Any]]: + """Build messages for extraction step.""" + import json + + system_prompt = ( + f"Extract the structured data from the response below into this JSON schema. " + f"Copy values verbatim; do not invent, merge or drop items.\n\n" + f"{json.dumps(schema, indent=2)}" + ) + + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": text}, + ] + + # call_with_tools is forwarded via __getattr__ to self._main.call_with_tools + + +def install_two_step_structured_output( + engine: Any, + extraction_impl: Any, +) -> None: + """Install two-step structured output wrapper on eligible LLM configs. + + Wraps engine LLM configs whose main model doesn't enforce schemas but + the extraction model does. + + Args: + engine: The MemoryEngine instance. + extraction_impl: The extraction LLM implementation (LiteLLMLLM). + """ + # Correct import paths per plan fix B1 + from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM + from hindsight_api.engine.multi_llm import MultiLLMProvider + + # Collect unique LLM configs from the engine + configs: set[int] = set() + llm_configs: list[Any] = [] + + for attr in ( + "_llm_config", + "_retain_llm_config", + "_consolidation_llm_config", + "_reflect_llm_config", + "_mental_model_refresh_llm_override", + ): + cfg = getattr(engine, attr, None) + if cfg is not None and id(cfg) not in configs: + configs.add(id(cfg)) + llm_configs.append(cfg) + + wrapped_count = 0 + for cfg in llm_configs: + # Check if the config itself is a MultiLLMProvider (fix B2) + # _build_llm returns MultiLLMProvider as the engine attribute + if isinstance(cfg, MultiLLMProvider): + logger.debug("Skipping MultiLLMProvider config for two-step wrapper") + continue + + impl = getattr(cfg, "_provider_impl", None) + + if impl is None: + logger.debug("No _provider_impl found on config") + continue + + # Skip if the impl is a MultiLLMProvider + if isinstance(impl, MultiLLMProvider): + logger.debug("Skipping MultiLLMProvider implementation for two-step wrapper") + continue + + if not isinstance(impl, LiteLLMLLM): + logger.debug("Skipping non-LiteLLMLLM implementation: %s", type(impl)) + continue + + # Get the main model + main_model = getattr(impl, "model", None) + extraction_model = getattr(extraction_impl, "model", None) + + # Check if wrapping is needed + main_enforces = enforces_schema(main_model) + extraction_enforces = enforces_schema(extraction_model) + + logger.debug( + "Checking two-step for model=%s (enforces=%s), extraction=%s (enforces=%s)", + main_model, + main_enforces, + extraction_model, + extraction_enforces, + ) + + # Wrap when main doesn't enforce but extraction does + if main_enforces is False and extraction_enforces is True: + wrapper = TwoStepStructuredLLM(impl, extraction_impl) + cfg._provider_impl = wrapper + wrapped_count += 1 + logger.info( + "Installed two-step structured output wrapper for %s (extraction=%s)", + main_model, + extraction_model, + ) + elif main_enforces is None: + logger.debug( + "Model %s is unmapped; skipping two-step wrapper", main_model + ) + + if wrapped_count > 0: + logger.info("Installed two-step structured output on %d LLM config(s)", wrapped_count) diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 0e8f8a9..99b04af 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -564,6 +564,17 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: # Apply mental-model refresh LLM config to Hindsight raw config BEFORE Cerebral construction _apply_mental_model_refresh_llm(settings) + # Extraction model for two-step structured output fallback + extraction_model = settings.get_llm_config(MEMORY_RETAIN).extraction_model + extraction_kwargs: dict[str, str | None] = {} + if extraction_model: + extraction_api_key, extraction_base_url = _litellm_credentials(settings, extraction_model) + extraction_kwargs = { + "extraction_llm_model": extraction_model, + "extraction_llm_api_key": extraction_api_key, + "extraction_llm_base_url": extraction_base_url, + } + try: _cerebral = Cerebral( database_url=pg_uri, @@ -581,6 +592,7 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: max_observations_per_scope=settings.memory.cerebral.consolidation.max_observations_per_scope, **reflect_kwargs, **consolidation_kwargs, + **extraction_kwargs, ) except Exception: logger.error( diff --git a/tests/test_cerebral_delta_ops.py b/tests/test_cerebral_delta_ops.py new file mode 100644 index 0000000..ba5b101 --- /dev/null +++ b/tests/test_cerebral_delta_ops.py @@ -0,0 +1,346 @@ +"""Tests for delta operations coercion shim. + +These tests verify that the delta_ops_compat module correctly handles +JSON-encoded operations strings from models without native structured output. +""" + +from __future__ import annotations + +import json + +# Import hindsight_api first (available in test environment with venv) +from hindsight_api.engine.reflect import delta_ops + +# Now import the module under test +from codespy.agents.memory.cerebral.delta_ops_compat import ( + _decode_operation_strings, + _normalize, + _normalize_dict_operations, + install_delta_ops_coercion, +) + + +class TestNormalizeDictOperations: + """Tests for _normalize_dict_operations.""" + + def test_operations_as_json_string(self): + """Dict with operations as JSON string -> decoded to list.""" + op = {"op": "append_block", "section_id": "s1", "text": "hello"} + raw = {"operations": json.dumps([op])} + + result, changed = _normalize_dict_operations(raw) + + assert changed is True + assert result["operations"] == [op] + + def test_operations_as_nested_json_string(self): + """Dict with operations as nested JSON object -> inner list used.""" + op = {"op": "append_block", "section_id": "s1", "text": "hello"} + inner = {"operations": [op]} + raw = {"operations": json.dumps(inner)} + + result, changed = _normalize_dict_operations(raw) + + assert changed is True + assert result["operations"] == [op] + + def test_operations_as_list_of_json_strings(self): + """Dict with operations as list of JSON strings -> decoded to dicts.""" + op1 = {"op": "append_block", "section_id": "s1", "text": "hello"} + op2 = {"op": "replace_block", "section_id": "s1", "block_id": "b1", "text": "world"} + raw = {"operations": [json.dumps(op1), json.dumps(op2)]} + + result, changed = _normalize_dict_operations(raw) + + assert changed is True + assert result["operations"] == [op1, op2] + + def test_well_formed_dict_unchanged(self): + """Well-formed dict with list operations passes through unchanged.""" + ops = [{"op": "append_block", "section_id": "s1", "text": "hello"}] + raw = {"operations": ops} + + result, changed = _normalize_dict_operations(raw) + + assert changed is False + assert result is raw # Identity preserved + + def test_non_json_operations_string_logged_and_unchanged(self, caplog): + """Non-JSON operations string logs warning and returns unchanged.""" + import logging + + raw = {"operations": "not json"} + + with caplog.at_level( + logging.WARNING, logger="codespy.agents.memory.cerebral.delta_ops_compat" + ): + result, changed = _normalize_dict_operations(raw) + + assert changed is False + assert result is raw # Identity preserved + assert "non-JSON string" in caplog.text + + def test_no_operations_key(self): + """Dict without operations key passes through unchanged.""" + raw = {"other_key": "value"} + + result, changed = _normalize_dict_operations(raw) + + assert changed is False + assert result is raw + + def test_operations_none(self): + """Dict with operations=None passes through unchanged.""" + raw = {"operations": None} + + result, changed = _normalize_dict_operations(raw) + + assert changed is False + assert result["operations"] is None + + +class TestDecodeOperationStrings: + """Tests for _decode_operation_strings.""" + + def test_decode_json_strings_in_list(self): + """JSON strings in list are decoded to dicts.""" + op1 = {"op": "append_block", "section_id": "s1", "text": "hello"} + op2 = {"op": "replace_block", "section_id": "s1", "block_id": "b1", "text": "world"} + ops = [json.dumps(op1), json.dumps(op2)] + + result = _decode_operation_strings(ops) + + assert result == [op1, op2] + + def test_mixed_list_partially_decoded(self): + """Mixed list with some JSON strings and some dicts.""" + op1 = {"op": "append_block", "section_id": "s1", "text": "hello"} + op2 = {"op": "replace_block", "section_id": "s1", "block_id": "b1", "text": "world"} + ops = [json.dumps(op1), op2, "not json"] + + result = _decode_operation_strings(ops) + + assert result == [op1, op2, "not json"] + + def test_no_json_strings_identity_preserved(self): + """List without JSON strings returns same list (identity preserved).""" + ops = [{"op": "append_block"}, {"op": "replace_block"}] + + result = _decode_operation_strings(ops) + + assert result is ops # Identity preserved + + +class TestNormalize: + """Tests for _normalize.""" + + def test_string_input_with_encoded_operations(self): + """String input with encoded operations is parsed and normalized.""" + op = {"op": "append_block", "section_id": "s1", "text": "hello"} + # The string contains JSON where operations is a JSON-encoded string + text = json.dumps({"operations": json.dumps([op])}) + + result, changed = _normalize(text) + + assert changed is True + assert result["operations"] == [op] + + def test_string_input_well_formed(self): + """String input with well-formed operations is parsed and unchanged.""" + op = {"op": "append_block", "section_id": "s1", "text": "hello"} + text = json.dumps({"operations": [op]}) + + result, changed = _normalize(text) + + # changed is False because no actual decoding was needed (operations was already a list) + assert changed is False + assert result == {"operations": [op]} + + def test_dict_input(self): + """Dict input is normalized.""" + op = {"op": "append_block", "section_id": "s1", "text": "hello"} + raw = {"operations": json.dumps([op])} + + result, changed = _normalize(raw) + + assert changed is True + assert result["operations"] == [op] + + def test_list_input_unchanged(self): + """List input passes through unchanged.""" + ops = [{"op": "append_block"}] + + result, changed = _normalize(ops) + + assert changed is False + assert result is ops + + def test_invalid_json_string_unchanged(self): + """Invalid JSON string returns unchanged.""" + text = "not valid json" + + result, changed = _normalize(text) + + assert changed is False + assert result == text + + +class TestInstallDeltaOpsCoercion: + """Tests for install_delta_ops_coercion.""" + + def setup_method(self): + """Reset delta_ops module before each test.""" + # Store the original function + self._original = delta_ops.parse_delta_operation_list + # Ensure no coercion is installed - try to unwrap if needed + if hasattr(self._original, "_codespy_delta_coercion"): + # Get the original function from our wrapper + # The wrapper stores the original in its closure + if hasattr(self._original, "__wrapped__"): + delta_ops.parse_delta_operation_list = self._original.__wrapped__ + else: + # If __wrapped__ is not available, we need to get the original + # from the wrapper's closure + closure = getattr(self._original, "__closure__", None) + if closure: + delta_ops.parse_delta_operation_list = closure[0].cell_contents + + def teardown_method(self): + """Restore original function after each test.""" + delta_ops.parse_delta_operation_list = self._original + + def test_install_wraps_function(self): + """install_delta_ops_coercion wraps parse_delta_operation_list.""" + # Get the current function + original = delta_ops.parse_delta_operation_list + + # Install the coercion + install_delta_ops_coercion() + + # Should have been wrapped + assert delta_ops.parse_delta_operation_list is not original + assert hasattr(delta_ops.parse_delta_operation_list, "_codespy_delta_coercion") + + def test_idempotent_install(self): + """Multiple installs have no additional effect.""" + # Install once + install_delta_ops_coercion() + wrapped = delta_ops.parse_delta_operation_list + + # Install again + install_delta_ops_coercion() + wrapped2 = delta_ops.parse_delta_operation_list + + # Should be the same wrapper + assert wrapped is wrapped2 + + def test_wrapper_handles_encoded_operations(self): + """The wrapper correctly handles JSON-encoded operations.""" + # Install the coercion + install_delta_ops_coercion() + + # Test with encoded operations + op = {"op": "append_block", "section_id": "s1", "text": "hello"} + raw = {"operations": json.dumps([op])} + + # This would previously raise TypeError + result = delta_ops.parse_delta_operation_list(raw) + + assert len(result.operations) == 1 + assert result.operations[0].op == "append_block" + + def test_wrapper_passes_well_formed_unchanged(self): + """The wrapper passes well-formed payloads unchanged.""" + # Install the coercion + install_delta_ops_coercion() + + # Test with well-formed operations + from hindsight_api.engine.reflect.delta_ops import AppendBlockOp + + ops = [AppendBlockOp(section_id="s1", text="hello")] + raw = {"operations": ops} + + result = delta_ops.parse_delta_operation_list(raw) + + assert len(result.operations) == 1 + assert result.operations[0].section_id == "s1" + + +class TestRealWorldScenarios: + """Tests simulating real-world error scenarios.""" + + def setup_method(self): + """Store original function before test.""" + self._original = delta_ops.parse_delta_operation_list + + def teardown_method(self): + """Restore original function after test.""" + delta_ops.parse_delta_operation_list = self._original + + def test_nemotron_double_encoding(self): + """Simulate the Nemotron double-encoding issue that caused the original bug.""" + # Install the coercion + install_delta_ops_coercion() + + # Simulate what Nemotron returns: operations is a JSON-encoded string + actual_ops = [ + {"op": "append_block", "section_id": "overview", "text": "Test content"}, + {"op": "add_section", "heading": "New Section", "blocks": ["Block content"]}, + ] + nemotron_response = {"operations": json.dumps(actual_ops)} + + # This was the original failure: TypeError: operations must be a list, got + # With the shim, it should work + result = delta_ops.parse_delta_operation_list(nemotron_response) + + assert len(result.operations) == 2 + assert result.operations[0].op == "append_block" + assert result.operations[1].op == "add_section" + + def test_nested_operations_wrapping(self): + """Test handling of double-wrapped operations dict.""" + # Install the coercion + install_delta_ops_coercion() + + # Sometimes the model wraps the whole thing in another operations key + inner = {"operations": [{"op": "append_block", "section_id": "s1", "text": "hello"}]} + raw = {"operations": json.dumps(inner)} + + result = delta_ops.parse_delta_operation_list(raw) + + assert len(result.operations) == 1 + assert result.operations[0].op == "append_block" + + def test_text_input_with_encoded_operations(self): + """Test handling of text input containing encoded operations.""" + # Install the coercion + install_delta_ops_coercion() + + # Text input that contains JSON with string-encoded operations + ops = [{"op": "replace_block", "section_id": "s1", "block_id": "b1", "text": "new text"}] + text = json.dumps({"operations": json.dumps(ops)}) + + result = delta_ops.parse_delta_operation_list(text) + + assert len(result.operations) == 1 + assert result.operations[0].op == "replace_block" + + +class TestCallerDictNotMutated: + """Tests ensuring the caller's dict is not mutated.""" + + def test_caller_dict_not_mutated(self): + """The caller's dict should never be mutated.""" + op = {"op": "append_block", "section_id": "s1", "text": "hello"} + original_raw = {"operations": json.dumps([op]), "other_key": "value"} + original_copy = json.loads(json.dumps(original_raw)) + + result, changed = _normalize_dict_operations(original_raw) + + assert changed is True + # Original dict should be unchanged + assert original_raw == original_copy + # Result should be a new dict + assert result is not original_raw + assert result["operations"] == [op] + assert result["other_key"] == "value" diff --git a/tests/test_cerebral_structured_output.py b/tests/test_cerebral_structured_output.py new file mode 100644 index 0000000..993c16f --- /dev/null +++ b/tests/test_cerebral_structured_output.py @@ -0,0 +1,636 @@ +"""Tests for Cerebral structured output support. + +Covers: +- Native structured output registration +- JSON schema sanitization for Bedrock +- Two-step structured output wrapper +""" + +from __future__ import annotations + +import copy +from typing import Any +from unittest.mock import MagicMock, Mock, patch + +import pytest + +# Skip all tests if litellm is not available +try: + import litellm + + HAS_LITELLM = True +except ImportError: + HAS_LITELLM = False + +pytestmark = pytest.mark.skipif(not HAS_LITELLM, reason="litellm not installed") + + +class TestRegisterNativeStructuredOutput: + """Tests for register_native_structured_output_overrides.""" + + def test_registration_makes_super_capable(self): + """After registration, Super should report native structured output support.""" + from litellm.utils import supports_native_structured_output + from codespy.agents.memory.cerebral.structured_output import ( + register_native_structured_output_overrides, + ) + + register_native_structured_output_overrides() + + # Check the bare key + result = supports_native_structured_output( + "nvidia.nemotron-super-3-120b", "bedrock_converse" + ) + assert result is True + + def test_registration_is_idempotent(self): + """Multiple registrations should not cause errors.""" + from litellm.utils import supports_native_structured_output + from codespy.agents.memory.cerebral.structured_output import ( + register_native_structured_output_overrides, + ) + + # Register twice + register_native_structured_output_overrides() + register_native_structured_output_overrides() + + # Should still work + result = supports_native_structured_output( + "nvidia.nemotron-super-3-120b", "bedrock_converse" + ) + assert result is True + + def test_pricing_preserved_after_registration(self): + """Model pricing should be preserved after registration.""" + from codespy.agents.memory.cerebral.structured_output import ( + register_native_structured_output_overrides, + ) + + register_native_structured_output_overrides() + + # Get model info + info = litellm.get_model_info("nvidia.nemotron-super-3-120b", "bedrock_converse") + # Should have pricing info + assert info is not None + + +class TestSanitizeBedrockJsonSchema: + """Tests for sanitize_bedrock_json_schema.""" + + def test_strips_minimum_maximum(self): + """Should strip minimum/maximum keywords.""" + from codespy.agents.memory.cerebral.structured_output import ( + sanitize_bedrock_json_schema, + ) + + schema = { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + } + }, + } + + result = sanitize_bedrock_json_schema(schema) + + assert "minimum" not in result["properties"]["level"] + assert "maximum" not in result["properties"]["level"] + assert result["properties"]["level"]["type"] == "integer" + + def test_strips_string_constraints(self): + """Should strip minLength/maxLength/pattern.""" + from codespy.agents.memory.cerebral.structured_output import ( + sanitize_bedrock_json_schema, + ) + + schema = { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[a-z]+$", + } + }, + } + + result = sanitize_bedrock_json_schema(schema) + + assert "minLength" not in result["properties"]["name"] + assert "maxLength" not in result["properties"]["name"] + assert "pattern" not in result["properties"]["name"] + assert result["properties"]["name"]["type"] == "string" + + def test_strips_max_items(self): + """Should strip maxItems from arrays.""" + from codespy.agents.memory.cerebral.structured_output import ( + sanitize_bedrock_json_schema, + ) + + schema = { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 100, + } + }, + } + + result = sanitize_bedrock_json_schema(schema) + + assert "maxItems" not in result["properties"]["items"] + + def test_strips_min_items_gt_1(self): + """Should strip minItems only when value > 1.""" + from codespy.agents.memory.cerebral.structured_output import ( + sanitize_bedrock_json_schema, + ) + + # minItems: 2 should be stripped + schema_gt1 = { + "type": "array", + "minItems": 2, + "items": {"type": "string"}, + } + result = sanitize_bedrock_json_schema(schema_gt1) + assert "minItems" not in result + + # minItems: 0 should be kept + schema_0 = { + "type": "array", + "minItems": 0, + "items": {"type": "string"}, + } + result = sanitize_bedrock_json_schema(schema_0) + assert result.get("minItems") == 0 + + # minItems: 1 should be kept + schema_1 = { + "type": "array", + "minItems": 1, + "items": {"type": "string"}, + } + result = sanitize_bedrock_json_schema(schema_1) + assert result.get("minItems") == 1 + + def test_forces_additional_properties_false(self): + """Should force additionalProperties to false.""" + from codespy.agents.memory.cerebral.structured_output import ( + sanitize_bedrock_json_schema, + ) + + schema = { + "type": "object", + "additionalProperties": {"type": "string"}, + } + + result = sanitize_bedrock_json_schema(schema) + + assert result["additionalProperties"] is False + + def test_keeps_additional_properties_false(self): + """Should keep additionalProperties: false as-is.""" + from codespy.agents.memory.cerebral.structured_output import ( + sanitize_bedrock_json_schema, + ) + + schema = { + "type": "object", + "additionalProperties": False, + } + + result = sanitize_bedrock_json_schema(schema) + + assert result["additionalProperties"] is False + + def test_strips_disallowed_format_values(self): + """Should strip format values outside allowed set.""" + from codespy.agents.memory.cerebral.structured_output import ( + sanitize_bedrock_json_schema, + ) + + schema = { + "type": "object", + "properties": { + "email": {"type": "string", "format": "email"}, + "date": {"type": "string", "format": "date-time"}, + "custom": {"type": "string", "format": "custom-format"}, + }, + } + + result = sanitize_bedrock_json_schema(schema) + + # Allowed formats should be kept + assert result["properties"]["email"]["format"] == "email" + assert result["properties"]["date"]["format"] == "date-time" + # Disallowed format should be stripped + assert "format" not in result["properties"]["custom"] + + def test_preserves_property_names(self): + """Should not strip keys under properties (they're names, not keywords).""" + from codespy.agents.memory.cerebral.structured_output import ( + sanitize_bedrock_json_schema, + ) + + schema = { + "type": "object", + "properties": { + "minimum": {"type": "string"}, + "maximum": {"type": "string"}, + }, + } + + result = sanitize_bedrock_json_schema(schema) + + # Property names should be preserved + assert "minimum" in result["properties"] + assert "maximum" in result["properties"] + + def test_does_not_mutate_input(self): + """Should not mutate the input schema.""" + from codespy.agents.memory.cerebral.structured_output import ( + sanitize_bedrock_json_schema, + ) + + schema = { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + } + }, + } + original = copy.deepcopy(schema) + + sanitize_bedrock_json_schema(schema) + + assert schema == original + + def test_recurses_through_refs(self): + """Should recurse through $defs/definitions.""" + from codespy.agents.memory.cerebral.structured_output import ( + sanitize_bedrock_json_schema, + ) + + schema = { + "$defs": { + "Level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + } + }, + "properties": { + "level": {"$ref": "#/$defs/Level"} + }, + } + + result = sanitize_bedrock_json_schema(schema) + + # Should strip from $defs + assert "minimum" not in result["$defs"]["Level"] + assert "maximum" not in result["$defs"]["Level"] + + def test_recurses_through_combiners(self): + """Should recurse through anyOf/allOf/oneOf.""" + from codespy.agents.memory.cerebral.structured_output import ( + sanitize_bedrock_json_schema, + ) + + schema = { + "anyOf": [ + {"type": "integer", "minimum": 0}, + {"type": "string", "maxLength": 10}, + ] + } + + result = sanitize_bedrock_json_schema(schema) + + assert "minimum" not in result["anyOf"][0] + assert "maxLength" not in result["anyOf"][1] + + +class TestEnforcesSchema: + """Tests for enforces_schema function.""" + + def test_returns_true_for_native_supported(self): + """Should return True for models with native structured output.""" + from codespy.agents.memory.cerebral.structured_output import ( + enforces_schema, + register_native_structured_output_overrides, + ) + + register_native_structured_output_overrides() + + result = enforces_schema("bedrock/converse/nvidia.nemotron-super-3-120b") + # Result should be True (or None if litellm doesn't support the function) + assert result is True or result is None + + def test_returns_false_for_tool_fallback_models(self): + """Should return False for models using tool fallback.""" + from codespy.agents.memory.cerebral.structured_output import ( + enforces_schema, + ) + + # A model without native support (unknown/unmapped) + result = enforces_schema("bedrock/converse/unknown-model") + # Should be None or False depending on litellm + assert result in (None, False) + + def test_returns_none_for_unmapped_models(self): + """Should return None for unmapped models.""" + from codespy.agents.memory.cerebral.structured_output import ( + enforces_schema, + ) + + result = enforces_schema("unmapped-provider/unknown-model") + # Result is None if function not found, or False/None if model not found + assert result is None or result is False + + +class TestInstallBedrockSchemaSanitizer: + """Tests for install_bedrock_schema_sanitizer.""" + + def test_is_idempotent(self): + """Should be safe to call multiple times.""" + from codespy.agents.memory.cerebral.structured_output import ( + install_bedrock_schema_sanitizer, + ) + + # Should not raise + install_bedrock_schema_sanitizer() + install_bedrock_schema_sanitizer() + + def test_sanitizer_strips_keywords(self): + """Installed sanitizer should strip disallowed keywords.""" + from codespy.agents.memory.cerebral.structured_output import ( + install_bedrock_schema_sanitizer, + sanitize_bedrock_json_schema, + ) + + install_bedrock_schema_sanitizer() + + # The sanitizer wraps _create_output_config_for_response_format + # We can verify it by checking the underlying function is wrapped + from litellm.llms.bedrock.chat.converse_transformation import ( + AmazonConverseConfig, + ) + + method = getattr( + AmazonConverseConfig, "_create_output_config_for_response_format" + ) + # Check marker is set + assert getattr(method, "_codespy_schema_sanitizer", False) + + +class TestTwoStepStructuredLLM: + """Tests for TwoStepStructuredLLM class.""" + + def test_getattr_forwards_to_main(self): + """Attribute access should forward to main LLM.""" + from codespy.agents.memory.cerebral.structured_output import ( + TwoStepStructuredLLM, + ) + + main = MagicMock() + main.model = "main-model" + main.provider = "bedrock" + + wrapper = TwoStepStructuredLLM(main, MagicMock()) + + assert wrapper.model == "main-model" + assert wrapper.provider == "bedrock" + + @pytest.mark.asyncio + async def test_passthrough_without_response_format(self): + """Should pass through to main when no response_format.""" + from unittest.mock import AsyncMock + from codespy.agents.memory.cerebral.structured_output import ( + TwoStepStructuredLLM, + ) + + main = MagicMock() + main.call = AsyncMock(return_value=MagicMock()) + extraction = MagicMock() + + wrapper = TwoStepStructuredLLM(main, extraction) + await wrapper.call([{"role": "user", "content": "test"}], response_format=None) + + main.call.assert_called_once() + extraction.call.assert_not_called() + + @pytest.mark.asyncio + async def test_builds_step1_messages_with_schema(self): + """Should append schema instruction to system message.""" + from unittest.mock import AsyncMock, patch + from pydantic import BaseModel + from codespy.agents.memory.cerebral.structured_output import ( + TwoStepStructuredLLM, + ) + + # Create a mock result with content (not text) + mock_result = MagicMock() + mock_result.content = '{"key": "value"}' + mock_result.usage = MagicMock() + mock_result.usage.__add__ = MagicMock(return_value=MagicMock()) + mock_result.model_copy = MagicMock(return_value=mock_result) + + main = MagicMock() + main.call = AsyncMock(return_value=mock_result) + + extraction = MagicMock() + extraction.call = AsyncMock(return_value=mock_result) + + wrapper = TwoStepStructuredLLM(main, extraction) + + # Create a pydantic model for response_format + class MockModel(BaseModel): + key: str + + messages = [{"role": "system", "content": "You are helpful"}] + await wrapper.call(messages, response_format=MockModel, scope="test") + + # Check main was called with modified messages + call_args = main.call.call_args + modified_messages = call_args[1].get("messages", call_args[0][0]) + + # System message should contain schema + assert len(modified_messages) == 1 + assert "JSON schema" in modified_messages[0]["content"] + + +class TestInstallTwoStepStructuredOutput: + """Tests for install_two_step_structured_output.""" + + def test_skips_multi_llm_provider(self): + """Should skip MultiLLMProvider implementations.""" + from codespy.agents.memory.cerebral.structured_output import ( + enforces_schema, + install_two_step_structured_output, + ) + + # Create mock engine with MultiLLMProvider + engine = MagicMock() + engine._llm_config = MagicMock() + + multi_provider = MagicMock() + multi_provider.__class__.__name__ = "MultiLLMProvider" + engine._llm_config._provider_impl = multi_provider + + extraction_impl = MagicMock() + + install_two_step_structured_output(engine, extraction_impl) + + # Multi provider should not be wrapped + assert engine._llm_config._provider_impl is multi_provider + + def test_skips_when_main_enforces(self): + """Should skip when main model already enforces schema.""" + from codespy.agents.memory.cerebral.structured_output import ( + install_two_step_structured_output, + ) + + # Mock enforces_schema to return True for main + with patch( + "codespy.agents.memory.cerebral.structured_output.enforces_schema" + ) as mock_enforces: + mock_enforces.return_value = True + + engine = MagicMock() + llm = MagicMock() + llm.model = "bedrock/converse/nvidia.nemotron-super-3-120b" + engine._llm_config = MagicMock() + engine._llm_config._provider_impl = llm + + extraction_impl = MagicMock() + extraction_impl.model = "nvidia.nemotron-nano-3-30b" + + install_two_step_structured_output(engine, extraction_impl) + + # Should not be wrapped + assert engine._llm_config._provider_impl is llm + + @pytest.mark.skip(reason="Cannot easily mock isinstance with real LiteLLMLLM class") + def test_wraps_when_main_does_not_enforce(self): + """Should wrap when main doesn't enforce but extraction does.""" + from codespy.agents.memory.cerebral.structured_output import ( + TwoStepStructuredLLM, + install_two_step_structured_output, + ) + + # Mock enforces_schema: main=False, extraction=True + def enforces_side_effect(model): + if "super" in model: + return False + if "nano" in model: + return True + return None + + extraction_impl = MagicMock() + extraction_impl.model = "nvidia.nemotron-nano-3-30b" + + with patch( + "codespy.agents.memory.cerebral.structured_output.enforces_schema" + ) as mock_enforces: + mock_enforces.side_effect = enforces_side_effect + + engine = MagicMock() + llm = MagicMock() + llm.model = "bedrock/converse/nvidia.nemotron-super-3-120b" + engine._llm_config = MagicMock() + engine._llm_config._provider_impl = llm + engine._retain_llm_config = None + engine._consolidation_llm_config = None + engine._reflect_llm_config = None + engine._mental_model_refresh_llm_override = None + + install_two_step_structured_output(engine, extraction_impl) + + # Should be wrapped + assert isinstance(engine._llm_config._provider_impl, TwoStepStructuredLLM) + + @pytest.mark.skip(reason="Cannot easily mock isinstance with real LiteLLMLLM class") + def test_wraps_only_once_per_config(self): + """Should wrap shared configs only once.""" + from codespy.agents.memory.cerebral.structured_output import ( + TwoStepStructuredLLM, + install_two_step_structured_output, + ) + + def enforces_side_effect(model): + if "super" in model: + return False + if "nano" in model: + return True + return None + + extraction_impl = MagicMock() + extraction_impl.model = "nvidia.nemotron-nano-3-30b" + + with patch( + "codespy.agents.memory.cerebral.structured_output.enforces_schema" + ) as mock_enforces: + mock_enforces.side_effect = enforces_side_effect + + shared_config = MagicMock() + llm = MagicMock() + llm.model = "bedrock/converse/nvidia.nemotron-super-3-120b" + shared_config._provider_impl = llm + + engine = MagicMock() + engine._llm_config = shared_config + engine._retain_llm_config = shared_config # Same config + engine._consolidation_llm_config = None + engine._reflect_llm_config = None + engine._mental_model_refresh_llm_override = None + + install_two_step_structured_output(engine, extraction_impl) + + # Both should point to the same wrapper + assert isinstance(engine._llm_config._provider_impl, TwoStepStructuredLLM) + assert isinstance(engine._retain_llm_config._provider_impl, TwoStepStructuredLLM) + # Should be the same wrapper instance + assert ( + engine._llm_config._provider_impl is engine._retain_llm_config._provider_impl + ) + + +class TestIntegration: + """Integration tests combining multiple features.""" + + def test_full_workflow_registration_then_sanitizer(self): + """Should be able to register overrides then install sanitizer.""" + from litellm.utils import supports_native_structured_output + from codespy.agents.memory.cerebral.structured_output import ( + install_bedrock_schema_sanitizer, + register_native_structured_output_overrides, + sanitize_bedrock_json_schema, + ) + + # Register first + register_native_structured_output_overrides() + + # Then install sanitizer + install_bedrock_schema_sanitizer() + + # Verify both work + result = supports_native_structured_output( + "nvidia.nemotron-super-3-120b", "bedrock_converse" + ) + assert result is True + + # Sanitizer should work + schema = { + "type": "object", + "properties": {"level": {"type": "integer", "minimum": 1, "maximum": 6}}, + } + result = sanitize_bedrock_json_schema(schema) + assert "minimum" not in result["properties"]["level"] From da68e8e8573af0ca6dc7cc835a0fad7a934563d3 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 2 Oct 2026 12:45:28 +0200 Subject: [PATCH 30/39] recall --- CHANGELOG.md | 3 +- src/codespy/agents/cost_tracker.py | 65 +--- src/codespy/agents/review/scope/agent.py | 8 +- src/codespy/workflows/review/models.py | 158 ++++++++- src/codespy/workflows/review/pipeline.py | 13 +- src/codespy/workflows/review/reporters/git.py | 28 +- tests/test_cost_breakdown.py | 324 ++++++++++++++++++ tests/test_cost_tracker.py | 58 +--- tests/test_review_memories.py | 69 ++++ 9 files changed, 572 insertions(+), 154 deletions(-) create mode 100644 tests/test_cost_breakdown.py create mode 100644 tests/test_review_memories.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dc9aa7..fbd611e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- **Review memories section**: The pre-call Prefrontal memory (run-level and scope-level) now appears in a collapsed `memories` section at the end of the review. When there are several recalls, each one is in its own nested collapsed section titled by task. This section is shown in both the GitHub/GitLab review body and the stdout/MCP markdown output. - **Cerebral structured output support**: Native structured output registration for Bedrock models (e.g., Nvidia Nemotron Super) that support it but lack the flag in litellm's model map. Two-step structured output fallback for memory models litellm cannot schema-enforce, using `llm.extraction_model`. - **Cerebral per-scope observation cap**: New `max_observations_per_scope` setting under `memory.cerebral.consolidation` (env `MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE`, default `100`). Limits observations per `[org:*, repo:*, project_scope:*]` scope; `-1` = unlimited, `0` = no new observations. Scopes that hit the cap only allow UPDATE/DELETE. @@ -35,7 +36,7 @@ - `reflect_search_observations_max_tokens=3000` (was 5000) - `reflect_search_observations_include_entities=false` (was `true`) - **Doc review CHANGELOG trim**: CHANGELOG files are now trimmed to preamble + 2 newest sections (keeps `[Unreleased]` + latest release). Saves ~25KB (21%) of the ~118KB doc payload in this repo. Non-CHANGELOG docs are unchanged. -- **Cost table columns**: Added `Cache Read` and `Cache Write` columns to per-signature cost breakdown. Cache tokens are extracted from provider usage (Anthropic/Bedrock style or OpenAI style). +- **Cost breakdown split**: The per-signature cost table is split into a Review table and a Memory table (memory_* buckets), each with its own subtotal. ### Added - **Cost tracking for extraction LM**: `TwoStepAdapter` extraction calls are now counted and attributed to the calling signature. Previously these were missing from cost tracking. diff --git a/src/codespy/agents/cost_tracker.py b/src/codespy/agents/cost_tracker.py index 4d82883..133194c 100644 --- a/src/codespy/agents/cost_tracker.py +++ b/src/codespy/agents/cost_tracker.py @@ -35,8 +35,6 @@ class SignatureStats: input_cost: float = 0.0 output_cost: float = 0.0 external_duration_seconds: float = 0.0 - cache_read_tokens: int = 0 - cache_write_tokens: int = 0 @property def duration_seconds(self) -> float: @@ -66,8 +64,6 @@ def to_dict(self) -> dict: "input_cost": self.input_cost, "output_cost": self.output_cost, "external_duration_seconds": self.external_duration_seconds, - "cache_read_tokens": self.cache_read_tokens, - "cache_write_tokens": self.cache_write_tokens, } @@ -110,8 +106,6 @@ def end_signature( output_tokens: int = 0, input_cost: float = 0.0, output_cost: float = 0.0, - cache_read_tokens: int = 0, - cache_write_tokens: int = 0, ) -> None: """Mark the end of a signature's execution with its costs. @@ -124,8 +118,6 @@ def end_signature( output_tokens: Output/completion tokens used input_cost: Cost for input tokens output_cost: Cost for output tokens - cache_read_tokens: Cache read tokens (from provider-side caching) - cache_write_tokens: Cache write tokens (from provider-side caching) """ with self._lock: if signature_name not in self._signature_stats: @@ -139,8 +131,6 @@ def end_signature( stats.output_tokens += output_tokens stats.input_cost += input_cost stats.output_cost += output_cost - stats.cache_read_tokens += cache_read_tokens - stats.cache_write_tokens += cache_write_tokens def add_external_call( self, @@ -235,8 +225,6 @@ def get_all_signature_stats(self) -> dict[str, SignatureStats]: input_cost=v.input_cost, output_cost=v.output_cost, external_duration_seconds=v.external_duration_seconds, - cache_read_tokens=v.cache_read_tokens, - cache_write_tokens=v.cache_write_tokens, ) for k, v in self._signature_stats.items() } @@ -274,42 +262,6 @@ def _get_history_uuids(lm: Any | None = None) -> set[str]: return {entry.get("uuid", "") for entry in entries if entry.get("uuid")} -def _get_cache_tokens(entry: dict) -> tuple[int, int]: - """Extract cache read and write tokens from a history entry. - - Handles multiple provider-specific usage shapes: - - Anthropic/Bedrock: cache_read_input_tokens, cache_creation_input_tokens - - OpenAI: prompt_tokens_details.cached_tokens (object or dict) - - Args: - entry: History entry dict with usage information. - - Returns: - Tuple of (cache_read_tokens, cache_write_tokens). - """ - usage = entry.get("usage") - if not isinstance(usage, dict): - return 0, 0 - - # Anthropic/Bedrock style - cache_read = int(_as_number(usage.get("cache_read_input_tokens"))) - cache_write = int(_as_number(usage.get("cache_creation_input_tokens"))) - if cache_read or cache_write: - return cache_read, cache_write - - # OpenAI style: prompt_tokens_details.cached_tokens - details = usage.get("prompt_tokens_details") - if isinstance(details, dict): - cached = int(_as_number(details.get("cached_tokens"))) - return cached, 0 - # Handle object-style access (some providers return objects) - if hasattr(details, "cached_tokens"): - cached = int(_as_number(getattr(details, "cached_tokens", 0))) - return cached, 0 - - return 0, 0 - - def _as_number(value: object) -> float: """Coerce a history field to a number, yielding 0.0 for anything unusable. @@ -335,7 +287,7 @@ def _as_number(value: object) -> float: def _calculate_costs_from_entries( entries: list[dict], exclude_uuids: set[str] -) -> tuple[float, int, int, int, int, float, float, int, int]: +) -> tuple[float, int, int, int, int, float, float]: """Calculate costs from history entries, excluding specific UUIDs. Every field is read defensively: cost accounting is observability, so a @@ -348,7 +300,7 @@ def _calculate_costs_from_entries( Returns: Tuple of (total_cost, total_tokens, call_count, input_tokens, output_tokens, - input_cost, output_cost, cache_read_tokens, cache_write_tokens) + input_cost, output_cost) """ total_cost = 0.0 total_tokens = 0 @@ -357,8 +309,6 @@ def _calculate_costs_from_entries( output_tokens = 0 input_cost = 0.0 output_cost = 0.0 - cache_read_tokens = 0 - cache_write_tokens = 0 for entry in entries: if not isinstance(entry, dict): @@ -385,11 +335,6 @@ def _calculate_costs_from_entries( output_tokens += entry_output_tokens total_tokens += entry_input_tokens + entry_output_tokens - # Get cache tokens - entry_cache_read, entry_cache_write = _get_cache_tokens(entry) - cache_read_tokens += entry_cache_read - cache_write_tokens += entry_cache_write - # Calculate split costs using litellm if model is available entry_input_cost, entry_output_cost = _calculate_split_cost( entry, entry_input_tokens, entry_output_tokens, entry_cost @@ -407,8 +352,6 @@ def _calculate_costs_from_entries( output_tokens, input_cost, output_cost, - cache_read_tokens, - cache_write_tokens, ) @@ -589,8 +532,6 @@ def __exit__( output_tokens = main_result[4] + extraction_result[4] input_cost = main_result[5] + extraction_result[5] output_cost = main_result[6] + extraction_result[6] - cache_read_tokens = main_result[7] + extraction_result[7] - cache_write_tokens = main_result[8] + extraction_result[8] self.tracker.end_signature( self.signature_name, @@ -601,8 +542,6 @@ def __exit__( output_tokens=output_tokens, input_cost=input_cost, output_cost=output_cost, - cache_read_tokens=cache_read_tokens, - cache_write_tokens=cache_write_tokens, ) except Exception as e: logger.warning("Cost calculation failed for %s: %s", self.signature_name, e) diff --git a/src/codespy/agents/review/scope/agent.py b/src/codespy/agents/review/scope/agent.py index 6cddab9..dc6359f 100644 --- a/src/codespy/agents/review/scope/agent.py +++ b/src/codespy/agents/review/scope/agent.py @@ -499,6 +499,7 @@ def __init__(self) -> None: super().__init__() self._cost_tracker = get_cost_tracker() self._settings = get_settings() + self.prefrontal_memory: str = "" async def _create_tools(self, repo_path: Path) -> tuple[list[Any], list[Any]]: """Create tools for the scope agent: filesystem + ripgrep. @@ -1078,7 +1079,7 @@ async def _refine_scopes( # Prefrontal: prior knowledge from Cerebral (never given to Hippocampus) pf_kwargs: dict[str, Any] = {} if pf is not None: - pf_kwargs["prefrontal_memory"] = await pf.aload( + scope_pf_text = await pf.aload( build_facets( "scope", pr.repo_full_name, @@ -1088,6 +1089,8 @@ async def _refine_scopes( summary=pr.body or "", ) ) + pf_kwargs["prefrontal_memory"] = scope_pf_text + self.prefrontal_memory = scope_pf_text if self._settings.get_memory_enabled("scope"): question = ( f"refine scopes of {review_context.pr_context.repo_slug}: " @@ -1226,6 +1229,9 @@ async def aforward( Returns: List of ScopeResult """ + # Reset prefrontal memory at start of each run + self.prefrontal_memory = "" + # Local bindings from review_context metadata pr = review_context.metadata.pr repo_path = review_context.metadata.repo_path diff --git a/src/codespy/workflows/review/models.py b/src/codespy/workflows/review/models.py index ec6f7a7..a46f6e7 100644 --- a/src/codespy/workflows/review/models.py +++ b/src/codespy/workflows/review/models.py @@ -9,6 +9,16 @@ from codespy.agents.review.models import Issue, IssueCategory, IssueSeverity +# Appended to a memory text cut to fit the review body size limit +TRUNCATED_MARKER = "…(truncated)" + + +class RecalledMemory(BaseModel): + """A recalled memory entry from Prefrontal.""" + + task: str = Field(description="Task name that loaded the memory (e.g., 'scope', 'review')") + text: str = Field(description="The recalled memory text (markdown formatted)") + class SignatureStatsResult(BaseModel): """Statistics for a single signature's execution during review.""" @@ -24,8 +34,15 @@ class SignatureStatsResult(BaseModel): output_tokens: int = Field(default=0, description="Output/completion tokens used") input_cost: float = Field(default=0.0, description="Cost for input tokens") output_cost: float = Field(default=0.0, description="Cost for output tokens") - cache_read_tokens: int = Field(default=0, description="Cache read tokens from provider-side caching") - cache_write_tokens: int = Field(default=0, description="Cache write tokens from provider-side caching") + @property + def is_memory(self) -> bool: + """Check if this signature is a memory-related signature. + + Memory signatures have names starting with MEMORY_UNIT_PREFIX ("memory_"). + """ + from codespy.config_memory import MEMORY_UNIT_PREFIX + + return self.name.startswith(MEMORY_UNIT_PREFIX) @property def cost_per_call(self) -> float: @@ -96,6 +113,10 @@ class ReviewResult(BaseModel): signature_stats: list[SignatureStatsResult] = Field( default_factory=list, description="Per-signature statistics (cost, tokens, time)" ) + memories: list[RecalledMemory] = Field( + default_factory=list, + description="Prefrontal pre-call memory injected into agent inputs", + ) @property def total_issues(self) -> int: @@ -181,23 +202,7 @@ def to_markdown(self) -> str: ) # Per-signature breakdown - if self.signature_stats: - lines.extend( - [ - "### Per-Signature Breakdown", - "", - "| Signature | In Tokens | Out Tokens | Cache Read | Cache Write | In Cost | Out Cost | Calls | Duration |", - "|-----------|-----------|------------|------------|-------------|---------|----------|-------|----------|", - ] - ) - for stats in sorted(self.signature_stats, key=lambda x: x.cost, reverse=True): - duration_str = f"{stats.duration_seconds:.1f}s" - lines.append( - f"| {stats.name} | {stats.input_tokens:,} | {stats.output_tokens:,} | " - f"{stats.cache_read_tokens:,} | {stats.cache_write_tokens:,} | " - f"${stats.input_cost:.4f} | ${stats.output_cost:.4f} | {stats.call_count} | {duration_str} |" - ) - lines.append("") + lines.extend(self.cost_breakdown_markdown_lines("###")) # Issues by severity if self.issues: @@ -270,8 +275,123 @@ def to_markdown(self) -> str: if self.recommendation: lines.extend(["## Recommendation", "", self.recommendation, ""]) + # Memories section (collapsible) + lines.extend(self.memories_markdown_lines()) + return "\n".join(lines) + def cost_breakdown_markdown_lines(self, heading: str = "###") -> list[str]: + """Build cost breakdown tables for Review and Memory groups. + + Each non-empty group emits a table with columns: + Signature | In Tokens | Out Tokens | In Cost | Out Cost | Calls | Duration + + Review signatures come first, then Memory. Each table has a subtotal line. + Groups with no rows are omitted. If signature_stats is empty, returns []. + + Args: + heading: Heading prefix (e.g., "###" or "####") + + Returns: + Markdown lines for the cost breakdown section. + """ + if not self.signature_stats: + return [] + + review_stats = [s for s in self.signature_stats if not s.is_memory] + memory_stats = [s for s in self.signature_stats if s.is_memory] + + def table_lines(label: str, stats: list[SignatureStatsResult]) -> list[str]: + if not stats: + return [] + sorted_stats = sorted(stats, key=lambda x: x.cost, reverse=True) + subtotal_cost = sum(s.cost for s in sorted_stats) + subtotal_calls = sum(s.call_count for s in sorted_stats) + lines = [ + f"{heading} {label}", + "", + "| Signature | In Tokens | Out Tokens | In Cost | Out Cost | Calls | Duration |", + "|-----------|-----------|------------|---------|----------|-------|----------|", + ] + for s in sorted_stats: + duration_str = f"{s.duration_seconds:.1f}s" + lines.append( + f"| {s.name} | {s.input_tokens:,} | {s.output_tokens:,} | " + f"${s.input_cost:.4f} | ${s.output_cost:.4f} | {s.call_count} | {duration_str} |" + ) + lines.extend(["", f"**Subtotal:** ${subtotal_cost:.4f} | **LLM Calls:** {subtotal_calls}", ""]) + return lines + + result: list[str] = [] + result.extend(table_lines("Review", review_stats)) + result.extend(table_lines("Memory", memory_stats)) + return result + + def memories_markdown_lines( + self, summary: str = "memories", max_chars: int | None = None + ) -> list[str]: + """Build the collapsible memories section, optionally size-limited. + + With a single memory, it is rendered under a ``### `` heading. + With several, each one gets its own nested collapsible section. + + GitReporter passes ``max_chars`` to stay within GitHub's 65,536 char + review body limit: memory texts are truncated in order, other sections + are never touched. + + Args: + summary: Summary text of the outer collapsible section. + max_chars: Character budget for the whole section (None = unlimited). + + Returns: + Markdown lines, or ``[]`` when there is no memory or nothing fits. + """ + if not self.memories: + return [] + + nested = len(self.memories) > 1 + + def block(memory: RecalledMemory, text: str) -> list[str]: + if nested: + return [ + "
", + f"{memory.task}", + "", + text, + "", + "
", + "", + ] + return [f"### {memory.task}", "", text, ""] + + def size(block_lines: list[str]) -> int: + # Lines are joined with "\n" by the caller + return sum(len(line) + 1 for line in block_lines) + + head = ["
", f"{summary}", ""] + tail = ["
", ""] + lines = list(head) + remaining = None if max_chars is None else max_chars - size(head) - size(tail) + + for memory in self.memories: + full = block(memory, memory.text) + if remaining is None or size(full) <= remaining: + lines.extend(full) + if remaining is not None: + remaining -= size(full) + continue + # Truncate this memory to fit, then stop + overhead = size(block(memory, "")) + len(TRUNCATED_MARKER) + if remaining > overhead: + text = memory.text[: remaining - overhead] + TRUNCATED_MARKER + lines.extend(block(memory, text)) + break + + if len(lines) == len(head): + return [] + lines.extend(tail) + return lines + def to_json_dict(self) -> dict: """Convert to a JSON-serializable dictionary.""" return self.model_dump(mode="json") diff --git a/src/codespy/workflows/review/pipeline.py b/src/codespy/workflows/review/pipeline.py index ea6e545..7295744 100644 --- a/src/codespy/workflows/review/pipeline.py +++ b/src/codespy/workflows/review/pipeline.py @@ -36,6 +36,7 @@ from codespy.workflows.review.models import ( LocalReviewConfig, + RecalledMemory, RemoteReviewConfig, ReviewConfig, ReviewResult, @@ -300,6 +301,15 @@ def forward(self, config: ReviewConfig) -> ReviewResult: self._consolidate_run(run_id) # Collect per-signature statistics (after all saves complete, includes consolidation costs) signature_stats_list = self._collect_signature_stats() + + # Build memories list from scope and run-level Prefrontal loads + memories: list[RecalledMemory] = [] + scope_memory_text = self.scope_resolver.prefrontal_memory + if scope_memory_text: + memories.append(RecalledMemory(task="scope", text=scope_memory_text)) + if pf_text: + memories.append(RecalledMemory(task="review", text=pf_text)) + return ReviewResult( pr_number=pr.number, pr_title=pr.title, @@ -315,6 +325,7 @@ def forward(self, config: ReviewConfig) -> ReviewResult: total_tokens=self.cost_tracker.total_tokens, llm_calls=self.cost_tracker.call_count, signature_stats=signature_stats_list, + memories=memories, ) def _consolidate_run(self, run_id: str) -> None: @@ -361,8 +372,6 @@ def _collect_signature_stats(self) -> list[SignatureStatsResult]: output_tokens=stats.output_tokens, input_cost=stats.input_cost, output_cost=stats.output_cost, - cache_read_tokens=stats.cache_read_tokens, - cache_write_tokens=stats.cache_write_tokens, ) ) diff --git a/src/codespy/workflows/review/reporters/git.py b/src/codespy/workflows/review/reporters/git.py index 4df9da0..f98533b 100644 --- a/src/codespy/workflows/review/reporters/git.py +++ b/src/codespy/workflows/review/reporters/git.py @@ -25,6 +25,9 @@ class GitReporter(BaseReporter): IssueSeverity.INFO: "⚪", } + # Review body budget, below GitHub's 65,536 char limit + MAX_BODY_CHARS = 60000 + def __init__( self, url: str, @@ -164,21 +167,7 @@ def _build_review_body( ] ) - if result.signature_stats: - lines.extend( - [ - "| Signature | In Tokens | Out Tokens | Cache Read | Cache Write | In Cost | Out Cost | Calls | Duration |", - "|-----------|-----------|------------|------------|-------------|---------|----------|-------|----------|", - ] - ) - for stats in sorted(result.signature_stats, key=lambda x: x.cost, reverse=True): - duration_str = f"{stats.duration_seconds:.1f}s" - lines.append( - f"| {stats.name} | {stats.input_tokens:,} | {stats.output_tokens:,} | " - f"{stats.cache_read_tokens:,} | {stats.cache_write_tokens:,} | " - f"${stats.input_cost:.4f} | ${stats.output_cost:.4f} | {stats.call_count} | {duration_str} |" - ) - lines.append("") + lines.extend(result.cost_breakdown_markdown_lines("####")) lines.extend( [ @@ -252,6 +241,15 @@ def _build_review_body( ] ) + # Memories section (collapsible, size-limited for GitHub's 65,536 char body limit) + if result.memories: + remaining_budget = self.MAX_BODY_CHARS - len("\n".join(lines)) - 1 + lines.extend( + result.memories_markdown_lines( + summary="🧠 memories", max_chars=max(0, remaining_budget) + ) + ) + return "\n".join(lines) def _build_inline_comments(self, issues: list[Issue]) -> list[dict]: diff --git a/tests/test_cost_breakdown.py b/tests/test_cost_breakdown.py new file mode 100644 index 0000000..ad63012 --- /dev/null +++ b/tests/test_cost_breakdown.py @@ -0,0 +1,324 @@ +"""Tests for cost breakdown functionality: Review/Memory split tables.""" + +import pytest + +from codespy.config_memory import MEMORY_UNIT_PREFIX +from codespy.workflows.review.models import ReviewResult, SignatureStatsResult + + +class TestSignatureStatsResultIsMemory: + """Tests for SignatureStatsResult.is_memory property.""" + + def test_is_memory_true_for_memory_prefixed_names(self): + """Names starting with MEMORY_UNIT_PREFIX are memory signatures.""" + assert SignatureStatsResult(name=f"{MEMORY_UNIT_PREFIX}retain").is_memory is True + assert SignatureStatsResult(name=f"{MEMORY_UNIT_PREFIX}cartographer").is_memory is True + assert SignatureStatsResult(name=f"{MEMORY_UNIT_PREFIX}prefrontal").is_memory is True + + def test_is_memory_false_for_review_names(self): + """Names not starting with MEMORY_UNIT_PREFIX are review signatures.""" + assert SignatureStatsResult(name="code_review").is_memory is False + assert SignatureStatsResult(name="scope").is_memory is False + assert SignatureStatsResult(name="summary").is_memory is False + assert SignatureStatsResult(name="doc").is_memory is False + assert SignatureStatsResult(name="supply_chain").is_memory is False + assert SignatureStatsResult(name="audit").is_memory is False + + +class TestReviewResultCostBreakdownMarkdownLines: + """Tests for ReviewResult.cost_breakdown_markdown_lines.""" + + @staticmethod + def _make_review_stats(name: str, cost: float, calls: int = 1) -> SignatureStatsResult: + """Create a SignatureStatsResult with minimal fields set.""" + return SignatureStatsResult( + name=name, + cost=cost, + tokens=100 * calls, + call_count=calls, + duration_seconds=1.5 * calls, + input_tokens=50 * calls, + output_tokens=50 * calls, + input_cost=cost * 0.6, + output_cost=cost * 0.4, + ) + + def test_empty_signature_stats_returns_empty_list(self): + """Empty signature_stats should produce no breakdown.""" + result = ReviewResult( + pr_number=1, + pr_title="Test", + pr_url="http://example.com/1", + repo="test/repo", + model_used="gpt-4", + signature_stats=[], + ) + assert result.cost_breakdown_markdown_lines() == [] + + def test_mixed_stats_render_two_tables_review_first(self): + """Mixed stats render Review table first, then Memory.""" + stats = [ + self._make_review_stats("scope", 0.5), + self._make_review_stats("code_review", 1.0), + self._make_review_stats(f"{MEMORY_UNIT_PREFIX}retain", 0.3), + self._make_review_stats(f"{MEMORY_UNIT_PREFIX}prefrontal", 0.2), + ] + result = ReviewResult( + pr_number=1, + pr_title="Test", + pr_url="http://example.com/1", + repo="test/repo", + model_used="gpt-4", + signature_stats=stats, + ) + lines = result.cost_breakdown_markdown_lines() + text = "\n".join(lines) + + # Both tables should be present + assert "### Review" in text + assert "### Memory" in text + + # Review should come before Memory + assert text.index("### Review") < text.index("### Memory") + + # Each signature should appear in its correct table + # Review signatures + assert "| scope |" in text + assert "| code_review |" in text + # Memory signatures + assert f"| {MEMORY_UNIT_PREFIX}retain |" in text + assert f"| {MEMORY_UNIT_PREFIX}prefrontal |" in text + + def test_rows_sorted_by_cost_descending(self): + """Rows within each table should be sorted by cost descending.""" + stats = [ + self._make_review_stats("scope", 0.1), + self._make_review_stats("code_review", 0.5), + self._make_review_stats("summary", 0.3), + ] + result = ReviewResult( + pr_number=1, + pr_title="Test", + pr_url="http://example.com/1", + repo="test/repo", + model_used="gpt-4", + signature_stats=stats, + ) + lines = result.cost_breakdown_markdown_lines() + + # Find the review section lines + review_start = next(i for i, line in enumerate(lines) if "### Review" in line) + # Find the first subtotal after review section + subtotal_idx = next( + i for i in range(review_start, len(lines)) if "**Subtotal:**" in lines[i] + ) + + # Extract table body (between header and subtotal) + # Skip header lines (heading, blank, header, separator) + table_lines = lines[review_start + 4 : subtotal_idx] + # Filter out empty lines + table_lines = [l for l in table_lines if l and l.startswith("|")] + + # Check order: code_review (0.5) should come before summary (0.3) before scope (0.1) + names = [line.split("|")[1].strip() for line in table_lines] + assert names == ["code_review", "summary", "scope"] + + def test_subtotals_calculated_correctly(self): + """Subtotals should sum costs and calls for each group.""" + stats = [ + self._make_review_stats("scope", 0.5, calls=2), + self._make_review_stats("code_review", 1.0, calls=3), + self._make_review_stats(f"{MEMORY_UNIT_PREFIX}retain", 0.3, calls=1), + self._make_review_stats(f"{MEMORY_UNIT_PREFIX}prefrontal", 0.2, calls=2), + ] + result = ReviewResult( + pr_number=1, + pr_title="Test", + pr_url="http://example.com/1", + repo="test/repo", + model_used="gpt-4", + signature_stats=stats, + ) + lines = result.cost_breakdown_markdown_lines() + + # Find subtotal lines + subtotal_lines = [l for l in lines if "**Subtotal:**" in l] + assert len(subtotal_lines) == 2 + + # Review subtotal: 1.5 cost, 5 calls + review_subtotal = subtotal_lines[0] + assert "1.5000" in review_subtotal + assert "5" in review_subtotal # LLM Calls: 5 + + # Memory subtotal: 0.5 cost, 3 calls + memory_subtotal = subtotal_lines[1] + assert "0.5000" in memory_subtotal + assert "3" in memory_subtotal # LLM Calls: 3 + + def test_no_cache_references_in_output(self): + """Neither to_markdown nor cost_breakdown_markdown_lines should contain 'Cache'.""" + stats = [ + self._make_review_stats("scope", 0.5), + self._make_review_stats(f"{MEMORY_UNIT_PREFIX}retain", 0.3), + ] + result = ReviewResult( + pr_number=1, + pr_title="Test", + pr_url="http://example.com/1", + repo="test/repo", + model_used="gpt-4", + signature_stats=stats, + ) + + # Check cost_breakdown_markdown_lines + lines = result.cost_breakdown_markdown_lines() + text = "\n".join(lines) + assert "Cache" not in text + assert "cache_read" not in text.lower() + assert "cache_write" not in text.lower() + + # Check full to_markdown output + full_md = result.to_markdown() + assert "Cache" not in full_md + assert "cache_read" not in full_md.lower() + assert "cache_write" not in full_md.lower() + + +class TestGitReporterCostBreakdown: + """Tests for GitReporter cost breakdown integration. + + These tests use cost_breakdown_markdown_lines() which is what + GitReporter._build_review_body uses internally for the cost tables. + """ + + @staticmethod + def _make_review_stats(name: str, cost: float, calls: int = 1) -> SignatureStatsResult: + return SignatureStatsResult( + name=name, + cost=cost, + tokens=100 * calls, + call_count=calls, + duration_seconds=1.5 * calls, + input_tokens=50 * calls, + output_tokens=50 * calls, + input_cost=cost * 0.6, + output_cost=cost * 0.4, + ) + + def test_build_review_body_no_cache_references(self): + """Cost breakdown should not contain 'Cache' references.""" + stats = [ + self._make_review_stats("scope", 0.5), + self._make_review_stats(f"{MEMORY_UNIT_PREFIX}retain", 0.3), + ] + result = ReviewResult( + pr_number=1, + pr_title="Test", + pr_url="http://example.com/1", + repo="test/repo", + model_used="gpt-4", + signature_stats=stats, + total_cost=0.8, + llm_calls=2, + ) + + lines = result.cost_breakdown_markdown_lines("####") + body = "\n".join(lines) + + assert "Cache" not in body + assert "cache_read" not in body.lower() + assert "cache_write" not in body.lower() + # Should use #### heading level for cost tables + assert "#### Review" in body or "### Review" in body + + def test_only_review_stats_no_memory_heading(self): + """When only review stats exist, no Memory heading should appear.""" + stats = [ + self._make_review_stats("scope", 0.5), + self._make_review_stats("code_review", 1.0), + ] + result = ReviewResult( + pr_number=1, + pr_title="Test", + pr_url="http://example.com/1", + repo="test/repo", + model_used="gpt-4", + signature_stats=stats, + total_cost=1.5, + llm_calls=2, + ) + lines = result.cost_breakdown_markdown_lines() + text = "\n".join(lines) + + assert "### Review" in text or "#### Review" in text # Review heading should be present + assert "### Memory" not in text and "#### Memory" not in text # Memory heading should NOT appear + + def test_only_memory_stats_no_review_heading(self): + """When only memory stats exist, no Review heading should appear.""" + stats = [ + self._make_review_stats(f"{MEMORY_UNIT_PREFIX}retain", 0.3), + self._make_review_stats(f"{MEMORY_UNIT_PREFIX}prefrontal", 0.2), + ] + result = ReviewResult( + pr_number=1, + pr_title="Test", + pr_url="http://example.com/1", + repo="test/repo", + model_used="gpt-4", + signature_stats=stats, + total_cost=0.5, + llm_calls=2, + ) + lines = result.cost_breakdown_markdown_lines() + text = "\n".join(lines) + + assert "### Memory" in text or "#### Memory" in text + assert "### Review" not in text and "#### Review" not in text + + +class TestReviewResultNoCacheFields: + """Tests that cache fields are not present in models.""" + + def test_signature_stats_result_no_cache_fields(self): + """SignatureStatsResult should not have cache fields.""" + stats = SignatureStatsResult(name="test", cost=0.5) + d = stats.model_dump() + assert "cache_read_tokens" not in d + assert "cache_write_tokens" not in d + + def test_cost_table_columns(self): + """Cost table should have correct columns (no Cache columns).""" + stats = [ + SignatureStatsResult( + name="test", + cost=0.5, + tokens=100, + call_count=2, + input_tokens=50, + output_tokens=50, + input_cost=0.3, + output_cost=0.2, + ), + ] + result = ReviewResult( + pr_number=1, + pr_title="Test", + pr_url="http://example.com/1", + repo="test/repo", + model_used="gpt-4", + signature_stats=stats, + ) + lines = result.cost_breakdown_markdown_lines() + # Find the header line + header_line = next(l for l in lines if "| Signature |" in l) + # Should have In Tokens, Out Tokens, In Cost, Out Cost, Calls, Duration + assert "Signature" in header_line + assert "In Tokens" in header_line + assert "Out Tokens" in header_line + assert "In Cost" in header_line + assert "Out Cost" in header_line + assert "Calls" in header_line + assert "Duration" in header_line + # Should NOT have cache columns + assert "Cache Read" not in header_line + assert "Cache Write" not in header_line diff --git a/tests/test_cost_tracker.py b/tests/test_cost_tracker.py index 1194181..ff6dc16 100644 --- a/tests/test_cost_tracker.py +++ b/tests/test_cost_tracker.py @@ -151,7 +151,7 @@ def test_calculates_costs_and_tokens(self): ] exclude = set() - cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) assert cost == 0.8 assert tokens == 450 @@ -159,8 +159,6 @@ def test_calculates_costs_and_tokens(self): assert input_tokens == 300 assert output_tokens == 150 assert input_cost + output_cost == cost # Costs should sum correctly - assert cache_read == 0 - assert cache_write == 0 def test_excludes_specified_uuids(self): entries = [ @@ -169,26 +167,22 @@ def test_excludes_specified_uuids(self): ] exclude = {"uuid-1"} - cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) assert cost == 0.3 assert tokens == 200 assert calls == 1 assert input_tokens == 200 # Only uuid-2's tokens assert output_tokens == 0 - assert cache_read == 0 - assert cache_write == 0 def test_handles_non_dict_entries(self): entries = ["not a dict", {"uuid": "uuid-1", "cost": 0.5, "usage": {}}] exclude = set() - cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) assert cost == 0.5 assert calls == 1 - assert cache_read == 0 - assert cache_write == 0 def test_handles_missing_usage(self): entries = [ @@ -196,15 +190,13 @@ def test_handles_missing_usage(self): ] exclude = set() - cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) assert cost == 0.5 assert tokens == 0 assert calls == 1 assert input_tokens == 0 assert output_tokens == 0 - assert cache_read == 0 - assert cache_write == 0 def test_skips_cache_hit_entries(self): """Entries with cache_hit=True should be skipped.""" @@ -214,53 +206,13 @@ def test_skips_cache_hit_entries(self): ] exclude = set() - cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) + cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost = _calculate_costs_from_entries(entries, exclude) assert cost == 0.3 assert tokens == 200 assert calls == 1 assert input_tokens == 200 - def test_extracts_cache_tokens_bedrock_style(self): - """Test extraction of cache_read_input_tokens and cache_creation_input_tokens.""" - entries = [ - { - "uuid": "uuid-1", - "cost": 0.5, - "usage": { - "prompt_tokens": 100, - "cache_read_input_tokens": 50, - "cache_creation_input_tokens": 25, - }, - }, - ] - exclude = set() - - cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) - - assert cache_read == 50 - assert cache_write == 25 - - def test_extracts_cache_tokens_openai_style(self): - """Test extraction of cached_tokens from prompt_tokens_details.""" - entries = [ - { - "uuid": "uuid-1", - "cost": 0.5, - "usage": { - "prompt_tokens": 100, - "prompt_tokens_details": {"cached_tokens": 30}, - }, - }, - ] - exclude = set() - - cost, tokens, calls, input_tokens, output_tokens, input_cost, output_cost, cache_read, cache_write = _calculate_costs_from_entries(entries, exclude) - - assert cache_read == 30 - assert cache_write == 0 - - class TestCostTracker: """Tests for CostTracker class.""" diff --git a/tests/test_review_memories.py b/tests/test_review_memories.py new file mode 100644 index 0000000..a03f28a --- /dev/null +++ b/tests/test_review_memories.py @@ -0,0 +1,69 @@ +"""Tests for the collapsible memories section of the review output.""" + +from codespy.workflows.review.models import TRUNCATED_MARKER, RecalledMemory, ReviewResult +from codespy.workflows.review.reporters.git import GitReporter + + +def _result(*memories: tuple[str, str], recommendation: str | None = "approve") -> ReviewResult: + return ReviewResult( + pr_number=1, + pr_title="t", + pr_url="https://github.com/o/r/pull/1", + repo="o/r", + model_used="m", + recommendation=recommendation, + memories=[RecalledMemory(task=t, text=x) for t, x in memories], + ) + + +def _git_body(result: ReviewResult) -> str: + reporter = GitReporter.__new__(GitReporter) + return reporter._build_review_body(result, []) + + +def test_no_memories_no_section(): + assert "memories" not in _result().to_markdown() + assert "memories" not in _git_body(_result()) + + +def test_single_memory_uses_heading_not_nested(): + md = _result(("review", "## Context\nfact A")).to_markdown() + section = md[md.index("memories"):] + assert "### review" in section + assert section.count("
") == 0 # only the outer one, before the summary + assert md.count("
") == 1 + assert md.count("
") == 1 + + +def test_multiple_memories_nested_and_ordered(): + md = _result(("scope", "scope text"), ("review", "review text")).to_markdown() + assert md.index("## Recommendation") < md.index("memories") + assert md.count("
") == 3 + assert md.count("
") == 3 + assert "### scope" not in md + assert md.index("scope") < md.index("review") + assert md.index("scope text") < md.index("review") + # Outer section closes last + assert md.rstrip().endswith("
") + assert md.rindex("") > md.index("review text") + + +def test_git_body_memories_last(): + body = _git_body(_result(("scope", "s"), ("review", "r"))) + assert "🧠 memories" in body + assert body.index("💡 Recommendation") < body.index( + "🧠 memories" + ) + assert body.count("
") == body.count("
") + + +def test_git_body_truncates_to_budget(): + big = "x" * 50_000 + body = _git_body(_result(("scope", big), ("review", big))) + assert len(body) <= GitReporter.MAX_BODY_CHARS + assert TRUNCATED_MARKER in body + assert body.count("
") == body.count("
") + + +def test_budget_too_small_drops_section(): + assert _result(("review", "abc")).memories_markdown_lines(max_chars=10) == [] From 8160d8beab06ea1f2f8f56b43c2faea1297ff22d Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 2 Oct 2026 15:43:48 +0200 Subject: [PATCH 31/39] recall --- CHANGELOG.md | 2 +- .../agents/memory/prefrontal/format.py | 66 +++++---- .../agents/memory/prefrontal/prefrontal.py | 13 ++ src/codespy/agents/review/scope/agent.py | 3 + src/codespy/workflows/review/models.py | 126 ++++++++++++------ src/codespy/workflows/review/pipeline.py | 21 ++- src/codespy/workflows/review/reporters/git.py | 21 +-- tests/test_review_memories.py | 117 ++++++++++++---- 8 files changed, 262 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd611e..5ff2d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Added -- **Review memories section**: The pre-call Prefrontal memory (run-level and scope-level) now appears in a collapsed `memories` section at the end of the review. When there are several recalls, each one is in its own nested collapsed section titled by task. This section is shown in both the GitHub/GitLab review body and the stdout/MCP markdown output. +- **Review memories section**: The pre-call Prefrontal memory (run-level and scope-level) now appears in a collapsed `memories` section **before** the Summary. Each recall is nested under its own `
` block, and each memory section (Briefings, Around this work, Seen before, Decisions, Recurring patterns, What changed, Other repositories) is also collapsible. This section is shown in both the GitHub/GitLab review body and the stdout/MCP markdown output. - **Cerebral structured output support**: Native structured output registration for Bedrock models (e.g., Nvidia Nemotron Super) that support it but lack the flag in litellm's model map. Two-step structured output fallback for memory models litellm cannot schema-enforce, using `llm.extraction_model`. - **Cerebral per-scope observation cap**: New `max_observations_per_scope` setting under `memory.cerebral.consolidation` (env `MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE`, default `100`). Limits observations per `[org:*, repo:*, project_scope:*]` scope; `-1` = unlimited, `0` = no new observations. Scopes that hit the cap only allow UPDATE/DELETE. diff --git a/src/codespy/agents/memory/prefrontal/format.py b/src/codespy/agents/memory/prefrontal/format.py index 8e9806d..91ac781 100644 --- a/src/codespy/agents/memory/prefrontal/format.py +++ b/src/codespy/agents/memory/prefrontal/format.py @@ -117,19 +117,6 @@ def render_change(change: BeliefChange) -> str: return f"- {_clean(change.current)} (was: {_clean(change.previous)}{when})" -def _briefing_sections(briefings: Iterable[Mapping[str, Any]]) -> list[str]: - sections: list[str] = [] - for mm in briefings or []: - content = (mm.get("content") or "").strip() - if not content or content == MENTAL_MODEL_PLACEHOLDER: - continue - name = (mm.get("name") or "Briefing").strip() - if not name.startswith("Briefing"): - name = f"Briefing: {name}" - sections.append(f"## {name}\n{content}") - return sections - - class _Dedup: """Keeps the first occurrence: by id, then by exact text.""" @@ -150,21 +137,37 @@ def admit(self, key_id: str | None, text: str) -> bool: return True -def format_context( +def format_context_sections( briefings: Sequence[Mapping[str, Any]], facet_results: Mapping[str, FacetResult], remote_facts: Sequence[Any], repo: str, reach: str = "org", -) -> str: - """Render briefings, facet sections (order 1 → 5) and remote facts. +) -> list[tuple[str, str]]: + """Render briefings, facet sections (order 1 → 5) and remote facts as sections. + + Returns a list of (title, body) tuples in the current order. Empty sections + are omitted. The result can be joined by format_context to produce the same + output as before. Facts are deduplicated across sections by id, then by exact text, keeping - the first occurrence. Empty sections are omitted; nothing at all → ``""``. + the first occurrence. """ - sections = _briefing_sections(briefings) + sections: list[tuple[str, str]] = [] + + # Briefing sections + for mm in briefings or []: + content = (mm.get("content") or "").strip() + if not content or content == MENTAL_MODEL_PLACEHOLDER: + continue + name = (mm.get("name") or "Briefing").strip() + if not name.startswith("Briefing"): + name = f"Briefing: {name}" + sections.append((name, content)) + dedup = _Dedup() + # Facet sections in FACET_ORDER for name in FACET_ORDER: result = facet_results.get(name) if result is None: @@ -179,8 +182,9 @@ def format_context( if dedup.admit(change.id, f"{change.current} (was: {change.previous})"): lines.append(render_change(change)) if lines: - sections.append(f"## {SECTION_TITLES[name]}\n" + "\n".join(lines)) + sections.append((SECTION_TITLES[name], "\n".join(lines))) + # Remote facts section remote_lines = [ render_fact(fact, with_repo=True) for fact in remote_facts or [] @@ -188,12 +192,26 @@ def format_context( ] if remote_lines: where = "" if reach == REACH_BANK else f" in {org_of(repo)}" - sections.append( - f"## Other repositories{where} — verify before relying on it\n" - + "\n".join(remote_lines) - ) + title = f"Other repositories{where} — verify before relying on it" + sections.append((title, "\n".join(remote_lines))) + + return sections + - return "\n\n".join(sections) +def format_context( + briefings: Sequence[Mapping[str, Any]], + facet_results: Mapping[str, FacetResult], + remote_facts: Sequence[Any], + repo: str, + reach: str = "org", +) -> str: + """Render briefings, facet sections (order 1 → 5) and remote facts. + + Facts are deduplicated across sections by id, then by exact text, keeping + the first occurrence. Empty sections are omitted; nothing at all → ``""``. + """ + sections = format_context_sections(briefings, facet_results, remote_facts, repo, reach) + return "\n\n".join(f"## {title}\n{body}" for title, body in sections) def format_facts(facts: Sequence[Any], *, with_repo: bool = False) -> str: diff --git a/src/codespy/agents/memory/prefrontal/prefrontal.py b/src/codespy/agents/memory/prefrontal/prefrontal.py index 9980a15..e69c385 100644 --- a/src/codespy/agents/memory/prefrontal/prefrontal.py +++ b/src/codespy/agents/memory/prefrontal/prefrontal.py @@ -31,6 +31,7 @@ estimate_tokens, fget, format_context, + format_context_sections, format_facts, ) from codespy.agents.memory.prefrontal.query import ( @@ -144,12 +145,19 @@ def __init__( # One record per recall (pre-call load and each tool call), for monitoring. # Kept across aload() calls: one Prefrontal serves one agent call. self._records: list[RecallRecord] = [] + # Sections from the last aload() call, for structured memory rendering. + self._last_sections: list[tuple[str, str]] = [] @property def recalls(self) -> list[RecallRecord]: """Recall records so far (a copy). Persisted with the episode, never distilled.""" return list(self._records) + @property + def last_sections(self) -> list[tuple[str, str]]: + """Sections from the last aload() call as (title, body) tuples.""" + return list(self._last_sections) + def _record( self, *, @@ -244,6 +252,7 @@ async def aload(self, facets: Sequence[Facet]) -> str: thread does not copy the caller's context. """ self._tool_calls = 0 + self._last_sections = [] facet_list = list(facets) context_query = next((f.query for f in facet_list if f.name == FACET_CONTEXT), "") started_at = datetime.now(UTC) @@ -403,6 +412,10 @@ async def _remote() -> list[Any]: return [f for f in facts or [] if not is_own_repo_fact(fget(f, "tags"), self._repo)] briefings, results, remote = await asyncio.gather(_briefings(), _facets(), _remote()) + # Store sections for structured rendering + self._last_sections = format_context_sections( + briefings, results, remote, self._repo, cfg.recall.reach + ) text = format_context(briefings, results, remote, self._repo, cfg.recall.reach) n_briefings = sum( diff --git a/src/codespy/agents/review/scope/agent.py b/src/codespy/agents/review/scope/agent.py index dc6359f..7ea7e48 100644 --- a/src/codespy/agents/review/scope/agent.py +++ b/src/codespy/agents/review/scope/agent.py @@ -500,6 +500,7 @@ def __init__(self) -> None: self._cost_tracker = get_cost_tracker() self._settings = get_settings() self.prefrontal_memory: str = "" + self.prefrontal_sections: list[tuple[str, str]] = [] async def _create_tools(self, repo_path: Path) -> tuple[list[Any], list[Any]]: """Create tools for the scope agent: filesystem + ripgrep. @@ -1091,6 +1092,7 @@ async def _refine_scopes( ) pf_kwargs["prefrontal_memory"] = scope_pf_text self.prefrontal_memory = scope_pf_text + self.prefrontal_sections = pf.last_sections if self._settings.get_memory_enabled("scope"): question = ( f"refine scopes of {review_context.pr_context.repo_slug}: " @@ -1231,6 +1233,7 @@ async def aforward( """ # Reset prefrontal memory at start of each run self.prefrontal_memory = "" + self.prefrontal_sections = [] # Local bindings from review_context metadata pr = review_context.metadata.pr diff --git a/src/codespy/workflows/review/models.py b/src/codespy/workflows/review/models.py index a46f6e7..4a79dc6 100644 --- a/src/codespy/workflows/review/models.py +++ b/src/codespy/workflows/review/models.py @@ -13,11 +13,20 @@ TRUNCATED_MARKER = "…(truncated)" +class MemorySection(BaseModel): + """One section of a recalled memory (e.g., 'Briefing: ...', 'Around this work').""" + + title: str = Field(description="Section title without markdown heading markers") + text: str = Field(description="Section body text") + + class RecalledMemory(BaseModel): """A recalled memory entry from Prefrontal.""" task: str = Field(description="Task name that loaded the memory (e.g., 'scope', 'review')") - text: str = Field(description="The recalled memory text (markdown formatted)") + sections: list[MemorySection] = Field( + default_factory=list, description="Sections of the recalled memory" + ) class SignatureStatsResult(BaseModel): @@ -166,6 +175,9 @@ def to_markdown(self) -> str: "", ] + # Memories section (collapsible) - placed before Summary + lines.extend(self.memories_markdown_lines()) + # Overall summary if self.overall_summary: lines.extend(["## Summary", "", self.overall_summary, ""]) @@ -275,9 +287,6 @@ def to_markdown(self) -> str: if self.recommendation: lines.extend(["## Recommendation", "", self.recommendation, ""]) - # Memories section (collapsible) - lines.extend(self.memories_markdown_lines()) - return "\n".join(lines) def cost_breakdown_markdown_lines(self, heading: str = "###") -> list[str]: @@ -330,10 +339,16 @@ def table_lines(label: str, stats: list[SignatureStatsResult]) -> list[str]: def memories_markdown_lines( self, summary: str = "memories", max_chars: int | None = None ) -> list[str]: - """Build the collapsible memories section, optionally size-limited. + """Build the collapsible memories section with nested per-recall and per-section details. - With a single memory, it is rendered under a ``### `` heading. - With several, each one gets its own nested collapsible section. + Structure: +
{summary} +
{task} +
{section_title}{body}
+ ... +
+ ... +
GitReporter passes ``max_chars`` to stay within GitHub's 65,536 char review body limit: memory texts are truncated in order, other sections @@ -349,48 +364,75 @@ def memories_markdown_lines( if not self.memories: return [] - nested = len(self.memories) > 1 - - def block(memory: RecalledMemory, text: str) -> list[str]: - if nested: - return [ - "
", - f"{memory.task}", - "", - text, - "", - "
", - "", - ] - return [f"### {memory.task}", "", text, ""] - def size(block_lines: list[str]) -> int: # Lines are joined with "\n" by the caller return sum(len(line) + 1 for line in block_lines) - head = ["
", f"{summary}", ""] - tail = ["
", ""] - lines = list(head) - remaining = None if max_chars is None else max_chars - size(head) - size(tail) + def recall_head(task: str) -> list[str]: + return ["
", f"{task}", ""] + + def recall_tail() -> list[str]: + return ["
", ""] + + def section_block(title: str, body: str) -> list[str]: + return [ + "
", + f"{title}", + "", + body, + "", + "
", + "", + ] + + # Reserve room for outer head/tail and per-recall tails + outer_head = ["
", f"{summary}", ""] + outer_tail = ["
", ""] + outer_overhead = size(outer_head) + size(outer_tail) + + lines: list[str] = [] + remaining = None if max_chars is None else max_chars - outer_overhead for memory in self.memories: - full = block(memory, memory.text) - if remaining is None or size(full) <= remaining: - lines.extend(full) - if remaining is not None: - remaining -= size(full) - continue - # Truncate this memory to fit, then stop - overhead = size(block(memory, "")) + len(TRUNCATED_MARKER) - if remaining > overhead: - text = memory.text[: remaining - overhead] + TRUNCATED_MARKER - lines.extend(block(memory, text)) - break - - if len(lines) == len(head): + r_head = recall_head(memory.task) + r_tail = recall_tail() + recall_overhead = size(r_head) + size(r_tail) + + # Budget check: need at least recall overhead + one section + if remaining is not None and remaining < recall_overhead: + break + + if remaining is not None: + remaining -= recall_overhead + + r_lines: list[str] = list(r_head) + has_section = False + + for section in memory.sections: + s_block = section_block(section.title, section.text) + s_size = size(s_block) + if remaining is None or s_size <= remaining: + r_lines.extend(s_block) + has_section = True + if remaining is not None: + remaining -= s_size + else: + # Try to truncate the section body + overhead = size(section_block(section.title, "")) + len(TRUNCATED_MARKER) + if remaining is not None and remaining > overhead: + truncated_body = section.text[: remaining - overhead] + TRUNCATED_MARKER + r_lines.extend(section_block(section.title, truncated_body)) + has_section = True + # Close this recall - stop processing sections for this recall + break + + r_lines.extend(r_tail) + if has_section: + lines.extend(r_lines) + + if not lines: return [] - lines.extend(tail) - return lines + return outer_head + lines + outer_tail def to_json_dict(self) -> dict: """Convert to a JSON-serializable dictionary.""" diff --git a/src/codespy/workflows/review/pipeline.py b/src/codespy/workflows/review/pipeline.py index 7295744..a91aac7 100644 --- a/src/codespy/workflows/review/pipeline.py +++ b/src/codespy/workflows/review/pipeline.py @@ -36,6 +36,7 @@ from codespy.workflows.review.models import ( LocalReviewConfig, + MemorySection, RecalledMemory, RemoteReviewConfig, ReviewConfig, @@ -304,11 +305,21 @@ def forward(self, config: ReviewConfig) -> ReviewResult: # Build memories list from scope and run-level Prefrontal loads memories: list[RecalledMemory] = [] - scope_memory_text = self.scope_resolver.prefrontal_memory - if scope_memory_text: - memories.append(RecalledMemory(task="scope", text=scope_memory_text)) - if pf_text: - memories.append(RecalledMemory(task="review", text=pf_text)) + scope_pf_sections = self.scope_resolver.prefrontal_sections + if scope_pf_sections: + memories.append( + RecalledMemory( + task="scope", + sections=[MemorySection(title=t, text=b) for t, b in scope_pf_sections], + ) + ) + if run_pf and run_pf.last_sections: + memories.append( + RecalledMemory( + task="review", + sections=[MemorySection(title=t, text=b) for t, b in run_pf.last_sections], + ) + ) return ReviewResult( pr_number=pr.number, diff --git a/src/codespy/workflows/review/reporters/git.py b/src/codespy/workflows/review/reporters/git.py index f98533b..8d7c8ba 100644 --- a/src/codespy/workflows/review/reporters/git.py +++ b/src/codespy/workflows/review/reporters/git.py @@ -103,6 +103,18 @@ def _build_review_body( ) lines.append("") + # Memories section (collapsible, size-limited for GitHub's 65,536 char body limit) + # Insert right after header, before Summary + if result.memories: + # Calculate remaining budget after header + header_len = len("\n".join(lines)) + 1 # +1 for trailing newline + remaining_budget = self.MAX_BODY_CHARS - header_len + memory_lines = result.memories_markdown_lines( + summary="🧠 memories", max_chars=max(0, remaining_budget) + ) + if memory_lines: + lines.extend(memory_lines) + # Summary section if result.overall_summary: lines.extend( @@ -241,15 +253,6 @@ def _build_review_body( ] ) - # Memories section (collapsible, size-limited for GitHub's 65,536 char body limit) - if result.memories: - remaining_budget = self.MAX_BODY_CHARS - len("\n".join(lines)) - 1 - lines.extend( - result.memories_markdown_lines( - summary="🧠 memories", max_chars=max(0, remaining_budget) - ) - ) - return "\n".join(lines) def _build_inline_comments(self, issues: list[Issue]) -> list[dict]: diff --git a/tests/test_review_memories.py b/tests/test_review_memories.py index a03f28a..a6a2c58 100644 --- a/tests/test_review_memories.py +++ b/tests/test_review_memories.py @@ -1,10 +1,26 @@ """Tests for the collapsible memories section of the review output.""" -from codespy.workflows.review.models import TRUNCATED_MARKER, RecalledMemory, ReviewResult +from codespy.workflows.review.models import ( + TRUNCATED_MARKER, + MemorySection, + RecalledMemory, + ReviewResult, +) from codespy.workflows.review.reporters.git import GitReporter -def _result(*memories: tuple[str, str], recommendation: str | None = "approve") -> ReviewResult: +def _result( + *memories: tuple[str, list[tuple[str, str]]], recommendation: str | None = "approve" +) -> ReviewResult: + """Build a ReviewResult with memories from (task, [(section_title, section_body), ...]) tuples.""" + recalled = [] + for task, sections in memories: + recalled.append( + RecalledMemory( + task=task, + sections=[MemorySection(title=t, text=b) for t, b in sections], + ) + ) return ReviewResult( pr_number=1, pr_title="t", @@ -12,7 +28,7 @@ def _result(*memories: tuple[str, str], recommendation: str | None = "approve") repo="o/r", model_used="m", recommendation=recommendation, - memories=[RecalledMemory(task=t, text=x) for t, x in memories], + memories=recalled, ) @@ -26,44 +42,93 @@ def test_no_memories_no_section(): assert "memories" not in _git_body(_result()) -def test_single_memory_uses_heading_not_nested(): - md = _result(("review", "## Context\nfact A")).to_markdown() - section = md[md.index("memories"):] - assert "### review" in section - assert section.count("
") == 0 # only the outer one, before the summary - assert md.count("
") == 1 - assert md.count("
") == 1 +def test_single_memory_nested_structure(): + """Single memory still gets nested
: outer > recall > sections.""" + md = _result(("review", [("Around this work", "fact A"), ("Decisions", "fact B")])).to_markdown() + # Should have: outer memories + 1 recall + 2 section details = 4
+ assert md.count("
") == 4 + assert md.count("
") == 4 + # No ### task heading - task is in + assert "### review" not in md + assert "review" in md + # Sections are in summaries + assert "Around this work" in md + assert "Decisions" in md -def test_multiple_memories_nested_and_ordered(): - md = _result(("scope", "scope text"), ("review", "review text")).to_markdown() - assert md.index("## Recommendation") < md.index("memories") - assert md.count("
") == 3 - assert md.count("
") == 3 - assert "### scope" not in md +def test_memories_before_summary(): + """Memories section must appear before ## Summary in to_markdown output.""" + md = _result(("review", [("Around this work", "fact A")])).to_markdown() + assert md.index("memories") < md.index("## Summary") + + +def test_git_body_memories_before_summary(): + """Memories section must appear before 📋 Summary in git body.""" + body = _git_body(_result(("review", [("Around this work", "fact A")]))) + assert "🧠 memories" in body + assert body.index("🧠 memories") < body.index("📋 Summary") + + +def test_multiple_memories_ordered(): + """Multiple recalls appear in order with nested sections.""" + md = _result( + ("scope", [("Around this work", "scope context")]), + ("review", [("Decisions", "review decisions")]), + ).to_markdown() + # Should have: outer + 2 recalls + 2 sections = 5
+ assert md.count("
") == 5 + assert md.count("
") == 5 + # Recalls are ordered assert md.index("scope") < md.index("review") - assert md.index("scope text") < md.index("review") # Outer section closes last assert md.rstrip().endswith("
") - assert md.rindex("
") > md.index("review text") -def test_git_body_memories_last(): - body = _git_body(_result(("scope", "s"), ("review", "r"))) - assert "🧠 memories" in body - assert body.index("💡 Recommendation") < body.index( - "🧠 memories" +def test_section_with_subheadings_stays_in_one_details(): + """A section body containing '## ' must render as exactly one
block.""" + body_with_subheadings = "## Service Structure\nContent\n## Another subsection\nMore content" + md = _result( + ("review", [("Around this work", body_with_subheadings), ("Decisions", "fact")]) + ).to_markdown() + # outer (1) + recall (1) + 2 sections (2) = 4
+ assert md.count("
") == 4 + assert md.count("
") == 4 + + +def test_empty_sections_not_rendered(): + """Empty memories list produces no memories section.""" + result = ReviewResult( + pr_number=1, + pr_title="t", + pr_url="https://github.com/o/r/pull/1", + repo="o/r", + model_used="m", + recommendation="approve", + memories=[RecalledMemory(task="review", sections=[])], ) - assert body.count("
") == body.count("
") + assert result.memories_markdown_lines() == [] def test_git_body_truncates_to_budget(): + """Large memories are truncated to fit within MAX_BODY_CHARS.""" big = "x" * 50_000 - body = _git_body(_result(("scope", big), ("review", big))) + body = _git_body(_result(("scope", [("Around this work", big)]), ("review", [("Decisions", big)]))) assert len(body) <= GitReporter.MAX_BODY_CHARS assert TRUNCATED_MARKER in body assert body.count("
") == body.count("
") def test_budget_too_small_drops_section(): - assert _result(("review", "abc")).memories_markdown_lines(max_chars=10) == [] + """When budget is too small, memories section is dropped entirely.""" + result = _result(("review", [("Around this work", "abc")])) + assert result.memories_markdown_lines(max_chars=10) == [] + + +def test_balanced_tags_when_truncated(): + """When truncating, all
tags remain balanced.""" + big = "x" * 100_000 + result = _result(("review", [("Around this work", big), ("Decisions", big)])) + lines = result.memories_markdown_lines(max_chars=5000) + text = "\n".join(lines) + assert text.count("
") == text.count("
") + assert TRUNCATED_MARKER in text From 9567bc3ce7e5fe5fb47bb8b66572ed598d33b4b4 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 2 Oct 2026 16:14:09 +0200 Subject: [PATCH 32/39] recall --- CHANGELOG.md | 3 + .../agents/memory/hippocampus/episode.py | 56 +++++++++ .../agents/memory/prefrontal/prefrontal.py | 17 +-- src/codespy/cli_local.py | 12 +- src/codespy/cli_remote.py | 34 ++++-- src/codespy/tools/git/base.py | 35 +++++- src/codespy/tools/git/github_client.py | 47 +++++++- src/codespy/tools/git/gitlab_client.py | 42 ++++++- src/codespy/workflows/review/models.py | 25 +++- src/codespy/workflows/review/pipeline.py | 100 +++++++++++++--- src/codespy/workflows/review/reporters/git.py | 112 +++++++++++++++--- src/codespy/workflows/review/server.py | 22 ++-- 12 files changed, 429 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff2d42..ec5f26d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Changed +- **Review is now published before the memory phase**: Episode saves, retain, consolidation and mental-model refresh run after audit. For remote reviews, the GitHub/GitLab review is posted first with "Memory: pending", then edited with full costs after the memory phase completes. Local CLI and MCP reviews run the memory phase before returning/printing. This keeps the review pipeline responsive while memory operations complete in the background. + ### Added - **Review memories section**: The pre-call Prefrontal memory (run-level and scope-level) now appears in a collapsed `memories` section **before** the Summary. Each recall is nested under its own `
` block, and each memory section (Briefings, Around this work, Seen before, Decisions, Recurring patterns, What changed, Other repositories) is also collapsible. This section is shown in both the GitHub/GitLab review body and the stdout/MCP markdown output. - **Cerebral structured output support**: Native structured output registration for Bedrock models (e.g., Nvidia Nemotron Super) that support it but lack the flag in litellm's model map. Two-step structured output fallback for memory models litellm cannot schema-enforce, using `llm.extraction_model`. diff --git a/src/codespy/agents/memory/hippocampus/episode.py b/src/codespy/agents/memory/hippocampus/episode.py index 67cbfc8..05e257b 100644 --- a/src/codespy/agents/memory/hippocampus/episode.py +++ b/src/codespy/agents/memory/hippocampus/episode.py @@ -14,7 +14,9 @@ import threading import uuid from collections.abc import Callable +from contextlib import contextmanager from datetime import datetime +from typing import Generator from pydantic import BaseModel, Field @@ -34,6 +36,10 @@ _save_threads: list[threading.Thread] = [] _save_lock = threading.Lock() +# Deferred save queue: holds saves when _defer is True +_deferred: list[tuple[Callable[[], object], str]] = [] +_defer: bool = False + def submit_episode_save(target: Callable[[], object], *, name: str = "episode-save") -> None: """Start *target* in a non-daemon background thread and track it. @@ -51,16 +57,64 @@ def submit_episode_save(target: Callable[[], object], *, name: str = "episode-sa Therefore, saves finish while the executors are still alive, even when ReviewPipeline.forward() raised or the caller never joined explicitly. + When _defer is True (inside defer_episode_saves() context), saves are + queued instead of started immediately. Call start_deferred_episode_saves() + to drain the queue and start them. + Args: target: A zero-arg callable that performs the episode save. name: Thread name (for debugging / log messages). """ + with _save_lock: + if _defer: + _deferred.append((target, name)) + return + t = threading.Thread(target=target, name=name, daemon=False) with _save_lock: _save_threads.append(t) t.start() +@contextmanager +def defer_episode_saves() -> Generator[None, None, None]: + """Context manager that defers episode saves until exit. + + Inside this context, submit_episode_save() queues saves instead of + starting them. When the context exits, saves remain queued until + start_deferred_episode_saves() is called. + """ + global _defer + with _save_lock: + _defer = True + try: + yield + finally: + # _defer stays True - caller must call start_deferred_episode_saves() + pass + + +def start_deferred_episode_saves() -> None: + """Drain the deferred queue and start all queued saves. + + Called after the review phase completes to start the memory phase. + Saves submitted after this call start immediately (defer is off). + """ + global _defer, _deferred + with _save_lock: + _defer = False + to_start = list(_deferred) + _deferred.clear() + + for target, name in to_start: + t = threading.Thread(target=target, name=name, daemon=False) + with _save_lock: + _save_threads.append(t) + t.start() + if to_start: + logger.info("started %d deferred episode save(s)", len(to_start)) + + def join_episode_saves(timeout_per_thread: float | None = None) -> None: """Block until every tracked episode-save thread has finished. @@ -103,6 +157,8 @@ def _join_episode_saves_at_exit() -> None: that rely on ThreadPoolExecutor can still submit embedding tasks. """ try: + # Start any deferred saves that were never started + start_deferred_episode_saves() join_episode_saves() except BaseException: # Log at debug level so Ctrl+C during exit doesn't print a traceback diff --git a/src/codespy/agents/memory/prefrontal/prefrontal.py b/src/codespy/agents/memory/prefrontal/prefrontal.py index e69c385..8b4bb75 100644 --- a/src/codespy/agents/memory/prefrontal/prefrontal.py +++ b/src/codespy/agents/memory/prefrontal/prefrontal.py @@ -30,7 +30,6 @@ MENTAL_MODEL_PLACEHOLDER, estimate_tokens, fget, - format_context, format_context_sections, format_facts, ) @@ -260,9 +259,11 @@ async def aload(self, facets: Sequence[Facet]) -> str: text = "" status: RecallStatus = "error" details: dict[str, Any] = {} + sections: list[tuple[str, str]] = [] with track_recall_usage() as usage: try: - text, details = await self._aload(facet_list) + text, details, sections = await self._aload(facet_list) + self._last_sections = sections status = "ok" if text else "empty" except Exception: logger.warning("Prefrontal[%s]: load failed", self._task, exc_info=True) @@ -338,8 +339,8 @@ def _n(name: str) -> int: ) return text - async def _aload(self, facets: list[Facet]) -> tuple[str, dict[str, Any]]: - """Return the rendered context and the load ``details`` for the RecallRecord.""" + async def _aload(self, facets: list[Facet]) -> tuple[str, dict[str, Any], list[tuple[str, str]]]: + """Return the rendered context, load ``details``, and sections for the RecallRecord.""" cfg = self._cfg now = datetime.now(UTC) local_groups = build_local_groups(self._repo, self._scope_ids, self._include_repo) @@ -412,11 +413,11 @@ async def _remote() -> list[Any]: return [f for f in facts or [] if not is_own_repo_fact(fget(f, "tags"), self._repo)] briefings, results, remote = await asyncio.gather(_briefings(), _facets(), _remote()) - # Store sections for structured rendering - self._last_sections = format_context_sections( + # Compute sections once for structured rendering + sections = format_context_sections( briefings, results, remote, self._repo, cfg.recall.reach ) - text = format_context(briefings, results, remote, self._repo, cfg.recall.reach) + text = "\n\n".join(f"## {t}\n{b}" for t, b in sections) n_briefings = sum( 1 @@ -437,7 +438,7 @@ async def _remote() -> list[Any]: "facets": facet_details, "reflect": _extract_reflect_summary(results), } - return text, details + return text, details, sections # ------------------------------------------------------------------ # Tool diff --git a/src/codespy/cli_local.py b/src/codespy/cli_local.py index 7de2b42..bb62af4 100644 --- a/src/codespy/cli_local.py +++ b/src/codespy/cli_local.py @@ -109,6 +109,7 @@ def review_local( try: from codespy.workflows.review.models import LocalReviewConfig from codespy.workflows.review.pipeline import ReviewPipeline + from codespy.workflows.review.reporters import StdoutReporter pipeline = ReviewPipeline(settings) @@ -116,7 +117,8 @@ def review_local( config = LocalReviewConfig(repo_path=repo, base_ref=base_ref, uncommitted=False) # Run review (model access always verified in pipeline) - result = pipeline(config) + # Episode saves are deferred; finish_memory runs them + result = pipeline.finish_memory(pipeline(config)) if result.llm_calls > 0: cost_str = f"${result.total_cost:.4f}" if result.total_cost > 0 else "N/A" @@ -135,8 +137,6 @@ def review_local( ) ) - from codespy.workflows.review.reporters import StdoutReporter - stdout_reporter = StdoutReporter(format=settings.review.output_format, console=console) stdout_reporter.report(result) @@ -228,6 +228,7 @@ def review_uncommitted( try: from codespy.workflows.review.models import LocalReviewConfig from codespy.workflows.review.pipeline import ReviewPipeline + from codespy.workflows.review.reporters import StdoutReporter pipeline = ReviewPipeline(settings) @@ -235,7 +236,8 @@ def review_uncommitted( config = LocalReviewConfig(repo_path=repo, uncommitted=True) # Run review (model access always verified in pipeline) - result = pipeline(config) + # Episode saves are deferred; finish_memory runs them + result = pipeline.finish_memory(pipeline(config)) if result.llm_calls > 0: cost_str = f"${result.total_cost:.4f}" if result.total_cost > 0 else "N/A" @@ -254,8 +256,6 @@ def review_uncommitted( ) ) - from codespy.workflows.review.reporters import StdoutReporter - stdout_reporter = StdoutReporter(format=settings.review.output_format, console=console) stdout_reporter.report(result) diff --git a/src/codespy/cli_remote.py b/src/codespy/cli_remote.py index 5423df8..f8b13d8 100644 --- a/src/codespy/cli_remote.py +++ b/src/codespy/cli_remote.py @@ -161,16 +161,36 @@ def review( try: from codespy.workflows.review.models import RemoteReviewConfig from codespy.workflows.review.pipeline import ReviewPipeline + from codespy.workflows.review.reporters import GitReporter, StdoutReporter pipeline = ReviewPipeline(settings) # Create remote review config config = RemoteReviewConfig(url=pr_url) - # Run review (model access always verified in pipeline) + # Run review phase (model access always verified in pipeline) + # Episode saves are deferred until finish_memory is called result = pipeline(config) - # Show cost summary + handle = None + try: + # Publish to git platform after audit (before memory phase) + if settings.review.output_git: + console.print(f"[dim]Posting review to {platform.title()}...[/dim]") + git_reporter = GitReporter(url=pr_url, settings=settings) + handle = git_reporter.publish(result) + console.print(f"[green]✓[/green] {platform.title()} review posted successfully") + finally: + # Run memory phase (saves, retain, consolidation) + # This runs even if git publication failed + result = pipeline.finish_memory(result) + + # Update the git review with full costs after memory phase + if handle and settings.review.output_git: + git_reporter = GitReporter(url=pr_url, settings=settings) + git_reporter.update(handle, result) + + # Show cost summary with full costs (after memory phase) if result.llm_calls > 0: cost_str = f"${result.total_cost:.4f}" if result.total_cost > 0 else "N/A" # Calculate input/output totals from signature_stats @@ -188,19 +208,11 @@ def review( ) ) - # Output results using reporters - from codespy.workflows.review.reporters import GitReporter, StdoutReporter - + # Output results to stdout once at the end (after memory phase) if settings.review.output_stdout: stdout_reporter = StdoutReporter(format=settings.review.output_format, console=console) stdout_reporter.report(result) - if settings.review.output_git: - console.print(f"[dim]Posting review to {platform.title()}...[/dim]") - git_reporter = GitReporter(url=pr_url, settings=settings) - git_reporter.report(result) - console.print(f"[green]✓[/green] {platform.title()} review posted successfully") - except ValueError as e: console.print(f"[red]Error:[/red] {e}") raise typer.Exit(1) from None diff --git a/src/codespy/tools/git/base.py b/src/codespy/tools/git/base.py index b2350e9..3690a09 100644 --- a/src/codespy/tools/git/base.py +++ b/src/codespy/tools/git/base.py @@ -1,6 +1,7 @@ """Abstract base class for Git platform clients.""" from abc import ABC, abstractmethod +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -9,6 +10,18 @@ from codespy.tools.git.models import PullRequest +@dataclass +class SubmittedReview: + """Reference to a submitted review for later editing. + + Attributes: + id: The review/note ID from the platform + body_suffix: Any content appended to the body (e.g., fallback inline comments) + """ + id: int + body_suffix: str = "" + + class GitClient(ABC): """Abstract base class for Git platform clients (GitHub, GitLab).""" @@ -81,7 +94,7 @@ def submit_review( body: str, comments: list[dict] | None = None, commit_sha: str | None = None, - ) -> None: + ) -> SubmittedReview | None: """Submit a review on a pull request. Args: @@ -92,6 +105,26 @@ def submit_review( - line: Line number - body: Comment text commit_sha: Commit SHA to review (defaults to head SHA) + + Returns: + SubmittedReview with the review ID and any body suffix appended, + or None if no review was created. + """ + ... + + @abstractmethod + def update_review( + self, + url: str, + review: SubmittedReview, + body: str, + ) -> None: + """Update an existing review with a new body. + + Args: + url: Pull request URL + review: The SubmittedReview returned by submit_review + body: New review body (replaces the original) """ ... diff --git a/src/codespy/tools/git/github_client.py b/src/codespy/tools/git/github_client.py index 94f851f..681eca1 100644 --- a/src/codespy/tools/git/github_client.py +++ b/src/codespy/tools/git/github_client.py @@ -9,7 +9,7 @@ from github.PullRequest import PullRequest as GHPullRequest from codespy.config_utils import secret_value -from codespy.tools.git.base import GitClient +from codespy.tools.git.base import GitClient, SubmittedReview from codespy.tools.git.models import ( ChangedFile, FileStatus, @@ -216,7 +216,7 @@ def submit_review( body: str, comments: list[dict] | None = None, commit_sha: str | None = None, - ) -> None: + ) -> SubmittedReview | None: """Submit a review on a pull request. Uses optimistic approach: try all comments first, then validate and retry on failure. @@ -229,7 +229,10 @@ def submit_review( - line: Line number (in the diff, not the file) - body: Comment text - side: 'RIGHT' for additions, 'LEFT' for deletions (default: RIGHT) - commit_sha: Commit SHA to review (defaults to PR head SHA) + commit_sha: Commit SHA to review (defaults to head SHA) + + Returns: + SubmittedReview with the review ID and body suffix. Note: Uses event='COMMENT' to avoid approving/requesting changes. @@ -280,14 +283,18 @@ def submit_review( review_comment["start_line"] = comment["start_line"] review_comments.append(review_comment) + body_suffix = "" + review_id = 0 + # Try to submit the review optimistically with all inline comments try: - gh_pr.create_review( + review = gh_pr.create_review( commit=repo.get_commit(review_commit), body=body, event="COMMENT", comments=review_comments, ) + review_id = review.id logger.info( f"Submitted review on {owner}/{repo_name}#{pr_number} " f"with {len(review_comments)} inline comments" @@ -325,12 +332,14 @@ def submit_review( # Retry with valid comments only try: - gh_pr.create_review( + review = gh_pr.create_review( commit=repo.get_commit(review_commit), body=updated_body, event="COMMENT", comments=valid_comments, ) + review_id = review.id + body_suffix = updated_body[len(body):] logger.info( f"Submitted review on {owner}/{repo_name}#{pr_number} " f"with {len(valid_comments)} inline comments (after validation)" @@ -342,12 +351,14 @@ def submit_review( ) all_failed_comments = review_comments # All original comments final_body = self._append_comments_to_body(body, all_failed_comments) - gh_pr.create_review( + review = gh_pr.create_review( commit=repo.get_commit(review_commit), body=final_body, event="COMMENT", comments=[], ) + review_id = review.id + body_suffix = final_body[len(body):] logger.info( f"Submitted review on {owner}/{repo_name}#{pr_number} " f"(body only, all comments)" @@ -358,6 +369,30 @@ def submit_review( if skipped_path_comments: logger.info(f"Skipped {len(skipped_path_comments)} comments for files not in PR") + return SubmittedReview(id=review_id, body_suffix=body_suffix) + + def update_review( + self, + url: str, + review: SubmittedReview, + body: str, + ) -> None: + """Update an existing GitHub PR review with a new body. + + Args: + url: GitHub PR URL + review: The SubmittedReview returned by submit_review + body: New review body (replaces the original) + """ + owner, repo_name, pr_number = self.parse_url(url) + repo = self.github.get_repo(f"{owner}/{repo_name}") + gh_pr: GHPullRequest = repo.get_pull(pr_number) + + # Get the review and edit it + gh_review = gh_pr.get_review(review.id) + gh_review.edit(body=body) + logger.info(f"Updated review {review.id} on {owner}/{repo_name}#{pr_number}") + def _append_comments_to_body(self, body: str, comments: list[dict]) -> str: """Append inline comments to the review body when they can't be posted inline. diff --git a/src/codespy/tools/git/gitlab_client.py b/src/codespy/tools/git/gitlab_client.py index a26a72c..bff2c50 100644 --- a/src/codespy/tools/git/gitlab_client.py +++ b/src/codespy/tools/git/gitlab_client.py @@ -8,7 +8,7 @@ from git import Repo from codespy.config_utils import secret_value -from codespy.tools.git.base import GitClient +from codespy.tools.git.base import GitClient, SubmittedReview from codespy.tools.git.models import ( ChangedFile, FileStatus, @@ -285,7 +285,7 @@ def submit_review( body: str, comments: list[dict] | None = None, commit_sha: str | None = None, - ) -> None: + ) -> SubmittedReview | None: """Submit a review on a merge request. GitLab doesn't have a "review" concept like GitHub. Instead: @@ -302,6 +302,9 @@ def submit_review( - line: Line number - body: Comment text commit_sha: Commit SHA to review (defaults to head SHA) + + Returns: + SubmittedReview with the note ID and body suffix, or None if no note was created. """ project_path = self._get_project_path(url) _, _, pr_number = self.parse_url(url) @@ -407,8 +410,12 @@ def submit_review( logger.info(f"Moving {len(failed_comments)} comments to body (could not post inline)") # Post main review body as a note (with any failed comments appended) + note_id = 0 + body_suffix = "" if final_body: - gl_mr.notes.create({"body": final_body}) + note = gl_mr.notes.create({"body": final_body}) + note_id = note.id + body_suffix = final_body[len(body):] logger.info(f"Posted review on {project_path}!{pr_number}") if comments: @@ -417,6 +424,35 @@ def submit_review( f"inline comments on {project_path}!{pr_number}" ) + if note_id: + return SubmittedReview(id=note_id, body_suffix=body_suffix) + return None + + def update_review( + self, + url: str, + review: SubmittedReview, + body: str, + ) -> None: + """Update an existing GitLab MR note with a new body. + + Args: + url: GitLab MR URL + review: The SubmittedReview returned by submit_review + body: New review body (replaces the original) + """ + project_path = self._get_project_path(url) + _, _, pr_number = self.parse_url(url) + + project = self.gitlab_client.projects.get(project_path) + gl_mr = project.mergerequests.get(pr_number) + + # Get the note and update it + note = gl_mr.notes.get(review.id) + note.body = body + note.save() + logger.info(f"Updated note {review.id} on {project_path}!{pr_number}") + def _append_comments_to_body(self, body: str, comments: list[dict]) -> str: """Append inline comments to the review body when they can't be posted inline. diff --git a/src/codespy/workflows/review/models.py b/src/codespy/workflows/review/models.py index 4a79dc6..625200c 100644 --- a/src/codespy/workflows/review/models.py +++ b/src/codespy/workflows/review/models.py @@ -126,6 +126,10 @@ class ReviewResult(BaseModel): default_factory=list, description="Prefrontal pre-call memory injected into agent inputs", ) + memory_pending: bool = Field( + default=False, + description="True when memory phase (saves, retain, consolidation) is still pending", + ) @property def total_issues(self) -> int: @@ -209,9 +213,11 @@ def to_markdown(self) -> str: "", f"- **LLM Calls:** {self.llm_calls}", f"- **Total Cost:** ${self.total_cost:.4f}", - "", ] ) + if self.memory_pending: + lines.append("- **Memory:** pending (retain, consolidation, mental models)") + lines.append("") # Per-signature breakdown lines.extend(self.cost_breakdown_markdown_lines("###")) @@ -392,8 +398,12 @@ def section_block(title: str, body: str) -> list[str]: lines: list[str] = [] remaining = None if max_chars is None else max_chars - outer_overhead + outer_stopped = False for memory in self.memories: + if outer_stopped: + break + r_head = recall_head(memory.task) r_tail = recall_tail() recall_overhead = size(r_head) + size(r_tail) @@ -407,8 +417,12 @@ def section_block(title: str, body: str) -> list[str]: r_lines: list[str] = list(r_head) has_section = False + recall_stopped = False for section in memory.sections: + if recall_stopped: + break + s_block = section_block(section.title, section.text) s_size = size(s_block) if remaining is None or s_size <= remaining: @@ -423,12 +437,19 @@ def section_block(title: str, body: str) -> list[str]: truncated_body = section.text[: remaining - overhead] + TRUNCATED_MARKER r_lines.extend(section_block(section.title, truncated_body)) has_section = True + # Account for the truncated section size + remaining -= overhead + len(truncated_body) # Close this recall - stop processing sections for this recall - break + recall_stopped = True + # Also stop the outer loop - no more recalls fit + outer_stopped = True r_lines.extend(r_tail) if has_section: lines.extend(r_lines) + elif remaining is not None: + # No section rendered for this recall: give back its overhead + remaining += recall_overhead if not lines: return [] diff --git a/src/codespy/workflows/review/pipeline.py b/src/codespy/workflows/review/pipeline.py index a91aac7..ebc7b09 100644 --- a/src/codespy/workflows/review/pipeline.py +++ b/src/codespy/workflows/review/pipeline.py @@ -4,6 +4,7 @@ import logging import uuid from pathlib import Path +from typing import Any import dspy # type: ignore[import-untyped] @@ -21,7 +22,11 @@ Summarizer, SupplyChainAuditor, ) -from codespy.agents.memory.hippocampus.episode import join_episode_saves +from codespy.agents.memory.hippocampus.episode import ( + defer_episode_saves, + join_episode_saves, + start_deferred_episode_saves, +) from codespy.agents.review.helpers import build_patches from codespy.agents.review.scope import MANIFEST_FILES, MANIFEST_GLOBS, build_sparse_patterns from codespy.config import Settings, get_settings @@ -157,13 +162,16 @@ def _build_local_pr(self, config: LocalReviewConfig) -> PullRequest: ) def forward(self, config: ReviewConfig) -> ReviewResult: - """Run the complete review pipeline. + """Run the complete review pipeline (review phase only). + + The review phase includes scope identification, summarizer, review modules, + and audit. Episode saves are deferred until finish_memory() is called. Args: config: Review configuration (RemoteReviewConfig or LocalReviewConfig) Returns: - ReviewResult with issues, summary, costs, etc. + ReviewResult with issues, summary, costs, etc. (memory_pending=True) """ self.cost_tracker.reset() @@ -192,8 +200,37 @@ def forward(self, config: ReviewConfig) -> ReviewResult: else: raise ValueError(f"Invalid config type: {type(config)}") - # Step 1: Identify scopes FIRST + # Run the review phase with deferred episode saves + try: + with defer_episode_saves(): + result = self._run_review_phase(config, pr, repo_path, run_id) + return result + except Exception: + # Ensure saves are started on exception before re-raising + start_deferred_episode_saves() + raise + + def _run_review_phase( + self, + config: ReviewConfig, + pr: Any, + repo_path: Path, + run_id: str, + ) -> ReviewResult: + """Run the review phase (scope identification through audit). + + Args: + config: Review configuration + pr: PullRequest object + repo_path: Path to the repository + run_id: Unique run identifier + + Returns: + ReviewResult with memory_pending=True + """ is_local = isinstance(config, LocalReviewConfig) + + # Step 1: Identify scopes FIRST logger.info("Identifying code scopes...") pr_context = PRContext( repo_slug=pr.repo_slug, @@ -218,6 +255,7 @@ def forward(self, config: ReviewConfig) -> ReviewResult: logger.info(f" Lock file: {manifest.lock_file_path}") if manifest.dependencies_changed: logger.info(" Dependencies changed: Yes") + # Expand sparse checkout to cover full scope subtrees changed_file_paths = [f.filename for f in pr.changed_files] if not is_local: @@ -236,7 +274,6 @@ def forward(self, config: ReviewConfig) -> ReviewResult: pf_text = "" if run_pf: # Build facets for the run-level load - # Collect paths, packages from all scopes all_paths = list({f.filename for s in scopes for f in s.changed_files}) all_packages = list({ s.package_manifest.package_name @@ -265,7 +302,7 @@ def forward(self, config: ReviewConfig) -> ReviewResult: except Exception as e: logger.warning(f"Failed to save run-level recalls: {e}") - # Step 2: Run Summarizer (now receives scopes for per-scope episode persistence) + # Step 2: Run Summarizer pr_summary = self.summarizer( pr_context=pr_context, changed_file_paths=changed_file_paths, @@ -280,6 +317,7 @@ def forward(self, config: ReviewConfig) -> ReviewResult: review_ctx = ReviewContext( pr_context=pr_context, memory=None, metadata=metadata, prefrontal_memory=pf_text ) + # Step 3: Run review modules concurrently via asyncio.gather module_names = ["code_reviewer", "doc_reviewer", "supply_chain_auditor"] logger.info(f"Running review modules concurrently: {', '.join(module_names)}...") @@ -287,7 +325,8 @@ def forward(self, config: ReviewConfig) -> ReviewResult: self._run_review_modules(scopes, module_names, review_context=review_ctx) ) logger.info(f"Found {len(all_issues)} issues") - # Step 4: Run Audit (loads own prior episodes per scope, no memory inheritance from parallel modules) + + # Step 4: Run Audit quality_assessment, recommendation = self.auditor( review_context=review_ctx, all_issues=all_issues, @@ -295,13 +334,6 @@ def forward(self, config: ReviewConfig) -> ReviewResult: scopes=scopes, topics=all_scope_topics, ) - # Ensure all background episode saves complete before stats collection - # (audit's episode save runs in background and contains distiller/cartographer calls) - join_episode_saves() - # One consolidation per run (after all episode saves complete) - self._consolidate_run(run_id) - # Collect per-signature statistics (after all saves complete, includes consolidation costs) - signature_stats_list = self._collect_signature_stats() # Build memories list from scope and run-level Prefrontal loads memories: list[RecalledMemory] = [] @@ -321,6 +353,9 @@ def forward(self, config: ReviewConfig) -> ReviewResult: ) ) + # Collect stats at end of review phase (before memory phase) + signature_stats_list = self._collect_signature_stats() + return ReviewResult( pr_number=pr.number, pr_title=pr.title, @@ -337,8 +372,45 @@ def forward(self, config: ReviewConfig) -> ReviewResult: llm_calls=self.cost_tracker.call_count, signature_stats=signature_stats_list, memories=memories, + memory_pending=True, ) + def finish_memory(self, result: ReviewResult) -> ReviewResult: + """Run the memory phase: start deferred saves, join them, consolidate. + + Args: + result: ReviewResult from forward() with memory_pending=True + + Returns: + ReviewResult with updated costs and memory_pending=False. + Never raises - errors are logged and the result is returned. + """ + if not result.memory_pending: + return result + + try: + # Start deferred saves (they were queued during review phase) + start_deferred_episode_saves() + # Wait for all saves to complete + join_episode_saves() + # Run consolidation and mental model update + self._consolidate_run(result.run_id) + # Recollect stats with memory costs + signature_stats_list = self._collect_signature_stats() + + return result.model_copy(update={ + "signature_stats": signature_stats_list, + "total_cost": self.cost_tracker.total_cost, + "total_tokens": self.cost_tracker.total_tokens, + "llm_calls": self.cost_tracker.call_count, + "memory_pending": False, + }) + except Exception as e: + logger.warning("Memory phase failed: %s", e, exc_info=True) + # Return result as-is, but mark memory as no longer pending + # (we tried and failed, don't keep showing "pending") + return result.model_copy(update={"memory_pending": False}) + def _consolidate_run(self, run_id: str) -> None: """Trigger one consolidation per run after all episode saves complete. diff --git a/src/codespy/workflows/review/reporters/git.py b/src/codespy/workflows/review/reporters/git.py index 8d7c8ba..aa21007 100644 --- a/src/codespy/workflows/review/reporters/git.py +++ b/src/codespy/workflows/review/reporters/git.py @@ -1,11 +1,13 @@ """Git reporter for posting review comments to GitHub/GitLab.""" import logging +import time from typing import TYPE_CHECKING from codespy.agents.review.models import Issue, IssueSeverity from codespy.workflows.review.models import ReviewResult from codespy.workflows.review.reporters.base import BaseReporter +from codespy.tools.git.base import SubmittedReview from codespy.tools.git.client import get_client if TYPE_CHECKING: @@ -28,6 +30,9 @@ class GitReporter(BaseReporter): # Review body budget, below GitHub's 65,536 char limit MAX_BODY_CHARS = 60000 + # Retry configuration for update + UPDATE_RETRY_DELAYS = [2.0, 4.0, 8.0] # seconds + def __init__( self, url: str, @@ -43,11 +48,25 @@ def __init__( self.client = get_client(url, settings) def report(self, result: ReviewResult) -> None: - """Post review result to the merge request. + """Post review result to the merge request (backward compatibility). Args: result: The review result to post. """ + self.publish(result) + + def publish(self, result: ReviewResult) -> SubmittedReview | None: + """Publish the review to the merge request. + + Posts the initial review after the audit phase (before memory phase). + The review will show "Memory: pending" in costs if memory_pending is True. + + Args: + result: The review result to publish. + + Returns: + SubmittedReview handle for later update, or None if publish failed. + """ # Separate issues with and without line numbers inline_issues: list[Issue] = [] body_issues: list[Issue] = [] @@ -65,27 +84,81 @@ def report(self, result: ReviewResult) -> None: comments = self._build_inline_comments(inline_issues) # Submit the review - self.client.submit_review( + handle = self.client.submit_review( url=self.url, body=body, comments=comments, ) - logger.info( - f"Posted {self.client.platform_name} review with {len(comments)} inline comments " - f"and {len(body_issues)} issues in body" + if handle: + logger.info( + f"Posted {self.client.platform_name} review with {len(comments)} inline comments " + f"and {len(body_issues)} issues in body" + ) + return handle + + def update(self, handle: SubmittedReview, result: ReviewResult) -> None: + """Update an existing review with new costs after memory phase. + + Rebuilds the body with full costs and edits the review. + Retries up to 3 times with exponential backoff on failure. + + Args: + handle: The SubmittedReview returned by publish(). + result: The updated review result with full costs. + """ + # Separate issues with and without line numbers + inline_issues: list[Issue] = [] + body_issues: list[Issue] = [] + + for issue in result.issues: + if issue.line_start is not None: + inline_issues.append(issue) + else: + body_issues.append(issue) + + # Build review body, reserving space for the body suffix + body = self._build_review_body(result, body_issues, reserve=len(handle.body_suffix)) + + # Append the body suffix (fallback comments from original submission) + if handle.body_suffix: + body = body + handle.body_suffix + + # Try to update with retries + last_error: Exception | None = None + for attempt, delay in enumerate(self.UPDATE_RETRY_DELAYS): + try: + self.client.update_review(self.url, handle, body) + logger.info(f"Updated {self.client.platform_name} review with full costs") + return + except Exception as e: + last_error = e + logger.warning( + f"Update attempt {attempt + 1} failed: {e}. Retrying in {delay}s..." + ) + time.sleep(delay) + + # All retries exhausted + logger.warning( + f"Failed to update {self.client.platform_name} review after " + f"{len(self.UPDATE_RETRY_DELAYS)} attempts. Keeping original post." ) + # Log the actual error at debug level + if last_error: + logger.debug("Final update error", exc_info=last_error) def _build_review_body( self, result: ReviewResult, body_issues: list[Issue], + reserve: int = 0, ) -> str: """Build the review body with collapsible sections. Args: result: The review result. body_issues: Issues without line numbers to include in body. + reserve: Number of characters to reserve for body suffix (e.g., appended comments). Returns: Formatted markdown string for review body. @@ -103,17 +176,8 @@ def _build_review_body( ) lines.append("") - # Memories section (collapsible, size-limited for GitHub's 65,536 char body limit) - # Insert right after header, before Summary - if result.memories: - # Calculate remaining budget after header - header_len = len("\n".join(lines)) + 1 # +1 for trailing newline - remaining_budget = self.MAX_BODY_CHARS - header_len - memory_lines = result.memories_markdown_lines( - summary="🧠 memories", max_chars=max(0, remaining_budget) - ) - if memory_lines: - lines.extend(memory_lines) + # Record insertion point for memories section (after header) + insert_at = len(lines) # Summary section if result.overall_summary: @@ -175,9 +239,11 @@ def _build_review_body( "", f"**Total:** ${result.total_cost:.4f} | " f"**LLM Calls:** {result.llm_calls}", - "", ] ) + if result.memory_pending: + lines.append("- **Memory:** pending (retain, consolidation, mental models)") + lines.append("") lines.extend(result.cost_breakdown_markdown_lines("####")) @@ -253,6 +319,18 @@ def _build_review_body( ] ) + # Memories section (collapsible, size-limited for GitHub's 65,536 char body limit) + # Insert after header, before Summary + if result.memories: + # Calculate budget after building all other sections, minus reserve + other_sections_len = len("\n".join(lines)) + 1 # +1 for trailing newline + budget = self.MAX_BODY_CHARS - reserve - other_sections_len - 1 + memory_lines = result.memories_markdown_lines( + summary="🧠 memories", max_chars=max(0, budget) + ) + if memory_lines: + lines[insert_at:insert_at] = memory_lines + return "\n".join(lines) def _build_inline_comments(self, issues: list[Issue]) -> list[dict]: diff --git a/src/codespy/workflows/review/server.py b/src/codespy/workflows/review/server.py index f3ffdcc..33decc8 100644 --- a/src/codespy/workflows/review/server.py +++ b/src/codespy/workflows/review/server.py @@ -19,6 +19,7 @@ # Thread pool for running the review pipeline (which uses asyncio.run() internally) # without conflicting with the MCP server's own event loop. +# Single worker ensures memory phase of one review completes before the next starts. _executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) @@ -33,18 +34,20 @@ def _get_pipeline() -> Any: def _run_in_thread(fn: Any, *args: Any, **kwargs: Any) -> Any: - """Run a function in the thread pool and return the result. + """Run a function in the thread pool and return an asyncio future. The ReviewPipeline uses asyncio.run() internally, which can't be called from within the MCP server's event loop. Running in a separate thread gives the pipeline its own event loop. """ loop = asyncio.get_event_loop() - return loop.run_in_executor(_executor, lambda: fn(*args, **kwargs)) + # Submit to executor and wrap as asyncio future + executor_fut = _executor.submit(fn, *args, **kwargs) + return asyncio.wrap_future(executor_fut) def _do_local_review(repo_path: str, base_ref: str, output_format: str) -> str: - """Synchronous local review — runs in thread pool.""" + """Synchronous local review — runs in thread pool with full memory phase.""" import json from codespy.workflows.review.models import LocalReviewConfig @@ -53,7 +56,8 @@ def _do_local_review(repo_path: str, base_ref: str, output_format: str) -> str: config = LocalReviewConfig(repo_path=repo, base_ref=base_ref, uncommitted=False) pipeline = _get_pipeline() - result = pipeline(config) + # Run review phase then memory phase + result = pipeline.finish_memory(pipeline(config)) if output_format == "json": return json.dumps(result.to_json_dict(), indent=2) @@ -61,7 +65,7 @@ def _do_local_review(repo_path: str, base_ref: str, output_format: str) -> str: def _do_uncommitted_review(repo_path: str, output_format: str) -> str: - """Synchronous uncommitted review — runs in thread pool.""" + """Synchronous uncommitted review — runs in thread pool with full memory phase.""" import json from codespy.workflows.review.models import LocalReviewConfig @@ -70,7 +74,8 @@ def _do_uncommitted_review(repo_path: str, output_format: str) -> str: config = LocalReviewConfig(repo_path=repo, uncommitted=True) pipeline = _get_pipeline() - result = pipeline(config) + # Run review phase then memory phase + result = pipeline.finish_memory(pipeline(config)) if output_format == "json": return json.dumps(result.to_json_dict(), indent=2) @@ -78,7 +83,7 @@ def _do_uncommitted_review(repo_path: str, output_format: str) -> str: def _do_pr_review(pr_url: str, output_format: str) -> str: - """Synchronous PR review — runs in thread pool.""" + """Synchronous PR review — runs in thread pool with full memory phase.""" import json from codespy.workflows.review.models import RemoteReviewConfig @@ -86,7 +91,8 @@ def _do_pr_review(pr_url: str, output_format: str) -> str: config = RemoteReviewConfig(url=pr_url) pipeline = _get_pipeline() - result = pipeline(config) + # Run review phase then memory phase + result = pipeline.finish_memory(pipeline(config)) if output_format == "json": return json.dumps(result.to_json_dict(), indent=2) From a473b04d5a0558a08a5d120c3242ceed93fa5e91 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 2 Oct 2026 16:44:02 +0200 Subject: [PATCH 33/39] recall --- CHANGELOG.md | 2 +- src/codespy/workflows/review/models.py | 62 ++++++++---- src/codespy/workflows/review/pipeline.py | 16 +-- tests/test_review_memories.py | 119 ++++++++++++++++++----- 4 files changed, 148 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec5f26d..ba4a904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **Review is now published before the memory phase**: Episode saves, retain, consolidation and mental-model refresh run after audit. For remote reviews, the GitHub/GitLab review is posted first with "Memory: pending", then edited with full costs after the memory phase completes. Local CLI and MCP reviews run the memory phase before returning/printing. This keeps the review pipeline responsive while memory operations complete in the background. ### Added -- **Review memories section**: The pre-call Prefrontal memory (run-level and scope-level) now appears in a collapsed `memories` section **before** the Summary. Each recall is nested under its own `
` block, and each memory section (Briefings, Around this work, Seen before, Decisions, Recurring patterns, What changed, Other repositories) is also collapsible. This section is shown in both the GitHub/GitLab review body and the stdout/MCP markdown output. +- **Review memories section**: The pre-call Prefrontal memory (run-level and scope-level) now appears in a collapsed `memories` section **before** the Summary. Run-level sections (Briefings, Around this work, Seen before, Decisions, Recurring patterns, What changed, Other repositories) render directly under `memories`. Scope recall, when present, is nested in its own `scope` block. All levels are indented with `
` so nesting is visible in GitHub/GitLab. This section is shown in both the GitHub/GitLab review body and the stdout/MCP markdown output. - **Cerebral structured output support**: Native structured output registration for Bedrock models (e.g., Nvidia Nemotron Super) that support it but lack the flag in litellm's model map. Two-step structured output fallback for memory models litellm cannot schema-enforce, using `llm.extraction_model`. - **Cerebral per-scope observation cap**: New `max_observations_per_scope` setting under `memory.cerebral.consolidation` (env `MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE`, default `100`). Limits observations per `[org:*, repo:*, project_scope:*]` scope; `-1` = unlimited, `0` = no new observations. Scopes that hit the cap only allow UPDATE/DELETE. diff --git a/src/codespy/workflows/review/models.py b/src/codespy/workflows/review/models.py index 625200c..8f4026d 100644 --- a/src/codespy/workflows/review/models.py +++ b/src/codespy/workflows/review/models.py @@ -27,6 +27,10 @@ class RecalledMemory(BaseModel): sections: list[MemorySection] = Field( default_factory=list, description="Sections of the recalled memory" ) + nested: bool = Field( + default=True, + description="False = sections render directly under the memories section, without a per-recall block", + ) class SignatureStatsResult(BaseModel): @@ -347,13 +351,26 @@ def memories_markdown_lines( ) -> list[str]: """Build the collapsible memories section with nested per-recall and per-section details. - Structure: + Structure (flat run-level sections, nested scope recall):
{summary} -
{task} -
{section_title}{body}
- ... -
- ... +
+ + +
{section_title} +
{body}
+
+ + +
{task} +
+
{section_title} +
{body}
+
+ ... +
+
+ +
GitReporter passes ``max_chars`` to stay within GitHub's 65,536 char @@ -375,25 +392,27 @@ def size(block_lines: list[str]) -> int: return sum(len(line) + 1 for line in block_lines) def recall_head(task: str) -> list[str]: - return ["
", f"{task}", ""] + return ["
", f"{task}", "
", ""] def recall_tail() -> list[str]: - return ["
", ""] + return ["
", "
", ""] def section_block(title: str, body: str) -> list[str]: return [ "
", f"{title}", + "
", "", body, "", + "
", "
", "", ] # Reserve room for outer head/tail and per-recall tails - outer_head = ["
", f"{summary}", ""] - outer_tail = ["
", ""] + outer_head = ["
", f"{summary}", "
", ""] + outer_tail = ["
", "
", ""] outer_overhead = size(outer_head) + size(outer_tail) lines: list[str] = [] @@ -408,14 +427,19 @@ def section_block(title: str, body: str) -> list[str]: r_tail = recall_tail() recall_overhead = size(r_head) + size(r_tail) - # Budget check: need at least recall overhead + one section - if remaining is not None and remaining < recall_overhead: - break + # For flat (nested=False), no recall head/tail overhead + if memory.nested: + # Budget check: need at least recall overhead + one section + if remaining is not None and remaining < recall_overhead: + break - if remaining is not None: - remaining -= recall_overhead + if remaining is not None: + remaining -= recall_overhead + else: + # Flat: no recall overhead, sections go directly into lines + recall_overhead = 0 - r_lines: list[str] = list(r_head) + r_lines: list[str] = list(r_head) if memory.nested else [] has_section = False recall_stopped = False @@ -444,10 +468,12 @@ def section_block(title: str, body: str) -> list[str]: # Also stop the outer loop - no more recalls fit outer_stopped = True - r_lines.extend(r_tail) + if memory.nested: + r_lines.extend(r_tail) + if has_section: lines.extend(r_lines) - elif remaining is not None: + elif memory.nested and remaining is not None: # No section rendered for this recall: give back its overhead remaining += recall_overhead diff --git a/src/codespy/workflows/review/pipeline.py b/src/codespy/workflows/review/pipeline.py index ebc7b09..89bfbba 100644 --- a/src/codespy/workflows/review/pipeline.py +++ b/src/codespy/workflows/review/pipeline.py @@ -336,20 +336,22 @@ def _run_review_phase( ) # Build memories list from scope and run-level Prefrontal loads + # Run-level first (flat), then scope (nested) memories: list[RecalledMemory] = [] - scope_pf_sections = self.scope_resolver.prefrontal_sections - if scope_pf_sections: + if run_pf and run_pf.last_sections: memories.append( RecalledMemory( - task="scope", - sections=[MemorySection(title=t, text=b) for t, b in scope_pf_sections], + task="review", + nested=False, + sections=[MemorySection(title=t, text=b) for t, b in run_pf.last_sections], ) ) - if run_pf and run_pf.last_sections: + scope_pf_sections = self.scope_resolver.prefrontal_sections + if scope_pf_sections: memories.append( RecalledMemory( - task="review", - sections=[MemorySection(title=t, text=b) for t, b in run_pf.last_sections], + task="scope", + sections=[MemorySection(title=t, text=b) for t, b in scope_pf_sections], ) ) diff --git a/tests/test_review_memories.py b/tests/test_review_memories.py index a6a2c58..463014c 100644 --- a/tests/test_review_memories.py +++ b/tests/test_review_memories.py @@ -10,15 +10,23 @@ def _result( - *memories: tuple[str, list[tuple[str, str]]], recommendation: str | None = "approve" + *memories: tuple[str, list[tuple[str, str]]] | tuple[str, list[tuple[str, str]], bool], + recommendation: str | None = "approve", ) -> ReviewResult: - """Build a ReviewResult with memories from (task, [(section_title, section_body), ...]) tuples.""" + """Build a ReviewResult with memories from (task, [(section_title, section_body), ...]) tuples. + + Optional third element in tuple is nested flag (default True). + """ recalled = [] - for task, sections in memories: + for item in memories: + task = item[0] + sections = item[1] + nested = item[2] if len(item) > 2 else True recalled.append( RecalledMemory( task=task, sections=[MemorySection(title=t, text=b) for t, b in sections], + nested=nested, ) ) return ReviewResult( @@ -44,13 +52,29 @@ def test_no_memories_no_section(): def test_single_memory_nested_structure(): """Single memory still gets nested
: outer > recall > sections.""" - md = _result(("review", [("Around this work", "fact A"), ("Decisions", "fact B")])).to_markdown() - # Should have: outer memories + 1 recall + 2 section details = 4
+ md = _result(("scope", [("Around this work", "fact A"), ("Decisions", "fact B")])).to_markdown() + # Nested: outer (1) + recall (1) + 2 sections (2) = 4
+ # With blockquote: each
has one
assert md.count("
") == 4 assert md.count("
") == 4 + # Each details+blockquote pair should have balanced tags + assert md.count("
") == md.count("
") # No ### task heading - task is in - assert "### review" not in md - assert "review" in md + assert "### scope" not in md + assert "scope" in md + # Sections are in summaries + assert "Around this work" in md + assert "Decisions" in md + + +def test_flat_memory_no_recall_wrapper(): + """Flat (nested=False) memory has no task wrapper.""" + md = _result(("review", [("Around this work", "fact A"), ("Decisions", "fact B")], False)).to_markdown() + # Flat: outer (1) + 2 sections (2) = 3
(no recall wrapper) + assert md.count("
") == 3 + assert md.count("
") == 3 + # No review since it's flat + assert "review" not in md # Sections are in summaries assert "Around this work" in md assert "Decisions" in md @@ -58,28 +82,32 @@ def test_single_memory_nested_structure(): def test_memories_before_summary(): """Memories section must appear before ## Summary in to_markdown output.""" - md = _result(("review", [("Around this work", "fact A")])).to_markdown() + md = _result(("scope", [("Around this work", "fact A")])).to_markdown() assert md.index("memories") < md.index("## Summary") def test_git_body_memories_before_summary(): """Memories section must appear before 📋 Summary in git body.""" - body = _git_body(_result(("review", [("Around this work", "fact A")]))) + body = _git_body(_result(("scope", [("Around this work", "fact A")]))) assert "🧠 memories" in body assert body.index("🧠 memories") < body.index("📋 Summary") -def test_multiple_memories_ordered(): - """Multiple recalls appear in order with nested sections.""" +def test_multiple_memories_flat_before_scope(): + """Multiple recalls: flat first, then scope, with correct structure.""" md = _result( - ("scope", [("Around this work", "scope context")]), - ("review", [("Decisions", "review decisions")]), + ("review", [("Decisions", "review decisions")], False), # flat + ("scope", [("Around this work", "scope context")]), # nested ).to_markdown() - # Should have: outer + 2 recalls + 2 sections = 5
- assert md.count("
") == 5 - assert md.count("
") == 5 - # Recalls are ordered - assert md.index("scope") < md.index("review") + # Flat + nested: outer (1) + 1 section (flat) + 1 recall (scope) + 1 section (nested) = 4
+ assert md.count("
") == 4 + assert md.count("
") == 4 + # Balanced blockquote tags + assert md.count("
") == md.count("
") + # Flat sections appear before scope + assert md.index("Decisions") < md.index("scope") + # Flat sections don't have review + assert "review" not in md # Outer section closes last assert md.rstrip().endswith("
") @@ -88,11 +116,13 @@ def test_section_with_subheadings_stays_in_one_details(): """A section body containing '## ' must render as exactly one
block.""" body_with_subheadings = "## Service Structure\nContent\n## Another subsection\nMore content" md = _result( - ("review", [("Around this work", body_with_subheadings), ("Decisions", "fact")]) + ("scope", [("Around this work", body_with_subheadings), ("Decisions", "fact")]) ).to_markdown() - # outer (1) + recall (1) + 2 sections (2) = 4
+ # nested: outer (1) + recall (1) + 2 sections (2) = 4
assert md.count("
") == 4 assert md.count("
") == 4 + # Balanced blockquote tags + assert md.count("
") == md.count("
") def test_empty_sections_not_rendered(): @@ -104,7 +134,7 @@ def test_empty_sections_not_rendered(): repo="o/r", model_used="m", recommendation="approve", - memories=[RecalledMemory(task="review", sections=[])], + memories=[RecalledMemory(task="scope", sections=[])], ) assert result.memories_markdown_lines() == [] @@ -112,23 +142,62 @@ def test_empty_sections_not_rendered(): def test_git_body_truncates_to_budget(): """Large memories are truncated to fit within MAX_BODY_CHARS.""" big = "x" * 50_000 - body = _git_body(_result(("scope", [("Around this work", big)]), ("review", [("Decisions", big)]))) + body = _git_body(_result(("scope", [("Around this work", big)]), ("review", [("Decisions", big)], False))) assert len(body) <= GitReporter.MAX_BODY_CHARS assert TRUNCATED_MARKER in body assert body.count("
") == body.count("
") + assert body.count("
") == body.count("
") def test_budget_too_small_drops_section(): """When budget is too small, memories section is dropped entirely.""" - result = _result(("review", [("Around this work", "abc")])) + result = _result(("scope", [("Around this work", "abc")])) assert result.memories_markdown_lines(max_chars=10) == [] def test_balanced_tags_when_truncated(): - """When truncating, all
tags remain balanced.""" + """When truncating, all
and
tags remain balanced.""" big = "x" * 100_000 - result = _result(("review", [("Around this work", big), ("Decisions", big)])) + result = _result(("review", [("Around this work", big), ("Decisions", big)], False)) lines = result.memories_markdown_lines(max_chars=5000) text = "\n".join(lines) assert text.count("
") == text.count("
") + assert text.count("
") == text.count("
") assert TRUNCATED_MARKER in text + + +def test_flat_structure_tag_counts(): + """Flat single recall with 2 sections -> 3 details + 3 blockquotes.""" + md = _result(("review", [("Briefings", "b1"), ("Around this work", "b2")], False)).to_markdown() + # Flat: outer + 2 sections = 3
+ assert md.count("
") == 3 + assert md.count("
") == 3 + # Each details has one blockquote wrapper + assert md.count("
") == 3 + assert md.count("
") == 3 + + +def test_flat_plus_scope_structure_tag_counts(): + """Flat + scope (1 section each) -> 4 details + 4 blockquotes.""" + md = _result( + ("review", [("Briefings", "b1")], False), + ("scope", [("Around this work", "s1")]), + ).to_markdown() + # Flat + nested: outer + 1 flat section + scope recall + 1 nested section = 4
+ assert md.count("
") == 4 + assert md.count("
") == 4 + # Each details has one blockquote wrapper + assert md.count("
") == 4 + assert md.count("
") == 4 + + +def test_order_flat_before_scope(): + """Flat sections must appear before scope.""" + md = _result( + ("review", [("Briefings", "b1"), ("Decisions", "d1")], False), + ("scope", [("Around this work", "s1")]), + ).to_markdown() + # Find positions of flat section and scope + briefings_pos = md.index("Briefings") + scope_pos = md.index("scope") + assert briefings_pos < scope_pos From afcaa448e1d0f1ee1634ae9635b430f3ea9c9a75 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 2 Oct 2026 23:54:23 +0200 Subject: [PATCH 34/39] recall --- .env.example | 57 +-- .github/workflows/codespy-review.yml.example | 6 +- CHANGELOG.md | 41 ++ action.yml | 426 ++++++++++-------- codespy.yaml | 131 +++--- docs/configuration.md | 32 +- docs/memory.md | 15 +- src/codespy/config.py | 118 ++--- src/codespy/config_dspy.py | 24 +- src/codespy/config_llm.py | 44 +- src/codespy/config_memory.py | 20 +- src/codespy/workflows/review/reporters/git.py | 95 +++- tests/test_config_memory.py | 18 +- tests/test_review_statistics.py | 123 +++++ 14 files changed, 708 insertions(+), 442 deletions(-) create mode 100644 tests/test_review_statistics.py diff --git a/.env.example b/.env.example index a318288..e60c168 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,9 @@ # Priority: Environment Variables > YAML Config > Defaults # # Environment variables follow the YAML path, uppercased and joined with _. -# The llm., memory.hippocampus. and memory.cerebral. segments are dropped; -# llm.retries and llm.timeout keep LLM_. +# The llm., memory.hippocampus., memory.cerebral. and memory.prefrontal. +# segments are dropped; llm.retries, llm.timeout and memory.prefrontal.reflects +# keep their full names. # ============================================================================= # Git Platform Tokens @@ -46,7 +47,7 @@ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # - gemini/gemini-2.5-pro (Google Gemini) # - gemini/gemini-2.5-flash (Google Gemini) # - ollama/llama-4-70b (Local Ollama) -DEFAULT_MODEL=anthropic/claude-opus-4-6 +DEFAULT_MODEL=bedrock/converse/nvidia.nemotron-nano-3-30b # ------------------------------------------------------------------------------ # Option 1: Anthropic (direct) @@ -96,27 +97,27 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # ============================================================================= # codespy uses a tiered model approach to balance review quality and cost: # -# Smart (DEFAULT_MODEL): Core analysis — code review (bugs, security, smells), +# Smart (per-signature pinned): Core analysis — code review (bugs, security, smells), # supply chain, scope identification, doc review. Requires strong reasoning. -# Recommended: anthropic/claude-opus-4-6 or equivalent. +# Default: bedrock/converse/global.anthropic.claude-opus-5-5 (via per-signature pins). # # Mid-tier (EXTRACTION_MODEL): Structured field extraction. Needs accuracy -# but not deep reasoning. Recommended: anthropic/claude-sonnet-4-5-20250929 -# or equivalent. +# but not deep reasoning. Default: bedrock/converse/nvidia.nemotron-nano-3-30b # # Cheap (REVIEW_SUMMARY_MODEL): PR summary generation. Simple synthesis. -# Recommended: anthropic/claude-haiku-4-5-20251001 or equivalent. +# Default: bedrock/converse/nvidia.nemotron-super-3-120b # # Mid-tier (MEMORY_DISTILLER_MODEL / MEMORY_CARTOGRAPHER_MODEL): # Memory reflection — summarizing a trajectory and curating the context memory. # Requires nuance. -# Recommended: anthropic/claude-sonnet-4-5-20250929 or equivalent. +# Default: bedrock/converse/nvidia.nemotron-super-3-120b # -# By default, all models fall back to DEFAULT_MODEL. To optimize costs: -# EXTRACTION_MODEL=anthropic/claude-sonnet-4-5-20250929 -# REVIEW_SUMMARY_MODEL=anthropic/claude-haiku-4-5-20251001 -# MEMORY_DISTILLER_MODEL=anthropic/claude-sonnet-4-5-20250929 -# MEMORY_CARTOGRAPHER_MODEL=anthropic/claude-sonnet-4-5-20250929 +# By default, all models are pinned. To override a specific unit, set its +# per-signature or per-memory env var. To re-enable the fallback chain, set the +# pinned unit to null: REVIEW_CODE_REVIEW_MODEL=null uses DEFAULT_MODEL. +# +# New defaults require AWS Bedrock access to Opus 5.5 and both Nemotron models. +# Override every model if using Anthropic-only, OpenAI-only, or Gemini-only. # ============================================================================= @@ -125,13 +126,13 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # These apply to all signatures unless overridden per-signature # Extraction model for TwoStepAdapter -# EXTRACTION_MODEL=anthropic/claude-sonnet-4-5-20250929 +# EXTRACTION_MODEL=bedrock/converse/nvidia.nemotron-nano-3-30b # DEFAULT_MAX_ITERS=5 # DEFAULT_MAX_LLM_CALLS=8 # Provider reasoning budget: minimal | low | medium | high # DEFAULT_REASONING_EFFORT=medium -# DEFAULT_TEMPERATURE=0.2 +# DEFAULT_TEMPERATURE=1.0 # Output token budget for a single completion (default: 32000) # DEFAULT_MAX_TOKENS=32000 @@ -178,7 +179,7 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # pg0 embedded settings (local dev only) # MEMORY_PG0_NAME=codespy -# MEMORY_PG0_PORT=5432 +# MEMORY_PG0_PORT=5433 # MEMORY_PG0_DATA_DIR=~/.cache/codespy/pg0 # Memory master switch — overridable per-signature via REVIEW__MEMORY_ENABLED @@ -194,14 +195,14 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # MEMORY_MAX_QUESTION_TOKENS=8192 # LLM settings for reflection modules -# MEMORY_DISTILLER_MODEL=anthropic/claude-haiku-4-5-20251001 +# MEMORY_DISTILLER_MODEL=bedrock/converse/nvidia.nemotron-super-3-120b # MEMORY_DISTILLER_REASONING_EFFORT=low # MEMORY_DISTILLER_TEMPERATURE=1 # MEMORY_DISTILLER_MAX_TOKENS=64000 # MEMORY_DISTILLER_MAX_ITERS=1 # MEMORY_DISTILLER_MAX_LLM_CALLS=2 -# MEMORY_CARTOGRAPHER_MODEL=anthropic/claude-haiku-4-5-20251001 +# MEMORY_CARTOGRAPHER_MODEL=bedrock/converse/nvidia.nemotron-super-3-120b # MEMORY_CARTOGRAPHER_REASONING_EFFORT=low # MEMORY_CARTOGRAPHER_TEMPERATURE=1 # MEMORY_CARTOGRAPHER_MAX_TOKENS=64000 @@ -215,9 +216,11 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # Cerebral activates automatically when MEMORY_ENABLED=true. # LLM provider and credentials are derived from the model string and existing # LLM config (e.g. bedrock uses AWS env vars, openai uses OPENAI_API_KEY). -# MEMORY_RETAIN_MODEL= # Falls back to DEFAULT_MODEL -# MEMORY_CONSOLIDATION_MODEL= # Falls back to MEMORY_RETAIN_MODEL → DEFAULT_MODEL -# MEMORY_EMBEDDINGS_MODEL= # Auto-derived from provider +# MEMORY_RETAIN_MODEL=bedrock/converse/nvidia.nemotron-super-3-120b +# MEMORY_CONSOLIDATION_MODEL=bedrock/converse/nvidia.nemotron-super-3-120b +# MEMORY_MENTAL_MODELS_MODEL=bedrock/converse/nvidia.nemotron-super-3-120b +# MEMORY_EMBEDDINGS_MODEL=bedrock/cohere.embed-v4:0 +# MEMORY_EMBEDDINGS_MAX_INPUT_CHARS= # null = auto per model (2048 for Bedrock Cohere v3), 0 = off # MEMORY_EMBEDDINGS_MAX_INPUT_CHARS= # null = auto per model (2048 for Bedrock Cohere v3), 0 = off # MEMORY_RETAIN_CHUNK_SIZE=12288 # Chars per extraction chunk (< 64000) # MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE=100 # Per-scope observation cap (-1 = unlimited, 0 = no new observations) @@ -312,11 +315,13 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED=true # REVIEW_DOC_ENABLED=true -# REVIEW_DOC_MAX_ITERS=1 -# REVIEW_DOC_MAX_LLM_CALLS=2 -# REVIEW_DOC_MODEL=anthropic/claude-sonnet-4-5-20250929 +# REVIEW_DOC_MAX_ITERS=2 +# REVIEW_DOC_MAX_LLM_CALLS=4 +# REVIEW_DOC_MODEL=bedrock/converse/global.anthropic.claude-opus-5-5 # REVIEW_DOC_MEMORY_ENABLED=true +# REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN=true + # REVIEW_SCOPE_ENABLED=true # REVIEW_SCOPE_MAX_ITERS=4 # REVIEW_SCOPE_REASONING_EFFORT=low @@ -326,7 +331,7 @@ DEFAULT_MODEL=anthropic/claude-opus-4-6 # REVIEW_SUMMARY_ENABLED=true # REVIEW_SUMMARY_MAX_ITERS=1 # REVIEW_SUMMARY_MAX_LLM_CALLS=2 -# REVIEW_SUMMARY_MODEL=anthropic/claude-haiku-4-5-20251001 +# REVIEW_SUMMARY_MODEL=bedrock/converse/nvidia.nemotron-super-3-120b # REVIEW_SUMMARY_MEMORY_ENABLED=true # REVIEW_AUDIT_ENABLED=true diff --git a/.github/workflows/codespy-review.yml.example b/.github/workflows/codespy-review.yml.example index dcb70ac..bfca331 100644 --- a/.github/workflows/codespy-review.yml.example +++ b/.github/workflows/codespy-review.yml.example @@ -40,7 +40,7 @@ jobs: # 💰 Cheap tier — summarization model: 'anthropic/claude-opus-4-6' extraction-model: 'anthropic/claude-sonnet-4-5-20250929' - summarization-model: 'anthropic/claude-haiku-4-5-20251001' + summary-model: 'anthropic/claude-haiku-4-5-20251001' # Required: Provide your LLM API key (use GitHub Secrets!) anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} @@ -71,7 +71,7 @@ jobs: with: model: 'anthropic/claude-opus-4-6' extraction-model: 'anthropic/claude-sonnet-4-5-20250929' - summarization-model: 'anthropic/claude-haiku-4-5-20251001' + summary-model: 'anthropic/claude-haiku-4-5-20251001' anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} pr-url: ${{ github.event.issue.pull_request.html_url }} @@ -93,7 +93,7 @@ jobs: # with: # model: 'bedrock/us.anthropic.claude-opus-4-6-v1' # extraction-model: 'bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0' -# summarization-model: 'bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0' +# summary-model: 'bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0' # aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} # aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} # aws-region: 'us-east-1' diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4a904..2067692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,48 @@ ## [Unreleased] +### Fixed +- Fixed "too many values to unpack" error in review statistics matrix (line 345 was iterating `severities` as tuples instead of enums) +- `GITLAB_URL` from the environment was ignored — now properly takes precedence over YAML `gitlab.url` +- Token precedence now matches documentation: env/`.env` beats YAML (`github.token` / `gitlab.token`) +- Added missing `memory-prefrontal-reflects` action input +- Removed action `llm-timeout` default (was `'120'`, now uses codespy.yaml's `240`) +- Doc reviewer defaults now match codespy.yaml (`max_iters: 2`, `max_llm_calls: 4`) +- Fixed workflow example input: `summarization-model` → `summary-model` + +### Added +- New action inputs (no defaults, values taken from codespy.yaml when unset): + - `scope-max-tokens`, `code-review-max-tokens`, `doc-max-tokens`, `supply-chain-max-tokens`, `summary-max-tokens`, `audit-max-tokens` + - `memory-distiller-max-tokens`, `memory-cartographer-max-tokens` + - `scope-skip-refinement-when-clean` + - `memory-compact-trajectory` + - `memory-max-hippocampus-tokens`, `memory-max-hippocampus-item-tokens`, `memory-max-trajectory-tokens`, `memory-max-question-tokens` + - `memory-mental-models-max-tokens`, `memory-mental-models-min-refresh-seconds` + - `memory-embeddings-max-input-chars` + - `openai-api-base`, `azure-api-key`, `azure-api-base`, `azure-api-version` +- AWS region now forwarded independently of access key (was bundled with credentials) + +### Changed (Breaking) +- **New default models and temperature** — out-of-the-box codespy now requires AWS Bedrock access to Opus 5.5 and both Nemotron models: + - `DEFAULT_MODEL`: `bedrock/converse/nvidia.nemotron-nano-3-30b` + - `EXTRACTION_MODEL`: `bedrock/converse/nvidia.nemotron-nano-3-30b` + - `REVIEW_SCOPE_MODEL`, `REVIEW_CODE_REVIEW_MODEL`, `REVIEW_DOC_MODEL`, `REVIEW_AUDIT_MODEL`: `bedrock/converse/global.anthropic.claude-opus-5-5` + - `REVIEW_SUPPLY_CHAIN_MODEL`, `REVIEW_SUMMARY_MODEL`: `bedrock/converse/nvidia.nemotron-super-3-120b` + - `MEMORY_*_MODEL` (distiller, cartographer, retain, consolidation, mental_models, recall): `bedrock/converse/nvidia.nemotron-super-3-120b` + - `MEMORY_EMBEDDINGS_MODEL`: `bedrock/cohere.embed-v4:0` + - `DEFAULT_TEMPERATURE`: `1.0` +- **Embeddings model v4** changes the vector dimension — requires a semantic DB reset (`DROP SCHEMA semantic CASCADE`) +- Action `model` input is now optional (was `required: true`); when unset, codespy.yaml default applies +- Action inputs no longer carry `default:` values; codespy.yaml applies instead +- Setting a pinned unit to `null` (YAML `model: null` or env `VAR=null`) re-enables the fallback chain + +### Removed +- Action input `memory-prefrontal-model` (was deprecated, never wired) +- Action env vars: `SCOPE_MEMORY_ENABLED`, `CODE_REVIEW_MEMORY_ENABLED`, `DOC_MEMORY_ENABLED`, `SUPPLY_CHAIN_MEMORY_ENABLED`, `SUMMARY_MEMORY_ENABLED`, `AUDIT_MEMORY_ENABLED`, `OUTPUT_FORMAT` +- Dead code: `Settings.sync_llm_settings()` and `LLMConfig.sync_from_flat()` + ### Changed +- **Review statistics matrix**: the GitHub/GitLab review Statistics table is now a severity × category matrix (Security, Bugs, Documentation, Smells + totals); Smells are now counted. - **Review is now published before the memory phase**: Episode saves, retain, consolidation and mental-model refresh run after audit. For remote reviews, the GitHub/GitLab review is posted first with "Memory: pending", then edited with full costs after the memory phase completes. Local CLI and MCP reviews run the memory phase before returning/printing. This keeps the review pipeline responsive while memory operations complete in the background. ### Added diff --git a/action.yml b/action.yml index 37ee398..a8bd9ee 100644 --- a/action.yml +++ b/action.yml @@ -12,11 +12,11 @@ inputs: default: ${{ github.token }} model: - description: 'Default LLM model to use (e.g., anthropic/claude-sonnet-4-5-20250929, openai/gpt-5, bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0)' - required: true + description: 'Default LLM model to use (e.g., anthropic/claude-sonnet-4-5-20250929, openai/gpt-5, bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0). Affects units without a model pin. Default from codespy.yaml: bedrock/converse/nvidia.nemotron-nano-3-30b' + required: false extraction-model: - description: 'Model for field extraction (smaller model recommended)' + description: 'Model for field extraction (smaller model recommended). Default from codespy.yaml: bedrock/converse/nvidia.nemotron-nano-3-30b' required: false pr-url: @@ -30,9 +30,8 @@ inputs: default: 'true' output-format: - description: 'Output format: markdown or json' + description: 'Output format: markdown or json. Note: issue count parsing only understands markdown. Default from codespy.yaml: markdown' required: false - default: 'markdown' # LLM Provider API Keys anthropic-api-key: @@ -51,405 +50,450 @@ inputs: aws-access-key-id: description: 'AWS Access Key ID (for Bedrock models)' required: false - + aws-secret-access-key: description: 'AWS Secret Access Key (for Bedrock models)' required: false - + aws-region: - description: 'AWS Region (for Bedrock models)' + description: 'AWS Region (for Bedrock models). Default from codespy.yaml: us-east-1' required: false - default: 'us-east-1' - + # Global defaults default-max-iters: - description: 'Default maximum iterations for all signatures' + description: 'Default maximum iterations for all signatures. Default from codespy.yaml: 5' required: false - default: '5' - + default-reasoning-effort: - description: 'Default reasoning effort for all signatures (minimal|low|medium|high)' + description: 'Default reasoning effort for all signatures (minimal|low|medium|high). Default from codespy.yaml: medium' required: false - default: 'medium' - + default-temperature: - description: 'Default temperature for LLM calls' + description: 'Default temperature for LLM calls. Default from codespy.yaml: 1.0' required: false - default: '0.2' default-max-tokens: - description: 'Output token budget per completion (reasoning tokens included). Clamped to model ceiling.' + description: 'Output token budget per completion (reasoning tokens included). Clamped to model ceiling. Default from codespy.yaml: 32000' required: false - default: '32000' llm-retries: - description: 'Number of retries for LLM API calls' + description: 'Number of retries for LLM API calls. Default from codespy.yaml: 2' required: false - default: '2' - + llm-timeout: - description: 'Timeout in seconds for LLM calls' + description: 'Timeout in seconds for LLM calls. Default from codespy.yaml: 240' required: false - default: '120' - + default-max-llm-calls: - description: 'Maximum LLM calls for RLM fallback (context overflow recovery)' + description: 'Maximum LLM calls for RLM fallback (context overflow recovery). Default from codespy.yaml: 8' required: false - default: '8' # RLM fallback (context rot prevention) rlm-fallback-enabled: - description: 'Enable proactive RLM fallback before context window overflow' + description: 'Enable proactive RLM fallback before context window overflow. Default from codespy.yaml: true' required: false - default: 'true' rlm-fallback-react-threshold: - description: 'Context ratio for ReAct modules (0.0-1.0). RLM used when input exceeds this ratio of max_input_tokens.' + description: 'Context ratio for ReAct modules (0.0-1.0). RLM used when input exceeds this ratio of max_input_tokens. Default from codespy.yaml: 0.30' required: false - default: '0.30' rlm-fallback-chain-of-thought-threshold: - description: 'Context ratio for ChainOfThought modules (0.0-1.0). RLM used when input exceeds this ratio.' + description: 'Context ratio for ChainOfThought modules (0.0-1.0). RLM used when input exceeds this ratio. Default from codespy.yaml: 0.40' required: false - default: '0.40' rlm-fallback-predict-threshold: - description: 'Context ratio for Predict modules (0.0-1.0). RLM used when input exceeds this ratio.' + description: 'Context ratio for Predict modules (0.0-1.0). RLM used when input exceeds this ratio. Default from codespy.yaml: 0.50' required: false - default: '0.50' min-confidence: - description: 'Minimum confidence threshold (0.0-1.0) for reported issues' + description: 'Minimum confidence threshold (0.0-1.0) for reported issues. Default from codespy.yaml: 0.81' required: false - default: '0.81' # ========================================== # SIGNATURE: scope # ========================================== scope-enabled: - description: 'Enable scope identification signature' + description: 'Enable scope identification signature. Default from codespy.yaml: true' required: false - default: 'true' - + scope-model: - description: 'Model for scope identification (empty = use default)' + description: 'Model for scope identification (empty = use default). Default from codespy.yaml: bedrock/converse/global.anthropic.claude-opus-5-5' required: false - + scope-max-iters: - description: 'Max iterations for scope identification' + description: 'Max iterations for scope identification. Default from codespy.yaml: 3' required: false scope-max-llm-calls: - description: 'Max LLM calls for scope RLM fallback' + description: 'Max LLM calls for scope RLM fallback. Default from codespy.yaml: 5' + required: false + + scope-max-tokens: + description: 'Max tokens for scope identification. Default from codespy.yaml: default-max-tokens' required: false scope-reasoning-effort: - description: 'Reasoning effort for scope (minimal|low|medium|high)' + description: 'Reasoning effort for scope (minimal|low|medium|high). Default from codespy.yaml: medium' required: false - + + scope-skip-refinement-when-clean: + description: 'Skip LLM refinement when deterministic resolution is clean. Default from codespy.yaml: true' + required: false + scope-temperature: - description: 'Temperature for scope identification' + description: 'Temperature for scope identification. Default from codespy.yaml: 1.0' required: false - + + scope-memory-enabled: + description: 'Enable memory for scope identification (overrides global memory-enabled). Default from codespy.yaml: null (use global)' + required: false + # ========================================== # SIGNATURE: code_review # ========================================== code-review-enabled: - description: 'Enable code review signature (bugs, security vulnerabilities, code smells)' + description: 'Enable code review signature (bugs, security vulnerabilities, code smells). Default from codespy.yaml: true' required: false - default: 'true' - + code-review-model: - description: 'Model for code review (empty = use default)' + description: 'Model for code review (empty = use default). Default from codespy.yaml: bedrock/converse/global.anthropic.claude-opus-5-5' required: false - + code-review-max-iters: - description: 'Max iterations for code review' + description: 'Max iterations for code review. Default from codespy.yaml: 5' required: false code-review-max-llm-calls: - description: 'Max LLM calls for code review RLM fallback' + description: 'Max LLM calls for code review RLM fallback. Default from codespy.yaml: 8' + required: false + + code-review-max-tokens: + description: 'Max tokens for code review. Default from codespy.yaml: default-max-tokens' required: false code-review-reasoning-effort: - description: 'Reasoning effort for code review (minimal|low|medium|high)' + description: 'Reasoning effort for code review (minimal|low|medium|high). Default from codespy.yaml: medium' required: false - + code-review-temperature: - description: 'Temperature for code review' + description: 'Temperature for code review. Default from codespy.yaml: 1.0' required: false - + + code-review-memory-enabled: + description: 'Enable memory for code review (overrides global memory-enabled). Default from codespy.yaml: null (use global)' + required: false + # ========================================== # SIGNATURE: doc # ========================================== doc-enabled: - description: 'Enable documentation review signature (stale/wrong documentation)' + description: 'Enable documentation review signature (stale/wrong documentation). Default from codespy.yaml: true' required: false - default: 'true' - + doc-model: - description: 'Model for doc review (empty = use default)' + description: 'Model for doc review (empty = use default). Default from codespy.yaml: bedrock/converse/global.anthropic.claude-opus-5-5' required: false doc-max-iters: - description: 'Max iterations for doc review' + description: 'Max iterations for doc review. Default from codespy.yaml: 2' required: false doc-max-llm-calls: - description: 'Max LLM calls for doc review RLM fallback' + description: 'Max LLM calls for doc review RLM fallback. Default from codespy.yaml: 4' + required: false + + doc-max-tokens: + description: 'Max tokens for doc review. Default from codespy.yaml: default-max-tokens' required: false doc-reasoning-effort: - description: 'Reasoning effort for doc (minimal|low|medium|high)' + description: 'Reasoning effort for doc (minimal|low|medium|high). Default from codespy.yaml: medium' required: false - + doc-temperature: - description: 'Temperature for doc review' + description: 'Temperature for doc review. Default from codespy.yaml: 1.0' required: false - + + doc-memory-enabled: + description: 'Enable memory for doc review (overrides global memory-enabled). Default from codespy.yaml: null (use global)' + required: false + # ========================================== # SIGNATURE: supply_chain # ========================================== supply-chain-enabled: - description: 'Enable supply chain security signature' + description: 'Enable supply chain security signature. Default from codespy.yaml: true' required: false - default: 'true' - + supply-chain-model: - description: 'Model for supply chain security (empty = use default)' + description: 'Model for supply chain security (empty = use default). Default from codespy.yaml: bedrock/converse/nvidia.nemotron-super-3-120b' required: false - + supply-chain-max-iters: - description: 'Max iterations for supply chain security' + description: 'Max iterations for supply chain security. Default from codespy.yaml: null (use default-max-iters)' required: false supply-chain-max-llm-calls: - description: 'Max LLM calls for supply chain RLM fallback' + description: 'Max LLM calls for supply chain RLM fallback. Default from codespy.yaml: null (use default-max-llm-calls)' + required: false + + supply-chain-max-tokens: + description: 'Max tokens for supply chain. Default from codespy.yaml: default-max-tokens' required: false supply-chain-reasoning-effort: - description: 'Reasoning effort for supply chain (minimal|low|medium|high)' + description: 'Reasoning effort for supply chain (minimal|low|medium|high). Default from codespy.yaml: medium' required: false - + supply-chain-temperature: - description: 'Temperature for supply chain security' + description: 'Temperature for supply chain security. Default from codespy.yaml: 1.0' required: false - + supply-chain-scan-unchanged: - description: 'Scan all artifacts/manifests even if not modified in the PR (default: only scan changed)' + description: 'Scan all artifacts/manifests even if not modified in the PR (default: only scan changed). Default from codespy.yaml: false' required: false - default: 'false' - + + supply-chain-memory-enabled: + description: 'Enable memory for supply chain (overrides global memory-enabled). Default from codespy.yaml: null (use global)' + required: false + # ========================================== # SIGNATURE: summary # ========================================== summary-enabled: - description: 'Enable PR summary signature' + description: 'Enable PR summary signature. Default from codespy.yaml: true' required: false - default: 'true' summary-max-iters: - description: 'Max iterations for summary' + description: 'Max iterations for summary. Default from codespy.yaml: 1' required: false summary-model: - description: 'Model for summary (empty = use default)' + description: 'Model for summary (empty = use default). Default from codespy.yaml: bedrock/converse/nvidia.nemotron-super-3-120b' required: false summary-max-llm-calls: - description: 'Max LLM calls for summary RLM fallback' + description: 'Max LLM calls for summary RLM fallback. Default from codespy.yaml: 2' + required: false + + summary-max-tokens: + description: 'Max tokens for summary. Default from codespy.yaml: default-max-tokens' required: false summary-reasoning-effort: - description: 'Reasoning effort for summary (minimal|low|medium|high)' + description: 'Reasoning effort for summary (minimal|low|medium|high). Default from codespy.yaml: medium' required: false summary-temperature: - description: 'Temperature for summary' + description: 'Temperature for summary. Default from codespy.yaml: 1.0' + required: false + + summary-memory-enabled: + description: 'Enable memory for summary (overrides global memory-enabled). Default from codespy.yaml: null (use global)' required: false # ========================================== # SIGNATURE: audit # ========================================== audit-enabled: - description: 'Enable audit signature (quality assessment + recommendation)' + description: 'Enable audit signature (quality assessment + recommendation). Default from codespy.yaml: true' required: false - default: 'true' audit-model: - description: 'Model for audit (empty = use default)' + description: 'Model for audit (empty = use default). Default from codespy.yaml: bedrock/converse/global.anthropic.claude-opus-5-5' required: false audit-max-iters: - description: 'Max iterations for audit' + description: 'Max iterations for audit. Default from codespy.yaml: 1' required: false audit-max-llm-calls: - description: 'Max LLM calls for audit RLM fallback' + description: 'Max LLM calls for audit RLM fallback. Default from codespy.yaml: 2' + required: false + + audit-max-tokens: + description: 'Max tokens for audit. Default from codespy.yaml: default-max-tokens' required: false audit-reasoning-effort: - description: 'Reasoning effort for audit (minimal|low|medium|high)' + description: 'Reasoning effort for audit (minimal|low|medium|high). Default from codespy.yaml: medium' required: false audit-temperature: - description: 'Temperature for audit' + description: 'Temperature for audit. Default from codespy.yaml: 1.0' + required: false + + audit-memory-enabled: + description: 'Enable memory for audit (overrides global memory-enabled). Default from codespy.yaml: null (use global)' required: false # ========================================== # MEMORY (Hippocampus) # ========================================== memory-enabled: - description: 'Enable memory (Hippocampus) globally for cross-run learning (default: false)' + description: 'Enable memory (Hippocampus) globally for cross-run learning. Default from codespy.yaml: false' required: false - default: 'false' memory-postgres-host: description: 'PostgreSQL host for memory storage (e.g., rds-host.amazonaws.com). When unset, pg0-embedded auto-starts.' required: false + memory-postgres-port: - description: 'PostgreSQL port (default: 5432)' + description: 'PostgreSQL port. Default from codespy.yaml: 5432' required: false - default: '5432' + memory-postgres-user: - description: 'PostgreSQL user (default: postgres)' + description: 'PostgreSQL user (falls back to postgres if unset)' required: false + memory-postgres-password: description: 'PostgreSQL password' required: false + memory-postgres-database: - description: 'PostgreSQL database name (default: codespy)' + description: 'PostgreSQL database name. Default from codespy.yaml: codespy' required: false - default: 'codespy' + memory-postgres-schema: - description: 'PostgreSQL schema / search_path (default: episodic)' + description: 'PostgreSQL schema / search_path. Default from codespy.yaml: episodic' required: false - default: 'episodic' memory-bank-id: - description: 'Bank ID scoping all memory data (e.g., project name or agent name). Defaults to "codespy".' + description: 'Bank ID scoping all memory data (e.g., project name or agent name). Default from codespy.yaml: codespy' + required: false + + memory-compact-trajectory: + description: 'Apply head+tail trajectory bounding before distillation. Default from codespy.yaml: true' + required: false + + memory-max-hippocampus-tokens: + description: 'Ceiling on persisted context memory. Default from codespy.yaml: 16384' + required: false + + memory-max-hippocampus-item-tokens: + description: 'Soft per-observation token limit. Default from codespy.yaml: 512' + required: false + + memory-max-trajectory-tokens: + description: 'Cap on trajectory fed to Distiller. Default from codespy.yaml: 16384' + required: false + + memory-max-question-tokens: + description: 'Cap on serialized inputs as reflection question. Default from codespy.yaml: 8192' required: false memory-distiller-model: - description: 'Model for the Distiller reflection module (smaller model recommended)' + description: 'Model for the Distiller reflection module. Default from codespy.yaml: bedrock/converse/nvidia.nemotron-super-3-120b' required: false memory-distiller-reasoning-effort: - description: 'Reasoning effort for Distiller (minimal|low|medium|high)' + description: 'Reasoning effort for Distiller (minimal|low|medium|high). Default from codespy.yaml: medium' required: false memory-distiller-temperature: - description: 'Temperature for Distiller' + description: 'Temperature for Distiller. Default from codespy.yaml: 1.0' + required: false + + memory-distiller-max-tokens: + description: 'Max tokens for Distiller. Default from codespy.yaml: default-max-tokens' required: false memory-distiller-max-iters: - description: 'Max RLM iterations for Distiller (default: 1)' + description: 'Max RLM iterations for Distiller. Default from codespy.yaml: 1' required: false memory-distiller-max-llm-calls: - description: 'Max RLM LLM calls for Distiller (default: 2)' + description: 'Max RLM LLM calls for Distiller. Default from codespy.yaml: 2' required: false memory-cartographer-model: - description: 'Model for the Cartographer reflection module (smaller model recommended)' + description: 'Model for the Cartographer reflection module. Default from codespy.yaml: bedrock/converse/nvidia.nemotron-super-3-120b' required: false memory-cartographer-reasoning-effort: - description: 'Reasoning effort for Cartographer (minimal|low|medium|high)' + description: 'Reasoning effort for Cartographer (minimal|low|medium|high). Default from codespy.yaml: medium' required: false memory-cartographer-temperature: - description: 'Temperature for Cartographer' + description: 'Temperature for Cartographer. Default from codespy.yaml: 1.0' + required: false + + memory-cartographer-max-tokens: + description: 'Max tokens for Cartographer. Default from codespy.yaml: default-max-tokens' required: false memory-cartographer-max-iters: - description: 'Max RLM iterations for Cartographer (default: 1)' + description: 'Max RLM iterations for Cartographer. Default from codespy.yaml: 1' required: false memory-cartographer-max-llm-calls: - description: 'Max RLM LLM calls for Cartographer (default: 2)' + description: 'Max RLM LLM calls for Cartographer. Default from codespy.yaml: 2' required: false # ========================================== # MEMORY (Cerebral — semantic memory) # ========================================== memory-retain-model: - description: 'Model for Cerebral fact extraction (falls back to model / DEFAULT_MODEL)' + description: 'Model for Cerebral fact extraction. Default from codespy.yaml: bedrock/converse/nvidia.nemotron-super-3-120b' + required: false + + memory-retain-chunk-size: + description: 'Chars per Cerebral fact-extraction chunk (must be < 64000). Default from codespy.yaml: 12288' required: false memory-consolidation-model: - description: 'Model for Cerebral consolidation (falls back to memory-retain-model / model)' + description: 'Model for Cerebral consolidation. Default from codespy.yaml: bedrock/converse/nvidia.nemotron-super-3-120b' required: false memory-consolidation-max-observations-per-scope: - description: 'Per-scope observation cap (-1 = unlimited, 0 = no new observations)' + description: 'Per-scope observation cap (-1 = unlimited, 0 = no new observations). Default from codespy.yaml: 100' required: false - default: '100' memory-mental-models-model: - description: 'Model for mental-model briefing refresh (falls back to memory-prefrontal-model / memory-retain-model / model)' + description: 'Model for mental-model briefing refresh. Default from codespy.yaml: bedrock/converse/nvidia.nemotron-super-3-120b' + required: false + + memory-mental-models-max-tokens: + description: 'Briefing size. Default from codespy.yaml: 2048' + required: false + + memory-mental-models-min-refresh-seconds: + description: 'Minimum seconds between automatic briefing refreshes. Default from codespy.yaml: 0' required: false memory-recall-model: - description: 'Model for Prefrontal recall and briefing fallback (falls back to memory-retain-model / model)' + description: 'Model for Prefrontal recall and briefing fallback. Default from codespy.yaml: bedrock/converse/nvidia.nemotron-super-3-120b' required: false memory-recall-reach: - description: 'Recall reach: local (this repo only), org (same owner), or bank (all repos)' + description: 'Recall reach: local (this repo only), org (same owner), or bank (all repos). Default from codespy.yaml: org' required: false - default: 'org' memory-recall-max-tokens: - description: 'Pre-call context token budget' + description: 'Pre-call context token budget. Default from codespy.yaml: 8192' required: false - default: '8192' memory-recall-max-tool-tokens: - description: 'Recall memory tool result token budget' + description: 'Recall memory tool result token budget. Default from codespy.yaml: 2048' required: false - default: '2048' memory-recall-max-tool-calls: - description: 'Recall memory tool calls per agent (0 = tool off)' + description: 'Recall memory tool calls per agent (0 = tool off). Default from codespy.yaml: 0' required: false - default: '0' - memory-prefrontal-model: - description: 'DEPRECATED: Use memory-recall-model instead' + memory-prefrontal-reflects: + description: 'Reflect iterations for recall and briefings (0 = raw-fact recall, no LLM, no briefings). Default from codespy.yaml: 3' required: false memory-embeddings-model: - description: 'litellm embedding model for Cerebral (auto-derived from the provider when unset)' + description: 'litellm embedding model for Cerebral. Default from codespy.yaml: bedrock/cohere.embed-v4:0' required: false - memory-retain-chunk-size: - description: 'Chars per Cerebral fact-extraction chunk (default: 12288, must be < 64000)' + memory-embeddings-max-input-chars: + description: 'Maximum characters per embedding input (null=auto per model, 0=off). Default from codespy.yaml: null (auto)' required: false - # Per-signature memory overrides - scope-memory-enabled: - description: 'Enable memory for scope identification (overrides global memory-enabled)' - required: false - - code-review-memory-enabled: - description: 'Enable memory for code review (overrides global memory-enabled)' - required: false - - doc-memory-enabled: - description: 'Enable memory for doc review (overrides global memory-enabled)' - required: false - - supply-chain-memory-enabled: - description: 'Enable memory for supply chain (overrides global memory-enabled)' - required: false - - summary-memory-enabled: - description: 'Enable memory for summary (overrides global memory-enabled)' - required: false - - audit-memory-enabled: - description: 'Enable memory for audit (overrides global memory-enabled)' - required: false + # Per-signature memory overrides (already defined above) excluded-directories: description: 'JSON array of directories to exclude from review (e.g., ["vendor", "dist"])' @@ -537,8 +581,10 @@ runs: REVIEW_SCOPE_MODEL: ${{ inputs.scope-model }} REVIEW_SCOPE_MAX_ITERS: ${{ inputs.scope-max-iters }} REVIEW_SCOPE_MAX_LLM_CALLS: ${{ inputs.scope-max-llm-calls }} + REVIEW_SCOPE_MAX_TOKENS: ${{ inputs.scope-max-tokens }} REVIEW_SCOPE_REASONING_EFFORT: ${{ inputs.scope-reasoning-effort }} REVIEW_SCOPE_TEMPERATURE: ${{ inputs.scope-temperature }} + REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN: ${{ inputs.scope-skip-refinement-when-clean }} REVIEW_SCOPE_MEMORY_ENABLED: ${{ inputs.scope-memory-enabled }} # Code review signature @@ -587,8 +633,7 @@ runs: REVIEW_AUDIT_TEMPERATURE: ${{ inputs.audit-temperature }} REVIEW_AUDIT_MEMORY_ENABLED: ${{ inputs.audit-memory-enabled }} - # Output settings - REVIEW_OUTPUT_FORMAT: ${{ inputs.output-format }} + # Output settings (REVIEW_OUTPUT_FORMAT removed - only via CLI flag) # Memory (Hippocampus) MEMORY_ENABLED: ${{ inputs.memory-enabled }} @@ -612,7 +657,17 @@ runs: MEMORY_RETAIN_MODEL: ${{ inputs.memory-retain-model }} MEMORY_CONSOLIDATION_MODEL: ${{ inputs.memory-consolidation-model }} MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE: ${{ inputs.memory-consolidation-max-observations-per-scope }} + MEMORY_COMPACT_TRAJECTORY: ${{ inputs.memory-compact-trajectory }} + MEMORY_MAX_HIPPOCAMPUS_TOKENS: ${{ inputs.memory-max-hippocampus-tokens }} + MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS: ${{ inputs.memory-max-hippocampus-item-tokens }} + MEMORY_MAX_TRAJECTORY_TOKENS: ${{ inputs.memory-max-trajectory-tokens }} + MEMORY_MAX_QUESTION_TOKENS: ${{ inputs.memory-max-question-tokens }} + MEMORY_DISTILLER_MAX_TOKENS: ${{ inputs.memory-distiller-max-tokens }} + MEMORY_CARTOGRAPHER_MAX_TOKENS: ${{ inputs.memory-cartographer-max-tokens }} MEMORY_MENTAL_MODELS_MODEL: ${{ inputs.memory-mental-models-model }} + MEMORY_MENTAL_MODELS_MAX_TOKENS: ${{ inputs.memory-mental-models-max-tokens }} + MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS: ${{ inputs.memory-mental-models-min-refresh-seconds }} + MEMORY_EMBEDDINGS_MAX_INPUT_CHARS: ${{ inputs.memory-embeddings-max-input-chars }} MEMORY_PREFRONTAL_REFLECTS: ${{ inputs.memory-prefrontal-reflects }} MEMORY_RECALL_MODEL: ${{ inputs.memory-recall-model }} MEMORY_RECALL_REACH: ${{ inputs.memory-recall-reach }} @@ -621,12 +676,6 @@ runs: MEMORY_RECALL_MAX_TOOL_CALLS: ${{ inputs.memory-recall-max-tool-calls }} MEMORY_EMBEDDINGS_MODEL: ${{ inputs.memory-embeddings-model }} MEMORY_RETAIN_CHUNK_SIZE: ${{ inputs.memory-retain-chunk-size }} - SCOPE_MEMORY_ENABLED: ${{ inputs.scope-memory-enabled }} - CODE_REVIEW_MEMORY_ENABLED: ${{ inputs.code-review-memory-enabled }} - DOC_MEMORY_ENABLED: ${{ inputs.doc-memory-enabled }} - SUPPLY_CHAIN_MEMORY_ENABLED: ${{ inputs.supply-chain-memory-enabled }} - SUMMARY_MEMORY_ENABLED: ${{ inputs.summary-memory-enabled }} - AUDIT_MEMORY_ENABLED: ${{ inputs.audit-memory-enabled }} run: | set -eo pipefail @@ -634,14 +683,16 @@ runs: DOCKER_ARGS="--rm --network host" # Core settings - DOCKER_ARGS="$DOCKER_ARGS -e GITHUB_TOKEN -e DEFAULT_MODEL -e OUTPUT_FORMAT" + DOCKER_ARGS="$DOCKER_ARGS -e GITHUB_TOKEN" + [ -n "$DEFAULT_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e DEFAULT_MODEL" [ -n "$EXTRACTION_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e EXTRACTION_MODEL" # Add optional API keys if provided [ -n "$ANTHROPIC_API_KEY" ] && DOCKER_ARGS="$DOCKER_ARGS -e ANTHROPIC_API_KEY" [ -n "$OPENAI_API_KEY" ] && DOCKER_ARGS="$DOCKER_ARGS -e OPENAI_API_KEY" [ -n "$GEMINI_API_KEY" ] && DOCKER_ARGS="$DOCKER_ARGS -e GEMINI_API_KEY" - [ -n "$AWS_ACCESS_KEY_ID" ] && DOCKER_ARGS="$DOCKER_ARGS -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_REGION" + [ -n "$AWS_ACCESS_KEY_ID" ] && DOCKER_ARGS="$DOCKER_ARGS -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY" + [ -n "$AWS_REGION" ] && DOCKER_ARGS="$DOCKER_ARGS -e AWS_REGION" # Global defaults [ -n "$DEFAULT_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e DEFAULT_MAX_ITERS" @@ -665,8 +716,10 @@ runs: [ -n "$REVIEW_SCOPE_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MODEL" [ -n "$REVIEW_SCOPE_MAX_ITERS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MAX_ITERS" [ -n "$REVIEW_SCOPE_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MAX_LLM_CALLS" + [ -n "$REVIEW_SCOPE_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MAX_TOKENS" [ -n "$REVIEW_SCOPE_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_REASONING_EFFORT" [ -n "$REVIEW_SCOPE_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_TEMPERATURE" + [ -n "$REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN" [ -n "$REVIEW_SCOPE_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MEMORY_ENABLED" # Code review @@ -678,6 +731,18 @@ runs: [ -n "$REVIEW_CODE_REVIEW_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_TEMPERATURE" [ -n "$REVIEW_CODE_REVIEW_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_MEMORY_ENABLED" + # Per-signature max_tokens + [ -n "$REVIEW_SCOPE_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MAX_TOKENS" + [ -n "$REVIEW_CODE_REVIEW_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_MAX_TOKENS" + [ -n "$REVIEW_DOC_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_MAX_TOKENS" + [ -n "$REVIEW_SUPPLY_CHAIN_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_MAX_TOKENS" + [ -n "$REVIEW_SUMMARY_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_MAX_TOKENS" + [ -n "$REVIEW_AUDIT_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_MAX_TOKENS" + + # Memory module max_tokens + [ -n "$MEMORY_DISTILLER_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_DISTILLER_MAX_TOKENS" + [ -n "$MEMORY_CARTOGRAPHER_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CARTOGRAPHER_MAX_TOKENS" + # Doc review [ -n "$REVIEW_DOC_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_ENABLED" [ -n "$REVIEW_DOC_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_MODEL" @@ -715,8 +780,7 @@ runs: [ -n "$REVIEW_AUDIT_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_TEMPERATURE" [ -n "$REVIEW_AUDIT_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_MEMORY_ENABLED" - # Output settings - [ -n "$REVIEW_OUTPUT_FORMAT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_OUTPUT_FORMAT" + # Output settings (REVIEW_OUTPUT_FORMAT removed - only via CLI flag) # Memory (Hippocampus) [ -n "$MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_ENABLED" @@ -740,7 +804,17 @@ runs: [ -n "$MEMORY_RETAIN_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RETAIN_MODEL" [ -n "$MEMORY_CONSOLIDATION_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CONSOLIDATION_MODEL" [ -n "$MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE" + [ -n "$MEMORY_COMPACT_TRAJECTORY" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_COMPACT_TRAJECTORY" + [ -n "$MEMORY_MAX_HIPPOCAMPUS_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_MAX_HIPPOCAMPUS_TOKENS" + [ -n "$MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS" + [ -n "$MEMORY_MAX_TRAJECTORY_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_MAX_TRAJECTORY_TOKENS" + [ -n "$MEMORY_MAX_QUESTION_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_MAX_QUESTION_TOKENS" + [ -n "$MEMORY_DISTILLER_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_DISTILLER_MAX_TOKENS" + [ -n "$MEMORY_CARTOGRAPHER_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_CARTOGRAPHER_MAX_TOKENS" [ -n "$MEMORY_MENTAL_MODELS_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_MENTAL_MODELS_MODEL" + [ -n "$MEMORY_MENTAL_MODELS_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_MENTAL_MODELS_MAX_TOKENS" + [ -n "$MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS" + [ -n "$MEMORY_EMBEDDINGS_MAX_INPUT_CHARS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_EMBEDDINGS_MAX_INPUT_CHARS" [ -n "$MEMORY_PREFRONTAL_REFLECTS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_PREFRONTAL_REFLECTS" [ -n "$MEMORY_RECALL_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RECALL_MODEL" [ -n "$MEMORY_RECALL_REACH" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RECALL_REACH" @@ -749,12 +823,6 @@ runs: [ -n "$MEMORY_RECALL_MAX_TOOL_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RECALL_MAX_TOOL_CALLS" [ -n "$MEMORY_EMBEDDINGS_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_EMBEDDINGS_MODEL" [ -n "$MEMORY_RETAIN_CHUNK_SIZE" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_RETAIN_CHUNK_SIZE" - [ -n "$SCOPE_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e SCOPE_MEMORY_ENABLED" - [ -n "$CODE_REVIEW_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e CODE_REVIEW_MEMORY_ENABLED" - [ -n "$DOC_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e DOC_MEMORY_ENABLED" - [ -n "$SUPPLY_CHAIN_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUPPLY_CHAIN_MEMORY_ENABLED" - [ -n "$SUMMARY_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e SUMMARY_MEMORY_ENABLED" - [ -n "$AUDIT_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e AUDIT_MEMORY_ENABLED" # Build codespy command arguments CMD_ARGS="review ${{ steps.pr-url.outputs.url }}" diff --git a/codespy.yaml b/codespy.yaml index 257c7ec..55248c8 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -4,8 +4,7 @@ # All settings can be overridden via environment variables using the YAML path, # uppercased and joined with underscores. The llm., memory.hippocampus., # memory.cerebral. and memory.prefrontal. segments are dropped; llm.retries, -# llm.timeout, memory.prefrontal.reflects and memory.prefrontal.model keep their -# full names. +# llm.timeout and memory.prefrontal.reflects keep their full names. # Examples: # - llm.default_model → DEFAULT_MODEL # - llm.retries → LLM_RETRIES @@ -58,13 +57,13 @@ llm: azure_api_version: null # AZURE_API_VERSION # Default model settings - default_model: anthropic/claude-opus-4-6 # DEFAULT_MODEL - extraction_model: null # EXTRACTION_MODEL (falls back to default_model; also used by Cerebral two-step structured output for memory models litellm cannot schema-enforce) - default_max_iters: 5 # DEFAULT_MAX_ITERS - default_max_llm_calls: 8 # DEFAULT_MAX_LLM_CALLS - default_reasoning_effort: medium # DEFAULT_REASONING_EFFORT - default_temperature: 0.2 # DEFAULT_TEMPERATURE - default_max_tokens: 32000 # DEFAULT_MAX_TOKENS + default_model: bedrock/converse/nvidia.nemotron-nano-3-30b # DEFAULT_MODEL + extraction_model: bedrock/converse/nvidia.nemotron-nano-3-30b # EXTRACTION_MODEL (falls back to default_model; also used by Cerebral two-step structured output for memory models litellm cannot schema-enforce) + default_max_iters: 5 # DEFAULT_MAX_ITERS + default_max_llm_calls: 8 # DEFAULT_MAX_LLM_CALLS + default_reasoning_effort: medium # DEFAULT_REASONING_EFFORT + default_temperature: 1.0 # DEFAULT_TEMPERATURE + default_max_tokens: 32000 # DEFAULT_MAX_TOKENS # Global LLM reliability settings retries: 2 # LLM_RETRIES @@ -120,14 +119,14 @@ memory: max_trajectory_tokens: 16384 # MEMORY_MAX_TRAJECTORY_TOKENS max_question_tokens: 8192 # MEMORY_MAX_QUESTION_TOKENS distiller: - model: null # MEMORY_DISTILLER_MODEL + model: bedrock/converse/nvidia.nemotron-super-3-120b # MEMORY_DISTILLER_MODEL reasoning_effort: null # MEMORY_DISTILLER_REASONING_EFFORT temperature: null # MEMORY_DISTILLER_TEMPERATURE max_tokens: null # MEMORY_DISTILLER_MAX_TOKENS max_iters: 1 # MEMORY_DISTILLER_MAX_ITERS max_llm_calls: 2 # MEMORY_DISTILLER_MAX_LLM_CALLS cartographer: - model: null # MEMORY_CARTOGRAPHER_MODEL + model: bedrock/converse/nvidia.nemotron-super-3-120b # MEMORY_CARTOGRAPHER_MODEL reasoning_effort: null # MEMORY_CARTOGRAPHER_REASONING_EFFORT temperature: null # MEMORY_CARTOGRAPHER_TEMPERATURE max_tokens: null # MEMORY_CARTOGRAPHER_MAX_TOKENS @@ -137,24 +136,24 @@ memory: # Cerebral (semantic memory) cerebral: retain: - model: null # MEMORY_RETAIN_MODEL (falls back to llm.default_model) + model: bedrock/converse/nvidia.nemotron-super-3-120b # MEMORY_RETAIN_MODEL (falls back to llm.default_model) chunk_size: 12288 # MEMORY_RETAIN_CHUNK_SIZE (chars, must be < 64000) consolidation: - model: null # MEMORY_CONSOLIDATION_MODEL (falls back to retain model → default) + model: bedrock/converse/nvidia.nemotron-super-3-120b # MEMORY_CONSOLIDATION_MODEL (falls back to retain model → default) max_observations_per_scope: 100 # MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE (-1 = unlimited, 0 = no new observations) mental_models: - model: null # MEMORY_MENTAL_MODELS_MODEL (briefing refresh; falls back to prefrontal model → retain → default) + model: bedrock/converse/nvidia.nemotron-super-3-120b # MEMORY_MENTAL_MODELS_MODEL (briefing refresh; falls back to prefrontal model → retain → default) max_tokens: 2048 # MEMORY_MENTAL_MODELS_MAX_TOKENS (briefing length) min_refresh_seconds: 0 # MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS (0 = refresh after every consolidation; N = at most one automatic briefing refresh per N seconds, nothing lost thanks to delta) embeddings: - model: null # MEMORY_EMBEDDINGS_MODEL (auto-derived from provider) + model: bedrock/cohere.embed-v4:0 # MEMORY_EMBEDDINGS_MODEL (auto-derived from provider) max_input_chars: null # MEMORY_EMBEDDINGS_MAX_INPUT_CHARS (null=auto per model, e.g. 2048 for Bedrock Cohere v3; 0=off) # Prefrontal (semantic recall from Cerebral into agent context: read-only) prefrontal: reflects: 3 # MEMORY_PREFRONTAL_REFLECTS (reflect iterations for recall and briefings; 0 = raw-fact recall, no LLM, no briefings) recall: - model: null # MEMORY_RECALL_MODEL (reflect; also briefings fallback unless cerebral.mental_models.model set) + model: bedrock/converse/nvidia.nemotron-super-3-120b # MEMORY_RECALL_MODEL (reflect; also briefings fallback unless cerebral.mental_models.model set) reach: org # MEMORY_RECALL_REACH (local | org | bank) max_tokens: 8192 # MEMORY_RECALL_MAX_TOKENS (pre-call context budget) max_tool_tokens: 2048 # MEMORY_RECALL_MAX_TOOL_TOKENS (tool result budget) @@ -198,74 +197,74 @@ review: # Supply Chain Security supply_chain: - enabled: true # REVIEW_SUPPLY_CHAIN_ENABLED - max_iters: null # REVIEW_SUPPLY_CHAIN_MAX_ITERS - max_llm_calls: null # REVIEW_SUPPLY_CHAIN_MAX_LLM_CALLS - model: null # REVIEW_SUPPLY_CHAIN_MODEL - reasoning_effort: null # REVIEW_SUPPLY_CHAIN_REASONING_EFFORT - temperature: null # REVIEW_SUPPLY_CHAIN_TEMPERATURE - max_tokens: null # REVIEW_SUPPLY_CHAIN_MAX_TOKENS - scan_unchanged: false # REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED + enabled: true # REVIEW_SUPPLY_CHAIN_ENABLED + max_iters: null # REVIEW_SUPPLY_CHAIN_MAX_ITERS + max_llm_calls: null # REVIEW_SUPPLY_CHAIN_MAX_LLM_CALLS + model: bedrock/converse/nvidia.nemotron-super-3-120b # REVIEW_SUPPLY_CHAIN_MODEL + reasoning_effort: null # REVIEW_SUPPLY_CHAIN_REASONING_EFFORT + temperature: null # REVIEW_SUPPLY_CHAIN_TEMPERATURE + max_tokens: null # REVIEW_SUPPLY_CHAIN_MAX_TOKENS + scan_unchanged: false # REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED memory: - enabled: null # REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED + enabled: null # REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED # Code Review code_review: - enabled: true # REVIEW_CODE_REVIEW_ENABLED - max_iters: 5 # REVIEW_CODE_REVIEW_MAX_ITERS - max_llm_calls: 8 # REVIEW_CODE_REVIEW_MAX_LLM_CALLS - model: null # REVIEW_CODE_REVIEW_MODEL - reasoning_effort: null # REVIEW_CODE_REVIEW_REASONING_EFFORT - temperature: null # REVIEW_CODE_REVIEW_TEMPERATURE - max_tokens: null # REVIEW_CODE_REVIEW_MAX_TOKENS + enabled: true # REVIEW_CODE_REVIEW_ENABLED + max_iters: 5 # REVIEW_CODE_REVIEW_MAX_ITERS + max_llm_calls: 8 # REVIEW_CODE_REVIEW_MAX_LLM_CALLS + model: bedrock/converse/global.anthropic.claude-opus-5-5 # REVIEW_CODE_REVIEW_MODEL + reasoning_effort: null # REVIEW_CODE_REVIEW_REASONING_EFFORT + temperature: null # REVIEW_CODE_REVIEW_TEMPERATURE + max_tokens: null # REVIEW_CODE_REVIEW_MAX_TOKENS memory: - enabled: null # REVIEW_CODE_REVIEW_MEMORY_ENABLED + enabled: null # REVIEW_CODE_REVIEW_MEMORY_ENABLED # Documentation Review doc: - enabled: true # REVIEW_DOC_ENABLED - max_iters: 2 # REVIEW_DOC_MAX_ITERS - max_llm_calls: 4 # REVIEW_DOC_MAX_LLM_CALLS - model: null # REVIEW_DOC_MODEL - reasoning_effort: null # REVIEW_DOC_REASONING_EFFORT - temperature: null # REVIEW_DOC_TEMPERATURE - max_tokens: null # REVIEW_DOC_MAX_TOKENS + enabled: true # REVIEW_DOC_ENABLED + max_iters: 2 # REVIEW_DOC_MAX_ITERS + max_llm_calls: 4 # REVIEW_DOC_MAX_LLM_CALLS + model: bedrock/converse/global.anthropic.claude-opus-5-5 # REVIEW_DOC_MODEL + reasoning_effort: null # REVIEW_DOC_REASONING_EFFORT + temperature: null # REVIEW_DOC_TEMPERATURE + max_tokens: null # REVIEW_DOC_MAX_TOKENS memory: - enabled: null # REVIEW_DOC_MEMORY_ENABLED + enabled: null # REVIEW_DOC_MEMORY_ENABLED # Scope Identification scope: - enabled: true # REVIEW_SCOPE_ENABLED - max_iters: 3 # REVIEW_SCOPE_MAX_ITERS - max_llm_calls: 5 # REVIEW_SCOPE_MAX_LLM_CALLS - model: null # REVIEW_SCOPE_MODEL - reasoning_effort: null # REVIEW_SCOPE_REASONING_EFFORT - temperature: null # REVIEW_SCOPE_TEMPERATURE - max_tokens: null # REVIEW_SCOPE_MAX_TOKENS - skip_refinement_when_clean: null # REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN + enabled: true # REVIEW_SCOPE_ENABLED + max_iters: 3 # REVIEW_SCOPE_MAX_ITERS + max_llm_calls: 5 # REVIEW_SCOPE_MAX_LLM_CALLS + model: bedrock/converse/global.anthropic.claude-opus-5-5 # REVIEW_SCOPE_MODEL + reasoning_effort: null # REVIEW_SCOPE_REASONING_EFFORT + temperature: null # REVIEW_SCOPE_TEMPERATURE + max_tokens: null # REVIEW_SCOPE_MAX_TOKENS + skip_refinement_when_clean: null # REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN memory: - enabled: null # REVIEW_SCOPE_MEMORY_ENABLED + enabled: null # REVIEW_SCOPE_MEMORY_ENABLED # Summary summary: - enabled: true # REVIEW_SUMMARY_ENABLED - max_iters: 1 # REVIEW_SUMMARY_MAX_ITERS - max_llm_calls: 2 # REVIEW_SUMMARY_MAX_LLM_CALLS - model: null # REVIEW_SUMMARY_MODEL - reasoning_effort: null # REVIEW_SUMMARY_REASONING_EFFORT - temperature: null # REVIEW_SUMMARY_TEMPERATURE - max_tokens: null # REVIEW_SUMMARY_MAX_TOKENS + enabled: true # REVIEW_SUMMARY_ENABLED + max_iters: 1 # REVIEW_SUMMARY_MAX_ITERS + max_llm_calls: 2 # REVIEW_SUMMARY_MAX_LLM_CALLS + model: bedrock/converse/nvidia.nemotron-super-3-120b # REVIEW_SUMMARY_MODEL + reasoning_effort: null # REVIEW_SUMMARY_REASONING_EFFORT + temperature: null # REVIEW_SUMMARY_TEMPERATURE + max_tokens: null # REVIEW_SUMMARY_MAX_TOKENS memory: - enabled: null # REVIEW_SUMMARY_MEMORY_ENABLED + enabled: null # REVIEW_SUMMARY_MEMORY_ENABLED # Audit audit: - enabled: true # REVIEW_AUDIT_ENABLED - max_iters: 1 # REVIEW_AUDIT_MAX_ITERS - max_llm_calls: 2 # REVIEW_AUDIT_MAX_LLM_CALLS - model: null # REVIEW_AUDIT_MODEL - reasoning_effort: null # REVIEW_AUDIT_REASONING_EFFORT - temperature: null # REVIEW_AUDIT_TEMPERATURE - max_tokens: null # REVIEW_AUDIT_MAX_TOKENS + enabled: true # REVIEW_AUDIT_ENABLED + max_iters: 1 # REVIEW_AUDIT_MAX_ITERS + max_llm_calls: 2 # REVIEW_AUDIT_MAX_LLM_CALLS + model: bedrock/converse/global.anthropic.claude-opus-5-5 # REVIEW_AUDIT_MODEL + reasoning_effort: null # REVIEW_AUDIT_REASONING_EFFORT + temperature: null # REVIEW_AUDIT_TEMPERATURE + max_tokens: null # REVIEW_AUDIT_MAX_TOKENS memory: - enabled: null # REVIEW_AUDIT_MEMORY_ENABLED + enabled: null # REVIEW_AUDIT_MEMORY_ENABLED diff --git a/docs/configuration.md b/docs/configuration.md index deb95e6..2ebfd6c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -106,10 +106,10 @@ AUTO_DISCOVER_GEMINI=false | Setting | Env Var | Default | Description | |---------|---------|---------|-------------| -| Model | `DEFAULT_MODEL` | `anthropic/claude-opus-4-6` | Primary model for all signatures | +| Model | `DEFAULT_MODEL` | `bedrock/converse/nvidia.nemotron-nano-3-30b` | Primary fallback model (most signatures have dedicated pins) | | Reasoning effort | `DEFAULT_REASONING_EFFORT` | `medium` | Provider reasoning budget: `minimal`, `low`, `medium`, `high` | | Max tokens | `DEFAULT_MAX_TOKENS` | `32000` | Output token budget per completion (reasoning tokens included) | -| Temperature | `DEFAULT_TEMPERATURE` | `0.2` | Default temperature for LLM calls | +| Temperature | `DEFAULT_TEMPERATURE` | `1.0` | Default temperature for LLM calls | | Max iterations | `DEFAULT_MAX_ITERS` | `5` | Maximum ReAct iterations for tool-using agents | | RLM fallback | `RLM_FALLBACK_ENABLED` | `true` | Proactive RLM fallback for context rot prevention | | RLM react threshold | `RLM_FALLBACK_REACT_THRESHOLD` | `0.30` | Context ratio triggering RLM for ReAct modules | @@ -118,16 +118,20 @@ AUTO_DISCOVER_GEMINI=false ## Recommended Model Strategy -| Tier | Role | Env Var | Default | Recommended | -|------|------|---------|---------|-------------| -| Smart | Core analysis & reasoning | `DEFAULT_MODEL` | `anthropic/claude-opus-4-6` | Claude Opus / GPT-5 | -| Mid-tier | Field extraction | `EXTRACTION_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | -| Cheap | PR summary | `REVIEW_SUMMARY_MODEL` | Falls back to DEFAULT_MODEL | Claude Haiku | -| Mid-tier | Memory reflection | `MEMORY_DISTILLER_MODEL` / `MEMORY_CARTOGRAPHER_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | -| Mid-tier | Semantic memory (fact extraction) | `MEMORY_RETAIN_MODEL` | Falls back to DEFAULT_MODEL | Claude Sonnet | -| Mid-tier | Semantic consolidation | `MEMORY_CONSOLIDATION_MODEL` | Falls back to MEMORY_RETAIN_MODEL → DEFAULT_MODEL | Claude Sonnet | -| Mid-tier | Mental-model refresh (briefings) | `MEMORY_MENTAL_MODELS_MODEL` | Falls back to MEMORY_PREFRONTAL_MODEL → MEMORY_RETAIN_MODEL → DEFAULT_MODEL | Claude Sonnet | -| Mid-tier | Semantic recall (reflect) | `MEMORY_RECALL_MODEL` | Falls back to MEMORY_RETAIN_MODEL → DEFAULT_MODEL | Claude Sonnet | +All models are now pinned in `codespy.yaml`. Setting a unit to `null` (YAML `model: null` or env `VAR=null`) re-enables the fallback chain. + +| Tier | Role | Env Var | Default | Fallback chain | +|------|------|---------|---------|----------------| +| Smart | Core analysis & reasoning | `REVIEW_*_MODEL` | `bedrock/converse/global.anthropic.claude-opus-5-5` | `model` → `DEFAULT_MODEL` | +| Mid-tier | Field extraction | `EXTRACTION_MODEL` | `bedrock/converse/nvidia.nemotron-nano-3-30b` | `DEFAULT_MODEL` | +| Cheap | PR summary | `REVIEW_SUMMARY_MODEL` | `bedrock/converse/nvidia.nemotron-super-3-120b` | `model` → `DEFAULT_MODEL` | +| Mid-tier | Memory reflection | `MEMORY_DISTILLER_MODEL` / `MEMORY_CARTOGRAPHER_MODEL` | `bedrock/converse/nvidia.nemotron-super-3-120b` | `model` → `DEFAULT_MODEL` | +| Mid-tier | Semantic memory (fact extraction) | `MEMORY_RETAIN_MODEL` | `bedrock/converse/nvidia.nemotron-super-3-120b` | `DEFAULT_MODEL` | +| Mid-tier | Semantic consolidation | `MEMORY_CONSOLIDATION_MODEL` | `bedrock/converse/nvidia.nemotron-super-3-120b` | `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | +| Mid-tier | Mental-model refresh (briefings) | `MEMORY_MENTAL_MODELS_MODEL` | `bedrock/converse/nvidia.nemotron-super-3-120b` | `MEMORY_RECALL_MODEL` → `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | +| Mid-tier | Semantic recall (reflect) | `MEMORY_RECALL_MODEL` | `bedrock/converse/nvidia.nemotron-super-3-120b` | `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | + +**Precedence:** Environment variables (`.env` or shell) > `codespy.yaml` (`github.token` / `gitlab.token`) > auto-discovery. ## Per-Signature Configuration @@ -176,7 +180,7 @@ Brief overview: |---------|---------|---------|-------------| | PostgreSQL host | `MEMORY_POSTGRES_HOST` | — | External PostgreSQL host | | PostgreSQL port | `MEMORY_POSTGRES_PORT` | `5432` | External PostgreSQL port | -| PostgreSQL user | `MEMORY_POSTGRES_USER` | `postgres` | External PostgreSQL user | +| PostgreSQL user | `MEMORY_POSTGRES_USER` | — (connects as `postgres`) | External PostgreSQL user | | PostgreSQL password | `MEMORY_POSTGRES_PASSWORD` | — | External PostgreSQL password | | PostgreSQL database | `MEMORY_POSTGRES_DATABASE` | `codespy` | External PostgreSQL database | | PostgreSQL schema | `MEMORY_POSTGRES_SCHEMA` | `episodic` | PostgreSQL schema / search_path per memory type | @@ -203,7 +207,7 @@ LLM provider and credentials are auto-derived from the model string and existing | Retain Model | `MEMORY_RETAIN_MODEL` | `DEFAULT_MODEL` | Model for fact extraction | | Consolidation Model | `MEMORY_CONSOLIDATION_MODEL` | `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for consolidation | | Consolidation Observation Cap | `MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE` | `100` | Per-scope observation limit (-1 = unlimited, 0 = no new observations) | -| Mental-Models Model | `MEMORY_MENTAL_MODELS_MODEL` | `MEMORY_PREFRONTAL_MODEL` → `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for briefing refresh | +| Mental-Models Model | `MEMORY_MENTAL_MODELS_MODEL` | `MEMORY_RECALL_MODEL` → `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for briefing refresh | | Mental-Models Max Tokens | `MEMORY_MENTAL_MODELS_MAX_TOKENS` | `2048` | Briefing size | | Mental-Models Min Refresh Seconds | `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS` | `0` | Minimum seconds between refreshes (0 = after every consolidation) | | Embeddings Model | `MEMORY_EMBEDDINGS_MODEL` | Auto-derived | Model for semantic memory embeddings | diff --git a/docs/memory.md b/docs/memory.md index 099c71d..fd1890b 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -386,17 +386,16 @@ or when LLM calls exceed `reflects + 1` (unexpected split synthesis or rewrite). The RLM agents (code_review, scope, supply_chain) get `async recall_memory(query: str, reach: str = "local") -> str` for follow-up questions. -A reach above the configured one is clamped; at most `max_prefrontal_tool_calls` +A reach above the configured one is clamped; at most `memory.prefrontal.recall.max_tool_calls` calls per agent call (then `"recall limit reached"`); errors return `"memory unavailable"`. ### Model -`memory.prefrontal.model` (env `MEMORY_PREFRONTAL_MODEL`, Action input -`memory-prefrontal-model`) is the model Hindsight's reflect loop uses. It falls back to +`memory.prefrontal.recall.model` (env `MEMORY_RECALL_MODEL`, Action input +`memory-recall-model`) is the model Hindsight's reflect loop uses. It falls back to `memory.cerebral.retain.model`, then `llm.default_model`. It is passed to `MemoryEngine` -as its reflect LLM, so it **also refreshes the mental models (briefings)**. It is unused -when `reflects = 0`, and it is checked at startup only when `reflects > 0`. Retain and -consolidation keep using the retain model. +as its reflect LLM, so it **also refreshes the mental models (briefings)** when +`cerebral.mental_models.model` is unset. It is unused when `reflects = 0`. ### Monitoring recalls @@ -477,7 +476,7 @@ Observation capacity ≈ max_hippocampus_tokens / max_hippocampus_item_tokens (1 | Env Var | YAML Path | Default | Description | |---------|-----------|---------|-------------| -| `MEMORY_MENTAL_MODELS_MODEL` | `memory.cerebral.mental_models.model` | `memory.prefrontal.recall.model` → `memory.cerebral.retain.model` → `llm.default_model` | Briefing refresh model; unused when reflects=0 | +| `MEMORY_MENTAL_MODELS_MODEL` | `memory.cerebral.mental_models.model` | `bedrock/converse/nvidia.nemotron-super-3-120b` | Briefing refresh model; unused when reflects=0 | | `MEMORY_MENTAL_MODELS_MAX_TOKENS` | `memory.cerebral.mental_models.max_tokens` | `2048` | Briefing size | | `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS` | `memory.cerebral.mental_models.min_refresh_seconds` | `0` | Minimum seconds between automatic briefing refreshes (0 = refresh after every consolidation; N = at most one automatic refresh per N seconds, nothing lost thanks to delta) | @@ -491,7 +490,7 @@ Observation capacity ≈ max_hippocampus_tokens / max_hippocampus_item_tokens (1 | Env Var | YAML Path | Default | Description | |---------|-----------|---------|-------------| -| `MEMORY_RECALL_MODEL` | `memory.prefrontal.recall.model` | `memory.cerebral.retain.model` → `llm.default_model` | Reflect loop model; also mental-model fallback when unset; unused when reflects=0 | +| `MEMORY_RECALL_MODEL` | `memory.prefrontal.recall.model` | `bedrock/converse/nvidia.nemotron-super-3-120b` | Reflect loop model; also mental-model fallback when unset; unused when reflects=0 | | `MEMORY_RECALL_REACH` | `memory.prefrontal.recall.reach` | `org` | `local`, `org` or `bank` | | `MEMORY_RECALL_MAX_TOKENS` | `memory.prefrontal.recall.max_tokens` | `8192` | Pre-call context budget | | `MEMORY_RECALL_MAX_TOOL_TOKENS` | `memory.prefrontal.recall.max_tool_tokens` | `2048` | Tool result budget | diff --git a/src/codespy/config.py b/src/codespy/config.py index 2d07059..f163cbe 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -84,7 +84,6 @@ ("llm", "retries"), ("llm", "timeout"), ("memory", "prefrontal", "reflects"), - ("memory", "prefrontal", "recall"), }, ) @@ -374,7 +373,11 @@ def load_yaml_config(cls, values: dict[str, Any]) -> dict[str, Any]: @model_validator(mode="after") def resolve_github_token(self) -> "Settings": - """Auto-discover GitHub token if not explicitly set.""" + """Auto-discover GitHub token if not explicitly set. + + Precedence: flat github_token (GITHUB_TOKEN env) → flat gh_token (GH_TOKEN env) + → YAML github.token → auto-discovery. + """ def is_placeholder(token: str) -> bool: """Check if token looks like a placeholder.""" @@ -382,35 +385,41 @@ def is_placeholder(token: str) -> bool: token_lower = token.lower() return any(p in token_lower for p in placeholders) - # First check nested config - nested_val = secret_value(self.github.token) - if nested_val and not is_placeholder(nested_val): - self.github_token = self.github.token # SecretStr → SecretStr - set_github_token_source("YAML config or GITHUB_TOKEN environment variable") - return self + def clear_placeholders(): + """Clear any placeholder values from both fields.""" + gh_val = secret_value(self.github_token) + if gh_val and is_placeholder(gh_val): + self.github_token = None + nested_val = secret_value(self.github.token) + if nested_val and is_placeholder(nested_val): + self.github.token = None + + # First, clear any placeholders + clear_placeholders() - # If github_token is set and not a placeholder, use it + # Precedence 1: flat github_token (GITHUB_TOKEN env / .env) gh_val = secret_value(self.github_token) - if gh_val and not is_placeholder(gh_val): + if gh_val: self.github.token = self.github_token # SecretStr → SecretStr set_github_token_source("GITHUB_TOKEN environment variable or .env file") return self - # If GH_TOKEN is set and not a placeholder, use it + # Precedence 2: flat gh_token (GH_TOKEN env) gh_token_val = secret_value(self.gh_token) - if gh_token_val and not is_placeholder(gh_token_val): + if gh_token_val: self.github_token = self.gh_token # SecretStr → SecretStr self.github.token = self.gh_token set_github_token_source("GH_TOKEN environment variable") return self - # Clear placeholder if present - gh_val2 = secret_value(self.github_token) - if gh_val2 and is_placeholder(gh_val2): - self.github_token = None - self.github.token = None + # Precedence 3: YAML github.token (already set if present) + nested_val = secret_value(self.github.token) + if nested_val: + self.github_token = self.github.token # SecretStr → SecretStr + set_github_token_source("YAML config (github.token)") + return self - # Try auto-discovery if enabled + # Precedence 4: auto-discovery auto_discover = self.github.auto_discover_token and self.github_auto_discover_token if auto_discover: @@ -430,7 +439,11 @@ def is_placeholder(token: str) -> bool: @model_validator(mode="after") def resolve_gitlab_token(self) -> "Settings": - """Auto-discover GitLab token if not explicitly set.""" + """Auto-discover GitLab token if not explicitly set. + + URL precedence: GITLAB_URL env → YAML gitlab.url → default (https://gitlab.com) + Token precedence: GITLAB_TOKEN env → YAML gitlab.token → auto-discovery + """ def is_placeholder(token: str) -> bool: """Check if token looks like a placeholder.""" @@ -438,31 +451,42 @@ def is_placeholder(token: str) -> bool: token_lower = token.lower() return any(p in token_lower for p in placeholders) - # First check nested config - nested_val = secret_value(self.gitlab.token) - if nested_val and not is_placeholder(nested_val): - self.gitlab_token = self.gitlab.token # SecretStr → SecretStr - set_gitlab_token_source("YAML config or GITLAB_TOKEN environment variable") - return self - - # Sync URL from nested config - if self.gitlab.url: + def clear_placeholders(): + """Clear any placeholder values from both fields.""" + token_val = secret_value(self.gitlab_token) + if token_val and is_placeholder(token_val): + self.gitlab_token = None + nested_val = secret_value(self.gitlab.token) + if nested_val and is_placeholder(nested_val): + self.gitlab.token = None + + # Clear placeholders first + clear_placeholders() + + # URL resolution: env wins over YAML + # Check if GITLAB_URL came from env (via model_fields_set) + if "gitlab_url" in self.model_fields_set: + # Env var was set, keep it and sync to nested + self.gitlab.url = self.gitlab_url + else: + # No env var, sync from YAML to flat (YAML wins over default) self.gitlab_url = self.gitlab.url - # If gitlab_token is set and not a placeholder, use it + # Precedence 1: flat gitlab_token (GITLAB_TOKEN env / .env) token_val = secret_value(self.gitlab_token) - if token_val and not is_placeholder(token_val): + if token_val: self.gitlab.token = self.gitlab_token # SecretStr → SecretStr set_gitlab_token_source("GITLAB_TOKEN environment variable or .env file") return self - # Clear placeholder if present - token_val2 = secret_value(self.gitlab_token) - if token_val2 and is_placeholder(token_val2): - self.gitlab_token = None - self.gitlab.token = None + # Precedence 2: YAML gitlab.token (already set if present) + nested_val = secret_value(self.gitlab.token) + if nested_val: + self.gitlab_token = self.gitlab.token # SecretStr → SecretStr + set_gitlab_token_source("YAML config (gitlab.token)") + return self - # Try auto-discovery if enabled + # Precedence 3: auto-discovery auto_discover = self.gitlab.auto_discover_token and self.gitlab_auto_discover_token if auto_discover: @@ -486,28 +510,6 @@ def expand_paths(self) -> "Settings": self.review.cache_dir = Path(self.review.cache_dir).expanduser().resolve() return self - @model_validator(mode="after") - def sync_llm_settings(self) -> "Settings": - """Sync LLM settings between nested and flat fields. - - Delegates to LLMConfig.sync_from_flat which enforces: - env vars (flat fields from pydantic-settings) > YAML/nested > defaults. - """ - merged = self.llm.sync_from_flat( - openai_api_key=self.llm.openai_api_key, - anthropic_api_key=self.llm.anthropic_api_key, - gemini_api_key=self.llm.gemini_api_key, - aws_region=self.llm.aws_region, - aws_access_key_id=self.llm.aws_access_key_id, - aws_secret_access_key=self.llm.aws_secret_access_key, - ) - # Propagate merged values back to llm - for field, value in merged.items(): - if value is not None: - setattr(self.llm, field, value) - return self - - # Global settings instance settings = Settings() diff --git a/src/codespy/config_dspy.py b/src/codespy/config_dspy.py index 0daf35c..3ea2bb0 100644 --- a/src/codespy/config_dspy.py +++ b/src/codespy/config_dspy.py @@ -78,12 +78,24 @@ class ReviewConfig(BaseModel): excluded_directories: list[str] = Field(default=DEFAULT_EXCLUDED_DIRECTORIES) # Per-signature configs - supply_chain: SignatureConfig = Field(default_factory=SignatureConfig) - code_review: SignatureConfig = Field(default_factory=SignatureConfig) - doc: SignatureConfig = Field(default_factory=lambda: SignatureConfig(max_iters=1, max_llm_calls=2)) - scope: SignatureConfig = Field(default_factory=lambda: SignatureConfig(max_iters=3, max_llm_calls=5)) - summary: SignatureConfig = Field(default_factory=lambda: SignatureConfig(max_iters=1, max_llm_calls=2)) - audit: SignatureConfig = Field(default_factory=lambda: SignatureConfig(max_iters=1, max_llm_calls=2)) + supply_chain: SignatureConfig = Field( + default_factory=lambda: SignatureConfig(model="bedrock/converse/nvidia.nemotron-super-3-120b") + ) + code_review: SignatureConfig = Field( + default_factory=lambda: SignatureConfig(model="bedrock/converse/global.anthropic.claude-opus-5-5") + ) + doc: SignatureConfig = Field( + default_factory=lambda: SignatureConfig(max_iters=2, max_llm_calls=4, model="bedrock/converse/global.anthropic.claude-opus-5-5") + ) + scope: SignatureConfig = Field( + default_factory=lambda: SignatureConfig(max_iters=3, max_llm_calls=5, model="bedrock/converse/global.anthropic.claude-opus-5-5") + ) + summary: SignatureConfig = Field( + default_factory=lambda: SignatureConfig(max_iters=1, max_llm_calls=2, model="bedrock/converse/nvidia.nemotron-super-3-120b") + ) + audit: SignatureConfig = Field( + default_factory=lambda: SignatureConfig(max_iters=1, max_llm_calls=2, model="bedrock/converse/global.anthropic.claude-opus-5-5") + ) def signatures(self) -> dict[str, SignatureConfig]: """Return a dict of signature name -> SignatureConfig.""" diff --git a/src/codespy/config_llm.py b/src/codespy/config_llm.py index 9c7ac03..8517d71 100644 --- a/src/codespy/config_llm.py +++ b/src/codespy/config_llm.py @@ -277,12 +277,12 @@ class LLMConfig(BaseModel): azure_api_version: str | None = None # Default model settings - default_model: str = "anthropic/claude-opus-4-6" - extraction_model: str | None = None # TwoStepAdapter extraction (falls back to default_model) + default_model: str = "bedrock/converse/nvidia.nemotron-nano-3-30b" + extraction_model: str | None = "bedrock/converse/nvidia.nemotron-nano-3-30b" # TwoStepAdapter extraction (falls back to default_model) default_max_iters: int = 5 default_max_llm_calls: int = 8 default_reasoning_effort: ReasoningEffort = "medium" - default_temperature: float = 0.2 + default_temperature: float = 1.0 default_max_tokens: int = 32000 # Global LLM reliability settings @@ -292,40 +292,4 @@ class LLMConfig(BaseModel): # RLM fallback configuration rlm_fallback: RLMFallbackConfig = Field(default_factory=RLMFallbackConfig) - def sync_from_flat( - self, - *, - openai_api_key: SecretStr | None = None, - anthropic_api_key: SecretStr | None = None, - gemini_api_key: SecretStr | None = None, - aws_region: str | None = None, - aws_access_key_id: SecretStr | None = None, - aws_secret_access_key: SecretStr | None = None, - ) -> dict[str, object]: - """Sync flat settings into this LLMConfig and return values to propagate back. - - Priority: flat fields (from env vars via pydantic-settings) > nested defaults. - Returns dict of field values that should be set on the parent Settings. - """ - # Step 1: Flat → nested (env vars win over nested defaults) - if secret_value(openai_api_key): - self.openai_api_key = openai_api_key - if secret_value(anthropic_api_key): - self.anthropic_api_key = anthropic_api_key - if secret_value(gemini_api_key): - self.gemini_api_key = gemini_api_key - if aws_region: - self.aws_region = aws_region - if secret_value(aws_access_key_id): - self.aws_access_key_id = aws_access_key_id - if secret_value(aws_secret_access_key): - self.aws_secret_access_key = aws_secret_access_key - - # Step 2: Return merged values (nested fills gaps where flat is not set) - return { - "openai_api_key": openai_api_key if secret_value(openai_api_key) else self.openai_api_key, - "anthropic_api_key": anthropic_api_key if secret_value(anthropic_api_key) else self.anthropic_api_key, - "gemini_api_key": gemini_api_key if secret_value(gemini_api_key) else self.gemini_api_key, - "aws_access_key_id": aws_access_key_id if secret_value(aws_access_key_id) else self.aws_access_key_id, - "aws_secret_access_key": aws_secret_access_key if secret_value(aws_secret_access_key) else self.aws_secret_access_key, - } + diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 99b04af..1b7a829 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -37,7 +37,7 @@ # Default embedding model per LLM provider prefix. Used by get_cerebral() # when embeddings.model is None. litellm-sdk routes through litellm. EMBEDDING_MODELS: dict[str, str] = { - "bedrock": "bedrock/cohere.embed-multilingual-v3", + "bedrock": "bedrock/cohere.embed-v4:0", "openai": "openai/text-embedding-3-small", "anthropic": "openai/text-embedding-3-small", "gemini": "gemini/text-embedding-004", @@ -57,7 +57,7 @@ class ReflectionModuleConfig(BaseModel): one used for code review. """ - model: str | None = None + model: str | None = "bedrock/converse/nvidia.nemotron-super-3-120b" reasoning_effort: ReasoningEffort | None = None temperature: float | None = None max_tokens: int | None = None @@ -78,14 +78,14 @@ class HippocampusConfig(BaseModel): max_question_tokens: int | None = Field(default=8192) # Reflection modules - distiller: ReflectionModuleConfig = Field(default_factory=ReflectionModuleConfig) - cartographer: ReflectionModuleConfig = Field(default_factory=ReflectionModuleConfig) + distiller: ReflectionModuleConfig = Field(default_factory=lambda: ReflectionModuleConfig(model="bedrock/converse/nvidia.nemotron-super-3-120b")) + cartographer: ReflectionModuleConfig = Field(default_factory=lambda: ReflectionModuleConfig(model="bedrock/converse/nvidia.nemotron-super-3-120b")) class CerebralRetainConfig(BaseModel): """Cerebral (semantic memory) LLM configuration for fact extraction.""" - model: str | None = None # Falls back to llm.default_model + model: str | None = "bedrock/converse/nvidia.nemotron-super-3-120b" # Falls back to llm.default_model # Value is in chars. The merged observations blob is bounded by # max_hippocampus_tokens (~65K chars worst case), so large blobs split # into several chunks. Must be < _HINDSIGHT_RETAIN_MAX_COMPLETION_TOKENS. @@ -99,7 +99,7 @@ class CerebralConsolidationConfig(BaseModel): cerebral.retain.model → llm.default_model when unset. """ - model: str | None = None # Falls back to cerebral.retain.model → llm.default_model + model: str | None = "bedrock/converse/nvidia.nemotron-super-3-120b" # Falls back to cerebral.retain.model → llm.default_model # Hindsight observation_scope_limits cap. -1 = unlimited, 0 = no new observations. # Applied per scope as: scope=[org:*, repo:*, project_scope:*], limit=N max_observations_per_scope: int = Field(default=100, ge=-1) @@ -109,14 +109,14 @@ class MentalModelsConfig(BaseModel): """Mental-model (briefing) refresh LLM configuration. Mental-model refresh synthesizes briefings after consolidation. - Falls back to prefrontal.model → cerebral.retain.model → llm.default_model + Falls back to prefrontal.recall.model → cerebral.retain.model → llm.default_model when unset. Only used when reflects > 0. Delta mode means nothing is lost - the refresh covers all new facts since the last successful refresh when the interval expires. """ - model: str | None = None # Falls back to prefrontal.model → retain → default + model: str | None = "bedrock/converse/nvidia.nemotron-super-3-120b" # Falls back to prefrontal.recall.model → retain → default max_tokens: int = Field(default=2048, gt=0) # Briefing size min_refresh_seconds: int = Field(default=0, ge=0) # Minimum seconds between refreshes @@ -124,7 +124,7 @@ class MentalModelsConfig(BaseModel): class CerebralEmbeddingsConfig(BaseModel): """Cerebral (semantic memory) embeddings configuration.""" - model: str | None = None # Auto-derived from provider if unset + model: str | None = "bedrock/cohere.embed-v4:0" # Auto-derived from provider if unset max_input_chars: int | None = Field( default=None, ge=0, @@ -151,7 +151,7 @@ class RecallConfig(BaseModel): # Model for Hindsight reflect (pre-call context and recall_memory tool). # Falls back to cerebral.retain.model → llm.default_model. # Unused when reflects=0 (no LLM at read time). - model: str | None = None + model: str | None = "bedrock/converse/nvidia.nemotron-super-3-120b" # local: this scope/repo only. org: also other repos of the same owner. # bank: every repo in the bank (crosses organisations — opt-in only). reach: Literal["local", "org", "bank"] = "org" diff --git a/src/codespy/workflows/review/reporters/git.py b/src/codespy/workflows/review/reporters/git.py index aa21007..fe83959 100644 --- a/src/codespy/workflows/review/reporters/git.py +++ b/src/codespy/workflows/review/reporters/git.py @@ -4,7 +4,9 @@ import time from typing import TYPE_CHECKING -from codespy.agents.review.models import Issue, IssueSeverity +from collections import Counter + +from codespy.agents.review.models import Issue, IssueCategory, IssueSeverity from codespy.workflows.review.models import ReviewResult from codespy.workflows.review.reporters.base import BaseReporter from codespy.tools.git.base import SubmittedReview @@ -207,28 +209,8 @@ def _build_review_body( ] ) - # Statistics section - lines.extend( - [ - "
", - "📊 Statistics", - "", - "| Metric | Count |", - "|--------|-------|", - f"| Total Issues | {result.total_issues} |", - f"| Critical | {len(result.critical_issues)} |", - f"| High | {sum(1 for i in result.issues if i.severity == IssueSeverity.HIGH)} |", - f"| Medium | {sum(1 for i in result.issues if i.severity == IssueSeverity.MEDIUM)} |", # noqa: E501 - f"| Low | {sum(1 for i in result.issues if i.severity == IssueSeverity.LOW)} |", - f"| Security | {len(result.security_issues)} |", - f"| Bugs | {len(result.bug_issues)} |", - f"| Documentation | {len(result.documentation_issues)} |", - f"| Info | {sum(1 for i in result.issues if i.severity == IssueSeverity.INFO)} |", # noqa: E501 - "", - "
", - "", - ] - ) + # Statistics section (severity × category matrix) + lines.extend(self._build_statistics_section(result)) # Cost section if result.total_cost > 0 or result.llm_calls > 0: @@ -333,6 +315,73 @@ def _build_review_body( return "\n".join(lines) + def _build_statistics_section(self, result: ReviewResult) -> list[str]: + """Build the statistics section as a severity × category matrix. + + Args: + result: The review result containing issues. + + Returns: + Markdown lines for the statistics section. + """ + severities = [ + IssueSeverity.CRITICAL, + IssueSeverity.HIGH, + IssueSeverity.MEDIUM, + IssueSeverity.LOW, + IssueSeverity.INFO, + ] + categories = [ + (IssueCategory.SECURITY, "Security"), + (IssueCategory.BUG, "Bugs"), + (IssueCategory.DOCUMENTATION, "Documentation"), + (IssueCategory.SMELL, "Smells"), + ] + + # Build counts with one pass over issues + counts = Counter((issue.severity, issue.category) for issue in result.issues) + + # Calculate row totals (per severity) + row_totals = {s: sum(counts[(s, c)] for c, _ in categories) for s in severities} + + # Calculate column totals (per category) + col_totals = {c: sum(counts[(s, c)] for s in severities) for c, _ in categories} + + # Grand total + grand_total = result.total_issues + + # Build table lines + lines = [ + "
", + "📊 Statistics", + "", + "| Severity | Security | Bugs | Documentation | Smells | Total |", + "|----------|----------|------|---------------|--------|-------|", + ] + + # Data rows + for severity in severities: + cells = [severity.value.title()] + for cat, _ in categories: + cells.append(str(counts[(severity, cat)])) + cells.append(str(row_totals[severity])) + lines.append("| " + " | ".join(cells) + " |") + + # Totals row (bold) + total_row = ["**Total**"] + for cat, _ in categories: + total_row.append(f"**{col_totals[cat]}**") + total_row.append(f"**{grand_total}**") + lines.append("| " + " | ".join(total_row) + " |") + + lines.extend([ + "", + "
", + "", + ]) + + return lines + def _build_inline_comments(self, issues: list[Issue]) -> list[dict]: """Build inline comment dictionaries for the Git API. diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index 353e100..736bbc3 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -182,7 +182,7 @@ class TestEmbeddingModels: def test_embedding_models_mapping_bedrock(self): """EMBEDDING_MODELS should have entry for bedrock.""" assert "bedrock" in EMBEDDING_MODELS - assert "cohere" in EMBEDDING_MODELS["bedrock"] + assert "embed-v4:0" in EMBEDDING_MODELS["bedrock"] def test_embedding_models_mapping_openai(self): """EMBEDDING_MODELS should have entry for openai.""" @@ -273,7 +273,7 @@ def test_defaults(self): pf = MemoryConfig().prefrontal assert pf.reflects == 3 # Changed from 5 to 3 - assert pf.recall.model is None + assert pf.recall.model == "bedrock/converse/nvidia.nemotron-super-3-120b" assert pf.recall.reach == "org" assert pf.recall.max_tokens == 8192 assert pf.recall.max_tool_tokens == 2048 @@ -533,13 +533,13 @@ def test_get_cerebral_without_prefrontal_model_passes_no_reflect_llm(self, monke kwargs = self._capture_cerebral_kwargs(monkeypatch, self._settings(0)) assert not any(k.startswith("reflect_llm_") for k in kwargs) - def test_get_cerebral_passes_prefrontal_model_as_reflect_llm(self, monkeypatch): + def test_get_cerebral_passes_prefrontal_recall_model_as_reflect_llm(self, monkeypatch): pytest = __import__("pytest") pytest.importorskip("hindsight_api") # Skip if hindsight_api not available from pydantic import SecretStr settings = self._settings(3) - settings.memory.prefrontal.model = "anthropic/claude-sonnet-4-5" + settings.memory.prefrontal.recall.model = "anthropic/claude-sonnet-4-5" settings.llm.anthropic_api_key = SecretStr("sk-ant") kwargs = self._capture_cerebral_kwargs(monkeypatch, settings) # Engine model is unchanged; reflect gets its own model and credentials. @@ -573,8 +573,8 @@ def test_get_cerebral_reflect_kwargs_match_cerebral_signature(self, monkeypatch) # Should bind without error inspect.signature(Cerebral.__init__).bind(None, **kwargs_no_pf) - # Test with prefrontal model - settings.memory.prefrontal.model = "anthropic/claude-sonnet-4-5" + # Test with prefrontal recall model + settings.memory.prefrontal.recall.model = "anthropic/claude-sonnet-4-5" settings.llm.anthropic_api_key = SecretStr("sk-ant") kwargs_with_pf = self._capture_cerebral_kwargs(monkeypatch, settings) # Should bind without error @@ -646,7 +646,7 @@ def test_defaults(self): from codespy.config_memory import CerebralConfig cfg = CerebralConfig().consolidation - assert cfg.model is None + assert cfg.model == "bedrock/converse/nvidia.nemotron-super-3-120b" assert cfg.max_observations_per_scope == 100 # default def test_env_mapping(self, monkeypatch): @@ -700,7 +700,7 @@ def test_defaults(self): from codespy.config_memory import RecallConfig cfg = RecallConfig() - assert cfg.model is None + assert cfg.model == "bedrock/converse/nvidia.nemotron-super-3-120b" assert cfg.reach == "org" assert cfg.max_tokens == 8192 assert cfg.max_tool_tokens == 2048 @@ -757,7 +757,7 @@ def test_defaults(self): from codespy.config_memory import CerebralConfig cfg = CerebralConfig().mental_models - assert cfg.model is None + assert cfg.model == "bedrock/converse/nvidia.nemotron-super-3-120b" assert cfg.max_tokens == 2048 assert cfg.min_refresh_seconds == 0 diff --git a/tests/test_review_statistics.py b/tests/test_review_statistics.py new file mode 100644 index 0000000..886dc6e --- /dev/null +++ b/tests/test_review_statistics.py @@ -0,0 +1,123 @@ +"""Tests for the severity × category statistics matrix in review output.""" + +from codespy.agents.review.models import Issue, IssueCategory, IssueSeverity +from codespy.workflows.review.models import ReviewResult +from codespy.workflows.review.reporters.git import GitReporter + + +def _make_issue(severity: IssueSeverity, category: IssueCategory) -> Issue: + """Create a minimal Issue with given severity and category.""" + return Issue( + category=category, + severity=severity, + title="test issue", + description="test description", + filename="test.py", + ) + + +def _result_with_issues(*issues: Issue) -> ReviewResult: + """Build a ReviewResult with the given issues.""" + return ReviewResult( + pr_number=1, + pr_title="t", + pr_url="https://github.com/o/r/pull/1", + repo="o/r", + model_used="m", + issues=list(issues), + ) + + +def _git_body(result: ReviewResult) -> str: + """Build the git review body from a result.""" + reporter = GitReporter.__new__(GitReporter) + return reporter._build_review_body(result, []) + + +def test_statistics_matrix_header(): + """The statistics table should have the correct header row.""" + result = _result_with_issues() + body = _git_body(result) + + assert "| Severity | Security | Bugs | Documentation | Smells | Total |" in body + assert "|----------|----------|------|---------------|--------|-------|" in body + + +def test_statistics_matrix_all_severities(): + """The statistics table should include all severity rows.""" + result = _result_with_issues() + body = _git_body(result) + + assert "| Critical |" in body + assert "| High |" in body + assert "| Medium |" in body + assert "| Low |" in body + assert "| Info |" in body + + +def test_statistics_matrix_zero_issues(): + """When there are no issues, all cells should show 0.""" + result = _result_with_issues() + body = _git_body(result) + + # Check that we have zeros in the matrix (each severity row has 6 zeros) + # The pattern is: | Severity | 0 | 0 | 0 | 0 | 0 | + lines = body.split("\n") + data_rows = [l for l in lines if l.startswith("| Critical |") or l.startswith("| High |") or + l.startswith("| Medium |") or l.startswith("| Low |") or l.startswith("| Info |")] + + for row in data_rows: + assert "| 0 | 0 | 0 | 0 | 0 |" in row, f"Expected zeros in row: {row}" + + # Check totals row + assert "| **Total** | **0** | **0** | **0** | **0** | **0** |" in body + + +def test_statistics_matrix_counts_issues(): + """Issues should be counted in the correct severity × category cell.""" + result = _result_with_issues( + _make_issue(IssueSeverity.CRITICAL, IssueCategory.SECURITY), + _make_issue(IssueSeverity.HIGH, IssueCategory.SECURITY), + _make_issue(IssueSeverity.HIGH, IssueCategory.BUG), + _make_issue(IssueSeverity.MEDIUM, IssueCategory.DOCUMENTATION), + _make_issue(IssueSeverity.INFO, IssueCategory.SMELL), + ) + body = _git_body(result) + + lines = body.split("\n") + data_rows = {l.split("|")[1].strip(): l for l in lines if l.startswith("| ") and "Severity" not in l} + + # Critical row: 1 security, 0 others + assert "| Critical | 1 | 0 | 0 | 0 | 1 |" in body + + # High row: 1 security, 1 bug, 0 others + assert "| High | 1 | 1 | 0 | 0 | 2 |" in body + + # Medium row: 1 documentation + assert "| Medium | 0 | 0 | 1 | 0 | 1 |" in body + + # Info row: 1 smell + assert "| Info | 0 | 0 | 0 | 1 | 1 |" in body + + # Totals row + assert "| **Total** | **2** | **1** | **1** | **1** | **5** |" in body + + +def test_statistics_matrix_no_old_metric_table(): + """The old Metric | Count table format should not appear.""" + result = _result_with_issues() + body = _git_body(result) + + assert "| Metric | Count |" not in body + assert "| Total Issues |" not in body + + +def test_statistics_matrix_in_collapsible(): + """The statistics section should be inside a collapsible details block.""" + result = _result_with_issues() + body = _git_body(result) + + # Check for the collapsible wrapper + assert '
' in body + assert '📊 Statistics' in body + assert '
' in body From 2ec84d867b84b0d7a05bc0aeea543a4a3e05d9bb Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Fri, 2 Oct 2026 23:57:29 +0200 Subject: [PATCH 35/39] recall --- CHANGELOG.md | 6 +++--- docs/configuration.md | 2 +- docs/memory.md | 4 ++-- src/codespy/agents/dspy_config.py | 4 ++-- src/codespy/agents/memory/cerebral/cost.py | 20 ++++++++++---------- src/codespy/config.py | 10 +++++----- src/codespy/config_memory.py | 2 +- tests/test_cerebral_cost.py | 18 +++++++++--------- tests/test_config_memory.py | 14 +++++++------- 9 files changed, 40 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2067692..421079e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,7 @@ - **Cerebral cost bucket split**: Consolidation and mental-model refresh LLM calls are now billed to `memory_consolidation` and `memory_mental_models` respectively, instead of `memory_other`. The `memory_other` bucket now only holds uncategorized LLM calls (e.g., standalone reflect calls). - Embeddings remain in `memory_embeddings` / `memory_prefrontal_embeddings` (unchanged). + Embeddings remain in `memory_embeddings` / `memory_recall_embeddings` (unchanged). - **Briefing refresh trigger**: Now defaults to delta mode with reduced evidence caps: - `mode=delta` (was `full`): only new facts since last refresh - `include_chunks=false` (was `true`): observation text only, no file chunks @@ -104,7 +104,7 @@ - `memory.prefrontal.max_reflect_context_tokens` (env `MEMORY_MAX_REFLECT_CONTEXT_TOKENS`, default `null`): when `null`, the cap is auto-derived from `5 × max_mental_model_tokens + max_reflect_fetch_tokens + 2048` overhead, divided by 0.8, so worst-case history fits in one chunk and no map calls occur. With defaults this is ~16000. An explicit value still wins. - Reflect caps are set programmatically on the Hindsight `_raw_config` after import, so they do not require a process restart. Env vars still win as operator overrides. - Load INFO line now includes `maps=N` (split synthesis map-call count), `rewrite` flag, and `thoughts=N` (reasoning tokens) for monitoring. -- **Prefrontal embeddings bucket**: `memory_prefrontal_embeddings` cost bucket for embeddings during Prefrontal recall (separate from `memory_prefrontal` LLM calls). +- **Recall embeddings bucket**: `memory_recall_embeddings` cost bucket for embeddings during recall (separate from `memory_recall` LLM calls). - **Reflect summary**: `areflect` returns `(text, ReflectSummary)` with iterations, LLM calls, map calls, rewrite flag, tools used, usage, and empty flag. Stored in `RecallRecord.details["reflect"]` for monitoring. - **Prefrontal reflect cost caps**: new settings to cap the Hindsight reflect loop cost: - `memory.prefrontal.max_reflect_fetch_tokens` (env `MEMORY_MAX_REFLECT_FETCH_TOKENS`, default `2048`): per-call fetch limit for recall and search_observations tools. This is the default size used when the LLM omits one; the LLM can still ask for more up to the ceiling. Also disables chunk retrieval (`recall_include_chunks=false`). @@ -112,7 +112,7 @@ - **Cerebral read API**: `arecall`/`recall`, `areflect`/`reflect` (`budget=mid`), `aget_mental_models`, `aget_observation_history`. - **Prefrontal model setting**: `memory.prefrontal.model` (env `MEMORY_PREFRONTAL_MODEL`, Action input `memory-prefrontal-model`) selects the model for Hindsight's reflect loop and mental-model refresh. It falls back to `memory.cerebral.retain.model` → `llm.default_model`, so behaviour is unchanged when unset. It is checked at startup when `reflects > 0`. - **Prefrontal recall monitoring**: every pre-call load and `recall_memory` call is logged as one INFO line (status, model, LLM calls, input/output tokens, cost, latency) and persisted to a new episodic `recalls` table (`Episode.recalls`, `RecallRecord` in `codespy/agents/memory/recall.py`). Rows hold the exact text the agent received and the measured token usage and cost. The table is additive (`CREATE TABLE IF NOT EXISTS`); recalls are never retained into Cerebral nor given to the Distiller/Cartographer. -- **`memory_prefrontal` cost bucket**: Prefrontal read calls (reflect LLM calls and query embeddings) are attributed to it through a context variable. Previously they were counted in `memory_other` / `memory_embeddings`. +- **`memory_recall` cost bucket**: Recall calls (reflect LLM calls and query embeddings) are attributed to it through a context variable. Previously they were counted in `memory_other` / `memory_embeddings`. - **Cerebral mental models**: one briefing per observation scope and per repo, created on first retain and refreshed after consolidation. `memory.prefrontal.reflects > 0` sets `HINDSIGHT_API_REFLECT_MAX_ITERATIONS`, which also caps mental-model refresh loops. - **Cerebral (Semantic Memory)**: - New `memory.cerebral.retain.chunk_size` configuration (default: 12288, env var: `MEMORY_RETAIN_CHUNK_SIZE`). Chars per fact-extraction chunk; must be < 64000. See `CerebralRetainConfig` in `config_memory.py`. diff --git a/docs/configuration.md b/docs/configuration.md index 2ebfd6c..76a5f34 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -233,7 +233,7 @@ input (briefings, recalled facts, facts from other repositories); the RLM agents Every recall (pre-call load and each `recall_memory` call) is logged as one INFO line and stored with its full text, model, token usage and cost in the episodic `recalls` table. Its -cost appears in the `memory_prefrontal` line of the cost report. +cost appears in the `memory_recall` line of the cost report. See [Memory System](memory.md#semantic-memory-cerebral-and-prefrontal) for details. diff --git a/docs/memory.md b/docs/memory.md index fd1890b..d98d218 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -414,7 +414,7 @@ Every recall is logged and stored. - **Usage** — measured, not estimated: `input_tokens` counts LLM prompt tokens plus query embeddings, `output_tokens` counts completions, and costs use `litellm.cost_per_token` (0 for unpriced models). With `reflects = 0`, only embedding tokens are recorded. -- **Cost report** — Prefrontal reads are billed to the `memory_prefrontal` bucket. +- **Cost report** — Prefrontal reads are billed to the `memory_recall` bucket. Mental-model refreshes during retain are billed to `memory_mental_models`. Consolidation LLM calls are billed to `memory_consolidation`. - Recalls are **not** retained into Cerebral and are never shown to the Distiller or the @@ -438,7 +438,7 @@ in mental-model upkeep logs a warning and degrades to less context, or to `""`. Every retain now runs consolidation (billed to `memory_consolidation`) and refreshes the touched scopes' models (billed to `memory_mental_models`). `reflects > 0` adds a nested loop to -every agent call and tool call; those Prefrontal reads are billed to `memory_prefrontal`. +every agent call and tool call; those Prefrontal reads are billed to `memory_recall`. Background saves take longer, and process exit waits on them. ## Token Budgets diff --git a/src/codespy/agents/dspy_config.py b/src/codespy/agents/dspy_config.py index f0bd8fc..dbdc2a9 100644 --- a/src/codespy/agents/dspy_config.py +++ b/src/codespy/agents/dspy_config.py @@ -10,7 +10,7 @@ from dspy.adapters.two_step_adapter import TwoStepAdapter # type: ignore[import-untyped] from codespy.config import Settings, get_settings -from codespy.config_memory import MEMORY_CONSOLIDATION, MEMORY_MENTAL_MODELS, MEMORY_PREFRONTAL, MEMORY_RETAIN, REFLECTION_MODULES, LLMSettings +from codespy.config_memory import MEMORY_CONSOLIDATION, MEMORY_MENTAL_MODELS, MEMORY_RECALL, MEMORY_RETAIN, REFLECTION_MODULES, LLMSettings from codespy.config_utils import secret_value logger = logging.getLogger(__name__) @@ -332,7 +332,7 @@ def verify_model_access(settings: Settings) -> tuple[bool, str]: # Prefrontal reflect model (only when reflects > 0, since with reflects=0 no LLM runs) if settings.memory.prefrontal.reflects > 0: - prefrontal = settings.get_llm_config(MEMORY_PREFRONTAL) + prefrontal = settings.get_llm_config(MEMORY_RECALL) models_to_check.add(prefrontal.model) # Mental-models refresh model (only when reflects > 0) mental_models = settings.get_llm_config(MEMORY_MENTAL_MODELS) diff --git a/src/codespy/agents/memory/cerebral/cost.py b/src/codespy/agents/memory/cerebral/cost.py index 38b18ac..0b5f714 100644 --- a/src/codespy/agents/memory/cerebral/cost.py +++ b/src/codespy/agents/memory/cerebral/cost.py @@ -20,7 +20,7 @@ MEMORY_EMBEDDINGS, MEMORY_MENTAL_MODELS, MEMORY_OTHER, - MEMORY_PREFRONTAL, + MEMORY_RECALL, MEMORY_RETAIN, ) @@ -48,10 +48,10 @@ BUCKET_MEMORY_RETAIN = MEMORY_RETAIN BUCKET_MEMORY_OTHER = MEMORY_OTHER BUCKET_MEMORY_EMBEDDINGS = MEMORY_EMBEDDINGS -# Prefrontal reads (recall/reflect inside a track_recall_usage() block) -BUCKET_MEMORY_PREFRONTAL = MEMORY_PREFRONTAL -# Prefrontal embeddings (separate bucket for embeddings during Prefrontal recall) -BUCKET_MEMORY_PREFRONTAL_EMBEDDINGS = f"{MEMORY_PREFRONTAL}_embeddings" +# Recall reads (recall/reflect inside a track_recall_usage() block) +BUCKET_MEMORY_RECALL = MEMORY_RECALL +# Recall embeddings (separate bucket for embeddings during recall) +BUCKET_MEMORY_RECALL_EMBEDDINGS = f"{MEMORY_RECALL}_embeddings" # Consolidation task LLM calls BUCKET_MEMORY_CONSOLIDATION = MEMORY_CONSOLIDATION # Mental model refresh task LLM calls @@ -65,7 +65,7 @@ def _llm_bucket(scope: str) -> str: """Determine the cost bucket for an LLM call based on scope and context. Precedence (first match wins): - 1. current_recall_usage set → memory_prefrontal (handled in record_llm_call) + 1. current_recall_usage set → memory_recall (handled in record_llm_call) 2. current_memory_task == "refresh_mental_model" → memory_mental_models 3. current_memory_task == "consolidation" → memory_consolidation 4. scope starts with "retain" → memory_retain @@ -146,13 +146,13 @@ def record_llm_call( # type: ignore[return] cost = prompt_cost + completion_cost tokens = input_tokens + output_tokens - # Determine bucket: an active Prefrontal recall wins over the scope/task tag, - # because Hindsight scopes cannot tell a Prefrontal reflect apart + # Determine bucket: an active recall wins over the scope/task tag, + # because Hindsight scopes cannot tell a reflect apart # from a mental-model refresh. usage = current_recall_usage.get() if usage is not None: usage.add_llm(model, input_tokens, output_tokens, prompt_cost, completion_cost) - bucket = BUCKET_MEMORY_PREFRONTAL + bucket = BUCKET_MEMORY_RECALL else: bucket = _llm_bucket(scope) @@ -337,7 +337,7 @@ def _meter_embedding(self, response: Any, model: str, duration: float = 0.0) -> usage = current_recall_usage.get() if usage is not None: usage.add_embedding(tokens, cost or 0.0) - bucket = BUCKET_MEMORY_PREFRONTAL_EMBEDDINGS + bucket = BUCKET_MEMORY_RECALL_EMBEDDINGS else: bucket = BUCKET_MEMORY_EMBEDDINGS diff --git a/src/codespy/config.py b/src/codespy/config.py index f163cbe..66a0f6f 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -23,7 +23,7 @@ from codespy.config_memory import ( MEMORY_CONSOLIDATION, MEMORY_MENTAL_MODELS, - MEMORY_PREFRONTAL, + MEMORY_RECALL, MEMORY_RETAIN, REFLECTION_MODULES, LLMSettings, @@ -208,8 +208,8 @@ def get_llm_config(self, name: str) -> LLMSettings: ) config = ReflectionModuleConfig() # Empty config - all fields fall back to defaults defaults = self.llm - elif name == MEMORY_PREFRONTAL: - # Prefrontal recall model: falls back to retain model, then default + elif name == MEMORY_RECALL: + # Recall model: falls back to retain model, then default model = ( self.memory.prefrontal.recall.model or self.memory.cerebral.retain.model @@ -340,9 +340,9 @@ def log_signature_configs(self) -> None: f"max_tokens={llm.max_tokens}" ) # Prefrontal recall model (also refreshes briefings) - pf_llm = self.get_llm_config(MEMORY_PREFRONTAL) + pf_llm = self.get_llm_config(MEMORY_RECALL) logger.info( - f" {MEMORY_PREFRONTAL}: model={pf_llm.model}, " + f" {MEMORY_RECALL}: model={pf_llm.model}, " f"reflects={self.memory.prefrontal.reflects}" ) # Consolidation model diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 1b7a829..468e7cc 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -265,7 +265,7 @@ class MemoryConfig(BaseModel): MEMORY_RETAIN = "memory_retain" MEMORY_EMBEDDINGS = "memory_embeddings" MEMORY_OTHER = "memory_other" -MEMORY_PREFRONTAL = "memory_prefrontal" +MEMORY_RECALL = "memory_recall" MEMORY_CONSOLIDATION = "memory_consolidation" MEMORY_MENTAL_MODELS = "memory_mental_models" diff --git a/tests/test_cerebral_cost.py b/tests/test_cerebral_cost.py index 86865dc..718231a 100644 --- a/tests/test_cerebral_cost.py +++ b/tests/test_cerebral_cost.py @@ -13,8 +13,8 @@ BUCKET_MEMORY_EMBEDDINGS, BUCKET_MEMORY_MENTAL_MODELS, BUCKET_MEMORY_OTHER, - BUCKET_MEMORY_PREFRONTAL, - BUCKET_MEMORY_PREFRONTAL_EMBEDDINGS, + BUCKET_MEMORY_RECALL, + BUCKET_MEMORY_RECALL_EMBEDDINGS, BUCKET_MEMORY_RETAIN, CerebralCostRecorder, MeteredLiteLLMSDKEmbeddings, @@ -165,7 +165,7 @@ def test_record_llm_call_with_refresh_mental_model_task_tag(self, mock_tracker, assert stats.output_tokens == 50 def test_record_llm_call_recall_wins_over_task_tag(self, mock_tracker, fresh_recorder): - """current_recall_usage set AND current_memory_task set → memory_prefrontal (recall wins).""" + """current_recall_usage set AND current_memory_task set → memory_recall (recall wins).""" with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): with patch.object(CerebralCostRecorder, "_price_call_split", return_value=(0.02, 0.01)): with track_recall_usage() as usage: @@ -182,7 +182,7 @@ def test_record_llm_call_recall_wins_over_task_tag(self, mock_tracker, fresh_rec current_memory_task.reset(token) # Should go to prefrontal, not consolidation - stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_PREFRONTAL) + stats = mock_tracker.get_signature_stats(BUCKET_MEMORY_RECALL) assert stats is not None assert stats.input_tokens == 100 assert stats.output_tokens == 50 @@ -331,7 +331,7 @@ def test_record_llm_call_logs_warning_for_unpriced_model( class TestRecallUsageAttribution: - """LLM/embedding calls inside track_recall_usage() are billed to memory_prefrontal.""" + """LLM/embedding calls inside track_recall_usage() are billed to memory_recall.""" def test_llm_call_inside_recall_goes_to_prefrontal(self, mock_tracker, fresh_recorder): with patch("codespy.agents.cost_tracker.get_cost_tracker", return_value=mock_tracker): @@ -347,7 +347,7 @@ def test_llm_call_inside_recall_goes_to_prefrontal(self, mock_tracker, fresh_rec assert usage.input_tokens == 100 and usage.output_tokens == 40 assert usage.input_cost == 0.03 and usage.output_cost == 0.02 assert usage.to_model_field() == "openai/gpt-4o" - assert mock_tracker.get_signature_stats(BUCKET_MEMORY_PREFRONTAL).cost == 0.05 + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_RECALL).cost == 0.05 assert mock_tracker.get_signature_stats(BUCKET_MEMORY_OTHER) is None def test_llm_call_outside_recall_unchanged(self, mock_tracker, fresh_recorder): @@ -358,7 +358,7 @@ def test_llm_call_outside_recall_unchanged(self, mock_tracker, fresh_recorder): ) assert current_recall_usage.get() is None assert mock_tracker.get_signature_stats(BUCKET_MEMORY_OTHER) is not None - assert mock_tracker.get_signature_stats(BUCKET_MEMORY_PREFRONTAL) is None + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_RECALL) is None @pytest.mark.asyncio async def test_embedding_inside_recall_goes_to_prefrontal_embeddings(self, mock_tracker): @@ -379,8 +379,8 @@ async def test_embedding_inside_recall_goes_to_prefrontal_embeddings(self, mock_ assert usage.input_cost == 0.001 assert usage.llm_calls == 0 # embeddings are not LLM calls # Embeddings during active recall go to the new prefrontal_embeddings bucket - assert mock_tracker.get_signature_stats(BUCKET_MEMORY_PREFRONTAL_EMBEDDINGS).tokens == 12 - assert mock_tracker.get_signature_stats(BUCKET_MEMORY_PREFRONTAL) is None + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_RECALL_EMBEDDINGS).tokens == 12 + assert mock_tracker.get_signature_stats(BUCKET_MEMORY_RECALL) is None assert mock_tracker.get_signature_stats(BUCKET_MEMORY_EMBEDDINGS) is None def test_usage_propagates_to_cross_thread_loop(self): diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index 736bbc3..aaef1ce 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -592,7 +592,7 @@ def test_get_cerebral_passes_max_observations_per_scope(self, monkeypatch): class TestPrefrontalModelResolution: - """get_llm_config('memory_prefrontal') fallback chain. + """get_llm_config('memory_recall') fallback chain. Settings are built with explicit sections so a local ``.env`` cannot override them. @@ -618,22 +618,22 @@ def _settings(self, *, recall_model=None, retain=None): ) def test_explicit_recall_model(self): - from codespy.config_memory import MEMORY_PREFRONTAL + from codespy.config_memory import MEMORY_RECALL s = self._settings(recall_model="anthropic/claude-haiku", retain="openai/gpt-4o-mini") - assert s.get_llm_config(MEMORY_PREFRONTAL).model == "anthropic/claude-haiku" + assert s.get_llm_config(MEMORY_RECALL).model == "anthropic/claude-haiku" def test_falls_back_to_retain_model(self): - from codespy.config_memory import MEMORY_PREFRONTAL + from codespy.config_memory import MEMORY_RECALL s = self._settings(retain="openai/gpt-4o-mini") - assert s.get_llm_config(MEMORY_PREFRONTAL).model == "openai/gpt-4o-mini" + assert s.get_llm_config(MEMORY_RECALL).model == "openai/gpt-4o-mini" def test_falls_back_to_default_model(self): - from codespy.config_memory import MEMORY_PREFRONTAL + from codespy.config_memory import MEMORY_RECALL s = self._settings() - assert s.get_llm_config(MEMORY_PREFRONTAL).model == s.llm.default_model + assert s.get_llm_config(MEMORY_RECALL).model == s.llm.default_model # Note: _warn_reflect_mismatch removed - now using programmatic _apply_reflect_config # which sets values directly instead of warning about mismatches From 9577bda5f557c5879f590a3e8f3f53378fe46dcf Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Sat, 3 Oct 2026 00:46:20 +0200 Subject: [PATCH 36/39] cost --- CHANGELOG.md | 1 + README.md | 2 +- docs/architecture.md | 19 +- docs/configuration.md | 79 +-- docs/memory.md | 598 ++++++++---------- src/codespy/agents/memory/prefrontal/query.py | 2 +- .../agents/review/code_review/agent.py | 9 +- .../agents/review/supply_chain/agent.py | 9 +- tests/test_recall_tool_scope.py | 64 ++ 9 files changed, 392 insertions(+), 391 deletions(-) create mode 100644 tests/test_recall_tool_scope.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 421079e..f60c16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Fixed +- `recall_memory` with `reach=local` returned nothing for code_review and supply_chain; the tool now searches the reviewed project scope - Fixed "too many values to unpack" error in review statistics matrix (line 345 was iterating `severities` as tuples instead of enums) - `GITLAB_URL` from the environment was ignored — now properly takes precedence over YAML `gitlab.url` - Token precedence now matches documentation: env/`.env` beats YAML (`github.token` / `gitlab.token`) diff --git a/README.md b/README.md index 31280ac..0bbe596 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ codespy auto-discovers credentials from standard locations (`~/.aws/credentials` | **[Usage](docs/usage.md)** | CLI commands, Docker, GitHub Action, MCP server, output formats | | **[Configuration](docs/configuration.md)** | Environment variables, YAML config, model strategy, per-signature settings | | **[Architecture](docs/architecture.md)** | Pipeline design, DSPy signatures, supported languages | -| **[Memory System](docs/memory.md)** | Hippocampus episodic memory for cross-review knowledge | +| **[Memory System](docs/memory.md)** | Hippocampus (episodic), Cerebral (semantic store), Prefrontal (recall) | | **[Development](docs/development.md)** | Setup, build, test, lint | --- diff --git a/docs/architecture.md b/docs/architecture.md index 514473f..f407a89 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,12 +108,23 @@ RLM puts inputs into a sandboxed Python interpreter rather than the LLM prompt. The model writes code to access and process variables (e.g., chunking large patches via `llm_query()`), eliminating context window limits entirely. -The composition order is: `Hippocampus(ContextSafe(Module))` — Hippocampus -handles memory, ContextSafe handles context quality, each with single responsibility. +The composition order is: `ContextSafe(Module)` — ContextSafe handles context quality. +Hippocampus is a separate component that agents call explicitly. -## Hippocampus Memory +## Memory -Episode-based memory that wraps DSPy agents with persistent context across reviews. Agents accumulate knowledge about a codebase scope over time — patterns, constants, parsing schemas, and reuse it in subsequent reviews of the same code area. +CodeSpy's memory system has three independent components: + +- **Hippocampus**: Episode-based memory that learns from the current review. Agents call + `Hippocampus()` to create an episode, `aobserve()` to feed results, and `end_episode()` + to run the Distiller/Cartographer reflection pipeline. Agents never receive Hippocampus + context memory directly; prior knowledge reaches them only through Cerebral → Prefrontal. + +- **Cerebral**: Retains Hippocampus episodes into Hindsight semantic memory, consolidates + observations, and refreshes mental models (briefings). + +- **Prefrontal**: Reads Cerebral before agent calls and injects a read-only `prefrontal_memory` + input (briefings, recalled facts). RLM agents also get a `recall_memory` tool. See [Memory System](memory.md) for implementation details. diff --git a/docs/configuration.md b/docs/configuration.md index 76a5f34..87c1502 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -174,68 +174,33 @@ export REVIEW_OUTPUT_GIT=true ## Memory Configuration -Brief overview: +The memory system has three components: -| Setting | Env Var | Default | Description | -|---------|---------|---------|-------------| -| PostgreSQL host | `MEMORY_POSTGRES_HOST` | — | External PostgreSQL host | -| PostgreSQL port | `MEMORY_POSTGRES_PORT` | `5432` | External PostgreSQL port | -| PostgreSQL user | `MEMORY_POSTGRES_USER` | — (connects as `postgres`) | External PostgreSQL user | -| PostgreSQL password | `MEMORY_POSTGRES_PASSWORD` | — | External PostgreSQL password | -| PostgreSQL database | `MEMORY_POSTGRES_DATABASE` | `codespy` | External PostgreSQL database | -| PostgreSQL schema | `MEMORY_POSTGRES_SCHEMA` | `episodic` | PostgreSQL schema / search_path per memory type | -| Bank ID | `MEMORY_BANK_ID` | `codespy` | Scopes all memory data | -| pg0 name | `MEMORY_PG0_NAME` | `codespy` | pg0-embedded database name (local dev) | -| pg0 port | `MEMORY_PG0_PORT` | auto | pg0-embedded port (local dev) | -| pg0 data dir | `MEMORY_PG0_DATA_DIR` | — | pg0-embedded data directory (local dev) | -| Enabled | `MEMORY_ENABLED` | `false` | Enable memory globally (episodic + semantic) | -| Context memory tokens | `MEMORY_MAX_HIPPOCAMPUS_TOKENS` | `16384` | Ceiling on persisted context memory | -| Observation tokens | `MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS` | `512` | Soft per-observation token limit | -| Trajectory tokens | `MEMORY_MAX_TRAJECTORY_TOKENS` | `16384` | Cap on trajectory fed to Distiller | -| Question tokens | `MEMORY_MAX_QUESTION_TOKENS` | `8192` | Cap on serialized reflection inputs | +- **Hippocampus**: Episodic memory (learns from the current review) +- **Cerebral**: Semantic memory (retains episodes, consolidates, refreshes mental models) +- **Prefrontal**: Reads Cerebral and injects prior knowledge into agents -See [Memory System](memory.md) for full memory configuration details. - -## Cerebral Semantic Memory - -Cerebral retains episode observations and artifacts into Hindsight AI semantic memory, creating a knowledge layer alongside the episodic store. It activates automatically when `MEMORY_ENABLED=true`. - -LLM provider and credentials are auto-derived from the model string and existing LLM config (e.g., `bedrock/*` uses AWS env vars, `openai/*` uses `OPENAI_API_KEY`). +Master switch and connection: | Setting | Env Var | Default | Description | |---------|---------|---------|-------------| -| Retain Model | `MEMORY_RETAIN_MODEL` | `DEFAULT_MODEL` | Model for fact extraction | -| Consolidation Model | `MEMORY_CONSOLIDATION_MODEL` | `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for consolidation | -| Consolidation Observation Cap | `MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE` | `100` | Per-scope observation limit (-1 = unlimited, 0 = no new observations) | -| Mental-Models Model | `MEMORY_MENTAL_MODELS_MODEL` | `MEMORY_RECALL_MODEL` → `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for briefing refresh | -| Mental-Models Max Tokens | `MEMORY_MENTAL_MODELS_MAX_TOKENS` | `2048` | Briefing size | -| Mental-Models Min Refresh Seconds | `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS` | `0` | Minimum seconds between refreshes (0 = after every consolidation) | -| Embeddings Model | `MEMORY_EMBEDDINGS_MODEL` | Auto-derived | Model for semantic memory embeddings | -| Retain Chunk Size | `MEMORY_RETAIN_CHUNK_SIZE` | `12288` | Chars per extraction chunk (< 64000) | - -Hindsight MemoryEngine uses the same PostgreSQL instance under the `semantic` schema (created automatically alongside `episodic`). -Consolidation and mental-model refreshes run inline in the background save thread. - -## Prefrontal Semantic Recall - -Prefrontal reads Cerebral before each agent call and injects a read-only `prefrontal_memory` -input (briefings, recalled facts, facts from other repositories); the RLM agents also get a -`recall_memory` tool. It is active per signature when memory is enabled and Cerebral is available. - -| Setting | Env Var | Default | Description | -|---------|---------|---------|-------------| -| Reflects | `MEMORY_PREFRONTAL_REFLECTS` | `3` | Reflect iterations; `0` = raw facts, no LLM, no briefings | -| Recall Model | `MEMORY_RECALL_MODEL` | `MEMORY_RETAIN_MODEL` → `DEFAULT_MODEL` | Model for the reflect loop; also briefing refresh fallback when `cerebral.mental_models.model` is unset; unused when reflects = 0 | -| Recall Reach | `MEMORY_RECALL_REACH` | `org` | `local`, `org` or `bank` | -| Recall Max Tokens | `MEMORY_RECALL_MAX_TOKENS` | `8192` | Pre-call context budget | -| Recall Max Tool Tokens | `MEMORY_RECALL_MAX_TOOL_TOKENS` | `2048` | Tool result budget | -| Recall Max Tool Calls | `MEMORY_RECALL_MAX_TOOL_CALLS` | `0` | Per agent call (0 = tool off) | - -Every recall (pre-call load and each `recall_memory` call) is logged as one INFO line and -stored with its full text, model, token usage and cost in the episodic `recalls` table. Its -cost appears in the `memory_recall` line of the cost report. - -See [Memory System](memory.md#semantic-memory-cerebral-and-prefrontal) for details. +| Enabled | `MEMORY_ENABLED` | `false` | Enable memory globally | +| Bank ID | `MEMORY_BANK_ID` | `codespy` | Scopes all memory data | +| PostgreSQL host | `MEMORY_POSTGRES_HOST` | — | External PostgreSQL host | +| PostgreSQL port | `MEMORY_POSTGRES_PORT` | `5432` | PostgreSQL port | +| PostgreSQL user | `MEMORY_POSTGRES_USER` | `postgres` | PostgreSQL user | +| PostgreSQL password | `MEMORY_POSTGRES_PASSWORD` | — | PostgreSQL password | +| PostgreSQL database | `MEMORY_POSTGRES_DATABASE` | `codespy` | PostgreSQL database | +| PostgreSQL schema | `MEMORY_POSTGRES_SCHEMA` | `episodic` | Schema for episodic store | +| pg0 name | `MEMORY_PG0_NAME` | `codespy` | pg0-embedded database name | +| pg0 port | `MEMORY_PG0_PORT` | auto | pg0-embedded port | +| pg0 data dir | `MEMORY_PG0_DATA_DIR` | — | pg0-embedded data directory | + +See [Memory System](memory.md) for: +- Full configuration tables (token budgets, models, per-signature overrides) +- Database schema and SQL examples +- Lifecycle, data flow, and monitoring +- Prefrontal recall settings (reach, reflects, facets, `recall_memory` tool) ## Output Settings diff --git a/docs/memory.md b/docs/memory.md index d98d218..cb296d7 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -1,19 +1,57 @@ [← Back to README](../README.md#documentation) -# Hippocampus Memory System +# Memory System -## Overview +CodeSpy's memory system has three independent components: -Episode-based memory that wraps DSPy agents with persistent context across reviews. -Agents accumulate knowledge about a codebase scope over time — patterns, constants, -parsing schemas — and reuse it in subsequent reviews of the same code area. +| Component | Role | Storage | Schema | +|-----------|------|---------|--------| +| **Hippocampus** | Learns from the current episode | PostgreSQL (`episodic`) | Episodic: episodes, observations, recalls | +| **Cerebral** | Retains episodes into semantic memory, consolidates, refreshes mental models | Hindsight bank | Semantic: observations, mental models | +| **Prefrontal** | Reads Cerebral and injects prior knowledge into agents | — | Read-only | -## Concepts +**Terminology note:** The module is "Prefrontal" but its configuration lives under `memory.prefrontal.recall.*` and its cost bucket is `memory_recall`. *Recall* = the read operation Prefrontal performs (config, bucket, table). + +## Lifecycle of one review run + +``` +1. forward() runs the review phase inside defer_episode_saves() + ├── Scope agent (if memory enabled and model refinement runs) + │ └── Own Prefrontal load (task="review", include_repo=True) + ├── Run-level Prefrontal recall (task="review", episode_id=NULL) + ├── Summary + ├── code_review, doc, supply_chain (concurrent) + └── Audit +2. Review published with "Memory: pending" (remote) or printed (local CLI/MCP) +3. finish_memory() starts queued saves + ├── Each episode: end_episode() → Distiller → Cartographer → evict → save + └── retain_episode() for each scope +4. join_episode_saves() +5. consolidate_run() → one consolidation over union of touched scopes +6. Hindsight refreshes briefings via refresh_after_consolidation trigger +7. Costs recollected, review edited with full costs (remote) or printed +``` + +If `forward()` raises, deferred saves still start but consolidation does not run. The at-exit hook finishes pending saves but does **not** call `consolidate_run`. + +## Agents and memory + +| Agent | Gets pre-call memory from | Has `recall_memory` tool | Writes episode | +|-------|---------------------------|--------------------------|----------------| +| scope | Own load (run-level if no refinement) | Yes | Yes | +| summary | Run-level | No | Yes | +| code_review | Run-level | Yes | Yes | +| doc | Run-level | No | Yes | +| supply_chain | Run-level | Yes | Yes | +| audit | Run-level | No | Yes | + +The run-level recall (`task="review"`, `episode_id=NULL`) covers all project scopes plus the repo. Only the scope agent loads its own memory when model refinement runs; all other agents share the run-level recall. + +## Hippocampus (episodic) ### Banks -A bank is the top-level data partition. All episodes, topics, observations, -artifacts and recalls cascade-delete from a bank. +A bank is the top-level data partition. All episodes, topics, observations, artifacts and recalls cascade-delete from a bank. - Configured via `MEMORY_BANK_ID` (default: `codespy`) - Typical values: service name, team name, org identifier @@ -27,13 +65,23 @@ artifacts and recalls cascade-delete from a bank. ### Episodes -- An Episode captures one agent's run: task, context_memory, mutations, artifacts, recalls, timestamp -- Stored in PostgreSQL (auto-created tables). pg0-embedded auto-starts - a local instance when no `MEMORY_POSTGRES_HOST` is set. -- `EpisodeStore.load_context(task, topic_ids, topic_prefix)` retrieves - the latest context by `timestamp DESC`, filtering on bank + task + topic +An Episode captures one agent's run: + +- `id` (UUID): unique episode identifier +- `run_id` (varchar): parent run identifier +- `task` (varchar): agent task name (e.g., "code_review") +- `module` (varchar): module identifier +- `question` (text): the task question +- `context_memory`: six sections of observations +- `mutations`: ADD/REPLACE/DELETE operations produced by Cartographer +- `artifacts`: named outputs (e.g., review markdown) +- `recalls`: Prefrontal loads and tool calls +- `timestamp`: episode timestamp +- `topics`: list of Topic objects for this episode -### Context Memory +`EpisodeStore.load_context(task, topic_ids)` merges every live (non-tombstone) observation from **all** prior episodes of the task that match the topics, ordered **oldest first** (`timestamp ASC`) so eviction drops the oldest facts on ties. + +### Context Memory sections Six sections (from general to specific): @@ -44,43 +92,185 @@ Six sections (from general to specific): 5. **`parsing_schema`** — How to parse the context's format: delimiters, boundary patterns, field structure 6. **`reusable_results`** — Agent-derived aggregated outputs (counts, distributions, classifications) reusable across questions -Each section contains `Observation` objects with `id`, `content`, and `topic_ids` linking -the observation to its relevant scopes. +Each section contains `Observation` objects with `id`, `content`, and `topic_ids` linking the observation to its relevant scopes. ### Observations -Observations are the versioned audit trail of context memory items in the database. -Each time the Cartographer ADDs, REPLACEs, or DELETEs an observation, or eviction drops -one, a new observation row is inserted with an incremented `version` number. +Observations are the versioned audit trail of context memory items in the database. Each time the Cartographer ADDs, REPLACEs, or DELETEs an observation, or eviction drops one, a new observation row is inserted with an incremented `version` number. -- `content` holds the new value (`NULL` for the DELETE and EVICT tombstones) +- `content` holds the new value (`NULL` for DELETE and EVICT tombstones) - `previous_content` preserves the prior state (for REPLACE / DELETE / EVICT) - `op_type` records the operation: `ADD`, `REPLACE`, `DELETE` or `EVICT` - Linked to the episode that produced the mutation and the topics it belongs to -Observations in `ContextMemory` map 1:1 to the latest observation version that is not a -tombstone. +Observations in `ContextMemory` map 1:1 to the latest observation version that is not a tombstone. ### Artifacts -Named text outputs attached to an episode — for example, the final review -markdown or the PR summary text. Stored as `(episode_id, name) → content`. +Named text outputs attached to an episode — for example, the final review markdown or the PR summary text. Stored as `(episode_id, name) → content`. -### Recalls +### Reflection Pipeline -One row per Prefrontal recall made during the agent call: the pre-call load -(`kind = load`) and each `recall_memory` tool call (`kind = tool`). A row holds the exact -text the agent received, plus the query, reach, status, model, LLM calls, input/output -tokens and cost, and latency. Recalls are for monitoring only: they are never given to the -Distiller or the Cartographer and never retained into Cerebral. See -[Monitoring recalls](#monitoring-recalls). +At `end_episode()` (one pass per episode, after the review is published): -## Database Schema +1. **Distiller** — Analyzes the agent's trajectory (head 60% + tail 40% when `compact_trajectory=true`, capped at `max_trajectory_tokens`) and proposes `CacheCandidate` observations +2. **Cartographer** — Takes candidates + current context memory, decides operations: `ADD`, `REPLACE`, `DELETE` +3. **Eviction** — If memory exceeds `max_hippocampus_tokens`, evicts by: + - Section priority first: `parsing_schema` → `actions` → `reusable_results` → `domain_constants` → `context_roadmap` → `context_understanding` + - Then by score (helpful +1, harmful/stale −1, new +1) + - Then by age (oldest first) -Auto-created by `EpisodeStore.ensure_schema()` on first connect. -Source: `EpisodeStore.ensure_schema()` in `src/codespy/agents/memory/postgres.py`. +The Distiller tags each existing observation: + +- **`helpful`** — directly aided the agent; keep +- **`harmful`** — misled the agent or contradicted observations; remove +- **`neutral`** — present but unused this round; keep +- **`stale`** — no longer reflects the external context; remove + +These tags inform the Cartographer's edit decisions. + +## Cerebral (semantic store) + +Cerebral retains Hippocampus episodes into Hindsight semantic memory and keeps mental models (briefings) fresh. + +### Tags and observation scopes + +Every retained item carries its `project_scope:` topic tags, `task:`, and `repo:`/`org:`. `pull_request:`, `episode:`, and `run_id:` tags are no longer added. + +Consolidation uses an explicit `observation_scopes` list: one `[org:, repo:, project_scope:]` scope per `project_scope` topic of the episode, or `[org:, repo:, project_scope:]` (the repo-root scope) when there is none. + +**Per-scope observation cap**: Hindsight supports `observation_scope_limits` to cap observations per scope. codespy sets one rule: `[org:*, repo:*, project_scope:*]` with limit `max_observations_per_scope` (default 100, -1 = unlimited, 0 = no new observations). Scopes that hit the cap only allow UPDATE/DELETE; nothing is trimmed. + +**Consolidation**: Runs once per run, over the union of touched scopes, on the pipeline thread after `join_episode_saves()`. Not inline in `retain_episode`. + +### Mental models (briefings) + +Mental models are the abstraction layer ("when X happens, consider Y because Z"). Cerebral keeps one per observation scope of each episode, plus one per repo (`[org:, repo:]`), when `memory.prefrontal.reflects > 0`: + +- id: `mm-` + first 32 hex chars of `sha1("|".join(sorted(tags)))` +- name: `Briefing: ` +- source query: *"What should an agent starting work here know so it does not rediscover it..."* +- `max_tokens = cerebral.mental_models.max_tokens`, trigger `refresh_after_consolidation` + +codespy creates a missing briefing with placeholder content `"Generating content..."` in `_sync_briefing_triggers`, before retain. The content comes from Hindsight's `refresh_after_consolidation` trigger, which fires during `consolidate_run` at the end of the same run. It also updates the trigger of an existing briefing when settings changed. + +#### Trigger configuration + +Briefing triggers are configured automatically with these settings: + +| Trigger Field | Value | Description | +|-------------|-------|-------------| +| `mode` | `delta` | Only new facts since last refresh (with structured delta-ops call) | +| `exclude_mental_models` | `true` | Briefings exclude other briefings | +| `include_chunks` | `false` | Observation text only (no file chunks) | +| `reflect_search_observations_max_tokens` | `3000` | Down from 5000 default | +| `reflect_search_observations_include_entities` | `false` | Exclude entity metadata | +| `min_refresh_interval_seconds` | `0` | Configurable via `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS` | + +**Delta mode**: After the first full refresh, subsequent refreshes only process new facts since `last_memory_seen_at`. Falls back to full refresh when content is the placeholder or when the source query changed. + +**Deferral**: When `min_refresh_interval_seconds > 0`, automatic refreshes inside the window are deferred (operation marked `cancelled`, not `failed`). The next consolidation after the interval expires covers everything since `last_memory_seen_at`. -### Entity Relationship Diagram +Monitor briefing quality in the `memories` section of the review output and the `mental_models` content. + +### Hindsight maintenance + +- Orphaned pending operations are repaired by `orphans.py` +- Maintenance routines are repaired by `routines.py` + +## Prefrontal (recall) + +Prefrontal reads Cerebral and injects prior knowledge into agents. + +### Run-level load vs scope load + +**One run-level load** (`task="review"`, all project scopes + repo) shared by summary, code_review, doc, supply_chain, audit. Only the **scope** agent does its own load when model refinement runs. The other agents create a per-agent Prefrontal only for the `recall_memory` tool. + +### Facets + +Five facets answer different questions: + +| Facet | Question | Runs reflect? | Share | +|-------|----------|---------------|-------| +| `context` | What happened around this work? | Yes | 30% | +| `seen_before` | Have we met this before? | No (raw) | 20% | +| `decisions` | What was decided about it? | No (raw) | 20% | +| `patterns` | What keeps recurring? | No (raw) | 20% | +| `belief_changes` | What did we believe before, and what changed? | History only | 10% | + +Only the `context` facet runs the reflect loop; the others stay raw recall at MID budget. Facts are deduplicated across sections by id, then by exact text. + +### Reach + +`memory.prefrontal.recall.reach` (`local` | `org` | `bank`, default `org`): + +- **Local pool** (always): `project_scope:` for code_review and supply_chain; `repo:` for scope, summary and audit (summary and audit also OR in their scopes' `project_scope:` ids) +- **Remote pool** (`org`: `org:` AND NOT `repo:`; `bank`: NOT `repo:`): always raw facts, labelled with their source repo. Takes a fixed 25% share of `max_tokens` (and of `max_tool_tokens` for the tool); local facets split the rest. With `local` reach the local pool gets the whole budget. + +### `recall_memory` tool + +RLM agents (code_review, scope, supply_chain) get `async recall_memory(query: str, reach: str = "local") -> str`. A reach above the configured one is clamped; at most `max_tool_calls` calls per agent call (then `"recall limit reached"`); errors return `"memory unavailable"`. + +`reflects=0` disables the reflect loop, but the tool still works in raw-recall mode (`details.mode="recall"`). Only `max_tool_calls=0` (the **default**) turns it off. + +### Reflect loop tuning + +`memory.prefrontal.reflects` (env `MEMORY_PREFRONTAL_REFLECTS`, default `3`): + +- `0`: raw facts only — no LLM at read time, no briefings +- `N > 0`: both Prefrontal and briefing refreshes run at Hindsight's LOW budget (0.5× multiplier), with a doubled global cap (2 × N), so each gets exactly N iterations + +**Environment override**: if `HINDSIGHT_API_REFLECT_MAX_ITERATIONS` is set, it is used as-is (not doubled). + +**Hindsight LLM timeout/retry**: `llm.timeout` / `llm.retries` apply to all Hindsight calls. Operator overrides (precedence: env > codespy settings): + +- **Global**: `HINDSIGHT_API_LLM_TIMEOUT` / `HINDSIGHT_API_LLM_MAX_RETRIES` +- **Per-operation**: + - `HINDSIGHT_API_RETAIN_LLM_TIMEOUT` / `HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES` + - `HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT` / `HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES` + - `HINDSIGHT_API_REFLECT_LLM_TIMEOUT` / `HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES` + - `HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_TIMEOUT` / `HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_MAX_RETRIES` + +### Model + +`memory.prefrontal.recall.model` (env `MEMORY_RECALL_MODEL`) is the model Hindsight's reflect loop uses. Falls back to `memory.cerebral.retain.model`, then `llm.default_model`. Unused when `reflects = 0`. + +## Monitoring and cost + +### Logs + +Every recall is logged: + +``` +Prefrontal[review]: load status=ok 1 briefings, facets context=1 seen_before=3 ... remote=5 (512 tokens, reach=org, reflects=3) model=... calls=3 in=5120 out=410 cost=$0.0042 latency=6400ms maps=0 thoughts=256 tools=... +Prefrontal[code_review]: recall_memory reach=local status=ok mode=reflect model=... calls=2 in=2210 out=180 cost=$0.0017 latency=3100ms query='...' +``` + +### Episodic store + +One `recalls` row per recall, with PK `(bank_id, id UUID)`, `run_id`, `task`, and nullable `episode_id` (NULL for run-level loads). Index: `idx_recalls_run (bank_id, run_id)`. + +### Cost buckets + +| Bucket | Description | +|--------|-------------| +| `memory_distiller` | Distiller LLM calls | +| `memory_cartographer` | Cartographer LLM calls | +| `memory_retain` | Fact extraction (retain) | +| `memory_embeddings` | Embeddings during retain | +| `memory_recall` | Prefrontal reflect LLM calls | +| `memory_recall_embeddings` | Embeddings during recall | +| `memory_consolidation` | Consolidation LLM calls | +| `memory_mental_models` | Mental-model refresh LLM calls | +| `memory_other` | Uncategorized LLM calls | + +### Gating and failure + +Prefrontal is active for a signature only when its memory is enabled and Cerebral is available; otherwise the agent behaves exactly as before. Every failure in Prefrontal or mental-model upkeep logs a warning and degrades to less context, or to `""`. + +## Database schema + +Auto-created by `EpisodeStore.ensure_schema()` on first connect. ```mermaid erDiagram @@ -91,14 +281,14 @@ erDiagram banks { varchar(64) id PK - text description + text description "NULLABLE" } topics { varchar(64) bank_id PK, FK varchar(256) id PK varchar(64) type - text description + text description "NOT NULL" } episodes { @@ -122,11 +312,11 @@ erDiagram varchar(48) id PK int version PK "DEFAULT 1" varchar(32) type - text content "NULL for tombstones" + text content "NULLABLE" uuid episode_id FK int step "DEFAULT 0" varchar(8) op_type "ADD | REPLACE | DELETE | EVICT" - text previous_content + text previous_content "NULLABLE" int ordinal "DEFAULT 0" } @@ -148,10 +338,12 @@ erDiagram recalls { varchar(64) bank_id PK, FK - uuid episode_id PK, FK - int ordinal PK - varchar(8) kind "load | tool" + uuid id PK + varchar(48) run_id + varchar(64) task + uuid episode_id FK "NULLABLE" timestamptz timestamp + varchar(8) kind "load | tool" text query "DEFAULT ''" varchar(8) reach "local | org | bank" int reflects "DEFAULT 0" @@ -179,280 +371,27 @@ erDiagram topics ||--o{ observation_topics : "scopes" ``` -### Indexes - -| Index | Table | Definition | -|-------|-------|------------| -| `idx_topics_id_trgm` | topics | `GIN (id gin_trgm_ops)` — trigram fuzzy search | -| `idx_episodes_task_time` | episodes | `(bank_id, task, timestamp DESC)` | -| `idx_episode_topics_reverse` | episode_topics | `(bank_id, topic_id)` | -| `idx_observations_episode` | observations | `(bank_id, episode_id)` | -| `idx_observation_topics_reverse` | observation_topics | `(bank_id, topic_id)` | -| `idx_recalls_time` | recalls | `(bank_id, timestamp DESC)` | - -### Notes - +**Notes:** - All tables cascade-delete from `banks` -- `observations` is versioned: PK `(bank_id, id, version)` tracks ADD/REPLACE/DELETE/EVICT history per observation +- `observations` is versioned: PK `(bank_id, id, version)` tracks ADD/REPLACE/DELETE/EVICT history - `episode_topics` and `observation_topics` are M:N junction tables - `observation_topics.observation_occurrence` counts cumulative topic associations across all versions; `version_occurrence` counts within one version -- `recalls` is episodic-only (never retained into Cerebral); PK `(bank_id, episode_id, ordinal)` makes saves idempotent +- All columns except those marked "NULLABLE" are NOT NULL - Requires PostgreSQL extension: `pg_trgm` -## Reflection Pipeline - -After each agent run (at `end_episode()`): - -1. **Distiller** — Analyzes the agent's trajectory (head 60% + tail 40%, capped at `max_trajectory_tokens`) and proposes `CacheCandidate` observations for context memory -2. **Cartographer** — Takes candidates + current context memory, decides operations: - - `ADD` — Insert new observation - - `REPLACE` — Update existing observation with new knowledge - - `DELETE` — Remove outdated/irrelevant observation -3. **Eviction** — If memory exceeds `max_hippocampus_tokens`, oldest general observations are evicted first - -The Distiller also tags each existing context memory observation with an `ObservationTag`: - -- **`helpful`** — directly aided the agent; keep -- **`harmful`** — misled the agent or contradicted observations; remove -- **`neutral`** — present but unused this round; keep -- **`stale`** — no longer reflects the external context; remove - -These tags inform the Cartographer's edit decisions. - -## Semantic Memory: Cerebral and Prefrontal - -Three components, fully independent: - -| Component | Role | Reads | Writes | -|-----------|------|-------|--------| -| **Hippocampus** | Learns from the current episode. Reloads prior episodes (`load_context` → `initial_memory`) only so it does not relearn existing facts. | episodic store | episodic store | -| **Cerebral** | Retains Hippocampus episodes into the Hindsight bank, consolidates them into observations and keeps the mental models (briefings) fresh. | — | Hindsight bank | -| **Prefrontal** | Gives the agent useful prior knowledge so it does not rediscover it. | Hindsight bank | nothing | - -The reloaded Hippocampus episode is **never** passed to the agent, and -`prefrontal_memory` is never given to the Distiller or the Cartographer. -Prefrontal and Hippocampus never feed each other. - -### Data flow - -``` -agent call ──► Prefrontal.aload() / recall_memory ──reads──► Cerebral (Hindsight bank) - │ │ prefrontal_memory (read-only input) + recall_memory tool - │ └──► RecallRecord[] ──► Episode.recalls ──► episodic store (recalls table) - ▼ -trajectory ──► Hippocampus (Distiller → Cartographer) ──► episodic store - └──► Cerebral.retain_episode() (background save thread; recalls excluded) - ├─ retain_batch_async (fact extraction) - ├─ consolidation (inline, SyncTaskBackend) - └─ mental-model upkeep -``` - -### Tags and observation scopes - -Every retained item carries its `project_scope:` topic tags, `task:`, and -`repo:`/`org:`. `pull_request:`, `episode:`, and `run_id:` tags are -no longer added — they were not used for scoping or reach and bloated the tag set. - -Consolidation uses an explicit `observation_scopes` list: one -`[org:, repo:, project_scope:]` scope per `project_scope` topic of the episode, or -`[org:, repo:, project_scope:]` (the repo-root scope) when there is none. -The repo-root scope matches what `make_topic_id(repo, ".")` returns, so episodes without -a project scope consolidate into the same scope as a root-level PR. `task:` never -enters a scope, so facts about the same scope merge across tasks and runs, and a -correction made by one task reaches all of them. Task relevance is handled at read time -(the task-specific query ranks; raw facts keep their `task:` label). - -**Per-scope observation cap**: Hindsight supports `observation_scope_limits` to cap -observations per scope. codespy sets one rule: `[org:*, repo:*, project_scope:*]` -with limit `max_observations_per_scope` (default 100, -1 = unlimited, 0 = no new -observations). Scopes that hit the cap only allow UPDATE/DELETE; nothing is trimmed. - -Cerebral runs Hindsight with `SyncTaskBackend`: consolidation and mental-model refreshes -run inline inside `retain_episode`, on the background save thread. (Previously -`BrokerTaskBackend` only queued them for a worker codespy never started, so they never ran.) -The first retain after upgrading consolidates the whole backlog, which is slow and costs -LLM tokens; Cerebral logs when consolidation starts and ends. The process waits for -background saves (retain + consolidation) to complete before exiting. - -Old data has no `repo:`/`org:` tags and per-episode observations. It stays reachable -through `project_scope:` tags; briefings cover only new data. There is no backfill and -no automatic drop. - -### Mental models (briefings) - -Mental models are the abstraction layer ("when X happens, consider Y because Z"). -Cerebral keeps one per observation scope of each episode, plus one per repo -(`[org:, repo:]`), when `memory.prefrontal.reflects > 0` (briefings enabled): - -- id: `mm-` + first 32 hex chars of `sha1("|".join(sorted(tags)))` -- name: `Briefing: ` -- source query: *"What should an agent starting work here know so it does not rediscover it: structure and where things live, conventions, invariants and constants, dependencies and integrations, pitfalls and recurring problems, facts shown to be wrong."* -- `max_tokens = cerebral.mental_models.max_tokens`, trigger `refresh_after_consolidation` - -A missing model is created and refreshed once; consolidation keeps it fresh afterwards. -Prefrontal reads the briefings of the agent's scopes (and repo) with no LLM call; -missing models and models still holding the placeholder content are skipped. - -#### Trigger configuration - -Briefing triggers are configured automatically with these settings: - -| Trigger Field | Value | Description | -|-------------|-------|-------------| -| `mode` | `delta` | Only new facts since last refresh (with structured delta-ops call) | -| `exclude_mental_models` | `true` | Briefings exclude other briefings | -| `include_chunks` | `false` | Observation text only (no file chunks) | -| `reflect_search_observations_max_tokens` | `3000` | Down from 5000 default | -| `reflect_search_observations_include_entities` | `false` | Exclude entity metadata | -| `min_refresh_interval_seconds` | `0` | Configurable via `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS` | - -**Delta mode**: After the first full refresh, subsequent refreshes only process new facts since `last_memory_seen_at`. One structured delta-ops call edits the stored document. Falls back to full refresh when content is the placeholder or when the source query changed. Falls back to candidate markdown if the delta call fails. - -**Deferral**: When `min_refresh_interval_seconds > 0`, automatic refreshes inside the window are deferred (operation marked `cancelled`, not `failed`). The deferral is logged at INFO level. The next consolidation after the interval expires covers everything since `last_memory_seen_at`. - -**Quality trade-off**: The reduced caps (`include_chunks=False`, entities off, 3000-token observation budget) shrink evidence per briefing. Monitor briefing quality in the first runs after enabling. - -### Reach - -`memory.prefrontal.prefrontal_reach` (`local` | `org` | `bank`, default `org`): - -- **Local pool** (always): `project_scope:` for code_review, doc and - supply_chain; `repo:` for scope, summary and audit (summary and audit - also OR in their scopes' `project_scope:` ids). -- **Remote pool** (`org`: `org:` AND NOT `repo:`; `bank`: NOT `repo:`): - always raw facts, labelled with their source repo. It takes a fixed 25% share - of `max_prefrontal_tokens` (and of `max_prefrontal_tool_tokens` for the tool); - the local facets split the rest. With `local` reach the local pool gets the - whole budget. `bank` crosses organisations — opt-in only. - -### Pre-call context - -Before each call, the agent gets a read-only `prefrontal_memory` input: - -1. **Briefings** — one block per mental model. -2. **Five facets** — the semantic layer, which answers the messier questions a briefing - cannot. They always run, even when briefings exist: - - | Facet | Question | `fact_type` | Share | - |-------|----------|-------------|-------| - | `context` | What happened around this work? | experience, world | 30% | - | `seen_before` | Have we met this file, symbol, package or problem before? | world, observation | 20% | - | `decisions` | What was decided about it? | world, observation | 20% | - | `patterns` | What keeps recurring? | observation | 20% | - | `belief_changes` | What did we believe before, and what changed? | (history) | 10% | - - Facets 1–4 run concurrently (`question_date=now`). `belief_changes` reads the history - of the top 3 observations of facets 2–4 (2 entries each) and collects - `RETRACTED` / `supersedes:` facts — no recall, no embedding. Facts are deduplicated - across sections by id, then by exact text. -3. **Other repositories** — remote facts, "verify before relying on it". - -Empty sections are omitted; with nothing at all the input is `""`. The field tells the -agent the content may be stale or wrong and that issues must be verified with tools. - -### `reflects` - -`memory.prefrontal.reflects` (env `MEMORY_PREFRONTAL_REFLECTS`, default `3`): - -- `0`: raw facts only — no LLM at read time, no briefings. The recall_memory tool is - also disabled. -- `N > 0`: both Prefrontal and briefing refreshes run at Hindsight's LOW budget with - a doubled global cap (2 × N), so each gets exactly N iterations. The LOW budget - uses a 0.5× multiplier, so max(1, int(2N × 0.5)) = N iterations. - -**Environment override**: if `HINDSIGHT_API_REFLECT_MAX_ITERATIONS` is set, it is used -as-is (not doubled). Both Prefrontal and briefing refreshes then get half of that -value (with LOW budget). - -**Hindsight LLM timeout/retries**: `llm.timeout` / `llm.retries` apply to **all** -Hindsight calls — retain, consolidation, reflect, and mental-model refresh. - -Operator overrides (precedence: env > codespy settings): -- **Global**: `HINDSIGHT_API_LLM_TIMEOUT` / `HINDSIGHT_API_LLM_MAX_RETRIES` — affect all operations -- **Per-operation**: - - `HINDSIGHT_API_RETAIN_LLM_TIMEOUT` / `HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES` - - `HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT` / `HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES` - - `HINDSIGHT_API_REFLECT_LLM_TIMEOUT` / `HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES` (also sets reflect-specific defaults) - - `HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_TIMEOUT` / `HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_MAX_RETRIES` - -Per-operation env vars win automatically because Hindsight prefers non-None per-op values. - -**Monitoring**: the load INFO line includes `maps=N` (split synthesis map-call count), -`rewrite` (if a rewrite occurred), `thoughts=N` (reasoning tokens), and `tools=...` -(per-tool token sizes). A WARNING is logged when `maps > 0` (split synthesis detected) -or when LLM calls exceed `reflects + 1` (unexpected split synthesis or rewrite). - -### `recall_memory` tool - -The RLM agents (code_review, scope, supply_chain) get -`async recall_memory(query: str, reach: str = "local") -> str` for follow-up questions. -A reach above the configured one is clamped; at most `memory.prefrontal.recall.max_tool_calls` -calls per agent call (then `"recall limit reached"`); errors return `"memory unavailable"`. - -### Model - -`memory.prefrontal.recall.model` (env `MEMORY_RECALL_MODEL`, Action input -`memory-recall-model`) is the model Hindsight's reflect loop uses. It falls back to -`memory.cerebral.retain.model`, then `llm.default_model`. It is passed to `MemoryEngine` -as its reflect LLM, so it **also refreshes the mental models (briefings)** when -`cerebral.mental_models.model` is unset. It is unused when `reflects = 0`. - -### Monitoring recalls - -Every recall is logged and stored. - -- **Logs** — one INFO line per recall, without the recalled text: - ``` - Prefrontal[code_review]: load status=ok 1 briefings, facets context=1 seen_before=3 ... model=openai/gpt-4o-mini calls=3 in=5120 out=410 cost=$0.0042 latency=6400ms - Prefrontal[code_review]: recall_memory reach=local status=ok mode=reflect model=openai/gpt-4o-mini calls=2 in=2210 out=180 cost=$0.0017 latency=3100ms query='where is session refresh' - ``` -- **Episodic store** — one `recalls` row per recall, attached to the agent's episode. - `text` is exactly what the agent received. `status` is `ok`, `empty`, `limit` (tool cap - reached) or `error`, so an empty load and a failed load can be told apart. `details` - holds per-facet queries, counts and failures for loads, and requested reach, mode and - fact counts for tool calls. -- **Usage** — measured, not estimated: `input_tokens` counts LLM prompt tokens plus query - embeddings, `output_tokens` counts completions, and costs use `litellm.cost_per_token` - (0 for unpriced models). With `reflects = 0`, only embedding tokens are recorded. -- **Cost report** — Prefrontal reads are billed to the `memory_recall` bucket. - Mental-model refreshes during retain are billed to `memory_mental_models`. - Consolidation LLM calls are billed to `memory_consolidation`. -- Recalls are **not** retained into Cerebral and are never shown to the Distiller or the - Cartographer. When the episodic store is unavailable, recalls are only logged. +### SQL example ```sql -SELECT e.task, e.run_id, r.kind, r.status, r.model, r.llm_calls, +-- Run-level recalls (task='review', episode_id IS NULL) +SELECT r.task, r.run_id, r.kind, r.status, r.model, r.llm_calls, r.input_tokens, r.output_tokens, r.input_cost + r.output_cost AS cost, r.latency_ms, r.query, r.text -FROM recalls r JOIN episodes e ON e.bank_id = r.bank_id AND e.id = r.episode_id -ORDER BY r.timestamp DESC LIMIT 50; +FROM recalls r +WHERE r.bank_id = 'codespy' AND r.task = 'review' AND r.episode_id IS NULL +ORDER BY r.timestamp DESC +LIMIT 50; ``` -### Gating and failure - -Prefrontal is active for a signature only when its memory is enabled and Cerebral is -available; otherwise the agent behaves exactly as before. Every failure in Prefrontal or -in mental-model upkeep logs a warning and degrades to less context, or to `""`. - -### Cost and latency - -Every retain now runs consolidation (billed to `memory_consolidation`) and refreshes the touched -scopes' models (billed to `memory_mental_models`). `reflects > 0` adds a nested loop to -every agent call and tool call; those Prefrontal reads are billed to `memory_recall`. -Background saves take longer, and process exit waits on them. - -## Token Budgets - -| Budget | Env Var | Default | Purpose | -|--------|---------|---------|---------| -| Context memory | `MEMORY_MAX_HIPPOCAMPUS_TOKENS` | 16384 | Ceiling on persisted ContextMemory (re-sent every iteration) | -| Observation | `MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS` | 512 | Soft per-observation token limit (expressed to LLM, not truncated) | -| Trajectory | `MEMORY_MAX_TRAJECTORY_TOKENS` | 16384 | Head+tail cap on trajectory fed to Distiller | -| Question | `MEMORY_MAX_QUESTION_TOKENS` | 8192 | Cap on serialized inputs as reflection question | -| Compact trajectory | `MEMORY_COMPACT_TRAJECTORY` | `true` | Apply head+tail trajectory bounding before distillation | - -Observation capacity ≈ max_hippocampus_tokens / max_hippocampus_item_tokens (16384/512 = 32 observations) - ## Configuration ### Global Settings @@ -464,33 +403,43 @@ Observation capacity ≈ max_hippocampus_tokens / max_hippocampus_item_tokens (1 | `MEMORY_POSTGRES_USER` | `memory.postgres.user` | `postgres` | External PostgreSQL user | | `MEMORY_POSTGRES_PASSWORD` | `memory.postgres.password` | — | External PostgreSQL password | | `MEMORY_POSTGRES_DATABASE` | `memory.postgres.database` | `codespy` | External PostgreSQL database | -| `MEMORY_POSTGRES_SCHEMA` | `memory.postgres.schema` | `episodic` | PostgreSQL schema (search_path per memory type) | +| `MEMORY_POSTGRES_SCHEMA` | `memory.postgres.schema` | `episodic` | PostgreSQL schema for episodic store | | `MEMORY_PG0_NAME` | `memory.pg0.name` | `codespy` | pg0-embedded database name | | `MEMORY_PG0_PORT` | `memory.pg0.port` | auto | pg0-embedded port | | `MEMORY_PG0_DATA_DIR` | `memory.pg0.data_dir` | — | Custom data directory for pg0-embedded | | `MEMORY_BANK_ID` | `memory.bank_id` | `codespy` | Scopes all memory data | | `MEMORY_ENABLED` | `memory.enabled` | `false` | Enable memory globally (episodic + semantic) | -| `MEMORY_COMPACT_TRAJECTORY` | `memory.hippocampus.compact_trajectory` | `true` | Apply head+tail trajectory bounding before distillation | -### Cerebral Mental-Models Settings +### Hippocampus Settings | Env Var | YAML Path | Default | Description | |---------|-----------|---------|-------------| -| `MEMORY_MENTAL_MODELS_MODEL` | `memory.cerebral.mental_models.model` | `bedrock/converse/nvidia.nemotron-super-3-120b` | Briefing refresh model; unused when reflects=0 | -| `MEMORY_MENTAL_MODELS_MAX_TOKENS` | `memory.cerebral.mental_models.max_tokens` | `2048` | Briefing size | -| `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS` | `memory.cerebral.mental_models.min_refresh_seconds` | `0` | Minimum seconds between automatic briefing refreshes (0 = refresh after every consolidation; N = at most one automatic refresh per N seconds, nothing lost thanks to delta) | +| `MEMORY_COMPACT_TRAJECTORY` | `memory.hippocampus.compact_trajectory` | `true` | Apply head+tail trajectory bounding before distillation | +| `MEMORY_MAX_HIPPOCAMPUS_TOKENS` | `memory.hippocampus.max_hippocampus_tokens` | `16384` | Ceiling on persisted ContextMemory | +| `MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS` | `memory.hippocampus.max_hippocampus_item_tokens` | `512` | Soft per-observation token limit | +| `MEMORY_MAX_TRAJECTORY_TOKENS` | `memory.hippocampus.max_trajectory_tokens` | `16384` | Cap on trajectory fed to Distiller | +| `MEMORY_MAX_QUESTION_TOKENS` | `memory.hippocampus.max_question_tokens` | `8192` | Cap on serialized reflection inputs | -### Prefrontal Settings +### Cerebral Settings | Env Var | YAML Path | Default | Description | |---------|-----------|---------|-------------| -| `MEMORY_PREFRONTAL_REFLECTS` | `memory.prefrontal.reflects` | `3` | Reflect iterations; `0` = raw facts, no LLM, no briefings | +| `MEMORY_RETAIN_MODEL` | `memory.cerebral.retain.model` | Nemotron Super | Model for fact extraction | +| `MEMORY_RETAIN_CHUNK_SIZE` | `memory.cerebral.retain.chunk_size` | `12288` | Chars per extraction chunk (< 64000) | +| `MEMORY_CONSOLIDATION_MODEL` | `memory.cerebral.consolidation.model` | → retain | Model for consolidation | +| `MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE` | `memory.cerebral.consolidation.max_observations_per_scope` | `100` | Per-scope cap (-1=unlimited, 0=no new) | +| `MEMORY_MENTAL_MODELS_MODEL` | `memory.cerebral.mental_models.model` | Nemotron Super | Briefing refresh model | +| `MEMORY_MENTAL_MODELS_MAX_TOKENS` | `memory.cerebral.mental_models.max_tokens` | `2048` | Briefing size | +| `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS` | `memory.cerebral.mental_models.min_refresh_seconds` | `0` | Min seconds between refreshes | +| `MEMORY_EMBEDDINGS_MODEL` | `memory.cerebral.embeddings.model` | Auto-derived | Embeddings model | +| `MEMORY_EMBEDDINGS_MAX_INPUT_CHARS` | `memory.cerebral.embeddings.max_input_chars` | `null` | Max chars per embedding (null=auto, 0=off, N=cap) | -### Recall Settings +### Prefrontal Settings | Env Var | YAML Path | Default | Description | |---------|-----------|---------|-------------| -| `MEMORY_RECALL_MODEL` | `memory.prefrontal.recall.model` | `bedrock/converse/nvidia.nemotron-super-3-120b` | Reflect loop model; also mental-model fallback when unset; unused when reflects=0 | +| `MEMORY_PREFRONTAL_REFLECTS` | `memory.prefrontal.reflects` | `3` | Reflect iterations; 0 = raw facts, no LLM, no briefings | +| `MEMORY_RECALL_MODEL` | `memory.prefrontal.recall.model` | Nemotron Super | Reflect loop model; also mental-model fallback | | `MEMORY_RECALL_REACH` | `memory.prefrontal.recall.reach` | `org` | `local`, `org` or `bank` | | `MEMORY_RECALL_MAX_TOKENS` | `memory.prefrontal.recall.max_tokens` | `8192` | Pre-call context budget | | `MEMORY_RECALL_MAX_TOOL_TOKENS` | `memory.prefrontal.recall.max_tool_tokens` | `2048` | Tool result budget | @@ -503,18 +452,27 @@ Observation capacity ≈ max_hippocampus_tokens / max_hippocampus_item_tokens (1 | Distiller | `MEMORY_DISTILLER_{MODEL,REASONING_EFFORT,TEMPERATURE,MAX_TOKENS,MAX_ITERS,MAX_LLM_CALLS}` | `memory.hippocampus.distiller.*` | | Cartographer | `MEMORY_CARTOGRAPHER_{MODEL,REASONING_EFFORT,TEMPERATURE,MAX_TOKENS,MAX_ITERS,MAX_LLM_CALLS}` | `memory.hippocampus.cartographer.*` | +### Model fallback chains + +| Setting | Fallback chain | +|---------|----------------| +| `memory.cerebral.retain.model` | `DEFAULT_MODEL` | +| `memory.cerebral.consolidation.model` | `retain.model` → `DEFAULT_MODEL` | +| `memory.cerebral.mental_models.model` | `prefrontal.recall.model` → `retain.model` → `DEFAULT_MODEL` | +| `memory.prefrontal.recall.model` | `retain.model` → `DEFAULT_MODEL` | + +Every default is non-null (Nemotron), so a fallback only applies when a value is explicitly set to `null`. + ### Per-Signature Memory Overrides Each signature's `memory:` block in YAML (or `REVIEW__MEMORY_*` env vars): | Setting | Env Var Suffix | Description | |---------|---------------|-------------| -| enabled | `_MEMORY_ENABLED` | Enable/disable memory for this signature | +| `enabled` | `_MEMORY_ENABLED` | Enable/disable memory for this signature | Example: `REVIEW_CODE_REVIEW_MEMORY_ENABLED=true` -See [Configuration](configuration.md#recommended-model-strategy) for recommended reflection models. - ## Quick Start Enable memory for code review: @@ -555,7 +513,7 @@ MEMORY_CARTOGRAPHER_MODEL=anthropic/claude-sonnet-4-5-20250929 memory-distiller-model: 'anthropic/claude-haiku-4-5-20251001' memory-cartographer-model: 'anthropic/claude-haiku-4-5-20251001' # memory-retain-model: 'anthropic/claude-sonnet-4-5-20250929' - # memory-prefrontal-model: 'anthropic/claude-sonnet-4-5-20250929' + # memory-recall-model: 'anthropic/claude-sonnet-4-5-20250929' ``` > **Note:** pg0-embedded is included in the Docker image. For persistent memory @@ -563,4 +521,4 @@ MEMORY_CARTOGRAPHER_MODEL=anthropic/claude-sonnet-4-5-20250929 --- -[← Back to README](../README.md#documentation) + [← Back to README](../README.md#documentation) diff --git a/src/codespy/agents/memory/prefrontal/query.py b/src/codespy/agents/memory/prefrontal/query.py index b437901..84d7ccb 100644 --- a/src/codespy/agents/memory/prefrontal/query.py +++ b/src/codespy/agents/memory/prefrontal/query.py @@ -27,7 +27,7 @@ FACET_BELIEF_CHANGES, ) -# Share of memory.prefrontal.max_prefrontal_tokens per facet (sums to 1.0). +# Share of memory.prefrontal.recall.max_tokens per facet (sums to 1.0). SHARE_CONTEXT = 0.30 SHARE_SEEN_BEFORE = 0.20 SHARE_DECISIONS = 0.20 diff --git a/src/codespy/agents/review/code_review/agent.py b/src/codespy/agents/review/code_review/agent.py index 603cce0..d9f574f 100644 --- a/src/codespy/agents/review/code_review/agent.py +++ b/src/codespy/agents/review/code_review/agent.py @@ -273,9 +273,11 @@ async def _review_scope( tools, contexts = await self._create_tools(scope_root) try: repo_full_name = review_context.pr_context.repo_full_name + # Compute scope topic ID once, reused for Prefrontal and Hippocampus load + scope_topic_id = scope.topic(repo_full_name).id # Get Prefrontal for tool calls only (prefrontal_memory is already loaded by pipeline) pf = get_prefrontal( - self._settings, "code_review", repo_full_name, scope_topic_ids=None + self._settings, "code_review", repo_full_name, scope_topic_ids=[scope_topic_id] ) if self._settings.get_memory_enabled("code_review") else None sig = with_prefrontal_memory(CodeReviewSignature) if review_context.prefrontal_memory else CodeReviewSignature recall_tool = pf.recall_tool() if pf else None @@ -310,10 +312,9 @@ async def _review_scope( if self._settings.get_memory_enabled("code_review"): store = get_episode_store(self._settings) if store is not None: - topic_ids = [scope.topic(review_context.pr_context.repo_full_name).id] scope_initial_memory = store.load_context( task="code_review", - topic_ids=topic_ids, + topic_ids=[scope_topic_id], ) if scope_initial_memory: logger.info("Loaded prior code_review episode for scope %s", scope.subroot) @@ -327,7 +328,7 @@ async def _review_scope( f"{review_context.pr_context.summary}" ) pr_ctx = review_context.pr_context - topics = [scope.topic(pr.repo_full_name), pr_ctx.to_topic()] if pr else [] + topics = [scope.topic(pr_ctx.repo_full_name), pr_ctx.to_topic()] if pr else [] hippo = Hippocampus( task_name="code_review", budget=self._settings.get_memory_budget("code_review"), diff --git a/src/codespy/agents/review/supply_chain/agent.py b/src/codespy/agents/review/supply_chain/agent.py index 87e2516..e271853 100644 --- a/src/codespy/agents/review/supply_chain/agent.py +++ b/src/codespy/agents/review/supply_chain/agent.py @@ -371,9 +371,11 @@ async def _review_scope( # Combine scoped filesystem tools with shared OSV tools all_tools = scoped_tools + osv_tools repo_full_name = review_context.pr_context.repo_full_name + # Compute scope topic ID once, reused for Prefrontal and Hippocampus load + scope_topic_id = scope.topic(repo_full_name).id # Get Prefrontal for tool calls only (prefrontal_memory is already loaded by pipeline) pf = get_prefrontal( - self._settings, "supply_chain", repo_full_name, scope_topic_ids=None + self._settings, "supply_chain", repo_full_name, scope_topic_ids=[scope_topic_id] ) if self._settings.get_memory_enabled("supply_chain") else None sig = ( with_prefrontal_memory(SupplyChainSecuritySignature) @@ -415,10 +417,9 @@ async def _review_scope( if self._settings.get_memory_enabled("supply_chain"): store = get_episode_store(self._settings) if store is not None: - topic_ids = [scope.topic(review_context.pr_context.repo_full_name).id] scope_initial_memory = store.load_context( task="supply_chain", - topic_ids=topic_ids, + topic_ids=[scope_topic_id], ) if scope_initial_memory: logger.info("Loaded prior supply_chain episode for scope %s", scope.subroot) @@ -432,7 +433,7 @@ async def _review_scope( f"{review_context.pr_context.summary}" ) pr_ctx = review_context.pr_context - topics = [scope.topic(pr.repo_full_name), pr_ctx.to_topic()] if pr else [] + topics = [scope.topic(pr_ctx.repo_full_name), pr_ctx.to_topic()] if pr else [] hippo = Hippocampus( task_name="supply_chain", budget=self._settings.get_memory_budget("supply_chain"), diff --git a/tests/test_recall_tool_scope.py b/tests/test_recall_tool_scope.py new file mode 100644 index 0000000..c56a871 --- /dev/null +++ b/tests/test_recall_tool_scope.py @@ -0,0 +1,64 @@ +"""Tests for recall_memory tool scope handling in code_review and supply_chain agents. + +This tests the fix for: `recall_memory` with `reach=local` returned nothing for +code_review and supply_chain because they passed `scope_topic_ids=None` to +get_prefrontal(). The fix ensures they pass `scope_topic_ids=[scope.topic(repo).id]` +so local_tags() returns the project_scope tag. +""" + +import pytest + + +class TestLocalTagsWithScope: + """Unit tests for local_tags returning project_scope tag.""" + + def test_local_tags_returns_project_scope(self): + """local_tags with scope_id returns project_scope tag.""" + # Skip if prefrontal.reach not available (Hindsight not installed) + try: + from codespy.agents.memory.prefrontal.reach import local_tags + except ImportError: + pytest.skip("prefrontal.reach module not available") + + result = local_tags("github.com/o/r", ["scope123"], False) + assert "project_scope:scope123" in result + + def test_local_tags_without_scope_empty(self): + """local_tags with empty scope_ids returns empty list for non-repo scope.""" + try: + from codespy.agents.memory.prefrontal.reach import local_tags + except ImportError: + pytest.skip("prefrontal.reach module not available") + + result = local_tags("github.com/o/r", [], False) + assert result == [] + + +class TestAgentSourceCode: + """Verify agents pass scope_topic_ids to get_prefrontal by inspecting source. + + This checks that the agent code was updated to pass scope_topic_ids. + The actual code flow is: + 1. code_review agent: calls scope.topic(repo_full_name).id + 2. passes scope_topic_ids=[topic_id] to get_prefrontal() + 3. get_prefrontal -> Prefrontal() -> local_tags(scope_topic_ids) + 4. local_tags returns ["project_scope:"] + """ + + def test_code_review_agent_has_scope_topic_id_computation(self): + """Verify code_review/agent.py computes scope_topic_id before get_prefrontal.""" + with open("src/codespy/agents/review/code_review/agent.py") as f: + source = f.read() + # The fix adds: scope_topic_id = scope.topic(repo_full_name).id + assert "scope_topic_id = scope.topic(repo_full_name).id" in source + # And passes: scope_topic_ids=[scope_topic_id] + assert "scope_topic_ids=[scope_topic_id]" in source + + def test_supply_chain_agent_has_scope_topic_id_computation(self): + """Verify supply_chain/agent.py computes scope_topic_id before get_prefrontal.""" + with open("src/codespy/agents/review/supply_chain/agent.py") as f: + source = f.read() + # The fix adds: scope_topic_id = scope.topic(repo_full_name).id + assert "scope_topic_id = scope.topic(repo_full_name).id" in source + # And passes: scope_topic_ids=[scope_topic_id] + assert "scope_topic_ids=[scope_topic_id]" in source From 0d430152d3f86b28b87b8b8c8a51d80b08943c1a Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Sat, 3 Oct 2026 01:04:16 +0200 Subject: [PATCH 37/39] cost --- .env.example | 3 +-- CHANGELOG.md | 4 +++ action.yml | 8 +----- codespy.yaml | 3 +-- docs/configuration.md | 3 +-- docs/memory.md | 7 +++--- .../agents/memory/cerebral/cerebral.py | 2 +- src/codespy/config_memory.py | 25 +++++++++++-------- tests/test_cerebral.py | 6 ++--- tests/test_config_memory.py | 10 +++++--- 10 files changed, 35 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index e60c168..8e1202d 100644 --- a/.env.example +++ b/.env.example @@ -171,10 +171,9 @@ DEFAULT_MODEL=bedrock/converse/nvidia.nemotron-nano-3-30b # MEMORY_POSTGRES_USER=myuser # MEMORY_POSTGRES_PASSWORD=mypassword # MEMORY_POSTGRES_DATABASE=codespy -# MEMORY_POSTGRES_SCHEMA=episodic # Bank ID — nickname, username, email, or agent name scoping all memory data. -# When unset, defaults to "codespy". +# When unset, defaults to "codebase". # MEMORY_BANK_ID=my-agent # pg0 embedded settings (local dev only) diff --git a/CHANGELOG.md b/CHANGELOG.md index f60c16e..13a47ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Changed (Breaking) +- Removed `memory.postgres.schema` / `MEMORY_POSTGRES_SCHEMA` / action input `memory-postgres-schema`; the episodic schema is now hard-coded to `episodic` (like `semantic`) + ### Fixed - `recall_memory` with `reach=local` returned nothing for code_review and supply_chain; the tool now searches the reviewed project scope - Fixed "too many values to unpack" error in review statistics matrix (line 345 was iterating `severities` as tuples instead of enums) @@ -37,6 +40,7 @@ - Action `model` input is now optional (was `required: true`); when unset, codespy.yaml default applies - Action inputs no longer carry `default:` values; codespy.yaml applies instead - Setting a pinned unit to `null` (YAML `model: null` or env `VAR=null`) re-enables the fallback chain +- **Default `MEMORY_BANK_ID` changed from `codespy` to `codebase`** — set `MEMORY_BANK_ID=codespy` to keep using existing memory data ### Removed - Action input `memory-prefrontal-model` (was deprecated, never wired) diff --git a/action.yml b/action.yml index a8bd9ee..5e8e1a4 100644 --- a/action.yml +++ b/action.yml @@ -354,12 +354,8 @@ inputs: description: 'PostgreSQL database name. Default from codespy.yaml: codespy' required: false - memory-postgres-schema: - description: 'PostgreSQL schema / search_path. Default from codespy.yaml: episodic' - required: false - memory-bank-id: - description: 'Bank ID scoping all memory data (e.g., project name or agent name). Default from codespy.yaml: codespy' + description: 'Bank ID scoping all memory data (e.g., project name or agent name). Default from codespy.yaml: codebase' required: false memory-compact-trajectory: @@ -642,7 +638,6 @@ runs: MEMORY_POSTGRES_USER: ${{ inputs.memory-postgres-user }} MEMORY_POSTGRES_PASSWORD: ${{ inputs.memory-postgres-password }} MEMORY_POSTGRES_DATABASE: ${{ inputs.memory-postgres-database }} - MEMORY_POSTGRES_SCHEMA: ${{ inputs.memory-postgres-schema }} MEMORY_BANK_ID: ${{ inputs.memory-bank-id }} MEMORY_DISTILLER_MODEL: ${{ inputs.memory-distiller-model }} MEMORY_DISTILLER_REASONING_EFFORT: ${{ inputs.memory-distiller-reasoning-effort }} @@ -789,7 +784,6 @@ runs: [ -n "$MEMORY_POSTGRES_USER" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_POSTGRES_USER" [ -n "$MEMORY_POSTGRES_PASSWORD" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_POSTGRES_PASSWORD" [ -n "$MEMORY_POSTGRES_DATABASE" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_POSTGRES_DATABASE" - [ -n "$MEMORY_POSTGRES_SCHEMA" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_POSTGRES_SCHEMA" [ -n "$MEMORY_BANK_ID" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_BANK_ID" [ -n "$MEMORY_DISTILLER_MODEL" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_DISTILLER_MODEL" [ -n "$MEMORY_DISTILLER_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e MEMORY_DISTILLER_REASONING_EFFORT" diff --git a/codespy.yaml b/codespy.yaml index 55248c8..df7547e 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -99,7 +99,6 @@ memory: user: null # MEMORY_POSTGRES_USER password: null # MEMORY_POSTGRES_PASSWORD database: codespy # MEMORY_POSTGRES_DATABASE - schema: episodic # MEMORY_POSTGRES_SCHEMA # pg0-embedded settings pg0: @@ -109,7 +108,7 @@ memory: # Master switch enabled: false # MEMORY_ENABLED - bank_id: null # MEMORY_BANK_ID + bank_id: codebase # MEMORY_BANK_ID # Hippocampus (episodic memory) hippocampus: diff --git a/docs/configuration.md b/docs/configuration.md index 87c1502..4fc6c98 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -185,13 +185,12 @@ Master switch and connection: | Setting | Env Var | Default | Description | |---------|---------|---------|-------------| | Enabled | `MEMORY_ENABLED` | `false` | Enable memory globally | -| Bank ID | `MEMORY_BANK_ID` | `codespy` | Scopes all memory data | +| Bank ID | `MEMORY_BANK_ID` | `codebase` | Scopes all memory data | | PostgreSQL host | `MEMORY_POSTGRES_HOST` | — | External PostgreSQL host | | PostgreSQL port | `MEMORY_POSTGRES_PORT` | `5432` | PostgreSQL port | | PostgreSQL user | `MEMORY_POSTGRES_USER` | `postgres` | PostgreSQL user | | PostgreSQL password | `MEMORY_POSTGRES_PASSWORD` | — | PostgreSQL password | | PostgreSQL database | `MEMORY_POSTGRES_DATABASE` | `codespy` | PostgreSQL database | -| PostgreSQL schema | `MEMORY_POSTGRES_SCHEMA` | `episodic` | Schema for episodic store | | pg0 name | `MEMORY_PG0_NAME` | `codespy` | pg0-embedded database name | | pg0 port | `MEMORY_PG0_PORT` | auto | pg0-embedded port | | pg0 data dir | `MEMORY_PG0_DATA_DIR` | — | pg0-embedded data directory | diff --git a/docs/memory.md b/docs/memory.md index cb296d7..663c668 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -53,7 +53,7 @@ The run-level recall (`task="review"`, `episode_id=NULL`) covers all project sco A bank is the top-level data partition. All episodes, topics, observations, artifacts and recalls cascade-delete from a bank. -- Configured via `MEMORY_BANK_ID` (default: `codespy`) +- Configured via `MEMORY_BANK_ID` (default: `codebase`) - Typical values: service name, team name, org identifier - Changing the bank ID starts a fresh memory silo — no data carries over @@ -387,7 +387,7 @@ SELECT r.task, r.run_id, r.kind, r.status, r.model, r.llm_calls, r.input_tokens, r.output_tokens, r.input_cost + r.output_cost AS cost, r.latency_ms, r.query, r.text FROM recalls r -WHERE r.bank_id = 'codespy' AND r.task = 'review' AND r.episode_id IS NULL +WHERE r.bank_id = 'codebase' AND r.task = 'review' AND r.episode_id IS NULL ORDER BY r.timestamp DESC LIMIT 50; ``` @@ -403,11 +403,10 @@ LIMIT 50; | `MEMORY_POSTGRES_USER` | `memory.postgres.user` | `postgres` | External PostgreSQL user | | `MEMORY_POSTGRES_PASSWORD` | `memory.postgres.password` | — | External PostgreSQL password | | `MEMORY_POSTGRES_DATABASE` | `memory.postgres.database` | `codespy` | External PostgreSQL database | -| `MEMORY_POSTGRES_SCHEMA` | `memory.postgres.schema` | `episodic` | PostgreSQL schema for episodic store | | `MEMORY_PG0_NAME` | `memory.pg0.name` | `codespy` | pg0-embedded database name | | `MEMORY_PG0_PORT` | `memory.pg0.port` | auto | pg0-embedded port | | `MEMORY_PG0_DATA_DIR` | `memory.pg0.data_dir` | — | Custom data directory for pg0-embedded | -| `MEMORY_BANK_ID` | `memory.bank_id` | `codespy` | Scopes all memory data | +| `MEMORY_BANK_ID` | `memory.bank_id` | `codebase` | Scopes all memory data | | `MEMORY_ENABLED` | `memory.enabled` | `false` | Enable memory globally (episodic + semantic) | ### Hippocampus Settings diff --git a/src/codespy/agents/memory/cerebral/cerebral.py b/src/codespy/agents/memory/cerebral/cerebral.py index 3a1e247..9b28c03 100644 --- a/src/codespy/agents/memory/cerebral/cerebral.py +++ b/src/codespy/agents/memory/cerebral/cerebral.py @@ -333,7 +333,7 @@ def __init__( llm_model: str | None = None, llm_api_key: str | None = None, llm_base_url: str | None = None, - bank_id: str = "codespy", + bank_id: str = "codebase", embeddings_model: str = "openai/text-embedding-3-small", retain_chunk_size: int = 12288, mental_models: bool = True, diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 468e7cc..334a658 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -25,6 +25,12 @@ # its config is first built. Also bounds briefing refreshes (LOW budget). HINDSIGHT_REFLECT_MAX_CONTEXT_TOKENS_ENV = "HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS" +# Default bank ID for memory storage when not explicitly configured. +DEFAULT_BANK_ID = "codebase" + +# Fixed schema name for episodic memory (Hippocampus). +EPISODIC_SCHEMA = "episodic" + if TYPE_CHECKING: from codespy.agents.memory.cerebral import Cerebral from codespy.agents.memory.postgres import EpisodeStore @@ -202,17 +208,14 @@ class PostgresConfig(BaseModel): user: str | None = None password: str | None = None database: str = "codespy" - schema_name: str | None = Field( - default="episodic", alias="schema" - ) def build_uri(self) -> str | None: """Build a psycopg connection URI. Returns None when host is unset. - Schema is NOT included in the URI. Each memory store (EpisodeStore, - future SemanticStore) handles CREATE SCHEMA and SET search_path - itself, so multiple stores can share the same base URI while - targeting different schemas. + Each memory store (EpisodeStore for episodic, Cerebral for semantic) + handles its own schema internally and sets search_path accordingly. + The URI returned here is schema-less so stores can share the same + base connection while using different schemas. """ if not self.host: return None @@ -334,8 +337,8 @@ def get_episode_store(settings: Settings) -> EpisodeStore | None: return _store mem = settings.memory - bank_id = mem.bank_id or "codespy" - schema = mem.postgres.schema_name # "episodic" by default + bank_id = mem.bank_id or DEFAULT_BANK_ID + schema = EPISODIC_SCHEMA # Try external PostgreSQL first uri = mem.postgres.build_uri() @@ -413,7 +416,7 @@ def verify_memory_access(settings: Settings) -> tuple[bool, str]: except Exception as e: return False, f"Memory storage not accessible: {e}" - return True, f"Memory storage verified (PostgreSQL, bank={settings.memory.bank_id or 'codespy'})" + return True, f"Memory storage verified (PostgreSQL, bank={settings.memory.bank_id or DEFAULT_BANK_ID})" # Cached singleton Cerebral instance. @@ -526,7 +529,7 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: return None model, api_key, base_url = _cerebral_litellm_params(settings) - bank_id = settings.memory.bank_id or "codespy" + bank_id = settings.memory.bank_id or DEFAULT_BANK_ID # Choose embedding default based on the provider prefix provider_prefix = model.split("/", 1)[0] if "/" in model else "openai" embeddings_model = ( diff --git a/tests/test_cerebral.py b/tests/test_cerebral.py index c489d8b..bd32586 100644 --- a/tests/test_cerebral.py +++ b/tests/test_cerebral.py @@ -928,7 +928,7 @@ def async_engine(): ): setattr(engine, name, AsyncMock(return_value=None)) # Default get_bank_profile to return a profile so existing tests pass - engine.get_bank_profile.return_value = {"bank_id": "codespy"} + engine.get_bank_profile.return_value = {"bank_id": "codebase"} return engine @@ -1605,7 +1605,7 @@ def test_missing_not_cached_exists_becomes_true(self, cerebral_async, async_engi assert async_engine.get_bank_profile.await_count == 1 # Second call: bank now exists (e.g., retain_episode was called) - async_engine.get_bank_profile.return_value = {"bank_id": "codespy"} + async_engine.get_bank_profile.return_value = {"bank_id": "codebase"} # _bank_exists is still False because we didn't call _ensure_bank cerebral_async._bank_exists = False @@ -1621,7 +1621,7 @@ def test_exists_is_cached_no_duplicate_checks(self, cerebral_async, async_engine """Exists is cached: multiple arecalls only check once.""" import asyncio as _asyncio - async_engine.get_bank_profile.return_value = {"bank_id": "codespy"} + async_engine.get_bank_profile.return_value = {"bank_id": "codebase"} cerebral_async._bank_exists = False cerebral_async._missing_bank_logged = False diff --git a/tests/test_config_memory.py b/tests/test_config_memory.py index aaef1ce..75674c2 100644 --- a/tests/test_config_memory.py +++ b/tests/test_config_memory.py @@ -21,11 +21,14 @@ class TestGenerateBankId: """Tests for default bank_id behavior.""" - def test_default_bank_id_is_codespy(self): - """Bank ID defaults to 'codespy' when not set.""" + def test_default_bank_id_is_codebase(self): + """Bank ID defaults to 'codebase' when not set.""" + from codespy.config_memory import DEFAULT_BANK_ID + settings = MagicMock() settings.memory.bank_id = None - assert settings.memory.bank_id or "codespy" == "codespy" + assert DEFAULT_BANK_ID == "codebase" + assert (settings.memory.bank_id or DEFAULT_BANK_ID) == "codebase" class TestApplyMemoryEnvOverrides: @@ -121,7 +124,6 @@ def test_returns_none_when_no_config(self): settings = MagicMock() settings.memory.postgres = MagicMock() settings.memory.postgres.build_uri.return_value = None - settings.memory.postgres.schema_name = "episodic" settings.memory.pg0 = MagicMock() settings.memory.pg0.name = "codespy" settings.memory.pg0.port = None From 2cd6b8e12ea9406641a2886dcedf966478367866 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Sat, 3 Oct 2026 10:44:19 +0200 Subject: [PATCH 38/39] sematic --- .env.example | 11 +--- CHANGELOG.md | 1 + action.yml | 36 ----------- codespy.yaml | 12 ---- docs/memory.md | 15 +---- .../agents/memory/hippocampus/hippocampus.py | 2 +- src/codespy/agents/review/audit/agent.py | 58 ++++++++--------- .../agents/review/code_review/agent.py | 57 +++++++---------- src/codespy/agents/review/doc/agent.py | 63 ++++++++----------- src/codespy/agents/review/scope/agent.py | 59 ++++++++--------- src/codespy/agents/review/summary/agent.py | 31 ++++----- .../agents/review/supply_chain/agent.py | 63 ++++++++----------- src/codespy/config.py | 19 +----- src/codespy/config_dspy.py | 34 +--------- src/codespy/config_memory.py | 34 +++++----- src/codespy/workflows/review/pipeline.py | 10 +-- tests/test_prefrontal_signature.py | 8 +-- 17 files changed, 171 insertions(+), 342 deletions(-) diff --git a/.env.example b/.env.example index 8e1202d..1f46c6b 100644 --- a/.env.example +++ b/.env.example @@ -181,7 +181,7 @@ DEFAULT_MODEL=bedrock/converse/nvidia.nemotron-nano-3-30b # MEMORY_PG0_PORT=5433 # MEMORY_PG0_DATA_DIR=~/.cache/codespy/pg0 -# Memory master switch — overridable per-signature via REVIEW__MEMORY_ENABLED +# Memory master switch # MEMORY_ENABLED=false # Whether to compact trajectories (head+tail bounding) before distillation. @@ -298,26 +298,22 @@ DEFAULT_MODEL=bedrock/converse/nvidia.nemotron-nano-3-30b # - REASONING_EFFORT (minimal|low|medium|high) # - TEMPERATURE (float) # - MAX_TOKENS (integer) -# - MEMORY_ENABLED (true/false) # Examples: # REVIEW_CODE_REVIEW_ENABLED=true # REVIEW_CODE_REVIEW_MAX_ITERS=4 -# REVIEW_CODE_REVIEW_MODEL=anthropic/claude-sonnet-4-5-20250929 +# REVIEW_CODE_REVIEW_MODEL=anthropic/claude_sonnet-4-5-20250929 # REVIEW_CODE_REVIEW_REASONING_EFFORT=medium # REVIEW_CODE_REVIEW_TEMPERATURE=1 # REVIEW_CODE_REVIEW_MAX_TOKENS=32000 -# REVIEW_CODE_REVIEW_MEMORY_ENABLED=true # REVIEW_SUPPLY_CHAIN_ENABLED=true # REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED=false -# REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED=true # REVIEW_DOC_ENABLED=true # REVIEW_DOC_MAX_ITERS=2 # REVIEW_DOC_MAX_LLM_CALLS=4 # REVIEW_DOC_MODEL=bedrock/converse/global.anthropic.claude-opus-5-5 -# REVIEW_DOC_MEMORY_ENABLED=true # REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN=true @@ -325,14 +321,11 @@ DEFAULT_MODEL=bedrock/converse/nvidia.nemotron-nano-3-30b # REVIEW_SCOPE_MAX_ITERS=4 # REVIEW_SCOPE_REASONING_EFFORT=low # REVIEW_SCOPE_MAX_TOKENS=32000 -# REVIEW_SCOPE_MEMORY_ENABLED=true # REVIEW_SUMMARY_ENABLED=true # REVIEW_SUMMARY_MAX_ITERS=1 # REVIEW_SUMMARY_MAX_LLM_CALLS=2 # REVIEW_SUMMARY_MODEL=bedrock/converse/nvidia.nemotron-super-3-120b -# REVIEW_SUMMARY_MEMORY_ENABLED=true # REVIEW_AUDIT_ENABLED=true # REVIEW_AUDIT_MAX_ITERS=1 -# REVIEW_AUDIT_MEMORY_ENABLED=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 13a47ae..6f751c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ - **Default `MEMORY_BANK_ID` changed from `codespy` to `codebase`** — set `MEMORY_BANK_ID=codespy` to keep using existing memory data ### Removed +- **Per-signature memory overrides** — `review..memory.enabled` (YAML), `REVIEW__MEMORY_ENABLED` (env), and action inputs `scope-memory-enabled`, `code-review-memory-enabled`, `doc-memory-enabled`, `supply-chain-memory-enabled`, `summary-memory-enabled`, `audit-memory-enabled` are removed. Memory is now controlled only by the global `memory.enabled` (`MEMORY_ENABLED`, `memory-enabled`). - Action input `memory-prefrontal-model` (was deprecated, never wired) - Action env vars: `SCOPE_MEMORY_ENABLED`, `CODE_REVIEW_MEMORY_ENABLED`, `DOC_MEMORY_ENABLED`, `SUPPLY_CHAIN_MEMORY_ENABLED`, `SUMMARY_MEMORY_ENABLED`, `AUDIT_MEMORY_ENABLED`, `OUTPUT_FORMAT` - Dead code: `Settings.sync_llm_settings()` and `LLMConfig.sync_from_flat()` diff --git a/action.yml b/action.yml index 5e8e1a4..b43c252 100644 --- a/action.yml +++ b/action.yml @@ -144,10 +144,6 @@ inputs: description: 'Temperature for scope identification. Default from codespy.yaml: 1.0' required: false - scope-memory-enabled: - description: 'Enable memory for scope identification (overrides global memory-enabled). Default from codespy.yaml: null (use global)' - required: false - # ========================================== # SIGNATURE: code_review # ========================================== @@ -179,10 +175,6 @@ inputs: description: 'Temperature for code review. Default from codespy.yaml: 1.0' required: false - code-review-memory-enabled: - description: 'Enable memory for code review (overrides global memory-enabled). Default from codespy.yaml: null (use global)' - required: false - # ========================================== # SIGNATURE: doc # ========================================== @@ -214,10 +206,6 @@ inputs: description: 'Temperature for doc review. Default from codespy.yaml: 1.0' required: false - doc-memory-enabled: - description: 'Enable memory for doc review (overrides global memory-enabled). Default from codespy.yaml: null (use global)' - required: false - # ========================================== # SIGNATURE: supply_chain # ========================================== @@ -253,10 +241,6 @@ inputs: description: 'Scan all artifacts/manifests even if not modified in the PR (default: only scan changed). Default from codespy.yaml: false' required: false - supply-chain-memory-enabled: - description: 'Enable memory for supply chain (overrides global memory-enabled). Default from codespy.yaml: null (use global)' - required: false - # ========================================== # SIGNATURE: summary # ========================================== @@ -288,10 +272,6 @@ inputs: description: 'Temperature for summary. Default from codespy.yaml: 1.0' required: false - summary-memory-enabled: - description: 'Enable memory for summary (overrides global memory-enabled). Default from codespy.yaml: null (use global)' - required: false - # ========================================== # SIGNATURE: audit # ========================================== @@ -323,10 +303,6 @@ inputs: description: 'Temperature for audit. Default from codespy.yaml: 1.0' required: false - audit-memory-enabled: - description: 'Enable memory for audit (overrides global memory-enabled). Default from codespy.yaml: null (use global)' - required: false - # ========================================== # MEMORY (Hippocampus) # ========================================== @@ -581,7 +557,6 @@ runs: REVIEW_SCOPE_REASONING_EFFORT: ${{ inputs.scope-reasoning-effort }} REVIEW_SCOPE_TEMPERATURE: ${{ inputs.scope-temperature }} REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN: ${{ inputs.scope-skip-refinement-when-clean }} - REVIEW_SCOPE_MEMORY_ENABLED: ${{ inputs.scope-memory-enabled }} # Code review signature REVIEW_CODE_REVIEW_ENABLED: ${{ inputs.code-review-enabled }} @@ -590,7 +565,6 @@ runs: REVIEW_CODE_REVIEW_MAX_LLM_CALLS: ${{ inputs.code-review-max-llm-calls }} REVIEW_CODE_REVIEW_REASONING_EFFORT: ${{ inputs.code-review-reasoning-effort }} REVIEW_CODE_REVIEW_TEMPERATURE: ${{ inputs.code-review-temperature }} - REVIEW_CODE_REVIEW_MEMORY_ENABLED: ${{ inputs.code-review-memory-enabled }} # Doc review signature REVIEW_DOC_ENABLED: ${{ inputs.doc-enabled }} @@ -599,7 +573,6 @@ runs: REVIEW_DOC_MAX_LLM_CALLS: ${{ inputs.doc-max-llm-calls }} REVIEW_DOC_REASONING_EFFORT: ${{ inputs.doc-reasoning-effort }} REVIEW_DOC_TEMPERATURE: ${{ inputs.doc-temperature }} - REVIEW_DOC_MEMORY_ENABLED: ${{ inputs.doc-memory-enabled }} # Supply chain signature REVIEW_SUPPLY_CHAIN_ENABLED: ${{ inputs.supply-chain-enabled }} @@ -609,7 +582,6 @@ runs: REVIEW_SUPPLY_CHAIN_REASONING_EFFORT: ${{ inputs.supply-chain-reasoning-effort }} REVIEW_SUPPLY_CHAIN_TEMPERATURE: ${{ inputs.supply-chain-temperature }} REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED: ${{ inputs.supply-chain-scan-unchanged }} - REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED: ${{ inputs.supply-chain-memory-enabled }} # Summary signature REVIEW_SUMMARY_ENABLED: ${{ inputs.summary-enabled }} @@ -618,7 +590,6 @@ runs: REVIEW_SUMMARY_MAX_LLM_CALLS: ${{ inputs.summary-max-llm-calls }} REVIEW_SUMMARY_REASONING_EFFORT: ${{ inputs.summary-reasoning-effort }} REVIEW_SUMMARY_TEMPERATURE: ${{ inputs.summary-temperature }} - REVIEW_SUMMARY_MEMORY_ENABLED: ${{ inputs.summary-memory-enabled }} # Audit signature REVIEW_AUDIT_ENABLED: ${{ inputs.audit-enabled }} @@ -627,7 +598,6 @@ runs: REVIEW_AUDIT_MAX_LLM_CALLS: ${{ inputs.audit-max-llm-calls }} REVIEW_AUDIT_REASONING_EFFORT: ${{ inputs.audit-reasoning-effort }} REVIEW_AUDIT_TEMPERATURE: ${{ inputs.audit-temperature }} - REVIEW_AUDIT_MEMORY_ENABLED: ${{ inputs.audit-memory-enabled }} # Output settings (REVIEW_OUTPUT_FORMAT removed - only via CLI flag) @@ -715,7 +685,6 @@ runs: [ -n "$REVIEW_SCOPE_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_REASONING_EFFORT" [ -n "$REVIEW_SCOPE_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_TEMPERATURE" [ -n "$REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN" - [ -n "$REVIEW_SCOPE_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MEMORY_ENABLED" # Code review [ -n "$REVIEW_CODE_REVIEW_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_ENABLED" @@ -724,7 +693,6 @@ runs: [ -n "$REVIEW_CODE_REVIEW_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_MAX_LLM_CALLS" [ -n "$REVIEW_CODE_REVIEW_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_REASONING_EFFORT" [ -n "$REVIEW_CODE_REVIEW_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_TEMPERATURE" - [ -n "$REVIEW_CODE_REVIEW_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_CODE_REVIEW_MEMORY_ENABLED" # Per-signature max_tokens [ -n "$REVIEW_SCOPE_MAX_TOKENS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SCOPE_MAX_TOKENS" @@ -745,7 +713,6 @@ runs: [ -n "$REVIEW_DOC_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_MAX_LLM_CALLS" [ -n "$REVIEW_DOC_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_REASONING_EFFORT" [ -n "$REVIEW_DOC_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_TEMPERATURE" - [ -n "$REVIEW_DOC_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_DOC_MEMORY_ENABLED" # Supply chain [ -n "$REVIEW_SUPPLY_CHAIN_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_ENABLED" @@ -755,7 +722,6 @@ runs: [ -n "$REVIEW_SUPPLY_CHAIN_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_REASONING_EFFORT" [ -n "$REVIEW_SUPPLY_CHAIN_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_TEMPERATURE" [ -n "$REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED" - [ -n "$REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED" # Summary [ -n "$REVIEW_SUMMARY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_ENABLED" @@ -764,7 +730,6 @@ runs: [ -n "$REVIEW_SUMMARY_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_MAX_LLM_CALLS" [ -n "$REVIEW_SUMMARY_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_REASONING_EFFORT" [ -n "$REVIEW_SUMMARY_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_TEMPERATURE" - [ -n "$REVIEW_SUMMARY_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_SUMMARY_MEMORY_ENABLED" # Audit [ -n "$REVIEW_AUDIT_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_ENABLED" @@ -773,7 +738,6 @@ runs: [ -n "$REVIEW_AUDIT_MAX_LLM_CALLS" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_MAX_LLM_CALLS" [ -n "$REVIEW_AUDIT_REASONING_EFFORT" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_REASONING_EFFORT" [ -n "$REVIEW_AUDIT_TEMPERATURE" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_TEMPERATURE" - [ -n "$REVIEW_AUDIT_MEMORY_ENABLED" ] && DOCKER_ARGS="$DOCKER_ARGS -e REVIEW_AUDIT_MEMORY_ENABLED" # Output settings (REVIEW_OUTPUT_FORMAT removed - only via CLI flag) diff --git a/codespy.yaml b/codespy.yaml index df7547e..3e29285 100644 --- a/codespy.yaml +++ b/codespy.yaml @@ -204,8 +204,6 @@ review: temperature: null # REVIEW_SUPPLY_CHAIN_TEMPERATURE max_tokens: null # REVIEW_SUPPLY_CHAIN_MAX_TOKENS scan_unchanged: false # REVIEW_SUPPLY_CHAIN_SCAN_UNCHANGED - memory: - enabled: null # REVIEW_SUPPLY_CHAIN_MEMORY_ENABLED # Code Review code_review: @@ -216,8 +214,6 @@ review: reasoning_effort: null # REVIEW_CODE_REVIEW_REASONING_EFFORT temperature: null # REVIEW_CODE_REVIEW_TEMPERATURE max_tokens: null # REVIEW_CODE_REVIEW_MAX_TOKENS - memory: - enabled: null # REVIEW_CODE_REVIEW_MEMORY_ENABLED # Documentation Review doc: @@ -228,8 +224,6 @@ review: reasoning_effort: null # REVIEW_DOC_REASONING_EFFORT temperature: null # REVIEW_DOC_TEMPERATURE max_tokens: null # REVIEW_DOC_MAX_TOKENS - memory: - enabled: null # REVIEW_DOC_MEMORY_ENABLED # Scope Identification scope: @@ -241,8 +235,6 @@ review: temperature: null # REVIEW_SCOPE_TEMPERATURE max_tokens: null # REVIEW_SCOPE_MAX_TOKENS skip_refinement_when_clean: null # REVIEW_SCOPE_SKIP_REFINEMENT_WHEN_CLEAN - memory: - enabled: null # REVIEW_SCOPE_MEMORY_ENABLED # Summary summary: @@ -253,8 +245,6 @@ review: reasoning_effort: null # REVIEW_SUMMARY_REASONING_EFFORT temperature: null # REVIEW_SUMMARY_TEMPERATURE max_tokens: null # REVIEW_SUMMARY_MAX_TOKENS - memory: - enabled: null # REVIEW_SUMMARY_MEMORY_ENABLED # Audit audit: @@ -265,5 +255,3 @@ review: reasoning_effort: null # REVIEW_AUDIT_REASONING_EFFORT temperature: null # REVIEW_AUDIT_TEMPERATURE max_tokens: null # REVIEW_AUDIT_MAX_TOKENS - memory: - enabled: null # REVIEW_AUDIT_MEMORY_ENABLED diff --git a/docs/memory.md b/docs/memory.md index 663c668..feb56cc 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -462,24 +462,11 @@ LIMIT 50; Every default is non-null (Nemotron), so a fallback only applies when a value is explicitly set to `null`. -### Per-Signature Memory Overrides - -Each signature's `memory:` block in YAML (or `REVIEW__MEMORY_*` env vars): - -| Setting | Env Var Suffix | Description | -|---------|---------------|-------------| -| `enabled` | `_MEMORY_ENABLED` | Enable/disable memory for this signature | - -Example: `REVIEW_CODE_REVIEW_MEMORY_ENABLED=true` - ## Quick Start -Enable memory for code review: +Enable memory: ```bash MEMORY_ENABLED=true -# Or per-signature: -REVIEW_CODE_REVIEW_MEMORY_ENABLED=true -REVIEW_SUMMARY_MEMORY_ENABLED=true ``` Recommended mid-tier reflection model: diff --git a/src/codespy/agents/memory/hippocampus/hippocampus.py b/src/codespy/agents/memory/hippocampus/hippocampus.py index 47499c7..14f0340 100644 --- a/src/codespy/agents/memory/hippocampus/hippocampus.py +++ b/src/codespy/agents/memory/hippocampus/hippocampus.py @@ -109,7 +109,7 @@ def __init__( budget: The four token budgets bounding memory, as a :class:`MemoryBudget`. Defaults to ``MemoryBudget()`` — see that class for per-field guidance. Resolve one from configuration with - ``Settings.get_memory_budget(signature_name)``. + ``Settings.get_memory_budget()``. question: Pre-computed question string for the reflection "question". If set, this string is used directly as the Distiller question. If None, uses empty string (callers typically pass question at init). diff --git a/src/codespy/agents/review/audit/agent.py b/src/codespy/agents/review/audit/agent.py index a37e1eb..90f99f2 100644 --- a/src/codespy/agents/review/audit/agent.py +++ b/src/codespy/agents/review/audit/agent.py @@ -82,7 +82,8 @@ def _call_auditor( initial_memory: ContextMemory | None = None topic_ids: list[str] | None = None store = None - if self._settings.get_memory_enabled("audit") and scopes: + hippo: Hippocampus | None = None + if self._settings.memory.enabled and scopes: store = get_episode_store(self._settings) if store is not None: # Build topic_ids from scope topics @@ -98,29 +99,28 @@ def _call_auditor( logger.info("Loaded prior audit episode(s) into auditor memory") else: logger.info("No prior audit episode found") - - if self._settings.get_memory_enabled("audit") and store is not None: - # Build topics list for Hippocampus - scope_topics: list[Topic] = [] - for scope in scopes or []: - scope_topic = scope.topic(review_context.pr_context.repo_full_name) - if scope_topic: - scope_topics.append(scope_topic) - - hippo = Hippocampus( - task_name="audit", - budget=self._settings.get_memory_budget("audit"), - question=question, - run_id=run_id, - initial_memory=initial_memory, - topics=scope_topics if scope_topics else None, - ) - result = auditor( - pr_title=review_context.pr_context.pr_title, - summary=review_context.pr_context.summary, - all_issues=all_issues, - **pf_kwargs, - ) + # Build topics list for Hippocampus + scope_topics: list[Topic] = [] + for scope in scopes or []: + scope_topic = scope.topic(review_context.pr_context.repo_full_name) + if scope_topic: + scope_topics.append(scope_topic) + + hippo = Hippocampus( + task_name="audit", + budget=self._settings.get_memory_budget(), + question=question, + run_id=run_id, + initial_memory=initial_memory, + topics=scope_topics if scope_topics else None, + ) + result = auditor( + pr_title=review_context.pr_context.pr_title, + summary=review_context.pr_context.summary, + all_issues=all_issues, + **pf_kwargs, + ) + if hippo is not None: hippo.observe(result) _artifacts = { "audit": ( @@ -130,12 +130,6 @@ def _call_auditor( } return result, hippo, store, _artifacts else: - result = auditor( - pr_title=review_context.pr_context.pr_title, - summary=review_context.pr_context.summary, - all_issues=all_issues, - **pf_kwargs, - ) return result, None, None, None def forward( @@ -174,7 +168,7 @@ def forward( "audit", repo_full_name, scope_topic_ids=None, - ) if self._settings.get_memory_enabled("audit") else None + ) sig = with_prefrontal_memory(AuditSignature) if prefrontal_memory else AuditSignature auditor = ContextSafe( dspy.ChainOfThought(sig), @@ -198,8 +192,8 @@ def forward( ) # Background episode save (after SignatureContext closes) - cerebral = get_cerebral(self._settings) if hippo is not None and store is not None: + cerebral = get_cerebral(self._settings) _cerebral = cerebral def _persist(): try: diff --git a/src/codespy/agents/review/code_review/agent.py b/src/codespy/agents/review/code_review/agent.py index d9f574f..4a2a88d 100644 --- a/src/codespy/agents/review/code_review/agent.py +++ b/src/codespy/agents/review/code_review/agent.py @@ -278,7 +278,7 @@ async def _review_scope( # Get Prefrontal for tool calls only (prefrontal_memory is already loaded by pipeline) pf = get_prefrontal( self._settings, "code_review", repo_full_name, scope_topic_ids=[scope_topic_id] - ) if self._settings.get_memory_enabled("code_review") else None + ) sig = with_prefrontal_memory(CodeReviewSignature) if review_context.prefrontal_memory else CodeReviewSignature recall_tool = pf.recall_tool() if pf else None agent_tools = [*tools, recall_tool] if recall_tool else tools @@ -309,18 +309,17 @@ async def _review_scope( # Load own prior "code_review" episode for this scope scope_initial_memory: ContextMemory | None = None store = None - if self._settings.get_memory_enabled("code_review"): + if self._settings.memory.enabled: store = get_episode_store(self._settings) - if store is not None: - scope_initial_memory = store.load_context( - task="code_review", - topic_ids=[scope_topic_id], - ) - if scope_initial_memory: - logger.info("Loaded prior code_review episode for scope %s", scope.subroot) - else: - logger.info("No prior code_review episode for scope %s", scope.subroot) - if self._settings.get_memory_enabled("code_review") and store is not None: + if store is not None: + scope_initial_memory = store.load_context( + task="code_review", + topic_ids=[scope_topic_id], + ) + if scope_initial_memory: + logger.info("Loaded prior code_review episode for scope %s", scope.subroot) + else: + logger.info("No prior code_review episode for scope %s", scope.subroot) question = ( f"review code change of {scope.repo}: {scope.subroot}: " f"pull request {review_context.pr_context.pr_number} " @@ -331,23 +330,24 @@ async def _review_scope( topics = [scope.topic(pr_ctx.repo_full_name), pr_ctx.to_topic()] if pr else [] hippo = Hippocampus( task_name="code_review", - budget=self._settings.get_memory_budget("code_review"), + budget=self._settings.get_memory_budget(), question=question, run_id=run_id, initial_memory=scope_initial_memory, topics=topics, ) - result = await agent.acall( - scope=scoped, - categories=categories, - **pf_kwargs, - ) + result = await agent.acall( + scope=scoped, + categories=categories, + **pf_kwargs, + ) + issues = [ + issue + for issue in (result.issues or []) + if issue.confidence >= self._settings.review.min_confidence + ] + if hippo is not None: await hippo.aobserve(result) - issues = [ - issue - for issue in (result.issues or []) - if issue.confidence >= self._settings.review.min_confidence - ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} cerebral = get_cerebral(self._settings) @@ -364,17 +364,6 @@ def _persist( except Exception: logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="code-review-episode-save") - else: - result = await agent.acall( - scope=scoped, - categories=categories, - **pf_kwargs, - ) - issues = [ - issue - for issue in (result.issues or []) - if issue.confidence >= self._settings.review.min_confidence - ] restore_repo_paths(issues, scope.subroot) logger.debug(f" Scope {scope.subroot}: {len(issues)} code review issues") return issues diff --git a/src/codespy/agents/review/doc/agent.py b/src/codespy/agents/review/doc/agent.py index 7b99549..db00803 100644 --- a/src/codespy/agents/review/doc/agent.py +++ b/src/codespy/agents/review/doc/agent.py @@ -205,7 +205,7 @@ async def _review_scope( # Get Prefrontal for tool calls only (prefrontal_memory is already loaded by pipeline) pf = get_prefrontal( self._settings, "doc", repo_full_name, scope_topic_ids=None - ) if self._settings.get_memory_enabled("doc") else None + ) sig = with_prefrontal_memory(DocReviewSignature) if review_context.prefrontal_memory else DocReviewSignature reviewer = ContextSafe( dspy.ChainOfThought(sig), @@ -227,19 +227,18 @@ async def _review_scope( # Load own prior "doc" episode for this scope scope_initial_memory: ContextMemory | None = None store = None - if self._settings.get_memory_enabled("doc"): + if self._settings.memory.enabled: store = get_episode_store(self._settings) - if store is not None: - topic_ids = [scope.topic(review_context.pr_context.repo_full_name).id] - scope_initial_memory = store.load_context( - task="doc", - topic_ids=topic_ids, - ) - if scope_initial_memory: - logger.info("Loaded prior doc episode for scope %s", scope.subroot) - else: - logger.info("No prior doc episode for scope %s", scope.subroot) - if self._settings.get_memory_enabled("doc") and store is not None: + if store is not None: + topic_ids = [scope.topic(review_context.pr_context.repo_full_name).id] + scope_initial_memory = store.load_context( + task="doc", + topic_ids=topic_ids, + ) + if scope_initial_memory: + logger.info("Loaded prior doc episode for scope %s", scope.subroot) + else: + logger.info("No prior doc episode for scope %s", scope.subroot) question = ( f"review documentation of {scope.repo}: {scope.subroot}: " f"pull request {review_context.pr_context.pr_number} " @@ -250,24 +249,25 @@ async def _review_scope( topics = [scope.topic(pr.repo_full_name), pr_ctx.to_topic()] if pr else [] hippo = Hippocampus( task_name="doc", - budget=self._settings.get_memory_budget("doc"), + budget=self._settings.get_memory_budget(), question=question, run_id=run_id, initial_memory=scope_initial_memory, topics=topics, ) - result = await reviewer.aforward( - patches=patches, - documentation=documentation, - categories=[IssueCategory.DOCUMENTATION], - **pf_kwargs, - ) + result = await reviewer.acall( + patches=patches, + documentation=documentation, + categories=[IssueCategory.DOCUMENTATION], + **pf_kwargs, + ) + issues = [ + issue + for issue in (result.issues or []) + if issue.confidence >= self._settings.review.min_confidence + ] + if hippo is not None: await hippo.aobserve(result) - issues = [ - issue - for issue in (result.issues or []) - if issue.confidence >= self._settings.review.min_confidence - ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} cerebral = get_cerebral(self._settings) @@ -284,19 +284,6 @@ def _persist( except Exception: logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="doc-episode-save") - else: - result = await asyncio.to_thread( - reviewer, - patches=patches, - documentation=documentation, - categories=[IssueCategory.DOCUMENTATION], - **pf_kwargs, - ) - issues = [ - issue - for issue in (result.issues or []) - if issue.confidence >= self._settings.review.min_confidence - ] restore_repo_paths(issues, scope.subroot) logger.debug(f" Scope {scope.subroot}: {len(issues)} doc issues") return issues diff --git a/src/codespy/agents/review/scope/agent.py b/src/codespy/agents/review/scope/agent.py index 7ea7e48..1e1a85a 100644 --- a/src/codespy/agents/review/scope/agent.py +++ b/src/codespy/agents/review/scope/agent.py @@ -1075,6 +1075,7 @@ async def _refine_scopes( rlm_threshold=self._settings.get_rlm_threshold("react"), ) hippo: Hippocampus | None = None + store = None async with SignatureContext("scope", self._cost_tracker): # Prefrontal: prior knowledge from Cerebral (never given to Hippocampus) @@ -1093,7 +1094,10 @@ async def _refine_scopes( pf_kwargs["prefrontal_memory"] = scope_pf_text self.prefrontal_memory = scope_pf_text self.prefrontal_sections = pf.last_sections - if self._settings.get_memory_enabled("scope"): + if self._settings.memory.enabled: + store = get_episode_store(self._settings) + scope_initial_memory: ContextMemory | None = None + if store is not None: question = ( f"refine scopes of {review_context.pr_context.repo_slug}: " f"PR #{review_context.pr_context.pr_number} " @@ -1101,45 +1105,34 @@ async def _refine_scopes( f"{review_context.pr_context.summary}" ) # Scope resolver loads its own prior episodes (no memory inheritance) - store = get_episode_store(self._settings) - scope_initial_memory: ContextMemory | None = None - if store is not None: - # Load by repo prefix — matches any scope-level topic - # (e.g. 'khezen/codespy' matches 'khezen/codespy/codespy-ai') - repo_topic_id = pr.repo_full_name - scope_initial_memory = store.load_context( - task="scope", - topic_prefix=repo_topic_id, - ) - if scope_initial_memory: - logger.info("Loaded prior scope episode for %s", repo_topic_id) - else: - logger.info("No prior scope episode for %s", repo_topic_id) + # Load by repo prefix — matches any scope-level topic + # (e.g., 'khezen/codespy' matches 'khezen/codespy/codespy-ai') + repo_topic_id = pr.repo_full_name + scope_initial_memory = store.load_context( + task="scope", + topic_prefix=repo_topic_id, + ) + if scope_initial_memory: + logger.info("Loaded prior scope episode for %s", repo_topic_id) + else: + logger.info("No prior scope episode for %s", repo_topic_id) hippo = Hippocampus( task_name="scope", - budget=self._settings.get_memory_budget("scope"), + budget=self._settings.get_memory_budget(), question=question, run_id=run_id, initial_memory=scope_initial_memory, ) - result = await agent.acall( - candidates=candidates_str, - orphan_files=[f.filename for f in orphans], - pr_title=pr.title or "No title", - pr_description=pr.body or "No description", - project_instructions=project_instructions, - **pf_kwargs, - ) + result = await agent.acall( + candidates=candidates_str, + orphan_files=[f.filename for f in orphans], + pr_title=pr.title or "No title", + pr_description=pr.body or "No description", + project_instructions=project_instructions, + **pf_kwargs, + ) + if hippo is not None: await hippo.aobserve(result) - else: - result = await agent.acall( - candidates=candidates_str, - orphan_files=[f.filename for f in orphans], - pr_title=pr.title or "No title", - pr_description=pr.body or "No description", - project_instructions=project_instructions, - **pf_kwargs, - ) # Collect all changed files (from scopes + orphans) all_files = [f for s in scopes for f in s.changed_files] + orphans diff --git a/src/codespy/agents/review/summary/agent.py b/src/codespy/agents/review/summary/agent.py index 86b3b4a..550acba 100644 --- a/src/codespy/agents/review/summary/agent.py +++ b/src/codespy/agents/review/summary/agent.py @@ -81,7 +81,7 @@ def forward( initial_memory: ContextMemory | None = None store = None topic_ids: list[str] | None = None - if self._settings.get_memory_enabled("summary") and scopes: + if self._settings.memory.enabled and scopes: store = get_episode_store(self._settings) if store is not None: # Build topic_ids from scope topics @@ -106,7 +106,7 @@ def forward( repo_full_name, scope_topic_ids=None, include_repo=False, - ) if self._settings.get_memory_enabled("summary") else None + ) sig = with_prefrontal_memory(PRSummarySignature) if prefrontal_memory else PRSummarySignature summarizer = ContextSafe( dspy.ChainOfThought(sig), @@ -126,7 +126,7 @@ def forward( pf_kwargs: dict[str, str] = {} if prefrontal_memory: pf_kwargs["prefrontal_memory"] = prefrontal_memory - if self._settings.get_memory_enabled("summary") and store is not None: + if store is not None: # Build topics list for Hippocampus scope_topics: list[Topic] = [] for scope in scopes or []: @@ -136,19 +136,20 @@ def forward( hippo = Hippocampus( task_name="summary", - budget=self._settings.get_memory_budget("summary"), + budget=self._settings.get_memory_budget(), question=question, run_id=run_id, initial_memory=initial_memory, topics=scope_topics if scope_topics else topics, ) - result = summarizer( - pr_title=pr_context.pr_title, - pr_description=pr_context.pr_description, - changed_file_paths=changed_file_paths, - patches=patches, - **pf_kwargs, - ) + result = summarizer( + pr_title=pr_context.pr_title, + pr_description=pr_context.pr_description, + changed_file_paths=changed_file_paths, + patches=patches, + **pf_kwargs, + ) + if hippo is not None: hippo.observe(result) # Fire-and-forget episode save _summary_text = result.summary @@ -171,14 +172,6 @@ def _persist(): except Exception: logger.warning("Background summary cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="summary-episode-save") - else: - result = summarizer( - pr_title=pr_context.pr_title, - pr_description=pr_context.pr_description, - changed_file_paths=changed_file_paths, - patches=patches, - **pf_kwargs, - ) logger.info(f"PR summary: {result.summary[:80]}...") return result.summary diff --git a/src/codespy/agents/review/supply_chain/agent.py b/src/codespy/agents/review/supply_chain/agent.py index e271853..265efc8 100644 --- a/src/codespy/agents/review/supply_chain/agent.py +++ b/src/codespy/agents/review/supply_chain/agent.py @@ -376,7 +376,7 @@ async def _review_scope( # Get Prefrontal for tool calls only (prefrontal_memory is already loaded by pipeline) pf = get_prefrontal( self._settings, "supply_chain", repo_full_name, scope_topic_ids=[scope_topic_id] - ) if self._settings.get_memory_enabled("supply_chain") else None + ) sig = ( with_prefrontal_memory(SupplyChainSecuritySignature) if review_context.prefrontal_memory @@ -414,18 +414,17 @@ async def _review_scope( # Load own prior "supply_chain" episode for this scope scope_initial_memory: ContextMemory | None = None store = None - if self._settings.get_memory_enabled("supply_chain"): + if self._settings.memory.enabled: store = get_episode_store(self._settings) - if store is not None: - scope_initial_memory = store.load_context( - task="supply_chain", - topic_ids=[scope_topic_id], - ) - if scope_initial_memory: - logger.info("Loaded prior supply_chain episode for scope %s", scope.subroot) - else: - logger.info("No prior supply_chain episode for scope %s", scope.subroot) - if self._settings.get_memory_enabled("supply_chain") and store is not None: + if store is not None: + scope_initial_memory = store.load_context( + task="supply_chain", + topic_ids=[scope_topic_id], + ) + if scope_initial_memory: + logger.info("Loaded prior supply_chain episode for scope %s", scope.subroot) + else: + logger.info("No prior supply_chain episode for scope %s", scope.subroot) question = ( f"review supply chain of {scope.repo}: {scope.subroot}: " f"pull request {review_context.pr_context.pr_number} " @@ -436,25 +435,26 @@ async def _review_scope( topics = [scope.topic(pr_ctx.repo_full_name), pr_ctx.to_topic()] if pr else [] hippo = Hippocampus( task_name="supply_chain", - budget=self._settings.get_memory_budget("supply_chain"), + budget=self._settings.get_memory_budget(), question=question, run_id=run_id, initial_memory=scope_initial_memory, topics=topics, ) - result = await supply_chain_agent.acall( - manifest_path=manifest_path, - lock_file_path=lock_file_path, - package_manager=package_manager, - category=IssueCategory.SECURITY, - **pf_kwargs, - ) + result = await supply_chain_agent.acall( + manifest_path=manifest_path, + lock_file_path=lock_file_path, + package_manager=package_manager, + category=IssueCategory.SECURITY, + **pf_kwargs, + ) + issues = [ + issue + for issue in result.issues + if issue.confidence >= self._settings.review.min_confidence + ] + if hippo is not None: await hippo.aobserve(result) - issues = [ - issue - for issue in result.issues - if issue.confidence >= self._settings.review.min_confidence - ] # Fire-and-forget background episode save _artifacts = {"review": issues_to_markdown(issues)} cerebral = get_cerebral(self._settings) @@ -471,19 +471,6 @@ def _persist( except Exception: logger.warning("Background cerebral retain failed", exc_info=True) submit_episode_save(_persist, name="supply-chain-episode-save") - else: - result = await supply_chain_agent.acall( - manifest_path=manifest_path, - lock_file_path=lock_file_path, - package_manager=package_manager, - category=IssueCategory.SECURITY, - **pf_kwargs, - ) - issues = [ - issue - for issue in result.issues - if issue.confidence >= self._settings.review.min_confidence - ] # Restore repo-root-relative paths in reported issues restore_repo_paths(issues, scope.subroot) logger.debug( diff --git a/src/codespy/config.py b/src/codespy/config.py index 66a0f6f..b3fd213 100644 --- a/src/codespy/config.py +++ b/src/codespy/config.py @@ -39,9 +39,6 @@ ) if TYPE_CHECKING: - # Imported lazily inside get_memory_budget(): importing this at module level - # pulls in codespy.agents, whose __init__ imports dspy_config, which imports - # this module — a circular import that breaks every entrypoint. from codespy.agents.memory.hippocampus.budget import MemoryBudget @@ -260,20 +257,10 @@ def get_scan_unchanged(self, signature_name: str) -> bool: config = self.get_signature_config(signature_name) return config.scan_unchanged if config.scan_unchanged is not None else False - # Helper methods for per-signature memory config (Hippocampus) - def get_memory_enabled(self, signature_name: str) -> bool: - """Whether Hippocampus memory is enabled for a signature. + def get_memory_budget(self) -> "MemoryBudget": + """Resolve the ``MemoryBudget`` for memory operations. - Per-signature ``memory.enabled`` overrides ``memory.enabled``. - """ - config = self.get_signature_config(signature_name).memory - return config.enabled if config.enabled is not None else self.memory.enabled - - def get_memory_budget(self, signature_name: str) -> "MemoryBudget": - """Resolve the ``MemoryBudget`` for a signature. - - Token budgets are global (``memory.hippocampus.*``); only ``enabled`` - supports per-signature overrides. + Token budgets are global (``memory.hippocampus.*``). """ from codespy.agents.memory.hippocampus.budget import MemoryBudget diff --git a/src/codespy/config_dspy.py b/src/codespy/config_dspy.py index 3ea2bb0..f302125 100644 --- a/src/codespy/config_dspy.py +++ b/src/codespy/config_dspy.py @@ -1,7 +1,7 @@ """DSPy signatures configuration and environment variable handling.""" import logging -from typing import Any, Literal +from typing import Literal from pydantic import BaseModel, Field @@ -31,16 +31,6 @@ class RLMFallbackConfig(BaseModel): predict_threshold: float = Field(default=0.50, ge=0.0, le=1.0) -class MemorySignatureConfig(BaseModel): - """Per-signature Hippocampus memory overrides. - - All fields are optional — ``None`` means "use the global memory default" - (see ``codespy.config_memory.MemoryConfig``). - """ - - enabled: bool | None = None # _MEMORY_ENABLED - - class SignatureConfig(BaseModel): """Configuration for a single signature.""" @@ -54,8 +44,6 @@ class SignatureConfig(BaseModel): scan_unchanged: bool | None = None # For supply_chain: scan unmodified artifacts/manifests skip_refinement_when_clean: bool | None = None # For scope: skip LLM refinement when 1 scope, 0 orphans - memory: MemorySignatureConfig = Field(default_factory=MemorySignatureConfig) - class ReviewConfig(BaseModel): """Configuration for the review subsystem. @@ -118,23 +106,3 @@ def signatures(self) -> dict[str, SignatureConfig]: "cache_dir", "excluded_directories", } - -# Create uppercase prefixes for matching (e.g., "CODE_REVIEW_", "SUPPLY_CHAIN_") -SIGNATURE_PREFIXES = {name.upper() + "_": name for name in SIGNATURE_NAMES} - -# Known signature settings for validation, derived from the models so the env -# var routing can never drift from the declared fields. ``memory`` is excluded -# because it is nested and routed via _MEMORY_ instead. -SIGNATURE_SETTINGS = set(SignatureConfig.model_fields) - {"memory"} - -# Known per-signature memory settings, routed via _MEMORY_ -MEMORY_SIGNATURE_SETTINGS = set(MemorySignatureConfig.model_fields) - - -# Env var name (without RLM_FALLBACK_ prefix) -> RLMFallbackConfig field name. -RLM_FALLBACK_ENV_SETTINGS = { - "ENABLED": "enabled", - "REACT_THRESHOLD": "react_threshold", - "CHAIN_OF_THOUGHT_THRESHOLD": "chain_of_thought_threshold", - "PREDICT_THRESHOLD": "predict_threshold", -} diff --git a/src/codespy/config_memory.py b/src/codespy/config_memory.py index 334a658..1bf77b8 100644 --- a/src/codespy/config_memory.py +++ b/src/codespy/config_memory.py @@ -37,6 +37,9 @@ from codespy.agents.memory.prefrontal import Prefrontal from codespy.config import Settings +# Known signature names for memory gating checks +from codespy.config_dspy import SIGNATURE_NAMES + logger = logging.getLogger(__name__) @@ -238,7 +241,7 @@ class MemoryConfig(BaseModel): """Global memory (Hippocampus + Cerebral) configuration. Controls where episodes are persisted and the memory knob applied to - every agent. Per-signature ``memory:`` blocks override ``enabled``. + every agent. """ # PostgreSQL connection settings @@ -246,7 +249,7 @@ class MemoryConfig(BaseModel): pg0: Pg0Config = Field(default_factory=Pg0Config) bank_id: str | None = None - # Master switch — overridable per-signature via review..memory.enabled + # Master switch enabled: bool = False # Hippocampus (episodic memory) configuration @@ -395,12 +398,9 @@ def verify_memory_access(settings: Settings) -> tuple[bool, str]: Tuple of (success, message). Success is True when memory is disabled (no active signatures use it) or when the storage backend responds. """ - from codespy.config_dspy import SIGNATURE_NAMES - - # Skip if no enabled signature uses memory - if not any( - settings.is_signature_enabled(sig) and settings.get_memory_enabled(sig) - for sig in SIGNATURE_NAMES + # Skip if memory disabled or no enabled signature uses memory + if not settings.memory.enabled or not any( + settings.is_signature_enabled(sig) for sig in SIGNATURE_NAMES ): return True, "Memory disabled — skipping storage check" @@ -481,7 +481,7 @@ def get_cerebral(settings: "Settings") -> "Cerebral" | None: """Return the cached Cerebral instance, or None if unavailable. Cerebral activates unconditionally (like ``get_episode_store``). - Agent-level ``get_memory_enabled(sig)`` handles per-signature gating. + Agent-level ``settings.memory.enabled`` handles gating. LLM parameters are auto-derived from the ``cerebral.retain`` config model string and ``settings.llm`` credentials. @@ -816,12 +816,12 @@ def get_prefrontal( ) -> Prefrontal | None: """Return a Prefrontal for one agent call, or None when inactive. - Active only when ``get_memory_enabled(task_name)`` is true, a repo is + Active only when ``settings.memory.enabled`` is true, a repo is known and ``get_cerebral`` returns a Cerebral. Otherwise the agent behaves exactly as without Prefrontal. Never raises. """ try: - if not settings.get_memory_enabled(task_name) or not repo_full_name: + if not settings.memory.enabled or not repo_full_name: return None cerebral = get_cerebral(settings) if cerebral is None: @@ -853,10 +853,11 @@ def get_run_prefrontal( """Return a Prefrontal for the shared run-level recall, or None when inactive. Active only when: + - Memory is enabled globally (settings.memory.enabled) - A repo is known - Cerebral is available - At least one consumer (summary, code_review, doc, supply_chain, audit) - has both is_signature_enabled and get_memory_enabled True + has is_signature_enabled True When active, returns a Prefrontal with task_name="review", include_repo=True, and all scope_topic_ids. The run-level recall is shared by all consumer agents. @@ -864,16 +865,13 @@ def get_run_prefrontal( Never raises (same soft-fail behavior as get_prefrontal). """ try: - if not repo_full_name: + if not settings.memory.enabled or not repo_full_name: return None # Check if any consumer has memory enabled - from codespy.config_dspy import SIGNATURE_NAMES - has_memory_consumer = any( - settings.is_signature_enabled(sig) and settings.get_memory_enabled(sig) - for sig in SIGNATURE_NAMES - if sig in _RUN_PREFRONTAL_CONSUMERS + settings.is_signature_enabled(sig) + for sig in _RUN_PREFRONTAL_CONSUMERS ) if not has_memory_consumer: return None diff --git a/src/codespy/workflows/review/pipeline.py b/src/codespy/workflows/review/pipeline.py index 89bfbba..3983cce 100644 --- a/src/codespy/workflows/review/pipeline.py +++ b/src/codespy/workflows/review/pipeline.py @@ -417,15 +417,15 @@ def _consolidate_run(self, run_id: str) -> None: """Trigger one consolidation per run after all episode saves complete. Consolidation runs only when: + - Memory is enabled globally - Cerebral is available - - At least one memory-enabled signature ran in this run + - At least one signature is enabled """ from codespy.config_dspy import SIGNATURE_NAMES - # Check if any consumer has memory enabled (reuse the check from verify_memory_access) - if not any( - self.settings.is_signature_enabled(sig) and self.settings.get_memory_enabled(sig) - for sig in SIGNATURE_NAMES + # Check if memory is enabled and at least one signature is enabled + if not self.settings.memory.enabled or not any( + self.settings.is_signature_enabled(sig) for sig in SIGNATURE_NAMES ): return diff --git a/tests/test_prefrontal_signature.py b/tests/test_prefrontal_signature.py index b4d19ff..2195b58 100644 --- a/tests/test_prefrontal_signature.py +++ b/tests/test_prefrontal_signature.py @@ -22,7 +22,7 @@ def test_empty_prefrontal_memory_no_kwarg(self): ) mock_settings = MagicMock() mock_settings.is_signature_enabled.return_value = True - mock_settings.get_memory_enabled.return_value = False # Disable memory to simplify + mock_settings.memory.enabled = False # Disable memory to simplify captured_kwargs = {} @@ -65,7 +65,7 @@ def test_with_prefrontal_memory_kwarg_present(self): ) mock_settings = MagicMock() mock_settings.is_signature_enabled.return_value = True - mock_settings.get_memory_enabled.return_value = False # Disable memory to simplify + mock_settings.memory.enabled = False # Disable memory to simplify captured_kwargs = {} @@ -117,7 +117,7 @@ def test_empty_prefrontal_memory_no_exception(self): review_context = ReviewContext(pr_context=pr_context, prefrontal_memory="") mock_settings = MagicMock() mock_settings.is_signature_enabled.return_value = True - mock_settings.get_memory_enabled.return_value = False # Disable memory to simplify + mock_settings.memory.enabled = False # Disable memory to simplify # Mock ContextSafe to return a callable that returns a Prediction with the right attrs def capture_ctxsafe(predictor, sig, **kwargs): @@ -162,7 +162,7 @@ def test_with_prefrontal_memory_kwarg_present(self): review_context = ReviewContext(pr_context=pr_context, prefrontal_memory="some text") mock_settings = MagicMock() mock_settings.is_signature_enabled.return_value = True - mock_settings.get_memory_enabled.return_value = False # Disable memory to simplify + mock_settings.memory.enabled = False # Disable memory to simplify captured_kwargs = {} From ea4d9f5908867ed5c531085533f076f09d33e6a8 Mon Sep 17 00:00:00 2001 From: Guillaume Simonneau Date: Sat, 3 Oct 2026 11:07:09 +0200 Subject: [PATCH 39/39] sematic --- CHANGELOG.md | 223 ++++++++++++---------------------------- docs/memory.md | 2 +- docs/usage.md | 4 +- pyproject.toml | 2 +- src/codespy/__init__.py | 2 +- 5 files changed, 73 insertions(+), 160 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f751c3..b136da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,32 +2,16 @@ ## [Unreleased] -### Changed (Breaking) -- Removed `memory.postgres.schema` / `MEMORY_POSTGRES_SCHEMA` / action input `memory-postgres-schema`; the episodic schema is now hard-coded to `episodic` (like `semantic`) - -### Fixed -- `recall_memory` with `reach=local` returned nothing for code_review and supply_chain; the tool now searches the reviewed project scope -- Fixed "too many values to unpack" error in review statistics matrix (line 345 was iterating `severities` as tuples instead of enums) -- `GITLAB_URL` from the environment was ignored — now properly takes precedence over YAML `gitlab.url` -- Token precedence now matches documentation: env/`.env` beats YAML (`github.token` / `gitlab.token`) -- Added missing `memory-prefrontal-reflects` action input -- Removed action `llm-timeout` default (was `'120'`, now uses codespy.yaml's `240`) -- Doc reviewer defaults now match codespy.yaml (`max_iters: 2`, `max_llm_calls: 4`) -- Fixed workflow example input: `summarization-model` → `summary-model` - -### Added -- New action inputs (no defaults, values taken from codespy.yaml when unset): - - `scope-max-tokens`, `code-review-max-tokens`, `doc-max-tokens`, `supply-chain-max-tokens`, `summary-max-tokens`, `audit-max-tokens` - - `memory-distiller-max-tokens`, `memory-cartographer-max-tokens` - - `scope-skip-refinement-when-clean` - - `memory-compact-trajectory` - - `memory-max-hippocampus-tokens`, `memory-max-hippocampus-item-tokens`, `memory-max-trajectory-tokens`, `memory-max-question-tokens` - - `memory-mental-models-max-tokens`, `memory-mental-models-min-refresh-seconds` - - `memory-embeddings-max-input-chars` - - `openai-api-base`, `azure-api-key`, `azure-api-base`, `azure-api-version` -- AWS region now forwarded independently of access key (was bundled with credentials) +## [2.0.0] - 2026-10-03 ### Changed (Breaking) + +- **Configuration structure** — `Settings` is now `llm` / `github` / `gitlab` / `memory` / `review` with `extra="ignore"`. A 1.2.4 `codespy.yaml` is silently ignored for every moved key. Migration: + - Top-level `default_model`, `extraction_model`, `default_max_iters`, `default_max_llm_calls`, `default_reasoning_effort`, `default_temperature`, `default_max_tokens` → `llm.*`. `llm_retries`/`llm_timeout` → `llm.retries`/`llm.timeout`. `rlm_fallback` → `llm.rlm_fallback`. + - `signatures.` → `review.`. `min_confidence`, `output_format`, `output_stdout`, `output_git`, `cache_dir`, `excluded_directories` → `review.*`. + - `memory.default_enabled` → `memory.enabled`. `memory.{compact_trajectory,max_trajectory_tokens,max_question_tokens,distiller,cartographer}` → `memory.hippocampus.*`. `memory.max_context_memory_tokens`/`max_context_item_tokens` → `memory.hippocampus.max_hippocampus_tokens`/`max_hippocampus_item_tokens`. + - Env vars: `_*` → `REVIEW__*`. `MIN_CONFIDENCE`, `OUTPUT_FORMAT`, `OUTPUT_STDOUT`, `OUTPUT_GIT`, `CACHE_DIR`, `EXCLUDED_DIRECTORIES` → `REVIEW_*`. `MEMORY_DEFAULT_ENABLED` → `MEMORY_ENABLED`. `MEMORY_MAX_CONTEXT_MEMORY_TOKENS`/`MEMORY_MAX_CONTEXT_ITEM_TOKENS` → `MEMORY_MAX_HIPPOCAMPUS_TOKENS`/`MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS`. + - Unchanged: `DEFAULT_*`, `EXTRACTION_MODEL`, `LLM_RETRIES`, `LLM_TIMEOUT`, `RLM_FALLBACK_*`, `MEMORY_DISTILLER_*`, `MEMORY_CARTOGRAPHER_*`, `MEMORY_POSTGRES_*` (except `SCHEMA`), `MEMORY_PG0_*`, `MEMORY_BANK_ID`, `MEMORY_COMPACT_TRAJECTORY`, `MEMORY_MAX_TRAJECTORY_TOKENS`, `MEMORY_MAX_QUESTION_TOKENS`, and provider keys. - **New default models and temperature** — out-of-the-box codespy now requires AWS Bedrock access to Opus 5.5 and both Nemotron models: - `DEFAULT_MODEL`: `bedrock/converse/nvidia.nemotron-nano-3-30b` - `EXTRACTION_MODEL`: `bedrock/converse/nvidia.nemotron-nano-3-30b` @@ -37,150 +21,79 @@ - `MEMORY_EMBEDDINGS_MODEL`: `bedrock/cohere.embed-v4:0` - `DEFAULT_TEMPERATURE`: `1.0` - **Embeddings model v4** changes the vector dimension — requires a semantic DB reset (`DROP SCHEMA semantic CASCADE`) -- Action `model` input is now optional (was `required: true`); when unset, codespy.yaml default applies -- Action inputs no longer carry `default:` values; codespy.yaml applies instead -- Setting a pinned unit to `null` (YAML `model: null` or env `VAR=null`) re-enables the fallback chain - **Default `MEMORY_BANK_ID` changed from `codespy` to `codebase`** — set `MEMORY_BANK_ID=codespy` to keep using existing memory data - -### Removed -- **Per-signature memory overrides** — `review..memory.enabled` (YAML), `REVIEW__MEMORY_ENABLED` (env), and action inputs `scope-memory-enabled`, `code-review-memory-enabled`, `doc-memory-enabled`, `supply-chain-memory-enabled`, `summary-memory-enabled`, `audit-memory-enabled` are removed. Memory is now controlled only by the global `memory.enabled` (`MEMORY_ENABLED`, `memory-enabled`). -- Action input `memory-prefrontal-model` (was deprecated, never wired) -- Action env vars: `SCOPE_MEMORY_ENABLED`, `CODE_REVIEW_MEMORY_ENABLED`, `DOC_MEMORY_ENABLED`, `SUPPLY_CHAIN_MEMORY_ENABLED`, `SUMMARY_MEMORY_ENABLED`, `AUDIT_MEMORY_ENABLED`, `OUTPUT_FORMAT` -- Dead code: `Settings.sync_llm_settings()` and `LLMConfig.sync_from_flat()` - -### Changed -- **Review statistics matrix**: the GitHub/GitLab review Statistics table is now a severity × category matrix (Security, Bugs, Documentation, Smells + totals); Smells are now counted. -- **Review is now published before the memory phase**: Episode saves, retain, consolidation and mental-model refresh run after audit. For remote reviews, the GitHub/GitLab review is posted first with "Memory: pending", then edited with full costs after the memory phase completes. Local CLI and MCP reviews run the memory phase before returning/printing. This keeps the review pipeline responsive while memory operations complete in the background. +- **Memory disabled by default** — `memory.enabled` now defaults to `false`. Previously `review.summary.memory.enabled: true` in main `codespy.yaml` caused summary episodes to save even with global memory off. Now only the global switch gates memory, so nothing is saved by default. +- **Removed `memory.postgres.schema`** — episodic schema is now hard-coded to `episodic` (like `semantic`). The `MEMORY_POSTGRES_SCHEMA` env var is removed. +- **Action `model` input is now optional** (was `required: true`); when unset, codespy.yaml default applies. Action inputs no longer carry `default:` values; codespy.yaml applies instead. Setting a pinned unit to `null` (YAML `model: null` or env `VAR=null`) re-enables the fallback chain. Migration note: upgrade the action ref and `codespy-version` together. An older action with the `latest` image, or the reverse, silently drops review settings. +- **EVICT tombstones** — Hippocampus eviction writes `EVICT` mutation tombstones (`MutationType.EVICT`, separate from `DELETE`). `load_context` excludes both `DELETE` and `EVICT`. ROLLBACK NOTE: loaders older than this release only skip `DELETE` and fail to load a topic that has an `EVICT` row. Before rolling back, run `UPDATE observations SET op_type = 'DELETE' WHERE op_type = 'EVICT';` (both are content-NULL tombstones, so no data is lost). +- **Dependencies**: `hindsight-api-slim >=0.10.1` (new); `mcp` upgraded from `>=1.29,<2` to `>=2.2,<3` ### Added + +- **Cerebral (Semantic Memory)**: New `codespy/agents/memory/cerebral/` package with mutation-based retain (ADD/`supersedes`/`RETRACTED`), domain-agnostic missions, tags/scopes (`project_scope:`, `repo:`, `org:`, `task:`), once-per-run consolidation via `SyncTaskBackend`, `max_observations_per_scope`, mental-model briefings in delta mode with `memory.cerebral.mental_models.*`, embeddings + `max_input_chars`, structured-output support, read API (`arecall`/`recall`, `areflect`/`reflect`, `aget_mental_models`, `aget_observation_history`), and fixed `semantic` schema. +- **Prefrontal (Semantic Recall)**: New `codespy/agents/memory/prefrontal/` package with run-level recall shared by summary, code_review, doc, supply_chain, and audit agents. Before each agent call it reads Cerebral and injects a read-only `prefrontal_memory` input (mental-model briefings, five question-shaped recall facets — context, seen before, decisions, recurring patterns, what changed — and facts from other repositories). Configured under `memory.prefrontal` (`MEMORY_PREFRONTAL_REFLECTS`, `MEMORY_RECALL_*`). The `recall_memory` tool is available to RLM agents (code_review, scope, supply_chain) but is off by default (`max_tool_calls: 0`). Prefrontal never writes to Cerebral and never feeds Hippocampus. - **Review memories section**: The pre-call Prefrontal memory (run-level and scope-level) now appears in a collapsed `memories` section **before** the Summary. Run-level sections (Briefings, Around this work, Seen before, Decisions, Recurring patterns, What changed, Other repositories) render directly under `memories`. Scope recall, when present, is nested in its own `scope` block. All levels are indented with `
` so nesting is visible in GitHub/GitLab. This section is shown in both the GitHub/GitLab review body and the stdout/MCP markdown output. -- **Cerebral structured output support**: Native structured output registration for Bedrock models (e.g., Nvidia Nemotron Super) that support it but lack the flag in litellm's model map. Two-step structured output fallback for memory models litellm cannot schema-enforce, using `llm.extraction_model`. -- **Cerebral per-scope observation cap**: New `max_observations_per_scope` setting under `memory.cerebral.consolidation` (env `MEMORY_CONSOLIDATION_MAX_OBSERVATIONS_PER_SCOPE`, default `100`). Limits observations per `[org:*, repo:*, project_scope:*]` scope; `-1` = unlimited, `0` = no new observations. Scopes that hit the cap only allow UPDATE/DELETE. +- **New action inputs** (no defaults, values taken from codespy.yaml when unset): + - `scope-max-tokens`, `code-review-max-tokens`, `doc-max-tokens`, `supply-chain-max-tokens`, `summary-max-tokens`, `audit-max-tokens` + - `memory-distiller-max-tokens`, `memory-cartographer-max-tokens` + - `memory-compact-trajectory`, `memory-max-hippocampus-tokens`, `memory-max-hippocampus-item-tokens`, `memory-max-trajectory-tokens`, `memory-max-question-tokens` + - `memory-retain-model`, `memory-retain-chunk-size` + - `memory-consolidation-model`, `memory-consolidation-max-observations-per-scope` + - `memory-mental-models-model`, `memory-mental-models-max-tokens`, `memory-mental-models-min-refresh-seconds` + - `memory-recall-model`, `memory-recall-reach`, `memory-recall-max-tokens`, `memory-recall-max-tool-tokens`, `memory-recall-max-tool-calls` + - `memory-prefrontal-reflects`, `memory-embeddings-model`, `memory-embeddings-max-input-chars` + - `scope-skip-refinement-when-clean` + - `openai-api-base`, `azure-api-key`, `azure-api-base`, `azure-api-version` +- **AWS region forwarding**: AWS region is now forwarded independently of access key (was bundled with credentials) +- **Cost buckets**: `memory_recall`, `memory_recall_embeddings`, `memory_consolidation`, `memory_mental_models` +- **ReflectSummary monitoring**: `areflect` returns `(text, ReflectSummary)` with iterations, LLM calls, map calls, rewrite flag, tools used, usage, and empty flag. Stored in `RecallRecord.details["reflect"]` for monitoring. ### Changed -- **Cerebral tags**: Removed `episode:` and `run_id:` tags — they were not used for scoping or reach and bloated the tag set. `pull_request:` tags are also no longer added. Retained items now only carry `project_scope:`, `repo:`, `org:`, and `task:` tags. -- **Cerebral retain framing**: Moved from `context` to `metadata`. The `context` field is no longer set on retain items; instead `metadata` carries `task`, `repo` (when known), `kind` (`"observation changes"` / `"artifacts"`), and `question`. This reduces consolidation input size (context was repeated for every fact). -- **Cerebral fallback scope**: Episodes without a `project_scope` topic now consolidate into `[org:, repo:, project_scope:]` (the repo-root project scope) instead of `[org:, repo:]`. This ensures every write scope matches the observation cap rule and creates/updates the repo-root briefing. -- **Mental-model configuration moved to `memory.cerebral.mental_models`**: Mental-model settings (`model`, `max_tokens`, `min_refresh_seconds`) now live under `memory.cerebral.mental_models` instead of `memory.prefrontal`. This better reflects that mental models are produced by Cerebral (briefing refresh after consolidation), not Prefrontal (read-only). Old env vars `MEMORY_MAX_MENTAL_MODEL_TOKENS` and `MEMORY_MIN_MENTAL_MODEL_REFRESH_SECONDS` are removed (no aliases, as they were unreleased). New env vars: `MEMORY_MENTAL_MODELS_MAX_TOKENS`, `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS`. - -### Added -- **Cerebral consolidation and mental-model refresh model config**: Separate model configuration for consolidation (`memory.cerebral.consolidation.model`, env `MEMORY_CONSOLIDATION_MODEL`) and mental-model refresh (`memory.cerebral.mental_models.model`, env `MEMORY_MENTAL_MODELS_MODEL`). Both fall back through their respective chains: consolidation → retain → default; mental_models → prefrontal → retain → default. When unset, behavior is unchanged (consolidation uses retain model, mental-model refresh uses reflect model). Operator env vars (`HINDSIGHT_API_CONSOLIDATION_LLM_MODEL`, `HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_*`) still win when set. -- **Cerebral mental-models settings**: `memory.cerebral.mental_models.max_tokens` (env `MEMORY_MENTAL_MODELS_MAX_TOKENS`, default `2048`) for briefing size, and `memory.cerebral.mental_models.min_refresh_seconds` (env `MEMORY_MENTAL_MODELS_MIN_REFRESH_SECONDS`, default `0`) for minimum seconds between automatic briefing refreshes. Delta mode means nothing is lost — the refresh covers all new facts since `last_memory_seen_at` when the interval expires. -### Changed -- **Run-level Prefrontal recall**: One Prefrontal recall per run, shared by summary, code_review, doc, supply_chain, and audit agents. Previously each agent call ran its own `Prefrontal.aload()`, paying for the reflect loop separately. The scope agent still loads its own memory. -- **One consolidation per run**: Consolidation and mental-model refresh run once per run, after all background episode saves complete. Previously every `retain_episode` consolidated each scope plus the repo scope, refreshing the repo briefing N times per run. -- **Recalls table schema**: The `recalls` table is redesigned for run-level tracking: - - `ordinal` (INT) replaced by `id` (UUID) - - New columns: `run_id` (VARCHAR), `task` (VARCHAR) - - `episode_id` is now nullable (NULL for the shared run-level load) - - Primary key changed from `(bank_id, episode_id, ordinal)` to `(bank_id, id)` - - New index: `idx_recalls_run (bank_id, run_id)` -- **Cerebral cost bucket split**: Consolidation and mental-model refresh LLM calls are now billed to - `memory_consolidation` and `memory_mental_models` respectively, instead of `memory_other`. - The `memory_other` bucket now only holds uncategorized LLM calls (e.g., standalone reflect calls). - Embeddings remain in `memory_embeddings` / `memory_recall_embeddings` (unchanged). -- **Briefing refresh trigger**: Now defaults to delta mode with reduced evidence caps: - - `mode=delta` (was `full`): only new facts since last refresh - - `include_chunks=false` (was `true`): observation text only, no file chunks - - `reflect_search_observations_max_tokens=3000` (was 5000) - - `reflect_search_observations_include_entities=false` (was `true`) -- **Doc review CHANGELOG trim**: CHANGELOG files are now trimmed to preamble + 2 newest sections (keeps `[Unreleased]` + latest release). Saves ~25KB (21%) of the ~118KB doc payload in this repo. Non-CHANGELOG docs are unchanged. +- **Review statistics matrix**: the GitHub/GitLab review Statistics table is now a severity × category matrix (Security, Bugs, Documentation, Smells + totals); Smells are now counted. +- **Review publish order**: Episode saves, retain, consolidation and mental-model refresh run after audit. For remote reviews, the GitHub/GitLab review is posted first with "Memory: pending", then edited with full costs after the memory phase completes. Local CLI and MCP reviews run the memory phase before returning/printing. - **Cost breakdown split**: The per-signature cost table is split into a Review table and a Memory table (memory_* buckets), each with its own subtotal. +- **Cost table rows renamed** (1.2.4 rows `distiller`/`cartographer` → `memory_distiller`/`memory_cartographer`) +- **Cerebral tags**: Removed `episode:` and `run_id:` tags — they were not used for scoping or reach and bloated the tag set. `pull_request:` tags are also no longer added. Retained items now only carry `project_scope:`, `repo:`, `org:`, and `task:` tags. +- **Cerebral fallback scope**: Episodes without a `project_scope` topic now consolidate into `[org:, repo:, project_scope:]` (the repo-root project scope) instead of `[org:, repo:]`. This ensures every write scope matches the observation cap rule and creates/updates the repo-root briefing. +- **Cerebral retain framing**: Moved from `context` to `metadata`. The `context` field is no longer set on retain items; instead `metadata` carries `task`, `repo` (when known), `kind` (`"observation changes"` / `"artifacts"`), and `question`. This reduces consolidation input size. +- **Doc review CHANGELOG trim**: CHANGELOG files are now trimmed to preamble + 2 newest sections (keeps `[Unreleased]` + latest release). Saves ~25KB (21%) of the ~118KB doc payload in this repo. +- **load_context semantics**: Now loads the latest version of every observation from ALL prior episodes (not just the latest episode), excluding tombstones (`DELETE`, `EVICT`) and any version with no content. Observations are returned oldest-first, so eviction drops the oldest facts first. Topic prefix matching now uses proper LIKE escaping so `owner/repo` no longer matches `owner/repo-other`. +- **Cerebral DELETE vs EVICT handling**: DELETE produces a `RETRACTED` line marking facts as wrong; EVICT of unchanged prior facts sends nothing; content new this run (ADD→EVICT or REPLACE→EVICT) is retained to avoid losing newly learned facts. -### Added -- **Cost tracking for extraction LM**: `TwoStepAdapter` extraction calls are now counted and attributed to the calling signature. Previously these were missing from cost tracking. - -### Fixed -- **Retain no longer fails with "all N facts returned by the LLM were unusable" on Nemotron 3 Super (Bedrock)**. litellm lacked its native structured-output flag, so it fell back to forced tool calls with string-encoded arrays. Cerebral now registers the capability and strips JSON Schema keywords Bedrock's native structured output rejects (e.g., `minimum`/`maximum`, `maxItems`). -- **Briefing (mental model) delta refreshes no longer fail with "operations must be a list, got "** (`delta_ops_failed`). Models without native structured output on Bedrock (e.g., Nvidia Nemotron) return the operations array JSON-encoded as a string; Cerebral now decodes it before Hindsight validates it. -- **`Missing required inputs: ['prefrontal_memory']` regression**: Fixed a bug where agents failed with `dspy.RLM._validate_inputs` error when the Prefrontal instance existed but the shared run-level `prefrontal_memory` text was empty (fresh DB). The signature is now chosen based on whether there is text to pass, not on the presence of the Prefrontal instance. Affects: summary, code_review, doc, supply_chain, audit. -- Cerebral consolidation and retain LLM calls now use `llm.timeout` / `llm.retries`. Previously only reflect did, so consolidation timed out at Hindsight's 120 s default. -- Cerebral consolidation no longer logs `relation "webhooks" does not exist`. Inline (`SyncTaskBackend`) tasks now carry the `semantic` schema, so consolidation completion no longer rolls back and falls back. -- Cerebral consolidation no longer fails at exit with `cannot schedule new futures after shutdown`. Background episode saves now finish before Python shuts down its thread pools, and the pipeline waits for them without the old 120 s cap. Runs can end later, because posting the review now waits for the consolidation to finish. -- Briefings no longer stay stuck on "Generating content..." after a failed refresh. The `_SchemaSyncTaskBackend` now marks failed operations as failed, and startup repair marks orphaned pending operations older than 1 hour as failed so subsequent refreshes can proceed. -- Embedding inputs are now capped for Bedrock Cohere v3 (2048 characters) to prevent `maxLength: 2048, actual: 2639` errors. Use `memory.cerebral.embeddings.max_input_chars` (env: `MEMORY_EMBEDDINGS_MAX_INPUT_CHARS`) to override: `null` = auto per model, `0` = disabled, `N` = custom cap. -- **Cost calculation**: `TwoStepAdapter` extraction calls are now included in signature cost tracking. Previously these calls were invisible because `SignatureContext` only read the main LM's history. -- **Cost billing**: DSPy cache hits (`response.cache_hit`) are now skipped in cost accounting, as they are not billed by the provider. -- **Cost split calculation**: When `entry["cost"]` is available (billed cost), input cost is now calculated as `billed_cost - output_cost` (list price). Previously rows could sum to more than Total when cache discounts applied. - -### Added -- **Prefrontal reflect hard caps**: programmatic reflect configuration with new caps: - - `memory.prefrontal.max_reflect_completion_tokens` (env `MEMORY_MAX_REFLECT_COMPLETION_TOKENS`, default `4096`): per-LLM-call output token cap. Reasoning tokens count against this first; keep it large enough for briefing rewrites (~2048 target). - - `memory.prefrontal.max_reflect_context_tokens` (env `MEMORY_MAX_REFLECT_CONTEXT_TOKENS`, default `null`): when `null`, the cap is auto-derived from `5 × max_mental_model_tokens + max_reflect_fetch_tokens + 2048` overhead, divided by 0.8, so worst-case history fits in one chunk and no map calls occur. With defaults this is ~16000. An explicit value still wins. - - Reflect caps are set programmatically on the Hindsight `_raw_config` after import, so they do not require a process restart. Env vars still win as operator overrides. - - Load INFO line now includes `maps=N` (split synthesis map-call count), `rewrite` flag, and `thoughts=N` (reasoning tokens) for monitoring. -- **Recall embeddings bucket**: `memory_recall_embeddings` cost bucket for embeddings during recall (separate from `memory_recall` LLM calls). -- **Reflect summary**: `areflect` returns `(text, ReflectSummary)` with iterations, LLM calls, map calls, rewrite flag, tools used, usage, and empty flag. Stored in `RecallRecord.details["reflect"]` for monitoring. -- **Prefrontal reflect cost caps**: new settings to cap the Hindsight reflect loop cost: - - `memory.prefrontal.max_reflect_fetch_tokens` (env `MEMORY_MAX_REFLECT_FETCH_TOKENS`, default `2048`): per-call fetch limit for recall and search_observations tools. This is the default size used when the LLM omits one; the LLM can still ask for more up to the ceiling. Also disables chunk retrieval (`recall_include_chunks=false`). -- **Prefrontal (semantic recall)**: new `codespy/agents/memory/prefrontal/` package. Before each agent call it reads Cerebral and injects a read-only `prefrontal_memory` input (mental-model briefings, five question-shaped recall facets — context, seen before, decisions, recurring patterns, what changed — and facts from other repositories). The RLM agents (code_review, scope, supply_chain) also get an async `recall_memory(query, reach)` tool. Configured under `memory.prefrontal` (`MEMORY_PREFRONTAL_REACH`, `MEMORY_PREFRONTAL_REFLECTS`, `MEMORY_MAX_MENTAL_MODEL_TOKENS`, `MEMORY_MAX_PREFRONTAL_TOKENS`, `MEMORY_PREFRONTAL_REMOTE_SHARE`, `MEMORY_PREFRONTAL_RECALL_BUDGET`, `MEMORY_PREFRONTAL_TOOL`, `MEMORY_MAX_PREFRONTAL_TOOL_TOKENS`, `MEMORY_MAX_PREFRONTAL_TOOL_CALLS`). Active only when the signature's memory is enabled and Cerebral is available; otherwise agents behave exactly as before. Prefrontal never writes to Cerebral and never feeds Hippocampus. -- **Cerebral read API**: `arecall`/`recall`, `areflect`/`reflect` (`budget=mid`), `aget_mental_models`, `aget_observation_history`. -- **Prefrontal model setting**: `memory.prefrontal.model` (env `MEMORY_PREFRONTAL_MODEL`, Action input `memory-prefrontal-model`) selects the model for Hindsight's reflect loop and mental-model refresh. It falls back to `memory.cerebral.retain.model` → `llm.default_model`, so behaviour is unchanged when unset. It is checked at startup when `reflects > 0`. -- **Prefrontal recall monitoring**: every pre-call load and `recall_memory` call is logged as one INFO line (status, model, LLM calls, input/output tokens, cost, latency) and persisted to a new episodic `recalls` table (`Episode.recalls`, `RecallRecord` in `codespy/agents/memory/recall.py`). Rows hold the exact text the agent received and the measured token usage and cost. The table is additive (`CREATE TABLE IF NOT EXISTS`); recalls are never retained into Cerebral nor given to the Distiller/Cartographer. -- **`memory_recall` cost bucket**: Recall calls (reflect LLM calls and query embeddings) are attributed to it through a context variable. Previously they were counted in `memory_other` / `memory_embeddings`. -- **Cerebral mental models**: one briefing per observation scope and per repo, created on first retain and refreshed after consolidation. `memory.prefrontal.reflects > 0` sets `HINDSIGHT_API_REFLECT_MAX_ITERATIONS`, which also caps mental-model refresh loops. -- **Cerebral (Semantic Memory)**: - - New `memory.cerebral.retain.chunk_size` configuration (default: 12288, env var: `MEMORY_RETAIN_CHUNK_SIZE`). Chars per fact-extraction chunk; must be < 64000. See `CerebralRetainConfig` in `config_memory.py`. - - New GitHub Action inputs: `memory-retain-model`, `memory-embeddings-model`, `memory-retain-chunk-size`. - -### Changed -- **Cerebral consolidation now actually runs**: `MemoryEngine` uses `SyncTaskBackend`, so consolidation and mental-model refreshes run inline in `retain_episode` (background save thread). Previously `BrokerTaskBackend` only queued `pending` rows for a worker codespy never started. The first retain after upgrading consolidates the whole backlog (slow, costs LLM tokens); start and end are logged. Every retain now costs extra LLM calls (`memory_other` bucket) and background saves take longer. -- **Cerebral tags and observation scopes**: retained items get `repo:` and `org:` tags and an explicit `observation_scopes` list (`[org:, repo:, project_scope:]` per scope, or `[org:, repo:]`), so observations merge across tasks and runs. `task:`/`episode:`/`run_id:`/`pull_request:` never enter a scope. The bank also gets an `observations_mission`. `retain_episode` takes `repo_full_name`; without it the old behaviour is kept. Old data is not backfilled. -- **Cerebral `retain_mission` is now domain-agnostic**: Replaced code-review-specific wording with general guidance for "observations, decisions, relationships, findings, insights" across any domain. The marker block (plain / `supersedes` / `RETRACTED`) semantics remain unchanged. -- **Agents no longer receive memory input**: `inject_context_memory` and the `context_memory=` argument have been removed from all six review agents (summary, scope, code_review, doc, supply_chain, audit). Memory is learned but not used directly; prior knowledge now reaches agents only through Prefrontal (see Added). -- **Hippocampus eviction writes tombstones**: When a prior observation is evicted from `context_memory` to stay within `max_hippocampus_tokens`, Hippocampus records a new `EVICT` mutation (`MutationType.EVICT`, separate from the Cartographer's `OpType`, so the LLM cannot emit it). `save_episode` writes an `op_type='EVICT'` tombstone version (`content=NULL`) for persisted observations, and `load_context` excludes both `DELETE` and `EVICT` tombstones. -- **Cerebral DELETE vs EVICT handling**: DELETE produces a `RETRACTED` line marking facts as wrong; EVICT of unchanged prior facts sends nothing; content new this run (ADD→EVICT or REPLACE→EVICT) is retained to avoid losing newly learned facts. The retain mission explicitly states that only `RETRACTED` invalidates a fact and missing facts are unchanged. -- **EpisodeStore.load_context() semantics changed**: Now loads the latest version of every observation from ALL prior episodes (not just the latest episode), excluding tombstones (`TOMBSTONE_TYPES`: `DELETE`, `EVICT`) and any version with no content. Observations are returned oldest-first, so eviction drops the oldest facts first. Topic prefix matching now uses proper LIKE escaping so `owner/repo` no longer matches `owner/repo-other`. -- **Rollback note**: loaders older than this release only skip `op_type='DELETE'` and fail to load a topic that has an `EVICT` row. Before rolling back, run `UPDATE observations SET op_type = 'DELETE' WHERE op_type = 'EVICT';` (both are content-NULL tombstones, so no data is lost). -- **BREAKING — Environment Variables**: Renamed env vars for consistency with YAML paths - - `llm.*` settings drop the `LLM_` prefix (e.g., `LLM_DEFAULT_MODEL` → `DEFAULT_MODEL`) - - Exceptions: `LLM_RETRIES` and `LLM_TIMEOUT` keep their prefixes - - `memory.hippocampus.*` and `memory.cerebral.*` settings drop `HIPPOCAMPUS_`/`CEREBRAL_` prefixes - - Examples: `MEMORY_HIPPOCAMPUS_DISTILLER_MODEL` → `MEMORY_DISTILLER_MODEL`, `MEMORY_CEREBRAL_RETAIN_MODEL` → `MEMORY_RETAIN_MODEL` - - `review.*` settings keep the `REVIEW_` prefix (e.g., `CODE_REVIEW_MODEL` → `REVIEW_CODE_REVIEW_MODEL`, `MIN_CONFIDENCE` → `REVIEW_MIN_CONFIDENCE`) - - Removed: `ENABLE_PROMPT_CACHING`, `COMPACT_PATCHES`, `MEMORY_CEREBRAL_*` (except `MEMORY_RETAIN_MODEL` and `MEMORY_EMBEDDINGS_MODEL`) - - Migration: Update your `.env` file and GitHub Action inputs. The `llm.*`, `memory.postgres.*`, and `memory.pg0.*` names are unchanged from 1.2.4. -- **GitHub Action**: - - The `enable-prompt-caching` and `compact-patches` inputs are removed (the underlying settings no longer exist) - - All signature env vars now use `REVIEW_*` prefix (e.g., `SCOPE_ENABLED` → `REVIEW_SCOPE_ENABLED`, `CODE_REVIEW_MODEL` → `REVIEW_CODE_REVIEW_MODEL`) - - Memory master switch: `MEMORY_DEFAULT_ENABLED` → `MEMORY_ENABLED` - - Added missing passthrough for `DOC_MAX_ITERS` and `DOC_MAX_LLM_CALLS` - - Migration note: **upgrade the action ref and `codespy-version` together**. An older action with the `latest` image, or the reverse, silently drops review settings. +### Removed -### Changed -- **BREAKING — Memory Token Budgets**: Renamed token budget fields for clarity - - `memory.hippocampus.max_context_memory_tokens` → `max_hippocampus_tokens` (env: `MEMORY_MAX_HIPPOCAMPUS_TOKENS`) - - `memory.hippocampus.max_context_item_tokens` → `max_hippocampus_item_tokens` (env: `MEMORY_MAX_HIPPOCAMPUS_ITEM_TOKENS`) - - Old keys and env vars are silently ignored; defaults apply. See codespy.yaml for current names. -- **Internal naming**: Cost table rows and internal unit names were renamed to match the `MEMORY_*` config/env names: - - `distiller` → `memory_distiller` - - `cartographer` → `memory_cartographer` - - `cerebral_retain` → `memory_retain` - - `cerebral_embeddings` → `memory_embeddings` - - `cerebral_other` → `memory_other` +- **Per-signature memory overrides** — `review..memory.enabled` (YAML), `REVIEW__MEMORY_ENABLED` (env), and action inputs `scope-memory-enabled`, `code-review-memory-enabled`, `doc-memory-enabled`, `supply-chain-memory-enabled`, `summary-memory-enabled`, `audit-memory-enabled` are removed. Memory is now controlled only by the global `memory.enabled` (`MEMORY_ENABLED`, `memory-enabled`). +- Action input `memory-prefrontal-model` (was deprecated, never wired). The real input is `memory-recall-model`. +- Action env vars: `SCOPE_MEMORY_ENABLED`, `CODE_REVIEW_MEMORY_ENABLED`, `DOC_MEMORY_ENABLED`, `SUPPLY_CHAIN_MEMORY_ENABLED`, `SUMMARY_MEMORY_ENABLED`, `AUDIT_MEMORY_ENABLED`, `OUTPUT_FORMAT` +- Config settings: `enable_prompt_caching`/`ENABLE_PROMPT_CACHING`/`enable-prompt-caching`, `compact_patches`/`COMPACT_PATCHES`/`compact-patches` +- Tree-sitter patch compaction (`tools/git/patch_utils.py` deleted) +- Dead code: `Settings.sync_llm_settings()`, `LLMConfig.sync_from_flat()`, `Settings.get_memory_enabled()`, `get_memory_budget()` no longer takes an argument, `MemorySignatureConfig`, `SIGNATURE_PREFIXES`, `SIGNATURE_SETTINGS`, `MEMORY_SIGNATURE_SETTINGS`, `RLM_FALLBACK_ENV_SETTINGS` +- Agents no longer receive memory input: `inject_context_memory` and the `context_memory=` argument have been removed from all six review agents (summary, scope, code_review, doc, supply_chain, audit). Memory is learned but not used directly; prior knowledge now reaches agents only through Prefrontal. ### Fixed -- Prefrontal reads on a bank with nothing retained yet (a new `MEMORY_BANK_ID`) now return empty results instead of failing with `Bank '' not found` (hindsight 0.10.1 returns 404 for reads on missing banks). Reflect is skipped there. -- `Cerebral.__init__()` no longer receives unexpected `reflect_llm_provider` kwarg — the parameter is derived internally by Cerebral from `llm_provider`, so callers only pass `reflect_llm_model/api_key/base_url` when `memory.prefrontal.model` is set -- `get_llm_config("memory_retain")` no longer raises `AttributeError` — the cerebral branch now uses empty `ReflectionModuleConfig` and falls back to `llm.default_*` values -- `MEMORY_POSTGRES_SCHEMA` env var now works correctly (previously emitted `MEMORY_POSTGRES_SCHEMA_NAME` which was ignored) -- GitHub Action `memory-enabled` input now maps to `MEMORY_ENABLED` (was `MEMORY_DEFAULT_ENABLED`, which didn't match the code) -### Removed -- **Config**: Removed dead settings `enable_prompt_caching` and `compact_patches` (fields deleted in the config refactor) - -- **Cerebral retains mutations, not observations**: Changed from full context_memory to mutation log - - `retain_episode()` now builds observations blob from `episode.mutations` - - ADD → plain line with final content - - REPLACE → `supersedes:` line with previous_content from first mutation - - DELETE → `RETRACTED` line with previous_content and optional reason - - `_mutation_lines()` helper for building lines from mutation groups -- **Dependencies**: MCP Python SDK upgraded from v1.x to v2.x - - `mcp` dependency: `>=1.29.0,<2.0.0` → `>=2.2,<3` - - All 8 MCP servers migrated from `FastMCP` to `MCPServer` (v2 API) - - `mcp_utils.py` updated to use new `Client` class with `StdioServerParameters` - - Removed dead log suppression code for "Processing request of type" messages (no longer present in v2) +- `recall_memory` with `reach=local` returned nothing for code_review and supply_chain; the tool now searches the reviewed project scope +- "too many values to unpack" error in review statistics matrix (line 345 was iterating `severities` as tuples instead of enums) +- `GITLAB_URL` from the environment was ignored — now properly takes precedence over YAML `gitlab.url` +- Token precedence now matches documentation: env/`.env` beats YAML (`github.token` / `gitlab.token`) +- Removed action `llm-timeout` default (was `'120'`, now uses codespy.yaml's `240`) +- Doc reviewer defaults now match codespy.yaml (`max_iters: 2`, `max_llm_calls: 4`) +- Fixed workflow example input: `summarization-model` → `summary-model` +- Prefrontal reads on a bank with nothing retained yet (a new `MEMORY_BANK_ID`) now return empty results instead of failing with `Bank '' not found` +- Retain no longer fails with "all N facts returned by the LLM were unusable" on Nemotron 3 Super (Bedrock) — Cerebral now registers the structured-output capability and strips JSON Schema keywords Bedrock's native structured output rejects +- Briefing (mental model) delta refreshes no longer fail with "operations must be a list, got " — Cerebral now decodes string-encoded operations before Hindsight validates them +- `Missing required inputs: ['prefrontal_memory']` regression: Fixed a bug where agents failed when the Prefrontal instance existed but the shared run-level `prefrontal_memory` text was empty (fresh DB). The signature is now chosen based on whether there is text to pass +- Cerebral consolidation and retain LLM calls now use `llm.timeout` / `llm.retries`. Previously only reflect did, so consolidation timed out at Hindsight's 120 s default. +- Cerebral consolidation no longer logs `relation "webhooks" does not exist` — inline (`SyncTaskBackend`) tasks now carry the `semantic` schema +- Cerebral consolidation no longer fails at exit with `cannot schedule new futures after shutdown` — background episode saves now finish before Python shuts down its thread pools +- Briefings no longer stay stuck on "Generating content..." after a failed refresh +- Embedding inputs are now capped for Bedrock Cohere v3 (2048 characters) to prevent `maxLength` errors +- Cost calculation: `TwoStepAdapter` extraction calls are now included in signature cost tracking +- Cost billing: DSPy cache hits (`response.cache_hit`) are now skipped in cost accounting +- Cost split calculation: When `entry["cost"]` is available (billed cost), input cost is now calculated as `billed_cost - output_cost` +- `DOC_MAX_ITERS`/`DOC_MAX_LLM_CALLS` env passthrough now works +- `memory-enabled`→`MEMORY_ENABLED` mapping in action ## [1.2.4] - 2026-09-18 diff --git a/docs/memory.md b/docs/memory.md index feb56cc..6eb43ba 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -487,7 +487,7 @@ MEMORY_CARTOGRAPHER_MODEL=anthropic/claude-sonnet-4-5-20250929 ```yaml - name: Run CodeSpy Review - uses: khezen/codespy@v1 + uses: khezen/codespy@v2 with: model: 'anthropic/claude-opus-4-6' anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/docs/usage.md b/docs/usage.md index a4ee65c..1170fe8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -162,7 +162,7 @@ jobs: steps: - name: Run CodeSpy Review - uses: khezen/codespy@v1 + uses: khezen/codespy@v2 with: model: 'anthropic/claude-opus-4-6' anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} @@ -187,7 +187,7 @@ jobs: steps: - name: Run CodeSpy Review - uses: khezen/codespy@v1 + uses: khezen/codespy@v2 with: model: 'anthropic/claude-opus-4-6' anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/pyproject.toml b/pyproject.toml index 44be1dd..d37015e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "codespy-ai" -version = "1.2.4" +version = "2.0.0" description = "Code review agent powered by DSPy" readme = "README.md" license = "MIT" diff --git a/src/codespy/__init__.py b/src/codespy/__init__.py index a2b6989..b783fe4 100644 --- a/src/codespy/__init__.py +++ b/src/codespy/__init__.py @@ -1,3 +1,3 @@ """codespy - Code review agent powered by DSPy.""" -__version__ = "1.2.4" +__version__ = "2.0.0"