Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f53fb32a4 | |||
| 376b8114ad | |||
| 89988da472 | |||
| b700ac3808 | |||
| 6716245a99 | |||
| a900110fe4 | |||
| 794baf2a96 | |||
| 64429b19e6 | |||
| f1471cde84 | |||
| b4613ac30c | |||
| 01d4811717 | |||
| ceb60119fb | |||
| d09425c37b | |||
| 6bb800f5f8 | |||
| 970907cf1b | |||
| 55093a8437 | |||
| 41971de5bb | |||
| 4b21082959 | |||
| 098aefee7c | |||
| 2da58a13c7 | |||
| d4fd393f52 | |||
| 193bf814ec | |||
| 49f792f20c | |||
| fa4dd46cfc | |||
| 8554249421 | |||
| fe86759cfd | |||
| 6a20d3981f | |||
| 30f6c1a3da | |||
| d5d7ea3469 | |||
| e45cdbe54e | |||
| a2f0952a62 | |||
| 5ed3fd0982 | |||
| 8c914906e5 | |||
| 4acaddfd12 | |||
| fc85557f76 | |||
| 320bf4439b | |||
| cc014d0a73 | |||
| ebe3e27095 | |||
| b0f42ba86e | |||
| d9281a1816 | |||
| a83405beb1 | |||
| 734999e8bb | |||
| a087de9790 | |||
| 0a091fc42c | |||
| cb00474ab3 | |||
| 5492d9c0c5 | |||
| b5fe47074a | |||
| a19231abd0 | |||
| e5e32f2683 | |||
| 180af9eb63 | |||
| 94fb091e59 |
@@ -0,0 +1,52 @@
|
|||||||
|
# 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
|
||||||
+85
-9
@@ -1,11 +1,87 @@
|
|||||||
# Local backend (Ollama) — used by default for most calls.
|
# ====================================
|
||||||
LOCAL_BASE_URL=http://localhost:11434
|
# 🌌 GLOBAL LYRA CONFIG
|
||||||
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
|
|
||||||
|
|
||||||
# Where Lyra stores her memory.
|
# ====================================
|
||||||
LYRA_DB_PATH=data/lyra.db
|
# 🤖 LLM BACKEND OPTIONS
|
||||||
|
# ====================================
|
||||||
|
# Services choose which backend to use from these options
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# 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
|
||||||
|
# ============================================================================
|
||||||
+74
-28
@@ -1,37 +1,83 @@
|
|||||||
# Python
|
# =============================
|
||||||
|
# 📦 General
|
||||||
|
# =============================
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyc
|
||||||
*.egg-info/
|
*.log
|
||||||
.pytest_cache/
|
/.vscode/
|
||||||
.ruff_cache/
|
.vscode/
|
||||||
.mypy_cache/
|
# =============================
|
||||||
build/
|
# 🔐 Environment files (NEVER commit secrets!)
|
||||||
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.example
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
|
||||||
# Local data
|
# BUT track .env.example templates (safe to commit)
|
||||||
data/
|
!.env.example
|
||||||
|
!**/.env.example
|
||||||
|
|
||||||
|
# Ignore backup directory
|
||||||
|
.env-backups/
|
||||||
|
|
||||||
|
# =============================
|
||||||
|
# 🐳 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/
|
||||||
|
|
||||||
# IDE / OS
|
# Temporary and cache files
|
||||||
.vscode/
|
.cache/
|
||||||
.idea/
|
*.tmp
|
||||||
.DS_Store
|
*.temp
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
#lyra Stuff
|
|
||||||
/core/relay/sessions/
|
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# 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
@@ -0,0 +1,124 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
@@ -1,21 +1,483 @@
|
|||||||
# Lyra
|
# Project Lyra
|
||||||
|
|
||||||
A persistent, autonomous AI assistant. From-scratch rewrite of an earlier attempt.
|
**A streamlined AI conversation system with intelligent summarization and memory**
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Status
|
**Current Version:** v1.0.0 (2026-02-23)
|
||||||
|
|
||||||
Pre-MVP. Building toward the smallest useful version: chat with persistent memory across sessions.
|
---
|
||||||
|
|
||||||
## Setup
|
## Mission Statement
|
||||||
|
|
||||||
```bash
|
Project Lyra is designed to be your **external brain**. Unlike typical chatbots that forget everything, Lyra:
|
||||||
uv sync
|
- **Captures** everything you say in raw form
|
||||||
cp .env.example .env
|
- **Summarizes** conversations at multiple granularities (L1-L30)
|
||||||
# fill in ANTHROPIC_API_KEY and point LOCAL_BASE_URL at your Ollama
|
- **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) │
|
||||||
|
└─────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
### Components
|
||||||
|
|
||||||
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.
|
**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
|
||||||
|
# 1. Create .env file with your LLM backend
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your LLM URLs and API keys
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 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"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// 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 it’s 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
Generated
+5477
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
// 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}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// 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();
|
||||||
@@ -0,0 +1,927 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,909 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# ====================================
|
||||||
|
# 🧠 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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# LLM module - provides LLM routing and backend abstraction
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# 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.")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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 "")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# 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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Utilities module
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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}")
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
├── 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]
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""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
@@ -1,44 +0,0 @@
|
|||||||
"""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
@@ -1,133 +0,0 @@
|
|||||||
"""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
|
|
||||||
]
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
[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"]
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# ====================================
|
||||||
|
# 📚 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
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 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()}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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)")
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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 (~1500–2000 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)")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/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.
|
||||||
@@ -1,562 +0,0 @@
|
|||||||
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" },
|
|
||||||
]
|
|
||||||
Reference in New Issue
Block a user