Merge branch 'main' into docs/paperclip-integration-refresh

This commit is contained in:
adavyas 2026-04-08 16:33:35 -07:00 committed by GitHub
commit e083d5c7ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 3750 additions and 1465 deletions

View File

@ -57,160 +57,135 @@ AUTH_USE_AUTH=false
# AUTH_JWT_SECRET=your-secret-key-here
# =============================================================================
# LLM API Keys (REQUIRED for full functionality)
# LLM Provider (REQUIRED)
# =============================================================================
# OpenAI API key for embeddings
LLM_OPENAI_API_KEY=your-openai-api-key-here
# Anthropic API key for dialectic and deriver functionality
LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here
# Google API key for summarization (if using Gemini)
# LLM_GEMINI_API_KEY=your-google-api-key-here
# Groq API key for query generation (if using Groq)
# LLM_GROQ_API_KEY=your-groq-api-key-here
# Base URL for OpenAI Compatible Requests if you want to use a different provider
# LLM_OPENAI_COMPATIBLE_BASE_URL=
# LLM_OPENAI_COMPATIBLE_API_KEY=
# Separate vLLM endpoint (for local models)
# LLM_VLLM_API_KEY=
# LLM_VLLM_BASE_URL=
# =============================================================================
# LLM Configuration
# =============================================================================
# Global LLM settings
# Honcho uses LLMs for memory extraction, summarization, dialectic chat, and
# dream consolidation. The server will fail to start without a provider configured.
#
# Quick start: uncomment the two lines below, set your endpoint and API key,
# then uncomment the provider/model lines in each feature section below.
# Any OpenAI-compatible endpoint works (OpenRouter, Together, Fireworks, etc.).
# Models must support tool calling (function calling).
#
LLM_OPENAI_COMPATIBLE_BASE_URL=https://openrouter.ai/api/v1
LLM_OPENAI_COMPATIBLE_API_KEY=your-api-key-here
#
# Provider options for each feature: custom, vllm, google, anthropic, openai, groq
# "custom" routes through the OpenAI-compatible endpoint above.
# Model name format depends on your provider (e.g., OpenRouter: vendor/model-name).
#
# ---- Alternative: vLLM self-hosted ------------------------------------------
# LLM_VLLM_BASE_URL=http://localhost:8000/v1
# LLM_VLLM_API_KEY=not-needed
#
# ---- Alternative: direct vendor keys (no endpoint needed) -------------------
# LLM_GEMINI_API_KEY=
# LLM_ANTHROPIC_API_KEY=
# LLM_OPENAI_API_KEY=
# LLM_GROQ_API_KEY=
#
# ---- General LLM settings ---------------------------------------------------
# Embedding provider — defaults to openai (requires LLM_OPENAI_API_KEY).
# Set to openrouter to route embeddings through your custom endpoint instead.
LLM_EMBEDDING_PROVIDER=openrouter
# LLM_DEFAULT_MAX_TOKENS=2500
# LLM_EMBEDDING_PROVIDER=openai
# LLM_MAX_TOOL_OUTPUT_CHARS=10000 # Max chars for tool output (~2500 tokens)
# LLM_MAX_MESSAGE_CONTENT_CHARS=2000 # Max chars per message in tool results
# LLM_MAX_TOOL_OUTPUT_CHARS=10000
# LLM_MAX_MESSAGE_CONTENT_CHARS=2000
# =============================================================================
# Deriver (Background Worker) Settings
# Deriver (Background Worker)
# =============================================================================
# DERIVER_ENABLED=true
DERIVER_PROVIDER=custom
DERIVER_MODEL=your-model-here # e.g. google/gemini-2.5-flash
# DERIVER_THINKING_BUDGET_TOKENS=1024 # gt=0 required; omit for non-thinking models
# DERIVER_WORKERS=1
# DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
# DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
# DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # 30 days
# DERIVER_PROVIDER=google
# DERIVER_MODEL=gemini-2.5-flash-lite
# DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000
# DERIVER_TEMPERATURE=
# DERIVER_DEDUPLICATE=true
# DERIVER_MAX_OUTPUT_TOKENS=4096
# DERIVER_THINKING_BUDGET_TOKENS=1024
# DERIVER_LOG_OBSERVATIONS=false
# DERIVER_MAX_INPUT_TOKENS=23000
# DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100
# DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024
# DERIVER_FLUSH_ENABLED=false # Bypass batch token threshold, process work immediately
# DERIVER_BACKUP_PROVIDER=
# DERIVER_BACKUP_MODEL=
# DERIVER_FLUSH_ENABLED=false
# =============================================================================
# Peer Card Configuration
# Peer Card
# =============================================================================
# PEER_CARD_ENABLED=true
# =============================================================================
# Dialectic Settings
# Dialectic
# =============================================================================
# Global dialectic settings
# DIALECTIC_MAX_OUTPUT_TOKENS=8192
# DIALECTIC_MAX_INPUT_TOKENS=100000
# DIALECTIC_HISTORY_TOKEN_LIMIT=8192
# DIALECTIC_SESSION_HISTORY_MAX_TOKENS=4096
# Per-level settings (reasoning_level parameter in API)
# Each level can have its own provider, model, thinking budget, tool iterations, and max output tokens
# MAX_OUTPUT_TOKENS is optional per level; if not set, uses global DIALECTIC_MAX_OUTPUT_TOKENS
# Minimal level
# DIALECTIC_LEVELS__minimal__PROVIDER=google
# DIALECTIC_LEVELS__minimal__MODEL=gemini-2.5-flash-lite
#
# Per-level provider, model, and tuning:
DIALECTIC_LEVELS__minimal__PROVIDER=custom
DIALECTIC_LEVELS__minimal__MODEL=your-model-here # e.g. google/gemini-2.5-flash
# DIALECTIC_LEVELS__minimal__THINKING_BUDGET_TOKENS=0
# DIALECTIC_LEVELS__minimal__MAX_TOOL_ITERATIONS=1
# DIALECTIC_LEVELS__minimal__MAX_OUTPUT_TOKENS=250 # Reduced output for cost savings
# Low level
# DIALECTIC_LEVELS__low__PROVIDER=google
# DIALECTIC_LEVELS__low__MODEL=gemini-2.5-flash-lite
# DIALECTIC_LEVELS__minimal__MAX_OUTPUT_TOKENS=250
DIALECTIC_LEVELS__low__PROVIDER=custom
DIALECTIC_LEVELS__low__MODEL=your-model-here
# DIALECTIC_LEVELS__low__THINKING_BUDGET_TOKENS=0
# DIALECTIC_LEVELS__low__MAX_TOOL_ITERATIONS=5
# DIALECTIC_LEVELS__low__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
# Medium level
# DIALECTIC_LEVELS__medium__PROVIDER=anthropic
# DIALECTIC_LEVELS__medium__MODEL=claude-haiku-4-5
# DIALECTIC_LEVELS__medium__THINKING_BUDGET_TOKENS=1024
DIALECTIC_LEVELS__medium__PROVIDER=custom
DIALECTIC_LEVELS__medium__MODEL=your-model-here
# DIALECTIC_LEVELS__medium__THINKING_BUDGET_TOKENS=0
# DIALECTIC_LEVELS__medium__MAX_TOOL_ITERATIONS=2
# DIALECTIC_LEVELS__medium__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
# DIALECTIC_LEVELS__medium__TOOL_CHOICE=
# High level
# DIALECTIC_LEVELS__high__PROVIDER=anthropic
# DIALECTIC_LEVELS__high__MODEL=claude-haiku-4-5
# DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=1024
DIALECTIC_LEVELS__high__PROVIDER=custom
DIALECTIC_LEVELS__high__MODEL=your-model-here
# DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=0
# DIALECTIC_LEVELS__high__MAX_TOOL_ITERATIONS=4
# DIALECTIC_LEVELS__high__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
# Max level
# DIALECTIC_LEVELS__max__PROVIDER=anthropic
# DIALECTIC_LEVELS__max__MODEL=claude-haiku-4-5
# DIALECTIC_LEVELS__max__THINKING_BUDGET_TOKENS=2048
DIALECTIC_LEVELS__max__PROVIDER=custom
DIALECTIC_LEVELS__max__MODEL=your-model-here
# DIALECTIC_LEVELS__max__THINKING_BUDGET_TOKENS=0
# DIALECTIC_LEVELS__max__MAX_TOOL_ITERATIONS=10
# DIALECTIC_LEVELS__max__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
# Optional backup per level (must set both or neither):
# DIALECTIC_LEVELS__max__BACKUP_PROVIDER=google
# DIALECTIC_LEVELS__max__BACKUP_MODEL=gemini-2.5-pro
# =============================================================================
# Summary Settings
# Summary
# =============================================================================
# SUMMARY_ENABLED=true
SUMMARY_PROVIDER=custom
SUMMARY_MODEL=your-model-here # e.g. google/gemini-2.5-flash
# SUMMARY_THINKING_BUDGET_TOKENS=512 # gt=0 required; omit for non-thinking models
# SUMMARY_MESSAGES_PER_SHORT_SUMMARY=20
# SUMMARY_MESSAGES_PER_LONG_SUMMARY=60
# SUMMARY_PROVIDER=google
# SUMMARY_MODEL=gemini-2.5-flash
# SUMMARY_MAX_TOKENS_SHORT=1000
# SUMMARY_MAX_TOKENS_LONG=4000
# SUMMARY_THINKING_BUDGET_TOKENS=512
# SUMMARY_BACKUP_PROVIDER=
# SUMMARY_BACKUP_MODEL=
# =============================================================================
# Dream Settings
# Dream
# =============================================================================
# DREAM_ENABLED=true
DREAM_PROVIDER=custom
DREAM_MODEL=your-model-here # e.g. google/gemini-2.5-flash
DREAM_DEDUCTION_MODEL=your-model-here
DREAM_INDUCTION_MODEL=your-model-here
# DREAM_THINKING_BUDGET_TOKENS=8192 # gt=0 required; omit for non-thinking models
# DREAM_DOCUMENT_THRESHOLD=50
# DREAM_IDLE_TIMEOUT_MINUTES=60
# DREAM_MIN_HOURS_BETWEEN_DREAMS=8
# DREAM_ENABLED_TYPES=["omni"]
# DREAM_PROVIDER=anthropic
# DREAM_MODEL=claude-sonnet-4-20250514
# DREAM_MAX_OUTPUT_TOKENS=16384
# DREAM_THINKING_BUDGET_TOKENS=8192
# DREAM_MAX_TOOL_ITERATIONS=20
# DREAM_HISTORY_TOKEN_LIMIT=16384
# DREAM_BACKUP_PROVIDER=
# DREAM_BACKUP_MODEL=
# Specialist models (use same provider as main model)
# DREAM_DEDUCTION_MODEL=claude-haiku-4-5
# DREAM_INDUCTION_MODEL=claude-haiku-4-5
# Dream Surprisal Settings (Tree-based observation sampling for targeted reasoning)
#
# Surprisal sampling (advanced):
# DREAM_SURPRISAL__ENABLED=false
# DREAM_SURPRISAL__TREE_TYPE=kdtree # Options: kdtree, balltree, rptree, covertree, lsh, graph, prototype
# DREAM_SURPRISAL__TREE_K=5 # Number of neighbors for kNN-based trees
# DREAM_SURPRISAL__SAMPLING_STRATEGY=recent # Options: recent, random, all
# DREAM_SURPRISAL__SAMPLE_SIZE=200 # Number of observations to sample for tree building
# DREAM_SURPRISAL__TOP_PERCENT_SURPRISAL=0.10 # Top percentage of observations (0.10 = top 10%)
# DREAM_SURPRISAL__MIN_HIGH_SURPRISAL_FOR_REPLACE=10 # Hybrid mode: min observations to replace standard questions
# DREAM_SURPRISAL__INCLUDE_LEVELS=["explicit","deductive"] # Observation levels to include
# DREAM_SURPRISAL__TREE_TYPE=kdtree
# DREAM_SURPRISAL__TREE_K=5
# DREAM_SURPRISAL__SAMPLING_STRATEGY=recent
# DREAM_SURPRISAL__SAMPLE_SIZE=200
# DREAM_SURPRISAL__TOP_PERCENT_SURPRISAL=0.10
# DREAM_SURPRISAL__MIN_HIGH_SURPRISAL_FOR_REPLACE=10
# DREAM_SURPRISAL__INCLUDE_LEVELS=["explicit","deductive"]
# =============================================================================
# Webhook Settings

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.worktrees/
api/**/*.db
api/data
api/docker-compose.yml

View File

