57 Commits

Author SHA1 Message Date
serversdown 6d88505697 chore: add sessions to gitignore 2026-05-29 18:23:29 -04:00
Claude 0ee5a9ce47 feat: SQLite-backed memory with brute-force cosine recall
- lyra.memory.remember(session_id, role, content) embeds and stores
- lyra.memory.recent(session_id, n) returns the last N from a session
- lyra.memory.recall(query, k, session_id=None) returns top-k by cosine
  similarity across the chosen scope (all sessions by default)
- Embeddings live in the exchanges.embedding BLOB column as float32 bytes
- Connection reopens automatically if LYRA_DB_PATH changes (test-friendly)
2026-05-16 06:35:52 +00:00
Claude 6a1255dfdb feat: LLM router with local (Ollama) and cloud (OpenAI) backends
- lyra.config.load() reads env into a frozen Config dataclass
- lyra.llm.complete(messages, backend) routes to Ollama /api/chat or
  OpenAI chat completions
- lyra.llm.embed(texts) calls OpenAI embeddings
- .env.example switched from Anthropic to OpenAI to match available key
2026-05-16 06:10:48 +00:00
Claude b2523c2561 chore: project scaffold (uv, .env.example, README, lyra package) 2026-05-16 06:01:08 +00:00
Claude faf4e8a1aa chore: nuke legacy code, keep design docs for restart
Preserved on the archive branch. Keeping only the architecture and
design thinking that survives the rewrite:

- docs/ARCH_v0-6-1.md (Inner Self / Executive / Chat / Persona model)
- docs/ARCHITECTURE_v0-6-0.md (predecessor architecture)
- docs/PROJECT_SUMMARY.md (project history and rationale)
- docs/PROJECT_LYRA_COMPLETE_BREAKDOWN.md (detailed design notes)
- docs/ENVIRONMENT_VARIABLES.md (multi-backend env conventions)
- docs/LLMS.md
- docs/TRILLIUM_API.md (for future tool integration)

Removed: all service code (cortex, core/relay, neomem, rag, sandbox,
persona-sidecar), docker-compose, migration/logging docs, stale root
test scripts, CHANGELOG.
2026-05-16 05:57:07 +00:00
claude 4b951f3be8 Merge pull request #16 from serversdwn/dev
update to 0.9.0
2025-12-29 01:59:14 -05:00
claude 6b5580a80e 0.9.0 - Added Trilium ETAPI integration.
Lyra can now: Search trilium notes and create new notes. with proper ETAPI auth.
2025-12-29 01:58:20 -05:00
claude 86b37ab874 feat: Implement Trillium notes executor for searching and creating notes via ETAPI
- Added `trillium.py` for searching and creating notes with Trillium's ETAPI.
- Implemented `search_notes` and `create_note` functions with appropriate error handling and validation.

feat: Add web search functionality using DuckDuckGo

- Introduced `web_search.py` for performing web searches without API keys.
- Implemented `search_web` function with result handling and validation.

feat: Create provider-agnostic function caller for iterative tool calling

- Developed `function_caller.py` to manage LLM interactions with tools.
- Implemented iterative calling logic with error handling and tool execution.

feat: Establish a tool registry for managing available tools

- Created `registry.py` to define and manage tool availability and execution.
- Integrated feature flags for enabling/disabling tools based on environment variables.

feat: Implement event streaming for tool calling processes

- Added `stream_events.py` to manage Server-Sent Events (SSE) for tool calling.
- Enabled real-time updates during tool execution for enhanced user experience.

test: Add tests for tool calling system components

- Created `test_tools.py` to validate functionality of code execution, web search, and tool registry.
- Implemented asynchronous tests to ensure proper execution and result handling.

chore: Add Dockerfile for sandbox environment setup

- Created `Dockerfile` to set up a Python environment with necessary dependencies for code execution.

chore: Add debug regex script for testing XML parsing

- Introduced `debug_regex.py` to validate regex patterns against XML tool calls.

chore: Add HTML template for displaying thinking stream events

- Created `test_thinking_stream.html` for visualizing tool calling events in a user-friendly format.

test: Add tests for OllamaAdapter XML parsing

