From f4e1e18541e69a498c90c52910de2398be0f1002 Mon Sep 17 00:00:00 2001 From: HRiggs Date: Thu, 30 Apr 2026 13:59:46 -0400 Subject: [PATCH] feat: ollama, embedings and more --- .gitignore | 8 +- MEMORY_SYSTEM_PLAN.md | 1050 +++++++++++++++++ src/LocalDiplomacy.Agent/.agent-server.pid | 1 + src/LocalDiplomacy.Agent/config.example.yaml | 43 +- .../localdiplomacy_agent/app.py | 195 ++- .../localdiplomacy_agent/config.py | 45 +- .../localdiplomacy_agent/contracts.py | 4 +- .../localdiplomacy_agent/embeddings.py | 140 +++ .../localdiplomacy_agent/koboldcpp_client.py | 57 - .../localdiplomacy_agent/koboldcpp_process.py | 84 -- .../localdiplomacy_agent/lore.py | 231 ++++ .../localdiplomacy_agent/memory.py | 504 +++++++- .../localdiplomacy_agent/mock_game.py | 38 +- .../localdiplomacy_agent/ollama_client.py | 138 +++ .../localdiplomacy_agent/qdrant_process.py | 74 ++ .../localdiplomacy_agent/tools.py | 26 +- .../localdiplomacy_agent/vector_index.py | 231 ++++ src/LocalDiplomacy.Agent/pyproject.toml | 5 +- .../tests/test_full_lore_execution.py | 85 ++ .../tests/test_koboldcpp_client.py | 21 - .../tests/test_koboldcpp_process.py | 13 - .../tests/test_lore_vector_retrieval.py | 44 + .../tests/test_memory_store.py | 151 +++ .../tests/test_ollama_client.py | 203 ++++ src/LocalDiplomacy.Agent/uv.lock | 436 +------ 25 files changed, 3042 insertions(+), 785 deletions(-) create mode 100644 MEMORY_SYSTEM_PLAN.md create mode 100644 src/LocalDiplomacy.Agent/.agent-server.pid create mode 100644 src/LocalDiplomacy.Agent/localdiplomacy_agent/embeddings.py delete mode 100644 src/LocalDiplomacy.Agent/localdiplomacy_agent/koboldcpp_client.py delete mode 100644 src/LocalDiplomacy.Agent/localdiplomacy_agent/koboldcpp_process.py create mode 100644 src/LocalDiplomacy.Agent/localdiplomacy_agent/lore.py create mode 100644 src/LocalDiplomacy.Agent/localdiplomacy_agent/ollama_client.py create mode 100644 src/LocalDiplomacy.Agent/localdiplomacy_agent/qdrant_process.py create mode 100644 src/LocalDiplomacy.Agent/localdiplomacy_agent/vector_index.py create mode 100644 src/LocalDiplomacy.Agent/tests/test_full_lore_execution.py delete mode 100644 src/LocalDiplomacy.Agent/tests/test_koboldcpp_client.py delete mode 100644 src/LocalDiplomacy.Agent/tests/test_koboldcpp_process.py create mode 100644 src/LocalDiplomacy.Agent/tests/test_lore_vector_retrieval.py create mode 100644 src/LocalDiplomacy.Agent/tests/test_memory_store.py create mode 100644 src/LocalDiplomacy.Agent/tests/test_ollama_client.py diff --git a/.gitignore b/.gitignore index c0fccd5..201b7d1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,18 +3,14 @@ # Python __pycache__/ .pytest_cache/ +.tmp/ .venv/ *.pyc # Agent runtime -koboldcpp.exe -model.gguf -*.gguf data/ -src/LocalDiplomacy.Agent/data/ -src/LocalDiplomacy.Agent/koboldcpp.exe -src/LocalDiplomacy.Agent/model.gguf src/LocalDiplomacy.Agent/config.yaml +src/LocalDiplomacy.Agent/data/ # .NET/Bannerlord build output bin/ diff --git a/MEMORY_SYSTEM_PLAN.md b/MEMORY_SYSTEM_PLAN.md new file mode 100644 index 0000000..154b273 --- /dev/null +++ b/MEMORY_SYSTEM_PLAN.md @@ -0,0 +1,1050 @@ +# LocalDiplomacy Memory System Plan + +## Goal + +LocalDiplomacy should support deep NPC roleplay, persistent save-specific memory, world events, lore awareness, backstories, and background NPC activity while remaining usable with local 4B-32B models. + +The core design rule is: + +```text +Store the world outside the model. Retrieve only the tiny slice needed for the current turn. +``` + +The C# Bannerlord mod should stay focused on game integration: + +- collect current game state +- send conversation/world/action-result packets to Python +- receive assistant text and proposed game actions +- validate and execute game actions + +The Python agent should own: + +- memory storage +- save/playthrough scoping +- lore indexing +- NPC profiles and generated backstories +- world events +- task records +- background debate summaries +- prompt dossier construction +- semantic retrieval + +## Architecture + +Use both SQLite and Qdrant. + +```text +SQLite = source of truth +Qdrant = semantic search index +Ollama embedding model = turns text into searchable vectors +Ollama dialogue model = roleplay and reasoning over retrieved context +``` + +SQLite remains mandatory because it is easy to inspect, migrate, back up, and query exactly. Qdrant should be rebuildable from SQLite at any time. + +Qdrant should never be the only copy of important data. + +## Qdrant Operating Modes + +LocalDiplomacy should not require Docker for normal users. + +Support these modes: + +```text +disabled +embedded +managed_server +``` + +### disabled + +Use SQLite and SQLite FTS5 only. + +This mode is useful for early development, tests, and users who want the simplest possible install. + +### embedded + +Use Qdrant through the Python client local mode: + +```python +QdrantClient(path="./data/qdrant") +``` + +This should become the default vector mode. It persists the vector index to disk without a separate Qdrant server process. + +Benefits: + +- no Docker required +- no extra server setup +- easy mod install story +- good enough for many local campaigns + +SQLite remains the source of truth. The embedded Qdrant index can be deleted and rebuilt from SQLite. + +### managed_server + +Python starts and supervises a bundled or user-installed `qdrant.exe` process. + +This mode is for larger campaigns or heavier background simulation where a real Qdrant server process is useful. + +Responsibilities: + +- check whether Qdrant is already reachable on the configured host/port +- start `qdrant.exe` when `autostart` is enabled +- pass a local storage/config path +- wait for health check success +- stop the child process when the Python agent exits +- fall back to embedded or SQLite-only mode if configured to do so + +This mode should still be local-first and should not require Docker. + +## Data Ownership + +### C# Mod + +The C# side should not manage AI memory. It should send enough facts for Python to update memory and make decisions. + +Responsibilities: + +- send `campaign_id`, `save_id`, current day, player, NPC, scene, nearby parties, settlements, kingdom state, and recent game diffs +- execute validated `GameAction` proposals +- report action results back to Python +- send world ticks and important state changes + +### Python Agent + +The Python agent should be an always-on local service. + +Responsibilities: + +- persist all AI memory under the current `save_id` +- retrieve relevant facts before model calls +- import and index world lore markdown files +- generate first-meeting NPC profiles +- summarize conversations and background debates +- decide what memories should be stored +- return compact prompts to local models + +## Save-Scoped Storage + +All playthrough-specific records must include: + +```text +save_id +campaign_id +``` + +This prevents one campaign's Derthert, Caladog, or custom mod NPC from leaking into another playthrough. + +Suggested SQLite path: + +```text +data/localdiplomacy.sqlite3 +``` + +## SQLite Schema Plan + +### saves + +Tracks known playthroughs. + +```text +id +save_id +campaign_id +name +mod_profile +active_lore_source_id +created_at +last_seen_at +metadata_json +``` + +### characters + +Stores known game characters for a save. + +```text +id +save_id +campaign_id +character_id +name +clan_id +kingdom_id +culture_id +occupation +traits_json +last_seen_day +last_seen_at +metadata_json +``` + +### npc_profiles + +Stores generated and evolving NPC identity. + +```text +id +save_id +campaign_id +character_id +backstory +personality_json +speech_style +goals_json +fears_json +loyalties_json +relationship_to_player_json +known_history_summary +created_day +updated_day +created_at +updated_at +``` + +### memories + +Stores durable character/world facts. + +```text +id +save_id +campaign_id +subject_character_id +related_character_id +player_id +kingdom_id +location_id +category +importance +confidence +visibility +text +summary +tags_json +created_day +created_at +last_accessed_at +qdrant_point_id +metadata_json +``` + +Memory categories should include: + +```text +conversation +promise +secret +known_info +relationship +event +personality +backstory +speech_pattern +romance +death_history +visit +mentioned_entity +lie_detection +debate +task +``` + +### world_events + +Stores objective, rumored, or localized world events. + +```text +id +save_id +campaign_id +event_type +title +summary +location_id +actor_character_id +target_character_id +actor_faction_id +target_faction_id +importance +visibility +known_by_character_id +known_by_faction_id +created_day +expires_day +created_at +updated_at +qdrant_point_id +metadata_json +``` + +Visibility examples: + +```text +private +local +faction +global +rumor +``` + +### tasks + +Stores NPC commitments and ongoing assignments. + +```text +id +save_id +campaign_id +task_id +assignee_character_id +issuer_character_id +task_type +target_id +status +priority +created_day +due_day +completed_day +summary +constraints_json +result_json +created_at +updated_at +``` + +Task statuses: + +```text +proposed +active +completed +failed +cancelled +rejected +expired +``` + +### conversation_turns + +Stores raw audit/debug conversation data. + +```text +id +save_id +campaign_id +turn_id +player_id +npc_id +location_id +player_message +assistant_text +created_day +created_at +metadata_json +``` + +Raw turns should not usually go into prompts except for the most recent turns. + +### conversation_summaries + +Stores compressed relationship/context history. + +```text +id +save_id +campaign_id +player_id +npc_id +summary +turn_count +last_turn_day +updated_at +qdrant_point_id +``` + +### lore_sources + +Stores available lore files. + +```text +id +source_key +name +path +content_hash +active +created_at +updated_at +metadata_json +``` + +Examples: + +```text +base_bannerlord +realm_of_thrones +ancient_greece +``` + +### lore_chunks + +Stores indexed markdown chunks. + +```text +id +lore_source_id +chunk_key +heading_path +title +text +summary +tags_json +entities_json +qdrant_point_id +created_at +updated_at +``` + +### background_debates + +Stores summaries of NPC-to-NPC reasoning or faction debate. + +```text +id +save_id +campaign_id +debate_id +topic +participants_json +faction_ids_json +location_id +summary +outcome +importance +created_day +created_at +qdrant_point_id +metadata_json +``` + +## SQLite Indexes + +Create indexes for exact filters first. + +```sql +CREATE INDEX idx_memories_scope +ON memories(save_id, campaign_id, subject_character_id); + +CREATE INDEX idx_memories_related +ON memories(save_id, related_character_id); + +CREATE INDEX idx_memories_faction +ON memories(save_id, kingdom_id); + +CREATE INDEX idx_memories_location +ON memories(save_id, location_id); + +CREATE INDEX idx_memories_category +ON memories(save_id, category); + +CREATE INDEX idx_world_events_scope +ON world_events(save_id, campaign_id); + +CREATE INDEX idx_world_events_location +ON world_events(save_id, location_id); + +CREATE INDEX idx_world_events_factions +ON world_events(save_id, actor_faction_id, target_faction_id); + +CREATE INDEX idx_tasks_assignee +ON tasks(save_id, assignee_character_id, status); + +CREATE INDEX idx_profiles_character +ON npc_profiles(save_id, character_id); +``` + +Use SQLite FTS5 for fast keyword search: + +```text +memories_fts +world_events_fts +lore_chunks_fts +conversation_summaries_fts +background_debates_fts +``` + +FTS should index compact searchable text, not huge JSON blobs. + +## Qdrant Collections + +Use Qdrant for semantic retrieval once data grows. + +Suggested collections: + +```text +localdiplomacy_memories +localdiplomacy_world_events +localdiplomacy_lore +localdiplomacy_conversation_summaries +localdiplomacy_background_debates +``` + +Each point payload should contain enough metadata for filtering: + +```json +{ + "sqlite_table": "memories", + "sqlite_id": 123, + "save_id": "save_abc", + "campaign_id": "campaign_001", + "character_id": "lord_derthert", + "kingdom_id": "kingdom_vlandia", + "location_id": "town_sargot", + "category": "promise", + "importance": 8, + "created_day": 72.4 +} +``` + +Search pattern: + +```text +1. Embed current query. +2. Search Qdrant with metadata filters. +3. Return candidate SQLite IDs. +4. Load full records from SQLite. +5. Rerank with local scoring. +6. Build compact prompt dossier. +``` + +## Embeddings + +Embeddings convert text into vectors for semantic search. + +Use a local embedding model so the system stays offline/local. Good initial target: + +```text +Ollama + nomic-embed-text +``` + +Embeddings should be created when data is written: + +- lore import +- memory creation +- world event creation +- conversation summary update +- background debate summary creation + +At runtime, only the current query usually needs a fresh embedding. + +## Retrieval Dossier + +Before every conversation response, Python should build a compact dossier. + +Inputs: + +```text +save_id +campaign_id +player_id +npc_id +location_id +player_message +current_day +scene +nearby parties +nearby settlements +kingdom state +recent game diffs +``` + +Retrieve: + +```text +1. NPC profile +2. first-meeting backstory if needed +3. last 2-6 raw turns with this NPC +4. conversation summary for player+npc +5. top 3-8 relevant memories +6. top 2-5 relevant world events +7. active tasks for this NPC/player/location +8. top 2-5 relevant lore chunks +9. relevant background debate summaries +``` + +The model should receive a concise dossier, not raw database dumps. + +Example prompt section: + +```text +NPC PROFILE +Derthert is proud, pragmatic, protective of Vlandia, and sensitive to noble honor. + +RELEVANT MEMORIES +- The player promised Derthert they would defend Sargot if Battania attacked. +- Derthert distrusts the player's sympathy toward Battania. + +RECENT WORLD EVENTS +- Battanian raiders burned farms near Sargot on day 72. + +RELEVANT LORE +- Vlandian nobles value feudal oaths, cavalry service, inheritance, and military honor. + +CURRENT SCENE +The player is speaking with Derthert in Sargot after border raids. +``` + +## Token Budgets + +For local models, use hard budgets. + +Target for 8k context: + +```text +system instructions: 400 tokens +NPC profile: 250 tokens +current scene/game state: 500 tokens +memories: 500 tokens +world events: 400 tokens +lore: 500 tokens +recent dialogue: 500 tokens +tools/action rules: 400 tokens +response budget: 500-800 tokens +``` + +Prefer 2k-4k total prompt tokens for normal turns. + +For 4B-7B models, use smaller dossiers. Smaller models often perform better with cleaner, shorter context. + +## Lore Import + +Users should be able to select a world lore markdown file. + +Examples: + +```text +lore/base_bannerlord.md +lore/realm_of_thrones.md +lore/ancient_greece.md +``` + +Import flow: + +```text +1. Read markdown file. +2. Hash contents. +3. If unchanged, skip reimport. +4. Split by heading hierarchy. +5. Create 100-300 word chunks. +6. Extract headings, tags, and entity names. +7. Store chunks in SQLite. +8. Add chunks to FTS. +9. Embed chunks. +10. Upsert vectors into Qdrant. +``` + +At runtime, lore retrieval should consider: + +- player message +- NPC culture +- NPC kingdom/faction +- location +- mentioned entities +- current event type +- active mod profile + +Only retrieved lore chunks should enter the prompt. + +## First-Meeting Backstory Generation + +When the player meets an NPC for the first time in a save: + +```text +1. Check npc_profiles for save_id + character_id. +2. If missing, gather current NPC game stats. +3. Retrieve relevant lore chunks. +4. Retrieve recent world events affecting their faction/location. +5. Generate compact backstory/profile JSON. +6. Store it in npc_profiles. +7. Use it in future prompts. +``` + +Generation input should be small and grounded: + +```text +NPC: +- name +- clan +- kingdom +- culture +- occupation +- traits +- relation_to_player + +Relevant lore: +- retrieved lore chunks only + +Recent world events: +- retrieved world events only +``` + +Generated output: + +```json +{ + "backstory": "...", + "personality": ["proud", "cautious", "honor-bound"], + "speech_style": "formal, martial, terse", + "goals": ["protect Vlandia", "secure clan prestige"], + "fears": ["dishonor", "border collapse"], + "loyalties": ["kingdom_vlandia", "clan_dey_meroc"], + "relationship_seed": { + "trust": 15, + "respect": 20, + "suspicion": 5 + } +} +``` + +Backstories should be generated once per save unless explicitly regenerated. + +## Memory Write Flow + +After each conversation: + +```text +1. Store raw turn in conversation_turns. +2. Ask model or deterministic extractor what facts matter. +3. Store important facts in memories. +4. Update conversation summary if needed. +5. Update NPC profile if relationship/personality changed. +6. Embed new memory/summary. +7. Upsert vector into Qdrant. +``` + +Do not store every sentence as a long-term memory. + +Store atomic, useful facts: + +```text +Good: +The player promised Derthert they would defend Sargot from Battania. + +Bad: +The player said "I shall stand beside you if the storm comes, my lord..." +``` + +## World Event Flow + +World events can come from: + +- C# world ticks +- executed game actions +- rejected or failed action results +- AI-proposed events +- background debates +- major relationship/task changes + +Flow: + +```text +1. Receive event or diff. +2. Normalize into structured world_event. +3. Store in SQLite. +4. Embed summary. +5. Upsert to Qdrant. +6. Make it visible only to plausible characters/factions. +``` + +NPCs should not know all events automatically. + +Use visibility: + +```text +private +local +faction +global +rumor +``` + +## Background NPC Debates + +For performance, background debates should usually be summaries, not full chat transcripts. + +Example: + +```text +Topic: Peace with Battania +Participants: Derthert, Erdurand, local Vlandian nobles +Summary: Derthert opposed peace unless Battania pays tribute. Erdurand argued the border villages cannot survive another campaign. +Outcome: Vlandian nobles are split but open to tribute-backed peace. +``` + +Store the summary and outcome. Retrieve it when the player discusses related diplomacy. + +## Task System + +AI-created tasks should be structured records. + +The model may propose: + +```text +assign_npc_task +cancel_npc_task +update_task +``` + +But C# should validate and execute game-affecting changes. + +Python stores: + +- requested task +- who assigned it +- who accepted it +- current status +- result +- related memories/events + +Task results should feed memory: + +```text +Derthert completed the player's request to patrol near Sargot. +Derthert failed to arrive before the raid and feels ashamed. +``` + +## Prompt Construction Rules + +Never concatenate entire files or full databases into prompts. + +Allowed: + +- compact current scene +- compact NPC profile +- selected memories +- selected world events +- selected lore chunks +- selected tasks +- recent short dialogue window + +Forbidden: + +- full lore file +- full conversation history +- all world events +- all NPC memories +- raw JSON dumps larger than the budget + +## Retrieval Scoring + +Use hybrid retrieval. + +Candidate sources: + +```text +SQLite exact filters +SQLite FTS5 keyword search +Qdrant semantic search +recency/importance scoring +``` + +Example scoring: + +```text ++50 same NPC ++35 directly related NPC ++30 same kingdom/faction ++25 same location ++25 exact entity mention ++20 active task involved ++20 high importance ++15 recent ++semantic similarity score +-20 expired/stale +-30 wrong visibility +``` + +Final prompt entries should be deduplicated and summarized if too long. + +## Configuration + +Extend Python config with: + +```yaml +memory: + provider: "sqlite" + sqlite_path: "./data/localdiplomacy.sqlite3" + embedding_provider: "ollama" + embedding_model: "nomic-embed-text" + embedding_auto_pull: true + max_prompt_memories: 8 + max_prompt_lore_chunks: 5 + max_prompt_world_events: 5 + +vector_index: + mode: "embedded" # disabled | embedded | managed_server + path: "./data/qdrant" + host: "127.0.0.1" + port: 6333 + executable_path: "./qdrant/qdrant.exe" + autostart: false + startup_timeout_seconds: 30 + fallback_mode: "embedded" # embedded | disabled + +lore: + active_source: "base_bannerlord" + sources: + - key: "base_bannerlord" + name: "Base Bannerlord" + path: "./lore/base_bannerlord.md" +``` + +Ollama should be the default local model interface: + +```yaml +ollama: + base_url: "http://127.0.0.1:11434" + chat_path: "/v1/chat/completions" + model: "llama3.1:8b" + timeout_seconds: 120 + auto_pull_models: true +``` + +If the configured chat model is not installed, the Python agent should ask Ollama to download it through `/api/pull`. If that pull fails and another local model is already installed, the agent may fall back to the first installed model. If the configured embedding model is not installed, the embedding layer should also ask Ollama to pull it; if embeddings remain unavailable, it should fall back to deterministic hashing so memory continues working. + +## Implementation Phases + +### Phase 1: Persistent SQLite Memory + +- Add SQLite-backed memory store. +- Add migrations. +- Replace in-process fallback list. +- Store memory writes across restarts. +- Add tests for save-scoped memory isolation. + +Status: implemented for basic long-term memories. + +### Phase 2: Embedded Qdrant Index + +- Add `vector_index.mode = "embedded"`. +- Use `QdrantClient(path="./data/qdrant")`. +- Keep SQLite as the canonical record store. +- Store Qdrant point IDs on SQLite records. +- Add rebuild-index command that recreates embedded Qdrant from SQLite. +- Add tests for embedded Qdrant persistence across Python process restarts. + +Status: initial embedded Qdrant integration is implemented for memories. Rebuild support exists on `MemoryStore`; command-line/admin wiring still needs to be added. + +### Phase 3: Managed Qdrant Server + +- Add `vector_index.mode = "managed_server"`. +- Add a small Qdrant process manager for `qdrant.exe`. +- Check health before starting a new process. +- Start Qdrant when `autostart` is enabled. +- Use configured storage/config paths. +- Stop the child process on Python agent shutdown. +- Fall back to embedded or SQLite-only mode based on config. +- Add tests around process command construction and fallback behavior. + +Status: initial managed-server scaffolding is implemented, including reachability checks, optional autostart, process shutdown, and fallback to embedded mode. Real bundled-binary packaging still needs to be decided. + +### Phase 4: Lore Importer + +- Add markdown lore source config. +- Chunk lore by headings. +- Store `lore_sources` and `lore_chunks`. +- Add FTS5 indexing. +- Add search endpoint/tool for lore retrieval. + +Status: initial markdown lore import and embedded-Qdrant retrieval are implemented for tests. FTS indexing, config-driven file loading, and agent/tool integration still need to be added. + +### Phase 5: Retrieval Dossier + +- Add retrieval planner before model calls. +- Include NPC profile, memories, world events, lore, tasks, and recent turns. +- Enforce token budgets. +- Add tests for prompt size limits. + +### Phase 6: NPC Profiles And Backstories + +- Add `npc_profiles`. +- Generate first-meeting profiles from game stats, lore, and recent events. +- Store generated profile per save. +- Add profile update logic after important interactions. + +### Phase 7: World Events And Tasks + +- Store world ticks as normalized world events. +- Store action results as world events/memories. +- Add task records. +- Retrieve active tasks for prompts. + +### Phase 8: Semantic Index Integration + +- Add local embedding provider. +- Add Qdrant client wrapper. +- Upsert embeddings for memories, lore, events, summaries, and debates. +- Search Qdrant with save/faction/location filters. +- Load canonical records from SQLite. + +### Phase 9: Background Debates + +- Add background debate summaries. +- Store outcomes as world events and memories. +- Retrieve debate summaries for diplomacy conversations. + +### Phase 10: Maintenance Jobs + +- Summarize old conversation turns. +- Decay low-importance memories. +- Mark stale events expired. +- Rebuild missing embeddings. +- Add dashboard/debug views for memory retrieval. + +## Testing Strategy + +Add tests for: + +- save isolation +- memory persistence across `MemoryStore` instances +- lore import chunking +- FTS search +- Qdrant payload filters +- retrieval dossier token limits +- first-meeting backstory only generates once per save +- world event visibility +- task lifecycle +- rebuild Qdrant index from SQLite + +## Current Repo Gaps + +The current implementation has a useful scaffold and these remaining gaps: + +- `MemoryStore` now persists basic long-term memories in SQLite, but broader tables for profiles, lore, tasks, world events, summaries, and debates still need to be added. +- Embedded Qdrant memory indexing and initial managed `qdrant.exe` supervision exist; bundled-binary packaging/install UX still needs implementation. +- Ollama is now the active LLM interface for chat and the preferred embedding provider; the agent can ask Ollama to pull missing chat/embedding models, and deterministic hashing remains as an embedding fallback. +- Python returns `memory_writes`, but C# `ConversationResponse` does not currently model that field. +- Event log persists audit data but is not a full memory system. +- Lore can be imported/indexed through the initial `LoreStore`; config-driven world-file loading and prompt/tool integration still need implementation. +- NPC profiles/backstories are not implemented yet. +- Qdrant is currently integrated for memories only, not lore/events/summaries/debates yet. + +## Key Principle + +Depth should live in storage and retrieval, not in prompt length. + +The local model should receive: + +```text +the right 20 facts +``` + +not: + +```text +every fact the mod has ever seen +``` + +That is how LocalDiplomacy can support AI Influence-style depth while remaining practical for local models. diff --git a/src/LocalDiplomacy.Agent/.agent-server.pid b/src/LocalDiplomacy.Agent/.agent-server.pid new file mode 100644 index 0000000..890258c --- /dev/null +++ b/src/LocalDiplomacy.Agent/.agent-server.pid @@ -0,0 +1 @@ +22432 diff --git a/src/LocalDiplomacy.Agent/config.example.yaml b/src/LocalDiplomacy.Agent/config.example.yaml index f776f5c..832005e 100644 --- a/src/LocalDiplomacy.Agent/config.example.yaml +++ b/src/LocalDiplomacy.Agent/config.example.yaml @@ -2,31 +2,30 @@ server: host: "127.0.0.1" port: 8766 -koboldcpp: - autostart: true - executable_path: "./koboldcpp.exe" - model_path: "./model.gguf" - base_url: "http://127.0.0.1:5001" +ollama: + base_url: "http://127.0.0.1:11434" chat_path: "/v1/chat/completions" - model: "local-model" - port: 5001 - context_size: 8192 - extra_args: - - "--jinja" - - "--jinjatools" - startup_timeout_seconds: 180 + model: "llama3.1:8b" timeout_seconds: 120 - tool_mode: "openai_tools" - json_repair_retry: true + auto_pull_models: true memory: - provider: "disabled" - vector_store: "qdrant" - qdrant_host: "127.0.0.1" - qdrant_port: 6333 - collection: "localdiplomacy_memories" - embedder_provider: "ollama" - embedder_model: "nomic-embed-text" + provider: "sqlite" + sqlite_path: "./data/localdiplomacy.sqlite3" + embedding_provider: "ollama" + embedding_model: "nomic-embed-text" + embedding_auto_pull: true + max_prompt_memories: 8 + +vector_index: + mode: "embedded" + path: "./data/qdrant" + host: "127.0.0.1" + port: 6333 + executable_path: "./qdrant/qdrant.exe" + autostart: false + startup_timeout_seconds: 30 + fallback_mode: "disabled" event_log: sqlite_path: "./data/localdiplomacy_events.sqlite3" @@ -35,5 +34,5 @@ generation: temperature: 0.7 max_tokens: 800 # Useful for Qwen3-style thinking models when tool calls must be machine-readable. - suppress_thinking: false + suppress_thinking: true suppress_thinking_token: "/no_think" diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/app.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/app.py index a003e4f..a3da20c 100644 --- a/src/LocalDiplomacy.Agent/localdiplomacy_agent/app.py +++ b/src/LocalDiplomacy.Agent/localdiplomacy_agent/app.py @@ -1,11 +1,11 @@ from __future__ import annotations import json +import re import traceback from copy import deepcopy from contextlib import asynccontextmanager from datetime import datetime, timezone -from pathlib import Path from typing import Any from fastapi import FastAPI, Request @@ -24,19 +24,27 @@ from .contracts import ( WorldTickResponse, ) from .event_log import EventLog -from .koboldcpp_client import KoboldCppClient -from .koboldcpp_process import KoboldCppProcess +from .embeddings import create_embedder from .memory import MemoryStore +from .ollama_client import OllamaClient from .tools import ToolRegistry +from .vector_index import VectorIndex class AppState: def __init__(self) -> None: self.config = load_config() self.event_log = EventLog(resolve_runtime_path(self.config.event_log.sqlite_path)) - self.memory = MemoryStore(self.config.memory) - self.kobold_process = KoboldCppProcess(self.config.koboldcpp, Path.cwd()) - self.kobold_client = KoboldCppClient(self.config) + self.embedder = create_embedder( + self.config.memory.embedding_provider, + self.config.memory.embedding_model, + base_url=self.config.ollama.base_url, + timeout_seconds=self.config.ollama.timeout_seconds, + auto_pull=self.config.memory.embedding_auto_pull, + ) + self.vector_index = VectorIndex(self.config.vector_index, self.embedder) + self.memory = MemoryStore(self.config.memory, self.vector_index) + self.llm_client = OllamaClient(self.config) self.recent_errors: list[str] = [] self.ui_logs: list[dict[str, str]] = [] @@ -56,17 +64,18 @@ state = AppState() @asynccontextmanager async def lifespan(_: FastAPI): + state.vector_index.initialize() state.memory.initialize() state.log("info", "LocalDiplomacy.Agent starting.") - try: - state.kobold_process.ensure_started() - state.log("info", f"KoboldCpp endpoint configured as {state.config.koboldcpp.base_url}.") - except Exception as exc: # Keep service up so /health can explain the problem. - state.recent_errors.append(str(exc)) - state.log("error", str(exc)) + if state.llm_client.is_reachable(): + state.log("info", f"Ollama endpoint configured as {state.config.ollama.base_url}.") + else: + message = state.llm_client.last_error or f"Ollama is unreachable at {state.config.ollama.base_url}." + state.recent_errors.append(message) + state.log("error", message) yield state.log("info", "LocalDiplomacy.Agent stopping.") - state.kobold_process.stop() + state.vector_index.stop() app = FastAPI(title="LocalDiplomacy.Agent", version=__version__, lifespan=lifespan) @@ -91,12 +100,12 @@ def dashboard_state() -> JSONResponse: "version": __version__, "health": health_data, "debug": debug_data, - "koboldcpp": { - "base_url": state.config.koboldcpp.base_url, - "chat_path": state.config.koboldcpp.chat_path, - "model": state.config.koboldcpp.model, - "autostart": state.config.koboldcpp.autostart, - "timeout_seconds": state.config.koboldcpp.timeout_seconds, + "llm": { + "provider": "ollama", + "base_url": state.config.ollama.base_url, + "chat_path": state.config.ollama.chat_path, + "model": state.config.ollama.model, + "timeout_seconds": state.config.ollama.timeout_seconds, }, "generation": state.config.generation.model_dump(mode="json"), "logs": state.ui_logs[-100:], @@ -104,48 +113,45 @@ def dashboard_state() -> JSONResponse: ) -@app.post("/api/koboldcpp") -async def update_koboldcpp_settings(request: Request) -> JSONResponse: +@app.post("/api/ollama") +async def update_ollama_settings(request: Request) -> JSONResponse: payload = await request.json() base_url = str(payload.get("base_url") or "").strip().rstrip("/") model = str(payload.get("model") or "").strip() timeout_seconds = payload.get("timeout_seconds") - autostart = payload.get("autostart") if not base_url.startswith(("http://", "https://")): - return JSONResponse({"ok": False, "error": "KoboldCpp API URL must start with http:// or https://."}, status_code=400) + return JSONResponse({"ok": False, "error": "Ollama API URL must start with http:// or https://."}, status_code=400) - state.config.koboldcpp.base_url = base_url + state.config.ollama.base_url = base_url if model: - state.config.koboldcpp.model = model + state.config.ollama.model = model if isinstance(timeout_seconds, (int, float)) and timeout_seconds > 0: - state.config.koboldcpp.timeout_seconds = int(timeout_seconds) - if isinstance(autostart, bool): - state.config.koboldcpp.autostart = autostart + state.config.ollama.timeout_seconds = int(timeout_seconds) save_config(state.config) - state.log("info", f"KoboldCpp API settings updated: {state.config.koboldcpp.base_url}, model {state.config.koboldcpp.model}.") + state.log("info", f"Ollama API settings updated: {state.config.ollama.base_url}, model {state.config.ollama.model}.") return JSONResponse({"ok": True}) -@app.post("/api/koboldcpp/ping") -def ping_koboldcpp() -> JSONResponse: - reachable = state.kobold_process.is_reachable() - state.log("info" if reachable else "warning", f"KoboldCpp ping {'succeeded' if reachable else 'failed'} for {state.config.koboldcpp.base_url}.") - return JSONResponse({"ok": reachable, "base_url": state.config.koboldcpp.base_url}) +@app.post("/api/ollama/ping") +def ping_ollama() -> JSONResponse: + reachable = state.llm_client.is_reachable() + state.log("info" if reachable else "warning", f"Ollama ping {'succeeded' if reachable else 'failed'} for {state.config.ollama.base_url}.") + return JSONResponse({"ok": reachable, "base_url": state.config.ollama.base_url}) @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: errors = list(state.recent_errors[-5:]) - kobold = "reachable" if state.kobold_process.is_reachable() else "unreachable" - if kobold == "unreachable": - errors.extend(state.kobold_process.validate_runtime_files()) - status = "ok" if not errors and kobold == "reachable" else "degraded" + llm = "reachable" if state.llm_client.is_reachable() else "unreachable" + if llm == "unreachable" and state.llm_client.last_error: + errors.append(state.llm_client.last_error) + status = "ok" if not errors and llm == "reachable" else "degraded" return HealthResponse( status=status, agent_version=__version__, - koboldcpp=kobold, + llm=llm, memory=state.memory.status, event_log="ok", errors=errors, @@ -155,7 +161,7 @@ def health() -> HealthResponse: @app.get("/debug/status", response_model=DebugStatusResponse) def debug_status() -> DebugStatusResponse: return DebugStatusResponse( - last_koboldcpp_latency_ms=state.kobold_client.last_latency_ms, + last_llm_latency_ms=state.llm_client.last_latency_ms, memory_count_estimate=state.memory.count_estimate(), queued_action_count=0, recent_errors=state.recent_errors[-10:], @@ -171,7 +177,17 @@ async def conversation_respond(request: ConversationRequest) -> ConversationResp campaign_id=request.campaign_id, turn_id=request.turn_id, ) - registry = ToolRegistry(state.memory, state.event_log) + registry = ToolRegistry( + state.memory, + state.event_log, + { + "save_id": request.save_id, + "campaign_id": request.campaign_id, + "character_id": request.npc.id, + "kingdom_id": request.npc.kingdom_id, + "player_id": request.player.id, + }, + ) tool_schemas = _select_tool_schemas(request, registry) forced_tool_name = _forced_tool_name(tool_schemas) messages = _build_action_messages(request) if forced_tool_name else _build_messages(request) @@ -235,6 +251,9 @@ def _build_messages(request: ConversationRequest) -> list[dict[str, str]]: "Stay grounded in the supplied game state. Do not invent entity IDs. " "Use the provided tools only through the API tool-calling mechanism. " "Never write JSON arrays, XML tags, pseudo-functions, or tool calls in your visible message content. " + "Never explain your reasoning, compare candidate replies, or mention the game state packet. " + "Write exactly one concise line of NPC dialogue in the NPC's voice. " + "Do not write the player's response. " "If an action is needed, call propose_game_action. Otherwise, answer as the NPC in natural language. " "Proposed actions are requests only; the game validates them before anything happens." ) @@ -256,7 +275,7 @@ def _build_messages(request: ConversationRequest) -> list[dict[str, str]]: "role": "user", "content": ( "Game state packet follows as JSON. Use it as context, then respond to the player. " - "Visible content must be ordinary NPC dialogue, not JSON.\n\n" + "Visible content must be exactly one ordinary NPC dialogue line, not JSON, analysis, or alternatives.\n\n" f"{json.dumps(packet, ensure_ascii=False)}" ), }, @@ -394,7 +413,7 @@ async def _run_tool_loop( tool_choice: str | dict[str, Any] | None = None if index == 0 and forced_tool_name: tool_choice = {"type": "function", "function": {"name": forced_tool_name}} - data = await state.kobold_client.chat(messages, tool_schemas, tool_choice=tool_choice) or {} + data = await state.llm_client.chat(messages, tool_schemas, tool_choice=tool_choice) or {} choices = data.get("choices") or [{}] choice = choices[0] or {} message = choice.get("message") or {} @@ -403,7 +422,7 @@ async def _run_tool_loop( content = str(message.get("content") or "").strip() if _looks_like_pseudo_tool_content(content): return await _repair_visible_response(messages, content) - return content + return _clean_visible_response(content) messages.append(message) for call in tool_calls: @@ -431,6 +450,59 @@ def _looks_like_pseudo_tool_content(content: str) -> bool: ) +def _clean_visible_response(content: str) -> str: + cleaned = _strip_reasoning_markers(content).strip() + quoted = re.findall(r'"([^"\n]{1,320})"', cleaned) + if quoted: + cleaned = quoted[-1].strip() + + lines = [line.strip() for line in cleaned.splitlines() if line.strip()] + rejected_prefixes = ( + "okay", + "the user", + "my task", + "let me", + "looking at", + "the correct", + "the most", + "this response", + "therefore", + "i need to", + "i should", + ) + dialogue_lines = [ + line.strip('"') + for line in lines + if not line.lower().startswith(rejected_prefixes) + and "json" not in line.lower() + and "game state" not in line.lower() + ] + if dialogue_lines: + cleaned = dialogue_lines[0] + + sentences = re.split(r"(?<=[.!?])\s+", cleaned) + if sentences and len(cleaned) > 240: + cleaned = sentences[0] + return cleaned.strip().strip('"') or "Well met." + + +def _strip_reasoning_markers(content: str) -> str: + markers = ( + "", + "", + "Okay,", + "The most appropriate response is:", + "The correct response", + "Therefore,", + ) + cleaned = content + if "" in cleaned: + cleaned = cleaned.split("", 1)[1] + for marker in markers[:2]: + cleaned = cleaned.replace(marker, "") + return cleaned.strip() + + async def _repair_visible_response(messages: list[dict[str, Any]], bad_content: str) -> str: repair_messages = [ *messages, @@ -447,14 +519,14 @@ async def _repair_visible_response(messages: list[dict[str, Any]], bad_content: ), }, ] - data = await state.kobold_client.chat(repair_messages, []) + data = await state.llm_client.chat(repair_messages, []) choices = data.get("choices") or [{}] choice = choices[0] or {} message = choice.get("message") or {} repaired = str(message.get("content") or "").strip() if _looks_like_pseudo_tool_content(repaired): return "I understand the matter, but I will not move until the terms are made plain." - return repaired + return _clean_visible_response(repaired) DASHBOARD_HTML = """ @@ -609,22 +681,22 @@ DASHBOARD_HTML = """