@ -106,7 +106,7 @@ git commit -m "docs(readme): update installation instructions"
### Python Code Style
- Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines
- Use [Black](https://black.readthedocs.io/) for code formatting (we may add this to CI in the future)
- Use [ruff](https://docs.astral.sh/ruff/) for linting and code formatting
- Use type hints where possible
- Write docstrings for functions and classes using Google style docstrings

View File

@ -41,6 +41,7 @@ RUN addgroup --system app && adduser --system --group app && mkdir -p /tmp/uv-ca
COPY --chown=app:app src/ /app/src/
COPY --chown=app:app migrations/ /app/migrations/
COPY --chown=app:app scripts/ /app/scripts/
COPY --chown=app:app docker/ /app/docker/
COPY --chown=app:app alembic.ini /app/alembic.ini
# Copy config files - this will copy config.toml if it exists, and config.toml.example
COPY --chown=app:app config.toml* /app/
@ -51,6 +52,6 @@ USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/openapi.json')" || exit 1
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["fastapi", "run", "--host", "0.0.0.0", "src/main.py"]

View File

@ -162,8 +162,8 @@ Server.
Honcho is developed using [python](https://www.python.org/) and [uv](https://docs.astral.sh/uv/).
The minimum python version is `3.9`
The minimum uv version is `0.4.9`
The minimum python version is `3.10`
The minimum uv version is `0.5.0`
### Setup
@ -221,11 +221,11 @@ Below are the required configurations:
```env
DB_CONNECTION_URI= # Connection uri for a postgres database (with postgresql+psycopg prefix)
# LLM Provider API Keys (at least one required depending on your configuration)
LLM_ANTHROPIC_API_KEY= # API Key for Anthropic (used for dialectic by default)
LLM_OPENAI_API_KEY= # API Key for OpenAI (optional, for embeddings if EMBED_MESSAGES=true)
LLM_GEMINI_API_KEY= # API Key for Google Gemini (used for summary/deriver by default)
LLM_GROQ_API_KEY= # API Key for Groq (used for query generation by default)
# LLM Provider API Keys
LLM_GEMINI_API_KEY= # API Key for Google Gemini (used for deriver, summary, and dialectic minimal/low by default)
LLM_ANTHROPIC_API_KEY= # API Key for Anthropic (used for dialectic medium/high/max and dream by default)
LLM_OPENAI_API_KEY= # API Key for OpenAI (used for embeddings when EMBED_MESSAGES=true)
LLM_GROQ_API_KEY= # API Key for Groq (optional)
```
> Note that the `DB_CONNECTION_URI` must have the prefix `postgresql+psycopg` to
@ -455,14 +455,14 @@ If you have this in `config.toml`:
```toml
[db]
CONNECTION_URI = "postgresql://localhost/honcho_dev"
CONNECTION_URI = "postgresql+psycopg://localhost/honcho_dev"
POOL_SIZE = 10
```
You can override just the connection URI in production:
```bash
export DB_CONNECTION_URI="postgresql://prod-server/honcho_prod"
export DB_CONNECTION_URI="postgresql+psycopg://prod-server/honcho_prod"
```
The application will use the production connection URI while keeping the pool size from config.toml.

View File

@ -55,17 +55,21 @@ EMBEDDING_PROVIDER = "openai"
MAX_TOOL_OUTPUT_CHARS = 10000 # Max chars for tool output (~2500 tokens)
MAX_MESSAGE_CONTENT_CHARS = 2000 # Max chars per message in tool results
# API Keys for LLM providers
# ANTHROPIC_API_KEY = "your-api-key"
# OPENAI_API_KEY = "your-api-key"
# OPENAI_COMPATIBLE_API_KEY = "your-api-key"
# GEMINI_API_KEY = "your-api-key"
# GROQ_API_KEY = "your-api-key"
# OPENAI_COMPATIBLE_BASE_URL = "your-base-url"
# API Keys for LLM providers (set the ones you need)
# GEMINI_API_KEY = "your-api-key" # Default: deriver, summary, dialectic minimal/low
# ANTHROPIC_API_KEY = "your-api-key" # Default: dialectic medium/high/max, dream
# OPENAI_API_KEY = "your-api-key" # Default: embeddings
# GROQ_API_KEY = "your-api-key" # Not used by default
# Separate vLLM endpoint (for local models)
# VLLM_API_KEY = "your-api-key"
# VLLM_BASE_URL = "your-base-url"
# OpenAI-compatible endpoint (OpenRouter, Together, Fireworks, LiteLLM, etc.)
# Set provider to "custom" in feature config to route calls through this endpoint.
# OPENAI_COMPATIBLE_BASE_URL = "https://openrouter.ai/api/v1"
# OPENAI_COMPATIBLE_API_KEY = "your-api-key"
# vLLM endpoint (for self-hosted models)
# Set provider to "vllm" in feature config to route calls through this endpoint.
# VLLM_BASE_URL = "http://localhost:8000/v1"
# VLLM_API_KEY = "not-needed"
# Deriver settings
[deriver]

View File

@ -1,6 +1,15 @@
# Honcho Docker Compose
#
# Usage:
# cp docker-compose.yml.example docker-compose.yml
# cp .env.template .env # edit with your provider config
# docker compose up -d --build
#
# By default, ports are bound to 127.0.0.1 (localhost only).
# For development, uncomment the source mounts and monitoring services below.
services:
api:
image: honcho:latest
build:
context: .
dockerfile: Dockerfile
@ -11,16 +20,20 @@ services:
redis:
condition: service_healthy
ports:
- 8000:8000
volumes:
- .:/app
- venv:/app/.venv
- "127.0.0.1:8000:8000"
# -- Development: mount source for live reload --
# volumes:
# - .:/app
# - venv:/app/.venv
environment:
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
- CACHE_URL=redis://redis:6379/0?suppress=true
- CACHE_ENABLED=true
env_file:
- path: .env
required: false
restart: unless-stopped
deriver:
build:
context: .
@ -31,27 +44,29 @@ services:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- .:/app
- venv:/app/.venv
# -- Development: mount source for live reload --
# volumes:
# - .:/app
# - venv:/app/.venv
environment:
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
- CACHE_URL=redis://redis:6379/0?suppress=true
- METRICS_ENABLED=true
- CACHE_ENABLED=true
env_file:
- path: .env
required: false
restart: unless-stopped
database:
image: pgvector/pgvector:pg15
restart: always
restart: unless-stopped
ports:
- 5432:5432
command: ["postgres", "-c", "max_connections=800"]
- "127.0.0.1:5432:5432"
command: ["postgres", "-c", "max_connections=200"]
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST_AUTH_METHOD=trust
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
@ -61,44 +76,49 @@ services:
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:8.2
restart: always
restart: unless-stopped
ports:
- 6379:6379
- "127.0.0.1:6379:6379"
volumes:
- ./redis-data:/data
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping"]
interval: 5s
timeout: 5s
retries: 5
prometheus:
image: prom/prometheus:v3.2.1
ports:
- 9090:9090
volumes:
- ./docker/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
depends_on:
api:
condition: service_started
grafana:
image: grafana/grafana:11.4.0
ports:
- 3000:3000
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
volumes:
- ./grafana-data:/var/lib/grafana
- ./docker/grafana-datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml:ro
depends_on:
prometheus:
condition: service_started
# -- Development: monitoring stack (uncomment to enable) --
# prometheus:
# image: prom/prometheus:v3.2.1
# ports:
# - "127.0.0.1:9090:9090"
# volumes:
# - ./docker/prometheus.yml:/etc/prometheus/prometheus.yml:ro
# - prometheus-data:/prometheus
# depends_on:
# api:
# condition: service_started
# grafana:
# image: grafana/grafana:11.4.0
# ports:
# - "127.0.0.1:3000:3000"
# environment:
# - GF_SECURITY_ADMIN_USER=admin
# - GF_SECURITY_ADMIN_PASSWORD=admin
# - GF_AUTH_ANONYMOUS_ENABLED=true
# - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
# volumes:
# - ./docker/grafana-datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml:ro
# depends_on:
# prometheus:
# condition: service_started
volumes:
pgdata:
venv:
prometheus-data:
redis-data:
# -- Development: uncomment if using source mounts --
# venv:
# prometheus-data:

View File

@ -144,7 +144,8 @@
"group": "Self-Hosting",
"pages": [
"v3/contributing/self-hosting",
"v3/contributing/configuration"
"v3/contributing/configuration",
"v3/contributing/troubleshooting"
]
},
{

View File

@ -96,14 +96,14 @@ If you have this in `config.toml`:
```toml
[db]
CONNECTION_URI = "postgresql://localhost/honcho_dev"
CONNECTION_URI = "postgresql+psycopg://localhost/honcho_dev"
POOL_SIZE = 10
```
You can override just the connection URI in production:
```bash
export DB_CONNECTION_URI="postgresql://prod-server/honcho_prod"
export DB_CONNECTION_URI="postgresql+psycopg://prod-server/honcho_prod"
```
The application will use the production connection URI while keeping the pool size from config.toml.
@ -149,7 +149,7 @@ LOCAL_METRICS_FILE=metrics.jsonl
DB_CONNECTION_URI=postgresql+psycopg://username:password@host:port/database
# Example for local development
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/honcho
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
# Example for production
DB_CONNECTION_URI=postgresql+psycopg://honcho_user:secure_password@db.example.com:5432/honcho_prod

View File

@ -135,24 +135,21 @@ Download from [postgresql.org](https://www.postgresql.org/download/windows/)
```bash
docker run --name honcho-db \
-e POSTGRES_DB=honcho \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d pgvector/pgvector:pg15
```
### 3. Create Database and Enable Extensions
### 3. Enable Extensions
Connect to PostgreSQL and set up the database:
Connect to PostgreSQL and enable pgvector:
```bash
# Connect to PostgreSQL
psql -U postgres
# Create database and enable extensions
CREATE DATABASE honcho;
\c honcho
# Enable extensions on the default database
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
\q
@ -170,7 +167,7 @@ Edit `.env` with your configuration:
```bash
# Database connection
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/honcho
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
# Optional API keys (required for LLM features)
OPENAI_API_KEY=your-openai-api-key

File diff suppressed because it is too large Load Diff

View File

@ -20,9 +20,9 @@ By the end of this guide, you'll have:
Before you begin, ensure you have the following installed:
### Required Software
- **uv** - Python package manager: `pip install uv` (manages Python installations automatically)
- **uv** - Python package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh` or `brew install uv`
- **Git** - [Download from git-scm.com](https://git-scm.com/downloads)
- **Docker** (optional) - [Download from docker.com](https://www.docker.com/products/docker-desktop/)
- **Docker** (required for Docker setup, not needed for manual setup) - [Download from docker.com](https://www.docker.com/products/docker-desktop/)
### Database Options
You'll need a PostgreSQL database with the pgvector extension. Choose one:
@ -32,9 +32,48 @@ You'll need a PostgreSQL database with the pgvector extension. Choose one:
- **Railway** - Simple cloud PostgreSQL hosting
- **Your own PostgreSQL server**
## LLM Setup
Honcho uses LLMs for memory extraction, summarization, dialectic chat, and dreaming. The server will **fail to start** without a provider configured.
You need one API key and one model. Any OpenAI-compatible endpoint works — OpenRouter, Together, Fireworks, Ollama, vLLM, or a direct vendor API. Models must support tool calling (function calling).
The `.env.template` has provider and model lines ready for each feature. After copying it to `.env`, you need to set three things:
```bash
# 1. Your endpoint and API key (already uncommented in the template)
LLM_OPENAI_COMPATIBLE_BASE_URL=https://openrouter.ai/api/v1
LLM_OPENAI_COMPATIBLE_API_KEY=sk-or-v1-...
# 2. Replace "your-model-here" everywhere with your model
# (these are spread across the Deriver, Dialectic, Summary, and Dream sections)
DERIVER_MODEL=google/gemini-2.5-flash # e.g. google/gemini-2.5-flash
SUMMARY_MODEL=google/gemini-2.5-flash
DREAM_MODEL=google/gemini-2.5-flash
DIALECTIC_LEVELS__minimal__MODEL=google/gemini-2.5-flash
# ... same for low, medium, high, max
# 3. Everything else is already configured:
# - PROVIDER=custom for all features (routes through your endpoint)
# - THINKING_BUDGET_TOKENS=0 (correct for non-Anthropic models)
# - LLM_EMBEDDING_PROVIDER=openrouter (uses same endpoint for embeddings)
```
Use find-and-replace to swap all `your-model-here` with your chosen model in one step.
<Info>
For recommended model tiers per feature, using multiple providers, or direct vendor API keys, see the [Configuration Guide](./configuration#llm-configuration).
</Info>
<Info>
**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers, interactive provider setup, and Hermes Agent integration.
</Info>
## Docker Setup (Recommended)
The easiest way to get started is using Docker Compose, which handles both the database and Honcho server.
Docker Compose handles the database, Redis, and Honcho server. The compose file **builds the image from source** (there is no pre-built image on Docker Hub). This requires Docker with BuildKit enabled — see [Troubleshooting](./troubleshooting#docker-build-fails-with-permission-errors) if the build fails.
The compose file is production-oriented by default (ports bound to `127.0.0.1`, restart policies, caching enabled). For development, uncomment the source mounts and monitoring services inside the file.
### 1. Clone the Repository
@ -51,45 +90,37 @@ Copy the example environment file and configure it:
cp .env.template .env
```
Edit `.env` and set your API keys (if using LLM features):
```bash
# Optional API keys (required for LLM features)
OPENAI_API_KEY=your-openai-api-key
ANTHROPIC_API_KEY=your-anthropic-api-key
# Database will be created automatically by Docker
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
# Disable auth for local development
AUTH_USE_AUTH=false
```
Edit `.env` and configure your LLM provider — see [LLM Setup](#llm-setup) above. The database connection is set in the compose file. Auth is disabled by default (`AUTH_USE_AUTH=false`).
### 3. Start the Services
```bash
# Copy the example docker-compose file
cp docker-compose.yml.example docker-compose.yml
# Start PostgreSQL and Honcho
docker compose up -d
docker compose up -d --build
```
### 4. Verify It's Working
The first build takes a few minutes (compiling from source). Subsequent starts are fast.
Check that both services are running:
This starts four services: **api** (port 8000), **deriver** (background worker), **database** (PostgreSQL with pgvector, port 5432), and **redis** (port 6379). All ports are bound to `127.0.0.1`. Redis caching is enabled by default.
For development, uncomment the source mount and monitoring sections inside `docker-compose.yml` to enable live reload, Prometheus, and Grafana.
### 4. Verify
Migrations run automatically on startup.
```bash
# Check all containers are running
docker compose ps
```
Test the Honcho API:
```bash
# Health check (confirms the process is up)
curl http://localhost:8000/health
# Check the deriver is processing (look for "polling" or "processing" in logs)
docker compose logs deriver --tail 20
```
You should see a response indicating the service is healthy.
For a full end-to-end test, see [Verify Your Setup](#verify-your-setup) below.
## Manual Setup
@ -134,26 +165,22 @@ Download from [postgresql.org](https://www.postgresql.org/download/windows/)
```bash
docker run --name honcho-db \
-e POSTGRES_DB=honcho \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d pgvector/pgvector:pg15
```
### 3. Create Database and Enable Extensions
### 3. Enable Extensions
Connect to PostgreSQL and set up the database:
Connect to PostgreSQL and enable pgvector:
```bash
# Connect to PostgreSQL
psql -U postgres
# Create database and enable extensions
CREATE DATABASE honcho;
\c honcho
# Enable the pgvector extension on the default database
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
\q
```
@ -165,17 +192,10 @@ Create a `.env` file with your settings:
cp .env.template .env
```
Edit `.env` with your configuration:
Edit `.env` — configure your LLM provider (see [LLM Setup](#llm-setup) above) and set the database connection:
```bash
# Database connection
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/honcho
# Optional API keys (required for LLM features)
OPENAI_API_KEY=your-openai-api-key
ANTHROPIC_API_KEY=your-anthropic-api-key
# Development settings
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
AUTH_USE_AUTH=false
LOG_LEVEL=DEBUG
```
@ -191,11 +211,21 @@ uv run alembic upgrade head
```bash
# Start the development server
fastapi dev src/main.py
uv run fastapi dev src/main.py
```
The server will be available at `http://localhost:8000`.
### 7. Start the Background Worker (Deriver)
In a **separate terminal**, start the deriver background worker:
```bash
uv run python -m src.deriver
```
The deriver is essential for Honcho's core functionality. It processes incoming messages to extract observations, build peer representations, generate session summaries, and run dream consolidation. Without it, messages will be stored but no memory or reasoning will occur.
## Cloud Database Setup
If you prefer to use a managed PostgreSQL service:
@ -206,7 +236,6 @@ If you prefer to use a managed PostgreSQL service:
2. **Enable pgvector extension** in the SQL editor:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
```
3. **Get your connection string** from Settings > Database
4. **Update your `.env` file** with the connection string
@ -227,23 +256,38 @@ Once your Honcho server is running, verify everything is working:
```bash
curl http://localhost:8000/health
# {"status":"ok"}
```
### 2. API Documentation
Note: `/health` only confirms the process is running. It does not check database or LLM connectivity.
### 2. Smoke Test (database + API)
This confirms the database connection, migrations, and API are all working:
```bash
# Create a workspace
curl -s -X POST http://localhost:8000/v3/workspaces \
-H "Content-Type: application/json" \
-d '{"name": "test"}' | python3 -m json.tool
```
If you get back a workspace object with an `id`, your database is connected and migrations ran correctly.
### 3. API Documentation
Visit `http://localhost:8000/docs` to see the interactive API documentation.
### 3. Test with SDK
Create a simple test script:
### 4. Test with SDK
```python
from honcho import Honcho
# Connect to your local instance
client = Honcho(base_url="http://localhost:8000")
client = Honcho(
base_url="http://localhost:8000",
workspace_id="test"
)
# Create a test peer
peer = client.peer("test-user")
print(f"Created peer: {peer.id}")
```
@ -259,8 +303,7 @@ Now that Honcho is running locally, you can connect your applications:
from honcho import Honcho
client = Honcho(
base_url="http://localhost:8000", # Your local instance
api_key="your-api-key" # If auth is enabled
base_url="http://localhost:8000",
)
```
@ -269,56 +312,93 @@ client = Honcho(
import { Honcho } from '@honcho-ai/sdk';
const client = new Honcho({
baseUrl: 'http://localhost:8000', // Your local instance
apiKey: 'your-api-key' // If auth is enabled
baseUrl: 'http://localhost:8000',
});
```
### Next Steps
- **Configure Honcho**: Visit the [Configuration Guide](./configuration) for model tiers, provider options, and tuning
- **Explore the API**: Check out the [API Reference](../api-reference/introduction)
- **Try the SDKs**: See our [guides](../guides) for examples
- **Configure Honcho**: Visit the [Configuration Guide](./configuration) for detailed settings
- **Join the community**: [Discord](https://discord.gg/honcho)
## Troubleshooting
### Common Issues
Running into issues? See the [Troubleshooting Guide](./troubleshooting) for detailed solutions to common problems including:
**Database Connection Errors**
- Ensure PostgreSQL is running
- Verify the connection string format: `postgresql+psycopg://...`
- Check that pgvector extension is installed
- Startup failures (missing API keys, database issues)
- Runtime errors ("An unexpected error occurred" on every request)
- Deriver not processing messages
- Database connection and migration issues
- Docker and Redis problems
**API Key Issues**
- Verify your OpenAI and Anthropic API keys are valid
- Check that the keys have sufficient credits/quota
**Port Already in Use**
- Pass a different port to FastAPI or stop other services using port 8000
**Docker Issues**
- Ensure Docker is running
- Check container logs: `docker compose logs`
- Restart containers: `docker compose down && docker compose up -d`
**Migration Errors**
- Ensure the database exists and pgvector is enabled
- Check database permissions
- Run migrations manually: `uv run alembic upgrade head`
### Getting Help
- **GitHub Issues**: [Report bugs](https://github.com/plastic-labs/honcho/issues)
- **Discord**: [Join our community](https://discord.gg/honcho)
- **Documentation**: Check the [Configuration Guide](./configuration) for detailed settings
**Quick checks:**
- Verify the server is running: `curl http://localhost:8000/health`
- Check logs: `docker compose logs api` (Docker) or check terminal output (manual setup)
- Ensure migrations ran: `uv run alembic upgrade head`
## Production Considerations
When self-hosting for production, consider:
The default compose file is already production-oriented — ports bound to `127.0.0.1`, restart policies, caching enabled.
- **Security**: Enable authentication, use HTTPS, secure your database
- **Scaling**: Use connection pooling, consider load balancing
- **Monitoring**: Set up logging, error tracking, health checks
- **Backups**: Regular database backups, disaster recovery plan
- **Updates**: Keep Honcho and dependencies updated
### Security
- Set `AUTH_USE_AUTH=true` and generate a JWT secret with `python scripts/generate_jwt_secret.py`
- Use HTTPS via a reverse proxy in front of Honcho. Example with Caddy (automatic TLS):
```
honcho.example.com {
reverse_proxy localhost:8000
}
```
Or with nginx:
```nginx
server {
listen 443 ssl;
server_name honcho.example.com;
ssl_certificate /etc/letsencrypt/live/honcho.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/honcho.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
- Secure your database with strong credentials and restrict network access
- The production compose binds PostgreSQL and Redis to `127.0.0.1` only — they are not accessible from the network
### Scaling the Deriver
- Increase `DERIVER_WORKERS` (default: 1) for higher message throughput
- You can also run multiple deriver processes across machines — they coordinate via the database queue
- Monitor deriver logs for processing backlog
### Caching
- The production compose enables Redis caching by default (`CACHE_ENABLED=true`)
- For the development compose, enable manually: `CACHE_ENABLED=true`
- Configure `CACHE_URL` to point to your Redis instance (or use a managed Redis service)
### Database Migrations
- Always run `uv run alembic upgrade head` after updating Honcho before starting the server
- Check current migration status with `uv run alembic current`
### LLM Providers
- Ensure your API keys are configured (see [LLM Setup](#llm-setup))
- For alternative providers or per-feature model overrides, see the [Configuration Guide](./configuration#llm-configuration)
### Monitoring
- Enable Prometheus metrics with `METRICS_ENABLED=true`. The API exposes `/metrics` on port 8000, the deriver on port 9090 (internal to its container — not published to the host by default).
- Enable Sentry error tracking with `SENTRY_ENABLED=true`
- The development compose includes Prometheus (host port 9090) and Grafana (host port 3000) for scraping and dashboards. Uncomment those services to enable them.
### Backups
- Set up regular PostgreSQL backups:
```bash
# One-off backup
docker compose exec database pg_dump -U postgres postgres > backup-$(date +%Y%m%d).sql
# Restore
cat backup.sql | docker compose exec -T database psql -U postgres postgres
```
- Back up your `.env` or `config.toml` configuration files

View File

@ -0,0 +1,299 @@
---
title: 'Troubleshooting'
sidebarTitle: 'Troubleshooting'
description: 'Common issues and solutions when self-hosting Honcho'
icon: 'wrench'
---
This page covers common issues you may encounter when self-hosting Honcho, what causes them, and how to fix them.
## Startup Failures
### Server won't start: "Missing client for ..."
```
ValueError: Missing client for Deriver: google
```
**Cause:** The server validates at startup that all configured LLM providers have API keys. If a provider is referenced in your configuration but the corresponding API key isn't set, the server refuses to start.
**Fix:** Set the API keys for your configured providers. With default configuration, you need:
```bash
LLM_GEMINI_API_KEY=... # Used by deriver, summary, dialectic minimal/low
LLM_ANTHROPIC_API_KEY=... # Used by dialectic medium/high/max, dream
LLM_OPENAI_API_KEY=... # Used by embeddings (when EMBED_MESSAGES=true)
```
See the [LLM Setup](/v3/contributing/self-hosting#llm-setup) section for provider configuration. You can change which providers are used in your `.env` or `config.toml` (see [Configuration Guide](./configuration#llm-configuration)).
### Server won't start: "JWT_SECRET must be set"
```
ValueError: JWT_SECRET must be set if USE_AUTH is true
```
**Cause:** You enabled authentication (`AUTH_USE_AUTH=true`) but didn't provide a JWT secret.
**Fix:** Generate a secret and set it:
```bash
python scripts/generate_jwt_secret.py
# Then set the output as:
AUTH_JWT_SECRET=<generated_secret>
```
Or disable authentication for local development: `AUTH_USE_AUTH=false`
## Runtime Errors
### API returns "An unexpected error occurred" on every request
**Cause:** This is almost always a database issue. The health endpoint (`/health`) will return `{"status": "ok"}` even when the database is unreachable because it doesn't check the database connection. The actual error appears in the server logs.
**Common causes and fixes:**
1. **Database is unreachable** — Check that PostgreSQL is running and the `DB_CONNECTION_URI` is correct
2. **Migrations haven't been run** — The server starts successfully without tables, but every API call will fail. Run:
```bash
uv run alembic upgrade head
```
In Docker:
```bash
docker compose exec api uv run alembic upgrade head
```
3. **pgvector extension not installed** — The `vector` extension must be enabled in your database:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
**How to diagnose:** Check the server logs for the actual error. Look for:
- `sqlalchemy.exc.OperationalError` — database connection issue
- `sqlalchemy.exc.ProgrammingError` with "relation does not exist" — migrations not run
- `psycopg.OperationalError` — connection refused or authentication failed
### Health check passes but API calls fail
The `/health` endpoint is a lightweight check that confirms the server process is running. It does **not** verify:
- Database connectivity
- That migrations have been run
- That LLM providers are reachable
To verify full functionality, try creating a workspace:
```bash
curl -X POST http://localhost:8000/v3/workspaces \
-H "Content-Type: application/json" \
-d '{"name": "test"}'
```
If this succeeds, your database connection and migrations are working.
### Deriver not processing messages
Messages are stored but no observations, summaries, or representations are being generated.
**Common causes:**
1. **Deriver isn't running** — In manual setup, the deriver is a separate process:
```bash
uv run python -m src.deriver
```
In Docker, it starts automatically via `docker compose up`.
2. **Deriver can't reach the database** — Check deriver logs for connection errors. The deriver uses the same `DB_CONNECTION_URI` as the API server.
3. **Missing LLM API key for deriver provider** — By default the deriver uses Google Gemini (`LLM_GEMINI_API_KEY`). Check deriver logs for API errors.
4. **Processing backlog** — With `DERIVER_WORKERS=1` (default), high message volume can cause a backlog. Increase workers:
```bash
DERIVER_WORKERS=4
```
5. **Representation Batch Max** — By default the deriver is set to buffer its operations until there are enough tokens for a given representation in a session. This is set via the `REPRESENTATION_BATCH_MAX_TOKENS` environment variable. If you aren't seeing tasks continue it may be that the batch size is set too high or enough data hasn't flowed into to the session yet. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details
## Alternative Provider Issues
### OpenRouter / custom provider not working
If you set `PROVIDER=custom` but calls fail:
1. **Verify the endpoint and key are set:**
```bash
LLM_OPENAI_COMPATIBLE_BASE_URL=https://openrouter.ai/api/v1
LLM_OPENAI_COMPATIBLE_API_KEY=sk-or-v1-...
```
2. **Check model names match the provider's format.** OpenRouter uses `vendor/model` format (e.g., `anthropic/claude-haiku-4-5`), not the raw model ID.
3. **Ensure your model supports tool calling.** The deriver, dialectic, and dream agents require tool use. Check the provider's model page for tool calling support.
4. **Check server logs for the actual error.** API errors from the upstream provider will appear in Honcho's logs with the HTTP status code and message body.
### vLLM / Ollama not responding
1. **Verify the model server is running** and accessible from the Honcho process (or container):
```bash
curl http://localhost:8000/v1/models # vLLM
curl http://localhost:11434/v1/models # Ollama
```
2. **In Docker**, `localhost` inside a container doesn't reach the host. Use `host.docker.internal` (macOS/Windows) or the host's network IP:
```bash
LLM_VLLM_BASE_URL=http://host.docker.internal:8000/v1
```
3. **Structured output failures** — vLLM's structured output support is limited to certain response formats. If you see JSON parsing errors, check the deriver/dream logs for the raw response.
### Thinking budget errors with non-Anthropic providers
If you see errors like `thinking budget not supported`, `invalid parameter`, or silent failures where agents produce no output, your `THINKING_BUDGET_TOKENS` is likely set to a value > 0 with a provider that doesn't support Anthropic-style extended thinking.
**Fix:** Set `THINKING_BUDGET_TOKENS=0` for every component when using non-Anthropic providers:
```bash
DERIVER_THINKING_BUDGET_TOKENS=0
SUMMARY_THINKING_BUDGET_TOKENS=0
DREAM_THINKING_BUDGET_TOKENS=0
DIALECTIC_LEVELS__minimal__THINKING_BUDGET_TOKENS=0
DIALECTIC_LEVELS__low__THINKING_BUDGET_TOKENS=0
DIALECTIC_LEVELS__medium__THINKING_BUDGET_TOKENS=0
DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=0
DIALECTIC_LEVELS__max__THINKING_BUDGET_TOKENS=0
```
This applies to OpenRouter (with non-Anthropic models), vLLM, Ollama, Groq, Google, and OpenAI providers. Only Anthropic models support the thinking budget parameter.
## Database Issues
### Connection string format
The connection URI **must** use the `postgresql+psycopg` prefix:
```bash
# Correct
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
# Wrong - will fail
DB_CONNECTION_URI=postgresql://postgres:postgres@localhost:5432/postgres
DB_CONNECTION_URI=postgres://postgres:postgres@localhost:5432/postgres
```
### Checking migration status
```bash
# See current migration version
uv run alembic current
# See migration history
uv run alembic history
# Upgrade to latest
uv run alembic upgrade head
```
## Cache & Redis
### Redis is optional
Redis is used for caching when `CACHE_ENABLED=true` (default: `false`). If Redis is unreachable, Honcho **gracefully falls back to in-memory caching** and logs a warning. This means:
- The server and deriver will still start and function normally
- Performance may be reduced under high load without Redis
- You do not need Redis for local development or testing
### Redis connection issues
If you see Redis connection warnings in logs but `CACHE_ENABLED=false`, they can be safely ignored. If you want caching:
```bash
# Start Redis via Docker
docker run -d -p 6379:6379 redis:latest
# Configure Honcho
CACHE_ENABLED=true
CACHE_URL=redis://localhost:6379/0
```
## Docker Issues
### Docker build fails with permission errors
The Honcho Dockerfile uses BuildKit mount syntax and creates a non-root `app` user. Common build failures:
**1. BuildKit not enabled**
The Dockerfile uses `RUN --mount=type=cache` which requires Docker BuildKit. If you see syntax errors during build:
```bash
# Ensure BuildKit is enabled
DOCKER_BUILDKIT=1 docker compose build
```
Or add to your Docker daemon config (`/etc/docker/daemon.json`):
```json
{ "features": { "buildkit": true } }
```
**2. Permission denied during build or at runtime (Linux)**
On Linux, AppArmor or SELinux can block Docker build operations and volume mounts. Symptoms include permission denied errors during `COPY`, `RUN`, or when the container tries to access mounted volumes.
```bash
# Check if AppArmor is blocking Docker
sudo aa-status | grep docker
# Temporarily test without AppArmor (for diagnosis only)
docker compose down
sudo aa-remove-unknown
docker compose up -d
```
For SELinux, add `:z` to volume mounts in `docker-compose.yml`:
```yaml
volumes:
- .:/app:z
```
**3. Volume mount UID mismatch**
The Dockerfile creates a non-root `app` user, but `docker-compose.yml.example` mounts `.:/app` which overlays the container filesystem with host-owned files. The `app` user inside the container may not have permission to read them.
If you see permission errors at runtime (not build time), you can either:
- Run without the source mount (remove `- .:/app` from volumes — the image already contains the code)
- Or fix ownership: `sudo chown -R 100:101 .` (matches the `app` user inside the container)
### Containers start but API fails
1. Check container status: `docker compose ps`
2. Check API logs: `docker compose logs api`
3. Check database logs: `docker compose logs database`
4. Ensure migrations ran: `docker compose exec api uv run alembic upgrade head`
### Port conflicts
If port 8000 is already in use:
```bash
# Check what's using the port
lsof -i :8000
# Or change the port mapping in docker-compose.yml
ports:
- "8001:8000" # Map to a different host port
```
### Rebuilding after code changes
```bash
docker compose build --no-cache
docker compose up -d
```
## Getting Help
If your issue isn't covered here:
- **Check the logs** — most issues are diagnosed from server or deriver logs
- **GitHub Issues** — [Report bugs](https://github.com/plastic-labs/honcho/issues)
- **Discord** — [Join our community](https://discord.gg/plasticlabs)
- **Configuration** — See the [Configuration Guide](./configuration) for all available settings

View File

@ -41,130 +41,82 @@ Hermes exposes four Honcho tools to the agent:
| `honcho_context` | Dialectic Q&A powered by Honcho's LLM. Synthesizes answers from conversation history. |
| `honcho_conclude` | Writes durable facts to Honcho when the user states preferences, corrections, or important context. |
## Two memory layers
When Honcho is enabled, Hermes operates with two layer memory by default (`hybrid`):
**Local session history** -- the immediate transcript for the current chat, thread, or CLI session. Use it for recent turns, short-lived task context, and follow-up questions.
**Honcho memory** -- the semantic, cross-session layer. Use it for user preferences, durable project facts, cross-session continuity, and synthesized peer context.
## Running Honcho locally with Hermes
If you want to point Hermes at a local Honcho instance instead of the hosted API:
### Docker (quickest)
Follow the [Self-Hosting Guide](/v3/contributing/self-hosting) to get Honcho running locally. Once it's up, point Hermes at your instance:
```bash
git clone https://github.com/plastic-labs/honcho.git
cd honcho
cp .env.template .env
cp docker-compose.yml.example docker-compose.yml
hermes memory setup # select "honcho", enter http://localhost:8000 as the base URL
```
Edit `.env`:
```bash
OPENAI_API_KEY=your-openai-api-key
ANTHROPIC_API_KEY=your-anthropic-api-key
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/honcho
AUTH_USE_AUTH=false
```
```bash
docker compose up -d
curl http://localhost:8000/health
```
### Manual
```bash
git clone https://github.com/plastic-labs/honcho.git
cd honcho
uv sync
cp .env.template .env
```
Edit `.env` with a local or cloud Postgres connection string and API keys, then:
```bash
uv run alembic upgrade head
uv run fastapi dev src/main.py
```
Then update `~/.honcho/config.json` to point at your local instance:
Or manually create/edit the config file (checked in order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`):
```json
{
"apiKey": "not-needed-with-auth-disabled",
"baseUrl": "http://localhost:8000",
"hosts": {
"hermes": {
"workspace": "hermes",
"peerName": "your-name",
"enabled": true,
"aiPeer": "hermes",
"memoryMode": "hybrid",
"enabled": true
"peerName": "your-name",
"workspace": "hermes"
}
}
}
```
The `baseUrl` field overrides the default hosted API. With `AUTH_USE_AUTH=false` on the server, the `apiKey` value is ignored but the field must still be present.
For the full list of config fields (`recallMode`, `writeFrequency`, `sessionStrategy`, `dialecticReasoningLevel`, etc.), see the [Hermes memory provider docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory-providers#honcho).
See the full [self-hosting guide](/v3/contributing/self-hosting) for database options, cloud setup, and troubleshooting.
<Info>
**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers and Hermes Agent integration.
</Info>
## Verifying the integration
Steps to test the integration via CLI and agentically by speaking to Hermes agent in natural language.
### 1. Check configuration
### 1. Check status
```bash
hermes honcho status
hermes memory status
```
### 2. Test cross-session recall
This should show Honcho as the active memory provider with your base URL.
In one conversation:
### 2. Store a fact and recall it across sessions
In one conversation, tell Hermes something specific:
```text
Remember that my test phrase is velvet circuit.
My favorite programming language is Rust and I always use dark mode.
```
In a fresh conversation (different thread, new CLI session):
Start a **new session** (different thread, new CLI invocation, or a different platform). Ask:
```text
What is my test phrase?
What do you know about my preferences?
```
If Hermes recalls "velvet circuit" after short-term context is gone, Honcho is working.
If Hermes mentions Rust and dark mode without being told again, cross-session memory is working. The deriver processed your messages, extracted observations, and the dialectic recalled them.
### 3. Test writeback
### 3. Test tool calling directly
Tell Hermes a preference:
Ask Hermes to use a specific Honcho tool:
```text
Remember that I prefer terse answers.
Use your honcho_search tool to find anything you know about me.
```
Wait briefly if writes are asynchronous. Open a fresh conversation:
If Hermes calls the tool and returns results, the full tool pipeline (API connection, vector search, embedding) is functional.
```text
How should you respond to me?
```
If Hermes answers with the stored preference, writeback is functioning.
## Session strategy
| Scope | When to use |
|----------------------|---------------------------------------------------------|
| Per-Session | A honcho session starts fresh each time a new Hermes session is created. Hermes remembers the user across sessions. |
| Per Directory | One honcho session per project directory. Context is scoped to each directory. Coding/project memory scoped to each repository/workspace. |
| Global (per user) | Continuity across all chats, threads, and projects. One honcho session globally for the user and Hermes agent. |
## Configuration options
| Field | Default | Description |
|---|---|---|
| `recallMode` | `hybrid` | `hybrid` (auto-inject + tools), `context` (inject only), `tools` (tools only) |
| `writeFrequency` | `async` | `async`, `turn`, `session`, or integer N |
| `sessionStrategy` | `per-directory` | `per-directory`, `per-repo`, `per-session`, `global` |
| `dialecticReasoningLevel` | `low` | `minimal`, `low`, `medium`, `high`, `max` |
| `dialecticDynamic` | `true` | Auto-bump reasoning level by query complexity |
| `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) |
## Next steps
@ -182,6 +134,6 @@ If Hermes answers with the stored preference, writeback is functioning.
</Card>
<Card title="Self-Hosting Guide" icon="server" href="/v3/contributing/self-hosting">
Full local environment setup, database options, and troubleshooting.
Full local environment setup, provider configuration, and troubleshooting.
</Card>
</CardGroup>

View File

@ -0,0 +1,134 @@
---
title: "Zo Computer"
icon: 'bolt'
description: "Add persistent memory to Zo Computer skills using Honcho"
sidebarTitle: 'Zo Computer'
---
[Zo Computer](https://zo.computer) is a cloud AI platform where users build reusable workflows called skills. The Honcho memory skill gives any Zo workflow persistent memory — saving conversations, answering questions about past interactions, and injecting context into LLM prompts.
<Note>
The full source code is available on [GitHub](https://github.com/plastic-labs/honcho/tree/main/examples/zo) with working tests and Zo marketplace submission instructions.
</Note>
## What It Does
The skill provides three tools that any Zo workflow can call:
| Tool | Description |
| ---- | ----------- |
| `save_memory` | Save user or assistant messages to a Honcho session |
| `query_memory` | Ask natural language questions about what Honcho remembers |
| `get_context` | Retrieve conversation history formatted for LLM use (OpenAI message format) |
## Setup
Install dependencies:
```bash
pip install honcho-ai python-dotenv
```
Set your environment variables:
```bash
HONCHO_API_KEY=your-api-key
HONCHO_WORKSPACE_ID=default # optional, defaults to "default"
```
Get your API key at [app.honcho.dev](https://app.honcho.dev).
## Quick Start
```python
from tools.save_memory import save_memory
from tools.query_memory import query_memory
from tools.get_context import get_context
# Save conversation turns
save_memory("alice", "I love hiking in the mountains", "user", "session-1")
save_memory("alice", "That sounds wonderful!", "assistant", "session-1")
# Query what Honcho remembers
answer = query_memory("alice", "What are my hobbies?", "session-1")
print(answer) # "Alice enjoys hiking in the mountains."
# Get context ready for an LLM call
messages = get_context("alice", "session-1", "assistant", tokens=4000)
# Returns [{"role": "user", "content": "..."}, ...]
```
## Saving Messages
`save_memory` creates peers and sessions automatically on first use and persists the message.
```python
save_memory(
user_id="alice", # unique user identifier
content="Hello!", # message text
role="user", # "user" or "assistant"
session_id="session-1", # conversation identifier
assistant_id="assistant", # optional, defaults to "assistant"
)
```
## Querying Memory
`query_memory` uses Honcho's Dialectic API to answer natural language questions grounded in stored memory.
```python
answer = query_memory(
user_id="alice",
query="What are my interests?",
session_id="session-1", # optional — omit to query global memory
)
```
## Retrieving Context
`get_context` fetches recent conversation history within a token budget and returns it in OpenAI message format — ready to pass directly to an LLM.
```python
messages = get_context(
user_id="alice",
session_id="session-1",
assistant_id="assistant",
tokens=4000, # max tokens to include
)
# Use directly: llm.chat.completions.create(messages=messages)
```
## Concept Mapping
| Zo Computer | Honcho |
| --- | --- |
| Account | Workspace |
| User | Peer |
| Conversation | Session |
| Message | Message |
## Publishing to the Zo Marketplace
To submit the skill to the [Zo Skills Registry](https://github.com/zocomputer/skills):
1. Fork the `zocomputer/skills` repository
2. Copy the `examples/zo` directory into `/Community/honcho-memory/` in your fork
3. Run `bun validate` to check the skill format
4. Submit a pull request
## Next Steps
<CardGroup cols={2}>
<Card title="Source Code" icon="github" href="https://github.com/plastic-labs/honcho/tree/main/examples/zo">
Full source, tests, and SKILL.md for the Zo integration
</Card>
<Card title="Honcho Architecture" icon="sitemap" href="/v3/documentation/core-concepts/architecture">
Understand peers, sessions, and how memory works
</Card>
<Card title="Chat API" icon="brain" href="/v3/documentation/features/chat">
Learn more about querying peer memory with the Dialectic API
</Card>
<Card title="Get Context" icon="messages" href="/v3/documentation/features/get-context">
Details on retrieving and formatting conversation context
</Card>
</CardGroup>

View File

@ -62,6 +62,9 @@ Use Honcho as a memory layer in your agent orchestration stack:
<Card title="CrewAI" icon="users-gear" href="/v3/guides/integrations/crewai">
Give CrewAI agents memory that persists across sessions
</Card>
<Card title="Zo Computer" icon="bolt" href="/v3/guides/integrations/zo-computer">
Persistent memory skill for Zo Computer AI workflows
</Card>
<Card title="n8n" icon="share-nodes" href="/v3/guides/integrations/n8n">
Build intelligent automation workflows with persistent memory
</Card>

148
examples/zo/README.md Normal file
View File

@ -0,0 +1,148 @@
# Honcho Memory Skill for Zo Computer
Give your AI persistent memory across conversations using [Honcho](https://honcho.dev).
## Features
- **Auto-Memory**: Save user and assistant messages to Honcho with one call
- **Query Memory**: Ask natural language questions about what Honcho remembers ("What are my hobbies?")
- **Context Injection**: Retrieve conversation context formatted for direct LLM use
- **Multi-Workspace Support**: Manage separate memory spaces via `HONCHO_WORKSPACE_ID`
## Installation
```bash
pip install honcho-ai python-dotenv
```
Or with uv:
```bash
uv add honcho-ai python-dotenv
```
## Environment Variables
Create a `.env` file:
```env
HONCHO_API_KEY=your-api-key-here
HONCHO_WORKSPACE_ID=default
```
Get your API key at [honcho.dev](https://honcho.dev).
## Quick Start
```python
from tools.save_memory import save_memory
from tools.query_memory import query_memory
from tools.get_context import get_context
# Save a conversation turn
save_memory("alice", "I love hiking in the mountains", "user", "session-1")
save_memory("alice", "That sounds wonderful!", "assistant", "session-1")
# Query what Honcho remembers
answer = query_memory("alice", "What are my hobbies?", "session-1")
print(answer) # "Alice enjoys hiking in the mountains."
# Get context ready for an LLM call
messages = get_context("alice", "session-1", "assistant", tokens=4000)
# messages is a list of {"role": ..., "content": ...} dicts
```
## Tool Reference
### `save_memory(user_id, content, role, session_id, assistant_id="assistant")`
Saves a message to Honcho memory.
| Param | Type | Description |
|---|---|---|
| `user_id` | `str` | Unique user identifier |
| `content` | `str` | Message text |
| `role` | `str` | `"user"` or `"assistant"` |
| `session_id` | `str` | Session/conversation identifier |
| `assistant_id` | `str` | Peer ID for the assistant. Defaults to `"assistant"` |
Returns a confirmation string.
---
### `query_memory(user_id, query, session_id=None)`
Queries stored memory using Honcho's Dialectic API.
| Param | Type | Description |
|---|---|---|
| `user_id` | `str` | Unique user identifier |
| `query` | `str` | Natural language question |
| `session_id` | `str \| None` | Optional: scope to a specific session. Defaults to `None` (global memory) |
Returns a natural language answer.
> **Note:** In shared workspaces, `query_memory` may return data from other peers if the queried user has no stored memory yet. The Dialectic API draws from workspace-level context as a fallback. Use unique `HONCHO_WORKSPACE_ID` values per user group in production to prevent cross-peer data leakage.
---
### `get_context(user_id, session_id, assistant_id, tokens=4000)`
Retrieves conversation context in OpenAI message format.
| Param | Type | Description |
|---|---|---|
| `user_id` | `str` | Unique user identifier |
| `session_id` | `str` | Session/conversation identifier |
| `assistant_id` | `str` | Peer ID for the assistant |
| `tokens` | `int` | Max tokens to include (default: 4000) |
Returns a list of `{"role": ..., "content": ...}` dicts.
## Concept Mapping
| Zo Computer | Honcho |
|---|---|
| Account | Workspace |
| User | Peer |
| Conversation | Session |
| Message | Message |
## Running Tests
Requires a running Honcho server. See the [main repo](../../README.md) for setup instructions.
```bash
uv run pytest tests/ -v
```
## Submitting to the Zo Skill Marketplace
To publish this skill to the [Zo Skills Registry](https://github.com/zocomputer/skills):
1. **Fork** the `zocomputer/skills` repository.
2. **Copy** this directory into the `/Community` folder of your fork, naming it `honcho-memory`:
```
Community/
└── honcho-memory/
├── SKILL.md
├── README.md
├── client.py
├── pyproject.toml
└── tools/
```
3. **Validate** your skill:
```bash
bun validate
```
4. **Submit a pull request** to the upstream registry repository.
Once merged, the skill will be automatically added to the Zo marketplace `manifest.json`.
## License
AGPL-3.0-or-later

118
examples/zo/SKILL.md Normal file
View File

@ -0,0 +1,118 @@
---
name: honcho-memory
description: Gives AI agents persistent memory across conversations using Honcho. Automatically saves and retrieves user context so the AI remembers preferences, history, and facts between sessions. Use when you need the AI to remember past conversations, recall what a user has told it, inject relevant context into prompts, or manage separate memory spaces for different topics.
license: AGPL-3.0
compatibility: Requires Python 3.9+, honcho-ai>=2.1.0, and a Honcho API key from honcho.dev. Set HONCHO_API_KEY and optionally HONCHO_WORKSPACE_ID in your environment.
metadata:
author: plastic-labs
version: "0.1.0"
honcho-sdk: "2.1.0"
---
# Honcho Memory Skill
This skill provides three tools for storing and retrieving AI memory using [Honcho](https://honcho.dev).
## Setup
1. Get a Honcho API key at [honcho.dev](https://honcho.dev).
2. Set environment variables:
```
HONCHO_API_KEY=your-api-key
HONCHO_WORKSPACE_ID=default # optional, defaults to "default"
```
3. Install dependencies:
```
pip install honcho-ai python-dotenv
```
## Tools
### `save_memory`
Saves a conversation turn (user or assistant message) to Honcho.
**When to use:** After every message exchange to build up the user's memory.
```python
from tools.save_memory import save_memory
save_memory(
user_id="alice", # unique user identifier
content="I love hiking", # message text
role="user", # "user" or "assistant"
session_id="chat-1", # conversation session ID
assistant_id="assistant" # optional: assistant peer ID (default: "assistant")
)
```
### `query_memory`
Asks a natural language question against stored memory using Honcho's Dialectic API.
**When to use:** When the user asks "do you remember...?", or when you need to recall facts about the user before responding.
```python
from tools.query_memory import query_memory
answer = query_memory(
user_id="alice",
query="What are Alice's hobbies?",
session_id="chat-1" # optional: scope to a session
)
# Returns: "Alice enjoys hiking."
```
### `get_context`
Retrieves recent conversation history formatted for direct use in an LLM API call.
**When to use:** At the start of each LLM call to inject relevant context from past conversations.
```python
from tools.get_context import get_context
messages = get_context(
user_id="alice",
session_id="chat-1",
assistant_id="assistant",
tokens=4000 # max tokens to include
)
# Returns: [{"role": "user", "content": "..."}, ...]
```
## Concept Mapping
| Zo Computer | Honcho |
|---|---|
| Account | Workspace |
| User | Peer |
| Conversation | Session |
| Message | Message |
## Example: Full Conversation Flow
```python
from tools.save_memory import save_memory
from tools.query_memory import query_memory
from tools.get_context import get_context
user_id = "alice"
session_id = "session-1"
# 1. Save user message
save_memory(user_id, "I'm learning Rust and love rock climbing", "user", session_id)
# 2. Save assistant reply
save_memory(user_id, "That's great! Both require patience.", "assistant", session_id)
# 3. In a later session, recall what you know
print(query_memory(user_id, "What does Alice do in her free time?"))
# → "Alice is learning Rust and enjoys rock climbing."
# 4. Get context window for next LLM call
messages = get_context(user_id, session_id, "assistant", tokens=4000)
```

View File

@ -0,0 +1,25 @@
[project]
name = "honcho-zo-skill"
version = "0.1.0"
description = "Honcho persistent memory skill for Zo Computer"
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"honcho-ai>=2.1.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["tools"]
[tool.pytest.ini_options]
pythonpath = ["."]

View File

@ -0,0 +1,69 @@
"""Basic import and structure tests for honcho-zo-skill.
These tests validate package structure and imports without requiring
a running Honcho server.
"""
import os
import sys
import pytest
# Add parent directory to path so tools/ can be imported
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def test_save_memory_import():
"""Test that save_memory can be imported."""
from tools.save_memory import save_memory
assert callable(save_memory)
def test_query_memory_import():
"""Test that query_memory can be imported."""
from tools.query_memory import query_memory
assert callable(query_memory)
def test_get_context_import():
"""Test that get_context can be imported."""
from tools.get_context import get_context
assert callable(get_context)
def test_tools_package_import():
"""Test that the tools package exports all three functions."""
import tools
assert hasattr(tools, "save_memory")
assert hasattr(tools, "query_memory")
assert hasattr(tools, "get_context")
def test_tools_all_exports():
"""Test that __all__ contains expected exports."""
import tools
assert hasattr(tools, "__all__")
expected = ["get_context", "query_memory", "save_memory"]
for name in expected:
assert name in tools.__all__, f"{name} not in __all__"
def test_save_memory_raises_on_empty_content():
"""Test that save_memory raises ValueError for empty content."""
from tools.save_memory import save_memory
with pytest.raises(ValueError, match="content must not be empty"):
save_memory("user1", "", "user", "session1")
def test_query_memory_raises_on_empty_query():
"""Test that query_memory raises ValueError for empty query."""
from tools.query_memory import query_memory
with pytest.raises(ValueError, match="query must not be empty"):
query_memory("user1", "")

View File

@ -0,0 +1,199 @@
"""Functional tests for Honcho Zo skill tools.
These tests require a Honcho API key set in the HONCHO_API_KEY environment
variable. They run against the Honcho cloud API (honcho.dev) by default.
Set HONCHO_WORKSPACE_ID to scope tests to a specific workspace.
"""
import os
import sys
import time
import uuid
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from tools.get_context import get_context
from tools.query_memory import query_memory
from tools.save_memory import save_memory
pytestmark = pytest.mark.skipif(
not os.getenv("HONCHO_API_KEY"),
reason="HONCHO_API_KEY not set — skipping integration tests",
)
@pytest.fixture(autouse=True)
def rate_limit_delay():
"""Pause between tests to stay under the Honcho API rate limit (5 req/sec)."""
yield
time.sleep(0.5)
def unique_id(prefix: str) -> str:
"""Generate a unique ID with a prefix to avoid test state leakage."""
return f"{prefix}_{uuid.uuid4().hex[:8]}"
class TestSaveMemory:
"""Tests for save_memory tool."""
def test_returns_confirmation_string(self):
"""Test that save_memory returns a non-empty confirmation string."""
result = save_memory(unique_id("user"), "Hello, I love hiking!", "user", unique_id("session"))
assert isinstance(result, str)
assert len(result) > 0
def test_saves_user_message(self):
"""Test saving a user-role message."""
user_id = unique_id("user")
result = save_memory(user_id, "I enjoy Python programming", "user", unique_id("session"))
assert isinstance(result, str)
assert "user" in result.lower() or user_id in result
def test_saves_assistant_message(self):
"""Test saving an assistant-role message."""
result = save_memory(unique_id("user"), "That sounds great!", "assistant", unique_id("session"))
assert isinstance(result, str)
assert len(result) > 0
def test_saves_multiple_turns(self):
"""Test saving multiple turns in the same session."""
user_id = unique_id("user")
session_id = unique_id("session")
result1 = save_memory(user_id, "I love mountains", "user", session_id)
result2 = save_memory(user_id, "That's wonderful!", "assistant", session_id)
assert isinstance(result1, str) and len(result1) > 0
assert isinstance(result2, str) and len(result2) > 0
def test_non_assistant_role_treated_as_user(self):
"""Test that any role other than 'assistant' is treated as user."""
result = save_memory(unique_id("user"), "Testing role fallback", "human", unique_id("session"))
assert isinstance(result, str)
assert len(result) > 0
def test_custom_assistant_id(self):
"""Test that a custom assistant_id is accepted."""
result = save_memory(
unique_id("user"), "Hello!", "assistant", unique_id("session"), assistant_id="my-bot"
)
assert isinstance(result, str)
assert len(result) > 0
class TestQueryMemory:
"""Tests for query_memory tool."""
def test_returns_string(self):
"""Test that query_memory returns a string response."""
user_id = unique_id("user")
session_id = unique_id("session")
save_memory(user_id, "I love pizza and Italian food", "user", session_id)
result = query_memory(user_id, "What does the user enjoy?")
assert isinstance(result, str)
assert len(result) > 0
def test_returns_string_with_session_scope(self):
"""Test query_memory scoped to a specific session."""
user_id = unique_id("user")
session_id = unique_id("session")
save_memory(user_id, "My favorite color is blue", "user", session_id)
result = query_memory(user_id, "What is the user's favorite color?", session_id)
assert isinstance(result, str)
assert len(result) > 0
def test_returns_fallback_for_unknown_user(self):
"""Test that query_memory returns a non-empty string even for new users."""
result = query_memory(unique_id("user"), "What do I like?")
assert isinstance(result, str)
assert len(result) > 0
class TestGetContext:
"""Tests for get_context tool."""
def test_returns_list(self):
"""Test that get_context returns a list."""
user_id = unique_id("user")
session_id = unique_id("session")
save_memory(user_id, "Hello there!", "user", session_id)
result = get_context(user_id, session_id, "assistant")
assert isinstance(result, list)
def test_returns_openai_format(self):
"""Test that returned messages are in OpenAI format."""
user_id = unique_id("user")
session_id = unique_id("session")
save_memory(user_id, "My name is Alex", "user", session_id)
save_memory(user_id, "Nice to meet you, Alex!", "assistant", session_id)
result = get_context(user_id, session_id, "assistant")
assert isinstance(result, list)
for msg in result:
assert "role" in msg
assert "content" in msg
assert msg["role"] in ("user", "assistant", "system")
assert isinstance(msg["content"], str)
def test_respects_token_limit(self):
"""Test that context respects the token limit parameter."""
user_id = unique_id("user")
session_id = unique_id("session")
for i in range(5):
save_memory(user_id, f"Message number {i} with some content", "user", session_id)
result_small = get_context(user_id, session_id, "assistant", tokens=100)
result_large = get_context(user_id, session_id, "assistant", tokens=8000)
assert isinstance(result_small, list)
assert isinstance(result_large, list)
assert len(result_large) >= len(result_small)
def test_empty_session_returns_list(self):
"""Test that get_context returns an empty list for a session with no messages."""
result = get_context(unique_id("user"), unique_id("session"), "assistant")
assert isinstance(result, list)
class TestToolsWorkTogether:
"""Integration tests using all three tools in sequence."""
def test_save_query_roundtrip(self):
"""Test saving a message and then querying it."""
user_id = unique_id("user")
session_id = unique_id("session")
save_memory(user_id, "I am a software engineer who loves Rust", "user", session_id)
result = query_memory(user_id, "What is the user's profession?", session_id)
assert isinstance(result, str)
assert len(result) > 0
def test_save_then_get_context(self):
"""Test that saved messages appear in context."""
user_id = unique_id("user")
session_id = unique_id("session")
save_memory(user_id, "Hello!", "user", session_id)
save_memory(user_id, "Hi there!", "assistant", session_id)
messages = get_context(user_id, session_id, "assistant")
assert isinstance(messages, list)
assert len(messages) >= 1

View File

@ -0,0 +1,7 @@
"""Honcho memory tools for Zo Computer."""
from tools.get_context import get_context
from tools.query_memory import query_memory
from tools.save_memory import save_memory
__all__ = ["get_context", "query_memory", "save_memory"]

View File

@ -0,0 +1,32 @@
"""Honcho client initialization for Zo Computer skill."""
import os
from dotenv import load_dotenv
from honcho import Honcho
load_dotenv()
def get_client(workspace_id: str | None = None) -> Honcho:
"""Initialize and return a Honcho client.
Reads HONCHO_API_KEY and HONCHO_WORKSPACE_ID from environment variables.
The workspace_id parameter overrides the environment variable if provided.
Args:
workspace_id: Optional workspace ID override. Falls back to the
HONCHO_WORKSPACE_ID env var, then to "default".
Returns:
Configured Honcho client instance.
"""
api_key = os.getenv("HONCHO_API_KEY")
if not api_key:
raise ValueError(
"HONCHO_API_KEY is required. Set it in your environment or .env file."
)
env_workspace = os.getenv("HONCHO_WORKSPACE_ID")
resolved_workspace = workspace_id or env_workspace or "default"
return Honcho(api_key=api_key, workspace_id=resolved_workspace)

View File

@ -0,0 +1,42 @@
"""Retrieve conversation context from Honcho formatted for LLM use."""
from __future__ import annotations
from .client import get_client
def get_context(
user_id: str,
session_id: str,
assistant_id: str,
tokens: int = 4000,
) -> list[dict[str, str]]:
"""Retrieve conversation context ready for injection into an LLM prompt.
Fetches recent messages from a Honcho session within the given token
budget and converts them to OpenAI-compatible message format. Use the
returned list directly as the ``messages`` parameter in an LLM API call.
Args:
user_id: Unique identifier for the user peer. Used to ensure the
peer is registered in the session before fetching context.
session_id: Identifier for the conversation session.
assistant_id: Peer ID representing the assistant. This determines
which role is mapped to ``"assistant"`` in the output.
tokens: Maximum number of tokens to include in the context window.
Defaults to 4000.
Returns:
A list of message dicts in OpenAI format:
``[{"role": "user" | "assistant", "content": "..."}]``.
Returns an empty list if the session has no messages.
"""
honcho = get_client()
user_peer = honcho.peer(user_id)
assistant_peer = honcho.peer(assistant_id)
session = honcho.session(session_id)
session.add_peers([user_peer, assistant_peer])
context = session.context(tokens=tokens)
return context.to_openai(assistant=assistant_id)

View File

@ -0,0 +1,37 @@
"""Query a user's Honcho memory using the Dialectic API."""
from __future__ import annotations
from .client import get_client
def query_memory(user_id: str, query: str, session_id: str | None = None) -> str:
"""Query stored memory for a user using Honcho's Dialectic API.
Sends a natural language question to Honcho and returns an answer
grounded in the peer's long-term representation and stored observations.
Args:
user_id: Unique identifier for the user peer.
query: Natural language question, e.g. "What are my hobbies?".
session_id: Optional session ID to scope the query to a specific
conversation. If omitted, the query draws from global memory.
Returns:
A natural language answer from Honcho's Dialectic API, or a
default message if no relevant information was found.
Raises:
ValueError: If query is empty.
"""
if not query:
raise ValueError("query must not be empty")
honcho = get_client()
peer = honcho.peer(user_id)
response = peer.chat(query=query, session=session_id)
if response:
return str(response)
return "No relevant information found in memory."

View File

@ -0,0 +1,45 @@
"""Save a conversation message to Honcho memory."""
from .client import get_client
def save_memory(
user_id: str,
content: str,
role: str,
session_id: str,
assistant_id: str = "assistant",
) -> str:
"""Save a single conversation turn to Honcho memory.
Creates the peer and session if they do not already exist. Registers
the peer in the session on first use, then persists the message.
Args:
user_id: Unique identifier for the user peer.
content: Text content of the message to save.
role: Either "user" or "assistant". Determines which peer sends
the message. Any value other than "assistant" is treated as "user".
session_id: Identifier for the conversation session.
assistant_id: Peer ID for the assistant. Defaults to "assistant".
Returns:
A confirmation string describing what was saved.
Raises:
ValueError: If content is empty.
"""
if not content:
raise ValueError("content must not be empty")
honcho = get_client()
user_peer = honcho.peer(user_id)
assistant_peer = honcho.peer(assistant_id)
session = honcho.session(session_id)
session.add_peers([user_peer, assistant_peer])
sender = assistant_peer if role == "assistant" else user_peer
session.add_messages([sender.message(content)])
return f"Saved {role} message to session '{session_id}' for user '{user_id}'."

503
examples/zo/uv.lock Normal file
View File

@ -0,0 +1,503 @@
version = 1
revision = 3
requires-python = ">=3.9"
resolution-markers = [
"python_full_version >= '3.10'",
"python_full_version < '3.10'",
]
[[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.12.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.10'" },
{ name = "idna", marker = "python_full_version < '3.10'" },
{ name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
{ name = "idna", marker = "python_full_version >= '3.10'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.10' and 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.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[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 = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[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 = "honcho-ai"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/07/fb2a6654a9f44ff1070d88feb269113a865923e0aa91acf7864459179a1b/honcho_ai-2.1.0.tar.gz", hash = "sha256:c1988bbbf61492c2db168c2f0aa4317c489e18ea9867f74cb318a5f1b83289c8", size = 48050, upload-time = "2026-03-30T14:59:56.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/34/b814ea3bed1d96807377814461d58294f0d6c5c66e29f06625c0ac6069b6/honcho_ai-2.1.0-py3-none-any.whl", hash = "sha256:c07389036ef839ff31dc66e4757fa451da25ce976830bce108372e0756daf500", size = 58295, upload-time = "2026-03-30T14:59:55.774Z" },
]
[[package]]
name = "honcho-zo-skill"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "honcho-ai" },
{ name = "python-dotenv", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "python-dotenv", version = "1.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
[package.metadata]
requires-dist = [
{ name = "honcho-ai", specifier = ">=2.1.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
]
provides-extras = ["dev"]
[[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", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ 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.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
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 = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[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.12.5"
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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" },
{ url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" },
{ url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" },
{ url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" },
{ url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" },
{ url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" },
{ url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" },
{ url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" },
{ url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" },
{ url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" },
{ url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" },
{ url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
]
[[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 = "8.4.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.10'" },
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "packaging", marker = "python_full_version < '3.10'" },
{ name = "pluggy", marker = "python_full_version < '3.10'" },
{ name = "pygments", marker = "python_full_version < '3.10'" },
{ name = "tomli", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
{ name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "packaging", marker = "python_full_version >= '3.10'" },
{ name = "pluggy", marker = "python_full_version >= '3.10'" },
{ name = "pygments", marker = "python_full_version >= '3.10'" },
{ name = "tomli", marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
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 = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[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" },
]

View File

@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import models, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.utils.filter import apply_filter
from src.utils.formatting import ILIKE_ESCAPE_CHAR, escape_ilike_pattern
@ -34,6 +35,40 @@ def _deduplicate_messages(
return result
def _expunge_snippets(
db: AsyncSession, snippets: list[tuple[list[models.Message], list[models.Message]]]
) -> None:
"""Detach snippet messages from the session, guarding against duplicates."""
seen: set[int] = set()
for matches, context in snippets:
for msg in [*matches, *context]:
obj_id = id(msg)
if obj_id in seen:
continue
db.expunge(msg)
seen.add(obj_id)
async def get_peer_session_names(
db: AsyncSession,
workspace_name: str,
peer_name: str,
) -> list[str]:
"""Get all session names where a peer has any membership record.
Any membership record (regardless of joined_at/left_at) grants visibility
to all messages in that session.
"""
stmt = (
select(models.session_peers_table.c.session_name)
.where(models.session_peers_table.c.workspace_name == workspace_name)
.where(models.session_peers_table.c.peer_name == peer_name)
.distinct()
)
result = await db.execute(stmt)
return [row[0] for row in result.all()]
def _apply_token_limit(
base_conditions: list[ColumnElement[Any]], token_limit: int
) -> Select[tuple[models.Message]]:
@ -595,22 +630,19 @@ async def update_message(
async def _search_messages_external(
db: AsyncSession,
workspace_name: str,
query_embedding: list[float],
limit: int,
*,
session_name: str | None = None,
allowed_session_names: list[str] | None = None,
after_date: datetime | None = None,
before_date: datetime | None = None,
) -> list[models.Message]:
"""Query the external vector store for messages and fetch them from the DB.
) -> list[str]:
"""Query the external vector store and return ordered message IDs.
Multiple vector records can map to the same message (chunked embeddings),
so we oversample from the vector store and deduplicate by message_id.
Date filters are applied at the DB level since external vector stores
don't support temporal filtering.
"""
external_vector_store = get_external_vector_store()
if external_vector_store is None:
@ -621,6 +653,8 @@ async def _search_messages_external(
vector_filters: dict[str, Any] = {}
if session_name:
vector_filters["session_name"] = session_name
elif allowed_session_names is not None:
vector_filters["session_name"] = {"in": allowed_session_names}
# Oversample: chunks can map to the same message, and date filters are
# applied post-fetch (vector stores don't support temporal filtering),
@ -648,7 +682,18 @@ async def _search_messages_external(
if not message_ids:
return []
# Fetch from DB with optional date filtering
return message_ids
async def _fetch_messages_by_ids(
db: AsyncSession,
workspace_name: str,
message_ids: list[str],
*,
after_date: datetime | None = None,
before_date: datetime | None = None,
) -> list[models.Message]:
"""Fetch messages by ID, preserving the supplied ordering."""
fetch_stmt = (
select(models.Message)
.where(models.Message.public_id.in_(message_ids))
@ -662,18 +707,139 @@ async def _search_messages_external(
result = await db.execute(fetch_stmt)
messages_by_id = {msg.public_id: msg for msg in result.scalars().all()}
# Preserve vector store similarity order, apply limit
return [messages_by_id[mid] for mid in message_ids if mid in messages_by_id][:limit]
return [messages_by_id[mid] for mid in message_ids if mid in messages_by_id]
async def _search_messages_pgvector(
db: AsyncSession,
workspace_name: str,
session_name: str | None,
*,
query_embedding: list[float],
allowed_session_names: list[str] | None = None,
after_date: datetime | None = None,
before_date: datetime | None = None,
limit: int = 10,
context_window: int = 2,
) -> list[tuple[list[models.Message], list[models.Message]]]:
"""Run semantic message search against pgvector-backed embeddings."""
# pgvector path: cosine distance in SQL
# Oversample because a message with multiple embedding chunks can
# produce duplicate rows; we deduplicate in Python to preserve HNSW
# index usage (a DISTINCT ON subquery would prevent the index scan).
match_stmt = (
select(models.Message)
.join(
models.MessageEmbedding,
models.Message.public_id == models.MessageEmbedding.message_id,
)
.where(models.MessageEmbedding.workspace_name == workspace_name)
.order_by(models.MessageEmbedding.embedding.cosine_distance(query_embedding))
.limit(limit * 2)
)
if session_name:
match_stmt = match_stmt.where(
models.MessageEmbedding.session_name == session_name
)
elif allowed_session_names is not None:
match_stmt = match_stmt.where(
models.MessageEmbedding.session_name.in_(allowed_session_names)
)
if after_date:
match_stmt = match_stmt.where(models.Message.created_at >= after_date)
if before_date:
match_stmt = match_stmt.where(models.Message.created_at <= before_date)
result = await db.execute(match_stmt)
matched_messages = _deduplicate_messages(result.scalars().all(), limit)
return await _build_merged_snippets(
db, workspace_name, matched_messages, context_window
)
async def _semantic_search_messages(
workspace_name: str,
session_name: str | None,
*,
query_embedding: list[float],
limit: int = 10,
context_window: int = 2,
operation_name: str,
after_date: datetime | None = None,
before_date: datetime | None = None,
observer: str | None = None,
) -> list[tuple[list[models.Message], list[models.Message]]]:
"""Run semantic message search with optional temporal filters.
When observer is provided and session_name is None, results are
scoped to sessions the observer has any membership record in.
"""
# Pre-fetch peer session scope if needed (short-lived DB session)
allowed_session_names: list[str] | None = None
if observer and not session_name:
async with tracked_db(f"{operation_name}.peer_scope") as db:
allowed_session_names = await get_peer_session_names(
db, workspace_name, observer
)
if not allowed_session_names:
return []
if settings.VECTOR_STORE.TYPE != "pgvector" and settings.VECTOR_STORE.MIGRATED:
message_ids = await _search_messages_external(
workspace_name,
query_embedding,
limit,
session_name=session_name,
allowed_session_names=allowed_session_names,
after_date=after_date,
before_date=before_date,
)
if not message_ids:
return []
async with tracked_db(operation_name) as db:
matched_messages = (
await _fetch_messages_by_ids(
db,
workspace_name,
message_ids,
after_date=after_date,
before_date=before_date,
)
)[:limit]
snippets = await _build_merged_snippets(
db, workspace_name, matched_messages, context_window
)
_expunge_snippets(db, snippets)
return snippets
async with tracked_db(operation_name) as db:
snippets = await _search_messages_pgvector(
db,
workspace_name,
session_name,
query_embedding=query_embedding,
allowed_session_names=allowed_session_names,
after_date=after_date,
before_date=before_date,
limit=limit,
context_window=context_window,
)
_expunge_snippets(db, snippets)
return snippets
async def search_messages(
db: AsyncSession,
workspace_name: str,
session_name: str | None,
query: str,
limit: int = 10,
context_window: int = 2,
embedding: list[float] | None = None,
observer: str | None = None,
) -> list[tuple[list[models.Message], list[models.Message]]]:
"""
Search for messages using semantic similarity and return conversation snippets.
@ -682,86 +848,44 @@ async def search_messages(
snippets within the same session are merged to avoid repetition.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session (optional)
query: Search query text
limit: Maximum number of matching messages to return
context_window: Number of messages before/after each match to include
embedding: Optional pre-computed embedding
observer: When provided and session_name is None, scope results
to sessions this peer belongs to
Returns:
List of tuples: (matched_messages, context_messages)
Each snippet may contain multiple matches if they were close together.
Context messages are ordered chronologically and include the matched messages.
"""
# Use provided embedding or generate one
query_embedding = (
embedding if embedding is not None else await embedding_client.embed(query)
)
if settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED:
# pgvector path: cosine distance in SQL
# Oversample because a message with multiple embedding chunks can
# produce duplicate rows; we deduplicate in Python to preserve HNSW
# index usage (a DISTINCT ON subquery would prevent the index scan).
match_stmt = (
select(models.Message)
.join(
models.MessageEmbedding,
models.Message.public_id == models.MessageEmbedding.message_id,
)
.where(models.MessageEmbedding.workspace_name == workspace_name)
.order_by(
models.MessageEmbedding.embedding.cosine_distance(query_embedding)
)
.limit(limit * 2)
)
if session_name:
match_stmt = match_stmt.where(
models.MessageEmbedding.session_name == session_name
)
result = await db.execute(match_stmt)
matched_messages = _deduplicate_messages(result.scalars().all(), limit)
else:
# External vector store path
matched_messages = await _search_messages_external(
db, workspace_name, query_embedding, limit, session_name=session_name
)
return await _build_merged_snippets(
db, workspace_name, matched_messages, context_window
return await _semantic_search_messages(
workspace_name,
session_name,
query_embedding=query_embedding,
limit=limit,
context_window=context_window,
operation_name="message.search_messages",
observer=observer,
)
async def grep_messages(
async def _grep_messages_internal(
db: AsyncSession,
workspace_name: str,
session_name: str | None,
text: str,
limit: int = 10,
context_window: int = 2,
allowed_session_names: list[str] | None = None,
) -> list[tuple[list[models.Message], list[models.Message]]]:
"""
Search for messages containing specific text (case-insensitive substring match).
Unlike semantic search, this finds EXACT text matches. Useful for finding
specific names, dates, phrases, or keywords.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session (optional - searches all sessions if None)
text: Text to search for (case-insensitive)
limit: Maximum number of matching messages to return
context_window: Number of messages before/after each match to include
Returns:
List of tuples: (matched_messages, context_messages)
Each snippet may contain multiple matches if they were close together.
"""
"""Internal implementation of exact-text message search."""
# Build the base query with ILIKE for case-insensitive text search
escaped_text = escape_ilike_pattern(text)
match_stmt = (
@ -776,6 +900,10 @@ async def grep_messages(
if session_name:
match_stmt = match_stmt.where(models.Message.session_name == session_name)
elif allowed_session_names is not None:
match_stmt = match_stmt.where(
models.Message.session_name.in_(allowed_session_names)
)
result = await db.execute(match_stmt)
matched_messages = list(result.scalars().all())
@ -785,6 +913,56 @@ async def grep_messages(
)
async def grep_messages(
workspace_name: str,
session_name: str | None,
text: str,
limit: int = 10,
context_window: int = 2,
observer: str | None = None,
) -> list[tuple[list[models.Message], list[models.Message]]]:
"""
Search for messages containing specific text (case-insensitive substring match).
Unlike semantic search, this finds EXACT text matches. Useful for finding
specific names, dates, phrases, or keywords.
Args:
workspace_name: Name of the workspace
session_name: Name of the session (optional - searches all sessions if None)
text: Text to search for (case-insensitive)
limit: Maximum number of matching messages to return
context_window: Number of messages before/after each match to include
observer: When provided and session_name is None, scope results
to sessions this peer belongs to
Returns:
List of tuples: (matched_messages, context_messages)
Each snippet may contain multiple matches if they were close together.
"""
async with tracked_db("message.grep_messages") as db:
# Pre-fetch peer session scope if needed
allowed_session_names = None
if observer and not session_name:
allowed_session_names = await get_peer_session_names(
db, workspace_name, observer
)
if not allowed_session_names:
return []
snippets = await _grep_messages_internal(
db,
workspace_name,
session_name,
text,
limit,
context_window,
allowed_session_names=allowed_session_names,
)
_expunge_snippets(db, snippets)
return snippets
async def get_messages_by_date_range(
db: AsyncSession,
workspace_name: str,
@ -793,6 +971,7 @@ async def get_messages_by_date_range(
before_date: datetime | None = None,
limit: int = 20,
order: str = "desc",
observer: str | None = None,
) -> list[models.Message]:
"""
Get messages within a date range.
@ -805,14 +984,27 @@ async def get_messages_by_date_range(
before_date: Return messages before this datetime
limit: Maximum messages to return
order: Sort order - 'asc' for oldest first, 'desc' for newest first
observer: When provided and session_name is None, scope results
to sessions this peer belongs to
Returns:
List of messages within the date range
"""
# Pre-fetch peer session scope if needed
allowed_session_names = None
if observer and not session_name:
allowed_session_names = await get_peer_session_names(
db, workspace_name, observer
)
if not allowed_session_names:
return []
stmt = select(models.Message).where(models.Message.workspace_name == workspace_name)
if session_name:
stmt = stmt.where(models.Message.session_name == session_name)
elif allowed_session_names is not None:
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
if after_date:
stmt = stmt.where(models.Message.created_at >= after_date)
if before_date:
@ -830,7 +1022,6 @@ async def get_messages_by_date_range(
async def search_messages_temporal(
db: AsyncSession,
workspace_name: str,
session_name: str | None,
query: str,
@ -839,6 +1030,7 @@ async def search_messages_temporal(
limit: int = 10,
context_window: int = 2,
embedding: list[float] | None = None,
observer: str | None = None,
) -> list[tuple[list[models.Message], list[models.Message]]]:
"""
Search for messages using semantic similarity with optional date filtering.
@ -847,7 +1039,6 @@ async def search_messages_temporal(
to find recent mentions, or before_date to find what was said before a certain point.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session (optional)
query: Search query text
@ -856,58 +1047,24 @@ async def search_messages_temporal(
limit: Maximum number of matching messages to return
context_window: Number of messages before/after each match to include
embedding: Optional pre-computed embedding for the query
observer: When provided and session_name is None, scope results
to sessions this peer belongs to
Returns:
List of tuples: (matched_messages, context_messages)
Each snippet may contain multiple matches if they were close together.
"""
# Use provided embedding or generate one
query_embedding = (
embedding if embedding is not None else await embedding_client.embed(query)
)
if settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED:
# pgvector path: cosine distance in SQL with date filters
# Oversample to handle chunk duplicates (see search_messages comment)
match_stmt = (
select(models.Message)
.join(
models.MessageEmbedding,
models.Message.public_id == models.MessageEmbedding.message_id,
)
.where(models.MessageEmbedding.workspace_name == workspace_name)
)
if session_name:
match_stmt = match_stmt.where(
models.MessageEmbedding.session_name == session_name
)
# Apply date filters on the Message table
if after_date:
match_stmt = match_stmt.where(models.Message.created_at >= after_date)
if before_date:
match_stmt = match_stmt.where(models.Message.created_at <= before_date)
# Order by similarity and limit
match_stmt = match_stmt.order_by(
models.MessageEmbedding.embedding.cosine_distance(query_embedding)
).limit(limit * 2)
result = await db.execute(match_stmt)
matched_messages = _deduplicate_messages(result.scalars().all(), limit)
else:
# External vector store path with post-fetch date filtering
matched_messages = await _search_messages_external(
db,
workspace_name,
query_embedding,
limit,
session_name=session_name,
after_date=after_date,
before_date=before_date,
)
return await _build_merged_snippets(
db, workspace_name, matched_messages, context_window
return await _semantic_search_messages(
workspace_name,
session_name,
query_embedding=query_embedding,
after_date=after_date,
before_date=before_date,
limit=limit,
context_window=context_window,
operation_name="message.search_messages_temporal",
observer=observer,
)

View File

@ -223,7 +223,11 @@ async def update_peer(
db: AsyncSession, workspace_name: str, peer_name: str, peer: schemas.PeerUpdate
) -> models.Peer:
"""
Update a peer.
Get or create a peer, then apply metadata and configuration updates.
If the peer does not exist, the workspace and peer are created first.
Provided metadata and configuration replace the existing values when
present.
Args:
db: Database session
@ -235,9 +239,8 @@ async def update_peer(
The updated peer
Raises:
ResourceNotFoundException: If the peer does not exist
ValidationException: If the update data is invalid
ConflictException: If the update violates a unique constraint
ConflictException: If concurrent creation prevents fetching or creating
the peer
"""
peers_result = await get_or_create_peers(
db, workspace_name, [schemas.PeerCreate(name=peer_name)]
@ -269,7 +272,6 @@ async def update_peer(
return honcho_peer
await db.commit()
await db.refresh(honcho_peer)
await peers_result.post_commit()
cache_key = peer_cache_key(workspace_name, honcho_peer.name)

View File

@ -137,21 +137,30 @@ async def get_or_create_session(
_retry: bool = False,
) -> GetOrCreateResult[models.Session]:
"""
Get or create a session in a workspace with specified peers.
If the session already exists, the peers are added to the session.
Get an active session in a workspace or create it if it does not exist.
If the session already exists, provided metadata replaces the current
metadata, provided configuration keys are merged into the existing
configuration, and any provided peers are ensured to be members of the
session. If the session does not exist, the workspace and peers are created
as needed before the session is created.
Args:
db: Database session
session: Session creation schema
session: Session creation payload, including optional metadata,
configuration, and session-peer configuration
workspace_name: Name of the workspace
peer_names: List of peer names to add to the session
_retry: Whether to retry the operation
_retry: Whether to retry after a concurrent create conflict
Returns:
GetOrCreateResult containing the session and whether it was created
Raises:
ResourceNotFoundException: If the session does not exist and create is false
ConflictException: If we fail to get or create the session
ValueError: If session.name is empty
ResourceNotFoundException: If the named session exists but is inactive
ObserverException: If adding peers would exceed the observer limit
ConflictException: If concurrent creation prevents fetching or creating
the session
"""
if not session.name:
@ -247,10 +256,10 @@ async def get_or_create_session(
workspace_name=workspace_name,
session_name=session.name,
peer_names=session.peer_names,
fetch_after_upsert=False,
)
await db.commit()
await db.refresh(honcho_session)
# Run deferred cache operations from workspace/peer creation
if ws_result is not None:
@ -334,7 +343,11 @@ async def update_session(
session_name: str,
) -> models.Session:
"""
Update a session.
Get or create a session, then apply metadata and configuration updates.
Provided metadata replaces the current metadata when present. Provided
configuration keys are merged into the existing configuration instead of
replacing it wholesale.
Args:
db: Database session
@ -346,7 +359,9 @@ async def update_session(
The updated session
Raises:
ResourceNotFoundException: If the session does not exist or peer is not in session
ResourceNotFoundException: If the named session exists but is inactive
ConflictException: If concurrent creation prevents fetching or creating
the session
"""
honcho_session: models.Session = (
await get_or_create_session(
@ -381,7 +396,6 @@ async def update_session(
return honcho_session
await db.commit()
await db.refresh(honcho_session)
# Only invalidate if we actually updated
cache_key = session_cache_key(workspace_name, session_name)
@ -729,7 +743,6 @@ async def clone_session(
db.add(new_session_peer)
await db.commit()
await db.refresh(new_session)
logger.debug("Session %s cloned successfully", original_session_name)
# Cache will be populated on next read - read-through pattern
@ -795,7 +808,13 @@ async def get_peers_from_session(
# Get all active peers in the session (where left_at is NULL)
return (
select(models.Peer)
.join(models.SessionPeer, models.Peer.name == models.SessionPeer.peer_name)
.join(
models.SessionPeer,
and_(
models.Peer.name == models.SessionPeer.peer_name,
models.Peer.workspace_name == models.SessionPeer.workspace_name,
),
)
.where(models.SessionPeer.session_name == session_name)
.where(models.Peer.workspace_name == workspace_name)
.where(models.SessionPeer.left_at.is_(None)) # Only active peers
@ -825,7 +844,13 @@ async def get_session_peer_configuration(
models.SessionPeer.configuration.label("session_peer_configuration"),
(models.SessionPeer.left_at.is_(None)).label("is_active"),
)
.join(models.SessionPeer, models.Peer.name == models.SessionPeer.peer_name)
.join(
models.SessionPeer,
and_(
models.Peer.name == models.SessionPeer.peer_name,
models.Peer.workspace_name == models.SessionPeer.workspace_name,
),
)
.where(models.SessionPeer.session_name == session_name)
.where(models.Peer.workspace_name == workspace_name)
.where(models.SessionPeer.workspace_name == workspace_name)
@ -912,24 +937,35 @@ async def _get_or_add_peers_to_session(
workspace_name: str,
session_name: str,
peer_names: dict[str, schemas.SessionPeerConfig],
*,
fetch_after_upsert: bool = True,
) -> list[models.SessionPeer]:
"""
Add multiple peers to an existing session. If a peer already exists in the session,
it will be skipped gracefully.
Upsert session-peer memberships for a session and optionally fetch the
active memberships afterward.
New peers are inserted, peers that previously left the session are rejoined,
and already-active peers keep their existing session-level configuration.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_names: Set of peer names to add to the session
peer_names: Mapping of peer names to session-level configuration
fetch_after_upsert: If True, query and return the active session peers
after the upsert. If False, skip that read and return an empty list.
Returns:
List of all SessionPeer objects (both existing and newly created)
Active SessionPeer objects after the upsert, or an empty list when the
post-upsert fetch is skipped
Raises:
ValueError: If adding peers would exceed the maximum limit
ObserverException: If adding peers would exceed the observer limit
"""
# If no peers to add, skip the insert and just return existing active session peers
if not peer_names:
if not fetch_after_upsert:
return []
select_stmt = select(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
@ -994,6 +1030,9 @@ async def _get_or_add_peers_to_session(
)
await db.execute(stmt)
if not fetch_after_upsert:
return []
# Return all active session peers after the upsert
select_stmt = select(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,

View File

@ -18,17 +18,20 @@ async def get_or_create_webhook_endpoint(
webhook: schemas.WebhookEndpointCreate,
) -> GetOrCreateResult[schemas.WebhookEndpoint]:
"""
Get or create a webhook endpoint, optionally for a workspace.
Get an existing webhook endpoint for a workspace or create it if missing.
Args:
db: Database session
workspace_name: Name of the workspace
webhook: Webhook endpoint creation schema
Returns:
GetOrCreateResult containing the webhook endpoint and whether it was created
Raises:
ResourceNotFoundException: If the workspace is specified and does not exist
ResourceNotFoundException: If the workspace does not exist
ValueError: If the workspace already has the maximum number of webhook
endpoints
"""
# Verify workspace exists
await get_workspace(db, workspace_name=workspace_name)
@ -39,12 +42,6 @@ async def get_or_create_webhook_endpoint(
result = await db.execute(stmt)
endpoints = result.scalars().all()
# No more than WORKSPACE_LIMIT webhooks per workspace
if len(endpoints) >= settings.WEBHOOK.MAX_WORKSPACE_LIMIT:
raise ValueError(
f"Maximum number of webhook endpoints ({settings.WEBHOOK.MAX_WORKSPACE_LIMIT}) reached for this workspace."
)
# Check if webhook already exists for this workspace
for endpoint in endpoints:
if endpoint.url == webhook.url:
@ -52,6 +49,12 @@ async def get_or_create_webhook_endpoint(
schemas.WebhookEndpoint.model_validate(endpoint), created=False
)
# No more than WORKSPACE_LIMIT webhooks per workspace
if len(endpoints) >= settings.WEBHOOK.MAX_WORKSPACE_LIMIT:
raise ValueError(
f"Maximum number of webhook endpoints ({settings.WEBHOOK.MAX_WORKSPACE_LIMIT}) reached for this workspace."
)
# Create new webhook endpoint
webhook_endpoint = models.WebhookEndpoint(
workspace_name=workspace_name,
@ -59,7 +62,6 @@ async def get_or_create_webhook_endpoint(
)
db.add(webhook_endpoint)
await db.commit()
await db.refresh(webhook_endpoint)
logger.debug("Webhook endpoint created: %s", webhook.url)
return GetOrCreateResult(

View File

@ -202,7 +202,11 @@ async def update_workspace(
db: AsyncSession, workspace_name: str, workspace: schemas.WorkspaceUpdate
) -> models.Workspace:
"""
Update a workspace.
Get or create a workspace, then apply metadata and configuration updates.
Provided metadata replaces the current metadata when present. Provided
configuration keys are merged into the existing configuration instead of
replacing it wholesale.
Args:
db: Database session
@ -211,6 +215,10 @@ async def update_workspace(
Returns:
The updated workspace
Raises:
ConflictException: If concurrent creation prevents fetching or creating
the workspace
"""
ws_result = await get_or_create_workspace(
db,
@ -250,7 +258,6 @@ async def update_workspace(
return honcho_workspace
await db.commit()
await db.refresh(honcho_workspace)
await ws_result.post_commit()
# Only invalidate if we actually updated

View File

@ -70,8 +70,7 @@ async def process_item(queue_item: models.QueueItem) -> None:
queue_payload,
)
raise ValueError(f"Invalid payload structure: {str(e)}") from e
async with tracked_db() as db:
await webhook_delivery.deliver_webhook(db, validated, workspace_name)
await webhook_delivery.deliver_webhook(validated, workspace_name)
elif task_type == "summary":
try:

View File

@ -56,6 +56,48 @@ class WorkerOwnership(NamedTuple):
aqs_id: str # The ID of the ActiveQueueSession that the worker is processing
def _detach_queue_batch_objects(
db: AsyncSession,
messages_context: list[models.Message],
items_to_process: list[QueueItem],
) -> None:
"""Detach loaded batch objects so they remain usable after tracked_db exits."""
seen: set[int] = set()
for obj in [*messages_context, *items_to_process]:
obj_id = id(obj)
if obj_id in seen:
continue
db.expunge(obj)
seen.add(obj_id)
def _resolve_batch_configuration(
items_to_process: list[QueueItem],
) -> tuple[list[QueueItem], ResolvedConfiguration | None]:
"""Keep only the initial homogeneous configuration prefix for a batch."""
if not items_to_process:
return [], None
raw_config = items_to_process[0].payload.get("configuration")
resolved_config = (
None if raw_config is None else ResolvedConfiguration.model_validate(raw_config)
)
valid_items: list[QueueItem] = []
for item in items_to_process:
item_raw_config = item.payload.get("configuration")
item_config = (
None
if item_raw_config is None
else ResolvedConfiguration.model_validate(item_raw_config)
)
if item_config != resolved_config:
break
valid_items.append(item)
return valid_items, resolved_config
class QueueManager:
def __init__(self):
self.shutdown_event: asyncio.Event = asyncio.Event()
@ -608,21 +650,19 @@ class QueueManager:
)
batch_max_tokens = settings.DERIVER.REPRESENTATION_BATCH_MAX_TOKENS
parsed_key = parse_work_unit_key(work_unit_key)
messages_context: list[models.Message] = []
items_to_process: list[QueueItem] = []
async with tracked_db("get_queue_item_batch") as db:
# For batch tasks, get messages based on token limit.
# Step 1: Parse work_unit_key to get session context and focused sender
parsed_key = parse_work_unit_key(work_unit_key)
# Verify worker still owns the work_unit_key
# Step 1: Verify worker still owns the work_unit_key.
ownership_check = await db.execute(
select(models.ActiveQueueSession.id)
.where(models.ActiveQueueSession.work_unit_key == work_unit_key)
.where(models.ActiveQueueSession.id == aqs_id)
)
if not ownership_check.scalar_one_or_none():
# Worker lost ownership, return empty
await db.commit()
return [], [], None
# Step 2: Build a single SQL query that:
@ -716,11 +756,8 @@ class QueueManager:
result = await db.execute(query)
rows = result.all()
if not rows:
await db.commit()
return [], [], None
messages_context: list[models.Message] = []
items_to_process: list[QueueItem] = []
seen_messages: set[int] = set()
for m, qi in rows:
if m.id not in seen_messages:
@ -729,48 +766,21 @@ class QueueManager:
if qi is not None:
items_to_process.append(qi)
if items_to_process:
# Enforce homogeneous peer_card_config in the batch
# We stop collecting items as soon as we encounter a different configuration
payload = items_to_process[0].payload
_detach_queue_batch_objects(db, messages_context, items_to_process)
raw_config = payload.get("configuration")
if raw_config is None:
resolved_config = None
else:
resolved_config = ResolvedConfiguration.model_validate(raw_config)
items_to_process, resolved_config = _resolve_batch_configuration(
items_to_process
)
valid_items: list[QueueItem] = []
for item in items_to_process:
item_raw_config = item.payload.get("configuration")
if item_raw_config is None:
item_config = None
else:
item_config = ResolvedConfiguration.model_validate(
item_raw_config
)
if item_config != resolved_config:
break
valid_items.append(item)
items_to_process = valid_items
else:
resolved_config = None
if items_to_process:
max_queue_item_message_id = max(
qi.message_id for qi in items_to_process if qi.message_id is not None
)
messages_context = [
m for m in messages_context if m.id <= max_queue_item_message_id
]
if items_to_process:
max_queue_item_message_id = max(
[
qi.message_id
for qi in items_to_process
if qi.message_id is not None
]
)
messages_context = [ # remove any messages that are after the last message_id from queue items
m for m in messages_context if m.id <= max_queue_item_message_id
]
await db.commit()
return messages_context, items_to_process, resolved_config
return messages_context, items_to_process, resolved_config
async def mark_queue_items_as_processed(
self, items: list[QueueItem], work_unit_key: str

View File

@ -196,6 +196,12 @@ app.include_router(webhooks.router, prefix="/v3")
app.add_route("/metrics", metrics_endpoint, methods=["GET"])
@app.get("/health")
async def health_check():
"""Health check endpoint for monitoring and container orchestration."""
return {"status": "ok"}
# Global exception handlers
@app.exception_handler(HonchoException)
async def honcho_exception_handler(_request: Request, exc: HonchoException):

View File

@ -446,11 +446,10 @@ async def search_peer(
...,
description="Message search parameters. Use `limit` to control the number of results returned.",
),
db: AsyncSession = db,
):
"""Search a Peer's messages, optionally filtered by various criteria."""
# take user-provided filter and add workspace_id and peer_id to it
filters = body.filters or {}
filters["workspace_id"] = workspace_id
filters["peer_id"] = peer_id
return await search(db, body.query, filters=filters, limit=body.limit)
return await search(body.query, filters=filters, limit=body.limit)

View File

@ -794,7 +794,6 @@ async def search_session(
body: schemas.MessageSearchOptions = Body(
..., description="Message search parameters"
),
db: AsyncSession = db,
):
"""
Search a Session with optional filters. Use `limit` to control the number of results returned.
@ -804,7 +803,6 @@ async def search_session(
filters["workspace_id"] = workspace_id
filters["session_id"] = session_id
return await search(
db,
body.query,
filters=filters,
limit=body.limit,

View File

@ -142,7 +142,6 @@ async def search_workspace(
body: schemas.MessageSearchOptions = Body(
..., description="Message search parameters"
),
db: AsyncSession = db,
):
"""
Search messages in a Workspace using optional filters. Use `limit` to control the number of
@ -151,7 +150,7 @@ async def search_workspace(
# take user-provided filter and add workspace_id to it
filters = body.filters or {}
filters["workspace_id"] = workspace_id
return await search(db, body.query, filters=filters, limit=body.limit)
return await search(body.query, filters=filters, limit=body.limit)
@router.get(

View File

@ -854,6 +854,7 @@ async def get_observation_context(
workspace_name: str,
session_name: str | None,
message_ids: list[str],
observer: str | None = None,
) -> list[models.Message]:
"""
Retrieve messages for given message IDs along with surrounding context.
@ -867,6 +868,8 @@ async def get_observation_context(
workspace_name: Workspace identifier
session_name: Session identifier (optional)
message_ids: List of message IDs to retrieve
observer: When provided and session_name is None, scope results
to sessions this peer belongs to
Returns:
List of messages in chronological order, including the requested messages and surrounding context
@ -874,6 +877,17 @@ async def get_observation_context(
if not message_ids:
return []
# Pre-fetch peer session scope if needed
allowed_session_names: list[str] | None = None
if observer and not session_name:
from src.crud.message import get_peer_session_names
allowed_session_names = await get_peer_session_names(
db, workspace_name, observer
)
if not allowed_session_names:
return []
# Use a CTE to get seq_in_session values for target messages
stmt = (
select(models.Message.seq_in_session)
@ -883,6 +897,8 @@ async def get_observation_context(
if session_name:
stmt = stmt.where(models.Message.session_name == session_name)
elif allowed_session_names is not None:
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
target_seqs_cte = stmt.cte("target_seqs")
@ -905,6 +921,8 @@ async def get_observation_context(
if session_name:
stmt = stmt.where(models.Message.session_name == session_name)
elif allowed_session_names is not None:
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
result = await db.execute(stmt)
messages = list(result.scalars().all())
@ -916,6 +934,7 @@ async def extract_preferences(
workspace_name: str,
session_name: str | None,
observed: str,
observer: str | None = None,
) -> dict[str, list[str]]:
"""
Extract user preferences and standing instructions from conversation history.
@ -927,6 +946,8 @@ async def extract_preferences(
workspace_name: Workspace identifier
session_name: Session identifier (optional)
observed: The peer whose preferences to extract
observer: When provided and session_name is None, scope results
to sessions this peer belongs to
Returns:
Dict with 'messages' list containing potentially relevant messages
@ -959,27 +980,26 @@ async def extract_preferences(
for query in semantic_queries:
try:
async with tracked_db("extract_preferences") as db:
snippets = await crud.search_messages(
db,
workspace_name=workspace_name,
session_name=session_name,
query=query,
limit=10,
context_window=0,
embedding=(
query_embeddings_by_query.get(query)
if query_embeddings_by_query is not None
else None
),
)
for matches, _ in snippets:
for msg in matches:
if msg.peer_name == observed:
content_key = msg.content[:100].lower()
if content_key not in seen_content:
seen_content.add(content_key)
messages.append(f"'{msg.content.strip()}'")
snippets = await crud.search_messages(
workspace_name=workspace_name,
session_name=session_name,
query=query,
limit=10,
context_window=0,
embedding=(
query_embeddings_by_query.get(query)
if query_embeddings_by_query is not None
else None
),
observer=observer,
)
for matches, _ in snippets:
for msg in matches:
if msg.peer_name == observed:
content_key = msg.content[:100].lower()
if content_key not in seen_content:
seen_content.add(content_key)
messages.append(f"'{msg.content.strip()}'")
except Exception as e:
logger.warning("Error in semantic search for '%s': %s", query, e)
@ -1265,20 +1285,19 @@ async def _handle_search_memory(ctx: ToolContext, tool_input: dict[str, Any]) ->
if ctx.agent_type == "dialectic":
limit = min(_safe_int(tool_input.get("top_k"), 20), 20)
message_output = None
async with tracked_db("tool.search_memory.fallback") as db:
snippets = await crud.search_messages(
db,
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
query=query,
limit=limit,
context_window=0,
embedding=query_embedding,
snippets = await crud.search_messages(
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
query=query,
limit=limit,
context_window=0,
embedding=query_embedding,
observer=ctx.observer,
)
if snippets:
message_output = _format_message_snippets(
snippets, f"for query '{query}'"
)
if snippets:
message_output = _format_message_snippets(
snippets, f"for query '{query}'"
)
if message_output:
return (
f"No observations yet. Message search results:\n\n{message_output}"
@ -1302,6 +1321,7 @@ async def _handle_get_observation_context(
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
message_ids=tool_input["message_ids"],
observer=ctx.observer,
)
if not messages:
return f"No messages found for IDs {tool_input['message_ids']}"
@ -1326,19 +1346,18 @@ async def _handle_search_messages(ctx: ToolContext, tool_input: dict[str, Any])
# Pre-compute embedding outside DB session to avoid holding a connection
# during the external API call (same pattern as _handle_search_memory).
query_embedding = await embedding_client.embed(query)
async with tracked_db("tool.search_messages") as db:
snippets = await crud.search_messages(
db,
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
query=query,
limit=limit,
context_window=2,
embedding=query_embedding,
)
if not snippets:
return f"No messages found for query '{query}'"
formatted = _format_message_snippets(snippets, f"for query '{query}'")
snippets = await crud.search_messages(
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
query=query,
limit=limit,
context_window=2,
embedding=query_embedding,
observer=ctx.observer,
)
if not snippets:
return f"No messages found for query '{query}'"
formatted = _format_message_snippets(snippets, f"for query '{query}'")
return formatted
@ -1352,35 +1371,32 @@ async def _handle_grep_messages(ctx: ToolContext, tool_input: dict[str, Any]) ->
_safe_int(tool_input.get("context_window"), 2), 2
) # Cap context
async with tracked_db("tool.grep_messages") as db:
snippets = await crud.grep_messages(
db,
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
text=text,
limit=limit,
context_window=context_window,
)
if not snippets:
return f"No messages found containing '{text}'"
snippets = await crud.grep_messages(
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
text=text,
limit=limit,
context_window=context_window,
observer=ctx.observer,
)
if not snippets:
return f"No messages found containing '{text}'"
# Format with pattern-based snippet extraction
snippet_texts: list[str] = []
total_matches = sum(len(matches) for matches, _ in snippets)
for i, (matches, context) in enumerate(snippets, 1):
lines: list[str] = []
for msg in context:
truncated = _extract_pattern_snippet(msg.content, text)
lines.append(
format_new_turn_with_timestamp(
truncated, msg.created_at, msg.peer_name
)
)
sess = context[0].session_name if context else "unknown"
snippet_texts.append(
f"--- Snippet {i} (session: {sess}, {len(matches)} match(es)) ---\n"
+ "\n".join(lines)
# Format with pattern-based snippet extraction
snippet_texts: list[str] = []
total_matches = sum(len(matches) for matches, _ in snippets)
for i, (matches, context) in enumerate(snippets, 1):
lines: list[str] = []
for msg in context:
truncated = _extract_pattern_snippet(msg.content, text)
lines.append(
format_new_turn_with_timestamp(truncated, msg.created_at, msg.peer_name)
)
sess = context[0].session_name if context else "unknown"
snippet_texts.append(
f"--- Snippet {i} (session: {sess}, {len(matches)} match(es)) ---\n"
+ "\n".join(lines)
)
output = (
f"Found {total_matches} messages containing '{text}' in {len(snippets)} conversation snippets:\n\n"
@ -1425,6 +1441,7 @@ async def _handle_get_messages_by_date_range(
before_date=before_date,
limit=limit,
order=order,
observer=ctx.observer,
)
msg_count = len(messages)
messages_text = (
@ -1483,31 +1500,28 @@ async def _handle_search_messages_temporal(
# Pre-compute embedding outside DB session to avoid holding a connection
# during the external API call.
query_embedding = await embedding_client.embed(query)
async with tracked_db("tool.search_messages_temporal") as db:
snippets = await crud.search_messages_temporal(
db,
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
query=query,
after_date=after_date,
before_date=before_date,
limit=limit,
context_window=context_window,
embedding=query_embedding,
)
date_filter: list[str] = []
if after_date_str:
date_filter.append(f"after {after_date_str}")
if before_date_str:
date_filter.append(f"before {before_date_str}")
filter_desc = f" ({' and '.join(date_filter)})" if date_filter else ""
snippets = await crud.search_messages_temporal(
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
query=query,
after_date=after_date,
before_date=before_date,
limit=limit,
context_window=context_window,
embedding=query_embedding,
observer=ctx.observer,
)
date_filter: list[str] = []
if after_date_str:
date_filter.append(f"after {after_date_str}")
if before_date_str:
date_filter.append(f"before {before_date_str}")
filter_desc = f" ({' and '.join(date_filter)})" if date_filter else ""
if not snippets:
return f"No messages found for query '{query}'{filter_desc}"
if not snippets:
return f"No messages found for query '{query}'{filter_desc}"
formatted = _format_message_snippets(
snippets, f"for query '{query}'{filter_desc}"
)
formatted = _format_message_snippets(snippets, f"for query '{query}'{filter_desc}")
return formatted
@ -1659,6 +1673,7 @@ async def _handle_extract_preferences(
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
observed=ctx.observed,
observer=ctx.observer,
)
messages = results.get("messages", [])

View File

@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.config import settings
from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.exceptions import ValidationException
from src.models import session_peers_table
@ -23,6 +24,13 @@ from src.vector_store import get_external_vector_store
T = TypeVar("T")
def _uses_pgvector_message_search() -> bool:
"""Return True when semantic message search can stay entirely in Postgres."""
return (
settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED
)
def reciprocal_rank_fusion(*ranked_lists: list[T], k: int = 60, limit: int) -> list[T]:
"""
Combine multiple ranked lists using Reciprocal Rank Fusion (RRF).
@ -65,122 +73,115 @@ def reciprocal_rank_fusion(*ranked_lists: list[T], k: int = 60, limit: int) -> l
return result[:limit]
async def _semantic_search(
db: AsyncSession,
query: str,
async def query_external_vector_message_ids(
workspace_name: str,
embedding_query: list[float],
limit: int,
filters: dict[str, Any] | None = None,
) -> list[models.Message]:
"""
Perform semantic search using external vector store for message embeddings.
Args:
db: Database session
query: Search query
workspace_name: Name of the workspace to search in
limit: Maximum number of results to return
filters: Optional filters to apply at vector store level (supports: session_id, peer_id)
Returns:
list of messages ordered by semantic similarity
"""
try:
embedding_query = await embedding_client.embed(query)
except ValueError as e:
raise ValidationException(
f"Query exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}."
) from e
# Query Postgres / pgvector directly
if settings.EMBED_MESSAGES and (
settings.VECTOR_STORE.TYPE == "pgvector" or not settings.VECTOR_STORE.MIGRATED
):
# Join message_embeddings with messages to get full message objects
distance_expr = models.MessageEmbedding.embedding.cosine_distance(
embedding_query
)
stmt = (
select(models.Message)
.join(
models.MessageEmbedding,
models.Message.public_id == models.MessageEmbedding.message_id,
)
.where(models.MessageEmbedding.embedding.isnot(None))
.where(models.MessageEmbedding.workspace_name == workspace_name)
)
# Apply all additional filters using the standard filter utility
# filters dict uses external names (session_id, peer_id) which apply_filter will map
# to internal column names (session_name, peer_name)
if filters:
# Create a copy with workspace added
internal_filters = filters.copy()
internal_filters["workspace_id"] = workspace_name
stmt = apply_filter(stmt, models.Message, internal_filters)
# Order by cosine distance and limit
stmt = stmt.order_by(distance_expr).limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
# FALLBACK: Use external vector store (Turbopuffer, LanceDB)
) -> list[str]:
"""Query the external vector store and return ordered message IDs."""
external_vector_store = get_external_vector_store()
if external_vector_store is None:
return []
namespace = external_vector_store.get_vector_namespace("message", workspace_name)
# Build vector store filters from the provided filters
vector_filters: dict[str, Any] = {}
if filters:
# Map external filter keys to vector store metadata keys
if "session_id" in filters:
vector_filters["session_name"] = filters["session_id"]
if "peer_id" in filters:
vector_filters["peer_name"] = filters["peer_id"]
# Query external vector store for similar message embeddings
# Since all filters are applied at the vector store level, we don't need to oversample
# Oversample: multiple chunk-level hits can map to the same message,
# so fetch extra to ensure enough unique messages after deduplication.
vector_results = await external_vector_store.query(
namespace,
embedding_query,
top_k=limit,
top_k=limit * 3,
filters=vector_filters if vector_filters else None,
)
if not vector_results:
return []
# Extract message IDs from vector metadata
# Use dict to deduplicate while preserving order (dict keys maintain insertion order in Python 3.7+)
seen_message_ids: dict[str, None] = {}
for result in vector_results:
message_id = result.metadata.get("message_id")
if message_id and message_id not in seen_message_ids:
seen_message_ids[message_id] = None
message_ids = list(seen_message_ids.keys())
return list(seen_message_ids.keys())
# Fetch messages from database by the IDs from vector search and reapply filters
semantic_query = select(models.Message).where(
models.Message.public_id.in_(message_ids)
)
semantic_query = apply_filter(semantic_query, models.Message, filters)
result = await db.execute(semantic_query)
async def fetch_messages_by_ids(
db: AsyncSession,
message_ids: list[str],
filters: dict[str, Any] | None = None,
) -> list[models.Message]:
"""Fetch messages by ID and preserve the input ordering."""
if not message_ids:
return []
stmt = select(models.Message).where(models.Message.public_id.in_(message_ids))
stmt = apply_filter(stmt, models.Message, filters)
result = await db.execute(stmt)
messages = {msg.public_id: msg for msg in result.scalars().all()}
# Return messages in order of similarity (preserving vector store order)
ordered_messages: list[models.Message] = []
for msg_id in message_ids:
if msg_id in messages:
ordered_messages.append(messages[msg_id])
return [messages[msg_id] for msg_id in message_ids if msg_id in messages]
return ordered_messages
async def _semantic_search_pgvector(
db: AsyncSession,
workspace_name: str,
embedding_query: list[float],
limit: int,
filters: dict[str, Any] | None = None,
) -> list[models.Message]:
"""
Perform semantic message search using pgvector in Postgres.
Args:
db: Database session
workspace_name: Name of the workspace to search in
embedding_query: Pre-computed embedding for the search query
limit: Maximum number of results to return
filters: Optional filters to apply to the message query
Returns:
list of messages ordered by semantic similarity
"""
distance_expr = models.MessageEmbedding.embedding.cosine_distance(embedding_query)
stmt = (
select(models.Message)
.join(
models.MessageEmbedding,
models.Message.public_id == models.MessageEmbedding.message_id,
)
.where(models.MessageEmbedding.embedding.isnot(None))
.where(models.MessageEmbedding.workspace_name == workspace_name)
)
if filters:
internal_filters = filters.copy()
internal_filters["workspace_id"] = workspace_name
stmt = apply_filter(stmt, models.Message, internal_filters)
# Oversample because a message with multiple embedding chunks can
# produce duplicate rows; we deduplicate in Python to preserve HNSW
# index usage (a DISTINCT ON subquery would prevent the index scan).
stmt = stmt.order_by(distance_expr).limit(limit * 2)
result = await db.execute(stmt)
seen: set[str] = set()
deduped: list[models.Message] = []
for msg in result.scalars().all():
if msg.public_id not in seen:
seen.add(msg.public_id)
deduped.append(msg)
return deduped[:limit]
async def _filter_by_peer_perspective(
@ -308,7 +309,6 @@ async def _fulltext_search(
async def search(
db: AsyncSession,
query: str,
*,
filters: dict[str, Any] | None = None,
@ -321,7 +321,6 @@ async def search(
are available, providing better search results than either method alone.
Args:
db: Database session
query: Search query to match against message content
filters: Optional filters to scope search (must include workspace_id for semantic search).
Special filter 'peer_perspective' will search across all messages from sessions that the peer is/was a member of,
@ -368,50 +367,81 @@ async def search(
stmt = apply_filter(stmt, models.Message, filters)
search_results: list[list[models.Message]] = []
workspace_name: str | None = None
if filters:
workspace_value = filters.get("workspace_id") or filters.get("workspace_name")
if isinstance(workspace_value, str):
workspace_name = workspace_value
semantic_limit = limit * 4 if peer_perspective_name else limit * 2
query_embedding: list[float] | None = None
semantic_message_ids: list[str] | None = None
# Perform semantic search if enabled and we have workspace context
# workspace_id is required for semantic search to determine the vector namespace
workspace_name: str | None = filters.get("workspace_id") if filters else None
if settings.EMBED_MESSAGES and isinstance(workspace_name, str):
# Type narrowing: workspace_name is guaranteed to be str in this block
# Get more results for fusion (increase if peer_perspective filtering is applied post-search)
semantic_limit = limit * 4 if peer_perspective_name else limit * 2
semantic_results = await _semantic_search(
db=db,
query=query,
workspace_name=workspace_name,
limit=semantic_limit,
filters=filters,
)
try:
query_embedding = await embedding_client.embed(query)
except ValueError as e:
raise ValidationException(
f"Query exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}."
) from e
# Apply peer_perspective filtering to semantic results if needed
# Vector store can't handle temporal filtering (joined_at/left_at), so filter post-search
if peer_perspective_name:
semantic_results = await _filter_by_peer_perspective(
db, semantic_results, workspace_name, peer_perspective_name
if not _uses_pgvector_message_search():
semantic_message_ids = await query_external_vector_message_ids(
workspace_name=workspace_name,
embedding_query=query_embedding,
limit=semantic_limit,
filters=filters,
)
search_results.append(semantic_results)
async def _run_search(active_db: AsyncSession) -> list[models.Message]:
search_results: list[list[models.Message]] = []
# Perform full-text search
# Get more results for fusion
fulltext_limit = limit * 2
fulltext_results = await _fulltext_search(
db=db, query=query, stmt=stmt, limit=fulltext_limit
)
search_results.append(fulltext_results)
if (
settings.EMBED_MESSAGES
and isinstance(workspace_name, str)
and query_embedding is not None
):
if _uses_pgvector_message_search():
semantic_results = await _semantic_search_pgvector(
db=active_db,
workspace_name=workspace_name,
embedding_query=query_embedding,
limit=semantic_limit,
filters=filters,
)
else:
semantic_results = await fetch_messages_by_ids(
db=active_db,
message_ids=semantic_message_ids or [],
filters=filters,
)
# Combine results using RRF if we have multiple search methods
if len(search_results) > 1:
# Use RRF to combine semantic and full-text results
combined_results = reciprocal_rank_fusion(*search_results, limit=limit)
elif len(search_results) == 1:
# Single search method - apply limit directly
combined_results = search_results[0]
combined_results = combined_results[:limit]
else:
# No search results
combined_results = []
if peer_perspective_name:
semantic_results = await _filter_by_peer_perspective(
active_db,
semantic_results,
workspace_name,
peer_perspective_name,
)
return combined_results
search_results.append(semantic_results)
fulltext_results = await _fulltext_search(
db=active_db,
query=query,
stmt=stmt,
limit=limit * 2,
)
search_results.append(fulltext_results)
if len(search_results) > 1:
return reciprocal_rank_fusion(*search_results, limit=limit)
if len(search_results) == 1:
return search_results[0][:limit]
return []
async with tracked_db("search.messages") as managed_db:
combined_results = await _run_search(managed_db)
for message in combined_results:
managed_db.expunge(message)
return combined_results

View File

@ -9,42 +9,41 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from src.crud.webhook import list_webhook_endpoints
from src.dependencies import tracked_db
from src.utils.formatting import utc_now_iso
from src.utils.queue_payload import WebhookPayload
logger = logging.getLogger(__name__)
async def deliver_webhook(
db: AsyncSession, payload: WebhookPayload, workspace_name: str
) -> None:
async def deliver_webhook(payload: WebhookPayload, workspace_name: str) -> None:
"""
Deliver a single webhook event to its configured endpoints.
"""
async with httpx.AsyncClient(timeout=30.0) as client:
try:
try:
async with tracked_db("webhook.deliver") as db:
webhook_urls = await _get_webhook_urls(db, workspace_name)
if not webhook_urls:
logger.debug(
f"No webhook endpoints for workspace {workspace_name}, skipping."
)
return
event_payload = {
"type": payload.event_type,
"data": payload.data,
"timestamp": utc_now_iso(),
}
event_json = json.dumps(
event_payload, separators=(",", ":"), sort_keys=True
if not webhook_urls:
logger.debug(
f"No webhook endpoints for workspace {workspace_name}, skipping."
)
return
try:
signature = _generate_webhook_signature(event_json)
except ValueError:
logger.exception("Failed to generate webhook signature")
return
event_payload = {
"type": payload.event_type,
"data": payload.data,
"timestamp": utc_now_iso(),
}
event_json = json.dumps(event_payload, separators=(",", ":"), sort_keys=True)
try:
signature = _generate_webhook_signature(event_json)
except ValueError:
logger.exception("Failed to generate webhook signature")
return
async with httpx.AsyncClient(timeout=30.0) as client:
tasks = [
client.post(
url=url,
@ -73,10 +72,10 @@ async def deliver_webhook(
f"Failed delivery for {payload.event_type} to {url}. Exception: {result}"
)
except httpx.RequestError:
logger.exception(f"Error sending webhook for {workspace_name}.")
except Exception:
logger.exception("Unexpected error delivering webhook.")
except httpx.RequestError:
logger.exception(f"Error sending webhook for {workspace_name}.")
except Exception:
logger.exception("Unexpected error delivering webhook.")
async def _get_webhook_urls(db: AsyncSession, workspace_name: str) -> list[str]:

View File

@ -441,10 +441,6 @@ class BaseRunner(ABC, Generic[ResultT]):
f"{self.get_metrics_prefix()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
)
self.logger: Logger = configure_logging()
# Semaphore for rate limiting concurrent item execution
self._concurrency_semaphore: asyncio.Semaphore | None = (
asyncio.Semaphore(config.max_concurrent) if config.max_concurrent else None
)
# -------------------------------------------------------------------------
# Abstract methods - must be implemented by subclasses
@ -559,59 +555,64 @@ class BaseRunner(ABC, Generic[ResultT]):
print(f"Limiting to {self.config.max_concurrent} concurrent item(s)")
overall_start = time.time()
all_results: list[ResultT] = []
all_results: list[ResultT | None] = [None] * len(items)
# Process in batches
batch_size = self.config.batch_size
for i in range(0, len(items), batch_size):
batch = items[i : i + batch_size]
batch_num = (i // batch_size) + 1
total_batches = (len(items) + batch_size - 1) // batch_size
# Two-level concurrency:
# - inflight_sem limits how many items may be in the pipeline at once
# - active_sem limits how many items may actively hit Honcho at once
# Items release active_sem while waiting on queue polling so other work
# can progress, but inflight_sem prevents an unlimited thundering herd.
concurrency = self.config.max_concurrent or self.config.batch_size
inflight_sem = asyncio.Semaphore(concurrency)
active_sem = asyncio.Semaphore(concurrency)
print(f"\n{'=' * 60}")
print(f"Processing batch {batch_num}/{total_batches} ({len(batch)} items)")
print(f"{'=' * 60}")
async def _run_item(index: int, item: Any) -> None:
async with inflight_sem:
result = await self.execute_item(
item,
self._get_honcho_url(index),
active_sem=active_sem,
)
all_results[index] = result
# Run items in batch concurrently (with optional rate limiting)
batch_results = await asyncio.gather(
*[
self._execute_item_with_limit(item, self._get_honcho_url(i + idx))
for idx, item in enumerate(batch)
]
)
all_results.extend(batch_results)
tasks = [
asyncio.create_task(_run_item(index, item))
for index, item in enumerate(items)
]
await asyncio.gather(*tasks)
overall_duration = time.time() - overall_start
# Finalize metrics
self.metrics_collector.finalize_collection()
return all_results, overall_duration
missing_indexes = [
index for index, result in enumerate(all_results) if result is None
]
if missing_indexes:
raise RuntimeError(
f"Missing benchmark results for item indexes: {missing_indexes}"
)
async def _execute_item_with_limit(self, item: Any, honcho_url: str) -> ResultT:
"""Wrapper that applies concurrency limiting if configured."""
if self._concurrency_semaphore:
async with self._concurrency_semaphore:
return await self.execute_item(item, honcho_url)
return await self.execute_item(item, honcho_url)
return [cast(ResultT, result) for result in all_results], overall_duration
async def execute_item(self, item: Any, honcho_url: str) -> ResultT:
async def execute_item(
self,
item: Any,
honcho_url: str,
active_sem: asyncio.Semaphore | None = None,
) -> ResultT:
"""
Execute a single benchmark item.
This method orchestrates the standard flow:
1. Create workspace and client
2. Setup peers and session
3. Ingest messages
4. Wait for queue to empty
5. Trigger dreams
6. Execute questions
7. Cleanup (if configured)
Active work (setup, ingest, dream scheduling, query execution) acquires
``active_sem`` when provided. Idle queue polling releases that slot so
other items can continue making forward progress.
Args:
item: The item to process
honcho_url: URL of the Honcho instance to use
active_sem: Optional semaphore limiting active I/O phases
Returns:
Result for this item
@ -635,21 +636,22 @@ class BaseRunner(ABC, Generic[ResultT]):
start_time = time.time()
try:
# Setup peers
await self.setup_peers(ctx, item)
# Setup peers/session and ingest under the active semaphore.
if active_sem:
await active_sem.acquire()
try:
await self.setup_peers(ctx, item)
await self.setup_session(ctx, item)
# Setup session
await self.setup_session(ctx, item)
# Ingest messages
print(f"[{workspace_id}] Ingesting messages...")
message_count = await self.ingest_messages(ctx, item)
print(f"[{workspace_id}] Ingested {message_count} messages")
print(f"[{workspace_id}] Ingesting messages...")
message_count = await self.ingest_messages(ctx, item)
print(f"[{workspace_id}] Ingested {message_count} messages")
finally:
if active_sem:
active_sem.release()
# Wait for deriver queue
print(f"[{workspace_id}] Waiting for deriver queue to empty...")
await asyncio.sleep(1) # Give time for tasks to be queued
queue_empty = await self._wait_for_queue_empty(ctx.honcho_client)
if not queue_empty:
raise TimeoutError(
@ -670,20 +672,72 @@ class BaseRunner(ABC, Generic[ResultT]):
+ f"{len(dream_observers)} observer(s) across "
+ f"{len(dream_session_ids)} session(s)..."
)
for observer in dream_observers:
for dream_session_id in dream_session_ids:
success = await self._trigger_dream(
ctx.honcho_client, workspace_id, observer, dream_session_id
)
if not success:
if self.config.skip_dream:
print(f"[{workspace_id}] Skipping dreams (--skip-dream)")
else:
async def _schedule_dream(
observer: str,
session_id: str,
) -> bool:
try:
if active_sem:
await active_sem.acquire()
try:
await ctx.honcho_client.aio.schedule_dream(
observer=observer,
session=session_id,
observed=observer,
)
finally:
if active_sem:
active_sem.release()
print(
f"[{workspace_id}] Warning: Dream for {observer} in "
+ f"session {dream_session_id} did not complete"
f"[{workspace_id}] Dream triggered for "
+ f"{observer}/{observer} in {session_id}"
)
return True
except Exception as e:
print(
f"[{workspace_id}] ERROR: Dream trigger exception "
+ f"for {observer} in {session_id}: {e}"
)
return False
dream_results = await asyncio.gather(
*[
_schedule_dream(observer, dream_session_id)
for observer in dream_observers
for dream_session_id in dream_session_ids
]
)
if all(dream_results):
success = await self._wait_for_queue_empty(ctx.honcho_client)
if success:
print(f"[{workspace_id}] All dreams completed")
else:
print(f"[{workspace_id}] Dreams timed out")
elif any(dream_results):
failed = [i for i, ok in enumerate(dream_results) if not ok]
print(
f"[{workspace_id}] Warning: {len(failed)} of "
+ f"{len(dream_results)} dream schedules failed"
)
await self._wait_for_queue_empty(ctx.honcho_client)
else:
print(f"[{workspace_id}] Warning: No dreams were scheduled")
# Execute questions
print(f"[{workspace_id}] Executing questions...")
result = await self.execute_questions(ctx, item)
if active_sem:
await active_sem.acquire()
try:
result = await self.execute_questions(ctx, item)
finally:
if active_sem:
active_sem.release()
# Cleanup
if self.config.cleanup_workspace:
@ -765,13 +819,15 @@ class BaseRunner(ABC, Generic[ResultT]):
async def _wait_for_queue_empty(
self, honcho_client: Honcho, session_id: str | None = None
) -> bool:
"""Wait for the deriver queue to be empty."""
"""Wait for the deriver queue to be empty with exponential backoff."""
start_time = time.time()
delay = 0.2
while True:
try:
status = await honcho_client.aio.queue_status(session=session_id)
except Exception:
await asyncio.sleep(1)
await asyncio.sleep(delay)
delay = min(delay * 1.5, 2.0)
if time.time() - start_time >= self.config.timeout_seconds:
return False
continue
@ -781,7 +837,8 @@ class BaseRunner(ABC, Generic[ResultT]):
if time.time() - start_time >= self.config.timeout_seconds:
return False
await asyncio.sleep(1)
await asyncio.sleep(delay)
delay = min(delay * 1.5, 2.0)
async def _trigger_dream(
self,
@ -815,8 +872,6 @@ class BaseRunner(ABC, Generic[ResultT]):
print(f"[{workspace_id}] Dream triggered for {observer}/{observed}")
# Wait for dream to complete
await asyncio.sleep(2)
success = await self._wait_for_queue_empty(honcho_client)
if success:
print(f"[{workspace_id}] Dream for {observer} completed")

View File

@ -752,8 +752,11 @@ def mock_tracked_db(db_engine: AsyncEngine, request: pytest.FixtureRequest):
patch("src.dialectic.chat.tracked_db", mock_tracked_db_context),
patch("src.utils.summarizer.tracked_db", mock_tracked_db_context),
patch("src.webhooks.events.tracked_db", mock_tracked_db_context),
patch("src.webhooks.webhook_delivery.tracked_db", mock_tracked_db_context),
patch("src.utils.agent_tools.tracked_db", mock_tracked_db_context),
patch("src.utils.search.tracked_db", mock_tracked_db_context),
patch("src.crud.document.tracked_db", mock_tracked_db_context),
patch("src.crud.message.tracked_db", mock_tracked_db_context),
patch("src.dialectic.core.tracked_db", mock_tracked_db_context),
patch("src.dreamer.specialists.tracked_db", mock_tracked_db_context),
patch("src.dreamer.surprisal.tracked_db", mock_tracked_db_context),

View File

@ -4,6 +4,8 @@ Tests for message embedding functionality.
These tests verify that message embeddings are created, stored, and can be searched.
"""
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
import pytest
@ -12,7 +14,9 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.config import settings
from src.crud import create_messages
from src.crud import message as message_crud
from src.models import Peer, Workspace
from src.schemas import MessageCreate
from src.utils.search import search
@ -240,8 +244,7 @@ async def test_semantic_search_when_embeddings_enabled(
initial_call_count: int = mock_openai_embeddings["embed"].call_count
search_results = await search(
db=db_session,
query=search_query,
search_query,
filters={
"workspace_id": test_workspace.name,
"session_id": test_session.name,
@ -257,6 +260,212 @@ async def test_semantic_search_when_embeddings_enabled(
assert created_message.public_id in found_message_ids
@pytest.mark.asyncio
async def test_search_messages_external_lookup_happens_before_tracked_db(
monkeypatch: pytest.MonkeyPatch,
):
"""External semantic lookup should finish before opening tracked_db."""
monkeypatch.setattr(settings.VECTOR_STORE, "MIGRATED", True)
monkeypatch.setattr(settings.VECTOR_STORE, "TYPE", "external")
call_order: list[str] = []
message = models.Message(
workspace_name="workspace",
session_name="session",
peer_name="peer",
content="Relevant external search result",
seq_in_session=1,
token_count=5,
created_at=datetime.now(timezone.utc),
)
class FakeDb:
def expunge(self, _obj: object) -> None:
call_order.append("expunge")
fake_db = FakeDb()
async def fake_search_messages_external(
workspace_name: str,
query_embedding: list[float],
limit: int,
*,
session_name: str | None = None,
allowed_session_names: list[str] | None = None,
after_date: datetime | None = None,
before_date: datetime | None = None,
) -> list[str]:
_ = (
workspace_name,
query_embedding,
limit,
session_name,
allowed_session_names,
after_date,
before_date,
)
call_order.append("external")
return ["message-1"]
async def fake_fetch_messages_by_ids(
db: FakeDb,
workspace_name: str,
message_ids: list[str],
*,
after_date: datetime | None = None,
before_date: datetime | None = None,
) -> list[models.Message]:
_ = (workspace_name, message_ids, after_date, before_date)
assert db is fake_db
call_order.append("fetch")
return [message]
async def fake_build_merged_snippets(
db: FakeDb,
workspace_name: str,
matched_messages: list[models.Message],
context_window: int,
) -> list[tuple[list[models.Message], list[models.Message]]]:
_ = (workspace_name, context_window)
assert db is fake_db
assert matched_messages == [message]
call_order.append("build")
return [([message], [message])]
@asynccontextmanager
async def fake_tracked_db(_operation_name: str | None = None):
call_order.append("enter")
yield fake_db
call_order.append("exit")
monkeypatch.setattr(
message_crud, "_search_messages_external", fake_search_messages_external
)
monkeypatch.setattr(
message_crud, "_fetch_messages_by_ids", fake_fetch_messages_by_ids
)
monkeypatch.setattr(
message_crud, "_build_merged_snippets", fake_build_merged_snippets
)
monkeypatch.setattr(message_crud, "tracked_db", fake_tracked_db)
snippets = await message_crud.search_messages(
workspace_name="workspace",
session_name="session",
query="relevant query",
embedding=[0.1, 0.2, 0.3],
)
assert snippets == [([message], [message])]
assert call_order.index("external") < call_order.index("enter")
@pytest.mark.asyncio
async def test_search_messages_temporal_external_lookup_happens_before_tracked_db(
monkeypatch: pytest.MonkeyPatch,
):
"""Temporal external semantic lookup should finish before opening tracked_db."""
monkeypatch.setattr(settings.VECTOR_STORE, "MIGRATED", True)
monkeypatch.setattr(settings.VECTOR_STORE, "TYPE", "external")
call_order: list[str] = []
after_date = datetime(2024, 1, 1, tzinfo=timezone.utc)
before_date = datetime(2024, 12, 31, tzinfo=timezone.utc)
message = models.Message(
workspace_name="workspace",
session_name="session",
peer_name="peer",
content="Relevant temporal external search result",
seq_in_session=1,
token_count=5,
created_at=datetime.now(timezone.utc),
)
class FakeDb:
def expunge(self, _obj: object) -> None:
call_order.append("expunge")
fake_db = FakeDb()
async def fake_search_messages_external(
workspace_name: str,
query_embedding: list[float],
limit: int,
*,
session_name: str | None = None,
allowed_session_names: list[str] | None = None,
after_date: datetime | None = None,
before_date: datetime | None = None,
) -> list[str]:
_ = (
workspace_name,
query_embedding,
limit,
session_name,
allowed_session_names,
)
assert after_date is not None
assert before_date is not None
call_order.append("external")
return ["message-1"]
async def fake_fetch_messages_by_ids(
db: FakeDb,
workspace_name: str,
message_ids: list[str],
*,
after_date: datetime | None = None,
before_date: datetime | None = None,
) -> list[models.Message]:
_ = (workspace_name, message_ids)
assert db is fake_db
assert after_date is not None
assert before_date is not None
call_order.append("fetch")
return [message]
async def fake_build_merged_snippets(
db: FakeDb,
workspace_name: str,
matched_messages: list[models.Message],
context_window: int,
) -> list[tuple[list[models.Message], list[models.Message]]]:
_ = (workspace_name, context_window)
assert db is fake_db
assert matched_messages == [message]
call_order.append("build")
return [([message], [message])]
@asynccontextmanager
async def fake_tracked_db(_operation_name: str | None = None):
call_order.append("enter")
yield fake_db
call_order.append("exit")
monkeypatch.setattr(
message_crud, "_search_messages_external", fake_search_messages_external
)
monkeypatch.setattr(
message_crud, "_fetch_messages_by_ids", fake_fetch_messages_by_ids
)
monkeypatch.setattr(
message_crud, "_build_merged_snippets", fake_build_merged_snippets
)
monkeypatch.setattr(message_crud, "tracked_db", fake_tracked_db)
snippets = await message_crud.search_messages_temporal(
workspace_name="workspace",
session_name="session",
query="relevant query",
after_date=after_date,
before_date=before_date,
embedding=[0.1, 0.2, 0.3],
)
assert snippets == [([message], [message])]
assert call_order.index("external") < call_order.index("enter")
@pytest.mark.asyncio
async def test_message_chunking_creates_multiple_embeddings(
db_session: AsyncSession,

View File

@ -136,5 +136,8 @@ def mock_tracked_db(ts_db_session: async_sessionmaker[AsyncSession]):
patch("src.dialectic.chat.tracked_db", ts_tracked_db),
patch("src.utils.summarizer.tracked_db", ts_tracked_db),
patch("src.webhooks.events.tracked_db", ts_tracked_db),
patch("src.webhooks.webhook_delivery.tracked_db", ts_tracked_db),
patch("src.utils.search.tracked_db", ts_tracked_db),
patch("src.crud.message.tracked_db", ts_tracked_db),
):
yield

View File

@ -6,7 +6,7 @@ import pytest
from nanoid import generate as generate_nanoid
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src import crud, models
from src.utils.search import search
@ -62,11 +62,10 @@ async def test_peer_perspective_search_single_session(
created_at=join_time + datetime.timedelta(seconds=2),
)
db_session.add_all([msg1, msg2])
await db_session.flush()
await db_session.commit()
# Search with peer_perspective filter
results = await search(
db_session,
"Message",
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
limit=10,
@ -132,11 +131,10 @@ async def test_peer_perspective_search_multiple_sessions(
created_at=join_time + datetime.timedelta(seconds=2),
)
db_session.add_all([msg1, msg2])
await db_session.flush()
await db_session.commit()
# Search with peer_perspective filter
results = await search(
db_session,
"Message",
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
limit=10,
@ -212,11 +210,10 @@ async def test_peer_perspective_search_temporal_constraints(
created_at=leave_time + datetime.timedelta(seconds=1),
)
db_session.add_all([msg_before, msg_during, msg_after])
await db_session.flush()
await db_session.commit()
# Search with peer_perspective filter
results = await search(
db_session,
"Message",
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
limit=10,
@ -279,11 +276,10 @@ async def test_peer_perspective_search_active_member(
created_at=join_time + datetime.timedelta(seconds=100),
)
db_session.add_all([msg1, msg2])
await db_session.flush()
await db_session.commit()
# Search with peer_perspective filter
results = await search(
db_session,
"Message",
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
limit=10,
@ -339,11 +335,10 @@ async def test_peer_perspective_search_no_sessions(
created_at=join_time + datetime.timedelta(seconds=1),
)
db_session.add(msg)
await db_session.flush()
await db_session.commit()
# Search with peer_perspective filter for peer1 (not in any sessions)
results = await search(
db_session,
"Message",
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
limit=10,
@ -408,11 +403,10 @@ async def test_peer_perspective_search_boundary_timestamps(
created_at=leave_time, # Exact leave time
)
db_session.add_all([msg_at_join, msg_at_leave])
await db_session.flush()
await db_session.commit()
# Search with peer_perspective filter
results = await search(
db_session,
"Message",
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
limit=10,
@ -422,3 +416,291 @@ async def test_peer_perspective_search_boundary_timestamps(
assert len(results) == 2
assert msg_at_join.public_id in [m.public_id for m in results]
assert msg_at_leave.public_id in [m.public_id for m in results]
# =============================================================================
# Tests for observer scoping in CRUD message functions
# =============================================================================
async def _setup_multi_session_workspace(db_session: AsyncSession):
"""Helper: create workspace with 2 sessions, 2 peers. peer1 only in session1."""
workspace = models.Workspace(name=generate_nanoid())
db_session.add(workspace)
await db_session.flush()
peer1 = models.Peer(name="observer", workspace_name=workspace.name)
peer2 = models.Peer(name="other", workspace_name=workspace.name)
db_session.add_all([peer1, peer2])
await db_session.flush()
session1 = models.Session(name="session_visible", workspace_name=workspace.name)
session2 = models.Session(name="session_hidden", workspace_name=workspace.name)
db_session.add_all([session1, session2])
await db_session.flush()
join_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
minutes=10
)
# peer1 is only in session1
await db_session.execute(
models.session_peers_table.insert().values(
workspace_name=workspace.name,
session_name=session1.name,
peer_name=peer1.name,
joined_at=join_time,
left_at=None,
)
)
# peer2 is in both sessions
for s in [session1, session2]:
await db_session.execute(
models.session_peers_table.insert().values(
workspace_name=workspace.name,
session_name=s.name,
peer_name=peer2.name,
joined_at=join_time,
left_at=None,
)
)
await db_session.flush()
msg_visible = models.Message(
content="visible message with keyword",
session_name=session1.name,
peer_name=peer2.name,
workspace_name=workspace.name,
seq_in_session=1,
created_at=join_time + datetime.timedelta(seconds=1),
)
msg_hidden = models.Message(
content="hidden message with keyword",
session_name=session2.name,
peer_name=peer2.name,
workspace_name=workspace.name,
seq_in_session=1,
created_at=join_time + datetime.timedelta(seconds=2),
)
db_session.add_all([msg_visible, msg_hidden])
await db_session.commit()
return workspace, peer1, peer2, session1, session2, msg_visible, msg_hidden
@pytest.mark.asyncio
async def test_grep_messages_observer_scoping_excludes_non_member_sessions(
db_session: AsyncSession,
):
"""grep_messages with observer excludes messages from sessions the observer isn't in."""
(
workspace,
peer1,
_,
_,
_,
msg_visible,
msg_hidden,
) = await _setup_multi_session_workspace(db_session)
# Without scoping: both messages found
results_unscoped = await crud.grep_messages(
workspace_name=workspace.name,
session_name=None,
text="keyword",
)
all_matched_ids = [m.public_id for matches, _ in results_unscoped for m in matches]
assert msg_visible.public_id in all_matched_ids
assert msg_hidden.public_id in all_matched_ids
# With observer scoping: only visible message found
results_scoped = await crud.grep_messages(
workspace_name=workspace.name,
session_name=None,
text="keyword",
observer=peer1.name,
)
scoped_ids = [m.public_id for matches, _ in results_scoped for m in matches]
assert msg_visible.public_id in scoped_ids
assert msg_hidden.public_id not in scoped_ids
@pytest.mark.asyncio
async def test_get_messages_by_date_range_observer_scoping(
db_session: AsyncSession,
):
"""get_messages_by_date_range with observer excludes non-member sessions."""
(
workspace,
peer1,
_,
_,
_,
msg_visible,
msg_hidden,
) = await _setup_multi_session_workspace(db_session)
# Without scoping
results_unscoped = await crud.get_messages_by_date_range(
db_session,
workspace_name=workspace.name,
session_name=None,
)
unscoped_ids = [m.public_id for m in results_unscoped]
assert msg_visible.public_id in unscoped_ids
assert msg_hidden.public_id in unscoped_ids
# With observer scoping
results_scoped = await crud.get_messages_by_date_range(
db_session,
workspace_name=workspace.name,
session_name=None,
observer=peer1.name,
)
scoped_ids = [m.public_id for m in results_scoped]
assert msg_visible.public_id in scoped_ids
assert msg_hidden.public_id not in scoped_ids
@pytest.mark.asyncio
async def test_grep_messages_observer_scoping_noop_when_session_provided(
db_session: AsyncSession,
):
"""When session_name is provided, observer is ignored."""
(
workspace,
peer1,
_,
_,
session_hidden,
_,
msg_hidden,
) = await _setup_multi_session_workspace(db_session)
results = await crud.grep_messages(
workspace_name=workspace.name,
session_name=session_hidden.name,
text="keyword",
observer=peer1.name,
)
matched_ids = [m.public_id for matches, _ in results for m in matches]
assert msg_hidden.public_id in matched_ids
@pytest.mark.asyncio
async def test_grep_messages_observer_scoping_empty_when_no_sessions(
db_session: AsyncSession,
):
"""Observer not in any sessions returns empty results."""
workspace = models.Workspace(name=generate_nanoid())
db_session.add(workspace)
await db_session.flush()
loner = models.Peer(name="loner", workspace_name=workspace.name)
other = models.Peer(name="other", workspace_name=workspace.name)
db_session.add_all([loner, other])
await db_session.flush()
session = models.Session(name="s1", workspace_name=workspace.name)
db_session.add(session)
await db_session.flush()
await db_session.execute(
models.session_peers_table.insert().values(
workspace_name=workspace.name,
session_name=session.name,
peer_name=other.name,
joined_at=datetime.datetime.now(datetime.timezone.utc),
left_at=None,
)
)
await db_session.flush()
msg = models.Message(
content="some keyword content",
session_name=session.name,
peer_name=other.name,
workspace_name=workspace.name,
seq_in_session=1,
created_at=datetime.datetime.now(datetime.timezone.utc),
)
db_session.add(msg)
await db_session.commit()
results = await crud.grep_messages(
workspace_name=workspace.name,
session_name=None,
text="keyword",
observer=loner.name,
)
assert results == []
@pytest.mark.asyncio
async def test_grep_messages_observer_scoping_left_session_still_visible(
db_session: AsyncSession,
):
"""Observer who left a session still sees all messages in that session.
Any membership record (regardless of left_at) grants full session visibility.
"""
workspace = models.Workspace(name=generate_nanoid())
db_session.add(workspace)
await db_session.flush()
observer = models.Peer(name="obs", workspace_name=workspace.name)
other = models.Peer(name="other", workspace_name=workspace.name)
db_session.add_all([observer, other])
await db_session.flush()
session = models.Session(name="s1", workspace_name=workspace.name)
db_session.add(session)
await db_session.flush()
base_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
minutes=10
)
join_time = base_time
leave_time = base_time + datetime.timedelta(minutes=5)
await db_session.execute(
models.session_peers_table.insert().values(
workspace_name=workspace.name,
session_name=session.name,
peer_name=observer.name,
joined_at=join_time,
left_at=leave_time,
)
)
await db_session.flush()
# Message during membership
msg_during = models.Message(
content="keyword during",
session_name=session.name,
peer_name=other.name,
workspace_name=workspace.name,
seq_in_session=1,
created_at=join_time + datetime.timedelta(minutes=2),
)
# Message after observer left — still visible because any membership grants full access
msg_after = models.Message(
content="keyword after",
session_name=session.name,
peer_name=other.name,
workspace_name=workspace.name,
seq_in_session=2,
created_at=leave_time + datetime.timedelta(minutes=1),
)
db_session.add_all([msg_during, msg_after])
await db_session.commit()
results = await crud.grep_messages(
workspace_name=workspace.name,
session_name=None,
text="keyword",
observer=observer.name,
)
matched_ids = [m.public_id for matches, _ in results for m in matches]
assert msg_during.public_id in matched_ids
assert msg_after.public_id in matched_ids

View File

@ -29,6 +29,7 @@ from src.utils.agent_tools import (
_handle_grep_messages, # pyright: ignore[reportPrivateUsage]
_handle_search_memory, # pyright: ignore[reportPrivateUsage]
_handle_search_messages, # pyright: ignore[reportPrivateUsage]
_handle_search_messages_temporal, # pyright: ignore[reportPrivateUsage]
_handle_update_peer_card, # pyright: ignore[reportPrivateUsage]
create_observations,
create_tool_executor,
@ -528,15 +529,15 @@ class TestSearchMemory:
return []
async def fake_search_messages(
db: AsyncSession,
workspace_name: str,
session_name: str | None,
query: str,
limit: int = 10,
context_window: int = 2,
embedding: list[float] | None = None,
observer: str | None = None,
) -> list[tuple[list[models.Message], list[models.Message]]]:
_ = (db, workspace_name, session_name, query, limit, context_window)
_ = (workspace_name, session_name, query, limit, context_window, observer)
fallback_embeddings.append(embedding)
msg = models.Message(
workspace_name=ctx.workspace_name,
@ -610,6 +611,78 @@ class TestGrepMessages:
assert "ERROR" in result
@pytest.mark.asyncio
class TestSearchMessagesTemporal:
"""Tests for _handle_search_messages_temporal."""
async def test_reuses_precomputed_embedding(
self,
make_tool_context: Callable[..., ToolContext],
monkeypatch: pytest.MonkeyPatch,
):
"""Embeds once and forwards the precomputed embedding to CRUD search."""
ctx = make_tool_context()
embed_calls: list[str] = []
forwarded_embeddings: list[list[float] | None] = []
async def fake_embed(query: str) -> list[float]:
embed_calls.append(query)
return [0.9, 0.1, 0.3]
async def fake_search_messages_temporal(
workspace_name: str,
session_name: str | None,
query: str,
after_date: datetime | None = None,
before_date: datetime | None = None,
limit: int = 10,
context_window: int = 2,
embedding: list[float] | None = None,
observer: str | None = None,
) -> list[tuple[list[models.Message], list[models.Message]]]:
_ = (
workspace_name,
session_name,
query,
after_date,
before_date,
limit,
context_window,
observer,
)
forwarded_embeddings.append(embedding)
msg = models.Message(
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
peer_name=ctx.observed,
content="Relevant temporal fallback message",
seq_in_session=1,
token_count=5,
created_at=datetime.now(timezone.utc),
)
return [([msg], [msg])]
monkeypatch.setattr("src.utils.agent_tools.embedding_client.embed", fake_embed)
monkeypatch.setattr(
"src.utils.agent_tools.crud.search_messages_temporal",
fake_search_messages_temporal,
)
result = await _handle_search_messages_temporal(
ctx,
{
"query": "when did this happen",
"after_date": "2024-01-01",
"before_date": "2024-12-31",
},
)
assert "Found" in result
assert embed_calls == ["when did this happen"]
assert forwarded_embeddings == [[0.9, 0.1, 0.3]]
@pytest.mark.asyncio
class TestGetMessagesByDateRange:
"""Tests for _handle_get_messages_by_date_range."""
@ -991,15 +1064,15 @@ class TestExtractPreferences:
embedding_args: list[list[float] | None] = []
async def fake_search_messages(
_db: AsyncSession,
workspace_name: str,
session_name: str | None,
query: str,
limit: int,
context_window: int,
embedding: list[float] | None,
observer: str | None = None,
) -> list[tuple[list[models.Message], list[models.Message]]]:
_ = (limit, context_window)
_ = (limit, context_window, observer)
embedding_args.append(embedding)
msg = models.Message(
workspace_name=workspace_name,
@ -1292,3 +1365,55 @@ class TestObservationLockRegistry:
# All 100 entries should be cleaned up
remaining = sum(1 for k in _observation_locks if k[0].startswith("ws_growth_"))
assert remaining == 0
@pytest.mark.asyncio
class TestObserverPeerNameWiring:
"""Tests that tool handlers pass observer to CRUD functions."""
async def test_grep_messages_passes_observer(
self,
make_tool_context: Callable[..., ToolContext],
monkeypatch: pytest.MonkeyPatch,
):
"""_handle_grep_messages passes ctx.observer as observer."""
ctx = make_tool_context()
captured_kwargs: dict[str, Any] = {}
async def fake_grep_messages(
**kwargs: Any,
) -> list[tuple[list[models.Message], list[models.Message]]]:
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(
"src.utils.agent_tools.crud.grep_messages", fake_grep_messages
)
await _handle_grep_messages(ctx, {"text": "hello"})
assert captured_kwargs["observer"] == ctx.observer
async def test_get_messages_by_date_range_passes_observer(
self,
make_tool_context: Callable[..., ToolContext],
monkeypatch: pytest.MonkeyPatch,
):
"""_handle_get_messages_by_date_range passes ctx.observer as observer."""
ctx = make_tool_context()
captured_kwargs: dict[str, Any] = {}
async def fake_get_messages_by_date_range(
_db: Any, **kwargs: Any
) -> list[models.Message]:
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(
"src.utils.agent_tools.crud.get_messages_by_date_range",
fake_get_messages_by_date_range,
)
await _handle_get_messages_by_date_range(ctx, {"after_date": "2024-01-01"})
assert captured_kwargs["observer"] == ctx.observer

View File

@ -121,7 +121,7 @@ async def test_deliver_webhook_skips_when_no_urls(
)
payload = WebhookPayload(event_type="peer.created", data={"id": "p_123"})
await webhook_delivery.deliver_webhook(AsyncMock(), payload, "workspace-a")
await webhook_delivery.deliver_webhook(payload, "workspace-a")
assert fake_client.calls == []
@ -162,7 +162,7 @@ async def test_deliver_webhook_posts_signed_payload_to_each_endpoint(
event_type="message.created",
data={"id": "m_1", "workspace": "workspace-a"},
)
await webhook_delivery.deliver_webhook(AsyncMock(), payload, "workspace-a")
await webhook_delivery.deliver_webhook(payload, "workspace-a")
expected_event_json = json.dumps(
{
@ -210,7 +210,7 @@ async def test_deliver_webhook_handles_signature_generation_failure(
monkeypatch.setattr(httpx, "AsyncClient", async_client_factory)
payload = WebhookPayload(event_type="workspace.updated", data={"id": "ws_1"})
await webhook_delivery.deliver_webhook(AsyncMock(), payload, "workspace-a")
await webhook_delivery.deliver_webhook(payload, "workspace-a")
assert fake_client.calls == []
@ -233,4 +233,4 @@ async def test_deliver_webhook_catches_request_errors(
monkeypatch.setattr(httpx, "AsyncClient", async_client_factory)
payload = WebhookPayload(event_type="workspace.updated", data={"id": "ws_1"})
await webhook_delivery.deliver_webhook(AsyncMock(), payload, "workspace-a")
await webhook_delivery.deliver_webhook(payload, "workspace-a")