- Developed `test_ollama_parser.py` to validate XML parsing with various test cases, including malformed XML.
2025-12-26 03:49:20 -05:00
claude 8b66cd1e1d update to 0.7.0
Standard Mode Implementation - Complete documentation of the new simple chatbot mode
Backend Selection System - UI settings modal and routing changes
Session Management Overhaul - File-based persistence with CRUD API
UI Improvements - Settings modal, light/dark mode, modal fixes
Context Retention - Integration with Intake for conversation history
Architecture & Routing Changes - Updates to Relay, Cortex, Intake, LLM router
Fixed Critical Issues - DeepSeek R1, context retention, OpenAI errors, modal formatting, session persistence
Technical Improvements - Backward compatibility, code quality, performance
Architecture Diagrams - Data flow for Standard Mode, Cortex Mode, and sessions
Known Limitations - Standard Mode constraints, session management limits
Migration Notes - For users and developers upgrading
2025-12-22 01:41:21 -05:00
claude 7cb7033bb6 docs updated v0.7.0 2025-12-22 01:40:24 -05:00
claude 9226b2480b sessions improved, v0.7.0 2025-12-21 15:50:52 -05:00
claude 58d0afd1c6 mode selection, settings added to ui 2025-12-21 14:30:32 -05:00
claude 9c03b23a6d simple context added to standard mode 2025-12-21 13:01:00 -05:00
claude fdc51e598c v0.7.0 - Standard non cortex mode enabled 2025-12-20 04:15:22 -05:00
claude 092ac4d181 Cortex debugging logs cleaned up 2025-12-20 02:49:20 -05:00
claude a4f5308f9b Merge pull request #9 from serversdwn/dev
Update to 0.6.0. Docs updated.
2025-12-19 17:44:11 -05:00
claude 34aff34038 Docs updated v0.6.0 2025-12-19 17:43:22 -05:00
claude a41e342dbd cleanup ignore stuff 2025-12-17 02:46:23 -05:00
claude 09c00848b9 Merge branch 'dev' of https://github.com/serversdwn/project-lyra into dev 2025-12-17 01:47:30 -05:00
claude ec5f17694e ignore 2025-12-17 01:47:19 -05:00
claude b74658c000 complete breakdown for AI agents added 2025-12-15 11:49:49 -05:00
claude 0a03546039 neomem disabled 2025-12-15 04:10:03 -05:00
claude 0528d10081 autonomy phase 2.5 - tightening up some stuff in the pipeline 2025-12-15 01:56:57 -05:00
claude e2e55a0fda autonomy phase 2 2025-12-14 14:43:08 -05:00
claude ae41b51888 autonomy build, phase 1 2025-12-14 01:44:05 -05:00
claude 70e57ba5d2 cortex pipeline stablized, inner monologue is now determining user intent and tone 2025-12-13 04:13:12 -05:00
claude 7693bc4080 autonomy scaffold 2025-12-13 02:55:49 -05:00
claude 628edb681a v0.5.2 update
Dev
2025-12-12 08:04:20 +00:00
claude 58d6520056 v0.5.2 - fixed: llm router async, relay-UI mismatch, intake summarization failure, among others.
Memory relevance thresh. increased.
2025-12-12 02:58:23 -05:00
claude 77429ca6e0 v0.6.1 - reinstated UI, relay > cortex pipeline working 2025-12-11 16:28:25 -05:00
claude 67b7f9594c autonomy, initial scaffold 2025-12-11 13:12:44 -05:00
claude 875e660e31 docs updated for v0.5.1 2025-12-11 03:49:23 -05:00
claude 09b6b364e5 v0.5.1-Major cortex rework. clean up done too. Merge from dev
v0.5.1-Major cortex rework. clean up done too.
2025-12-11 03:48:29 -05:00
claude 832fea78d0 gitignore updated, to ignore vscode settings 2025-12-11 03:42:30 -05:00
claude 3b5ec9c974 cleaning up deprecated files 2025-12-11 03:40:47 -05:00
claude 3eb19d30f0 cortex rework continued. 2025-12-11 02:50:23 -05:00
claude 8428e5e04e deprecated old intake folder 2025-12-06 04:38:11 -05:00
claude 04f4ed6b51 intake/relay rewire 2025-12-06 04:32:42 -05:00
claude 03450b5f70 add. cleanup 2025-11-30 03:58:15 -05:00
claude 6312f2ae92 intake internalized by cortex, removed intake route in relay 2025-11-29 19:08:15 -05:00
claude 5db0614cdc cortex 0.2.... i think? 2025-11-29 05:14:32 -05:00
claude 26f5a6b972 fixed neomem URL request failure, now using correct variable 2025-11-28 19:50:53 -05:00
claude c3fffcdd80 context added, wired in. first attempt 2025-11-28 19:29:41 -05:00
claude 1dd84613cf Merge pull request #4 from serversdwn/dev
Big clean up to v0.5.0, docs updated, restructured throughout.
2025-11-28 18:14:18 -05:00
claude 211328aba9 docs updated 2025-11-28 18:05:59 -05:00
claude 50f95a1f59 Major rewire, all modules connected. Intake still wonkey 2025-11-28 15:14:47 -05:00
claude 7e34307b31 Cortex rework in progress 2025-11-26 18:01:48 -05:00
claude ca5f582f9c Fixin' crap so relay works again. pre llm redo 2025-11-26 14:20:47 -05:00
claude a5f3e0248a env cleanup round 2 2025-11-26 03:18:15 -05:00
claude 3b128ac7f6 Merge pull request #3 from serversdwn/dev
Dev branch reorganizing.
2025-11-26 02:32:31 -05:00
claude 8128b45fe5 reorganizing and restructuring 2025-11-26 02:28:00 -05:00
claude 6d5d442f96 intital file restructure 2025-11-25 20:50:05 -05:00
claude e30793661f Merge branch 'main' of https://github.com/serversdwn/project-lyra 2025-11-17 03:41:51 -05:00
claude 967abce237 WIP local changes 2025-11-17 03:39:56 -05:00
claude 7f5413af80 Add MI50 + vLLM full setup guide 2025-11-17 03:34:23 -05:00
claude e388aaeddf Remove rag chatlogs and add ignore rules 2025-11-16 03:20:10 -05:00
claude 20aec1a612 Initial clean commit - unified Lyra stack 2025-11-16 03:17:32 -05:00
49 changed files with 850 additions and 11982 deletions
-52
View File
@@ -1,52 +0,0 @@
# Git
.git
.gitignore
# Docker
docker-compose.yml
Dockerfile*
# Python
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
.venv
venv
# Node
node_modules
npm-debug.log
yarn-error.log
# IDE
.vscode
.idea
*.swp
*.swo
# Logs
*.log
logs
# Environment
.env.local
.env.*.local
# Backup directories
*-old
*-backup*
# OS
.DS_Store
Thumbs.db
# Temp
*.tmp
tmp
+11 -87
View File
@@ -1,87 +1,11 @@
# ==================================== # Local backend (Ollama) — used by default for most calls.
# 🌌 GLOBAL LYRA CONFIG LOCAL_BASE_URL=http://localhost:11434
# ==================================== LOCAL_MODEL=qwen2.5:7b-instruct
LOCAL_TZ_LABEL=America/New_York
DEFAULT_SESSION_ID=default # Cloud backend (OpenAI) — used for harder reasoning and embeddings.
OPENAI_API_KEY=
CLOUD_MODEL=gpt-4o-mini
# ==================================== EMBED_MODEL=text-embedding-3-small
# 🤖 LLM BACKEND OPTIONS
# ==================================== # Where Lyra stores her memory.
# Services choose which backend to use from these options LYRA_DB_PATH=data/lyra.db
# Primary: vLLM on MI50 GPU
LLM_PRIMARY_PROVIDER=vllm
LLM_PRIMARY_URL=http://10.0.0.43:8000
LLM_PRIMARY_MODEL=/model
# Secondary: Ollama on 3090 GPU
LLM_SECONDARY_PROVIDER=ollama
LLM_SECONDARY_URL=http://10.0.0.3:11434
LLM_SECONDARY_MODEL=qwen2.5:7b-instruct-q4_K_M
# Cloud: OpenAI
LLM_CLOUD_PROVIDER=openai_chat
LLM_CLOUD_URL=https://api.openai.com/v1
LLM_CLOUD_MODEL=gpt-4o-mini
OPENAI_API_KEY=sk-proj-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# Local Fallback: llama.cpp or LM Studio
LLM_FALLBACK_PROVIDER=openai_completions
LLM_FALLBACK_URL=http://10.0.0.41:11435
LLM_FALLBACK_MODEL=llama-3.2-8b-instruct
# Global LLM controls
LLM_TEMPERATURE=0.7
# ====================================
# 🗄️ DATABASE CONFIGURATION
# ====================================
# Postgres (pgvector for NeoMem)
POSTGRES_USER=neomem
POSTGRES_PASSWORD=change_me_in_production
POSTGRES_DB=neomem
POSTGRES_HOST=neomem-postgres
POSTGRES_PORT=5432
# Neo4j Graph Database
NEO4J_URI=bolt://neomem-neo4j:7687
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=change_me_in_production
NEO4J_AUTH=neo4j/change_me_in_production
# ====================================
# 🧠 MEMORY SERVICES (NEOMEM)
# ====================================
NEOMEM_API=http://neomem-api:7077
NEOMEM_API_KEY=generate_secure_random_token_here
NEOMEM_HISTORY_DB=postgresql://neomem:change_me_in_production@neomem-postgres:5432/neomem
# Embeddings configuration (used by NeoMem)
EMBEDDER_PROVIDER=openai
EMBEDDER_MODEL=text-embedding-3-small
# ====================================
# 🔌 INTERNAL SERVICE URLS
# ====================================
# Using container names for Docker network communication
INTAKE_API_URL=http://intake:7080
CORTEX_API=http://cortex:7081
CORTEX_URL=http://cortex:7081/reflect
CORTEX_URL_INGEST=http://cortex:7081/ingest
RAG_API_URL=http://rag:7090
RELAY_URL=http://relay:7078
# Persona service (optional)
PERSONA_URL=http://persona-sidecar:7080/current
# ====================================
# 🔧 FEATURE FLAGS
# ====================================
CORTEX_ENABLED=true
MEMORY_ENABLED=true
PERSONA_ENABLED=false
DEBUG_PROMPT=true
-132
View File
@@ -1,132 +0,0 @@
# ============================================================================
# CORTEX LOGGING CONFIGURATION
# ============================================================================
# This file contains all logging-related environment variables for the
# Cortex reasoning pipeline. Copy this to your .env file and adjust as needed.
#
# Log Detail Levels:
# minimal - Only errors and critical events
# summary - Stage completion + errors (DEFAULT - RECOMMENDED FOR PRODUCTION)
# detailed - Include raw LLM outputs, RAG results, timing breakdowns
# verbose - Everything including intermediate states, full JSON dumps
#
# Quick Start:
# - For debugging weak links: LOG_DETAIL_LEVEL=detailed
# - For finding performance bottlenecks: LOG_DETAIL_LEVEL=detailed + VERBOSE_DEBUG=true
# - For production: LOG_DETAIL_LEVEL=summary
# - For silent mode: LOG_DETAIL_LEVEL=minimal
# ============================================================================
# -----------------------------
# Primary Logging Level
# -----------------------------
# Controls overall verbosity across all components
LOG_DETAIL_LEVEL=detailed
# Legacy verbose debug flag (kept for compatibility)
# When true, enables maximum logging including raw data dumps
VERBOSE_DEBUG=false
# -----------------------------
# LLM Logging
# -----------------------------
# Enable raw LLM response logging (only works with detailed/verbose levels)
# Shows full JSON responses from each LLM backend call
# Set to "true" to see exact LLM outputs for debugging weak links
LOG_RAW_LLM_RESPONSES=true
# -----------------------------
# Context Logging
# -----------------------------
# Show full raw intake data (L1-L30 summaries) in logs
# WARNING: Very verbose, use only for deep debugging
LOG_RAW_CONTEXT_DATA=false
# -----------------------------
# Loop Detection & Protection
# -----------------------------
# Enable duplicate message detection to prevent processing loops
ENABLE_DUPLICATE_DETECTION=true
# Maximum number of messages to keep in session history (prevents unbounded growth)
# Older messages are trimmed automatically
MAX_MESSAGE_HISTORY=100
# Session TTL in hours - sessions inactive longer than this are auto-expired
SESSION_TTL_HOURS=24
# -----------------------------
# NeoMem / RAG Logging
# -----------------------------
# Relevance score threshold for NeoMem results
RELEVANCE_THRESHOLD=0.4
# Enable NeoMem long-term memory retrieval
NEOMEM_ENABLED=false
# -----------------------------
# Autonomous Features
# -----------------------------
# Enable autonomous tool invocation (RAG, WEB, WEATHER, CODEBRAIN)
ENABLE_AUTONOMOUS_TOOLS=true
# Confidence threshold for autonomous tool invocation (0.0 - 1.0)
AUTONOMOUS_TOOL_CONFIDENCE_THRESHOLD=0.6
# Enable proactive monitoring and suggestions
ENABLE_PROACTIVE_MONITORING=true
# Minimum priority for proactive suggestions to be included (0.0 - 1.0)
PROACTIVE_SUGGESTION_MIN_PRIORITY=0.6
# ============================================================================
# EXAMPLE LOGGING OUTPUT AT DIFFERENT LEVELS
# ============================================================================
#
# LOG_DETAIL_LEVEL=summary (RECOMMENDED):
# ────────────────────────────────────────────────────────────────────────────
# ✅ [LLM] PRIMARY | 14:23:45.123 | Reply: Based on your question about...
# 📊 Context | Session: abc123 | Messages: 42 | Last: 5.2min | RAG: 3 results
# 🧠 Monologue | question | Tone: curious
# ✨ PIPELINE COMPLETE | Session: abc123 | Total: 1250ms
# 📤 Output: 342 characters
# ────────────────────────────────────────────────────────────────────────────
#
# LOG_DETAIL_LEVEL=detailed (FOR DEBUGGING):
# ────────────────────────────────────────────────────────────────────────────
# 🚀 PIPELINE START | Session: abc123 | 14:23:45.123
# 📝 User: What is the meaning of life?
# ────────────────────────────────────────────────────────────────────────────
# 🧠 LLM CALL | Backend: PRIMARY | 14:23:45.234
# ────────────────────────────────────────────────────────────────────────────
# 📝 Prompt: You are Lyra, a thoughtful AI assistant...
# 💬 Reply: Based on philosophical perspectives, the meaning...
# ╭─ RAW RESPONSE ────────────────────────────────────────────────────────────
# │ {
# │ "choices": [
# │ {
# │ "message": {
# │ "content": "Based on philosophical perspectives..."
# │ }
# │ }
# │ ]
# │ }
# ╰───────────────────────────────────────────────────────────────────────────
#
# ✨ PIPELINE COMPLETE | Session: abc123 | Total: 1250ms
# ⏱️ Stage Timings:
# context : 150ms ( 12.0%)
# identity : 10ms ( 0.8%)
# monologue : 200ms ( 16.0%)
# reasoning : 450ms ( 36.0%)
# refinement : 300ms ( 24.0%)
# persona : 140ms ( 11.2%)
# ────────────────────────────────────────────────────────────────────────────
#
# LOG_DETAIL_LEVEL=verbose (MAXIMUM DEBUG):
# Same as detailed but includes:
# - Full 50+ line raw JSON dumps
# - Complete intake data structures
# - All intermediate processing states
# - Detailed traceback on errors
# ============================================================================
+27 -73
View File
@@ -1,83 +1,37 @@
# ============================= # Python
# 📦 General
# =============================
__pycache__/ __pycache__/
*.pyc *.py[cod]
*.log *.egg-info/
/.vscode/ .pytest_cache/
.vscode/ .ruff_cache/
# ============================= .mypy_cache/
# 🔐 Environment files (NEVER commit secrets!) build/
# ============================= dist/
# Ignore all .env files
# Virtual environments
.venv/
venv/
env/
# Env files (never commit secrets)
.env .env
.env.local .env.local
.env.*.local .env.*.local
**/.env
**/.env.local
# BUT track .env.example templates (safe to commit)
!.env.example !.env.example
!**/.env.example
# Ignore backup directory # Local data
.env-backups/ data/
# =============================
# 🐳 Docker volumes (HUGE)
# =============================
volumes/
*/volumes/
# =============================
# 📚 Databases & vector stores
# =============================
postgres_data/
neo4j_data/
*/postgres_data/
*/neo4j_data/
rag/chromadb/
rag/*.sqlite3
rag/chatlogs/
rag/lyra-chatlogs/
# =============================
# 🤖 Model weights (big)
# =============================
models/
*.gguf
*.bin
*.pt
*.safetensors
# =============================
# 📦 Node modules (installed via npm)
# =============================
node_modules/
core/relay/node_modules/
# =============================
# 💬 Runtime data & sessions
# =============================
# Session files (contain user conversation data)
core/relay/sessions/
**/sessions/
*.jsonl
# Log directories
logs/
**/logs/
*-logs/
intake-logs/
# Database files (generated at runtime)
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
neomem_history/
**/neomem_history/
# Temporary and cache files # IDE / OS
.cache/ .vscode/
*.tmp .idea/
*.temp .DS_Store
# Logs
*.log
#lyra Stuff
/core/relay/sessions/
-48
View File
@@ -1,48 +0,0 @@
# Unified Lyra Container - Relay (Node) + Cortex (Python)
FROM python:3.11-slim
# Install Node.js, npm, and docker CLI
RUN apt-get update && apt-get install -y \
curl \
docker.io \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ============================================================
# Install Python dependencies (Cortex)
# ============================================================
COPY cortex/requirements.txt /app/cortex/requirements.txt
RUN pip install --no-cache-dir -r /app/cortex/requirements.txt
# ============================================================
# Install Node dependencies (Relay)
# ============================================================
COPY core/relay/package*.json /app/relay/
WORKDIR /app/relay
RUN npm install
# ============================================================
# Copy application code
# ============================================================
WORKDIR /app
COPY cortex/ /app/cortex/
COPY core/relay/ /app/relay/
# ============================================================
# Copy startup script
# ============================================================
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
# ============================================================
# Expose ports
# ============================================================
EXPOSE 7078 7081
# ============================================================
# Start both services
# ============================================================
CMD ["/app/start.sh"]
-124
View File
@@ -1,124 +0,0 @@
# Lyra Quickstart
## Architecture
Lyra is now a **unified container** running:
- **Relay** (Node.js on port 7078) - User-facing API with OpenAI-compatible endpoints
- **Cortex** (Python on port 7081) - Brain with Intake summarization pipeline
- **Intake** - Multi-level summarization (L1-L30) that sends to Nebula
## Running Lyra
### 1. Start the system
```bash
docker-compose up -d
```
### 2. Check logs
```bash
# All services
docker-compose logs -f lyra
# Just startup
docker-compose logs lyra
```
### 3. Verify it's running
```bash
# Check Relay
curl http://localhost:7078/_health
# Check Cortex
curl http://localhost:7081/_health
# View UI
open http://localhost:8081
```
## Making Changes
### Restart after code changes
```bash
docker-compose restart lyra
```
### Rebuild after dependency changes
```bash
docker-compose up -d --build lyra
```
## Architecture Details
```
┌─────────────────────────────────────┐
│ Unified Container (lyra) │
│ │
│ ┌──────────────┐ ┌─────────────┐ │
│ │ Relay :7078 │ │Cortex :7081 │ │
│ │ (Node.js) │─→│ (Python) │ │
│ └──────────────┘ └─────────────┘ │
│ │ │
│ ↓ │
│ ┌─────────┐ │
│ │ Intake │ │
│ │Summarize│ │
│ └─────────┘ │
│ │ │
└─────────────────────────┼────────────┘
┌──────────┐
│ Nebula │ (external, to be built)
│ (vector │
│ storage) │
└──────────┘
```
## Endpoints
### Relay (Port 7078)
- `POST /chat` - Lyra-native chat endpoint
- `POST /v1/chat/completions` - OpenAI-compatible endpoint
- `GET /sessions` - List sessions
- `GET /_health` - Health check
### Cortex (Port 7081)
- `POST /reason` - Full reasoning pipeline
- `POST /simple` - Simple chat mode
- `POST /ingest` - Internal intake endpoint
- `GET /_health` - Health check
## Environment Variables
Key variables in `.env`:
```bash
# LLM Configuration
PRIMARY_LLM_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-...
# Nebula (when available)
NEBULA_API=http://nebula:7090
NEBULA_KEY=your-key
# Intake Settings
INTAKE_LLM=PRIMARY
SUMMARY_MAX_TOKENS=200
SUMMARY_TEMPERATURE=0.3
```
## Data Persistence
Until Nebula is running, summaries are saved to:
```
.nebula_fallback/
└── {session_id}/
├── L10_20260223_203045.json
├── L20_20260223_204512.json
└── L30_20260223_210030.json
```
Sessions are saved to:
```
core/relay/sessions/
├── {session_id}.json
└── {session_id}.meta.json
```
+10 -472
View File
@@ -1,483 +1,21 @@
# Project Lyra # Lyra
**A streamlined AI conversation system with intelligent summarization and memory** A persistent, autonomous AI assistant. From-scratch rewrite of an earlier attempt.
Lyra is a unified conversational AI system that processes your thoughts, summarizes conversations at multiple levels, and prepares them for semantic memory storage. Think of it as your personal thought processor—you dump ideas, it makes sense of them, and stores both the raw conversation and progressive summaries. The design thinking that survives the rewrite lives in [`docs/`](docs/) — start with [`docs/ARCH_v0-6-1.md`](docs/ARCH_v0-6-1.md). The previous implementation is preserved on the `archive` branch.
**Current Version:** v1.0.0 (2026-02-23) ## Status
--- Pre-MVP. Building toward the smallest useful version: chat with persistent memory across sessions.
## Mission Statement ## Setup
Project Lyra is designed to be your **external brain**. Unlike typical chatbots that forget everything, Lyra:
- **Captures** everything you say in raw form
- **Summarizes** conversations at multiple granularities (L1-L30)
- **Stores** both raw and summarized data for future retrieval
- **Prepares** everything for semantic search via vector embeddings (Nebula, coming soon)
You can vomit ideas at it, and Lyra will organize, summarize, and remember.
---
## Architecture Overview
Lyra runs as a **unified Docker container** with a clean separation of concerns:
```
┌─────────────────────────────────────────────┐
│ Unified Container (lyra) │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Relay :7078 │ │ Cortex :7081 │ │
│ │ (Node.js) │→ │ (Python FastAPI) │ │
│ │ │ │ │ │
│ │ - API Gateway│ │ - /reason (full) │ │
│ │ - Sessions │ │ - /simple (fast) │ │
│ │ - OpenAI API │ │ - /ingest (intake) │ │
│ └──────────────┘ └──────────────────────┘ │
│ │ │
│ ↓ │
│ ┌──────────────┐ │
│ │ Intake │ │
│ │ (embedded) │ │
│ │ │ │
│ │ - L1-L30 │ │
│ │ - Summary │ │
│ │ - Buffer │ │
│ └──────────────┘ │
│ │ │
└────────────────────────────┼─────────────────┘
┌─────────────┐
│ Nebula │ (coming soon)
│ (vector │
│ storage) │
└─────────────┘
```
### Components
**1. Relay (Node.js - Port 7078)**
- User-facing API gateway
- OpenAI-compatible endpoint: `POST /v1/chat/completions`
- Session management (save, load, rename, delete)
- Proxies requests to Cortex
**2. Cortex (Python - Port 7081)**
- Main reasoning and processing brain
- Multi-stage reasoning pipeline
- LLM routing to different backends
- Embedded Intake module
**3. Intake (Python Module - Embedded)**
- Short-term memory buffer (200 messages per session)
- Multi-level summarization:
- **L1** (5 messages): Ultra-short summary
- **L5** (10 messages): Short overview
- **L10** (10 messages): "Reality Check" - tone, intent, direction
- **L20** (merged L10s): "Session Overview" - progress and themes
- **L30** (merged L20s): "Continuity Report" - high-level reflection
- Sends summaries to Nebula (HTTP POST with disk fallback)
**4. Nebula (Future - Port 7090)**
- Vector database for semantic memory
- RAG (Retrieval-Augmented Generation)
- Memory resurfacing based on similarity
---
## What Makes Lyra Different?
### Progressive Summarization
Most chatbots either keep raw history (expensive) or forget everything (useless). Lyra does both:
- **Raw storage**: Every conversation turn saved
- **L1-L30 summaries**: Multiple granularities for different use cases
- L1: "What just happened?" (immediate context)
- L10: "What's the vibe?" (tone and direction)
- L20: "What did we accomplish?" (session overview)
- L30: "What's the big picture?" (continuity across sessions)
### Nebula-Ready Architecture
Summaries are sent via HTTP to Nebula (when available), with automatic disk fallback:
```
.nebula_fallback/
└── {session_id}/
├── L10_20260223_203045.json
├── L20_20260223_204512.json
└── L30_20260223_210030.json
```
### Dual Mode Operation
- **Simple Mode** (`/simple`): Fast, direct LLM responses
- **Cortex Mode** (`/reason`): Full 4-stage reasoning pipeline
1. Reflection (meta-awareness)
2. Reasoning (draft)
3. Refinement (polish)
4. Persona (Lyra's voice)
---
## Quick Start
### Prerequisites
- Docker + Docker Compose
- At least one LLM backend (llama.cpp, Ollama, OpenAI API)
### Run It
```bash ```bash
# 1. Create .env file with your LLM backend uv sync
cp .env.example .env cp .env.example .env
# Edit .env with your LLM URLs and API keys # fill in ANTHROPIC_API_KEY and point LOCAL_BASE_URL at your Ollama
# 2. Build and start
docker-compose up -d --build
# 3. Check health
curl http://localhost:7078/_health # Relay
curl http://localhost:7081/_health # Cortex
# 4. Open UI
open http://localhost:8081
``` ```
### Test It ## Architecture
```bash The long-term target is the cognitive split in `docs/ARCH_v0-6-1.md` — Inner Self as the seat of consciousness, Executive for hard reasoning, Cortex Chat for drafting, Persona for voice. The MVP implements only the chat + memory baseline. Cognitive layers come back one at a time.
# Simple chat
curl -X POST http://localhost:7078/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"mode": "standard",
"messages": [{"role": "user", "content": "Hello!"}],
"sessionId": "test"
}'
# Full reasoning pipeline
curl -X POST http://localhost:7078/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"mode": "cortex",
"messages": [{"role": "user", "content": "Explain quantum computing"}],
"sessionId": "test"
}'
```
---
## Data Flow
### Simple Mode (Fast Path)
```
User → Relay → Cortex (/simple) → Direct LLM → Response
Intake (buffer + summarize on triggers)
Nebula (summaries only)
```
### Cortex Mode (Full Pipeline)
```
User → Relay → Cortex (/reason)
1. Reflection (what's being asked?)
2. Reasoning (draft answer)
3. Refinement (polish)
4. Persona (Lyra's voice)
Intake (buffer + multi-level summaries)
Nebula (raw + summaries)
Response
```
---
## Configuration
### Environment Variables
**LLM Backends:**
```bash
# Primary backend (llama.cpp on AMD MI50)
LLM_PRIMARY_URL=http://10.0.0.44:8080
LLM_PRIMARY_MODEL=/model
# Secondary backend (Ollama on RTX 3090)
LLM_SECONDARY_URL=http://10.0.0.3:11434
LLM_SECONDARY_MODEL=qwen2.5:7b-instruct-q4_K_M
# Cloud backend (OpenAI)
LLM_OPENAI_URL=https://api.openai.com/v1
LLM_OPENAI_MODEL=gpt-4o-mini
OPENAI_API_KEY=sk-...
```
**Module-Specific Backend Selection:**
```bash
CORTEX_LLM=PRIMARY # Reasoning engine
INTAKE_LLM=PRIMARY # Summarization
SPEAK_LLM=OPENAI # Persona (final voice)
STANDARD_MODE_LLM=SECONDARY # Simple mode default
```
**Nebula Integration:**
```bash
NEBULA_API=http://localhost:7090 # When Nebula is running
NEBULA_KEY=your-api-key # Optional auth
```
**Intake Settings:**
```bash
INTAKE_LLM=PRIMARY
SUMMARY_MAX_TOKENS=200
SUMMARY_TEMPERATURE=0.3
```
---
## API Reference
### Relay Endpoints (Port 7078)
**Chat (OpenAI-compatible):**
```bash
POST /v1/chat/completions
{
"mode": "standard" | "cortex",
"messages": [{"role": "user", "content": "..."}],
"sessionId": "session-123"
}
```
**Sessions:**
```bash
GET /sessions # List all sessions
GET /sessions/:id # Get session history
POST /sessions/:id # Save session
PATCH /sessions/:id/metadata # Rename session
DELETE /sessions/:id # Delete session
```
**Health:**
```bash
GET /_health
```
### Cortex Endpoints (Port 7081)
**Reasoning:**
```bash
POST /reason
{
"session_id": "session-123",
"user_prompt": "Your question here"
}
```
**Simple Mode:**
```bash
POST /simple
{
"session_id": "session-123",
"user_prompt": "Your question here",
"backend": "SECONDARY" # Optional
}
```
**Intake:**
```bash
POST /ingest
{
"session_id": "session-123",
"user_msg": "User message",
"assistant_msg": "Assistant response"
}
```
**Health:**
```bash
GET /_health
```
---
## File Structure
```
project-lyra/
├── Dockerfile # Unified container (Node + Python)
├── docker-compose.yml # Single lyra service + UI
├── start.sh # Startup script (Cortex → Relay)
├── .dockerignore
├── QUICKSTART.md # Quick reference
├── core/
│ └── relay/ # Node.js API gateway
│ ├── server.js
│ ├── lib/
│ │ ├── cortex.js # Cortex HTTP client
│ │ └── llm.js # LLM routing
│ └── sessions/ # Session storage (volume)
├── cortex/ # Python reasoning engine
│ ├── main.py # FastAPI app
│ ├── router.py # /reason, /simple, /ingest
│ ├── context.py # Session context
│ ├── llm/
│ │ └── llm_router.py # Multi-backend LLM routing
│ ├── intake/
│ │ └── intake.py # Summarization module
│ ├── reasoning/
│ │ ├── reflection.py
│ │ ├── reasoning.py
│ │ └── refine.py
│ └── persona/
│ └── speak.py
└── .nebula_fallback/ # Disk storage until Nebula runs
└── {session_id}/
├── L10_*.json
├── L20_*.json
└── L30_*.json
```
---
## Roadmap
### ✅ Phase 1 (Complete)
- Unified container architecture
- Multi-level summarization (L1-L30)
- HTTP client for Nebula (with disk fallback)
- Session management
- Dual-mode operation
### 🚧 Phase 2 (In Progress)
- Build Nebula vector database
- RAG integration
- Memory resurfacing based on semantic similarity
### 📋 Phase 3 (Planned)
- Entity extraction from summaries
- Topic clustering
- Automatic knowledge graph generation
- Temporal memory (what happened when)
---
## Troubleshooting
### Container won't start
```bash
# Check logs
docker-compose logs lyra
# Common issues:
# - Missing .env file
# - Invalid LLM backend URLs
# - Port conflicts (7078, 7081)
```
### Summaries not appearing
```bash
# Check Nebula fallback directory
ls -la .nebula_fallback/
# Verify Cortex is processing
docker-compose logs lyra | grep "Nebula"
```
### Sessions not persisting
```bash
# Check volume mount
docker-compose exec lyra ls -la /app/relay/sessions/
# Verify session save calls
curl http://localhost:7078/sessions
```
---
## Development
### Making Changes
**Code changes (hot reload):**
```bash
docker-compose restart lyra
```
**Dependency changes (rebuild):**
```bash
docker-compose up -d --build lyra
```
**View logs:**
```bash
docker-compose logs -f lyra
```
### Adding a New LLM Backend
1. Add to `.env`:
```bash
LLM_CUSTOM_URL=http://your-backend:port
LLM_CUSTOM_MODEL=model-name
```
2. Configure module:
```bash
CORTEX_LLM=CUSTOM
```
3. Restart:
```bash
docker-compose restart lyra
```
---
## Version History
### v1.0.0 (2026-02-23) - The Great Simplification
**Major Refactor:**
- ✅ Unified Relay + Cortex into single container
- ✅ Removed NeoMem (replaced by upcoming Nebula)
- ✅ Removed old ingest_handler and RAG services
- ✅ Simplified to core flow: intake → summarize → store
- ✅ Added HTTP client for Nebula with disk fallback
- ✅ Cleaned docker-compose (2 services instead of 7)
- ✅ Updated documentation to reflect new architecture
**Architecture Changes:**
- Intake now sends summaries to Nebula (HTTP POST)
- Disk fallback writes JSON files to `.nebula_fallback/`
- Relay and Cortex communicate via localhost (faster)
- Single build, single deploy, single log stream
---
## License
© 2026 Terra-Mechanics / ServersDown Labs. Apache 2.0.
**Built with Claude Code**
---
## Credits
Built by Brian with assistance from Claude (Anthropic).
Special thanks to the open source community:
- FastAPI
- Express.js
- Docker
- llama.cpp
- Ollama
-159
View File
@@ -1,159 +0,0 @@
# Trilium ETAPI Integration Setup
This guide will help you enable Lyra's integration with your Trilium notes using the ETAPI (External API).
## What You Can Do with Trilium Integration
Once enabled, Lyra can help you:
- 🔍 Search through your notes
- 📝 Create new notes from conversations
- 🔄 Find duplicate or similar notes
- 🏷️ Suggest better organization and tags
- 📊 Summarize and update existing notes
## Prerequisites
- Trilium Notes installed and running
- Access to Trilium's web interface
- Lyra running on the same network as Trilium
## Step 1: Generate ETAPI Token in Trilium
1. **Open Trilium** in your web browser (e.g., `http://10.0.0.2:4292`)
2. **Navigate to Options**:
- Click the menu icon (≡) in the top-left corner
- Select **"Options"** from the menu
3. **Go to ETAPI Section**:
- In the Options sidebar, find and click **"ETAPI"**
- This section manages external API access
4. **Generate a New Token**:
- Look for the **"Create New Token"** or **"Generate Token"** button
- Click it to create a new ETAPI token
- You may be asked to provide a name/description for the token (e.g., "Lyra Integration")
5. **Copy the Token**:
- Once generated, you'll see a long string of characters (this is your token)
- **IMPORTANT**: Copy this token immediately - Trilium stores it hashed and you won't see it again!
- The token message will say: "ETAPI token created, copy the created token into the clipboard"
- Example format: `3ZOIydvNps3R_fZEE+kOFXiJlJ7vaeXHMEW6QuRYQm3+6qpjVxFwp9LE=`
6. **Save the Token Securely**:
- Store it temporarily in a secure place (password manager or secure note)
- You'll need to paste it into Lyra's configuration in the next step
## Step 2: Configure Lyra
1. **Edit the Environment File**:
```bash
nano /home/serversdown/project-lyra/.env
```
2. **Add/Update Trilium Configuration**:
Find or add these lines:
```env
# Trilium ETAPI Integration
ENABLE_TRILIUM=true
TRILIUM_URL=http://10.0.0.2:4292
TRILIUM_ETAPI_TOKEN=your_token_here
# Enable tools in standard mode (if not already set)
STANDARD_MODE_ENABLE_TOOLS=true
```
3. **Replace `your_token_here`** with the actual token you copied from Trilium
4. **Save and exit** (Ctrl+O, Enter, Ctrl+X in nano)
## Step 3: Restart Cortex Service
For the changes to take effect, restart the Cortex service:
```bash
cd /home/serversdown/project-lyra
docker-compose restart cortex
```
Or if running with Docker directly:
```bash
docker restart cortex
```
## Step 4: Test the Integration
Once restarted, try these example queries in Lyra (using Cortex mode):
1. **Test Search**:
- "Search my Trilium notes for topics about AI"
- "Find notes containing 'project planning'"
2. **Test Create Note**:
- "Create a note in Trilium titled 'Meeting Notes' with a summary of our conversation"
- "Save this to my Trilium as a new note"
3. **Watch the Thinking Stream**:
- Open the thinking stream panel (🧠 Show Work)
- You should see tool calls to `search_notes` and `create_note`
## Troubleshooting
### "Connection refused" or "Cannot reach Trilium"
- Verify Trilium is running: `curl http://10.0.0.2:4292`
- Check that Cortex can access Trilium's network
- Ensure the URL in `.env` is correct
### "Authentication failed" or "Invalid token"
- Double-check the token was copied correctly (no extra spaces)
- Generate a new token in Trilium if needed
- Verify `TRILIUM_ETAPI_TOKEN` in `.env` is set correctly
### "No results found" when searching
- Verify you have notes in Trilium
- Try a broader search query
- Check Trilium's search functionality works directly
### Tools not appearing in Cortex mode
- Verify `ENABLE_TRILIUM=true` is set
- Restart Cortex after changing `.env`
- Check Cortex logs: `docker logs cortex`
## Security Notes
⚠️ **Important Security Considerations**:
- The ETAPI token provides **full access** to your Trilium notes
- Keep the token secure - do not share or commit to git
- The `.env` file should be in `.gitignore` (already configured)
- Consider using a dedicated token for Lyra (you can create multiple tokens)
- Revoke tokens you no longer use from Trilium's ETAPI settings
## Available Functions
Currently enabled functions:
### `search_notes(query, limit)`
Search through your Trilium notes by keyword or phrase.
**Example**: "Search my notes for 'machine learning' and show the top 5 results"
### `create_note(title, content, parent_note_id)`
Create a new note in Trilium with specified title and content.
**Example**: "Create a note called 'Ideas from Today' with this summary: [content]"
**Optional**: Specify a parent note ID to nest the new note under an existing note.
## Future Enhancements
Potential additions to the integration:
- Update existing notes
- Retrieve full note content by ID
- Manage tags and attributes
- Clone/duplicate notes
- Export notes in various formats
---
**Need Help?** Check the Cortex logs or open an issue on the project repository.
-16
View File
@@ -1,16 +0,0 @@
# Ignore node_modules - Docker will rebuild them inside
node_modules
npm-debug.log
yarn-error.log
*.log
# Ignore environment files
.env
.env.local
# Ignore OS/editor cruft
.DS_Store
*.swp
*.swo
.vscode
.idea
-18
View File
@@ -1,18 +0,0 @@
# relay/Dockerfile
FROM node:18-alpine
# Create app directory
WORKDIR /app
# Copy package.json and install deps first (better caching)
COPY package.json ./
RUN npm install
# Copy the rest of the app
COPY . .
# Expose port
EXPOSE 7078
# Run the server
CMD ["npm", "start"]
-73
View File
@@ -1,73 +0,0 @@
// relay/lib/cortex.js
import fetch from "node-fetch";
const REFLECT_URL = process.env.CORTEX_URL || "http://localhost:7081/reflect";
const INGEST_URL = process.env.CORTEX_URL_INGEST || "http://localhost:7081/ingest";
export async function reflectWithCortex(userInput, memories = []) {
const body = { prompt: userInput, memories };
try {
const res = await fetch(REFLECT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
timeout: 120000,
});
const rawText = await res.text();
console.log("🔎 [Cortex-Debug] rawText from /reflect →", rawText.slice(0, 300));
if (!res.ok) {
throw new Error(`HTTP ${res.status}${rawText.slice(0, 200)}`);
}
let data;
try {
data = JSON.parse(rawText);
} catch (err) {
// Fallback ① try to grab a JSON-looking block
const match = rawText.match(/\{[\s\S]*\}/);
if (match) {
try {
data = JSON.parse(match[0]);
} catch {
data = { reflection_raw: rawText.trim(), notes: "partial parse" };
}
} else {
// Fallback ② if its already an object (stringified Python dict)
try {
const normalized = rawText
.replace(/'/g, '"') // convert single quotes
.replace(/None/g, 'null'); // convert Python None
data = JSON.parse(normalized);
} catch {
data = { reflection_raw: rawText.trim(), notes: "no JSON found" };
}
}
}
if (typeof data !== "object") {
data = { reflection_raw: rawText.trim(), notes: "non-object response" };
}
console.log("🧠 Cortex reflection normalized:", data);
return data;
} catch (e) {
console.warn("⚠️ Cortex reflect failed:", e.message);
return { error: e.message, reflection_raw: "" };
}
}
export async function ingestToCortex(user, assistant, reflection = {}, sessionId = "default") {
const body = { turn: { user, assistant }, reflection, session_id: sessionId };
try {
const res = await fetch(INGEST_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
timeout: 120000,
});
console.log(`📤 Sent exchange to Cortex ingest (${res.status})`);
} catch (e) {
console.warn("⚠️ Cortex ingest failed:", e.message);
}
}
-161
View File
@@ -1,161 +0,0 @@
async function tryBackend(backend, messages) {
if (!backend.url || !backend.model) throw new Error("missing url/model");
const isOllama = backend.type === "ollama";
const isOpenAI = backend.type === "openai";
const isVllm = backend.type === "vllm";
const isLlamaCpp = backend.type === "llamacpp";
let endpoint = backend.url;
let headers = { "Content-Type": "application/json" };
if (isOpenAI) headers["Authorization"] = `Bearer ${OPENAI_API_KEY}`;
// Choose correct endpoint automatically
if (isOllama && !endpoint.endsWith("/api/chat")) endpoint += "/api/chat";
if ((isVllm || isLlamaCpp) && !endpoint.endsWith("/v1/completions")) endpoint += "/v1/completions";
if (isOpenAI && !endpoint.endsWith("/v1/chat/completions")) endpoint += "/v1/chat/completions";
// Build payload based on backend style
const body = (isVllm || isLlamaCpp)
? {
model: backend.model,
prompt: messages.map(m => m.content).join("\n"),
max_tokens: 400,
temperature: 0.3,
}
: isOllama
? { model: backend.model, messages, stream: false }
: { model: backend.model, messages, stream: false };
const resp = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(body),
timeout: 120000,
});
if (!resp.ok) throw new Error(`${backend.key} HTTP ${resp.status}`);
const raw = await resp.text();
// 🧩 Normalize replies
let reply = "";
let parsedData = null;
try {
if (isOllama) {
// Ollama sometimes returns NDJSON lines; merge them
const merged = raw
.split("\n")
.filter(line => line.trim().startsWith("{"))
.map(line => JSON.parse(line))
.map(obj => obj.message?.content || obj.response || "")
.join("");
reply = merged.trim();
} else {
parsedData = JSON.parse(raw);
reply =
parsedData?.choices?.[0]?.text?.trim() ||
parsedData?.choices?.[0]?.message?.content?.trim() ||
parsedData?.message?.content?.trim() ||
"";
}
} catch (err) {
reply = `[parse error: ${err.message}]`;
}
return { reply, raw, parsedData, backend: backend.key };
}
// ------------------------------------
// Structured logging helper
// ------------------------------------
const LOG_DETAIL = process.env.LOG_DETAIL_LEVEL || "summary"; // minimal | summary | detailed | verbose
function logLLMCall(backend, messages, result, error = null) {
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
if (error) {
// Always log errors
console.warn(`⚠️ [LLM] ${backend.key.toUpperCase()} failed | ${timestamp} | ${error.message}`);
return;
}
// Success - log based on detail level
if (LOG_DETAIL === "minimal") {
return; // Don't log successful calls in minimal mode
}
if (LOG_DETAIL === "summary") {
console.log(`✅ [LLM] ${backend.key.toUpperCase()} | ${timestamp} | Reply: ${result.reply.substring(0, 80)}...`);
return;
}
// Detailed or verbose
console.log(`\n${'─'.repeat(100)}`);
console.log(`🧠 LLM CALL | Backend: ${backend.key.toUpperCase()} | ${timestamp}`);
console.log(`${'─'.repeat(100)}`);
// Show prompt preview
const lastMsg = messages[messages.length - 1];
const promptPreview = (lastMsg?.content || '').substring(0, 150);
console.log(`📝 Prompt: ${promptPreview}...`);
// Show parsed reply
console.log(`💬 Reply: ${result.reply.substring(0, 200)}...`);
// Show raw response only in verbose mode
if (LOG_DETAIL === "verbose" && result.parsedData) {
console.log(`\n╭─ RAW RESPONSE ────────────────────────────────────────────────────────────────────────────`);
const jsonStr = JSON.stringify(result.parsedData, null, 2);
const lines = jsonStr.split('\n');
const maxLines = 50;
lines.slice(0, maxLines).forEach(line => {
console.log(`${line}`);
});
if (lines.length > maxLines) {
console.log(`│ ... (${lines.length - maxLines} more lines - check raw field for full response)`);
}
console.log(`${'─'.repeat(95)}`);
}
console.log(`${'─'.repeat(100)}\n`);
}
// ------------------------------------
// Export the main call helper
// ------------------------------------
export async function callSpeechLLM(messages) {
const backends = [
{ key: "primary", type: "vllm", url: process.env.LLM_PRIMARY_URL, model: process.env.LLM_PRIMARY_MODEL },
{ key: "secondary",type: "ollama", url: process.env.LLM_SECONDARY_URL,model: process.env.LLM_SECONDARY_MODEL },
{ key: "cloud", type: "openai", url: process.env.LLM_CLOUD_URL, model: process.env.LLM_CLOUD_MODEL },
{ key: "fallback", type: "llamacpp", url: process.env.LLM_FALLBACK_URL, model: process.env.LLM_FALLBACK_MODEL },
];
const failedBackends = [];
for (const b of backends) {
if (!b.url || !b.model) continue;
try {
const out = await tryBackend(b, messages);
logLLMCall(b, messages, out);
return out;
} catch (err) {
logLLMCall(b, messages, null, err);
failedBackends.push({ backend: b.key, error: err.message });
}
}
// All backends failed - log summary
console.error(`\n${'='.repeat(100)}`);
console.error(`🔴 ALL LLM BACKENDS FAILED`);
console.error(`${'='.repeat(100)}`);
failedBackends.forEach(({ backend, error }) => {
console.error(` ${backend.toUpperCase()}: ${error}`);
});
console.error(`${'='.repeat(100)}\n`);
throw new Error("all_backends_failed");
}
-5477
View File
File diff suppressed because it is too large Load Diff
-16
View File
@@ -1,16 +0,0 @@
{
"name": "lyra-relay",
"version": "0.1.0",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"mem0ai": "^2.1.38",
"node-fetch": "^3.3.2"
}
}
-368
View File
@@ -1,368 +0,0 @@
// relay v0.3.0
// Core relay server for Lyra project
// Handles incoming chat requests and forwards them to Cortex services
import express from "express";
import dotenv from "dotenv";
import cors from "cors";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
dotenv.config();
// ES module __dirname workaround
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SESSIONS_DIR = path.join(__dirname, "sessions");
const app = express();
app.use(cors());
app.use(express.json());
const PORT = Number(process.env.PORT || 7078);
// Cortex endpoints (localhost since they're in the same container now)
const CORTEX_REASON = process.env.CORTEX_REASON_URL || "http://localhost:7081/reason";
const CORTEX_SIMPLE = process.env.CORTEX_SIMPLE_URL || "http://localhost:7081/simple";
// -----------------------------------------------------
// Helper request wrapper
// -----------------------------------------------------
async function postJSON(url, data) {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const raw = await resp.text();
let json;
try {
json = raw ? JSON.parse(raw) : null;
} catch (e) {
throw new Error(`Non-JSON from ${url}: ${raw}`);
}
if (!resp.ok) {
throw new Error(json?.detail || json?.error || raw);
}
return json;
}
// -----------------------------------------------------
// The unified chat handler
// -----------------------------------------------------
async function handleChatRequest(session_id, user_msg, mode = "cortex", backend = null) {
let reason;
// Determine which endpoint to use based on mode
const endpoint = mode === "standard" ? CORTEX_SIMPLE : CORTEX_REASON;
const modeName = mode === "standard" ? "simple" : "reason";
console.log(`Relay → routing to Cortex.${modeName} (mode: ${mode}${backend ? `, backend: ${backend}` : ''})`);
// Build request payload
const payload = {
session_id,
user_prompt: user_msg
};
// Add backend parameter if provided (only for standard mode)
if (backend && mode === "standard") {
payload.backend = backend;
}
// Call appropriate Cortex endpoint
try {
reason = await postJSON(endpoint, payload);
} catch (e) {
console.error(`Relay → Cortex.${modeName} error:`, e.message);
throw new Error(`cortex_${modeName}_failed: ${e.message}`);
}
// Correct persona field
const persona =
reason.persona ||
reason.final_output ||
"(no persona text)";
// Return final answer
return {
session_id,
reply: persona
};
}
// -----------------------------------------------------
// HEALTHCHECK
// -----------------------------------------------------
app.get("/_health", (_, res) => {
res.json({ ok: true });
});
// -----------------------------------------------------
// OPENAI-COMPATIBLE ENDPOINT
// -----------------------------------------------------
app.post("/v1/chat/completions", async (req, res) => {
try {
const session_id = req.body.session_id || req.body.sessionId || req.body.user || "default";
const messages = req.body.messages || [];
const lastMessage = messages[messages.length - 1];
const user_msg = lastMessage?.content || "";
const mode = req.body.mode || "cortex"; // Get mode from request, default to cortex
const backend = req.body.backend || null; // Get backend preference
if (!user_msg) {
return res.status(400).json({ error: "No message content provided" });
}
console.log(`Relay (v1) → received: "${user_msg}" [mode: ${mode}${backend ? `, backend: ${backend}` : ''}]`);
const result = await handleChatRequest(session_id, user_msg, mode, backend);
res.json({
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "lyra",
choices: [{
index: 0,
message: {
role: "assistant",
content: result.reply
},
finish_reason: "stop"
}],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
});
} catch (err) {
console.error("Relay v1 fatal:", err);
res.status(500).json({
error: {
message: err.message || String(err),
type: "server_error",
code: "relay_failed"
}
});
}
});
// -----------------------------------------------------
// MAIN ENDPOINT (Lyra-native UI)
// -----------------------------------------------------
app.post("/chat", async (req, res) => {
try {
const session_id = req.body.session_id || "default";
const user_msg = req.body.message || "";
const mode = req.body.mode || "cortex"; // Get mode from request, default to cortex
const backend = req.body.backend || null; // Get backend preference
console.log(`Relay → received: "${user_msg}" [mode: ${mode}${backend ? `, backend: ${backend}` : ''}]`);
const result = await handleChatRequest(session_id, user_msg, mode, backend);
res.json(result);
} catch (err) {
console.error("Relay fatal:", err);
res.status(500).json({
error: "relay_failed",
detail: err.message || String(err)
});
}
});
// -----------------------------------------------------
// SESSION ENDPOINTS (for UI)
// -----------------------------------------------------
// Helper functions for session persistence
async function ensureSessionsDir() {
try {
await fs.mkdir(SESSIONS_DIR, { recursive: true });
} catch (err) {
console.error("Failed to create sessions directory:", err);
}
}
async function loadSession(sessionId) {
try {
const sessionPath = path.join(SESSIONS_DIR, `${sessionId}.json`);
const data = await fs.readFile(sessionPath, "utf-8");
return JSON.parse(data);
} catch (err) {
// File doesn't exist or is invalid - return empty array
return [];
}
}
async function saveSession(sessionId, history, metadata = {}) {
try {
await ensureSessionsDir();
const sessionPath = path.join(SESSIONS_DIR, `${sessionId}.json`);
const metadataPath = path.join(SESSIONS_DIR, `${sessionId}.meta.json`);
// Save history
await fs.writeFile(sessionPath, JSON.stringify(history, null, 2), "utf-8");
// Save metadata (name, etc.)
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
return true;
} catch (err) {
console.error(`Failed to save session ${sessionId}:`, err);
return false;
}
}
async function loadSessionMetadata(sessionId) {
try {
const metadataPath = path.join(SESSIONS_DIR, `${sessionId}.meta.json`);
const data = await fs.readFile(metadataPath, "utf-8");
return JSON.parse(data);
} catch (err) {
// No metadata file, return default
return { name: sessionId };
}
}
async function saveSessionMetadata(sessionId, metadata) {
try {
await ensureSessionsDir();
const metadataPath = path.join(SESSIONS_DIR, `${sessionId}.meta.json`);
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
return true;
} catch (err) {
console.error(`Failed to save metadata for ${sessionId}:`, err);
return false;
}
}
async function listSessions() {
try {
await ensureSessionsDir();
const files = await fs.readdir(SESSIONS_DIR);
const sessions = [];
for (const file of files) {
if (file.endsWith(".json") && !file.endsWith(".meta.json")) {
const sessionId = file.replace(".json", "");
const sessionPath = path.join(SESSIONS_DIR, file);
const stats = await fs.stat(sessionPath);
// Try to read the session to get message count
let messageCount = 0;
try {
const data = await fs.readFile(sessionPath, "utf-8");
const history = JSON.parse(data);
messageCount = history.length;
} catch (e) {
// Invalid JSON, skip
}
// Load metadata (name)
const metadata = await loadSessionMetadata(sessionId);
sessions.push({
id: sessionId,
name: metadata.name || sessionId,
lastModified: stats.mtime,
messageCount
});
}
}
// Sort by last modified (newest first)
sessions.sort((a, b) => b.lastModified - a.lastModified);
return sessions;
} catch (err) {
console.error("Failed to list sessions:", err);
return [];
}
}
async function deleteSession(sessionId) {
try {
const sessionPath = path.join(SESSIONS_DIR, `${sessionId}.json`);
const metadataPath = path.join(SESSIONS_DIR, `${sessionId}.meta.json`);
// Delete session file
await fs.unlink(sessionPath);
// Delete metadata file (if exists)
try {
await fs.unlink(metadataPath);
} catch (e) {
// Metadata file doesn't exist, that's ok
}
return true;
} catch (err) {
console.error(`Failed to delete session ${sessionId}:`, err);
return false;
}
}
// GET /sessions - List all sessions
app.get("/sessions", async (req, res) => {
const sessions = await listSessions();
res.json(sessions);
});
// GET /sessions/:id - Get specific session history
app.get("/sessions/:id", async (req, res) => {
const sessionId = req.params.id;
const history = await loadSession(sessionId);
res.json(history);
});
// POST /sessions/:id - Save session history
app.post("/sessions/:id", async (req, res) => {
const sessionId = req.params.id;
const history = req.body;
// Load existing metadata to preserve it
const existingMetadata = await loadSessionMetadata(sessionId);
const success = await saveSession(sessionId, history, existingMetadata);
if (success) {
res.json({ ok: true, saved: history.length });
} else {
res.status(500).json({ error: "Failed to save session" });
}
});
// PATCH /sessions/:id/metadata - Update session metadata (name, etc.)
app.patch("/sessions/:id/metadata", async (req, res) => {
const sessionId = req.params.id;
const metadata = req.body;
const success = await saveSessionMetadata(sessionId, metadata);
if (success) {
res.json({ ok: true, metadata });
} else {
res.status(500).json({ error: "Failed to update metadata" });
}
});
// DELETE /sessions/:id - Delete a session
app.delete("/sessions/:id", async (req, res) => {
const sessionId = req.params.id;
const success = await deleteSession(sessionId);
if (success) {
res.json({ ok: true, deleted: sessionId });
} else {
res.status(500).json({ error: "Failed to delete session" });
}
});
// -----------------------------------------------------
app.listen(PORT, () => {
console.log(`Relay is online on port ${PORT}`);
});
-39
View File
@@ -1,39 +0,0 @@
// test-llm.js
import path from "path";
import { fileURLToPath } from "url";
import dotenv from "dotenv";
import { callSpeechLLM } from "./lib/llm.js";
// ───────────────────────────────────────────────
// 🔧 Load environment
// ───────────────────────────────────────────────
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envPath = path.join(__dirname, "../.env");
dotenv.config({ path: envPath });
console.log("🔧 Using .env from:", envPath);
console.log("🔧 LLM_FORCE_BACKEND =", process.env.LLM_FORCE_BACKEND);
console.log("🔧 LLM_PRIMARY_URL =", process.env.LLM_PRIMARY_URL);
// ───────────────────────────────────────────────
// 🧪 Run a simple test message
// ───────────────────────────────────────────────
async function testLLM() {
console.log("🧪 Testing LLM helper...");
const messages = [
{ role: "user", content: "Say hello in five words or less." }
];
try {
const { reply, backend } = await callSpeechLLM(messages);
console.log(`✅ Reply: ${reply || "[no reply]"}`);
console.log(`Backend used: ${backend || "[unknown]"}`);
} catch (err) {
console.error("💥 Test failed:", err.message);
}
}
testLLM();
-927
View File
@@ -1,927 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Lyra Core Chat</title>
<link rel="stylesheet" href="style.css" />
<!-- PWA -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="manifest.json" />
</head>
<body>
<!-- Mobile Menu Overlay -->
<div class="mobile-menu-overlay" id="mobileMenuOverlay"></div>
<!-- Mobile Slide-out Menu -->
<div class="mobile-menu" id="mobileMenu">
<div class="mobile-menu-section">
<h4>Mode</h4>
<select id="mobileMode">
<option value="standard">Standard</option>
<option value="cortex">Cortex</option>
</select>
</div>
<div class="mobile-menu-section">
<h4>Session</h4>
<select id="mobileSessions"></select>
<button id="mobileNewSessionBtn"> New Session</button>
<button id="mobileRenameSessionBtn">✏️ Rename Session</button>
</div>
<div class="mobile-menu-section">
<h4>Actions</h4>
<button id="mobileThinkingStreamBtn">🧠 Show Work</button>
<button id="mobileSettingsBtn">⚙ Settings</button>
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
<button id="mobileForceReloadBtn">🔄 Force Reload</button>
</div>
</div>
<div id="chat">
<!-- Mode selector -->
<div id="model-select">
<!-- Hamburger menu (mobile only) -->
<button class="hamburger-menu" id="hamburgerMenu" aria-label="Menu">
<span></span>
<span></span>
<span></span>
</button>
<label for="mode">Mode:</label>
<select id="mode">
<option value="standard">Standard</option>
<option value="cortex">Cortex</option>
</select>
<button id="settingsBtn" style="margin-left: auto;">⚙ Settings</button>
<div id="theme-toggle">
<button id="toggleThemeBtn">🌙 Dark Mode</button>
</div>
</div>
<!-- Session selector -->
<div id="session-select">
<label for="sessions">Session:</label>
<select id="sessions"></select>
<button id="newSessionBtn"> New</button>
<button id="renameSessionBtn">✏️ Rename</button>
<button id="thinkingStreamBtn" title="Show thinking stream panel">🧠 Show Work</button>
</div>
<!-- Status -->
<div id="status">
<span id="status-dot"></span>
<span id="status-text">Checking Relay...</span>
</div>
<!-- Chat messages -->
<div id="messages"></div>
<!-- Thinking Stream Panel (collapsible) -->
<div id="thinkingPanel" class="thinking-panel collapsed">
<div class="thinking-header" id="thinkingHeader">
<span>🧠 Thinking Stream</span>
<div class="thinking-controls">
<span class="thinking-status-dot" id="thinkingStatusDot"></span>
<button class="thinking-clear-btn" id="thinkingClearBtn" title="Clear events">🗑️</button>
<button class="thinking-toggle-btn" id="thinkingToggleBtn"></button>
</div>
</div>
<div class="thinking-content" id="thinkingContent">
<div class="thinking-empty" id="thinkingEmpty">
<div class="thinking-empty-icon">🤔</div>
<p>Waiting for thinking events...</p>
</div>
</div>
</div>
<!-- Input box -->
<div id="input">
<input id="userInput" type="text" placeholder="Type a message..." autofocus />
<button id="sendBtn">Send</button>
</div>
</div>
<!-- Settings Modal (outside chat container) -->
<div id="settingsModal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h3>Settings</h3>
<button id="closeModalBtn" class="close-btn"></button>
</div>
<div class="modal-body">
<div class="settings-section">
<h4>Standard Mode Backend</h4>
<p class="settings-desc">Select which LLM backend to use for Standard Mode:</p>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="backend" value="SECONDARY" checked>
<span>SECONDARY - Ollama/Qwen (3090)</span>
<small>Fast, local, good for general chat</small>
</label>
<label class="radio-label">
<input type="radio" name="backend" value="PRIMARY">
<span>PRIMARY - llama.cpp (MI50)</span>
<small>Local, powerful, good for complex reasoning</small>
</label>
<label class="radio-label">
<input type="radio" name="backend" value="OPENAI">
<span>OPENAI - GPT-4o-mini</span>
<small>Cloud-based, high quality (costs money)</small>
</label>
<label class="radio-label">
<input type="radio" name="backend" value="custom">
<span>Custom Backend</span>
<input type="text" id="customBackend" placeholder="e.g., FALLBACK" />
</label>
</div>
</div>
<div class="settings-section" style="margin-top: 24px;">
<h4>Session Management</h4>
<p class="settings-desc">Manage your saved chat sessions:</p>
<div id="sessionList" class="session-list">
<p style="color: var(--text-fade); font-size: 0.85rem;">Loading sessions...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button id="saveSettingsBtn" class="primary-btn">Save</button>
<button id="cancelSettingsBtn">Cancel</button>
</div>
</div>
</div>
<script>
const RELAY_BASE = "http://10.0.0.41:7078";
const API_URL = `${RELAY_BASE}/v1/chat/completions`;
function generateSessionId() {
return "sess-" + Math.random().toString(36).substring(2, 10);
}
let history = [];
let currentSession = localStorage.getItem("currentSession") || null;
let sessions = []; // Now loaded from server
async function loadSessionsFromServer() {
try {
const resp = await fetch(`${RELAY_BASE}/sessions`);
const serverSessions = await resp.json();
sessions = serverSessions;
return sessions;
} catch (e) {
console.error("Failed to load sessions from server:", e);
return [];
}
}
async function renderSessions() {
const select = document.getElementById("sessions");
const mobileSelect = document.getElementById("mobileSessions");
select.innerHTML = "";
mobileSelect.innerHTML = "";
sessions.forEach(s => {
const opt = document.createElement("option");
opt.value = s.id;
opt.textContent = s.name || s.id;
if (s.id === currentSession) opt.selected = true;
select.appendChild(opt);
// Clone for mobile menu
const mobileOpt = opt.cloneNode(true);
mobileSelect.appendChild(mobileOpt);
});
}
function getSessionName(id) {
const s = sessions.find(s => s.id === id);
return s ? (s.name || s.id) : id;
}
async function saveSessionMetadata(sessionId, name) {
try {
await fetch(`${RELAY_BASE}/sessions/${sessionId}/metadata`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name })
});
return true;
} catch (e) {
console.error("Failed to save session metadata:", e);
return false;
}
}
async function loadSession(id) {
try {
const res = await fetch(`${RELAY_BASE}/sessions/${id}`);
const data = await res.json();
history = Array.isArray(data) ? data : [];
const messagesEl = document.getElementById("messages");
messagesEl.innerHTML = "";
history.forEach(m => addMessage(m.role, m.content, false)); // Don't auto-scroll for each message
addMessage("system", `📂 Loaded session: ${getSessionName(id)}${history.length} message(s)`, false);
// Scroll to bottom after all messages are loaded
messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: "smooth" });
} catch (e) {
addMessage("system", `Failed to load session: ${e.message}`);
}
}
async function saveSession() {
if (!currentSession) return;
try {
await fetch(`${RELAY_BASE}/sessions/${currentSession}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(history)
});
} catch (e) {
addMessage("system", `Failed to save session: ${e.message}`);
}
}
async function sendMessage() {
const inputEl = document.getElementById("userInput");
const msg = inputEl.value.trim();
if (!msg) return;
inputEl.value = "";
addMessage("user", msg);
history.push({ role: "user", content: msg });
await saveSession(); // ✅ persist both user + assistant messages
const mode = document.getElementById("mode").value;
// make sure we always include a stable user_id
let userId = localStorage.getItem("userId");
if (!userId) {
userId = "brian"; // use whatever ID you seeded Mem0 with
localStorage.setItem("userId", userId);
}
// Get backend preference for Standard Mode
let backend = null;
if (mode === "standard") {
backend = localStorage.getItem("standardModeBackend") || "SECONDARY";
}
const body = {
mode: mode,
messages: history,
sessionId: currentSession
};
// Only add backend if in standard mode
if (backend) {
body.backend = backend;
}
try {
const resp = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const data = await resp.json();
const reply = data.choices?.[0]?.message?.content || "(no reply)";
addMessage("assistant", reply);
history.push({ role: "assistant", content: reply });
await saveSession();
} catch (err) {
addMessage("system", "Error: " + err.message);
}
}
function addMessage(role, text, autoScroll = true) {
const messagesEl = document.getElementById("messages");
const msgDiv = document.createElement("div");
msgDiv.className = `msg ${role}`;
msgDiv.textContent = text;
messagesEl.appendChild(msgDiv);
// Auto-scroll to bottom if enabled
if (autoScroll) {
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: "smooth" });
});
}
}
async function checkHealth() {
try {
const resp = await fetch(API_URL.replace("/v1/chat/completions", "/_health"));
if (resp.ok) {
document.getElementById("status-dot").className = "dot ok";
document.getElementById("status-text").textContent = "Relay Online";
} else {
throw new Error("Bad status");
}
} catch (err) {
document.getElementById("status-dot").className = "dot fail";
document.getElementById("status-text").textContent = "Relay Offline";
}
}
document.addEventListener("DOMContentLoaded", () => {
// Mobile Menu Toggle
const hamburgerMenu = document.getElementById("hamburgerMenu");
const mobileMenu = document.getElementById("mobileMenu");
const mobileMenuOverlay = document.getElementById("mobileMenuOverlay");
function toggleMobileMenu() {
mobileMenu.classList.toggle("open");
mobileMenuOverlay.classList.toggle("show");
hamburgerMenu.classList.toggle("active");
}
function closeMobileMenu() {
mobileMenu.classList.remove("open");
mobileMenuOverlay.classList.remove("show");
hamburgerMenu.classList.remove("active");
}
hamburgerMenu.addEventListener("click", toggleMobileMenu);
mobileMenuOverlay.addEventListener("click", closeMobileMenu);
// Sync mobile menu controls with desktop
const mobileMode = document.getElementById("mobileMode");
const desktopMode = document.getElementById("mode");
// Sync mode selection
mobileMode.addEventListener("change", (e) => {
desktopMode.value = e.target.value;
desktopMode.dispatchEvent(new Event("change"));
});
desktopMode.addEventListener("change", (e) => {
mobileMode.value = e.target.value;
});
// Mobile theme toggle
document.getElementById("mobileToggleThemeBtn").addEventListener("click", () => {
document.getElementById("toggleThemeBtn").click();
updateMobileThemeButton();
});
function updateMobileThemeButton() {
const isDark = document.body.classList.contains("dark");
document.getElementById("mobileToggleThemeBtn").textContent = isDark ? "☀️ Light Mode" : "🌙 Dark Mode";
}
// Mobile settings button
document.getElementById("mobileSettingsBtn").addEventListener("click", () => {
closeMobileMenu();
document.getElementById("settingsBtn").click();
});
// Mobile thinking stream button
document.getElementById("mobileThinkingStreamBtn").addEventListener("click", () => {
closeMobileMenu();
document.getElementById("thinkingStreamBtn").click();
});
// Mobile new session button
document.getElementById("mobileNewSessionBtn").addEventListener("click", () => {
closeMobileMenu();
document.getElementById("newSessionBtn").click();
});
// Mobile rename session button
document.getElementById("mobileRenameSessionBtn").addEventListener("click", () => {
closeMobileMenu();
document.getElementById("renameSessionBtn").click();
});
// Sync mobile session selector with desktop
document.getElementById("mobileSessions").addEventListener("change", async (e) => {
closeMobileMenu();
const desktopSessions = document.getElementById("sessions");
desktopSessions.value = e.target.value;
desktopSessions.dispatchEvent(new Event("change"));
});
// Mobile force reload button
document.getElementById("mobileForceReloadBtn").addEventListener("click", async () => {
if (confirm("Force reload the app? This will clear cache and reload.")) {
// Clear all caches if available
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
}
// Force reload from server (bypass cache)
window.location.reload(true);
}
});
// Dark mode toggle - defaults to dark
const btn = document.getElementById("toggleThemeBtn");
// Set dark mode by default if no preference saved
const savedTheme = localStorage.getItem("theme");
if (!savedTheme || savedTheme === "dark") {
document.body.classList.add("dark");
btn.textContent = "☀️ Light Mode";
localStorage.setItem("theme", "dark");
} else {
btn.textContent = "🌙 Dark Mode";
}
btn.addEventListener("click", () => {
document.body.classList.toggle("dark");
const isDark = document.body.classList.contains("dark");
btn.textContent = isDark ? "☀️ Light Mode" : "🌙 Dark Mode";
localStorage.setItem("theme", isDark ? "dark" : "light");
updateMobileThemeButton();
});
// Initialize mobile theme button
updateMobileThemeButton();
// Sessions - Load from server
(async () => {
await loadSessionsFromServer();
await renderSessions();
// Ensure we have at least one session
if (sessions.length === 0) {
const id = generateSessionId();
const name = "default";
currentSession = id;
history = [];
await saveSession(); // Create empty session on server
await saveSessionMetadata(id, name);
await loadSessionsFromServer();
await renderSessions();
localStorage.setItem("currentSession", currentSession);
} else {
// If no current session or current session doesn't exist, use first one
if (!currentSession || !sessions.find(s => s.id === currentSession)) {
currentSession = sessions[0].id;
localStorage.setItem("currentSession", currentSession);
}
}
// Load current session history
if (currentSession) {
await loadSession(currentSession);
}
})();
// Switch session
document.getElementById("sessions").addEventListener("change", async e => {
currentSession = e.target.value;
history = [];
localStorage.setItem("currentSession", currentSession);
addMessage("system", `Switched to session: ${getSessionName(currentSession)}`);
await loadSession(currentSession);
});
// Create new session
document.getElementById("newSessionBtn").addEventListener("click", async () => {
const name = prompt("Enter new session name:");
if (!name) return;
const id = generateSessionId();
currentSession = id;
history = [];
localStorage.setItem("currentSession", currentSession);
// Create session on server
await saveSession();
await saveSessionMetadata(id, name);
await loadSessionsFromServer();
await renderSessions();
addMessage("system", `Created session: ${name}`);
});
// Rename session
document.getElementById("renameSessionBtn").addEventListener("click", async () => {
const session = sessions.find(s => s.id === currentSession);
if (!session) return;
const newName = prompt("Rename session:", session.name || currentSession);
if (!newName) return;
// Update metadata on server
await saveSessionMetadata(currentSession, newName);
await loadSessionsFromServer();
await renderSessions();
addMessage("system", `Session renamed to: ${newName}`);
});
// Thinking Stream button
document.getElementById("thinkingStreamBtn").addEventListener("click", () => {
if (!currentSession) {
alert("Please select a session first");
return;
}
// Open thinking stream in new window
const streamUrl = `http://10.0.0.41:8081/thinking-stream.html?session=${currentSession}`;
const windowFeatures = "width=600,height=800,menubar=no,toolbar=no,location=no,status=no";
window.open(streamUrl, `thinking_${currentSession}`, windowFeatures);
addMessage("system", "🧠 Opened thinking stream in new window");
});
// Settings Modal
const settingsModal = document.getElementById("settingsModal");
const settingsBtn = document.getElementById("settingsBtn");
const closeModalBtn = document.getElementById("closeModalBtn");
const saveSettingsBtn = document.getElementById("saveSettingsBtn");
const cancelSettingsBtn = document.getElementById("cancelSettingsBtn");
const modalOverlay = document.querySelector(".modal-overlay");
// Load saved backend preference
const savedBackend = localStorage.getItem("standardModeBackend") || "SECONDARY";
// Set initial radio button state
const backendRadios = document.querySelectorAll('input[name="backend"]');
let isCustomBackend = !["SECONDARY", "PRIMARY", "OPENAI"].includes(savedBackend);
if (isCustomBackend) {
document.querySelector('input[name="backend"][value="custom"]').checked = true;
document.getElementById("customBackend").value = savedBackend;
} else {
document.querySelector(`input[name="backend"][value="${savedBackend}"]`).checked = true;
}
// Session management functions
async function loadSessionList() {
try {
// Reload from server to get latest
await loadSessionsFromServer();
const sessionListEl = document.getElementById("sessionList");
if (sessions.length === 0) {
sessionListEl.innerHTML = '<p style="color: var(--text-fade); font-size: 0.85rem;">No saved sessions found</p>';
return;
}
sessionListEl.innerHTML = "";
sessions.forEach(sess => {
const sessionItem = document.createElement("div");
sessionItem.className = "session-item";
const sessionInfo = document.createElement("div");
sessionInfo.className = "session-info";
const sessionName = sess.name || sess.id;
const lastModified = new Date(sess.lastModified).toLocaleString();
sessionInfo.innerHTML = `
<strong>${sessionName}</strong>
<small>${sess.messageCount} messages • ${lastModified}</small>
`;
const deleteBtn = document.createElement("button");
deleteBtn.className = "session-delete-btn";
deleteBtn.textContent = "🗑️";
deleteBtn.title = "Delete session";
deleteBtn.onclick = async () => {
if (!confirm(`Delete session "${sessionName}"?`)) return;
try {
await fetch(`${RELAY_BASE}/sessions/${sess.id}`, { method: "DELETE" });
// Reload sessions from server
await loadSessionsFromServer();
// If we deleted the current session, switch to another or create new
if (currentSession === sess.id) {
if (sessions.length > 0) {
currentSession = sessions[0].id;
localStorage.setItem("currentSession", currentSession);
history = [];
await loadSession(currentSession);
} else {
const id = generateSessionId();
const name = "default";
currentSession = id;
localStorage.setItem("currentSession", currentSession);
history = [];
await saveSession();
await saveSessionMetadata(id, name);
await loadSessionsFromServer();
}
}
// Refresh both the dropdown and the settings list
await renderSessions();
await loadSessionList();
addMessage("system", `Deleted session: ${sessionName}`);
} catch (e) {
alert("Failed to delete session: " + e.message);
}
};
sessionItem.appendChild(sessionInfo);
sessionItem.appendChild(deleteBtn);
sessionListEl.appendChild(sessionItem);
});
} catch (e) {
const sessionListEl = document.getElementById("sessionList");
sessionListEl.innerHTML = '<p style="color: #ff3333; font-size: 0.85rem;">Failed to load sessions</p>';
}
}
// Show modal and load session list
settingsBtn.addEventListener("click", () => {
settingsModal.classList.add("show");
loadSessionList(); // Refresh session list when opening settings
});
// Hide modal functions
const hideModal = () => {
settingsModal.classList.remove("show");
};
closeModalBtn.addEventListener("click", hideModal);
cancelSettingsBtn.addEventListener("click", hideModal);
modalOverlay.addEventListener("click", hideModal);
// ESC key to close
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && settingsModal.classList.contains("show")) {
hideModal();
}
});
// Save settings
saveSettingsBtn.addEventListener("click", () => {
const selectedRadio = document.querySelector('input[name="backend"]:checked');
let backendValue;
if (selectedRadio.value === "custom") {
backendValue = document.getElementById("customBackend").value.trim().toUpperCase();
if (!backendValue) {
alert("Please enter a custom backend name");
return;
}
} else {
backendValue = selectedRadio.value;
}
localStorage.setItem("standardModeBackend", backendValue);
addMessage("system", `Backend changed to: ${backendValue}`);
hideModal();
});
// Health check
checkHealth();
setInterval(checkHealth, 10000);
// Input events
document.getElementById("sendBtn").addEventListener("click", sendMessage);
document.getElementById("userInput").addEventListener("keypress", e => {
if (e.key === "Enter") sendMessage();
});
// ========== THINKING STREAM INTEGRATION ==========
const thinkingPanel = document.getElementById("thinkingPanel");
const thinkingHeader = document.getElementById("thinkingHeader");
const thinkingToggleBtn = document.getElementById("thinkingToggleBtn");
const thinkingClearBtn = document.getElementById("thinkingClearBtn");
const thinkingContent = document.getElementById("thinkingContent");
const thinkingStatusDot = document.getElementById("thinkingStatusDot");
const thinkingEmpty = document.getElementById("thinkingEmpty");
let thinkingEventSource = null;
let thinkingEventCount = 0;
const CORTEX_BASE = "http://10.0.0.41:7081";
// Load thinking panel state from localStorage
const isPanelCollapsed = localStorage.getItem("thinkingPanelCollapsed") === "true";
if (!isPanelCollapsed) {
thinkingPanel.classList.remove("collapsed");
}
// Toggle thinking panel
thinkingHeader.addEventListener("click", (e) => {
if (e.target === thinkingClearBtn) return; // Don't toggle if clicking clear
thinkingPanel.classList.toggle("collapsed");
localStorage.setItem("thinkingPanelCollapsed", thinkingPanel.classList.contains("collapsed"));
});
// Clear thinking events
thinkingClearBtn.addEventListener("click", (e) => {
e.stopPropagation();
clearThinkingEvents();
});
function clearThinkingEvents() {
thinkingContent.innerHTML = '';
thinkingContent.appendChild(thinkingEmpty);
thinkingEventCount = 0;
// Clear from localStorage
if (currentSession) {
localStorage.removeItem(`thinkingEvents_${currentSession}`);
}
}
function connectThinkingStream() {
if (!currentSession) return;
// Close existing connection
if (thinkingEventSource) {
thinkingEventSource.close();
}
// Load persisted events
loadThinkingEvents();
const url = `${CORTEX_BASE}/stream/thinking/${currentSession}`;
console.log('Connecting thinking stream:', url);
thinkingEventSource = new EventSource(url);
thinkingEventSource.onopen = () => {
console.log('Thinking stream connected');
thinkingStatusDot.className = 'thinking-status-dot connected';
};
thinkingEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
addThinkingEvent(data);
saveThinkingEvent(data); // Persist event
} catch (e) {
console.error('Failed to parse thinking event:', e);
}
};
thinkingEventSource.onerror = (error) => {
console.error('Thinking stream error:', error);
thinkingStatusDot.className = 'thinking-status-dot disconnected';
// Retry connection after 2 seconds
setTimeout(() => {
if (thinkingEventSource && thinkingEventSource.readyState === EventSource.CLOSED) {
console.log('Reconnecting thinking stream...');
connectThinkingStream();
}
}, 2000);
};
}
function addThinkingEvent(event) {
// Remove empty state if present
if (thinkingEventCount === 0 && thinkingEmpty.parentNode) {
thinkingContent.removeChild(thinkingEmpty);
}
const eventDiv = document.createElement('div');
eventDiv.className = `thinking-event thinking-event-${event.type}`;
let icon = '';
let message = '';
let details = '';
switch (event.type) {
case 'connected':
icon = '✓';
message = 'Stream connected';
details = `Session: ${event.session_id}`;
break;
case 'thinking':
icon = '🤔';
message = event.data.message;
break;
case 'tool_call':
icon = '🔧';
message = event.data.message;
if (event.data.args) {
details = JSON.stringify(event.data.args, null, 2);
}
break;
case 'tool_result':
icon = '📊';
message = event.data.message;
if (event.data.result && event.data.result.stdout) {
details = `stdout: ${event.data.result.stdout}`;
}
break;
case 'done':
icon = '✅';
message = event.data.message;
if (event.data.final_answer) {
details = event.data.final_answer;
}
break;
case 'error':
icon = '❌';
message = event.data.message;
break;
default:
icon = '•';
message = JSON.stringify(event.data);
}
eventDiv.innerHTML = `
<span class="thinking-event-icon">${icon}</span>
<span>${message}</span>
${details ? `<div class="thinking-event-details">${details}</div>` : ''}
`;
thinkingContent.appendChild(eventDiv);
thinkingContent.scrollTop = thinkingContent.scrollHeight;
thinkingEventCount++;
}
// Persist thinking events to localStorage
function saveThinkingEvent(event) {
if (!currentSession) return;
const key = `thinkingEvents_${currentSession}`;
let events = JSON.parse(localStorage.getItem(key) || '[]');
// Keep only last 50 events to avoid bloating localStorage
if (events.length >= 50) {
events = events.slice(-49);
}
events.push({
...event,
timestamp: Date.now()
});
localStorage.setItem(key, JSON.stringify(events));
}
// Load persisted thinking events
function loadThinkingEvents() {
if (!currentSession) return;
const key = `thinkingEvents_${currentSession}`;
const events = JSON.parse(localStorage.getItem(key) || '[]');
// Clear current display
thinkingContent.innerHTML = '';
thinkingEventCount = 0;
// Replay events
events.forEach(event => addThinkingEvent(event));
// Show empty state if no events
if (events.length === 0) {
thinkingContent.appendChild(thinkingEmpty);
}
}
// Update the old thinking stream button to toggle panel instead
document.getElementById("thinkingStreamBtn").addEventListener("click", () => {
thinkingPanel.classList.remove("collapsed");
localStorage.setItem("thinkingPanelCollapsed", "false");
});
// Mobile thinking stream button
document.getElementById("mobileThinkingStreamBtn").addEventListener("click", () => {
closeMobileMenu();
thinkingPanel.classList.remove("collapsed");
localStorage.setItem("thinkingPanelCollapsed", "false");
});
// Connect thinking stream when session loads
if (currentSession) {
connectThinkingStream();
}
// Reconnect thinking stream when session changes
const originalSessionChange = document.getElementById("sessions").onchange;
document.getElementById("sessions").addEventListener("change", () => {
setTimeout(() => {
connectThinkingStream();
}, 500); // Wait for session to load
});
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (thinkingEventSource) {
thinkingEventSource.close();
}
});
});
</script>
</body>
</html>
-20
View File
@@ -1,20 +0,0 @@
{
"name": "Lyra Chat",
"short_name": "Lyra",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#181818",
"theme_color": "#181818",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
-909
View File
@@ -1,909 +0,0 @@
:root {
--bg-dark: #0a0a0a;
--bg-panel: rgba(255, 115, 0, 0.1);
--accent: #ff6600;
--accent-glow: 0 0 12px #ff6600cc;
--text-main: #e6e6e6;
--text-fade: #999;
--font-console: "IBM Plex Mono", monospace;
}
/* Light mode variables */
body {
--bg-dark: #f5f5f5;
--bg-panel: rgba(255, 115, 0, 0.05);
--accent: #ff6600;
--accent-glow: 0 0 12px #ff6600cc;
--text-main: #1a1a1a;
--text-fade: #666;
}
/* Dark mode variables */
body.dark {
--bg-dark: #0a0a0a;
--bg-panel: rgba(255, 115, 0, 0.1);
--accent: #ff6600;
--accent-glow: 0 0 12px #ff6600cc;
--text-main: #e6e6e6;
--text-fade: #999;
}
body {
margin: 0;
background: var(--bg-dark);
color: var(--text-main);
font-family: var(--font-console);
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
#chat {
width: 95%;
max-width: 900px;
height: 95vh;
display: flex;
flex-direction: column;
border: 1px solid var(--accent);
border-radius: 10px;
box-shadow: var(--accent-glow);
background: var(--bg-dark);
overflow: hidden;
}
/* Header sections */
#model-select, #session-select, #status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--accent);
background-color: rgba(255, 102, 0, 0.05);
}
#status {
justify-content: flex-start;
border-top: 1px solid var(--accent);
}
label, select, button {
font-family: var(--font-console);
font-size: 0.9rem;
color: var(--text-main);
background: transparent;
border: 1px solid var(--accent);
border-radius: 4px;
padding: 4px 8px;
}
button:hover, select:hover {
box-shadow: 0 0 8px var(--accent);
cursor: pointer;
}
#thinkingStreamBtn {
background: rgba(138, 43, 226, 0.2);
border-color: #8a2be2;
}
#thinkingStreamBtn:hover {
box-shadow: 0 0 8px #8a2be2;
background: rgba(138, 43, 226, 0.3);
}
/* Chat area */
#messages {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
scroll-behavior: smooth;
}
/* Messages */
.msg {
max-width: 80%;
padding: 10px 14px;
border-radius: 8px;
line-height: 1.4;
word-wrap: break-word;
box-shadow: 0 0 8px rgba(255,102,0,0.2);
}
.msg.user {
align-self: flex-end;
background: rgba(255,102,0,0.15);
border: 1px solid var(--accent);
}
.msg.assistant {
align-self: flex-start;
background: rgba(255,102,0,0.08);
border: 1px solid rgba(255,102,0,0.5);
}
.msg.system {
align-self: center;
font-size: 0.8rem;
color: var(--text-fade);
}
/* Input bar */
#input {
display: flex;
border-top: 1px solid var(--accent);
background: rgba(255, 102, 0, 0.05);
padding: 10px;
}
#userInput {
flex: 1;
background: transparent;
color: var(--text-main);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 8px;
}
#sendBtn {
margin-left: 8px;
}
/* Relay status dot */
#status {
display: flex;
align-items: center;
margin: 10px 0;
gap: 8px;
font-family: monospace;
color: #f5f5f5;
}
#status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
@keyframes pulseGreen {
0% { box-shadow: 0 0 5px #00ff66; opacity: 0.9; }
50% { box-shadow: 0 0 20px #00ff99; opacity: 1; }
100% { box-shadow: 0 0 5px #00ff66; opacity: 0.9; }
}
.dot.ok {
background: #00ff66;
animation: pulseGreen 2s infinite ease-in-out;
}
/* Offline state stays solid red */
.dot.fail {
background: #ff3333;
box-shadow: 0 0 10px #ff3333;
}
/* Dropdown (session selector) styling */
select {
background-color: var(--bg-dark);
color: var(--text-main);
border: 1px solid #b84a12;
border-radius: 6px;
padding: 4px 6px;
font-size: 14px;
}
select option {
background-color: var(--bg-dark);
color: var(--text-main);
}
/* Hover/focus for better visibility */
select:focus,
select:hover {
outline: none;
border-color: #ff7a33;
background-color: var(--bg-panel);
}
/* Settings Modal */
.modal {
display: none !important;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
}
.modal.show {
display: block !important;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
z-index: 999;
}
.modal-content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(180deg, rgba(255,102,0,0.1) 0%, rgba(10,10,10,0.95) 100%);
border: 2px solid var(--accent);
border-radius: 12px;
box-shadow: var(--accent-glow), 0 0 40px rgba(255,102,0,0.3);
min-width: 400px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
z-index: 1001;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--accent);
background: rgba(255,102,0,0.1);
}
.modal-header h3 {
margin: 0;
font-size: 1.2rem;
color: var(--accent);
}
.close-btn {
background: transparent;
border: none;
color: var(--accent);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.close-btn:hover {
background: rgba(255,102,0,0.2);
box-shadow: 0 0 8px var(--accent);
}
.modal-body {
padding: 20px;
}
.settings-section h4 {
margin: 0 0 8px 0;
color: var(--accent);
font-size: 1rem;
}
.settings-desc {
margin: 0 0 16px 0;
color: var(--text-fade);
font-size: 0.85rem;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.radio-label {
display: flex;
flex-direction: column;
padding: 12px;
border: 1px solid rgba(255,102,0,0.3);
border-radius: 6px;
background: rgba(255,102,0,0.05);
cursor: pointer;
transition: all 0.2s;
}
.radio-label:hover {
border-color: var(--accent);
background: rgba(255,102,0,0.1);
box-shadow: 0 0 8px rgba(255,102,0,0.3);
}
.radio-label input[type="radio"] {
margin-right: 8px;
accent-color: var(--accent);
}
.radio-label span {
font-weight: 500;
margin-bottom: 4px;
}
.radio-label small {
color: var(--text-fade);
font-size: 0.8rem;
margin-left: 24px;
}
.radio-label input[type="text"] {
margin-top: 8px;
margin-left: 24px;
padding: 6px;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,102,0,0.5);
border-radius: 4px;
color: var(--text-main);
font-family: var(--font-console);
}
.radio-label input[type="text"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 8px rgba(255,102,0,0.3);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid var(--accent);
background: rgba(255,102,0,0.05);
}
.primary-btn {
background: var(--accent);
color: #000;
font-weight: bold;
}
.primary-btn:hover {
background: #ff7a33;
box-shadow: var(--accent-glow);
}
/* Session List */
.session-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.session-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid rgba(255,102,0,0.3);
border-radius: 6px;
background: rgba(255,102,0,0.05);
transition: all 0.2s;
}
.session-item:hover {
border-color: var(--accent);
background: rgba(255,102,0,0.1);
}
.session-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.session-info strong {
color: var(--text-main);
font-size: 0.95rem;
}
.session-info small {
color: var(--text-fade);
font-size: 0.75rem;
}
.session-delete-btn {
background: transparent;
border: 1px solid rgba(255,102,0,0.5);
color: var(--accent);
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
}
.session-delete-btn:hover {
background: rgba(255,0,0,0.2);
border-color: #ff3333;
color: #ff3333;
box-shadow: 0 0 8px rgba(255,0,0,0.3);
}
/* Thinking Stream Panel */
.thinking-panel {
border-top: 1px solid var(--accent);
background: rgba(255, 102, 0, 0.02);
display: flex;
flex-direction: column;
transition: max-height 0.3s ease;
max-height: 300px;
}
.thinking-panel.collapsed {
max-height: 40px;
}
.thinking-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: rgba(255, 102, 0, 0.08);
cursor: pointer;
user-select: none;
border-bottom: 1px solid rgba(255, 102, 0, 0.2);
font-size: 0.9rem;
font-weight: 500;
}
.thinking-header:hover {
background: rgba(255, 102, 0, 0.12);
}
.thinking-controls {
display: flex;
align-items: center;
gap: 8px;
}
.thinking-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
display: inline-block;
}
.thinking-status-dot.connected {
background: #00ff66;
box-shadow: 0 0 8px #00ff66;
}
.thinking-status-dot.disconnected {
background: #ff3333;
}
.thinking-clear-btn,
.thinking-toggle-btn {
background: transparent;
border: 1px solid rgba(255, 102, 0, 0.5);
color: var(--text-main);
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.thinking-clear-btn:hover,
.thinking-toggle-btn:hover {
background: rgba(255, 102, 0, 0.2);
box-shadow: 0 0 6px rgba(255, 102, 0, 0.3);
}
.thinking-toggle-btn {
transition: transform 0.3s ease;
}
.thinking-panel.collapsed .thinking-toggle-btn {
transform: rotate(-90deg);
}
.thinking-content {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.thinking-panel.collapsed .thinking-content {
display: none;
}
.thinking-empty {
text-align: center;
padding: 40px 20px;
color: var(--text-fade);
font-size: 0.85rem;
}
.thinking-empty-icon {
font-size: 2rem;
margin-bottom: 10px;
}
.thinking-event {
padding: 8px 12px;
border-radius: 6px;
font-size: 0.85rem;
font-family: 'Courier New', monospace;
animation: thinkingSlideIn 0.3s ease-out;
border-left: 3px solid;
word-wrap: break-word;
}
@keyframes thinkingSlideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.thinking-event-connected {
background: rgba(0, 255, 102, 0.1);
border-color: #00ff66;
color: #00ff66;
}
.thinking-event-thinking {
background: rgba(138, 43, 226, 0.1);
border-color: #8a2be2;
color: #c79cff;
}
.thinking-event-tool_call {
background: rgba(255, 165, 0, 0.1);
border-color: #ffa500;
color: #ffb84d;
}
.thinking-event-tool_result {
background: rgba(0, 191, 255, 0.1);
border-color: #00bfff;
color: #7dd3fc;
}
.thinking-event-done {
background: rgba(168, 85, 247, 0.1);
border-color: #a855f7;
color: #e9d5ff;
font-weight: bold;
}
.thinking-event-error {
background: rgba(255, 51, 51, 0.1);
border-color: #ff3333;
color: #fca5a5;
}
.thinking-event-icon {
display: inline-block;
margin-right: 8px;
}
.thinking-event-details {
font-size: 0.75rem;
color: var(--text-fade);
margin-top: 4px;
padding-left: 20px;
white-space: pre-wrap;
max-height: 100px;
overflow-y: auto;
}
/* ========== MOBILE RESPONSIVE STYLES ========== */
/* Hamburger Menu */
.hamburger-menu {
display: none;
flex-direction: column;
gap: 4px;
cursor: pointer;
padding: 8px;
border: 1px solid var(--accent);
border-radius: 4px;
background: transparent;
z-index: 100;
}
.hamburger-menu span {
width: 20px;
height: 2px;
background: var(--accent);
transition: all 0.3s;
display: block;
}
.hamburger-menu.active span:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.hamburger-menu.active span:nth-child(2) {
opacity: 0;
}
.hamburger-menu.active span:nth-child(3) {
transform: rotate(-45deg) translate(5px, -5px);
}
/* Mobile Menu Container */
.mobile-menu {
display: none;
position: fixed;
top: 0;
left: -100%;
width: 280px;
height: 100vh;
background: var(--bg-dark);
border-right: 2px solid var(--accent);
box-shadow: var(--accent-glow);
z-index: 999;
transition: left 0.3s ease;
overflow-y: auto;
padding: 20px;
flex-direction: column;
gap: 16px;
}
.mobile-menu.open {
left: 0;
}
.mobile-menu-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 998;
}
.mobile-menu-overlay.show {
display: block;
}
.mobile-menu-section {
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 102, 0, 0.3);
}
.mobile-menu-section:last-child {
border-bottom: none;
}
.mobile-menu-section h4 {
margin: 0;
color: var(--accent);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.mobile-menu button,
.mobile-menu select {
width: 100%;
padding: 10px;
font-size: 0.95rem;
text-align: left;
}
/* Mobile Breakpoints */
@media screen and (max-width: 768px) {
body {
padding: 0;
}
#chat {
width: 100%;
max-width: 100%;
height: 100vh;
border-radius: 0;
border-left: none;
border-right: none;
}
/* Show hamburger, hide desktop header controls */
.hamburger-menu {
display: flex;
}
#model-select {
padding: 12px;
justify-content: space-between;
}
/* Hide all controls except hamburger on mobile */
#model-select > *:not(.hamburger-menu) {
display: none;
}
#session-select {
display: none;
}
/* Show mobile menu */
.mobile-menu {
display: flex;
}
/* Messages - more width on mobile */
.msg {
max-width: 90%;
font-size: 0.95rem;
}
/* Status bar */
#status {
padding: 10px 12px;
font-size: 0.85rem;
}
/* Input area - bigger touch targets */
#input {
padding: 12px;
}
#userInput {
font-size: 16px; /* Prevents zoom on iOS */
padding: 12px;
}
#sendBtn {
padding: 12px 16px;
font-size: 1rem;
}
/* Modal - full width on mobile */
.modal-content {
width: 95%;
min-width: unset;
max-width: unset;
max-height: 90vh;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.modal-header {
padding: 12px 16px;
}
.modal-body {
padding: 16px;
}
.modal-footer {
padding: 12px 16px;
flex-wrap: wrap;
}
.modal-footer button {
flex: 1;
min-width: 120px;
}
/* Radio labels - stack better on mobile */
.radio-label {
padding: 10px;
}
.radio-label small {
margin-left: 20px;
font-size: 0.75rem;
}
/* Session list */
.session-item {
padding: 10px;
}
.session-info strong {
font-size: 0.9rem;
}
.session-info small {
font-size: 0.7rem;
}
/* Settings button in header */
#settingsBtn {
padding: 8px 12px;
}
/* Thinking panel adjustments for mobile */
.thinking-panel {
max-height: 250px;
}
.thinking-panel.collapsed {
max-height: 38px;
}
.thinking-header {
padding: 8px 10px;
font-size: 0.85rem;
}
.thinking-event {
font-size: 0.8rem;
padding: 6px 10px;
}
.thinking-event-details {
font-size: 0.7rem;
max-height: 80px;
}
}
/* Extra small devices (phones in portrait) */
@media screen and (max-width: 480px) {
.mobile-menu {
width: 240px;
}
.msg {
max-width: 95%;
font-size: 0.9rem;
padding: 8px 12px;
}
#userInput {
font-size: 16px;
padding: 10px;
}
#sendBtn {
padding: 10px 14px;
font-size: 0.95rem;
}
.modal-header h3 {
font-size: 1.1rem;
}
.settings-section h4 {
font-size: 0.95rem;
}
.radio-label span {
font-size: 0.9rem;
}
}
/* Tablet landscape and desktop */
@media screen and (min-width: 769px) {
/* Ensure mobile menu is hidden on desktop */
.mobile-menu,
.mobile-menu-overlay {
display: none !important;
}
.hamburger-menu {
display: none !important;
}
}
-362
View File
@@ -1,362 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🧠 Thinking Stream</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #0d0d0d;
color: #e0e0e0;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
background: #1a1a1a;
padding: 15px 20px;
border-bottom: 2px solid #333;
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
font-size: 18px;
font-weight: bold;
}
.status {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #666;
}
.status-dot.connected {
background: #90ee90;
box-shadow: 0 0 10px #90ee90;
}
.status-dot.disconnected {
background: #ff6b6b;
}
.events-container {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.event {
margin-bottom: 12px;
padding: 10px 15px;
border-radius: 6px;
font-size: 14px;
font-family: 'Courier New', monospace;
animation: slideIn 0.3s ease-out;
border-left: 3px solid;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.event-connected {
background: #1a2a1a;
border-color: #4a7c59;
color: #90ee90;
}
.event-thinking {
background: #1a3a1a;
border-color: #5a9c69;
color: #a0f0a0;
}
.event-tool_call {
background: #3a2a1a;
border-color: #d97706;
color: #fbbf24;
}
.event-tool_result {
background: #1a2a3a;
border-color: #0ea5e9;
color: #7dd3fc;
}
.event-done {
background: #2a1a3a;
border-color: #a855f7;
color: #e9d5ff;
font-weight: bold;
}
.event-error {
background: #3a1a1a;
border-color: #dc2626;
color: #fca5a5;
}
.event-icon {
display: inline-block;
margin-right: 8px;
}
.event-details {
font-size: 12px;
color: #999;
margin-top: 5px;
padding-left: 25px;
}
.footer {
background: #1a1a1a;
padding: 10px 20px;
border-top: 1px solid #333;
text-align: center;
font-size: 12px;
color: #666;
}
.clear-btn {
background: #333;
border: 1px solid #444;
color: #e0e0e0;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.clear-btn:hover {
background: #444;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="header">
<h1>🧠 Thinking Stream</h1>
<div class="status">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Connecting...</span>
</div>
</div>
<div class="events-container" id="events">
<div class="empty-state">
<div class="empty-state-icon">🤔</div>
<p>Waiting for thinking events...</p>
<p style="font-size: 12px; margin-top: 10px;">Events will appear here when Lyra uses tools</p>
</div>
</div>
<div class="footer">
<button class="clear-btn" onclick="clearEvents()">Clear Events</button>
<span style="margin: 0 20px;">|</span>
<span id="sessionInfo">Session: <span id="sessionId">-</span></span>
</div>
<script>
console.log('🧠 Thinking stream page loaded!');
// Get session ID from URL
const urlParams = new URLSearchParams(window.location.search);
const SESSION_ID = urlParams.get('session');
const CORTEX_BASE = "http://10.0.0.41:7081"; // Direct to cortex
console.log('Session ID:', SESSION_ID);
console.log('Cortex base:', CORTEX_BASE);
// Declare variables first
let eventSource = null;
let eventCount = 0;
if (!SESSION_ID) {
document.getElementById('events').innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<p>No session ID provided</p>
<p style="font-size: 12px; margin-top: 10px;">Please open this from the main chat interface</p>
</div>
`;
} else {
document.getElementById('sessionId').textContent = SESSION_ID;
connectStream();
}
function connectStream() {
if (eventSource) {
eventSource.close();
}
const url = `${CORTEX_BASE}/stream/thinking/${SESSION_ID}`;
console.log('Connecting to:', url);
eventSource = new EventSource(url);
eventSource.onopen = () => {
console.log('EventSource onopen fired');
updateStatus(true, 'Connected');
};
eventSource.onmessage = (event) => {
console.log('Received message:', event.data);
try {
const data = JSON.parse(event.data);
// Update status to connected when first message arrives
if (data.type === 'connected') {
updateStatus(true, 'Connected');
}
addEvent(data);
} catch (e) {
console.error('Failed to parse event:', e, event.data);
}
};
eventSource.onerror = (error) => {
console.error('Stream error:', error, 'readyState:', eventSource.readyState);
updateStatus(false, 'Disconnected');
// Try to reconnect after 2 seconds
setTimeout(() => {
if (eventSource.readyState === EventSource.CLOSED) {
console.log('Attempting to reconnect...');
connectStream();
}
}, 2000);
};
}
function updateStatus(connected, text) {
const dot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
dot.className = 'status-dot ' + (connected ? 'connected' : 'disconnected');
statusText.textContent = text;
}
function addEvent(event) {
const container = document.getElementById('events');
// Remove empty state if present
if (eventCount === 0) {
container.innerHTML = '';
}
const eventDiv = document.createElement('div');
eventDiv.className = `event event-${event.type}`;
let icon = '';
let message = '';
let details = '';
switch (event.type) {
case 'connected':
icon = '✓';
message = 'Stream connected';
details = `Session: ${event.session_id}`;
break;
case 'thinking':
icon = '🤔';
message = event.data.message;
break;
case 'tool_call':
icon = '🔧';
message = event.data.message;
details = JSON.stringify(event.data.args, null, 2);
break;
case 'tool_result':
icon = '📊';
message = event.data.message;
if (event.data.result && event.data.result.stdout) {
details = `stdout: ${event.data.result.stdout}`;
}
break;
case 'done':
icon = '✅';
message = event.data.message;
details = event.data.final_answer;
break;
case 'error':
icon = '❌';
message = event.data.message;
break;
default:
icon = '•';
message = JSON.stringify(event.data);
}
eventDiv.innerHTML = `
<span class="event-icon">${icon}</span>
<span>${message}</span>
${details ? `<div class="event-details">${details}</div>` : ''}
`;
container.appendChild(eventDiv);
container.scrollTop = container.scrollHeight;
eventCount++;
}
function clearEvents() {
const container = document.getElementById('events');
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🤔</div>
<p>Waiting for thinking events...</p>
<p style="font-size: 12px; margin-top: 10px;">Events will appear here when Lyra uses tools</p>
</div>
`;
eventCount = 0;
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (eventSource) {
eventSource.close();
}
});
</script>
</body>
</html>
-21
View File
@@ -1,21 +0,0 @@
# ====================================
# 🧠 CORTEX OPERATIONAL CONFIG
# ====================================
# Cortex-specific parameters (all other config inherited from root .env)
CORTEX_MODE=autonomous
CORTEX_LOOP_INTERVAL=300
CORTEX_REFLECTION_INTERVAL=86400
CORTEX_LOG_LEVEL=debug
NEOMEM_HEALTH_CHECK_INTERVAL=300
# Reflection output configuration
REFLECTION_NOTE_TARGET=trilium
REFLECTION_NOTE_PATH=/app/logs/reflections.log
# Memory retrieval tuning
RELEVANCE_THRESHOLD=0.78
# NOTE: LLM backend URLs, OPENAI_API_KEY, database credentials,
# and service URLs are all inherited from root .env
# Cortex uses LLM_PRIMARY (vLLM on MI50) by default
-15
View File
@@ -1,15 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
# Install docker CLI for code executor
RUN apt-get update && apt-get install -y \
docker.io \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 7081
# NOTE: Running with single worker to maintain SESSIONS global state in Intake.
# If scaling to multiple workers, migrate SESSIONS to Redis or shared storage.
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7081"]
-553
View File
@@ -1,553 +0,0 @@
# context.py
"""
Context layer for Cortex reasoning pipeline.
Provides unified context collection from:
- Intake (short-term memory, multilevel summaries L1-L30)
- NeoMem (long-term memory, semantic search)
- Session state (timestamps, messages, mode, mood, active_project)
Maintains per-session state for continuity across conversations.
"""
import os
import logging
from datetime import datetime
from typing import Dict, Any, Optional, List
import httpx
from intake.intake import summarize_context
from neomem_client import NeoMemClient
# -----------------------------
# Configuration
# -----------------------------
NEOMEM_API = os.getenv("NEOMEM_API", "http://neomem-api:8000")
NEOMEM_ENABLED = os.getenv("NEOMEM_ENABLED", "false").lower() == "true"
RELEVANCE_THRESHOLD = float(os.getenv("RELEVANCE_THRESHOLD", "0.4"))
LOG_DETAIL_LEVEL = os.getenv("LOG_DETAIL_LEVEL", "summary").lower()
# Loop detection settings
MAX_MESSAGE_HISTORY = int(os.getenv("MAX_MESSAGE_HISTORY", "100")) # Prevent unbounded growth
SESSION_TTL_HOURS = int(os.getenv("SESSION_TTL_HOURS", "24")) # Auto-expire old sessions
ENABLE_DUPLICATE_DETECTION = os.getenv("ENABLE_DUPLICATE_DETECTION", "true").lower() == "true"
# Tools available for future autonomy features
TOOLS_AVAILABLE = ["RAG", "WEB", "WEATHER", "CODEBRAIN", "POKERBRAIN"]
# -----------------------------
# Module-level session state
# -----------------------------
SESSION_STATE: Dict[str, Dict[str, Any]] = {}
# Logger
logger = logging.getLogger(__name__)
# Always set up basic logging
logger.setLevel(logging.INFO)
console_handler = logging.StreamHandler()
console_handler.setFormatter(logging.Formatter(
'%(asctime)s [CONTEXT] %(levelname)s: %(message)s',
datefmt='%H:%M:%S'
))
logger.addHandler(console_handler)
# -----------------------------
# Session initialization & cleanup
# -----------------------------
def _init_session(session_id: str) -> Dict[str, Any]:
"""
Initialize a new session state entry.
Returns:
Dictionary with default session state fields
"""
return {
"session_id": session_id,
"created_at": datetime.now(),
"last_timestamp": datetime.now(),
"last_user_message": None,
"last_assistant_message": None,
"mode": "default", # Future: "autonomous", "focused", "creative", etc.
"mood": "neutral", # Future: mood tracking
"active_project": None, # Future: project context
"message_count": 0,
"message_history": [],
"last_message_hash": None, # For duplicate detection
}
def _cleanup_expired_sessions():
"""Remove sessions that haven't been active for SESSION_TTL_HOURS"""
from datetime import timedelta
now = datetime.now()
expired_sessions = []
for session_id, state in SESSION_STATE.items():
last_active = state.get("last_timestamp", state.get("created_at"))
time_since_active = (now - last_active).total_seconds() / 3600 # hours
if time_since_active > SESSION_TTL_HOURS:
expired_sessions.append(session_id)
for session_id in expired_sessions:
del SESSION_STATE[session_id]
logger.info(f"🗑️ Expired session: {session_id} (inactive for {SESSION_TTL_HOURS}+ hours)")
return len(expired_sessions)
def _is_duplicate_message(session_id: str, user_prompt: str) -> bool:
"""
Check if this message is a duplicate of the last processed message.
Uses simple hash comparison to detect exact duplicates or processing loops.
"""
if not ENABLE_DUPLICATE_DETECTION:
return False
import hashlib
state = SESSION_STATE.get(session_id)
if not state:
return False
# Create hash of normalized message
message_hash = hashlib.md5(user_prompt.strip().lower().encode()).hexdigest()
# Check if it matches the last message
if state.get("last_message_hash") == message_hash:
logger.warning(
f"⚠️ DUPLICATE MESSAGE DETECTED | Session: {session_id} | "
f"Message: {user_prompt[:80]}..."
)
return True
# Update hash for next check
state["last_message_hash"] = message_hash
return False
def _trim_message_history(state: Dict[str, Any]):
"""
Trim message history to prevent unbounded growth.
Keeps only the most recent MAX_MESSAGE_HISTORY messages.
"""
history = state.get("message_history", [])
if len(history) > MAX_MESSAGE_HISTORY:
trimmed_count = len(history) - MAX_MESSAGE_HISTORY
state["message_history"] = history[-MAX_MESSAGE_HISTORY:]
logger.info(f"✂️ Trimmed {trimmed_count} old messages from session {state['session_id']}")
# -----------------------------
# Intake context retrieval
# -----------------------------
async def _get_intake_context(session_id: str, messages: List[Dict[str, str]]):
"""
Internal Intake — Direct call to summarize_context()
No HTTP, no containers, no failures.
"""
try:
return await summarize_context(session_id, messages)
except Exception as e:
logger.error(f"Internal Intake summarization failed: {e}")
return {
"session_id": session_id,
"L1": "",
"L5": "",
"L10": "",
"L20": "",
"L30": "",
"error": str(e)
}
# -----------------------------
# NeoMem semantic search
# -----------------------------
async def _search_neomem(
query: str,
user_id: str = "brian",
limit: int = 5
) -> List[Dict[str, Any]]:
"""
Search NeoMem for relevant long-term memories.
Returns full response structure from NeoMem:
[
{
"id": "mem_abc123",
"score": 0.92,
"payload": {
"data": "Memory text content...",
"metadata": {
"category": "...",
"created_at": "...",
...
}
}
},
...
]
Args:
query: Search query text
user_id: User identifier for memory filtering
limit: Maximum number of results
Returns:
List of memory objects with full structure, or empty list on failure
"""
if not NEOMEM_ENABLED:
logger.info("NeoMem search skipped (NEOMEM_ENABLED is false)")
return []
try:
# NeoMemClient reads NEOMEM_API from environment, no base_url parameter
client = NeoMemClient()
results = await client.search(
query=query,
user_id=user_id,
limit=limit,
threshold=RELEVANCE_THRESHOLD
)
# Results are already filtered by threshold in NeoMemClient.search()
logger.info(f"NeoMem search returned {len(results)} relevant results")
return results
except Exception as e:
logger.warning(f"NeoMem search failed: {e}")
return []
# -----------------------------
# Main context collection
# -----------------------------
async def collect_context(session_id: str, user_prompt: str) -> Dict[str, Any]:
"""
Collect unified context from all sources.
Orchestrates:
1. Initialize or update session state
2. Calculate time since last message
3. Retrieve Intake multilevel summaries (L1-L30)
4. Search NeoMem for relevant long-term memories
5. Update session state with current user message
6. Return unified context_state dictionary
Args:
session_id: Session identifier
user_prompt: Current user message
Returns:
Unified context state dictionary with structure:
{
"session_id": "...",
"timestamp": "2025-11-28T12:34:56",
"minutes_since_last_msg": 5.2,
"message_count": 42,
"intake": {
"L1": [...],
"L5": [...],
"L10": {...},
"L20": {...},
"L30": {...}
},
"rag": [
{
"id": "mem_123",
"score": 0.92,
"payload": {
"data": "...",
"metadata": {...}
}
},
...
],
"mode": "default",
"mood": "neutral",
"active_project": null,
"tools_available": ["RAG", "WEB", "WEATHER", "CODEBRAIN", "POKERBRAIN"]
}
"""
# A. Cleanup expired sessions periodically (every 100th call)
import random
if random.randint(1, 100) == 1:
_cleanup_expired_sessions()
# B. Initialize session state if needed
if session_id not in SESSION_STATE:
SESSION_STATE[session_id] = _init_session(session_id)
logger.info(f"Initialized new session: {session_id}")
state = SESSION_STATE[session_id]
# C. Check for duplicate messages (loop detection)
if _is_duplicate_message(session_id, user_prompt):
# Return cached context with warning flag
logger.warning(f"🔁 LOOP DETECTED - Returning cached context to prevent processing duplicate")
context_state = {
"session_id": session_id,
"timestamp": datetime.now().isoformat(),
"minutes_since_last_msg": 0,
"message_count": state["message_count"],
"intake": {},
"rag": [],
"mode": state["mode"],
"mood": state["mood"],
"active_project": state["active_project"],
"tools_available": TOOLS_AVAILABLE,
"duplicate_detected": True,
}
return context_state
# B. Calculate time delta
now = datetime.now()
time_delta_seconds = (now - state["last_timestamp"]).total_seconds()
minutes_since_last_msg = round(time_delta_seconds / 60.0, 2)
# C. Gather Intake context (multilevel summaries)
# Build compact message buffer for Intake:
messages_for_intake = []
# You track messages inside SESSION_STATE — assemble it here:
if "message_history" in state:
for turn in state["message_history"]:
messages_for_intake.append({
"user_msg": turn.get("user", ""),
"assistant_msg": turn.get("assistant", "")
})
intake_data = await _get_intake_context(session_id, messages_for_intake)
# D. Search NeoMem for relevant memories
if NEOMEM_ENABLED:
rag_results = await _search_neomem(
query=user_prompt,
user_id="brian", # TODO: Make configurable per session
limit=5
)
else:
rag_results = []
logger.info("Skipping NeoMem RAG retrieval; NEOMEM_ENABLED is false")
# E. Update session state
state["last_user_message"] = user_prompt
state["last_timestamp"] = now
state["message_count"] += 1
# Save user turn to history
state["message_history"].append({
"user": user_prompt,
"assistant": "" # assistant reply filled later by update_last_assistant_message()
})
# Trim history to prevent unbounded growth
_trim_message_history(state)
# F. Assemble unified context
context_state = {
"session_id": session_id,
"timestamp": now.isoformat(),
"minutes_since_last_msg": minutes_since_last_msg,
"message_count": state["message_count"],
"intake": intake_data,
"rag": rag_results,
"mode": state["mode"],
"mood": state["mood"],
"active_project": state["active_project"],
"tools_available": TOOLS_AVAILABLE,
}
# Log context summary in structured format
logger.info(
f"📊 Context | Session: {session_id} | "
f"Messages: {state['message_count']} | "
f"Last: {minutes_since_last_msg:.1f}min | "
f"RAG: {len(rag_results)} results"
)
# Show detailed context in detailed/verbose mode
if LOG_DETAIL_LEVEL in ["detailed", "verbose"]:
import json
logger.info(f"\n{''*100}")
logger.info(f"[CONTEXT] Session {session_id} | User: {user_prompt[:80]}...")
logger.info(f"{''*100}")
logger.info(f" Mode: {state['mode']} | Mood: {state['mood']} | Project: {state['active_project']}")
logger.info(f" Tools: {', '.join(TOOLS_AVAILABLE)}")
# Show intake summaries (condensed)
if intake_data:
logger.info(f"\n ╭─ INTAKE SUMMARIES ────────────────────────────────────────────────")
for level in ["L1", "L5", "L10", "L20", "L30"]:
if level in intake_data:
summary = intake_data[level]
if isinstance(summary, dict):
summary_text = summary.get("summary", str(summary)[:100])
else:
summary_text = str(summary)[:100]
logger.info(f"{level:4s}: {summary_text}...")
logger.info(f" ╰───────────────────────────────────────────────────────────────────")
# Show RAG results (condensed)
if rag_results:
logger.info(f"\n ╭─ RAG RESULTS ({len(rag_results)}) ──────────────────────────────────────────────")
for idx, result in enumerate(rag_results[:5], 1): # Show top 5
score = result.get("score", 0)
data_preview = str(result.get("payload", {}).get("data", ""))[:60]
logger.info(f" │ [{idx}] {score:.3f} | {data_preview}...")
if len(rag_results) > 5:
logger.info(f" │ ... and {len(rag_results) - 5} more results")
logger.info(f" ╰───────────────────────────────────────────────────────────────────")
# Show full raw data only in verbose mode
if LOG_DETAIL_LEVEL == "verbose":
logger.info(f"\n ╭─ RAW INTAKE DATA ─────────────────────────────────────────────────")
logger.info(f"{json.dumps(intake_data, indent=4, default=str)}")
logger.info(f" ╰───────────────────────────────────────────────────────────────────")
logger.info(f"{''*100}\n")
return context_state
# -----------------------------
# Session state management
# -----------------------------
def update_last_assistant_message(session_id: str, message: str) -> None:
"""
Update session state with assistant's response and complete
the last turn inside message_history.
"""
session = SESSION_STATE.get(session_id)
if not session:
logger.warning(f"Attempted to update non-existent session: {session_id}")
return
# Update last assistant message + timestamp
session["last_assistant_message"] = message
session["last_timestamp"] = datetime.now()
# Fill in assistant reply for the most recent turn
history = session.get("message_history", [])
if history:
# history entry already contains {"user": "...", "assistant": "...?"}
history[-1]["assistant"] = message
def get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
"""
Retrieve current session state.
Args:
session_id: Session identifier
Returns:
Session state dict or None if session doesn't exist
"""
return SESSION_STATE.get(session_id)
def close_session(session_id: str) -> bool:
"""
Close and cleanup a session.
Args:
session_id: Session identifier
Returns:
True if session was closed, False if it didn't exist
"""
if session_id in SESSION_STATE:
del SESSION_STATE[session_id]
logger.info(f"Closed session: {session_id}")
return True
return False
# -----------------------------
# Extension hooks for future autonomy
# -----------------------------
def update_mode(session_id: str, new_mode: str) -> None:
"""
Update session mode.
Future modes: "autonomous", "focused", "creative", "collaborative", etc.
Args:
session_id: Session identifier
new_mode: New mode string
"""
if session_id in SESSION_STATE:
old_mode = SESSION_STATE[session_id]["mode"]
SESSION_STATE[session_id]["mode"] = new_mode
logger.info(f"Session {session_id} mode changed: {old_mode} -> {new_mode}")
def update_mood(session_id: str, new_mood: str) -> None:
"""
Update session mood.
Future implementation: Sentiment analysis, emotional state tracking.
Args:
session_id: Session identifier
new_mood: New mood string
"""
if session_id in SESSION_STATE:
old_mood = SESSION_STATE[session_id]["mood"]
SESSION_STATE[session_id]["mood"] = new_mood
logger.info(f"Session {session_id} mood changed: {old_mood} -> {new_mood}")
def update_active_project(session_id: str, project: Optional[str]) -> None:
"""
Update active project context.
Future implementation: Project-specific memory, tools, preferences.
Args:
session_id: Session identifier
project: Project identifier or None
"""
if session_id in SESSION_STATE:
SESSION_STATE[session_id]["active_project"] = project
logger.info(f"Session {session_id} active project set to: {project}")
async def autonomous_heartbeat(session_id: str) -> Optional[str]:
"""
Autonomous thinking heartbeat.
Future implementation:
- Check if Lyra should initiate internal dialogue
- Generate self-prompted thoughts based on session state
- Update mood/mode based on context changes
- Trigger proactive suggestions or reminders
Args:
session_id: Session identifier
Returns:
Optional autonomous thought/action string
"""
# Stub for future implementation
# Example logic:
# - If minutes_since_last_msg > 60: Check for pending reminders
# - If mood == "curious" and active_project: Generate research questions
# - If mode == "autonomous": Self-prompt based on project goals
logger.debug(f"Autonomous heartbeat for session {session_id} (not yet implemented)")
return None
-18
View File
@@ -1,18 +0,0 @@
"""
Intake module - short-term memory summarization.
Runs inside the Cortex container as a pure Python module.
No standalone API server - called internally by Cortex.
"""
from .intake import (
SESSIONS,
add_exchange_internal,
summarize_context,
)
__all__ = [
"SESSIONS",
"add_exchange_internal",
"summarize_context",
]
-425
View File
@@ -1,425 +0,0 @@
import os
import json
from datetime import datetime
from typing import List, Dict, Any, TYPE_CHECKING
from collections import deque
from llm.llm_router import call_llm
# -------------------------------------------------------------------
# Global Short-Term Memory (new Intake)
# -------------------------------------------------------------------
SESSIONS: dict[str, dict] = {} # session_id → { buffer: deque, created_at: timestamp }
# Diagnostic: Verify module loads only once
print(f"[Intake Module Init] SESSIONS object id: {id(SESSIONS)}, module: {__name__}")
# L10 / L20 history lives here too
L10_HISTORY: Dict[str, list[str]] = {}
L20_HISTORY: Dict[str, list[str]] = {}
from llm.llm_router import call_llm # Use Cortex's shared LLM router
if TYPE_CHECKING:
# Only for type hints — do NOT redefine SESSIONS here
from collections import deque as _deque
def bg_summarize(session_id: str) -> None: ...
# ─────────────────────────────
# Config
# ─────────────────────────────
INTAKE_LLM = os.getenv("INTAKE_LLM", "PRIMARY").upper()
SUMMARY_MAX_TOKENS = int(os.getenv("SUMMARY_MAX_TOKENS", "200"))
SUMMARY_TEMPERATURE = float(os.getenv("SUMMARY_TEMPERATURE", "0.3"))
NEBULA_API = os.getenv("NEBULA_API", "http://localhost:7090")
NEBULA_KEY = os.getenv("NEBULA_KEY")
# ─────────────────────────────
# Internal history for L10/L20/L30
# ─────────────────────────────
L10_HISTORY: Dict[str, list[str]] = {} # session_id → list of L10 blocks
L20_HISTORY: Dict[str, list[str]] = {} # session_id → list of merged overviews
# ─────────────────────────────
# LLM helper (via Cortex router)
# ─────────────────────────────
async def _llm(prompt: str) -> str:
"""
Use Cortex's llm_router to run a summary prompt.
"""
try:
text = await call_llm(
prompt,
backend=INTAKE_LLM,
temperature=SUMMARY_TEMPERATURE,
max_tokens=SUMMARY_MAX_TOKENS,
)
return (text or "").strip()
except Exception as e:
return f"[Error summarizing: {e}]"
# ─────────────────────────────
# Formatting helpers
# ─────────────────────────────
def _format_exchanges(exchanges: List[Dict[str, Any]]) -> str:
"""
Expect each exchange to look like:
{ "user_msg": "...", "assistant_msg": "..." }
"""
chunks = []
for e in exchanges:
user = e.get("user_msg", "")
assistant = e.get("assistant_msg", "")
chunks.append(f"User: {user}\nAssistant: {assistant}\n")
return "\n".join(chunks)
# ─────────────────────────────
# Base factual summary
# ─────────────────────────────
async def summarize_simple(exchanges: List[Dict[str, Any]]) -> str:
"""
Simple factual summary of recent exchanges.
"""
if not exchanges:
return ""
text = _format_exchanges(exchanges)
prompt = f"""
Summarize the following conversation between Brian (user) and Lyra (assistant).
Focus only on factual content. Avoid names, examples, story tone, or invented details.
{text}
Summary:
"""
return await _llm(prompt)
# ─────────────────────────────
# Multilevel Summaries (L1, L5, L10, L20, L30)
# ─────────────────────────────
async def summarize_L1(buf: List[Dict[str, Any]]) -> str:
# Last ~5 exchanges
return await summarize_simple(buf[-5:])
async def summarize_L5(buf: List[Dict[str, Any]]) -> str:
# Last ~10 exchanges
return await summarize_simple(buf[-10:])
async def summarize_L10(session_id: str, buf: List[Dict[str, Any]]) -> str:
# "Reality Check" for last 10 exchanges
text = _format_exchanges(buf[-10:])
prompt = f"""
You are Lyra Intake performing a short 'Reality Check'.
Summarize the last block of conversation (up to 10 exchanges)
in one clear paragraph focusing on tone, intent, and direction.
{text}
Reality Check:
"""
summary = await _llm(prompt)
# Track history for this session
L10_HISTORY.setdefault(session_id, [])
L10_HISTORY[session_id].append(summary)
# Send to Nebula
await send_to_nebula(summary, session_id, "L10")
return summary
async def summarize_L20(session_id: str) -> str:
"""
Merge all L10 Reality Checks into a 'Session Overview'.
"""
history = L10_HISTORY.get(session_id, [])
joined = "\n\n".join(history) if history else ""
if not joined:
return ""
prompt = f"""
You are Lyra Intake creating a 'Session Overview'.
Merge the following Reality Check paragraphs into one short summary
capturing progress, themes, and the direction of the conversation.
{joined}
Overview:
"""
summary = await _llm(prompt)
L20_HISTORY.setdefault(session_id, [])
L20_HISTORY[session_id].append(summary)
# Send to Nebula
await send_to_nebula(summary, session_id, "L20")
return summary
async def summarize_L30(session_id: str) -> str:
"""
Merge all L20 session overviews into a 'Continuity Report'.
"""
history = L20_HISTORY.get(session_id, [])
joined = "\n\n".join(history) if history else ""
if not joined:
return ""
prompt = f"""
You are Lyra Intake generating a 'Continuity Report'.
Condense these session overviews into one high-level reflection,
noting major themes, persistent goals, and shifts.
{joined}
Continuity Report:
"""
summary = await _llm(prompt)
# Send to Nebula
await send_to_nebula(summary, session_id, "L30")
return summary
# ─────────────────────────────
# Nebula push
# ─────────────────────────────
async def send_to_nebula(summary: str, session_id: str, level: str) -> None:
"""
Send summary to Nebula vector memory system.
Falls back to disk storage if Nebula is not available.
"""
if not summary:
return
payload = {
"summary": summary,
"session_id": session_id,
"level": level,
"timestamp": datetime.now().isoformat(),
"source": "intake",
}
# Try HTTP POST to Nebula first
try:
import httpx
headers = {"Content-Type": "application/json"}
if NEBULA_KEY:
headers["Authorization"] = f"Bearer {NEBULA_KEY}"
async with httpx.AsyncClient() as client:
response = await client.post(
f"{NEBULA_API}/summaries",
json=payload,
headers=headers,
timeout=10.0,
)
response.raise_for_status()
print(f"🌌 Nebula updated ({level}) for {session_id}")
return
except Exception as e:
print(f"⚠️ Nebula unavailable, falling back to disk: {e}")
# Fallback: Write to disk
try:
fallback_dir = os.path.join(os.path.dirname(__file__), "../../.nebula_fallback")
os.makedirs(fallback_dir, exist_ok=True)
# Create session directory
session_dir = os.path.join(fallback_dir, session_id)
os.makedirs(session_dir, exist_ok=True)
# Write summary to timestamped file
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{level}_{timestamp}.json"
filepath = os.path.join(session_dir, filename)
import json
with open(filepath, "w") as f:
json.dump(payload, f, indent=2)
print(f"💾 Saved to disk: {filepath}")
except Exception as e:
print(f"❌ Failed to save summary to disk: {e}")
# ─────────────────────────────
# Main entrypoint for Cortex
# ─────────────────────────────
async def summarize_context(session_id: str, exchanges: list[dict]):
"""
Internal summarizer that uses Cortex's LLM router.
Produces cascading summaries based on exchange count:
- L1: Always (most recent activity)
- L2: After 2+ exchanges
- L5: After 5+ exchanges
- L10: After 10+ exchanges
- L20: After 20+ exchanges
- L30: After 30+ exchanges
Args:
session_id: The conversation/session ID
exchanges: A list of {"user_msg": ..., "assistant_msg": ..., "timestamp": ...}
"""
exchange_count = len(exchanges)
if exchange_count == 0:
return {
"session_id": session_id,
"exchange_count": 0,
"L1": "",
"L2": "",
"L5": "",
"L10": "",
"L20": "",
"L30": "",
"last_updated": datetime.now().isoformat()
}
result = {
"session_id": session_id,
"exchange_count": exchange_count,
"L1": "",
"L2": "",
"L5": "",
"L10": "",
"L20": "",
"L30": "",
"last_updated": datetime.now().isoformat()
}
try:
# L1: Always generate (most recent exchanges)
result["L1"] = await summarize_simple(exchanges[-5:])
print(f"[Intake] Generated L1 for {session_id} ({exchange_count} exchanges)")
# L2: After 2+ exchanges
if exchange_count >= 2:
result["L2"] = await summarize_simple(exchanges[-2:])
print(f"[Intake] Generated L2 for {session_id}")
# L5: After 5+ exchanges
if exchange_count >= 5:
result["L5"] = await summarize_simple(exchanges[-10:])
print(f"[Intake] Generated L5 for {session_id}")
# L10: After 10+ exchanges (Reality Check)
if exchange_count >= 10:
result["L10"] = await summarize_L10(session_id, exchanges)
print(f"[Intake] Generated L10 for {session_id}")
# L20: After 20+ exchanges (Session Overview - merges L10s)
if exchange_count >= 20 and exchange_count % 10 == 0:
result["L20"] = await summarize_L20(session_id)
print(f"[Intake] Generated L20 for {session_id}")
# L30: After 30+ exchanges (Continuity Report - merges L20s)
if exchange_count >= 30 and exchange_count % 10 == 0:
result["L30"] = await summarize_L30(session_id)
print(f"[Intake] Generated L30 for {session_id}")
return result
except Exception as e:
print(f"[Intake] Error during summarization: {e}")
result["L1"] = f"[Error summarizing: {str(e)}]"
return result
# ─────────────────────────────────
# Background summarization stub
# ─────────────────────────────────
def bg_summarize(session_id: str):
"""
Placeholder for background summarization.
Actual summarization happens during /reason via summarize_context().
This function exists to prevent NameError when called from add_exchange_internal().
"""
print(f"[Intake] Exchange added for {session_id}. Will summarize on next /reason call.")
# ─────────────────────────────
# Internal entrypoint for Cortex
# ─────────────────────────────
def get_recent_messages(session_id: str, limit: int = 20) -> list:
"""
Get recent raw messages from the session buffer.
Args:
session_id: Session identifier
limit: Maximum number of messages to return (default 20)
Returns:
List of message dicts with 'role' and 'content' fields
"""
if session_id not in SESSIONS:
return []
buffer = SESSIONS[session_id]["buffer"]
# Convert buffer to list and get last N messages
messages = list(buffer)[-limit:]
return messages
def add_exchange_internal(exchange: dict):
"""
Direct internal call — bypasses FastAPI request handling.
Cortex uses this to feed user/assistant turns directly
into Intake's buffer and trigger full summarization.
"""
session_id = exchange.get("session_id")
if not session_id:
raise ValueError("session_id missing")
exchange["timestamp"] = datetime.now().isoformat()
# DEBUG: Verify we're using the module-level SESSIONS
print(f"[add_exchange_internal] SESSIONS object id: {id(SESSIONS)}, current sessions: {list(SESSIONS.keys())}")
# Ensure session exists
if session_id not in SESSIONS:
SESSIONS[session_id] = {
"buffer": deque(maxlen=200),
"created_at": datetime.now()
}
print(f"[add_exchange_internal] Created new session: {session_id}")
else:
print(f"[add_exchange_internal] Using existing session: {session_id}")
# Append exchange into the rolling buffer
SESSIONS[session_id]["buffer"].append(exchange)
buffer_len = len(SESSIONS[session_id]["buffer"])
print(f"[add_exchange_internal] Added exchange to {session_id}, buffer now has {buffer_len} items")
# Trigger summarization immediately
try:
bg_summarize(session_id)
except Exception as e:
print(f"[Internal Intake] Summarization error: {e}")
return {"ok": True, "session_id": session_id}
-1
View File
@@ -1 +0,0 @@
# LLM module - provides LLM routing and backend abstraction
-165
View File
@@ -1,165 +0,0 @@
# llm_router.py
import os
import httpx
import json
import logging
from typing import Optional, List, Dict
logger = logging.getLogger(__name__)
# ------------------------------------------------------------
# Backend Configuration
# ------------------------------------------------------------
BACKENDS = {
"PRIMARY": {
"provider": os.getenv("LLM_PRIMARY_PROVIDER", "").lower(),
"url": os.getenv("LLM_PRIMARY_URL", ""),
"model": os.getenv("LLM_PRIMARY_MODEL", "")
},
"SECONDARY": {
"provider": os.getenv("LLM_SECONDARY_PROVIDER", "").lower(),
"url": os.getenv("LLM_SECONDARY_URL", ""),
"model": os.getenv("LLM_SECONDARY_MODEL", "")
},
"OPENAI": {
"provider": os.getenv("LLM_OPENAI_PROVIDER", "").lower(),
"url": os.getenv("LLM_OPENAI_URL", ""),
"model": os.getenv("LLM_OPENAI_MODEL", ""),
"api_key": os.getenv("OPENAI_API_KEY", "")
},
"FALLBACK": {
"provider": os.getenv("LLM_FALLBACK_PROVIDER", "").lower(),
"url": os.getenv("LLM_FALLBACK_URL", ""),
"model": os.getenv("LLM_FALLBACK_MODEL", "")
},
}
DEFAULT_BACKEND = "PRIMARY"
http_client = httpx.AsyncClient(timeout=120.0)
# ------------------------------------------------------------
# Public LLM Call
# ------------------------------------------------------------
async def call_llm(
prompt: Optional[str] = None,
messages: Optional[List[Dict]] = None,
backend: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 512,
):
"""
Simple LLM call.
Supports: ollama, mi50 (llama.cpp), openai.
Returns plain text response.
"""
backend = (backend or DEFAULT_BACKEND).upper()
if backend not in BACKENDS:
raise RuntimeError(f"Unknown backend '{backend}'")
cfg = BACKENDS[backend]
provider = cfg["provider"]
url = cfg["url"]
model = cfg["model"]
if not url or not model:
raise RuntimeError(f"Backend '{backend}' missing url/model in env")
# Convert prompt → messages if needed
if not messages:
messages = [{"role": "user", "content": prompt or ""}]
# ------------------------------------------------------------
# OLLAMA
# ------------------------------------------------------------
if provider == "ollama":
payload = {
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": temperature,
"num_predict": max_tokens
}
}
try:
r = await http_client.post(f"{url}/api/chat", json=payload)
r.raise_for_status()
data = r.json()
return data["message"]["content"]
except Exception as e:
logger.error(f"Ollama error: {e}")
raise RuntimeError(f"Ollama API error: {e}")
# ------------------------------------------------------------
# MI50 (llama.cpp server)
# ------------------------------------------------------------
if provider == "mi50":
# Convert messages to plain prompt
prompt_parts = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
prompt_parts.append(f"{role.capitalize()}: {content}")
full_prompt = "\n".join(prompt_parts) + "\nAssistant:"
payload = {
"prompt": full_prompt,
"n_predict": max_tokens,
"temperature": temperature,
"stop": ["User:", "\nUser:", "Assistant:", "\n\n\n"]
}
try:
r = await http_client.post(f"{url}/completion", json=payload)
r.raise_for_status()
data = r.json()
return data.get("content", "")
except Exception as e:
logger.error(f"MI50 error: {e}")
raise RuntimeError(f"MI50 API error: {e}")
# ------------------------------------------------------------
# OPENAI
# ------------------------------------------------------------
if provider == "openai":
headers = {
"Authorization": f"Bearer {cfg.get('api_key')}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
try:
r = await http_client.post(
f"{url}/chat/completions",
json=payload,
headers=headers
)
r.raise_for_status()
data = r.json()
return data["choices"][0]["message"]["content"]
except Exception as e:
logger.error(f"OpenAI error: {e}")
raise RuntimeError(f"OpenAI API error: {e}")
# ------------------------------------------------------------
# Unknown Provider
# ------------------------------------------------------------
raise RuntimeError(f"Provider '{provider}' not implemented.")
-21
View File
@@ -1,21 +0,0 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from router import cortex_router
app = FastAPI()
# Add CORS middleware to allow SSE connections from nginx UI
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify exact origins
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Health check endpoint
@app.get("/_health")
async def health_check():
return {"status": "ok"}
app.include_router(cortex_router)
-32
View File
@@ -1,32 +0,0 @@
import os, requests
from typing import Dict, Any, List
RAG_API_URL = os.getenv("RAG_API_URL", "http://localhost:7090")
def query_rag(query: str, where: Dict[str, Any] | None = None, k: int = 6) -> Dict[str, Any]:
payload = {"query": query, "k": k}
if where:
payload["where"] = where
try:
r = requests.post(f"{RAG_API_URL}/rag/search", json=payload, timeout=8)
r.raise_for_status()
data = r.json() or {}
except Exception as e:
data = {"answer": "", "chunks": [], "error": str(e)}
return data
def format_rag_block(result: Dict[str, Any]) -> str:
answer = (result.get("answer") or "").strip()
chunks: List[Dict[str, Any]] = result.get("chunks") or []
lines = ["[RAG]"]
if answer:
lines.append(f"Synthesized answer: {answer}")
if chunks:
lines.append("Top excerpts:")
for i, c in enumerate(chunks[:5], 1):
src = c.get("metadata", {}).get("source", "unknown")
txt = (c.get("text") or "").strip().replace("\n", " ")
if len(txt) > 220:
txt = txt[:220] + ""
lines.append(f" {i}. {txt}{src}")
return "\n".join(lines) + ("\n" if lines else "")
-10
View File
@@ -1,10 +0,0 @@
fastapi==0.115.8
uvicorn==0.34.0
python-dotenv==1.0.1
requests==2.32.3
httpx==0.27.2
pydantic==2.10.4
duckduckgo-search==6.3.5
aiohttp==3.9.1
tenacity==9.0.0
docker==7.1.0
-168
View File
@@ -1,168 +0,0 @@
# router.py
import os
import logging
import asyncio
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from intake.intake import add_exchange_internal
# Setup
# -------------------------------------------------------------------
LOG_DETAIL_LEVEL = os.getenv("LOG_DETAIL_LEVEL", "summary").lower()
logger = logging.getLogger(__name__)
# Always set up basic logging
logger.setLevel(logging.INFO)
console_handler = logging.StreamHandler()
console_handler.setFormatter(logging.Formatter(
'%(asctime)s [ROUTER] %(levelname)s: %(message)s',
datefmt='%H:%M:%S'
))
logger.addHandler(console_handler)
cortex_router = APIRouter()
# -------------------------------------------------------------------
# Models
# -------------------------------------------------------------------
class ReasonRequest(BaseModel):
session_id: str
user_prompt: str
temperature: float | None = None
backend: str | None = None
# -------------------------------------------------------------------
# /simple endpoint - Standard chatbot mode (no reasoning pipeline)
# -------------------------------------------------------------------
@cortex_router.post("/simple")
async def run_simple(req: ReasonRequest):
"""
Standard chatbot mode - bypasses all cortex reasoning pipeline.
Just a simple conversation loop like a typical chatbot.
"""
from datetime import datetime
from llm.llm_router import call_llm
start_time = datetime.now()
logger.info(f"\n{'='*100}")
logger.info(f"💬 SIMPLE MODE | Session: {req.session_id} | {datetime.now().strftime('%H:%M:%S.%f')[:-3]}")
logger.info(f"{'='*100}")
logger.info(f"📝 User: {req.user_prompt[:150]}...")
logger.info(f"{'-'*100}\n")
# Get recent messages from Intake buffer
from intake.intake import get_recent_messages
recent_msgs = get_recent_messages(req.session_id, limit=20)
logger.info(f"📋 Retrieved {len(recent_msgs)} recent messages from Intake buffer")
# Build simple conversation history with system message
system_message = {
"role": "system",
"content": (
"You are a helpful AI assistant. Provide direct, concise responses to the user's questions. "
"Maintain context from previous messages in the conversation."
)
}
messages = [system_message]
# Add conversation history
if recent_msgs:
for msg in recent_msgs:
messages.append({
"role": msg.get("role", "user"),
"content": msg.get("content", "")
})
logger.info(f" - {msg.get('role')}: {msg.get('content', '')[:50]}...")
# Add current user message
messages.append({
"role": "user",
"content": req.user_prompt
})
logger.info(f"📨 Total messages being sent to LLM: {len(messages)} (including system message)")
# Get backend from request, otherwise fall back to env variable
backend = req.backend if req.backend else os.getenv("STANDARD_MODE_LLM", "SECONDARY")
backend = backend.upper() # Normalize to uppercase
logger.info(f"🔧 Using backend: {backend}")
temperature = req.temperature if req.temperature is not None else 0.7
# Call LLM with or without tools
try:
# Direct LLM call without tools (original behavior)
raw_response = await call_llm(
messages=messages,
backend=backend,
temperature=temperature,
max_tokens=2048
)
response = raw_response.strip()
except Exception as e:
logger.error(f"❌ LLM call failed: {e}")
response = f"Error: {str(e)}"
# Update session with the exchange
try:
add_exchange_internal({
"session_id": req.session_id,
"role": "user",
"content": req.user_prompt
})
add_exchange_internal({
"session_id": req.session_id,
"role": "assistant",
"content": response
})
except Exception as e:
logger.warning(f"⚠️ Session update failed: {e}")
duration = (datetime.now() - start_time).total_seconds() * 1000
logger.info(f"\n{'='*100}")
logger.info(f"✨ SIMPLE MODE COMPLETE | Session: {req.session_id} | Total: {duration:.0f}ms")
logger.info(f"📤 Output: {len(response)} chars")
logger.info(f"{'='*100}\n")
return {
"draft": response,
"neutral": response,
"persona": response,
"reflection": "",
"session_id": req.session_id,
"context_summary": {
"message_count": len(messages),
"mode": "standard"
}
}
# -------------------------------------------------------------------
# /ingest endpoint (internal)
# -------------------------------------------------------------------
class IngestPayload(BaseModel):
session_id: str
user_msg: str
assistant_msg: str
@cortex_router.post("/ingest")
async def ingest(payload: IngestPayload):
try:
add_exchange_internal({
"session_id": payload.session_id,
"user_msg": payload.user_msg,
"assistant_msg": payload.assistant_msg,
})
except Exception as e:
logger.warning(f"[INGEST] Intake update failed: {e}")
return {"status": "ok", "session_id": payload.session_id}
-1
View File
@@ -1 +0,0 @@
# Utilities module
-33
View File
@@ -1,33 +0,0 @@
import os, json, datetime
# optional daily rotation
LOG_PATH = os.getenv("REFLECTION_NOTE_PATH") or \
f"/app/logs/reflections_{datetime.date.today():%Y%m%d}.log"
def log_reflection(reflection: dict, user_prompt: str, draft: str, final: str, session_id: str | None = None):
"""Append a reflection entry to the reflections log."""
try:
# 1️⃣ Make sure log directory exists
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
# 2️⃣ Ensure session_id is stored
reflection["session_id"] = session_id or reflection.get("session_id", "unknown")
# 3️⃣ Build JSON entry
entry = {
"timestamp": datetime.datetime.now().isoformat(),
"session_id": reflection["session_id"],
"prompt": user_prompt,
"draft_output": draft[:500],
"final_output": final[:500],
"reflection": reflection,
}
# 4️⃣ Write it in pretty JSON, comma-delimited for easy reading
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, indent=2, ensure_ascii=False) + ",\n")
print(f"[Cortex] Logged reflection → {LOG_PATH}")
except Exception as e:
print(f"[Cortex] Failed to log reflection: {e}")
-223
View File
@@ -1,223 +0,0 @@
"""
Structured logging utilities for Cortex pipeline debugging.
Provides hierarchical, scannable logs with clear section markers and raw data visibility.
"""
import json
import logging
from typing import Any, Dict, List, Optional
from datetime import datetime
from enum import Enum
class LogLevel(Enum):
"""Log detail levels"""
MINIMAL = 1 # Only errors and final results
SUMMARY = 2 # Stage summaries + errors
DETAILED = 3 # Include raw LLM outputs, RAG results
VERBOSE = 4 # Everything including intermediate states
class PipelineLogger:
"""
Hierarchical logger for cortex pipeline debugging.
Features:
- Clear visual section markers
- Collapsible detail sections
- Raw data dumps with truncation options
- Stage timing
- Error highlighting
"""
def __init__(self, logger: logging.Logger, level: LogLevel = LogLevel.SUMMARY):
self.logger = logger
self.level = level
self.stage_timings = {}
self.current_stage = None
self.stage_start_time = None
self.pipeline_start_time = None
def pipeline_start(self, session_id: str, user_prompt: str):
"""Mark the start of a pipeline run"""
self.pipeline_start_time = datetime.now()
self.stage_timings = {}
if self.level.value >= LogLevel.SUMMARY.value:
self.logger.info(f"\n{'='*100}")
self.logger.info(f"🚀 PIPELINE START | Session: {session_id} | {datetime.now().strftime('%H:%M:%S.%f')[:-3]}")
self.logger.info(f"{'='*100}")
if self.level.value >= LogLevel.DETAILED.value:
self.logger.info(f"📝 User prompt: {user_prompt[:200]}{'...' if len(user_prompt) > 200 else ''}")
self.logger.info(f"{'-'*100}\n")
def stage_start(self, stage_name: str, description: str = ""):
"""Mark the start of a pipeline stage"""
self.current_stage = stage_name
self.stage_start_time = datetime.now()
if self.level.value >= LogLevel.SUMMARY.value:
timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3]
desc_suffix = f" - {description}" if description else ""
self.logger.info(f"▶️ [{stage_name}]{desc_suffix} | {timestamp}")
def stage_end(self, result_summary: str = ""):
"""Mark the end of a pipeline stage"""
if self.current_stage and self.stage_start_time:
duration_ms = (datetime.now() - self.stage_start_time).total_seconds() * 1000
self.stage_timings[self.current_stage] = duration_ms
if self.level.value >= LogLevel.SUMMARY.value:
summary_suffix = f"{result_summary}" if result_summary else ""
self.logger.info(f"✅ [{self.current_stage}] Complete in {duration_ms:.0f}ms{summary_suffix}\n")
self.current_stage = None
self.stage_start_time = None
def log_llm_call(self, backend: str, prompt: str, response: Any, raw_response: str = None):
"""
Log LLM call details with proper formatting.
Args:
backend: Backend name (PRIMARY, SECONDARY, etc.)
prompt: Input prompt to LLM
response: Parsed response object
raw_response: Raw JSON response string
"""
if self.level.value >= LogLevel.DETAILED.value:
self.logger.info(f" 🧠 LLM Call | Backend: {backend}")
# Show prompt (truncated)
if isinstance(prompt, list):
prompt_preview = prompt[-1].get('content', '')[:150] if prompt else ''
else:
prompt_preview = str(prompt)[:150]
self.logger.info(f" Prompt: {prompt_preview}...")
# Show parsed response
if isinstance(response, dict):
response_text = (
response.get('reply') or
response.get('message', {}).get('content') or
str(response)
)[:200]
else:
response_text = str(response)[:200]
self.logger.info(f" Response: {response_text}...")
# Show raw response in collapsible block
if raw_response and self.level.value >= LogLevel.VERBOSE.value:
self.logger.debug(f" ╭─ RAW RESPONSE ────────────────────────────────────")
for line in raw_response.split('\n')[:50]: # Limit to 50 lines
self.logger.debug(f"{line}")
if raw_response.count('\n') > 50:
self.logger.debug(f" │ ... ({raw_response.count(chr(10)) - 50} more lines)")
self.logger.debug(f" ╰───────────────────────────────────────────────────\n")
def log_rag_results(self, results: List[Dict[str, Any]]):
"""Log RAG/NeoMem results in scannable format"""
if self.level.value >= LogLevel.SUMMARY.value:
self.logger.info(f" 📚 RAG Results: {len(results)} memories retrieved")
if self.level.value >= LogLevel.DETAILED.value and results:
self.logger.info(f" ╭─ MEMORY SCORES ───────────────────────────────────")
for idx, result in enumerate(results[:10], 1): # Show top 10
score = result.get("score", 0)
data_preview = str(result.get("payload", {}).get("data", ""))[:80]
self.logger.info(f" │ [{idx}] {score:.3f} | {data_preview}...")
if len(results) > 10:
self.logger.info(f" │ ... and {len(results) - 10} more results")
self.logger.info(f" ╰───────────────────────────────────────────────────")
def log_context_state(self, context_state: Dict[str, Any]):
"""Log context state summary"""
if self.level.value >= LogLevel.SUMMARY.value:
msg_count = context_state.get("message_count", 0)
minutes_since = context_state.get("minutes_since_last_msg", 0)
rag_count = len(context_state.get("rag", []))
self.logger.info(f" 📊 Context | Messages: {msg_count} | Last: {minutes_since:.1f}min ago | RAG: {rag_count} results")
if self.level.value >= LogLevel.DETAILED.value:
intake = context_state.get("intake", {})
if intake:
self.logger.info(f" ╭─ INTAKE SUMMARIES ────────────────────────────────")
for level in ["L1", "L5", "L10", "L20", "L30"]:
if level in intake:
summary = intake[level]
if isinstance(summary, dict):
summary = summary.get("summary", str(summary)[:100])
else:
summary = str(summary)[:100]
self.logger.info(f"{level}: {summary}...")
self.logger.info(f" ╰───────────────────────────────────────────────────")
def log_error(self, stage: str, error: Exception, critical: bool = False):
"""Log an error with context"""
level_marker = "🔴 CRITICAL" if critical else "⚠️ WARNING"
self.logger.error(f"{level_marker} | Stage: {stage} | Error: {type(error).__name__}: {str(error)}")
if self.level.value >= LogLevel.VERBOSE.value:
import traceback
self.logger.debug(f" Traceback:\n{traceback.format_exc()}")
def log_raw_data(self, label: str, data: Any, max_lines: int = 30):
"""Log raw data in a collapsible format"""
if self.level.value >= LogLevel.VERBOSE.value:
self.logger.debug(f" ╭─ {label.upper()} ──────────────────────────────────")
if isinstance(data, (dict, list)):
json_str = json.dumps(data, indent=2, default=str)
lines = json_str.split('\n')
for line in lines[:max_lines]:
self.logger.debug(f"{line}")
if len(lines) > max_lines:
self.logger.debug(f" │ ... ({len(lines) - max_lines} more lines)")
else:
lines = str(data).split('\n')
for line in lines[:max_lines]:
self.logger.debug(f"{line}")
if len(lines) > max_lines:
self.logger.debug(f" │ ... ({len(lines) - max_lines} more lines)")
self.logger.debug(f" ╰───────────────────────────────────────────────────")
def pipeline_end(self, session_id: str, final_output_length: int):
"""Mark the end of pipeline run with summary"""
if self.pipeline_start_time:
total_duration_ms = (datetime.now() - self.pipeline_start_time).total_seconds() * 1000
if self.level.value >= LogLevel.SUMMARY.value:
self.logger.info(f"\n{'='*100}")
self.logger.info(f"✨ PIPELINE COMPLETE | Session: {session_id} | Total: {total_duration_ms:.0f}ms")
self.logger.info(f"{'='*100}")
# Show timing breakdown
if self.stage_timings and self.level.value >= LogLevel.DETAILED.value:
self.logger.info("⏱️ Stage Timings:")
for stage, duration in self.stage_timings.items():
pct = (duration / total_duration_ms) * 100 if total_duration_ms > 0 else 0
self.logger.info(f" {stage:20s}: {duration:6.0f}ms ({pct:5.1f}%)")
self.logger.info(f"📤 Final output: {final_output_length} characters")
self.logger.info(f"{'='*100}\n")
def get_log_level_from_env() -> LogLevel:
"""Parse log level from environment variable"""
import os
verbose_debug = os.getenv("VERBOSE_DEBUG", "false").lower() == "true"
detail_level = os.getenv("LOG_DETAIL_LEVEL", "").lower()
if detail_level == "minimal":
return LogLevel.MINIMAL
elif detail_level == "summary":
return LogLevel.SUMMARY
elif detail_level == "detailed":
return LogLevel.DETAILED
elif detail_level == "verbose" or verbose_debug:
return LogLevel.VERBOSE
else:
return LogLevel.SUMMARY # Default
View File
-56
View File
@@ -1,56 +0,0 @@
networks:
lyra_net:
driver: bridge
volumes:
nebula_fallback:
driver: local
relay_sessions:
driver: local
services:
# ============================================================
# Lyra (Unified: Relay + Cortex + Intake)
# ============================================================
lyra:
build:
context: .
dockerfile: Dockerfile
container_name: lyra
restart: unless-stopped
env_file:
- ./.env
volumes:
- relay_sessions:/app/relay/sessions
- nebula_fallback:/app/.nebula_fallback
- ./cortex:/app/cortex # Mount for hot reload during development
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "7078:7078" # Relay API (user-facing)
- "7081:7081" # Cortex API (internal/debug)
networks:
- lyra_net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7078/_health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# ============================================================
# UI Server
# ============================================================
lyra-ui:
image: nginx:alpine
container_name: lyra-ui
restart: unless-stopped
ports:
- "8081:80"
volumes:
- ./core/ui:/usr/share/nginx/html:ro
networks:
- lyra_net
depends_on:
lyra:
condition: service_healthy
-441
View File
@@ -1,441 +0,0 @@
├── CHANGELOG.md
├── core
│ ├── env experiments
│ ├── persona-sidecar
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ ├── persona-server.js
│ │ └── personas.json
│ ├── relay
│ │ ├── Dockerfile
│ │ ├── lib
│ │ │ ├── cortex.js
│ │ │ └── llm.js
│ │ ├── package.json
│ │ ├── package-lock.json
│ │ ├── server.js
│ │ ├── sessions
│ │ │ ├── default.jsonl
│ │ │ ├── sess-6rxu7eia.json
│ │ │ ├── sess-6rxu7eia.jsonl
│ │ │ ├── sess-l08ndm60.json
│ │ │ └── sess-l08ndm60.jsonl
│ │ └── test-llm.js
│ ├── relay-backup
│ └── ui
│ ├── index.html
│ ├── manifest.json
│ └── style.css
├── cortex
│ ├── context.py
│ ├── Dockerfile
│ ├── ingest
│ │ ├── ingest_handler.py
│ │ ├── __init__.py
│ │ └── intake_client.py
│ ├── intake
│ │ ├── __init__.py
│ │ ├── intake.py
│ │ └── logs
│ ├── llm
│ │ ├── __init__.py
│ │ └── llm_router.py
│ ├── logs
│ │ ├── cortex_verbose_debug.log
│ │ └── reflections.log
│ ├── main.py
│ ├── neomem_client.py
│ ├── persona
│ │ ├── identity.py
│ │ ├── __init__.py
│ │ └── speak.py
│ ├── rag.py
│ ├── reasoning
│ │ ├── __init__.py
│ │ ├── reasoning.py
│ │ ├── refine.py
│ │ └── reflection.py
│ ├── requirements.txt
│ ├── router.py
│ ├── tests
│ └── utils
│ ├── config.py
│ ├── __init__.py
│ ├── log_utils.py
│ └── schema.py
├── deprecated.env.txt
├── DEPRECATED_FILES.md
├── docker-compose.yml
├── docs
│ ├── ARCHITECTURE_v0-6-0.md
│ ├── ENVIRONMENT_VARIABLES.md
│ ├── lyra_tree.txt
│ └── PROJECT_SUMMARY.md
├── intake-logs
│ └── summaries.log
├── neomem
│ ├── _archive
│ │ └── old_servers
│ │ ├── main_backup.py
│ │ └── main_dev.py
│ ├── docker-compose.yml
│ ├── Dockerfile
│ ├── neomem
│ │ ├── api
│ │ ├── client
│ │ │ ├── __init__.py
│ │ │ ├── main.py
│ │ │ ├── project.py
│ │ │ └── utils.py
│ │ ├── configs
│ │ │ ├── base.py
│ │ │ ├── embeddings
│ │ │ │ ├── base.py
│ │ │ │ └── __init__.py
│ │ │ ├── enums.py
│ │ │ ├── __init__.py
│ │ │ ├── llms
│ │ │ │ ├── anthropic.py
│ │ │ │ ├── aws_bedrock.py
│ │ │ │ ├── azure.py
│ │ │ │ ├── base.py
│ │ │ │ ├── deepseek.py
│ │ │ │ ├── __init__.py
│ │ │ │ ├── lmstudio.py
│ │ │ │ ├── ollama.py
│ │ │ │ ├── openai.py
│ │ │ │ └── vllm.py
│ │ │ ├── prompts.py
│ │ │ └── vector_stores
│ │ │ ├── azure_ai_search.py
│ │ │ ├── azure_mysql.py
│ │ │ ├── baidu.py
│ │ │ ├── chroma.py
│ │ │ ├── databricks.py
│ │ │ ├── elasticsearch.py
│ │ │ ├── faiss.py
│ │ │ ├── __init__.py
│ │ │ ├── langchain.py
│ │ │ ├── milvus.py
│ │ │ ├── mongodb.py
│ │ │ ├── neptune.py
│ │ │ ├── opensearch.py
│ │ │ ├── pgvector.py
│ │ │ ├── pinecone.py
│ │ │ ├── qdrant.py
│ │ │ ├── redis.py
│ │ │ ├── s3_vectors.py
│ │ │ ├── supabase.py
│ │ │ ├── upstash_vector.py
│ │ │ ├── valkey.py
│ │ │ ├── vertex_ai_vector_search.py
│ │ │ └── weaviate.py
│ │ ├── core
│ │ ├── embeddings
│ │ │ ├── aws_bedrock.py
│ │ │ ├── azure_openai.py
│ │ │ ├── base.py
│ │ │ ├── configs.py
│ │ │ ├── gemini.py
│ │ │ ├── huggingface.py
│ │ │ ├── __init__.py
│ │ │ ├── langchain.py
│ │ │ ├── lmstudio.py
│ │ │ ├── mock.py
│ │ │ ├── ollama.py
│ │ │ ├── openai.py
│ │ │ ├── together.py
│ │ │ └── vertexai.py
│ │ ├── exceptions.py
│ │ ├── graphs
│ │ │ ├── configs.py
│ │ │ ├── __init__.py
│ │ │ ├── neptune
│ │ │ │ ├── base.py
│ │ │ │ ├── __init__.py
│ │ │ │ ├── neptunedb.py
│ │ │ │ └── neptunegraph.py
│ │ │ ├── tools.py
│ │ │ └── utils.py
│ │ ├── __init__.py
│ │ ├── LICENSE
│ │ ├── llms
│ │ │ ├── anthropic.py
│ │ │ ├── aws_bedrock.py
│ │ │ ├── azure_openai.py
│ │ │ ├── azure_openai_structured.py
│ │ │ ├── base.py
│ │ │ ├── configs.py
│ │ │ ├── deepseek.py
│ │ │ ├── gemini.py
│ │ │ ├── groq.py
│ │ │ ├── __init__.py
│ │ │ ├── langchain.py
│ │ │ ├── litellm.py
│ │ │ ├── lmstudio.py
│ │ │ ├── ollama.py
│ │ │ ├── openai.py
│ │ │ ├── openai_structured.py
│ │ │ ├── sarvam.py
│ │ │ ├── together.py
│ │ │ ├── vllm.py
│ │ │ └── xai.py
│ │ ├── memory
│ │ │ ├── base.py
│ │ │ ├── graph_memory.py
│ │ │ ├── __init__.py
│ │ │ ├── kuzu_memory.py
│ │ │ ├── main.py
│ │ │ ├── memgraph_memory.py
│ │ │ ├── setup.py
│ │ │ ├── storage.py
│ │ │ ├── telemetry.py
│ │ │ └── utils.py
│ │ ├── proxy
│ │ │ ├── __init__.py
│ │ │ └── main.py
│ │ ├── server
│ │ │ ├── dev.Dockerfile
│ │ │ ├── docker-compose.yaml
│ │ │ ├── Dockerfile
│ │ │ ├── main_old.py
│ │ │ ├── main.py
│ │ │ ├── Makefile
│ │ │ ├── README.md
│ │ │ └── requirements.txt
│ │ ├── storage
│ │ ├── utils
│ │ │ └── factory.py
│ │ └── vector_stores
│ │ ├── azure_ai_search.py
│ │ ├── azure_mysql.py
│ │ ├── baidu.py
│ │ ├── base.py
│ │ ├── chroma.py
│ │ ├── configs.py
│ │ ├── databricks.py
│ │ ├── elasticsearch.py
│ │ ├── faiss.py
│ │ ├── __init__.py
│ │ ├── langchain.py
│ │ ├── milvus.py
│ │ ├── mongodb.py
│ │ ├── neptune_analytics.py
│ │ ├── opensearch.py
│ │ ├── pgvector.py
│ │ ├── pinecone.py
│ │ ├── qdrant.py
│ │ ├── redis.py
│ │ ├── s3_vectors.py
│ │ ├── supabase.py
│ │ ├── upstash_vector.py
│ │ ├── valkey.py
│ │ ├── vertex_ai_vector_search.py
│ │ └── weaviate.py
│ ├── neomem_history
│ │ └── history.db
│ ├── pyproject.toml
│ ├── README.md
│ └── requirements.txt
├── neomem_history
│ └── history.db
├── rag
│ ├── chatlogs
│ │ └── lyra
│ │ ├── 0000_Wire_ROCm_to_Cortex.json
│ │ ├── 0001_Branch___10_22_ct201branch-ssh_tut.json
│ │ ├── 0002_cortex_LLMs_11-1-25.json
│ │ ├── 0003_RAG_beta.json
│ │ ├── 0005_Cortex_v0_4_0_planning.json
│ │ ├── 0006_Cortex_v0_4_0_Refinement.json
│ │ ├── 0009_Branch___Cortex_v0_4_0_planning.json
│ │ ├── 0012_Cortex_4_-_neomem_11-1-25.json
│ │ ├── 0016_Memory_consolidation_concept.json
│ │ ├── 0017_Model_inventory_review.json
│ │ ├── 0018_Branch___Memory_consolidation_concept.json
│ │ ├── 0022_Branch___Intake_conversation_summaries.json
│ │ ├── 0026_Intake_conversation_summaries.json
│ │ ├── 0027_Trilium_AI_LLM_setup.json
│ │ ├── 0028_LLMs_and_sycophancy_levels.json
│ │ ├── 0031_UI_improvement_plan.json
│ │ ├── 0035_10_27-neomem_update.json
│ │ ├── 0044_Install_llama_cpp_on_ct201.json
│ │ ├── 0045_AI_task_assistant.json
│ │ ├── 0047_Project_scope_creation.json
│ │ ├── 0052_View_docker_container_logs.json
│ │ ├── 0053_10_21-Proxmox_fan_control.json
│ │ ├── 0054_10_21-pytorch_branch_Quant_experiments.json
│ │ ├── 0055_10_22_ct201branch-ssh_tut.json
│ │ ├── 0060_Lyra_project_folder_issue.json
│ │ ├── 0062_Build_pytorch_API.json
│ │ ├── 0063_PokerBrain_dataset_structure.json
│ │ ├── 0065_Install_PyTorch_setup.json
│ │ ├── 0066_ROCm_PyTorch_setup_quirks.json
│ │ ├── 0067_VM_model_setup_steps.json
│ │ ├── 0070_Proxmox_disk_error_fix.json
│ │ ├── 0072_Docker_Compose_vs_Portainer.json
│ │ ├── 0073_Check_system_temps_Proxmox.json
│ │ ├── 0075_Cortex_gpu_progress.json
│ │ ├── 0076_Backup_Proxmox_before_upgrade.json
│ │ ├── 0077_Storage_cleanup_advice.json
│ │ ├── 0082_Install_ROCm_on_Proxmox.json
│ │ ├── 0088_Thalamus_program_summary.json
│ │ ├── 0094_Cortex_blueprint_development.json
│ │ ├── 0095_mem0_advancments.json
│ │ ├── 0096_Embedding_provider_swap.json
│ │ ├── 0097_Update_git_commit_steps.json
│ │ ├── 0098_AI_software_description.json
│ │ ├── 0099_Seed_memory_process.json
│ │ ├── 0100_Set_up_Git_repo.json
│ │ ├── 0101_Customize_embedder_setup.json
│ │ ├── 0102_Seeding_Local_Lyra_memory.json
│ │ ├── 0103_Mem0_seeding_part_3.json
│ │ ├── 0104_Memory_build_prompt.json
│ │ ├── 0105_Git_submodule_setup_guide.json
│ │ ├── 0106_Serve_UI_on_LAN.json
│ │ ├── 0107_AI_name_suggestion.json
│ │ ├── 0108_Room_X_planning_update.json
│ │ ├── 0109_Salience_filtering_design.json
│ │ ├── 0110_RoomX_Cortex_build.json
│ │ ├── 0119_Explain_Lyra_cortex_idea.json
│ │ ├── 0120_Git_submodule_organization.json
│ │ ├── 0121_Web_UI_fix_guide.json
│ │ ├── 0122_UI_development_planning.json
│ │ ├── 0123_NVGRAM_debugging_steps.json
│ │ ├── 0124_NVGRAM_setup_troubleshooting.json
│ │ ├── 0125_NVGRAM_development_update.json
│ │ ├── 0126_RX_-_NeVGRAM_New_Features.json
│ │ ├── 0127_Error_troubleshooting_steps.json
│ │ ├── 0135_Proxmox_backup_with_ABB.json
│ │ ├── 0151_Auto-start_Lyra-Core_VM.json
│ │ ├── 0156_AI_GPU_benchmarks_comparison.json
│ │ └── 0251_Lyra_project_handoff.json
│ ├── chromadb
│ │ ├── c4f701ee-1978-44a1-9df4-3e865b5d33c1
│ │ │ ├── data_level0.bin
│ │ │ ├── header.bin
│ │ │ ├── index_metadata.pickle
│ │ │ ├── length.bin
│ │ │ └── link_lists.bin
│ │ └── chroma.sqlite3
│ ├── import.log
│ ├── lyra-chatlogs
│ │ ├── 0000_Wire_ROCm_to_Cortex.json
│ │ ├── 0001_Branch___10_22_ct201branch-ssh_tut.json
│ │ ├── 0002_cortex_LLMs_11-1-25.json
│ │ └── 0003_RAG_beta.json
│ ├── rag_api.py
│ ├── rag_build.py
│ ├── rag_chat_import.py
│ └── rag_query.py
├── README.md
└── volumes
├── neo4j_data
│ ├── databases
│ │ ├── neo4j
│ │ │ ├── database_lock
│ │ │ ├── id-buffer.tmp.0
│ │ │ ├── neostore
│ │ │ ├── neostore.counts.db
│ │ │ ├── neostore.indexstats.db
│ │ │ ├── neostore.labeltokenstore.db
│ │ │ ├── neostore.labeltokenstore.db.id
│ │ │ ├── neostore.labeltokenstore.db.names
│ │ │ ├── neostore.labeltokenstore.db.names.id
│ │ │ ├── neostore.nodestore.db
│ │ │ ├── neostore.nodestore.db.id
│ │ │ ├── neostore.nodestore.db.labels
│ │ │ ├── neostore.nodestore.db.labels.id
│ │ │ ├── neostore.propertystore.db
│ │ │ ├── neostore.propertystore.db.arrays
│ │ │ ├── neostore.propertystore.db.arrays.id
│ │ │ ├── neostore.propertystore.db.id
│ │ │ ├── neostore.propertystore.db.index
│ │ │ ├── neostore.propertystore.db.index.id
│ │ │ ├── neostore.propertystore.db.index.keys
│ │ │ ├── neostore.propertystore.db.index.keys.id
│ │ │ ├── neostore.propertystore.db.strings
│ │ │ ├── neostore.propertystore.db.strings.id
│ │ │ ├── neostore.relationshipgroupstore.db
│ │ │ ├── neostore.relationshipgroupstore.db.id
│ │ │ ├── neostore.relationshipgroupstore.degrees.db
│ │ │ ├── neostore.relationshipstore.db
│ │ │ ├── neostore.relationshipstore.db.id
│ │ │ ├── neostore.relationshiptypestore.db
│ │ │ ├── neostore.relationshiptypestore.db.id
│ │ │ ├── neostore.relationshiptypestore.db.names
│ │ │ ├── neostore.relationshiptypestore.db.names.id
│ │ │ ├── neostore.schemastore.db
│ │ │ ├── neostore.schemastore.db.id
│ │ │ └── schema
│ │ │ └── index
│ │ │ └── token-lookup-1.0
│ │ │ ├── 1
│ │ │ │ └── index-1
│ │ │ └── 2
│ │ │ └── index-2
│ │ ├── store_lock
│ │ └── system
│ │ ├── database_lock
│ │ ├── id-buffer.tmp.0
│ │ ├── neostore
│ │ ├── neostore.counts.db
│ │ ├── neostore.indexstats.db
│ │ ├── neostore.labeltokenstore.db
│ │ ├── neostore.labeltokenstore.db.id
│ │ ├── neostore.labeltokenstore.db.names
│ │ ├── neostore.labeltokenstore.db.names.id
│ │ ├── neostore.nodestore.db
│ │ ├── neostore.nodestore.db.id
│ │ ├── neostore.nodestore.db.labels
│ │ ├── neostore.nodestore.db.labels.id
│ │ ├── neostore.propertystore.db
│ │ ├── neostore.propertystore.db.arrays
│ │ ├── neostore.propertystore.db.arrays.id
│ │ ├── neostore.propertystore.db.id
│ │ ├── neostore.propertystore.db.index
│ │ ├── neostore.propertystore.db.index.id
│ │ ├── neostore.propertystore.db.index.keys
│ │ ├── neostore.propertystore.db.index.keys.id
│ │ ├── neostore.propertystore.db.strings
│ │ ├── neostore.propertystore.db.strings.id
│ │ ├── neostore.relationshipgroupstore.db
│ │ ├── neostore.relationshipgroupstore.db.id
│ │ ├── neostore.relationshipgroupstore.degrees.db
│ │ ├── neostore.relationshipstore.db
│ │ ├── neostore.relationshipstore.db.id
│ │ ├── neostore.relationshiptypestore.db
│ │ ├── neostore.relationshiptypestore.db.id
│ │ ├── neostore.relationshiptypestore.db.names
│ │ ├── neostore.relationshiptypestore.db.names.id
│ │ ├── neostore.schemastore.db
│ │ ├── neostore.schemastore.db.id
│ │ └── schema
│ │ └── index
│ │ ├── range-1.0
│ │ │ ├── 3
│ │ │ │ └── index-3
│ │ │ ├── 4
│ │ │ │ └── index-4
│ │ │ ├── 7
│ │ │ │ └── index-7
│ │ │ ├── 8
│ │ │ │ └── index-8
│ │ │ └── 9
│ │ │ └── index-9
│ │ └── token-lookup-1.0
│ │ ├── 1
│ │ │ └── index-1
│ │ └── 2
│ │ └── index-2
│ ├── dbms
│ │ └── auth.ini
│ ├── server_id
│ └── transactions
│ ├── neo4j
│ │ ├── checkpoint.0
│ │ └── neostore.transaction.db.0
│ └── system
│ ├── checkpoint.0
│ └── neostore.transaction.db.0
└── postgres_data [error opening dir]
+31
View File
@@ -0,0 +1,31 @@
"""Environment-driven configuration."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
@dataclass(frozen=True)
class Config:
local_base_url: str
local_model: str
openai_api_key: str
cloud_model: str
embed_model: str
db_path: Path
def load() -> Config:
return Config(
local_base_url=os.getenv("LOCAL_BASE_URL", "http://localhost:11434"),
local_model=os.getenv("LOCAL_MODEL", "qwen2.5:7b-instruct"),
openai_api_key=os.getenv("OPENAI_API_KEY", ""),
cloud_model=os.getenv("CLOUD_MODEL", "gpt-4o-mini"),
embed_model=os.getenv("EMBED_MODEL", "text-embedding-3-small"),
db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")),
)
+44
View File
@@ -0,0 +1,44 @@
"""LLM router: local (Ollama) chat, cloud (OpenAI) chat + embeddings."""
from __future__ import annotations
from typing import Literal, TypedDict
import httpx
from openai import OpenAI
from lyra.config import load
class Message(TypedDict):
role: Literal["system", "user", "assistant"]
content: str
Backend = Literal["local", "cloud"]
def complete(messages: list[Message], backend: Backend = "local") -> str:
cfg = load()
if backend == "cloud":
if not cfg.openai_api_key:
raise RuntimeError("OPENAI_API_KEY is not set")
client = OpenAI(api_key=cfg.openai_api_key)
resp = client.chat.completions.create(model=cfg.cloud_model, messages=messages)
return resp.choices[0].message.content or ""
resp = httpx.post(
f"{cfg.local_base_url}/api/chat",
json={"model": cfg.local_model, "messages": messages, "stream": False},
timeout=120,
)
resp.raise_for_status()
return resp.json()["message"]["content"]
def embed(texts: list[str]) -> list[list[float]]:
cfg = load()
if not cfg.openai_api_key:
raise RuntimeError("OPENAI_API_KEY is not set")
client = OpenAI(api_key=cfg.openai_api_key)
resp = client.embeddings.create(model=cfg.embed_model, input=texts)
return [d.embedding for d in resp.data]
+133
View File
@@ -0,0 +1,133 @@
"""Persistent memory: SQLite storage + brute-force cosine recall over embeddings.
Each exchange is stored with its OpenAI embedding as a float32 BLOB. Recall
loads all embeddings (optionally scoped to a session) into a matrix and
returns the top-k by cosine similarity. Brute force is fine up to tens of
thousands of rows; swap in a vector index when that stops being true.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
from lyra import llm
from lyra.config import load
SCHEMA = """
CREATE TABLE IF NOT EXISTS exchanges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
embedding BLOB NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_session_created ON exchanges(session_id, created_at);
"""
_conn: sqlite3.Connection | None = None
_conn_path: Path | None = None
def _connection() -> sqlite3.Connection:
"""Lazily open the SQLite connection. Reopens if LYRA_DB_PATH changed (for tests)."""
global _conn, _conn_path
cfg = load()
if _conn is None or _conn_path != cfg.db_path:
if _conn is not None:
_conn.close()
cfg.db_path.parent.mkdir(parents=True, exist_ok=True)
_conn = sqlite3.connect(cfg.db_path)
_conn.row_factory = sqlite3.Row
_conn.executescript(SCHEMA)
_conn_path = cfg.db_path
return _conn
@dataclass
class Exchange:
id: int
session_id: str
role: str
content: str
created_at: str
score: float | None = None
def _to_blob(vec: list[float]) -> bytes:
return np.asarray(vec, dtype=np.float32).tobytes()
def _from_blob(blob: bytes) -> np.ndarray:
return np.frombuffer(blob, dtype=np.float32)
def remember(session_id: str, role: str, content: str) -> int:
"""Embed and persist a single exchange. Returns the new row id."""
[embedding] = llm.embed([content])
now = datetime.now(timezone.utc).isoformat()
conn = _connection()
with conn:
cur = conn.execute(
"INSERT INTO exchanges (session_id, role, content, embedding, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(session_id, role, content, _to_blob(embedding), now),
)
return int(cur.lastrowid)
def recent(session_id: str, n: int = 10) -> list[Exchange]:
"""Last `n` exchanges from a session, oldest first."""
conn = _connection()
rows = conn.execute(
"SELECT id, session_id, role, content, created_at FROM exchanges "
"WHERE session_id = ? ORDER BY id DESC LIMIT ?",
(session_id, n),
).fetchall()
return [
Exchange(
id=r["id"],
session_id=r["session_id"],
role=r["role"],
content=r["content"],
created_at=r["created_at"],
)
for r in reversed(rows)
]
def recall(query: str, k: int = 5, session_id: str | None = None) -> list[Exchange]:
"""Top-k exchanges semantically similar to `query`, optionally scoped to a session."""
[q_vec] = llm.embed([query])
q = np.asarray(q_vec, dtype=np.float32)
conn = _connection()
sql = "SELECT id, session_id, role, content, embedding, created_at FROM exchanges"
params: tuple = ()
if session_id is not None:
sql += " WHERE session_id = ?"
params = (session_id,)
rows = conn.execute(sql, params).fetchall()
if not rows:
return []
matrix = np.stack([_from_blob(r["embedding"]) for r in rows])
norms = np.linalg.norm(matrix, axis=1)
scores = (matrix @ q) / (norms * np.linalg.norm(q) + 1e-9)
top_idx = np.argsort(scores)[::-1][:k]
return [
Exchange(
id=rows[i]["id"],
session_id=rows[i]["session_id"],
role=rows[i]["role"],
content=rows[i]["content"],
created_at=rows[i]["created_at"],
score=float(scores[i]),
)
for i in top_idx
]
+32
View File
@@ -0,0 +1,32 @@
[project]
name = "lyra"
version = "0.1.0"
description = "Persistent, autonomous AI assistant"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.28.1",
"numpy>=2.4.5",
"openai>=2.37.0",
"python-dotenv>=1.2.2",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.6",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["lyra"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.pytest.ini_options]
testpaths = ["tests"]
-11
View File
@@ -1,11 +0,0 @@
# ====================================
# 📚 RAG SERVICE CONFIG
# ====================================
# Retrieval-Augmented Generation service (Beta Lyrae)
# Currently not wired into the system - for future activation
# OPENAI_API_KEY and other shared config inherited from root .env
# RAG-specific configuration will go here when service is activated
# ChromaDB configuration
# Vector store settings
# Retrieval parameters
-56
View File
@@ -1,56 +0,0 @@
# rag_api.py
from fastapi import FastAPI, Body
from pydantic import BaseModel
import os, chromadb
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
# ---- setup ----
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
chroma = chromadb.PersistentClient(path="./chromadb")
collection = chroma.get_or_create_collection("lyra_chats")
app = FastAPI(title="Lyra RAG API")
class Query(BaseModel):
query: str
n_results: int = 5
@app.post("/rag/search")
def rag_search(q: Query = Body(...)):
# embed query
q_emb = client.embeddings.create(
model="text-embedding-3-small",
input=q.query
).data[0].embedding
# retrieve matches
results = collection.query(query_embeddings=[q_emb], n_results=q.n_results)
docs = results["documents"][0]
metas = results["metadatas"][0]
context = "\n\n".join(docs)
# synthesize short answer
answer = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Answer based only on the context below. Be concise and practical."},
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {q.query}"}
]
).choices[0].message.content
return {
"query": q.query,
"answer": answer,
"results": [
{"source": m.get("source"), "title": m.get("title"),
"role": m.get("role"), "excerpt": d[:300]}
for d, m in zip(docs, metas)
]
}
@app.get("/health")
def health():
return {"status": "ok", "collection_count": collection.count()}
-53
View File
@@ -1,53 +0,0 @@
import uuid, hashlib, os, json, glob
from tqdm import tqdm
import chromadb
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
# persistent local DB
chroma = chromadb.PersistentClient(path="./chromadb")
collection = chroma.get_or_create_collection("lyra_chats")
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
files = glob.glob("chatlogs/*.json")
added, skipped = 0, 0
for f in tqdm(files, desc="Indexing chats"):
with open(f) as fh:
data = json.load(fh)
title = data.get("title", f)
for msg in data.get("messages", []):
if msg["role"] not in ("user", "assistant"):
continue
text = msg["content"].strip()
if not text:
continue
# deterministic hash ID
doc_id = hashlib.sha1(text.encode("utf-8")).hexdigest()
# skip if already indexed
existing = collection.get(ids=[doc_id])
if existing and existing.get("ids"):
skipped += 1
continue
emb = client.embeddings.create(
model="text-embedding-3-small",
input=text
).data[0].embedding
collection.add(
ids=[doc_id],
documents=[text],
embeddings=[emb],
metadatas=[{"source": f, "title": title, "role": msg["role"]}]
)
added += 1
print(f"\n✅ Finished indexing {len(files)} chat files.")
print(f"🆕 Added {added:,} new chunks | ⏭️ Skipped {skipped:,} duplicates")
print(f"📦 Total in collection now: {collection.count()} (stored in ./chromadb)")
-75
View File
@@ -1,75 +0,0 @@
import json, glob, os, hashlib
from tqdm import tqdm
import chromadb
import datetime, hashlib
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
chroma = chromadb.PersistentClient(path="./chromadb")
collection = chroma.get_or_create_collection("lyra_chats")
CHUNK_SIZE = 5000 # characters (~15002000 tokens)
added, skipped = 0, 0
# recursive glob through all category folders
files = glob.glob("chatlogs/**/*.json", recursive=True)
for f in tqdm(files, desc="Indexing chats"):
with open(f) as fh:
data = json.load(fh)
title = data.get("title", os.path.basename(f))
category = os.path.basename(os.path.dirname(f)) # e.g. work, poker, etc.
chat_id = hashlib.sha1(f.encode("utf-8")).hexdigest() # <-- move it here (per file)
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(f)).isoformat()
now = datetime.datetime.utcnow().isoformat()
for msg in data.get("messages", []):
if msg["role"] not in ("user", "assistant"):
continue
text = msg["content"].strip()
if not text:
continue
for i in range(0, len(text), CHUNK_SIZE):
chunk = text[i:i+CHUNK_SIZE]
doc_id = hashlib.sha1((f"{f}_{i}_{chunk}").encode("utf-8")).hexdigest()
existing = collection.get(ids=[doc_id])
if existing and existing.get("ids"):
skipped += 1
continue
emb = client.embeddings.create(
model="text-embedding-3-small",
input=chunk
).data[0].embedding
metadata = {
"chat_id": chat_id, # ✅ now defined
"chunk_index": i // CHUNK_SIZE,
"source": f,
"title": title,
"role": msg["role"],
"category": category,
"type": "chat",
"file_modified": mtime,
"imported_at": now
}
collection.add(
ids=[doc_id],
documents=[chunk],
embeddings=[emb],
metadatas=[metadata]
)
added += 1
print(f"\n✅ Finished indexing {len(files)} chat files.")
print(f"🆕 Added {added:,} new chunks | ⏭️ Skipped {skipped:,} duplicates")
print(f"📦 Total in collection now: {collection.count()} (stored in ./chromadb)")
-37
View File
@@ -1,37 +0,0 @@
# rag_query.py
import os, sys, chromadb
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
query = " ".join(sys.argv[1:]) or input("Ask Lyra-Archive: ")
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
chroma = chromadb.PersistentClient(path="./chromadb")
collection = chroma.get_or_create_collection("lyra_chats")
# embed the question
q_emb = client.embeddings.create(
model="text-embedding-3-small",
input=query
).data[0].embedding
# search the collection
results = collection.query(query_embeddings=[q_emb], n_results=5)
print("\n🔍 Top related excerpts:\n")
for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
print(f"📄 {meta['source']} ({meta['role']}) — {meta['title']}")
print(doc[:300].strip(), "\n---")
# synthesize an answer
context = "\n\n".join(results["documents"][0])
answer = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Answer based only on the context below. Be concise and practical."},
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {query}"}
]
).choices[0].message.content
print("\n💡 Lyra-Archive Answer:\n", answer)
-34
View File
@@ -1,34 +0,0 @@
#!/bin/bash
# Unified startup script for Lyra (Relay + Cortex)
set -e
echo "🚀 Starting Lyra unified container..."
# Start Cortex (Python/FastAPI) in the background
echo "📡 Starting Cortex on port 7081..."
cd /app/cortex
uvicorn main:app --host 0.0.0.0 --port 7081 &
CORTEX_PID=$!
# Wait for Cortex to be ready
echo "⏳ Waiting for Cortex to be ready..."
for i in {1..30}; do
if curl -sf http://localhost:7081/_health > /dev/null 2>&1; then
echo "✅ Cortex is ready!"
break
fi
if [ $i -eq 30 ]; then
echo "❌ Cortex failed to start within 30 seconds"
exit 1
fi
sleep 1
done
# Start Relay (Node.js/Express) in the foreground
echo "🔌 Starting Relay on port 7078..."
cd /app/relay
exec node server.js
# Note: We exec the last process so signals get forwarded properly
# If Relay dies, the container stops. If Cortex dies, Relay will fail too.
Generated
+562
View File
@@ -0,0 +1,562 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
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 = "certifi"
version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
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 = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "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 = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "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 = "lyra"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "numpy" },
{ name = "openai" },
{ name = "python-dotenv" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "numpy", specifier = ">=2.4.5" },
{ name = "openai", specifier = ">=2.37.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "ruff", specifier = ">=0.6" },
]
[[package]]
name = "numpy"
version = "2.4.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/50/8e/b8041bc719f056afd864478029d52214789341ac6583437b0ee5031e9530/numpy-2.4.5.tar.gz", hash = "sha256:ca670567a5683b7c1670ec03e0ddd5862e10934e92a70751d68d7b7b74ca7f9f", size = 20735669, upload-time = "2026-05-15T20:25:19.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/44/1383ee4d1e916a9e610e46c876b5c83ea023526117d23cd911983929ec34/numpy-2.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3176dc8ff71dbb593606f91a69ad0c3cd3303c7eb546af477370ab9edf760288", size = 16969261, upload-time = "2026-05-15T20:22:23.036Z" },
{ url = "https://files.pythonhosted.org/packages/3d/61/54bacfbec7550bc398e6b6d9a861db35d64f75844e1d7920f5722c3cd5e7/numpy-2.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1811150e5148f5a01a7cc282cb2f489b4a3050a773e173adb480e507bad3a3d7", size = 14964009, upload-time = "2026-05-15T20:22:25.819Z" },
{ url = "https://files.pythonhosted.org/packages/7a/55/fe86c64561761f185339c26001164a2687bd4787af681e961431abd2d534/numpy-2.4.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0d63a780070871210853ba01e90b88f9b85cf2abf63a7f143d5127189265ddf6", size = 5469106, upload-time = "2026-05-15T20:22:28.13Z" },
{ url = "https://files.pythonhosted.org/packages/2f/74/cf29b8317627f0e3aa2c9fb332d386bd734308cecd9e07da9f407d9ce0c3/numpy-2.4.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:0c6919cefafb3b76cd46a89dbb203bf1dd95529d2a6d09fef2d325d95d6a79d8", size = 6798945, upload-time = "2026-05-15T20:22:30.061Z" },
{ url = "https://files.pythonhosted.org/packages/80/a9/b61730a17fa87d5abb13ce560a1b4ce3485d37a13e03eb7b414e598e72f8/numpy-2.4.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d51efede1e58e8b11877536a5518f60e318d8ff69b89ad7b38ee5e431b24d772", size = 15967025, upload-time = "2026-05-15T20:22:32.328Z" },
{ url = "https://files.pythonhosted.org/packages/03/39/70bcd187eb4d223c21fde02c2bdfbffbffef3288cbb3947c04c74ae39a08/numpy-2.4.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07ce7e74da92d7c71b5df157b9758bcdd53d7fea10602154de3afd2b3ddc34dd", size = 16918685, upload-time = "2026-05-15T20:22:34.759Z" },
{ url = "https://files.pythonhosted.org/packages/ab/31/400fd1315bbe228af3937cf8a74e32023df6217af36077919d00adc382e4/numpy-2.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d7828234a13185effb34979e146f9921f2a65dfbbe215e6dbb57d6478fc8e059", size = 17322963, upload-time = "2026-05-15T20:22:37.557Z" },
{ url = "https://files.pythonhosted.org/packages/18/6a/bbbafb657e6f6ee826b4ecdb8722a2e0aae4a981888eaf59eae6a535cc13/numpy-2.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f96083adc3dfc1bbf778f2c79654d88115fa07074c97cb724fe9508f12d91c55", size = 18651594, upload-time = "2026-05-15T20:22:40.449Z" },
{ url = "https://files.pythonhosted.org/packages/de/0c/857a515154a2a18b0dfae04089600d166d352d473ec17a0680d879582d06/numpy-2.4.5-cp311-cp311-win32.whl", hash = "sha256:4ed78c904a638b6e5d7cd4db90c06fca5fc6ec2f28d258305368f454a50e79cf", size = 6233849, upload-time = "2026-05-15T20:22:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/f0/66/d215f3fb93541617adb5d58b3b9508e8a6413e499711e0adc0b80bcb445d/numpy-2.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:079b0fad6f2899b23c5da89792b5409d2d83fc83e8bd5c2299cc9c397a264864", size = 12608238, upload-time = "2026-05-15T20:22:45.229Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c4/611d66d3fcfa931954d37a19ce5575f3283d023e89ff0df6ad43b334ae9c/numpy-2.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:d6c78e260b53affe9b395a9d54fc61f101f9521c4d9452c7e9e3718b19e2215b", size = 10479452, upload-time = "2026-05-15T20:22:47.962Z" },
{ url = "https://files.pythonhosted.org/packages/6c/18/3275231e98620002681c922e792db04d72c356e9d8073c387344fc0e4ff1/numpy-2.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:654fb8674b61b1c4bd568f944d13a908566fdcb0d797303521d4149d16da05ef", size = 16689166, upload-time = "2026-05-15T20:22:50.761Z" },
{ url = "https://files.pythonhosted.org/packages/db/23/000aab6a16bdec53307f0f72546b57a3ac9266a62d8c257bee97d85fd078/numpy-2.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4cd9f6fa7ce10dc4627f2bb81dd9075dab67e94632e04c2b638e12575ddaa862", size = 14699514, upload-time = "2026-05-15T20:22:53.678Z" },
{ url = "https://files.pythonhosted.org/packages/47/cc/ddaf3af9c46966fef5be879256f213d85a0c56c75d07a3b7defec7cf6b4c/numpy-2.4.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:4f5bc96d35d94e4ceab8b38a92241b4611e95dc44e63b9f1fa2a331858ee3507", size = 5204601, upload-time = "2026-05-15T20:22:56.257Z" },
{ url = "https://files.pythonhosted.org/packages/07/ea/627fadd11959b3c7759008f34c92a35af8ff942dd8284a66ced648bbe516/numpy-2.4.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:4bb33e900ee81730ad77a258965134aa8ceac805124f7e5229347beda4b8d0aa", size = 6551360, upload-time = "2026-05-15T20:22:58.334Z" },
{ url = "https://files.pythonhosted.org/packages/a1/47/0728b986b8682d742ff68c16baa5af9d185484abfc635c5cc700f44e62be/numpy-2.4.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32f8f852273ef32b291201ac2a2c97629c4a1ee8632bb670e3443eaa09fc2e72", size = 15671157, upload-time = "2026-05-15T20:23:01.081Z" },
{ url = "https://files.pythonhosted.org/packages/d1/0b/b905ae82d9419dc38123523862db64978ca2954b69609c3ae8fdaca1084c/numpy-2.4.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685681e956fc8dcb75adc6ff26694e1dfd738b24bd8d4696c51ca0110157f912", size = 16645703, upload-time = "2026-05-15T20:23:04.358Z" },
{ url = "https://files.pythonhosted.org/packages/5f/24/e27fc3f5236b4118ed9eed67111675f5c61a07ea333acec87c869c3b359d/numpy-2.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f64dd84b277a737eb59513f6b9bb6195bf41ab11941ef15b2562dbab43fa8ef", size = 17021018, upload-time = "2026-05-15T20:23:07.021Z" },
{ url = "https://files.pythonhosted.org/packages/d3/a7/9041af38d527ab80a06a93570a77e29425b41507ad41f6acf5da78cfb4a4/numpy-2.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b42d9496f79e3a728192f05a42d86e36163217b7cdecb3813d0028a0aa6b72d7", size = 18368768, upload-time = "2026-05-15T20:23:09.44Z" },
{ url = "https://files.pythonhosted.org/packages/49/82/326a014442f32c2663434fd424d9298791f47f8a0f17585ad60519a5606e/numpy-2.4.5-cp312-cp312-win32.whl", hash = "sha256:86d980970f5110595ca14855768073b08585fc1acc36895de303e039e7dee4a5", size = 5962819, upload-time = "2026-05-15T20:23:11.631Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/cbf5d391b0b3a5e8cad264603e2fae256b0bde8ce43566b13b78faedc659/numpy-2.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:3333dba6a4e611d666f69e177ba8fe4140366ff681a5feb2374d3fd4fff3acb6", size = 12321621, upload-time = "2026-05-15T20:23:14.305Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d0/0f18909d9bc37a5f3f969fc737d2bb5df9f2ff295f71b467e6f52a0d6c4e/numpy-2.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:4593d197270b894efeb538dcbe227e4bcf1c77f88c4c6bf933ead812cfaa4453", size = 10221430, upload-time = "2026-05-15T20:23:16.887Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a4/fb50657c7cab297bf34edcd60a074cb0647f61771430d6363575274160fe/numpy-2.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ef248460b645c102026b82337cc4e88231909c66dd77b59ec6d6cac7e44f277", size = 16684760, upload-time = "2026-05-15T20:23:19.436Z" },
{ url = "https://files.pythonhosted.org/packages/3e/43/87e731299b9408eda705b3b9cb31c7bceb9347d2af9cbb16b2b1e4b5bc0f/numpy-2.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4603622bdcdbf8dccb1d9d5b21d16a7aa4e473ae6c8e14048d846fd4ca2907a0", size = 14694117, upload-time = "2026-05-15T20:23:21.832Z" },
{ url = "https://files.pythonhosted.org/packages/a9/c7/0b2bb8acea222e9dd6e582afc2bc553b89b8833cbdccc68e68f050fb31f8/numpy-2.4.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6c18d49c67689c562854b53fdc433b93e47c12952aa6fa6d59f185e1a5992419", size = 5199141, upload-time = "2026-05-15T20:23:24.066Z" },
{ url = "https://files.pythonhosted.org/packages/39/60/b6972b5d47033d90000f0097c81a98b9486589a2d7003bf725bff275cb0d/numpy-2.4.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b1c663ddc641f4192e90511bec61a09bc231e3bbdb996cdc6edbcaa0e528d685", size = 6546954, upload-time = "2026-05-15T20:23:26.099Z" },
{ url = "https://files.pythonhosted.org/packages/c1/e9/ed667cb12c11ca0adde431f685d3a5dd78e6f78b27228c581c8415198e9e/numpy-2.4.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93793222b524f692f12b2f8752ce8b1d9d9125b2bfd5dbf0fb69c92c5e1ce86c", size = 15669430, upload-time = "2026-05-15T20:23:28.147Z" },
{ url = "https://files.pythonhosted.org/packages/44/e5/679f6ffeb01294b0008e5ada4a113cb47617bc0e1819a529fd7973c6d7f4/numpy-2.4.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1616bde34b2bcba2fa9bde06217ce00da4f3d1bdfb264d54525a99e8fe170d83", size = 16633390, upload-time = "2026-05-15T20:23:31.622Z" },
{ url = "https://files.pythonhosted.org/packages/36/46/42bfffc9a780ec902ccd7470d3219192ee82b7b442710307dd85b4d121b0/numpy-2.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09d7d97da1c2c62f4818b3e150a57572ff8dcf1cf5ac501aac832ffd4ebd9566", size = 17020709, upload-time = "2026-05-15T20:23:34.08Z" },
{ url = "https://files.pythonhosted.org/packages/44/00/3e840bfee0cc6cec22209f2c97057f26eeb30de031e4933b4dfc0395416c/numpy-2.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d68d0b355ab2e39fe0de59001d7151dfdbbb880ef67baeed806661e03df5097", size = 18357818, upload-time = "2026-05-15T20:23:36.965Z" },
{ url = "https://files.pythonhosted.org/packages/72/cb/3447b400b9da84134575486f0f656541559b00d4b262477bce9b678bbca8/numpy-2.4.5-cp313-cp313-win32.whl", hash = "sha256:fe28b64777ddfa0eca9b5f51474034ebe3dcb8324f48f27b28f479085673ae33", size = 5961114, upload-time = "2026-05-15T20:23:39.586Z" },
{ url = "https://files.pythonhosted.org/packages/28/f9/a90d2220ffcdc0798f5d55bb5d5463cd6254ec9ef43f384dae80217d7a2f/numpy-2.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:fb4a6c9c537d6ccec9cc4aeae4261bd3cc79b070c67ddc0646f5b1c07fddde42", size = 12318553, upload-time = "2026-05-15T20:23:41.436Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c9/96f531fb3234545315152d34efdf3de7daee81254448447eb619e8d16967/numpy-2.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d7df2da2e7ea0624a43aa368104b3a3ce14aae98ad4bb2c9a93fecef76f1c97", size = 10222200, upload-time = "2026-05-15T20:23:43.681Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f4/a291caab5a3c520babf93ff77c54fd5fdb1ebbc3296cee2eb2146ce773b1/numpy-2.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:2a235607a18df941760a695927051af4b1cd5d3ee85840d0e2af816785771feb", size = 14821438, upload-time = "2026-05-15T20:23:45.911Z" },
{ url = "https://files.pythonhosted.org/packages/85/26/13dbb1159b864370568e7309063fd72667984df89db74e9caeb175d067c7/numpy-2.4.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:58dcf64969d870f36bc7fbd557d2617e997db7dc06261b6e3327148ea460d0a4", size = 5326663, upload-time = "2026-05-15T20:23:48.18Z" },
{ url = "https://files.pythonhosted.org/packages/7c/99/d233408072a0e019e2288e27edd23f7d572ccd4a73d1539baa3270ede85d/numpy-2.4.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:235f54b0156274d8fa3155db3ed6d2f401c7e8f3367c90db0a12f02a58fde6ed", size = 6646874, upload-time = "2026-05-15T20:23:49.856Z" },
{ url = "https://files.pythonhosted.org/packages/c5/00/eeb6f193dfe767725e952e0464f3e51f44145c5dd261cd7389aa36ac0713/numpy-2.4.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3b5bb65437a3555c648e706475db01c645559ca80dc8b03e4f202ea757e0d6", size = 15728147, upload-time = "2026-05-15T20:23:51.655Z" },
{ url = "https://files.pythonhosted.org/packages/e5/c9/b8ed039f1fde1b13a8807c893e7e2f9432a379f4d6401edecf0028da5b2c/numpy-2.4.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f09a7e5f017d7098c66522097c96257411c9620c0926212200d66bc8cee3976", size = 16681770, upload-time = "2026-05-15T20:23:53.933Z" },
{ url = "https://files.pythonhosted.org/packages/11/5b/0198ef6cb7016eca6d895d392106012138127fab23f46637e76d5e25c9f5/numpy-2.4.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:993a88d8fdd8554466a8765cd8bacd97ba56b70ca6b0a04bcdca77f5afed4222", size = 17086218, upload-time = "2026-05-15T20:23:56.646Z" },
{ url = "https://files.pythonhosted.org/packages/f0/fe/8821f3cfc660ae84c92ee158505941874b62c56a42e035a41425228cd8cf/numpy-2.4.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:84f58bed609b5669f5ad3d597901a4f1f86ee5b3c3708aaa55f05b4fe6e0f656", size = 18403542, upload-time = "2026-05-15T20:23:59.173Z" },
{ url = "https://files.pythonhosted.org/packages/0e/00/e64ecaf498865e7b091f57658b2c522503e5d1b70e43b807f5f8247e1d88/numpy-2.4.5-cp313-cp313t-win32.whl", hash = "sha256:7200c58f3f933ca61e66346667dcc8510bb111995e9ce15398a731e6a4afa4bb", size = 6084903, upload-time = "2026-05-15T20:24:01.506Z" },
{ url = "https://files.pythonhosted.org/packages/20/c0/354997dedaf74e8311c2cf9a6027b476fd8d424cb92189cc0ae2b25f501c/numpy-2.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c26c71080d35db5002102f5d9ff614d45de02aa1f7802943e691e063e5ee93bc", size = 12458420, upload-time = "2026-05-15T20:24:03.735Z" },
{ url = "https://files.pythonhosted.org/packages/66/dc/917ee5ea4a31ca1a6e4c9a85386477efa318dcc60db257c5ef4adda096c1/numpy-2.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:2caa576d1707b275cba1aeb60a5c50daa6fa2a3f28ecb08123bc05fd439005db", size = 10291826, upload-time = "2026-05-15T20:24:06.535Z" },
{ url = "https://files.pythonhosted.org/packages/ca/c1/3be0bf102fc17cff5bd142e3be0bfffabec6fa46da0a462396c76b0765d0/numpy-2.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:889ca2c072315de638a5194a772aa1fa2df92bdd6175f6a222d4784040424b61", size = 16683455, upload-time = "2026-05-15T20:24:08.988Z" },
{ url = "https://files.pythonhosted.org/packages/e8/3e/0742d724901fa36bc54b338c6e62e463a7601180da896aa44978f0adf004/numpy-2.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:89e89304fb1f8c3f0ecfa4a7d48f311dd79771336a940e920159d643d1307e77", size = 14704577, upload-time = "2026-05-15T20:24:11.542Z" },
{ url = "https://files.pythonhosted.org/packages/25/1c/196c610ff4c6782d697ba780ebdc1616be143213701bf22c1a270f3bf7dd/numpy-2.4.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:144fcc5a3a17679b2b82543b4a2d8dd29937230a7af13232b5f753872feb6361", size = 5209756, upload-time = "2026-05-15T20:24:14.091Z" },
{ url = "https://files.pythonhosted.org/packages/52/c0/23fb1bc506f774e03db66219a2830e720f4d3dbcaaddf855a7ff7bb6d96f/numpy-2.4.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:398bb16772b265b9fa5c07b07072646ea97137c10ffb62a9a087b277fc825c29", size = 6543937, upload-time = "2026-05-15T20:24:16.223Z" },
{ url = "https://files.pythonhosted.org/packages/9f/49/db4662c26e68520afcc84d672a6f9f5294063dee0e57a46d61afdaa7f9ed/numpy-2.4.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb352e7b8876da1249e72254736d6c58c505fa4e58a3d7e30efca241ca9ca9ce", size = 15685292, upload-time = "2026-05-15T20:24:17.978Z" },
{ url = "https://files.pythonhosted.org/packages/43/80/1315439acedd8398319bac177d6de3d48ab39c62cc0c810f74f0a9a73996/numpy-2.4.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7341b08ff8124d7353939778e2707b8732d03c78c1c30e0815aba2dacbe1245a", size = 16638528, upload-time = "2026-05-15T20:24:20.478Z" },
{ url = "https://files.pythonhosted.org/packages/56/81/364388600932618fe735d97fdd2437cb8dd87a23377ac11d8b9d5db098b7/numpy-2.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:deb01226f012539f3945261ffe1c10aec081a0fa0a5c925419933c70f3ae2d23", size = 17036709, upload-time = "2026-05-15T20:24:22.949Z" },
{ url = "https://files.pythonhosted.org/packages/32/4a/a1185b18a94a6d9587e54b437e7d0ba36ecf6e614f1bea03f5249912c64e/numpy-2.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d888bdf7335f76878c3c7b264ac1ff089863e211ec81249f9fb5795c2183dc25", size = 18363254, upload-time = "2026-05-15T20:24:25.402Z" },
{ url = "https://files.pythonhosted.org/packages/b9/8e/95c1d2ed15ae97750ede8c8a0ac487c9c01207afff430f47078b1d9d7dc5/numpy-2.4.5-cp314-cp314-win32.whl", hash = "sha256:15f90d1256e9b2320aff24fde44815b787ab6d7c49a1a11bfd8138b321c5f080", size = 6010184, upload-time = "2026-05-15T20:24:27.852Z" },
{ url = "https://files.pythonhosted.org/packages/aa/92/d063df4d63d988b20d881856c74df76c0c1786229bb870f3a52af0981d4d/numpy-2.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4bd2cd4ef9c0afa87de73723c0a33c0edff62143e1432917458e26d3d195d87f", size = 12450344, upload-time = "2026-05-15T20:24:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/3d/64/c0ae481f7c3b2f85869bcd8fc5d30aa7c96b394162eef9c9315957f115c5/numpy-2.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:db304568c650e9d7039744d3575d0d287754debb2057d7c7b8cdfdc2c487a957", size = 10495674, upload-time = "2026-05-15T20:24:32.352Z" },
{ url = "https://files.pythonhosted.org/packages/57/89/c5a4c677acf17aa50ba09a15e61812f90baac42bb6ca38d112e005858351/numpy-2.4.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de2883e0d2c63eae1bab1a84b390dca74aabb3d20ea1f5d58f360853c83abf3", size = 14824078, upload-time = "2026-05-15T20:24:34.669Z" },
{ url = "https://files.pythonhosted.org/packages/e7/52/57e7144284f6b51ba93523e495ff239260b1ecd5257e3700a436332e5688/numpy-2.4.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:06760fe73ae5005008748d182de612c733542af3cde063d532cd2127561b27be", size = 5329246, upload-time = "2026-05-15T20:24:36.957Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b3/09dbce80fd4a7db4318f2fc01eec0ae76f29306442b5a32d4b811d082cdf/numpy-2.4.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:4b51a01745cb04cc19278482207444b4d30728ce91c28d27a3bfae5fc6ff24c7", size = 6649877, upload-time = "2026-05-15T20:24:38.861Z" },
{ url = "https://files.pythonhosted.org/packages/30/c2/dbdb23e82d540b757690ef13f011c386fca6a63848eec6136baf8ce7cbed/numpy-2.4.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a05636d7937d0936f271e5ba957fa8d746b5be3c2025caa1a2508f4fe521d40", size = 15730534, upload-time = "2026-05-15T20:24:41.168Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bd/68f6e9b3c20decf40ac06708a7b506757e3a8588efed32988d1b747316be/numpy-2.4.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b86f56048ed09c3bbe48962a7dff077c2fd3274f8cf981800f3b38eac49cc3", size = 16679741, upload-time = "2026-05-15T20:24:44.874Z" },
{ url = "https://files.pythonhosted.org/packages/39/1d/0fcac0b6b4ea1b50ca8fca05a34bed5c8d56e34c1cb5ffb04cf76109ac3c/numpy-2.4.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:130d58151c4db23e9fa860b84784e219a3aa3e030acc88a493ea37006c4dfd4c", size = 17085598, upload-time = "2026-05-15T20:24:47.603Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e8/a472b2564cf6cc498ad7aa9741d9832648221b8ab8cc0dbef41faa248ede/numpy-2.4.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d475afc8cbe935ff5944f753d863bba774d7f4e1feaaa4102901e3e053ca5963", size = 18403855, upload-time = "2026-05-15T20:24:50.474Z" },
{ url = "https://files.pythonhosted.org/packages/b9/a4/da82196f8cc4bd28ecf17bd57008c84f3d4696caf06753d9bad45e4ad749/numpy-2.4.5-cp314-cp314t-win32.whl", hash = "sha256:27f4a6dc26353a860b348961b9aa9e009835688b435cfa105e873b8dc2c726f5", size = 6156900, upload-time = "2026-05-15T20:24:53.134Z" },
{ url = "https://files.pythonhosted.org/packages/98/31/860959b91a73d9a085006554fa3850da51a7ffab64599bac5097243438ab/numpy-2.4.5-cp314-cp314t-win_amd64.whl", hash = "sha256:76ac6e90f5e226011c88f9b7040a4bcae612518bc7e9adc127e697a13b28ad1a", size = 12638906, upload-time = "2026-05-15T20:24:55.009Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2a/bbd3097913083ad07c0f28fc9629666221fc18923e17ce97ae22a5dccdd6/numpy-2.4.5-cp314-cp314t-win_arm64.whl", hash = "sha256:7c392e2c1bf596701d3c6832be7567eab5d5b0a13865036c33365ee097d37f8b", size = 10565875, upload-time = "2026-05-15T20:24:57.425Z" },
{ url = "https://files.pythonhosted.org/packages/fc/5d/9a644cfb841bc76b584afc3af1708b3bf6c5cb51fc84a7008246cd93b7b7/numpy-2.4.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6bf0bfc1c2e1db972e30b6cd3d4861f477f3af908b27799b239dc3cbe3eb4b95", size = 16847544, upload-time = "2026-05-15T20:24:59.746Z" },
{ url = "https://files.pythonhosted.org/packages/56/8f/4fe5e3ba76d858dae1fe79078818c0520447335be0082c0dedf82719cc08/numpy-2.4.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:73d664413fb97229149c4711ef56531a6fe8c15c1c2626b0bbe497b84c287e70", size = 14889039, upload-time = "2026-05-15T20:25:03.179Z" },
{ url = "https://files.pythonhosted.org/packages/8e/6f/79f195abf922ecc43e7d0eb6cc969462a71b524a35bcd1fa26b4a1d7406a/numpy-2.4.5-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:b35bee5ef99e8d227a07829bee2e864fcb65f7c157646fcd8ec8b4b45dd8b88f", size = 5394106, upload-time = "2026-05-15T20:25:05.659Z" },
{ url = "https://files.pythonhosted.org/packages/58/6f/79cd6247205802bcbd10b40ea087e20ded526e10e9be224d34de832b216e/numpy-2.4.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:02981d0fc9f9ce147643d552966d47f329a02f7ecb3b113e84207242f20dfa83", size = 6708718, upload-time = "2026-05-15T20:25:08.071Z" },
{ url = "https://files.pythonhosted.org/packages/d7/22/5f378a9d4633c98f28c4709d4144b1a4630c5c09e109d2e781e2d26c8fe1/numpy-2.4.5-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e63caf31a1df06338ae63d999f7a33a675ced62eea9c9b02db4b1c1f45cff38", size = 15798292, upload-time = "2026-05-15T20:25:10.689Z" },
{ url = "https://files.pythonhosted.org/packages/63/1c/cec582febef798c99888892d92dc1d28dfe29cb427c41f44d13d0dec208f/numpy-2.4.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8fc52b85a7b45e474be53eddf08e006d22e381a4e41bcde8e4aa08da0e7d198", size = 16747406, upload-time = "2026-05-15T20:25:13.879Z" },
{ url = "https://files.pythonhosted.org/packages/b1/dc/d358a16a6fec86cf736b8fbe67386044b3fa2aded1a80cff90e836799301/numpy-2.4.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:40c71d50a4da1a7c317af419461052d3911a5770bfc5fd55baf52cc45e7a2c20", size = 12504085, upload-time = "2026-05-15T20:25:16.667Z" },
]
[[package]]
name = "openai"
version = "2.37.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/32/50/5901f01ef14e6c27788beb91e54fef5d6204fb5fb9e97402fc8a14de2e32/openai-2.37.0.tar.gz", hash = "sha256:f4bc562cc5f3a43d40d678105572d9d44765f6e0f50c125f63055419b72f4bd9", size = 754706, upload-time = "2026-05-15T22:30:35.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/4c/bce61680d0699a78a405fd9a67989b175ba020590428831aab2ab1d2be7c/openai-2.37.0-py3-none-any.whl", hash = "sha256:814633888b8f3b1ffd6615697c6e4ef93632d08b7c2e28c8c5ef3556e5a10107", size = 1303238, upload-time = "2026-05-15T22:30:32.767Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
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-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "ruff"
version = "0.15.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
{ url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
{ url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
{ url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
{ url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
{ url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
{ url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
{ url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
{ url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
{ url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
{ url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
]
[[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 = "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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]