Status

Agent...
-
KoboldCpp...
+
Ollama...
Memory...
Last latency...
-

KoboldCpp API

+

Ollama API

- +
- +
@@ -640,19 +712,19 @@ DASHBOARD_HTML = """ const response = await fetch('/api/dashboard'); const data = await response.json(); const agentOk = data.health.status === 'ok'; - const koboldOk = data.health.koboldcpp === 'reachable'; + const llmOk = data.health.llm === 'reachable'; document.getElementById('headline').textContent = agentOk ? 'Ready for Bannerlord' : 'Needs attention'; document.getElementById('headline').className = agentOk ? 'ok' : 'bad'; document.getElementById('agentStatus').textContent = data.health.status; document.getElementById('agentStatus').className = 'value ' + (agentOk ? 'ok' : 'bad'); - document.getElementById('koboldStatus').textContent = data.health.koboldcpp; - document.getElementById('koboldStatus').className = 'value ' + (koboldOk ? 'ok' : 'bad'); + document.getElementById('llmStatus').textContent = data.health.llm; + document.getElementById('llmStatus').className = 'value ' + (llmOk ? 'ok' : 'bad'); document.getElementById('memoryStatus').textContent = data.health.memory; - document.getElementById('latency').textContent = data.debug.last_koboldcpp_latency_ms === null ? 'none' : data.debug.last_koboldcpp_latency_ms + ' ms'; + document.getElementById('latency').textContent = data.debug.last_llm_latency_ms === null ? 'none' : data.debug.last_llm_latency_ms + ' ms'; if (!loadedConfig) { - document.getElementById('baseUrl').value = data.koboldcpp.base_url; - document.getElementById('model').value = data.koboldcpp.model; - document.getElementById('timeout').value = data.koboldcpp.timeout_seconds; + document.getElementById('baseUrl').value = data.llm.base_url; + document.getElementById('model').value = data.llm.model; + document.getElementById('timeout').value = data.llm.timeout_seconds; loadedConfig = true; } const logs = document.getElementById('logs'); @@ -668,22 +740,21 @@ DASHBOARD_HTML = """ base_url: document.getElementById('baseUrl').value, model: document.getElementById('model').value, timeout_seconds: Number(document.getElementById('timeout').value), - autostart: false }; - const response = await fetch('/api/koboldcpp', { + const response = await fetch('/api/ollama', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); if (!response.ok) { const data = await response.json(); - alert(data.error || 'Failed to save KoboldCpp settings.'); + alert(data.error || 'Failed to save Ollama settings.'); } await refresh(); } - async function pingKobold() { - await fetch('/api/koboldcpp/ping', {method: 'POST'}); + async function pingOllama() { + await fetch('/api/ollama/ping', {method: 'POST'}); await refresh(); } diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/config.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/config.py index b4747c8..dc16d4b 100644 --- a/src/LocalDiplomacy.Agent/localdiplomacy_agent/config.py +++ b/src/LocalDiplomacy.Agent/localdiplomacy_agent/config.py @@ -15,30 +15,32 @@ class ServerConfig(BaseModel): port: int = 8766 -class KoboldCppConfig(BaseModel): - autostart: bool = True - executable_path: str = "./koboldcpp.exe" - model_path: str = "./model.gguf" - base_url: str = "http://127.0.0.1:5001" +class OllamaConfig(BaseModel): + base_url: str = "http://127.0.0.1:11434" chat_path: str = "/v1/chat/completions" - model: str = "local-model" - port: int = 5001 - context_size: int = 8192 - extra_args: list[str] = Field(default_factory=lambda: ["--jinja", "--jinjatools"]) - startup_timeout_seconds: int = 180 + model: str = "llama3.1:8b" timeout_seconds: int = 120 - tool_mode: str = "openai_tools" - json_repair_retry: bool = True + auto_pull_models: bool = True class MemoryConfig(BaseModel): - provider: str = "disabled" - vector_store: str = "qdrant" - qdrant_host: str = "127.0.0.1" - qdrant_port: int = 6333 - collection: str = "localdiplomacy_memories" - embedder_provider: str = "ollama" - embedder_model: str = "nomic-embed-text" + provider: str = "sqlite" + sqlite_path: str = "./data/localdiplomacy.sqlite3" + embedding_provider: str = "ollama" + embedding_model: str = "nomic-embed-text" + embedding_auto_pull: bool = True + max_prompt_memories: int = 8 + + +class VectorIndexConfig(BaseModel): + mode: str = "embedded" + path: str = "./data/qdrant" + host: str = "127.0.0.1" + port: int = 6333 + executable_path: str = "./qdrant/qdrant.exe" + autostart: bool = False + startup_timeout_seconds: int = 30 + fallback_mode: str = "disabled" class EventLogConfig(BaseModel): @@ -48,14 +50,15 @@ class EventLogConfig(BaseModel): class GenerationConfig(BaseModel): temperature: float = 0.7 max_tokens: int = 800 - suppress_thinking: bool = False + suppress_thinking: bool = True suppress_thinking_token: str = "/no_think" class AppConfig(BaseModel): server: ServerConfig = Field(default_factory=ServerConfig) - koboldcpp: KoboldCppConfig = Field(default_factory=KoboldCppConfig) + ollama: OllamaConfig = Field(default_factory=OllamaConfig) memory: MemoryConfig = Field(default_factory=MemoryConfig) + vector_index: VectorIndexConfig = Field(default_factory=VectorIndexConfig) event_log: EventLogConfig = Field(default_factory=EventLogConfig) generation: GenerationConfig = Field(default_factory=GenerationConfig) diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/contracts.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/contracts.py index 3472b10..50bdab5 100644 --- a/src/LocalDiplomacy.Agent/localdiplomacy_agent/contracts.py +++ b/src/LocalDiplomacy.Agent/localdiplomacy_agent/contracts.py @@ -115,14 +115,14 @@ class ActionResultResponse(BaseModel): class HealthResponse(BaseModel): status: Literal["ok", "degraded", "error"] agent_version: str - koboldcpp: str + llm: str memory: str event_log: str errors: list[str] = Field(default_factory=list) class DebugStatusResponse(BaseModel): - last_koboldcpp_latency_ms: int | None = None + last_llm_latency_ms: int | None = None memory_count_estimate: int | None = None queued_action_count: int = 0 recent_errors: list[str] = Field(default_factory=list) diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/embeddings.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/embeddings.py new file mode 100644 index 0000000..1a23206 --- /dev/null +++ b/src/LocalDiplomacy.Agent/localdiplomacy_agent/embeddings.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import hashlib +import math +import re +from dataclasses import dataclass +from typing import Protocol + +import httpx + + +_TOKEN_PATTERN = re.compile(r"[A-Za-z0-9_]+") + + +class Embedder(Protocol): + @property + def dimensions(self) -> int: + ... + + def embed(self, text: str) -> list[float]: + ... + + +@dataclass(frozen=True) +class HashingEmbedder: + """Deterministic local embedding fallback used until a real local embedder is configured.""" + + dimensions: int = 384 + + def embed(self, text: str) -> list[float]: + vector = [0.0] * self.dimensions + for token in _TOKEN_PATTERN.findall(text.lower()): + digest = hashlib.blake2b(token.encode("utf-8"), digest_size=8).digest() + bucket = int.from_bytes(digest[:4], "little") % self.dimensions + sign = 1.0 if digest[4] & 1 else -1.0 + vector[bucket] += sign + + magnitude = math.sqrt(sum(value * value for value in vector)) + if magnitude == 0: + return vector + return [value / magnitude for value in vector] + + +@dataclass(frozen=True) +class OllamaEmbedder: + model: str + base_url: str = "http://127.0.0.1:11434" + timeout_seconds: int = 60 + auto_pull: bool = True + _model_checked: bool = False + + @property + def dimensions(self) -> int: + return len(self.embed("dimension probe")) + + def embed(self, text: str) -> list[float]: + self._ensure_model() + response = httpx.post( + f"{self.base_url}/api/embed", + json={"model": self.model, "input": text}, + timeout=self.timeout_seconds, + ) + response.raise_for_status() + data = response.json() + embeddings = data.get("embeddings") + if isinstance(embeddings, list) and embeddings: + return [float(value) for value in embeddings[0]] + embedding = data.get("embedding") + if isinstance(embedding, list): + return [float(value) for value in embedding] + raise ValueError("Ollama embed response did not include an embedding.") + + def _ensure_model(self) -> None: + if self._model_checked: + return + object.__setattr__(self, "_model_checked", True) + if not self.auto_pull: + return + + try: + tags = httpx.get(f"{self.base_url}/api/tags", timeout=2) + tags.raise_for_status() + models = tags.json().get("models") or [] + names = {str(model.get("name") or model.get("model")) for model in models if model.get("name") or model.get("model")} + if self.model in names: + return + except httpx.HTTPError: + return + + response = httpx.post( + f"{self.base_url}/api/pull", + json={"model": self.model, "stream": False}, + timeout=None, + ) + response.raise_for_status() + + +@dataclass +class FallbackEmbedder: + primary: Embedder + fallback: Embedder + _using_fallback: bool = False + + @property + def dimensions(self) -> int: + if self._using_fallback: + return self.fallback.dimensions + try: + return self.primary.dimensions + except Exception: + self._using_fallback = True + return self.fallback.dimensions + + def embed(self, text: str) -> list[float]: + if self._using_fallback: + return self.fallback.embed(text) + try: + return self.primary.embed(text) + except Exception: + self._using_fallback = True + return self.fallback.embed(text) + + +def create_embedder( + provider: str, + model: str, + base_url: str = "http://127.0.0.1:11434", + timeout_seconds: int = 60, + auto_pull: bool = True, +) -> Embedder: + normalized = provider.lower().strip() + if normalized in {"hashing", "local_hash", "deterministic"}: + return HashingEmbedder() + if normalized == "ollama": + return FallbackEmbedder( + primary=OllamaEmbedder(model=model, base_url=base_url, timeout_seconds=timeout_seconds, auto_pull=auto_pull), + fallback=HashingEmbedder(), + ) + + return HashingEmbedder() diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/koboldcpp_client.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/koboldcpp_client.py deleted file mode 100644 index 71a2e19..0000000 --- a/src/LocalDiplomacy.Agent/localdiplomacy_agent/koboldcpp_client.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import time -from typing import Any - -import httpx - -from .config import AppConfig - - -class KoboldCppClient: - def __init__(self, config: AppConfig): - self.config = config - self.last_latency_ms: int | None = None - - async def chat( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]], - tool_choice: str | dict[str, Any] | None = None, - ) -> dict[str, Any]: - payload: dict[str, Any] = { - "model": self.config.koboldcpp.model, - "messages": self._prepare_messages(messages), - "temperature": self.config.generation.temperature, - "max_tokens": self.config.generation.max_tokens, - } - if tools: - payload["tools"] = tools - payload["tool_choice"] = tool_choice or "auto" - - url = f"{self.config.koboldcpp.base_url}{self.config.koboldcpp.chat_path}" - started = time.perf_counter() - async with httpx.AsyncClient(timeout=self.config.koboldcpp.timeout_seconds) as client: - response = await client.post(url, json=payload) - response.raise_for_status() - self.last_latency_ms = int((time.perf_counter() - started) * 1000) - return response.json() - - def _prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - if not self.config.generation.suppress_thinking: - return messages - - token = self.config.generation.suppress_thinking_token.strip() - if not token: - return messages - - prepared: list[dict[str, Any]] = [] - token_prefix = f"{token} " - for message in messages: - copied = dict(message) - content = copied.get("content") - role = copied.get("role") - if role in {"system", "user"} and isinstance(content, str) and not content.lstrip().startswith(token): - copied["content"] = f"{token_prefix}{content}" - prepared.append(copied) - return prepared diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/koboldcpp_process.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/koboldcpp_process.py deleted file mode 100644 index af73b99..0000000 --- a/src/LocalDiplomacy.Agent/localdiplomacy_agent/koboldcpp_process.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -import subprocess -import time -from pathlib import Path - -import httpx - -from .config import KoboldCppConfig, resolve_runtime_path - - -class KoboldCppProcess: - def __init__(self, config: KoboldCppConfig, cwd: Path | None = None): - self.config = config - self.cwd = cwd or Path.cwd() - self.process: subprocess.Popen[str] | None = None - self.last_error: str | None = None - - def validate_runtime_files(self) -> list[str]: - errors: list[str] = [] - executable = resolve_runtime_path(self.config.executable_path, self.cwd) - model = resolve_runtime_path(self.config.model_path, self.cwd) - if not executable.exists(): - errors.append(f"Missing KoboldCpp executable: {executable}") - if not model.exists(): - errors.append(f"Missing KoboldCpp model: {model}") - return errors - - def is_reachable(self) -> bool: - try: - with httpx.Client(timeout=2) as client: - response = client.get(f"{self.config.base_url}/v1/models") - return response.status_code < 500 - except httpx.HTTPError: - return False - - def ensure_started(self) -> None: - if not self.config.autostart or self.is_reachable(): - return - - errors = self.validate_runtime_files() - if errors: - self.last_error = "; ".join(errors) - raise RuntimeError(self.last_error) - - executable = resolve_runtime_path(self.config.executable_path, self.cwd) - model = resolve_runtime_path(self.config.model_path, self.cwd) - command = [ - str(executable), - "--model", - str(model), - "--port", - str(self.config.port), - "--contextsize", - str(self.config.context_size), - *self.config.extra_args, - ] - - self.process = subprocess.Popen( - command, - cwd=self.cwd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - text=True, - ) - self._wait_until_ready() - - def _wait_until_ready(self) -> None: - deadline = time.monotonic() + self.config.startup_timeout_seconds - while time.monotonic() < deadline: - if self.process is not None and self.process.poll() is not None: - self.last_error = f"KoboldCpp exited early with code {self.process.returncode}" - raise RuntimeError(self.last_error) - if self.is_reachable(): - return - time.sleep(1) - - self.last_error = "Timed out waiting for KoboldCpp API readiness" - raise TimeoutError(self.last_error) - - def stop(self) -> None: - if self.process is None or self.process.poll() is not None: - return - self.process.terminate() diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/lore.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/lore.py new file mode 100644 index 0000000..95b5e5a --- /dev/null +++ b/src/LocalDiplomacy.Agent/localdiplomacy_agent/lore.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import hashlib +import re +import sqlite3 +from dataclasses import dataclass, field +from pathlib import Path + +from .config import resolve_runtime_path +from .vector_index import VectorIndex + + +_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE) + + +@dataclass +class LoreStore: + sqlite_path: str + vector_index: VectorIndex | None = None + _initialized: bool = False + _path: Path = field(init=False) + + def __post_init__(self) -> None: + self._path = resolve_runtime_path(self.sqlite_path) + + def initialize(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as connection: + connection.execute("PRAGMA journal_mode=WAL") + self._create_schema(connection) + connection.commit() + self._initialized = True + + def import_markdown(self, source_key: str, name: str, markdown: str) -> int: + self._ensure_initialized() + content_hash = hashlib.sha256(markdown.encode("utf-8")).hexdigest() + chunks = self._chunk_markdown(markdown) + with self._connect() as connection: + cursor = connection.execute( + """ + INSERT INTO lore_sources (source_key, name, content_hash) + VALUES (?, ?, ?) + ON CONFLICT(source_key) DO UPDATE SET + name = excluded.name, + content_hash = excluded.content_hash, + updated_at = CURRENT_TIMESTAMP + RETURNING id + """, + (source_key, name, content_hash), + ) + source_id = int(cursor.fetchone()["id"]) + connection.execute("DELETE FROM lore_chunks WHERE lore_source_id = ?", (source_id,)) + + count = 0 + for index, chunk in enumerate(chunks): + chunk_key = f"{source_key}:{index}" + cursor = connection.execute( + """ + INSERT INTO lore_chunks ( + lore_source_id, + source_key, + chunk_key, + heading_path, + title, + text, + summary, + tags_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, '[]') + """, + ( + source_id, + source_key, + chunk_key, + chunk["heading_path"], + chunk["title"], + chunk["text"], + chunk["summary"], + ), + ) + chunk_id = int(cursor.lastrowid) + point_id = self._upsert_vector(chunk_id, source_id, source_key, chunk) + if point_id: + connection.execute( + "UPDATE lore_chunks SET qdrant_point_id = ? WHERE id = ?", + (point_id, chunk_id), + ) + count += 1 + connection.commit() + return count + + def search(self, query: str, source_key: str | None = None, limit: int = 5) -> list[dict[str, object]]: + self._ensure_initialized() + if self.vector_index and self.vector_index.enabled: + filters = {"source_key": source_key} if source_key else {} + results = self.vector_index.search_lore(query, filters, limit) + if results: + ids = [result.sqlite_id for result in results] + rows_by_id = {int(row["id"]): row for row in self._fetch_rows_by_ids(ids)} + return [self._row_to_chunk(rows_by_id[chunk_id]) for chunk_id in ids if chunk_id in rows_by_id] + + params: list[object] = [] + where = "" + if source_key: + where = "AND source_key = ?" + params.append(source_key) + needle = f"%{query.lower()}%" + rows = self._fetch_rows( + f""" + SELECT * + FROM lore_chunks + WHERE (LOWER(text) LIKE ? OR LOWER(summary) LIKE ? OR LOWER(title) LIKE ?) + {where} + ORDER BY id + LIMIT ? + """, + [needle, needle, needle, *params, limit], + ) + return [self._row_to_chunk(row) for row in rows] + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self._path) + connection.row_factory = sqlite3.Row + return connection + + def _ensure_initialized(self) -> None: + if not self._initialized: + self.initialize() + + def _create_schema(self, connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS lore_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + content_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS lore_chunks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lore_source_id INTEGER NOT NULL, + source_key TEXT NOT NULL, + chunk_key TEXT NOT NULL, + heading_path TEXT NOT NULL, + title TEXT NOT NULL, + text TEXT NOT NULL, + summary TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + qdrant_point_id TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(lore_source_id) REFERENCES lore_sources(id) ON DELETE CASCADE + ) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_lore_chunks_source + ON lore_chunks(source_key) + """ + ) + + def _upsert_vector(self, chunk_id: int, source_id: int, source_key: str, chunk: dict[str, str]) -> str | None: + if not self.vector_index or not self.vector_index.enabled: + return None + return self.vector_index.upsert_lore_chunk( + chunk_id, + "\n".join([chunk["title"], chunk["summary"], chunk["text"]]), + { + "lore_source_id": source_id, + "source_key": source_key, + "title": chunk["title"], + }, + ) + + def _fetch_rows(self, statement: str, params: list[object]) -> list[sqlite3.Row]: + with self._connect() as connection: + return list(connection.execute(statement, params).fetchall()) + + def _fetch_rows_by_ids(self, chunk_ids: list[int]) -> list[sqlite3.Row]: + if not chunk_ids: + return [] + placeholders = ",".join("?" for _ in chunk_ids) + return self._fetch_rows(f"SELECT * FROM lore_chunks WHERE id IN ({placeholders})", list(chunk_ids)) + + @staticmethod + def _chunk_markdown(markdown: str) -> list[dict[str, str]]: + matches = list(_HEADING_PATTERN.finditer(markdown)) + if not matches: + text = markdown.strip() + return [{"title": "Lore", "heading_path": "Lore", "text": text, "summary": text[:240]}] if text else [] + + chunks: list[dict[str, str]] = [] + heading_stack: list[tuple[int, str]] = [] + for index, match in enumerate(matches): + level = len(match.group(1)) + title = match.group(2).strip() + start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(markdown) + body = markdown[start:end].strip() + heading_stack = [(existing_level, existing_title) for existing_level, existing_title in heading_stack if existing_level < level] + heading_stack.append((level, title)) + if not body: + continue + heading_path = " > ".join(existing_title for _, existing_title in heading_stack) + chunks.append( + { + "title": title, + "heading_path": heading_path, + "text": body, + "summary": body[:240], + } + ) + return chunks + + @staticmethod + def _row_to_chunk(row: sqlite3.Row) -> dict[str, object]: + return { + "id": row["id"], + "source_key": row["source_key"], + "title": row["title"], + "heading_path": row["heading_path"], + "text": row["text"], + "summary": row["summary"], + "qdrant_point_id": row["qdrant_point_id"], + } diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/memory.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/memory.py index 2691599..c0b8466 100644 --- a/src/LocalDiplomacy.Agent/localdiplomacy_agent/memory.py +++ b/src/LocalDiplomacy.Agent/localdiplomacy_agent/memory.py @@ -1,70 +1,476 @@ from __future__ import annotations +import json +import re +import sqlite3 from dataclasses import dataclass, field +from pathlib import Path from typing import Any -from .config import MemoryConfig +from .config import MemoryConfig, resolve_runtime_path +from .vector_index import VectorIndex + + +_TOKEN_PATTERN = re.compile(r"[A-Za-z0-9_]+") @dataclass class MemoryStore: config: MemoryConfig - _fallback: list[dict[str, Any]] = field(default_factory=list) - _mem0: Any = None + vector_index: VectorIndex | None = None + _initialized: bool = False + _fts_available: bool = False + _last_error: str | None = None + _path: Path = field(init=False) + + def __post_init__(self) -> None: + self._path = resolve_runtime_path(self.config.sqlite_path) def initialize(self) -> None: - if self.config.provider.lower() != "mem0": - return - - try: - from mem0 import Memory # type: ignore - except ImportError: - self._mem0 = None - return - - mem0_config = { - "vector_store": { - "provider": "qdrant", - "config": { - "host": self.config.qdrant_host, - "port": self.config.qdrant_port, - "collection_name": self.config.collection, - }, - }, - "embedder": { - "provider": self.config.embedder_provider, - "config": {"model": self.config.embedder_model}, - }, - } - self._mem0 = Memory.from_config(mem0_config) + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA foreign_keys=ON") + self._create_schema(connection) + self._fts_available = self._create_fts_schema(connection) + connection.commit() + self._initialized = True + self._last_error = None @property def status(self) -> str: - if self.config.provider.lower() == "disabled": - return "disabled" - return "reachable" if self._mem0 is not None else "fallback" + if self._last_error: + return "error" + if not self._initialized: + return "not_initialized" + sqlite_status = "sqlite_fts" if self._fts_available else "sqlite" + if self.vector_index: + return f"{sqlite_status}+vector:{self.vector_index.status}" + return sqlite_status def remember(self, text: str, metadata: dict[str, Any]) -> None: - if self._mem0 is not None: - user_id = metadata.get("campaign_id", "default") - self._mem0.add(text, user_id=user_id, metadata=metadata) - return - self._fallback.append({"text": text, "metadata": metadata}) + self._ensure_initialized() + normalized = self._normalize_metadata(metadata) + tags = normalized.pop("tags") + summary = normalized.pop("summary", None) or text + extra_metadata = { + key: value + for key, value in normalized.items() + if key + not in { + "save_id", + "campaign_id", + "character_id", + "related_character_id", + "player_id", + "kingdom_id", + "location_id", + "category", + "importance", + "confidence", + "visibility", + "created_day", + } + } + + with self._connect() as connection: + cursor = connection.execute( + """ + INSERT INTO memories ( + save_id, + campaign_id, + subject_character_id, + related_character_id, + player_id, + kingdom_id, + location_id, + category, + importance, + confidence, + visibility, + text, + summary, + tags_json, + created_day, + metadata_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + normalized.get("save_id"), + normalized.get("campaign_id"), + normalized.get("character_id"), + normalized.get("related_character_id"), + normalized.get("player_id"), + normalized.get("kingdom_id"), + normalized.get("location_id"), + normalized.get("category"), + int(normalized.get("importance") or 3), + float(normalized.get("confidence") or 1.0), + normalized.get("visibility") or "private", + text, + summary, + json.dumps(tags, ensure_ascii=False), + normalized.get("created_day"), + json.dumps(extra_metadata, ensure_ascii=False), + ), + ) + memory_id = int(cursor.lastrowid) + if self._fts_available: + connection.execute( + """ + INSERT INTO memories_fts (memory_id, text, summary, tags) + VALUES (?, ?, ?, ?) + """, + (memory_id, text, summary, " ".join(tags)), + ) + qdrant_point_id = self._upsert_vector(memory_id, text, normalized, tags) + if qdrant_point_id: + connection.execute( + "UPDATE memories SET qdrant_point_id = ? WHERE id = ?", + (qdrant_point_id, memory_id), + ) + connection.commit() def search(self, query: str, metadata: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]: - if self._mem0 is not None: - user_id = metadata.get("campaign_id", "default") - result = self._mem0.search(query, user_id=user_id, limit=limit) - return result if isinstance(result, list) else [result] - - campaign_id = metadata.get("campaign_id") - matches = [ - item - for item in self._fallback - if item["metadata"].get("campaign_id") == campaign_id - and query.lower() in item["text"].lower() - ] - return matches[:limit] + self._ensure_initialized() + normalized = self._normalize_metadata(metadata) + limit = max(1, min(int(limit), self.config.max_prompt_memories)) + vector_matches = self._search_vector(query, normalized, limit) + if vector_matches: + return vector_matches + if self._fts_available and query.strip(): + try: + matches = self._search_fts(query, normalized, limit) + if matches: + return matches + except sqlite3.OperationalError as exc: + self._last_error = str(exc) + return self._search_like(query, normalized, limit) def count_estimate(self) -> int: - return len(self._fallback) + self._ensure_initialized() + with self._connect() as connection: + row = connection.execute("SELECT COUNT(*) FROM memories").fetchone() + return int(row[0] if row else 0) + + def rebuild_vector_index(self) -> int: + self._ensure_initialized() + if not self.vector_index or not self.vector_index.enabled: + return 0 + count = 0 + with self._connect() as connection: + rows = connection.execute("SELECT * FROM memories ORDER BY id").fetchall() + for row in rows: + tags = json.loads(row["tags_json"] or "[]") + payload = self._row_to_vector_payload(row, tags) + point_id = self.vector_index.upsert_memory( + int(row["id"]), + self._index_text(row["text"], row["summary"], tags), + payload, + ) + if point_id: + connection.execute( + "UPDATE memories SET qdrant_point_id = ? WHERE id = ?", + (point_id, row["id"]), + ) + count += 1 + connection.commit() + return count + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self._path) + connection.row_factory = sqlite3.Row + return connection + + def _ensure_initialized(self) -> None: + if not self._initialized: + self.initialize() + + def _create_schema(self, connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS memory_schema ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + save_id TEXT, + campaign_id TEXT NOT NULL, + subject_character_id TEXT, + related_character_id TEXT, + player_id TEXT, + kingdom_id TEXT, + location_id TEXT, + category TEXT, + importance INTEGER NOT NULL DEFAULT 3, + confidence REAL NOT NULL DEFAULT 1.0, + visibility TEXT NOT NULL DEFAULT 'private', + text TEXT NOT NULL, + summary TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + created_day REAL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + qdrant_point_id TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}' + ) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_memories_scope + ON memories(save_id, campaign_id, subject_character_id) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_memories_campaign + ON memories(campaign_id) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_memories_faction + ON memories(save_id, kingdom_id) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_memories_location + ON memories(save_id, location_id) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_memories_category + ON memories(save_id, category) + """ + ) + connection.execute("INSERT OR IGNORE INTO memory_schema (version) VALUES (1)") + + def _create_fts_schema(self, connection: sqlite3.Connection) -> bool: + try: + connection.execute( + """ + CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts + USING fts5(memory_id UNINDEXED, text, summary, tags) + """ + ) + return True + except sqlite3.OperationalError as exc: + self._last_error = str(exc) + return False + + def _search_fts( + self, + query: str, + metadata: dict[str, Any], + limit: int, + ) -> list[dict[str, Any]]: + match_query = self._fts_query(query) + if not match_query: + return [] + where, params = self._scope_clause(metadata, "m") + rows = self._fetch_rows( + f""" + SELECT m.*, bm25(memories_fts) AS rank + FROM memories_fts + JOIN memories m ON m.id = memories_fts.memory_id + WHERE memories_fts MATCH ? + {where} + ORDER BY rank, m.importance DESC, m.id DESC + LIMIT ? + """, + [match_query, *params, limit], + ) + self._mark_accessed([int(row["id"]) for row in rows]) + return [self._row_to_memory(row) for row in rows] + + def _search_like( + self, + query: str, + metadata: dict[str, Any], + limit: int, + ) -> list[dict[str, Any]]: + where, params = self._scope_clause(metadata, "m") + text_clause = "" + if query.strip(): + text_clause = "AND (LOWER(m.text) LIKE ? OR LOWER(m.summary) LIKE ? OR LOWER(m.tags_json) LIKE ?)" + needle = f"%{query.lower()}%" + params.extend([needle, needle, needle]) + rows = self._fetch_rows( + f""" + SELECT m.* + FROM memories m + WHERE 1 = 1 + {where} + {text_clause} + ORDER BY m.importance DESC, m.id DESC + LIMIT ? + """, + [*params, limit], + ) + self._mark_accessed([int(row["id"]) for row in rows]) + return [self._row_to_memory(row) for row in rows] + + def _search_vector( + self, + query: str, + metadata: dict[str, Any], + limit: int, + ) -> list[dict[str, Any]]: + if not self.vector_index or not self.vector_index.enabled: + return [] + results = self.vector_index.search_memories(query, metadata, limit) + if not results: + return [] + ids = [result.sqlite_id for result in results] + rows_by_id = {int(row["id"]): row for row in self._fetch_rows_by_ids(ids)} + ordered_rows = [rows_by_id[memory_id] for memory_id in ids if memory_id in rows_by_id] + self._mark_accessed([int(row["id"]) for row in ordered_rows]) + return [self._row_to_memory(row) for row in ordered_rows] + + def _fetch_rows(self, statement: str, params: list[Any]) -> list[sqlite3.Row]: + with self._connect() as connection: + return list(connection.execute(statement, params).fetchall()) + + def _fetch_rows_by_ids(self, memory_ids: list[int]) -> list[sqlite3.Row]: + if not memory_ids: + return [] + placeholders = ",".join("?" for _ in memory_ids) + return self._fetch_rows(f"SELECT * FROM memories WHERE id IN ({placeholders})", list(memory_ids)) + + def _upsert_vector( + self, + memory_id: int, + text: str, + metadata: dict[str, Any], + tags: list[str], + ) -> str | None: + if not self.vector_index or not self.vector_index.enabled: + return None + payload = { + "save_id": metadata.get("save_id"), + "campaign_id": metadata.get("campaign_id"), + "character_id": metadata.get("character_id"), + "related_character_id": metadata.get("related_character_id"), + "player_id": metadata.get("player_id"), + "kingdom_id": metadata.get("kingdom_id"), + "location_id": metadata.get("location_id"), + "category": metadata.get("category"), + "importance": int(metadata.get("importance") or 3), + "visibility": metadata.get("visibility") or "private", + "tags": tags, + } + return self.vector_index.upsert_memory( + memory_id, + self._index_text(text, metadata.get("summary") or text, tags), + payload, + ) + + def _mark_accessed(self, memory_ids: list[int]) -> None: + if not memory_ids: + return + placeholders = ",".join("?" for _ in memory_ids) + with self._connect() as connection: + connection.execute( + f"UPDATE memories SET last_accessed_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})", + memory_ids, + ) + connection.commit() + + def _scope_clause(self, metadata: dict[str, Any], alias: str) -> tuple[str, list[Any]]: + clauses: list[str] = [] + params: list[Any] = [] + for field, column in ( + ("save_id", "save_id"), + ("campaign_id", "campaign_id"), + ("character_id", "subject_character_id"), + ("related_character_id", "related_character_id"), + ("player_id", "player_id"), + ("kingdom_id", "kingdom_id"), + ("location_id", "location_id"), + ("category", "category"), + ): + value = metadata.get(field) + if value is None or value == "": + continue + if field in {"save_id", "campaign_id"}: + clauses.append(f"AND {alias}.{column} = ?") + else: + clauses.append(f"AND ({alias}.{column} = ? OR {alias}.{column} IS NULL)") + params.append(value) + return "\n".join(clauses), params + + @staticmethod + def _normalize_metadata(metadata: dict[str, Any]) -> dict[str, Any]: + normalized = dict(metadata) + tags = normalized.get("tags") or [] + if isinstance(tags, str): + tags = [tags] + normalized["tags"] = [str(tag) for tag in tags if str(tag).strip()] + return normalized + + @staticmethod + def _fts_query(query: str) -> str: + tokens = [token.replace("'", "''") for token in _TOKEN_PATTERN.findall(query.lower())] + return " OR ".join(tokens[:12]) + + @staticmethod + def _row_to_memory(row: sqlite3.Row) -> dict[str, Any]: + metadata = json.loads(row["metadata_json"] or "{}") + tags = json.loads(row["tags_json"] or "[]") + scope = { + "save_id": row["save_id"], + "campaign_id": row["campaign_id"], + "character_id": row["subject_character_id"], + "related_character_id": row["related_character_id"], + "player_id": row["player_id"], + "kingdom_id": row["kingdom_id"], + "location_id": row["location_id"], + } + metadata.update( + { + **scope, + "category": row["category"], + "importance": row["importance"], + "confidence": row["confidence"], + "visibility": row["visibility"], + "tags": tags, + "created_day": row["created_day"], + "created_at": row["created_at"], + "last_accessed_at": row["last_accessed_at"], + } + ) + return { + "id": row["id"], + "text": row["text"], + "summary": row["summary"], + "metadata": metadata, + } + + @staticmethod + def _index_text(text: str, summary: str, tags: list[str]) -> str: + return "\n".join(part for part in (summary, text, " ".join(tags)) if part) + + @staticmethod + def _row_to_vector_payload(row: sqlite3.Row, tags: list[str]) -> dict[str, Any]: + return { + "save_id": row["save_id"], + "campaign_id": row["campaign_id"], + "character_id": row["subject_character_id"], + "related_character_id": row["related_character_id"], + "player_id": row["player_id"], + "kingdom_id": row["kingdom_id"], + "location_id": row["location_id"], + "category": row["category"], + "importance": row["importance"], + "visibility": row["visibility"], + "tags": tags, + } diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/mock_game.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/mock_game.py index 93bbda9..8be3163 100644 --- a/src/LocalDiplomacy.Agent/localdiplomacy_agent/mock_game.py +++ b/src/LocalDiplomacy.Agent/localdiplomacy_agent/mock_game.py @@ -22,7 +22,7 @@ from .contracts import ( ) -class MockKoboldClient: +class MockLlmClient: """Deterministic local LLM stand-in for connector testing.""" last_latency_ms: int | None = 1 @@ -168,7 +168,7 @@ def _extract_packet(content: str) -> dict[str, Any]: async def run_local_mock(command: str, args: argparse.Namespace) -> dict[str, Any]: - state.kobold_client = MockKoboldClient() # type: ignore[assignment] + state.llm_client = MockLlmClient() # type: ignore[assignment] if command == "health": return agent_health().model_dump(mode="json") @@ -243,11 +243,38 @@ async def run_http(command: str, args: argparse.Namespace) -> dict[str, Any]: return response.json() +async def run_interactive_chat(args: argparse.Namespace) -> None: + print("LocalDiplomacy chat. Type /quit to exit.") + print(f"Mode: {'mock in-process' if args.mock_agent else args.url}") + while True: + try: + message = input("You> ").strip() + except EOFError: + print() + return + if not message: + continue + if message.lower() in {"/q", "/quit", "quit", "exit"}: + return + + args.message = message + result = await run_local_mock("say", args) if args.mock_agent else await run_http("say", args) + print(f"Derthert> {result.get('assistant_text', '')}") + if result.get("game_actions"): + print("Actions:") + for action in result["game_actions"]: + print(f" - {action.get('action_type')} ({action.get('reason')})") + if result.get("memory_writes"): + print("Memory writes:") + for memory in result["memory_writes"]: + print(f" - {memory.get('text')}") + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Mock Bannerlord connector for LocalDiplomacy.Agent.") parser.add_argument("--url", default="http://127.0.0.1:8766", help="Agent URL for HTTP mode.") parser.add_argument("--timeout", type=float, default=120.0, help="HTTP timeout in seconds.") - parser.add_argument("--mock-agent", action="store_true", help="Run against the in-process agent with a fake Kobold client.") + parser.add_argument("--mock-agent", action="store_true", help="Run against the in-process agent with a fake LLM client.") subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("health") @@ -255,6 +282,8 @@ def build_parser() -> argparse.ArgumentParser: say = subparsers.add_parser("say") say.add_argument("message") + subparsers.add_parser("chat") + tick = subparsers.add_parser("tick") tick.add_argument("--day", type=float, default=1.0) @@ -270,6 +299,9 @@ def build_parser() -> argparse.ArgumentParser: async def async_main() -> int: parser = build_parser() args = parser.parse_args() + if args.command == "chat": + await run_interactive_chat(args) + return 0 if args.mock_agent: result = await run_local_mock(args.command, args) else: diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/ollama_client.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/ollama_client.py new file mode 100644 index 0000000..f9392e2 --- /dev/null +++ b/src/LocalDiplomacy.Agent/localdiplomacy_agent/ollama_client.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import time +from typing import Any + +import httpx + +from .config import AppConfig + + +class OllamaClient: + def __init__(self, config: AppConfig): + self.config = config + self.last_latency_ms: int | None = None + self.last_error: str | None = None + self._effective_model: str | None = None + + def is_reachable(self) -> bool: + try: + with httpx.Client(timeout=2) as client: + response = client.get(f"{self.config.ollama.base_url}/api/tags") + return response.status_code < 500 + except httpx.HTTPError as exc: + self.last_error = str(exc) + return False + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + tool_choice: str | dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "model": self._model(), + "messages": self._prepare_messages(messages), + "temperature": self.config.generation.temperature, + "max_tokens": self.config.generation.max_tokens, + } + if tools: + payload["tools"] = tools + payload["tool_choice"] = tool_choice or "auto" + + url = f"{self.config.ollama.base_url}{self.config.ollama.chat_path}" + started = time.perf_counter() + async with httpx.AsyncClient(timeout=self.config.ollama.timeout_seconds) as client: + response = await client.post(url, json=payload) + if response.status_code == 400 and tools and "does not support tools" in response.text: + payload.pop("tools", None) + payload.pop("tool_choice", None) + response = await client.post(url, json=payload) + response.raise_for_status() + self.last_latency_ms = int((time.perf_counter() - started) * 1000) + self.last_error = None + return response.json() + + def _model(self) -> str: + if self._effective_model: + return self._effective_model + + configured = self.config.ollama.model + installed = self._installed_models() + if configured.lower().strip() == "auto": + self._effective_model = installed[0] if installed else configured + return self._effective_model + if configured in installed: + self._effective_model = configured + return configured + + if self.config.ollama.auto_pull_models and self._pull_model(configured): + self._effective_model = configured + return configured + + self._effective_model = installed[0] if installed else configured + return self._effective_model + + def _installed_models(self) -> list[str]: + try: + with httpx.Client(timeout=2) as client: + response = client.get(f"{self.config.ollama.base_url}/api/tags") + response.raise_for_status() + models = response.json().get("models") or [] + except httpx.HTTPError as exc: + self.last_error = str(exc) + return [] + + names: list[str] = [] + for model in models: + name = str(model.get("name") or model.get("model") or "") + if not name or self._looks_like_embedding_model(model): + continue + names.append(name) + return names + + def _pull_model(self, model: str) -> bool: + try: + with httpx.Client(timeout=None) as client: + response = client.post( + f"{self.config.ollama.base_url}/api/pull", + json={"model": model, "stream": False}, + ) + response.raise_for_status() + self.last_error = None + return True + except httpx.HTTPError as exc: + self.last_error = str(exc) + return False + + @staticmethod + def _looks_like_embedding_model(model: dict[str, Any]) -> bool: + name = str(model.get("name") or model.get("model") or "").lower() + details = model.get("details") or {} + families = [str(family).lower() for family in details.get("families") or []] + family = str(details.get("family") or "").lower() + return ( + "embed" in name + or "embedding" in name + or family in {"bert", "nomic-bert"} + or any(family_name in {"bert", "nomic-bert"} or "embed" in family_name for family_name in families) + ) + + def _prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not self.config.generation.suppress_thinking: + return messages + + token = self.config.generation.suppress_thinking_token.strip() + if not token: + return messages + + prepared: list[dict[str, Any]] = [] + token_prefix = f"{token} " + for message in messages: + copied = dict(message) + content = copied.get("content") + role = copied.get("role") + if role in {"system", "user"} and isinstance(content, str) and not content.lstrip().startswith(token): + copied["content"] = f"{token_prefix}{content}" + prepared.append(copied) + return prepared diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/qdrant_process.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/qdrant_process.py new file mode 100644 index 0000000..a7cb8d5 --- /dev/null +++ b/src/LocalDiplomacy.Agent/localdiplomacy_agent/qdrant_process.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import os +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path + +import httpx + +from .config import VectorIndexConfig, resolve_runtime_path + + +@dataclass +class QdrantManagedProcess: + config: VectorIndexConfig + _process: subprocess.Popen[bytes] | None = None + + def is_reachable(self) -> bool: + try: + with httpx.Client(timeout=1.0) as client: + response = client.get(f"http://{self.config.host}:{self.config.port}/healthz") + return response.status_code < 500 + except httpx.HTTPError: + return False + + def ensure_started(self) -> None: + if self.is_reachable(): + return + if not self.config.autostart: + raise RuntimeError("Qdrant server is not reachable and autostart is disabled.") + + executable = resolve_runtime_path(self.config.executable_path) + if not executable.exists(): + raise FileNotFoundError(f"Qdrant executable not found: {executable}") + + storage_path = resolve_runtime_path(self.config.path) + storage_path.mkdir(parents=True, exist_ok=True) + env = { + **os.environ, + "QDRANT__SERVICE__HOST": self.config.host, + "QDRANT__SERVICE__HTTP_PORT": str(self.config.port), + "QDRANT__STORAGE__STORAGE_PATH": str(storage_path), + } + creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + self._process = subprocess.Popen( + [str(executable)], + cwd=str(executable.parent), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=creation_flags, + ) + self._wait_until_reachable() + + def stop(self) -> None: + if self._process is None or self._process.poll() is not None: + return + self._process.terminate() + try: + self._process.wait(timeout=10) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=5) + + def _wait_until_reachable(self) -> None: + deadline = time.monotonic() + self.config.startup_timeout_seconds + while time.monotonic() < deadline: + if self.is_reachable(): + return + if self._process is not None and self._process.poll() is not None: + raise RuntimeError(f"Qdrant exited early with code {self._process.returncode}.") + time.sleep(0.25) + raise TimeoutError("Timed out waiting for Qdrant to become reachable.") diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/tools.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/tools.py index aed6049..be8ddbc 100644 --- a/src/LocalDiplomacy.Agent/localdiplomacy_agent/tools.py +++ b/src/LocalDiplomacy.Agent/localdiplomacy_agent/tools.py @@ -14,9 +14,10 @@ ToolHandler = Callable[[dict[str, Any]], dict[str, Any]] class ToolRegistry: - def __init__(self, memory: MemoryStore, event_log: EventLog): + def __init__(self, memory: MemoryStore, event_log: EventLog, default_scope: dict[str, Any] | None = None): self.memory = memory self.event_log = event_log + self.default_scope = default_scope or {} self.queued_actions: list[GameAction] = [] self.memory_writes: list[MemoryWrite] = [] @@ -138,6 +139,7 @@ class ToolRegistry: "Search local long-term memory for relevant facts.", { "query": {"type": "string"}, + "save_id": {"type": "string"}, "campaign_id": {"type": "string"}, "character_id": {"type": "string"}, "kingdom_id": {"type": "string"}, @@ -150,6 +152,7 @@ class ToolRegistry: "Store an atomic long-term memory fact: conversations, secrets, known info, relationships, visits, events, promises, or personality changes.", { "text": {"type": "string"}, + "save_id": {"type": "string"}, "campaign_id": {"type": "string"}, "character_id": {"type": "string"}, "kingdom_id": {"type": "string"}, @@ -180,6 +183,7 @@ class ToolRegistry: "analyze_lie", "Analyze whether a player statement conflicts with known memories or live state; returns a recommendation, not a game mutation.", { + "save_id": {"type": "string"}, "campaign_id": {"type": "string"}, "speaker_id": {"type": "string"}, "listener_id": {"type": "string"}, @@ -330,9 +334,10 @@ class ToolRegistry: def _search_memory(self, args: dict[str, Any]) -> dict[str, Any]: metadata = { - "campaign_id": args.get("campaign_id"), - "character_id": args.get("character_id"), - "kingdom_id": args.get("kingdom_id"), + "save_id": args.get("save_id") or self.default_scope.get("save_id"), + "campaign_id": args.get("campaign_id") or self.default_scope.get("campaign_id"), + "character_id": args.get("character_id") or self.default_scope.get("character_id"), + "kingdom_id": args.get("kingdom_id") or self.default_scope.get("kingdom_id"), } memories = self.memory.search(args["query"], metadata, int(args.get("limit", 5))) return {"ok": True, "memories": memories} @@ -348,9 +353,10 @@ class ToolRegistry: def _remember_fact(self, args: dict[str, Any]) -> dict[str, Any]: scope = { - "campaign_id": args.get("campaign_id"), - "character_id": args.get("character_id"), - "kingdom_id": args.get("kingdom_id"), + "save_id": args.get("save_id") or self.default_scope.get("save_id"), + "campaign_id": args.get("campaign_id") or self.default_scope.get("campaign_id"), + "character_id": args.get("character_id") or self.default_scope.get("character_id"), + "kingdom_id": args.get("kingdom_id") or self.default_scope.get("kingdom_id"), } write = MemoryWrite( scope=scope, @@ -371,7 +377,11 @@ class ToolRegistry: def _analyze_lie(self, args: dict[str, Any]) -> dict[str, Any]: memories = self.memory.search( args["claim"], - {"campaign_id": args.get("campaign_id"), "character_id": args.get("listener_id")}, + { + "save_id": args.get("save_id") or self.default_scope.get("save_id"), + "campaign_id": args.get("campaign_id") or self.default_scope.get("campaign_id"), + "character_id": args.get("listener_id"), + }, 5, ) return { diff --git a/src/LocalDiplomacy.Agent/localdiplomacy_agent/vector_index.py b/src/LocalDiplomacy.Agent/localdiplomacy_agent/vector_index.py new file mode 100644 index 0000000..1f52187 --- /dev/null +++ b/src/LocalDiplomacy.Agent/localdiplomacy_agent/vector_index.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .config import VectorIndexConfig, resolve_runtime_path +from .embeddings import Embedder +from .qdrant_process import QdrantManagedProcess + + +MEMORY_COLLECTION = "localdiplomacy_memories" +LORE_COLLECTION = "localdiplomacy_lore" + + +@dataclass +class VectorSearchResult: + sqlite_id: int + score: float + + +@dataclass +class VectorIndex: + config: VectorIndexConfig + embedder: Embedder + _client: Any = None + _managed_process: QdrantManagedProcess | None = None + _status: str = "disabled" + _last_error: str | None = None + _path: Path = field(init=False) + + def __post_init__(self) -> None: + self._path = resolve_runtime_path(self.config.path) + + @property + def status(self) -> str: + if self._last_error: + return f"error:{self._last_error}" + return self._status + + @property + def enabled(self) -> bool: + return self._client is not None + + def initialize(self) -> None: + mode = self.config.mode.lower().strip() + if mode == "disabled": + self._status = "disabled" + return + if mode == "embedded": + self._initialize_embedded() + return + if mode == "managed_server": + self._initialize_managed_server() + return + self._last_error = f"unknown_mode:{self.config.mode}" + self._status = "disabled" + + def upsert_memory(self, sqlite_id: int, text: str, payload: dict[str, Any]) -> str | None: + return self._upsert(MEMORY_COLLECTION, "memories", sqlite_id, text, payload) + + def upsert_lore_chunk(self, sqlite_id: int, text: str, payload: dict[str, Any]) -> str | None: + return self._upsert(LORE_COLLECTION, "lore_chunks", sqlite_id, text, payload) + + def _upsert( + self, + collection_name: str, + sqlite_table: str, + sqlite_id: int, + text: str, + payload: dict[str, Any], + ) -> str | None: + if not self._client: + return None + try: + vector = self.embedder.embed(text) + models = self._models() + self._client.upsert( + collection_name=collection_name, + points=[ + models.PointStruct( + id=sqlite_id, + vector=vector, + payload={ + **{key: value for key, value in payload.items() if value is not None}, + "sqlite_table": sqlite_table, + "sqlite_id": sqlite_id, + }, + ) + ], + ) + except Exception as exc: # qdrant-client exceptions vary by backend. + self._last_error = str(exc) + return None + return str(sqlite_id) + + def search_memories(self, query: str, filters: dict[str, Any], limit: int) -> list[VectorSearchResult]: + return self._search(MEMORY_COLLECTION, query, filters, limit) + + def search_lore(self, query: str, filters: dict[str, Any], limit: int) -> list[VectorSearchResult]: + return self._search(LORE_COLLECTION, query, filters, limit) + + def _search( + self, + collection_name: str, + query: str, + filters: dict[str, Any], + limit: int, + ) -> list[VectorSearchResult]: + if not self._client or not query.strip(): + return [] + try: + vector = self.embedder.embed(query) + result = self._query_points(collection_name, vector, filters, limit) + except Exception as exc: + self._last_error = str(exc) + return [] + return [ + VectorSearchResult( + sqlite_id=int(point.payload.get("sqlite_id")), + score=float(getattr(point, "score", 0.0)), + ) + for point in result + if getattr(point, "payload", None) and point.payload.get("sqlite_id") is not None + ] + + def stop(self) -> None: + if self._managed_process is not None: + self._managed_process.stop() + + def _initialize_embedded(self) -> None: + try: + from qdrant_client import QdrantClient + except ImportError: + self._last_error = "qdrant_client_not_installed" + self._status = "disabled" + return + + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._client = QdrantClient(path=str(self._path)) + self._ensure_collections() + self._status = "embedded" + self._last_error = None + except Exception as exc: + self._client = None + self._last_error = str(exc) + self._status = "disabled" + + def _initialize_managed_server(self) -> None: + try: + from qdrant_client import QdrantClient + except ImportError: + self._last_error = "qdrant_client_not_installed" + self._status = "disabled" + return + + self._managed_process = QdrantManagedProcess(self.config) + try: + self._managed_process.ensure_started() + self._client = QdrantClient(host=self.config.host, port=self.config.port) + self._ensure_collections() + self._status = "managed_server" + self._last_error = None + except Exception as exc: + self._client = None + self._last_error = str(exc) + if self.config.fallback_mode.lower().strip() == "embedded": + self._initialize_embedded() + else: + self._status = "disabled" + + def _ensure_collections(self) -> None: + models = self._models() + for collection_name in (MEMORY_COLLECTION, LORE_COLLECTION): + if not self._client.collection_exists(collection_name): + self._client.create_collection( + collection_name=collection_name, + vectors_config=models.VectorParams( + size=self.embedder.dimensions, + distance=models.Distance.COSINE, + ), + ) + + def _query_points(self, collection_name: str, vector: list[float], filters: dict[str, Any], limit: int) -> list[Any]: + query_filter = self._build_filter(filters) + if hasattr(self._client, "query_points"): + response = self._client.query_points( + collection_name=collection_name, + query=vector, + query_filter=query_filter, + limit=limit, + with_payload=True, + ) + return list(response.points) + return list( + self._client.search( + collection_name=collection_name, + query_vector=vector, + query_filter=query_filter, + limit=limit, + with_payload=True, + ) + ) + + @staticmethod + def _models() -> Any: + from qdrant_client import models + + return models + + def _build_filter(self, filters: dict[str, Any]) -> Any: + must = [] + models = self._models() + for key in ( + "save_id", + "campaign_id", + "character_id", + "kingdom_id", + "location_id", + "category", + "lore_source_id", + "source_key", + ): + value = filters.get(key) + if value is None or value == "": + continue + must.append(models.FieldCondition(key=key, match=models.MatchValue(value=value))) + if not must: + return None + return models.Filter(must=must) diff --git a/src/LocalDiplomacy.Agent/pyproject.toml b/src/LocalDiplomacy.Agent/pyproject.toml index 484e042..06a024a 100644 --- a/src/LocalDiplomacy.Agent/pyproject.toml +++ b/src/LocalDiplomacy.Agent/pyproject.toml @@ -7,15 +7,12 @@ dependencies = [ "fastapi>=0.115.0", "httpx>=0.27.0", "pydantic>=2.8.0", + "qdrant-client>=1.10.0", "pyyaml>=6.0.0", "uvicorn[standard]>=0.30.0", ] [project.optional-dependencies] -memory = [ - "mem0ai>=0.1.0", - "qdrant-client>=1.10.0", -] test = [ "pytest>=8.2.0", ] diff --git a/src/LocalDiplomacy.Agent/tests/test_full_lore_execution.py b/src/LocalDiplomacy.Agent/tests/test_full_lore_execution.py new file mode 100644 index 0000000..942cda0 --- /dev/null +++ b/src/LocalDiplomacy.Agent/tests/test_full_lore_execution.py @@ -0,0 +1,85 @@ +from localdiplomacy_agent.config import VectorIndexConfig +from localdiplomacy_agent.embeddings import HashingEmbedder +from localdiplomacy_agent.lore import LoreStore +from localdiplomacy_agent.vector_index import VectorIndex + + +class ConsoleDemoAi: + def answer(self, npc_name: str, player_question: str, lore_chunks: list[dict[str, object]]) -> str: + lore_context = "\n\n".join( + f"{chunk['heading_path']}\n{chunk['text']}" + for chunk in lore_chunks + ) + print("\n--- PROMPT LORE CONTEXT GIVEN TO AI ---") + print(lore_context) + print("--- END PROMPT LORE CONTEXT ---\n") + + assert "Moonlit Compact" in lore_context + assert "silver bells" in lore_context + return ( + f"{npc_name}: You ask why our envoys wear silver bells? " + "It is the sign of the Moonlit Compact. In Aurelian custom, " + "no envoy bearing those bells may be harmed before moonset." + ) + + +def test_full_dummy_lore_execution_prints_console_trace(tmp_path): + dummy_world_lore = """ +# Aurelian Marches + +## Moonlit Compact + +The Moonlit Compact is the oldest diplomatic law of the Aurelian Marches. Envoys who wear silver bells are protected until moonset, even when they cross enemy roads during wartime. + +## Ember Host + +The Ember Host is a militant order that guards mountain passes. Its captains mark treaties with red wax and broken arrowheads. + +## Glass Abbey + +The Glass Abbey records noble bloodlines and refuses entry to anyone carrying an unsheathed blade. +""" + print("\n=== FULL LORE EXECUTION DEMO ===") + print(f"SQLite path: {tmp_path / 'lore.sqlite3'}") + print(f"Qdrant path: {tmp_path / 'qdrant'}") + + vector_index = VectorIndex( + VectorIndexConfig(mode="embedded", path=str(tmp_path / "qdrant")), + HashingEmbedder(), + ) + vector_index.initialize() + print(f"Vector index status: {vector_index.status}") + + lore = LoreStore(str(tmp_path / "lore.sqlite3"), vector_index) + lore.initialize() + imported_count = lore.import_markdown( + "aurelian_marches", + "Aurelian Marches Demo Lore", + dummy_world_lore, + ) + print(f"Imported lore chunks: {imported_count}") + + player_question = "Why does this envoy wear silver bells during war?" + retrieved = lore.search(player_question, source_key="aurelian_marches", limit=2) + print(f"Query: {player_question}") + print(f"Retrieved chunks: {len(retrieved)}") + for index, chunk in enumerate(retrieved, start=1): + print(f"\nRetrieved #{index}") + print(f" id: {chunk['id']}") + print(f" title: {chunk['title']}") + print(f" heading_path: {chunk['heading_path']}") + print(f" qdrant_point_id: {chunk['qdrant_point_id']}") + print(f" summary: {chunk['summary']}") + + answer = ConsoleDemoAi().answer( + npc_name="Lady Maravel", + player_question=player_question, + lore_chunks=retrieved, + ) + print("--- FINAL AI-STYLE ANSWER ---") + print(answer) + print("=== END FULL LORE EXECUTION DEMO ===\n") + + assert imported_count == 3 + assert retrieved[0]["title"] == "Moonlit Compact" + assert "silver bells" in answer diff --git a/src/LocalDiplomacy.Agent/tests/test_koboldcpp_client.py b/src/LocalDiplomacy.Agent/tests/test_koboldcpp_client.py deleted file mode 100644 index a3ce143..0000000 --- a/src/LocalDiplomacy.Agent/tests/test_koboldcpp_client.py +++ /dev/null @@ -1,21 +0,0 @@ -from localdiplomacy_agent.config import AppConfig, GenerationConfig -from localdiplomacy_agent.koboldcpp_client import KoboldCppClient - - -def test_suppress_thinking_prefixes_text_messages_once(): - client = KoboldCppClient( - AppConfig(generation=GenerationConfig(suppress_thinking=True)) - ) - - messages = [ - {"role": "system", "content": "Use tools when needed."}, - {"role": "user", "content": "/no_think Already prefixed."}, - {"role": "tool", "tool_call_id": "call_1", "content": "{}"}, - ] - - prepared = client._prepare_messages(messages) - - assert prepared[0]["content"] == "/no_think Use tools when needed." - assert prepared[1]["content"] == "/no_think Already prefixed." - assert prepared[2]["content"] == "{}" - assert messages[0]["content"] == "Use tools when needed." diff --git a/src/LocalDiplomacy.Agent/tests/test_koboldcpp_process.py b/src/LocalDiplomacy.Agent/tests/test_koboldcpp_process.py deleted file mode 100644 index 1b28641..0000000 --- a/src/LocalDiplomacy.Agent/tests/test_koboldcpp_process.py +++ /dev/null @@ -1,13 +0,0 @@ -from pathlib import Path - -from localdiplomacy_agent.config import KoboldCppConfig -from localdiplomacy_agent.koboldcpp_process import KoboldCppProcess - - -def test_runtime_file_validation_reports_missing_files(tmp_path: Path): - process = KoboldCppProcess(KoboldCppConfig(), cwd=tmp_path) - - errors = process.validate_runtime_files() - - assert any("koboldcpp.exe" in error for error in errors) - assert any("model.gguf" in error for error in errors) diff --git a/src/LocalDiplomacy.Agent/tests/test_lore_vector_retrieval.py b/src/LocalDiplomacy.Agent/tests/test_lore_vector_retrieval.py new file mode 100644 index 0000000..6027fa6 --- /dev/null +++ b/src/LocalDiplomacy.Agent/tests/test_lore_vector_retrieval.py @@ -0,0 +1,44 @@ +from localdiplomacy_agent.config import VectorIndexConfig +from localdiplomacy_agent.embeddings import HashingEmbedder +from localdiplomacy_agent.lore import LoreStore +from localdiplomacy_agent.vector_index import VectorIndex + + +class LoreAwareFakeAi: + def answer(self, player_question: str, lore_chunks: list[dict[str, object]]) -> str: + context = "\n".join(str(chunk["text"]) for chunk in lore_chunks) + assert "Dawn Court" in context + assert "sun oath" in context + return "The Dawn Court binds its knights by the sun oath before they ride from Auric Gate." + + +def test_dummy_world_lore_retrieved_from_vector_database_and_used_by_ai(tmp_path): + dummy_lore = """ +# Kingdoms of Testoria + +## Dawn Court + +The Dawn Court rules from Auric Gate. Its knights swear the sun oath, a vow to protect travelers at first light and never draw steel after sunset unless defending a guest. + +## Ashen League + +The Ashen League is a merchant republic that settles disputes through masked arbitration and river tolls. +""" + vector_index = VectorIndex( + VectorIndexConfig(mode="embedded", path=str(tmp_path / "qdrant")), + HashingEmbedder(), + ) + vector_index.initialize() + lore = LoreStore(str(tmp_path / "lore.sqlite3"), vector_index) + lore.initialize() + + imported = lore.import_markdown("dummy_testoria", "Testoria Dummy Lore", dummy_lore) + chunks = lore.search("Who swears the sun oath at Auric Gate?", source_key="dummy_testoria", limit=1) + answer = LoreAwareFakeAi().answer("Who swears the sun oath?", chunks) + + assert imported == 2 + assert vector_index.status == "embedded" + assert chunks[0]["title"] == "Dawn Court" + assert chunks[0]["qdrant_point_id"] == "1" + assert "Dawn Court" in answer + assert "sun oath" in answer diff --git a/src/LocalDiplomacy.Agent/tests/test_memory_store.py b/src/LocalDiplomacy.Agent/tests/test_memory_store.py new file mode 100644 index 0000000..8502075 --- /dev/null +++ b/src/LocalDiplomacy.Agent/tests/test_memory_store.py @@ -0,0 +1,151 @@ +import sqlite3 + +from localdiplomacy_agent.config import MemoryConfig +from localdiplomacy_agent.config import VectorIndexConfig +from localdiplomacy_agent.embeddings import HashingEmbedder +from localdiplomacy_agent.event_log import EventLog +from localdiplomacy_agent.memory import MemoryStore +from localdiplomacy_agent.tools import ToolRegistry +from localdiplomacy_agent.vector_index import VectorIndex + + +def test_memory_store_persists_across_instances(tmp_path): + sqlite_path = tmp_path / "memory.sqlite3" + first = MemoryStore(MemoryConfig(sqlite_path=str(sqlite_path))) + first.initialize() + first.remember( + "The player promised Derthert they would defend Sargot from Battania.", + { + "save_id": "save-a", + "campaign_id": "campaign", + "character_id": "lord_derthert", + "kingdom_id": "kingdom_vlandia", + "category": "promise", + "importance": 8, + "tags": ["sargot", "battania"], + }, + ) + + second = MemoryStore(MemoryConfig(sqlite_path=str(sqlite_path))) + second.initialize() + + matches = second.search( + "defend Sargot", + { + "save_id": "save-a", + "campaign_id": "campaign", + "character_id": "lord_derthert", + }, + ) + + assert second.count_estimate() == 1 + assert matches[0]["text"] == "The player promised Derthert they would defend Sargot from Battania." + assert matches[0]["metadata"]["category"] == "promise" + assert matches[0]["metadata"]["importance"] == 8 + + +def test_memory_store_isolates_saves(tmp_path): + store = MemoryStore(MemoryConfig(sqlite_path=str(tmp_path / "memory.sqlite3"))) + store.initialize() + store.remember( + "The player promised Derthert they would defend Sargot.", + {"save_id": "save-a", "campaign_id": "campaign", "character_id": "lord_derthert"}, + ) + store.remember( + "The player betrayed Derthert near Sargot.", + {"save_id": "save-b", "campaign_id": "campaign", "character_id": "lord_derthert"}, + ) + + matches = store.search( + "Derthert Sargot", + {"save_id": "save-a", "campaign_id": "campaign", "character_id": "lord_derthert"}, + limit=5, + ) + + assert len(matches) == 1 + assert "promised" in matches[0]["text"] + + +def test_tool_registry_injects_default_save_scope(tmp_path): + memory = MemoryStore(MemoryConfig(sqlite_path=str(tmp_path / "memory.sqlite3"))) + registry = ToolRegistry( + memory, + EventLog(tmp_path / "events.sqlite3"), + { + "save_id": "save-a", + "campaign_id": "campaign", + "character_id": "lord_derthert", + "kingdom_id": "kingdom_vlandia", + }, + ) + + registry.dispatch( + "remember_fact", + '{"text":"The player promised to defend Sargot.","category":"promise"}', + ) + + matches = memory.search( + "defend Sargot", + {"save_id": "save-a", "campaign_id": "campaign", "character_id": "lord_derthert"}, + ) + + assert len(matches) == 1 + assert matches[0]["metadata"]["save_id"] == "save-a" + assert matches[0]["metadata"]["campaign_id"] == "campaign" + + +def test_memory_store_upserts_embedded_vector_index(tmp_path): + sqlite_path = tmp_path / "memory.sqlite3" + vector_index = VectorIndex( + VectorIndexConfig(mode="embedded", path=str(tmp_path / "qdrant")), + HashingEmbedder(), + ) + vector_index.initialize() + memory = MemoryStore(MemoryConfig(sqlite_path=str(sqlite_path)), vector_index) + memory.initialize() + + memory.remember( + "The player promised Derthert they would defend Sargot from Battania.", + { + "save_id": "save-a", + "campaign_id": "campaign", + "character_id": "lord_derthert", + "kingdom_id": "kingdom_vlandia", + "category": "promise", + "tags": ["sargot", "battania"], + }, + ) + + matches = memory.search( + "promise defend Sargot", + { + "save_id": "save-a", + "campaign_id": "campaign", + "character_id": "lord_derthert", + "category": "promise", + }, + ) + + with sqlite3.connect(sqlite_path) as connection: + row = connection.execute("SELECT qdrant_point_id FROM memories").fetchone() + + assert vector_index.status == "embedded" + assert row[0] == "1" + assert matches[0]["id"] == 1 + + +def test_managed_qdrant_falls_back_to_embedded_when_unavailable(tmp_path): + vector_index = VectorIndex( + VectorIndexConfig( + mode="managed_server", + path=str(tmp_path / "qdrant"), + port=65333, + autostart=False, + fallback_mode="embedded", + ), + HashingEmbedder(), + ) + + vector_index.initialize() + + assert vector_index.status == "embedded" diff --git a/src/LocalDiplomacy.Agent/tests/test_ollama_client.py b/src/LocalDiplomacy.Agent/tests/test_ollama_client.py new file mode 100644 index 0000000..d0d9e6f --- /dev/null +++ b/src/LocalDiplomacy.Agent/tests/test_ollama_client.py @@ -0,0 +1,203 @@ +from localdiplomacy_agent.config import AppConfig, GenerationConfig +from localdiplomacy_agent.config import OllamaConfig +from localdiplomacy_agent.ollama_client import OllamaClient + +import pytest + + +def test_suppress_thinking_prefixes_text_messages_once(): + client = OllamaClient( + AppConfig(generation=GenerationConfig(suppress_thinking=True)) + ) + + messages = [ + {"role": "system", "content": "Use tools when needed."}, + {"role": "user", "content": "/no_think Already prefixed."}, + {"role": "tool", "tool_call_id": "call_1", "content": "{}"}, + ] + + prepared = client._prepare_messages(messages) + + assert prepared[0]["content"] == "/no_think Use tools when needed." + assert prepared[1]["content"] == "/no_think Already prefixed." + assert prepared[2]["content"] == "{}" + assert messages[0]["content"] == "Use tools when needed." + + +def test_model_falls_back_to_first_installed_ollama_model(monkeypatch): + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return {"models": [{"name": "installed-model:latest"}]} + + class FakeClient: + def __init__(self, timeout): + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, url): + return FakeResponse() + + monkeypatch.setattr("localdiplomacy_agent.ollama_client.httpx.Client", FakeClient) + + client = OllamaClient(AppConfig(ollama=OllamaConfig(model="missing-model", auto_pull_models=False))) + + assert client._model() == "installed-model:latest" + + +def test_auto_model_uses_first_installed_ollama_model(monkeypatch): + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return {"models": [{"name": "installed-model:latest"}]} + + class FakeClient: + def __init__(self, timeout): + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, url): + return FakeResponse() + + monkeypatch.setattr("localdiplomacy_agent.ollama_client.httpx.Client", FakeClient) + + client = OllamaClient(AppConfig(ollama=OllamaConfig(model="auto", auto_pull_models=True))) + + assert client._model() == "installed-model:latest" + + +def test_auto_model_skips_embedding_models(monkeypatch): + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return { + "models": [ + {"name": "nomic-embed-text:latest", "details": {"family": "bert", "families": ["bert"]}}, + {"name": "chat-model:latest", "details": {"family": "qwen2", "families": ["qwen2"]}}, + ] + } + + class FakeClient: + def __init__(self, timeout): + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, url): + return FakeResponse() + + monkeypatch.setattr("localdiplomacy_agent.ollama_client.httpx.Client", FakeClient) + + client = OllamaClient(AppConfig(ollama=OllamaConfig(model="auto", auto_pull_models=True))) + + assert client._model() == "chat-model:latest" + + +def test_model_pulls_missing_configured_model(monkeypatch): + calls = [] + + class FakeResponse: + def __init__(self, data): + self.data = data + + def raise_for_status(self): + return None + + def json(self): + return self.data + + class FakeClient: + def __init__(self, timeout): + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, url): + return FakeResponse({"models": []}) + + def post(self, url, json): + calls.append((url, json)) + return FakeResponse({"status": "success"}) + + monkeypatch.setattr("localdiplomacy_agent.ollama_client.httpx.Client", FakeClient) + + client = OllamaClient(AppConfig(ollama=OllamaConfig(model="llama3.1:8b", auto_pull_models=True))) + + assert client._model() == "llama3.1:8b" + assert calls == [ + ( + "http://127.0.0.1:11434/api/pull", + {"model": "llama3.1:8b", "stream": False}, + ) + ] + + +@pytest.mark.anyio +async def test_chat_retries_without_tools_when_model_does_not_support_tools(monkeypatch): + calls = [] + + class FakeResponse: + def __init__(self, status_code, text, data): + self.status_code = status_code + self.text = text + self.data = data + + def raise_for_status(self): + if self.status_code >= 400: + raise AssertionError("fallback response should be successful") + + def json(self): + return self.data + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json): + calls.append(dict(json)) + if len(calls) == 1: + return FakeResponse(400, "model does not support tools", {}) + return FakeResponse(200, "ok", {"choices": [{"message": {"content": "Hello."}}]}) + + monkeypatch.setattr("localdiplomacy_agent.ollama_client.httpx.AsyncClient", FakeAsyncClient) + + client = OllamaClient(AppConfig(ollama=OllamaConfig(model="Qwen3-4B-abliterated-q4_k_m"))) + client._effective_model = "Qwen3-4B-abliterated-q4_k_m" + result = await client.chat( + [{"role": "user", "content": "Hello"}], + [{"type": "function", "function": {"name": "remember_fact", "parameters": {"type": "object"}}}], + ) + + assert result["choices"][0]["message"]["content"] == "Hello." + assert "tools" in calls[0] + assert "tools" not in calls[1] diff --git a/src/LocalDiplomacy.Agent/uv.lock b/src/LocalDiplomacy.Agent/uv.lock index e74223d..800acc7 100644 --- a/src/LocalDiplomacy.Agent/uv.lock +++ b/src/LocalDiplomacy.Agent/uv.lock @@ -39,15 +39,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "certifi" version = "2026.4.22" @@ -57,95 +48,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, -] - [[package]] name = "click" version = "8.3.3" @@ -167,15 +69,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "distro" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, -] - [[package]] name = "fastapi" version = "0.136.1" @@ -192,63 +85,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] -[[package]] -name = "greenlet" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, - { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" }, - { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, - { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, - { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, - { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, - { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, - { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, - { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, - { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, - { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" }, - { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, - { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" }, - { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, - { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" }, - { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, - { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, - { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, - { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, - { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, - { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, - { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, - { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, - { url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" }, - { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, - { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, - { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, -] - [[package]] name = "grpcio" version = "1.80.0" @@ -427,96 +263,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "jiter" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, - { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, - { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, - { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, - { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, - { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, - { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, - { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, - { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, - { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, - { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, - { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, - { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, - { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, - { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, - { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, - { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, - { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, - { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, - { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, - { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, - { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, - { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, - { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, - { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, - { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, - { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, - { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, - { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, - { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, - { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, - { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, - { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, - { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, - { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, - { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, - { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, - { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, - { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, - { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, -] - [[package]] name = "localdiplomacy-agent" version = "0.1.0" @@ -526,14 +272,11 @@ dependencies = [ { name = "httpx" }, { name = "pydantic" }, { name = "pyyaml" }, + { name = "qdrant-client" }, { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] -memory = [ - { name = "mem0ai" }, - { name = "qdrant-client" }, -] test = [ { name = "pytest" }, ] @@ -542,32 +285,13 @@ test = [ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.27.0" }, - { name = "mem0ai", marker = "extra == 'memory'", specifier = ">=0.1.0" }, { name = "pydantic", specifier = ">=2.8.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.2.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "qdrant-client", marker = "extra == 'memory'", specifier = ">=1.10.0" }, + { name = "qdrant-client", specifier = ">=1.10.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, ] -provides-extras = ["memory", "test"] - -[[package]] -name = "mem0ai" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "openai" }, - { name = "posthog" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "pytz" }, - { name = "qdrant-client" }, - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/03/3dc535b98310912e4f10083acdbbca2c5e2dfccb3921230a460464f9f4d0/mem0ai-2.0.1.tar.gz", hash = "sha256:070dbc3f1f332c8908379b42a81ab3a96ab169f2f9fa537e6ac719df02478f9c", size = 211820, upload-time = "2026-04-25T17:39:06.744Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/96/e6153262f1464f4d412208732fea31496d9983ade155dd2c5c5492f8f8a4/mem0ai-2.0.1-py3-none-any.whl", hash = "sha256:63da5f50ad0c2514e27c2f380ef03f2ceea47c97873096ddfd997785b58043ec", size = 299461, upload-time = "2026-04-25T17:39:04.143Z" }, -] +provides-extras = ["test"] [[package]] name = "numpy" @@ -648,25 +372,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, ] -[[package]] -name = "openai" -version = "2.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f0/ee/d056c82f63c05f06baac0cffb4a90952d8274f90c49dfe244f20497b9bbd/openai-2.33.0.tar.gz", hash = "sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a", size = 693254, upload-time = "2026-04-28T14:04:42.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl", hash = "sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5", size = 1162695, upload-time = "2026-04-28T14:04:40.482Z" }, -] - [[package]] name = "packaging" version = "26.2" @@ -697,22 +402,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, ] -[[package]] -name = "posthog" -version = "7.13.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backoff" }, - { name = "distro" }, - { name = "python-dateutil" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/09/ecc82b5ba5876164a3807adcc5101466da1e4416600075bdbd2071327457/posthog-7.13.1.tar.gz", hash = "sha256:5e53c57db076807530bbec5634c96673ceae8e8e58b99c983af26f02bb4759aa", size = 194124, upload-time = "2026-04-24T19:08:32.56Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/bf/eafd5e7508b03264b7deb4db6563c4a2830de7114e01ccbf369756b779d1/posthog-7.13.1-py3-none-any.whl", hash = "sha256:fc0f4b4a8878957e1ea8d319b2e4038b66a19625837f59b020cddaaf59fce982", size = 228291, upload-time = "2026-04-24T19:08:30.822Z" }, -] - [[package]] name = "protobuf" version = "6.33.6" @@ -870,18 +559,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -891,15 +568,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] -[[package]] -name = "pytz" -version = "2026.1.post1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, -] - [[package]] name = "pywin32" version = "311" @@ -992,92 +660,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/69/77d1a971c4b933e8c79403e99bcbb790463da5e48333cc4fd5d412c63c98/qdrant_client-1.17.1-py3-none-any.whl", hash = "sha256:6cda4064adfeaf211c751f3fbc00edbbdb499850918c7aff4855a9a759d56cbd", size = 389947, upload-time = "2026-03-13T17:13:43.156Z" }, ] -[[package]] -name = "requests" -version = "2.33.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "sqlalchemy" -version = "2.0.49" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" }, - { url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" }, - { url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" }, - { url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" }, - { url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, - { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, - { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, - { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, - { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, - { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, - { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, - { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, - { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, - { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, - { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, - { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, - { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, - { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, -] - [[package]] name = "starlette" version = "1.0.0" @@ -1091,18 +673,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] -[[package]] -name = "tqdm" -version = "4.67.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0"