Honcho 2.1.0 "ROTE" deriver (#160)

* feat: update SDKs to core 1.2.0

* feat: 2.1.0 introduce ROTE deriver/dialectic
chore: refactor repo

* fix: get typescript sdk tests working again, bump version numbers

* chore: cleanup

* chore: update unit test provider config

* fix: remove "backup" query gen

* fix: remove old utils from conftest
This commit is contained in:
doria 2025-07-16 18:02:43 -04:00 committed by GitHub
parent 27bde3d6db
commit a14899521c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
120 changed files with 9693 additions and 11130 deletions

View File

@ -12,6 +12,11 @@ FASTAPI_HOST=0.0.0.0
FASTAPI_PORT=8000
# SESSION_PEERS_LIMIT=10
# Embedding settings
# EMBED_MESSAGES=true
# MAX_EMBEDDING_TOKENS=8192
# MAX_EMBEDDING_TOKENS_PER_REQUEST=300000
# =============================================================================
# Database Settings (REQUIRED)
# =============================================================================
@ -21,6 +26,7 @@ DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
# Optional database settings
# DB_SCHEMA=public
# DB_POOL_CLASS=default
# DB_POOL_SIZE=10
# DB_MAX_OVERFLOW=20
# DB_POOL_TIMEOUT=30
@ -63,48 +69,43 @@ LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here
# LLM Configuration
# =============================================================================
# Global LLM settings
# LLM_DEFAULT_MAX_TOKENS=1000
# LLM_DEFAULT_TEMPERATURE=0.0
# Dialectic LLM settings
# LLM_DIALECTIC_PROVIDER=anthropic
# LLM_DIALECTIC_MODEL=claude-3-7-sonnet-20250219
# Query generation LLM settings
# LLM_QUERY_GENERATION_PROVIDER=groq
# LLM_QUERY_GENERATION_MODEL=llama-3.1-8b-instant
# Summarization LLM settings
# LLM_SUMMARY_PROVIDER=google
# LLM_SUMMARY_MODEL=gemini-2.0-flash-lite
# LLM_SUMMARY_MAX_TOKENS_SHORT=1000
# LLM_SUMMARY_MAX_TOKENS_LONG=2000
# Embedding settings
# LLM_MAX_EMBEDDING_TOKENS=8192
# LLM_MAX_EMBEDDING_TOKENS_PER_REQUEST=300000
# =============================================================================
# Agent Settings
# =============================================================================
# AGENT_SEMANTIC_SEARCH_TOP_K=10
# AGENT_SEMANTIC_SEARCH_MAX_DISTANCE=0.85
# AGENT_TOM_INFERENCE_METHOD=single_prompt
# LLM_DEFAULT_MAX_TOKENS=2500
# =============================================================================
# Deriver (Background Worker) Settings
# =============================================================================
# DERIVER_WORKERS=1
# DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
# DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
# DERIVER_TOM_METHOD=single_prompt
# DERIVER_USER_REPRESENTATION_METHOD=long_term
# DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
# DERIVER_PROVIDER=google
# DERIVER_MODEL=gemini-2.0-flash-lite
# MAX_OUTPUT_TOKENS=2500
# only applied when using Anthropic as provider
# THINKING_BUDGET_TOKENS=1024
# DERIVER_DEDUCTIVE_OBSERVATIONS_COUNT=6
# DERIVER_EXPLICIT_OBSERVATIONS_COUNT=10
# =============================================================================
# History Settings
# Dialectic Settings
# =============================================================================
# HISTORY_MESSAGES_PER_SHORT_SUMMARY=20
# HISTORY_MESSAGES_PER_LONG_SUMMARY=60
# DIALECTIC_PROVIDER=anthropic
# DIALECTIC_MODEL=claude-sonnet-4-20250514
# DIALECTIC_QUERY_GENERATION_PROVIDER=groq
# DIALECTIC_QUERY_GENERATION_MODEL=llama-3.1-8b-instant
# DIALECTIC_MAX_OUTPUT_TOKENS=2500
# DIALECTIC_SEMANTIC_SEARCH_TOP_K=10
# DIALECTIC_SEMANTIC_SEARCH_MAX_DISTANCE=0.85
# DIALECTIC_THINKING_BUDGET_TOKENS=1024
# =============================================================================
# Summary Settings
# =============================================================================
# SUMMARY_MESSAGES_PER_SHORT_SUMMARY=20
# SUMMARY_MESSAGES_PER_LONG_SUMMARY=60
# SUMMARY_PROVIDER=google
# SUMMARY_MODEL=gemini-1.5-flash-latest
# SUMMARY_MAX_TOKENS_SHORT=1000
# SUMMARY_MAX_TOKENS_LONG=2000
# =============================================================================
# Monitoring and Observability (Optional)
@ -113,6 +114,6 @@ LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here
# SENTRY_ENABLED=false
# SENTRY_DSN=your-sentry-dsn-here
# SENTRY_RELEASE=your-release-semver
# SENTRY_ENVIRONMENT=string-that-labels-deployment (default "development")
# SENTRY_ENVIRONMENT=development
# SENTRY_TRACES_SAMPLE_RATE=0.1
# SENTRY_PROFILES_SAMPLE_RATE=0.1

View File

@ -51,11 +51,11 @@ jobs:
SENTRY_ENABLED: false
LLM_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LLM_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
LLM_DIALECTIC_PROVIDER: openai
LLM_DIALECTIC_MODEL: test
LLM_QUERY_GENERATION_PROVIDER: openai
LLM_QUERY_GENERATION_MODEL: test
LLM_TOM_INFERENCE_PROVIDER: openai
LLM_TOM_INFERENCE_MODEL: test
LLM_SUMMARY_PROVIDER: openai
LLM_SUMMARY_MODEL: test
DERIVER_PROVIDER: openai
DERIVER_MODEL: test
DIALECTIC_PROVIDER: openai
DIALECTIC_MODEL: test
DIALECTIC_QUERY_GENERATION_PROVIDER: openai
DIALECTIC_QUERY_GENERATION_MODEL: test
SUMMARY_PROVIDER: openai
SUMMARY_MODEL: test

View File

@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [2.1.0] - 2025-7-17
### Added
- File uploads
- Brand new "ROTE" deriver system
- Updated dialectic system
- Local working representations
- Better logging for deriver/dialectic
- Endpoint for deriver queue status
### Fixed
- Document insertion
- Session-scoped and peer-targeted dialectic queries work now
### Removed
- Peer-level messages
### Changed
- Dialectic chat endpoint takes a single query
- Rearranged configuration values (LLM, Deriver, Dialectic, History->Summary)
## [2.0.5] - 2025-07-11

View File

@ -1,6 +1,6 @@
# 🫡 Honcho
![Static Badge](https://img.shields.io/badge/Version-2.0.5-blue)
![Static Badge](https://img.shields.io/badge/Version-2.1.0-blue)
[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/plasticlabs)
[![arXiv](https://img.shields.io/badge/arXiv-2310.06983-b31b1b.svg)](https://arxiv.org/abs/2310.06983)
![GitHub License](https://img.shields.io/github/license/plastic-labs/honcho)
@ -143,9 +143,13 @@ security.
Below are the required configurations:
```env
DB_CONNECTION_URI= # Connection uri for a postgres database
OPENAI_API_KEY= # API Key for OpenAI used for embedding documents
ANTHROPIC_API_KEY= # API Key for Anthropic used for the deriver and dialectic API
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)
```
> Note that the `DB_CONNECTION_URI` must have the prefix `postgresql+psycopg` to
@ -247,13 +251,14 @@ cp config.toml.example config.toml
Then modify the values as needed. The TOML file is organized into sections:
- `[app]` - Application-level settings (log level, host, port)
- `[app]` - Application-level settings (log level, host, port, embedding settings)
- `[db]` - Database connection and pool settings
- `[auth]` - Authentication configuration
- `[llm]` - LLM provider and model settings
- `[agent]` - Agent behavior settings
- `[deriver]` - Background worker settings
- `[history]` - Message history settings
- `[llm]` - LLM provider API keys and general settings
- `[dialectic]` - Dialectic API configuration (provider, model, search settings)
- `[deriver]` - Background worker settings and theory of mind configuration
- `[summary]` - Session summarization settings
- `[sentry]` - Error tracking and monitoring settings
### Using Environment Variables
@ -266,7 +271,8 @@ Examples:
- `DB_CONNECTION_URI` - Database connection string
- `AUTH_JWT_SECRET` - JWT secret key
- `LLM_DIALECTIC_MODEL` - Dialectic LLM model
- `DIALECTIC_MODEL` - Dialectic API model
- `SUMMARY_PROVIDER` - Summary generation provider
- `LOG_LEVEL` - Application log level
### Configuration Priority

View File

@ -8,6 +8,10 @@
LOG_LEVEL = "INFO"
FASTAPI_HOST = "0.0.0.0"
FASTAPI_PORT = 8000
SESSION_PEERS_LIMIT = 10
EMBED_MESSAGES = true
MAX_EMBEDDING_TOKENS = 8192
MAX_EMBEDDING_TOKENS_PER_REQUEST = 300000
# Database settings
[db]
@ -32,27 +36,14 @@ JWT_SECRET = "your-secret-key-here" # Must be set if USE_AUTH is true
[sentry]
ENABLED = false
DSN = ""
RELEASE = ""
ENVIRONMENT = "development"
TRACES_SAMPLE_RATE = 0.1
PROFILES_SAMPLE_RATE = 0.1
# LLM settings
[llm]
DEFAULT_MAX_TOKENS = 1000
DEFAULT_TEMPERATURE = 0.0
# Dialectic specific
DIALECTIC_PROVIDER = "anthropic"
DIALECTIC_MODEL = "claude-3-7-sonnet-20250219"
# Query Generation specific
QUERY_GENERATION_PROVIDER = "groq"
QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
# Summarization specific
SUMMARY_PROVIDER = "google"
SUMMARY_MODEL = "gemini-2.0-flash-lite"
SUMMARY_MAX_TOKENS_SHORT = 1000
SUMMARY_MAX_TOKENS_LONG = 2000
DEFAULT_MAX_TOKENS = 2500
# API Keys for LLM providers
# ANTHROPIC_API_KEY = "your-api-key"
@ -60,23 +51,37 @@ SUMMARY_MAX_TOKENS_LONG = 2000
# OPENAI_COMPATIBLE_API_KEY = "your-api-key"
# GEMINI_API_KEY = "your-api-key"
# GROQ_API_KEY = "your-api-key"
# OPENAI_COMPATIBLE_BASE_URL = "your-api-key"
# Agent settings
[agent]
SEMANTIC_SEARCH_TOP_K = 10
SEMANTIC_SEARCH_MAX_DISTANCE = 0.85
TOM_INFERENCE_METHOD = "single_prompt"
# OPENAI_COMPATIBLE_BASE_URL = "your-base-url"
# Deriver settings
[deriver]
WORKERS = 1
STALE_SESSION_TIMEOUT_MINUTES = 5
POLLING_SLEEP_INTERVAL_SECONDS = 1.0
TOM_METHOD = "single_prompt"
USER_REPRESENTATION_METHOD = "long_term"
STALE_SESSION_TIMEOUT_MINUTES = 5
PROVIDER = "google"
MODEL = "gemini-2.0-flash-lite"
MAX_OUTPUT_TOKENS = 2500
THINKING_BUDGET_TOKENS = 1024 # only applied when using Anthropic
DEDUCTIVE_OBSERVATIONS_COUNT = 6
EXPLICIT_OBSERVATIONS_COUNT = 10
# History settings
[history]
# Dialectic settings
[dialectic]
PROVIDER = "anthropic"
MODEL = "claude-sonnet-4-20250514"
QUERY_GENERATION_PROVIDER = "groq"
QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
MAX_OUTPUT_TOKENS = 2500
SEMANTIC_SEARCH_TOP_K = 10
SEMANTIC_SEARCH_MAX_DISTANCE = 0.85
THINKING_BUDGET_TOKENS = 1024
# Summary settings
[summary]
MESSAGES_PER_SHORT_SUMMARY = 20
MESSAGES_PER_LONG_SUMMARY = 60
PROVIDER = "google"
MODEL = "gemini-1.5-flash-latest"
MAX_TOKENS_SHORT = 1000
MAX_TOKENS_LONG = 2000
THINKING_BUDGET_TOKENS = 512

View File

@ -6,9 +6,10 @@
"dependencies": {
"@mintlify/scraping": "^4.0.284",
"honcho-ai": "^0.0.11",
"mintlify": "^4.1.96",
},
"devDependencies": {
"mint": "^4.2.13",
"mint": "^4.1.96",
},
},
},
@ -103,25 +104,25 @@
"@mdx-js/react": ["@mdx-js/react@3.1.0", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ=="],
"@mintlify/cli": ["@mintlify/cli@4.0.617", "", { "dependencies": { "@mintlify/common": "1.0.445", "@mintlify/link-rot": "3.0.567", "@mintlify/models": "0.0.205", "@mintlify/prebuild": "1.0.563", "@mintlify/previewing": "4.0.606", "@mintlify/validation": "0.1.409", "chalk": "^5.2.0", "detect-port": "^1.5.1", "fs-extra": "^11.2.0", "ink": "^6.0.1", "inquirer": "^12.3.0", "js-yaml": "^4.1.0", "react": "^19.1.0", "semver": "^7.7.2", "yargs": "^17.6.0" }, "bin": { "mint": "bin/index.js", "mintlify": "bin/index.js" } }, "sha512-/kLgjR0crqpXZNJHKsM1iCyz3/uH/hSWxSBAPpjxA7pEmQfck5fVneGaNU+ClRXdX9QLjninshs0liec4yCE3Q=="],
"@mintlify/cli": ["@mintlify/cli@4.0.619", "", { "dependencies": { "@mintlify/common": "1.0.447", "@mintlify/link-rot": "3.0.569", "@mintlify/models": "0.0.206", "@mintlify/prebuild": "1.0.565", "@mintlify/previewing": "4.0.608", "@mintlify/validation": "0.1.411", "chalk": "^5.2.0", "detect-port": "^1.5.1", "fs-extra": "^11.2.0", "ink": "^6.0.1", "inquirer": "^12.3.0", "js-yaml": "^4.1.0", "react": "^19.1.0", "semver": "^7.7.2", "yargs": "^17.6.0" }, "bin": { "mint": "bin/index.js", "mintlify": "bin/index.js" } }, "sha512-BRKvb3F0GBnkB9eHCgaQ+9D4UAp7nuniz+tc2Jp6s21nreHZ9D1R0Bb+CDP7PD5eTwObyhdCOoduOky8SGoHlQ=="],
"@mintlify/common": ["@mintlify/common@1.0.444", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.204", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.408", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-hUIIJo2QAm+olEeJgcDsYL+1W6UhT+TwTJroOF9zBiQ0N5rVAvIX3TSjc7kiF1ZdhqTSCp6US7+3AADjMF5aYw=="],
"@mintlify/common": ["@mintlify/common@1.0.447", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.206", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.411", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-Fo06wwxuDpim+NfP406SdDDjT1MonJXayla91e3uDhkuVhsXnV2s7tyAK2wBZugHw8Qwm79mT7ufZz3RmAGGZw=="],
"@mintlify/link-rot": ["@mintlify/link-rot@3.0.567", "", { "dependencies": { "@mintlify/common": "1.0.445", "@mintlify/prebuild": "1.0.563", "@mintlify/previewing": "4.0.606", "fs-extra": "^11.1.0", "unist-util-visit": "^4.1.1" } }, "sha512-2/jXjjzAasgWP3ZM4Ug06NEPSW0VSHdzllRYMaMEzaFEmn9BlOexRtgFnUui7MvRS4jsLJ2IA2Oy6XV6Bc7aGA=="],
"@mintlify/link-rot": ["@mintlify/link-rot@3.0.569", "", { "dependencies": { "@mintlify/common": "1.0.447", "@mintlify/prebuild": "1.0.565", "@mintlify/previewing": "4.0.608", "fs-extra": "^11.1.0", "unist-util-visit": "^4.1.1" } }, "sha512-me37ZeCatENRUmAd3D9bTztVaVHS5pGdlGKzmoKHfZ+w4DSmejedG0m4NY5iRe8JwPY+79+tU1GkPXgcTd+Tjg=="],
"@mintlify/mdx": ["@mintlify/mdx@2.0.3", "", { "dependencies": { "@shikijs/transformers": "^3.6.0", "hast-util-to-string": "^3.0.1", "mdast-util-mdx-jsx": "^3.2.0", "next-mdx-remote-client": "^1.0.3", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-smartypants": "^3.0.2", "shiki": "^3.6.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" } }, "sha512-UGlwavma8QooWAlhtXpTAG5MAUZTTUKI8Qu25Wqfp1HMOPrYGvo5YQPmlqqogbMsqDMcFPLP/ZYnaZsGUYBspQ=="],
"@mintlify/models": ["@mintlify/models@0.0.205", "", { "dependencies": { "axios": "^1.8.3", "openapi-types": "^12.0.0" } }, "sha512-uA5nlI2oeCCkl5sDXdXZZ/HBDnDoM9MknpEYUOkyGZhrYcXQgl43w659Rhg7GHU+3MuguouEu7lasItRlPlTLg=="],
"@mintlify/models": ["@mintlify/models@0.0.206", "", { "dependencies": { "axios": "^1.8.3", "openapi-types": "^12.0.0" } }, "sha512-/EYzfsgWg3zv/GgyqWOiR9qZo9OGpquNe8vyq35PtgHfyhpJ0POURt5GL7NBh42K90b02NwSDwMfyqPMRe2yeQ=="],
"@mintlify/openapi-parser": ["@mintlify/openapi-parser@0.0.7", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-3ecbkzPbsnkKVZJypVL0H5pCTR7a4iLv4cP7zbffzAwy+vpH70JmPxNVpPPP62yLrdZlfNcMxu5xKeT7fllgMg=="],
"@mintlify/prebuild": ["@mintlify/prebuild@1.0.563", "", { "dependencies": { "@mintlify/common": "1.0.445", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/scraping": "4.0.301", "@mintlify/validation": "0.1.409", "chalk": "^5.3.0", "favicons": "^7.2.0", "fs-extra": "^11.1.0", "gray-matter": "^4.0.3", "js-yaml": "^4.1.0", "mdast": "^3.0.0", "openapi-types": "^12.0.0", "unist-util-visit": "^4.1.1" } }, "sha512-k8fvZEqIiaL141airNEUmZ9v8M3pPwsYoK9gQfDLa1E/r7s7njuNbNg1hOeIWrTFmLCBprxJ0wX3TfMa7i7XGQ=="],
"@mintlify/prebuild": ["@mintlify/prebuild@1.0.565", "", { "dependencies": { "@mintlify/common": "1.0.447", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/scraping": "4.0.303", "@mintlify/validation": "0.1.411", "chalk": "^5.3.0", "favicons": "^7.2.0", "fs-extra": "^11.1.0", "gray-matter": "^4.0.3", "js-yaml": "^4.1.0", "mdast": "^3.0.0", "openapi-types": "^12.0.0", "unist-util-visit": "^4.1.1" } }, "sha512-DinWiKKmaVGx5cCDCdd/jVfkdHaJbmg32tNSM30NU7ldM3BtiXk6TEVJZqmJl84T2rUQB0IkhCynoveBtJUgRA=="],
"@mintlify/previewing": ["@mintlify/previewing@4.0.606", "", { "dependencies": { "@mintlify/common": "1.0.445", "@mintlify/prebuild": "1.0.563", "@mintlify/validation": "0.1.409", "better-opn": "^3.0.2", "chalk": "^5.1.0", "chokidar": "^3.5.3", "express": "^4.18.2", "fs-extra": "^11.1.0", "got": "^13.0.0", "gray-matter": "^4.0.3", "ink": "^6.0.1", "ink-spinner": "^5.0.0", "is-online": "^10.0.0", "js-yaml": "^4.1.0", "mdast": "^3.0.0", "openapi-types": "^12.0.0", "react": "^19.1.0", "socket.io": "^4.7.2", "tar": "^6.1.15", "unist-util-visit": "^4.1.1", "yargs": "^17.6.0" } }, "sha512-p94v+YwN8swZYs4x0XCNnUOXEVhAlg4SXX+st+J9SW+LDNPdxVztVa91XiB46yzvy8UaZTH1nRmR/H4esOtyjw=="],
"@mintlify/previewing": ["@mintlify/previewing@4.0.608", "", { "dependencies": { "@mintlify/common": "1.0.447", "@mintlify/prebuild": "1.0.565", "@mintlify/validation": "0.1.411", "better-opn": "^3.0.2", "chalk": "^5.1.0", "chokidar": "^3.5.3", "express": "^4.18.2", "fs-extra": "^11.1.0", "got": "^13.0.0", "gray-matter": "^4.0.3", "ink": "^6.0.1", "ink-spinner": "^5.0.0", "is-online": "^10.0.0", "js-yaml": "^4.1.0", "mdast": "^3.0.0", "openapi-types": "^12.0.0", "react": "^19.1.0", "socket.io": "^4.7.2", "tar": "^6.1.15", "unist-util-visit": "^4.1.1", "yargs": "^17.6.0" } }, "sha512-7J9KkYzFd9wUwioM3pKB4o7a9o0c/OgazIlelkfGO6CLqzZfW+t2t+ugRGPPtWaZJ069lgEmx/h2mC52EqZkHA=="],
"@mintlify/scraping": ["@mintlify/scraping@4.0.300", "", { "dependencies": { "@mintlify/common": "1.0.444", "@mintlify/openapi-parser": "^0.0.7", "fs-extra": "^11.1.1", "hast-util-to-mdast": "^10.1.0", "js-yaml": "^4.1.0", "mdast-util-mdx-jsx": "^3.1.3", "neotraverse": "^0.6.18", "puppeteer": "^22.14.0", "rehype-parse": "^9.0.0", "remark-gfm": "^4.0.0", "remark-mdx": "^3.0.1", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "yargs": "^17.6.0", "zod": "^3.20.6" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-ceZJ5Mt0YoU9f9JjV6msODq5y/b7CVSVpTWKOkOy9tGVOPWL0xpmQjSxteslHuWzNI4HdlzIxIMFKgw016z/2g=="],
"@mintlify/scraping": ["@mintlify/scraping@4.0.303", "", { "dependencies": { "@mintlify/common": "1.0.447", "@mintlify/openapi-parser": "^0.0.7", "fs-extra": "^11.1.1", "hast-util-to-mdast": "^10.1.0", "js-yaml": "^4.1.0", "mdast-util-mdx-jsx": "^3.1.3", "neotraverse": "^0.6.18", "puppeteer": "^22.14.0", "rehype-parse": "^9.0.0", "remark-gfm": "^4.0.0", "remark-mdx": "^3.0.1", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "yargs": "^17.6.0", "zod": "^3.20.6" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-imT+oEdZhqsMvZx5sIKOEpTV2ohTyOFw+ecL1oSD79w+HHNwQ/FdpsXObQ9J5uMupWRpd7lUXPxSvqoZ0OiiFQ=="],
"@mintlify/validation": ["@mintlify/validation@0.1.409", "", { "dependencies": { "@mintlify/models": "0.0.205", "lcm": "^0.0.3", "lodash": "^4.17.21", "openapi-types": "^12.0.0", "zod": "^3.20.6", "zod-to-json-schema": "^3.20.3" } }, "sha512-tTLowyfKs3CH1Df6jnFZMnsY2IAumfpjPa9bwnh831/hryiA/HgGcnMZjz/a+lIcmYYpn62NBKcgXmle04jayQ=="],
"@mintlify/validation": ["@mintlify/validation@0.1.411", "", { "dependencies": { "@mintlify/models": "0.0.206", "lcm": "^0.0.3", "lodash": "^4.17.21", "openapi-types": "^12.0.0", "zod": "^3.20.6", "zod-to-json-schema": "^3.20.3" } }, "sha512-+vnA0pcAkFk5lmU+drKnYjtLeWETxjVoj7b7vzimuNCO5B3e6gWvfE1LA1XXPcTiAT9UsyaKIoK800gxolBFEA=="],
"@openapi-contrib/openapi-schema-to-json-schema": ["@openapi-contrib/openapi-schema-to-json-schema@3.2.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-Gj6C0JwCr8arj0sYuslWXUBSP/KnUlEGnPW4qxlXvAl543oaNQgMgIgkQUA6vs5BCCvwTEiL8m/wdWzfl4UvSw=="],
@ -143,19 +144,19 @@
"@puppeteer/browsers": ["@puppeteer/browsers@2.3.0", "", { "dependencies": { "debug": "^4.3.5", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.4.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA=="],
"@shikijs/core": ["@shikijs/core@3.7.0", "", { "dependencies": { "@shikijs/types": "3.7.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg=="],
"@shikijs/core": ["@shikijs/core@3.8.0", "", { "dependencies": { "@shikijs/types": "3.8.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-gWt8NNZFurL6FMESO4lEsmspDh0H1fyUibhx1NnEH/S3kOXgYiWa6ZFqy+dcjBLhZqCXsepuUaL1QFXk6PrpsQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.7.0", "", { "dependencies": { "@shikijs/types": "3.7.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-0t17s03Cbv+ZcUvv+y33GtX75WBLQELgNdVghnsdhTgU3hVcWcMsoP6Lb0nDTl95ZJfbP1mVMO0p3byVh3uuzA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.8.0", "", { "dependencies": { "@shikijs/types": "3.8.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-IBULFFpQ1N5Cg/C7jPCGnjIKz72CcRtD0BIbNhSuXPUOxLG0bF1URsP/uLfxQFQ9ORfunCQwL7UuSX1RSRBwUQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.7.0", "", { "dependencies": { "@shikijs/types": "3.7.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-5BxcD6LjVWsGu4xyaBC5bu8LdNgPCVBnAkWTtOCs/CZxcB22L8rcoWfv7Hh/3WooVjBZmFtyxhgvkQFedPGnFw=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.8.0", "", { "dependencies": { "@shikijs/types": "3.8.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Tx7kR0oFzqa+rY7t80LjN8ZVtHO3a4+33EUnBVx2qYP3fGxoI9H0bvnln5ySelz9SIUTsS0/Qn+9dg5zcUMsUw=="],
"@shikijs/langs": ["@shikijs/langs@3.7.0", "", { "dependencies": { "@shikijs/types": "3.7.0" } }, "sha512-1zYtdfXLr9xDKLTGy5kb7O0zDQsxXiIsw1iIBcNOO8Yi5/Y1qDbJ+0VsFoqTlzdmneO8Ij35g7QKF8kcLyznCQ=="],
"@shikijs/langs": ["@shikijs/langs@3.8.0", "", { "dependencies": { "@shikijs/types": "3.8.0" } }, "sha512-mfGYuUgjQ5GgXinB5spjGlBVhG2crKRpKkfADlp8r9k/XvZhtNXxyOToSnCEnF0QNiZnJjlt5MmU9PmhRdwAbg=="],
"@shikijs/themes": ["@shikijs/themes@3.7.0", "", { "dependencies": { "@shikijs/types": "3.7.0" } }, "sha512-VJx8497iZPy5zLiiCTSIaOChIcKQwR0FebwE9S3rcN0+J/GTWwQ1v/bqhTbpbY3zybPKeO8wdammqkpXc4NVjQ=="],
"@shikijs/themes": ["@shikijs/themes@3.8.0", "", { "dependencies": { "@shikijs/types": "3.8.0" } }, "sha512-yaZiLuyO23sXe16JFU76KyUMTZCJi4EMQKIrdQt7okoTzI4yAaJhVXT2Uy4k8yBIEFRiia5dtD7gC1t8m6y3oQ=="],
"@shikijs/transformers": ["@shikijs/transformers@3.7.0", "", { "dependencies": { "@shikijs/core": "3.7.0", "@shikijs/types": "3.7.0" } }, "sha512-VplaqIMRNsNOorCXJHkbF5S0pT6xm8Z/s7w7OPZLohf8tR93XH0krvUafpNy/ozEylrWuShJF0+ftEB+wFRwGA=="],
"@shikijs/transformers": ["@shikijs/transformers@3.8.0", "", { "dependencies": { "@shikijs/core": "3.8.0", "@shikijs/types": "3.8.0" } }, "sha512-EleKVjNH5Me8yhTtnYD5QGFtY7Acu2HJAWNmDjuOC/Egwt7n31p2nbyBhBqGz5cpdwa1wZkLdVgj/LsZ3ReyAQ=="],
"@shikijs/types": ["@shikijs/types@3.7.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg=="],
"@shikijs/types": ["@shikijs/types@3.8.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-I/b/aNg0rP+kznVDo7s3UK8jMcqEGTtoPDdQ+JlQ2bcJIyu/e2iRvl42GLIDMK03/W1YOHOuhlhQ7aM+XbKUeg=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
@ -227,7 +228,7 @@
"@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="],
"@types/node": ["@types/node@18.19.117", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hcxGs9TfQGghOM8atpRT+bBMUX7V8WosdYt98bQ59wUToJck55eCOlemJ+0FpOZOQw5ff7LSi9+IO56KvYEFyQ=="],
"@types/node": ["@types/node@18.19.118", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hIPK0hSrrcaoAu/gJMzN3QClXE4QdCdFvaenJ0JsjIbExP1JFFVH+RHcBt25c9n8bx5dkIfqKE+uw6BmBns7ug=="],
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
@ -295,7 +296,7 @@
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"avsc": ["avsc@5.7.8", "", {}, "sha512-6SyyCw6XzdiNrkJ3UF9dIj0cVexNQAGxwjZ69CRgEneBxdDKjjc2IdfEJD11iP4BfwQWrL8HwR04bx/Y3uUNEA=="],
"avsc": ["avsc@5.7.9", "", {}, "sha512-yOA4wFeI7ET3v32Di/sUybQ+ttP20JHSW3mxLuNGeO0uD6PPcvLrIQXSvy/rhJOWU5JrYh7U4OHplWMmtAtjMg=="],
"axios": ["axios@1.10.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw=="],
@ -981,7 +982,9 @@
"minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="],
"mint": ["mint@4.2.13", "", { "dependencies": { "@mintlify/cli": "4.0.617" }, "bin": { "mint": "index.js", "mintlify": "index.js" } }, "sha512-GIgZCh1wmr9vzkilkuEz91GQQsAeCKsWxIbTGKlKiMgydbxo5vGupIGlK7ecphwVzlu97W92NiznthoS40DHjQ=="],
"mint": ["mint@4.2.15", "", { "dependencies": { "@mintlify/cli": "4.0.619" }, "bin": { "mint": "index.js", "mintlify": "index.js" } }, "sha512-ZmDKezGM2kf0dNqcrE9CkElFoCxM4U75wIb7RjVZbPLjt8jjs40U88wQXuvv4p/OLnFr+Je9ck8jD3EGET6cNQ=="],
"mintlify": ["mintlify@4.2.15", "", { "dependencies": { "@mintlify/cli": "4.0.619" }, "bin": { "mint": "index.js", "mintlify": "index.js" } }, "sha512-9hFI7So7jLhuIkg9MIy1AZ9UlISJYHEViKKoz6lElTg+qQJhV5d+53ULUA6JKqv37PeOaMIL/UfNKd97kklU9A=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
@ -1219,7 +1222,7 @@
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"shiki": ["shiki@3.7.0", "", { "dependencies": { "@shikijs/core": "3.7.0", "@shikijs/engine-javascript": "3.7.0", "@shikijs/engine-oniguruma": "3.7.0", "@shikijs/langs": "3.7.0", "@shikijs/themes": "3.7.0", "@shikijs/types": "3.7.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-ZcI4UT9n6N2pDuM2n3Jbk0sR4Swzq43nLPgS/4h0E3B/NrFn2HKElrDtceSf8Zx/OWYOo7G1SAtBLypCp+YXqg=="],
"shiki": ["shiki@3.8.0", "", { "dependencies": { "@shikijs/core": "3.8.0", "@shikijs/engine-javascript": "3.8.0", "@shikijs/engine-oniguruma": "3.8.0", "@shikijs/langs": "3.8.0", "@shikijs/themes": "3.8.0", "@shikijs/types": "3.8.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-yPqK0y68t20aakv+3aMTpUMJZd6UHaBY2/SBUDowh9M70gVUwqT0bf7Kz5CWG0AXfHtFvXCHhBBHVAzdp0ILoQ=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
@ -1245,7 +1248,7 @@
"socket.io-parser": ["socket.io-parser@4.2.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" } }, "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew=="],
"socks": ["socks@2.8.5", "", { "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" } }, "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww=="],
"socks": ["socks@2.8.6", "", { "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" } }, "sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA=="],
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
@ -1443,24 +1446,10 @@
"@inquirer/select/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
"@mintlify/cli/@mintlify/common": ["@mintlify/common@1.0.445", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.205", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.409", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-4YN2EWeU6VI6voVD9l9FYGwrCUgXM0+1i/Fyn2DFMZyG4LNtaAbJCyS1K2puFvLGiZwXeipfx9DJzG/f8sisbg=="],
"@mintlify/common/@mintlify/models": ["@mintlify/models@0.0.204", "", { "dependencies": { "axios": "^1.8.3", "openapi-types": "^12.0.0" } }, "sha512-QhpfM3JUwRfkLEQYuS7RLqAzQyGpcaBSwWhhqJsXFOWEjgrSM7hBYwIg/cUEejILcmqPXIUVzpTM1lb9UA3Rhg=="],
"@mintlify/common/@mintlify/validation": ["@mintlify/validation@0.1.408", "", { "dependencies": { "@mintlify/models": "0.0.204", "lcm": "^0.0.3", "lodash": "^4.17.21", "openapi-types": "^12.0.0", "zod": "^3.20.6", "zod-to-json-schema": "^3.20.3" } }, "sha512-+6dWRFhVniZgX/7/gM3GqOCH3jKqYbtSjg21gnJ1mUWaiTVmH0EutY8drF0uR8AC2l9L1Dbrn3seY4VeibZ0tQ=="],
"@mintlify/link-rot/@mintlify/common": ["@mintlify/common@1.0.445", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.205", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.409", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-4YN2EWeU6VI6voVD9l9FYGwrCUgXM0+1i/Fyn2DFMZyG4LNtaAbJCyS1K2puFvLGiZwXeipfx9DJzG/f8sisbg=="],
"@mintlify/link-rot/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="],
"@mintlify/prebuild/@mintlify/common": ["@mintlify/common@1.0.445", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.205", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.409", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-4YN2EWeU6VI6voVD9l9FYGwrCUgXM0+1i/Fyn2DFMZyG4LNtaAbJCyS1K2puFvLGiZwXeipfx9DJzG/f8sisbg=="],
"@mintlify/prebuild/@mintlify/scraping": ["@mintlify/scraping@4.0.301", "", { "dependencies": { "@mintlify/common": "1.0.445", "@mintlify/openapi-parser": "^0.0.7", "fs-extra": "^11.1.1", "hast-util-to-mdast": "^10.1.0", "js-yaml": "^4.1.0", "mdast-util-mdx-jsx": "^3.1.3", "neotraverse": "^0.6.18", "puppeteer": "^22.14.0", "rehype-parse": "^9.0.0", "remark-gfm": "^4.0.0", "remark-mdx": "^3.0.1", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "yargs": "^17.6.0", "zod": "^3.20.6" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-Qv+dyE8jrNpaD6P0olQm6+E4JmgOtHRUpWnD8bcaUD1wV8Jw+cyFTqBZc1uveka7kIGfJxRJ9aD8et2zXbWWnQ=="],
"@mintlify/prebuild/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="],
"@mintlify/previewing/@mintlify/common": ["@mintlify/common@1.0.445", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.205", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.409", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-4YN2EWeU6VI6voVD9l9FYGwrCUgXM0+1i/Fyn2DFMZyG4LNtaAbJCyS1K2puFvLGiZwXeipfx9DJzG/f8sisbg=="],
"@mintlify/previewing/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="],
"@stoplight/better-ajv-errors/leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
@ -1559,26 +1548,18 @@
"@inquirer/select/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"@mintlify/link-rot/@mintlify/common/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
"@mintlify/link-rot/unist-util-visit/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"@mintlify/link-rot/unist-util-visit/unist-util-is": ["unist-util-is@5.2.1", "", { "dependencies": { "@types/unist": "^2.0.0" } }, "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw=="],
"@mintlify/link-rot/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@5.1.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0" } }, "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg=="],
"@mintlify/prebuild/@mintlify/common/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
"@mintlify/prebuild/@mintlify/scraping/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
"@mintlify/prebuild/unist-util-visit/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"@mintlify/prebuild/unist-util-visit/unist-util-is": ["unist-util-is@5.2.1", "", { "dependencies": { "@types/unist": "^2.0.0" } }, "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw=="],
"@mintlify/prebuild/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@5.1.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0" } }, "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg=="],
"@mintlify/previewing/@mintlify/common/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
"@mintlify/previewing/unist-util-visit/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"@mintlify/previewing/unist-util-visit/unist-util-is": ["unist-util-is@5.2.1", "", { "dependencies": { "@types/unist": "^2.0.0" } }, "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw=="],

View File

@ -8,7 +8,29 @@ This guide helps you understand which versions of Honcho's API are compatible wi
## Version Compatibility
### Honcho API v2.0.5 (Current)
### Honcho API v2.1.0 (Current)
<CardGroup cols={2}>
<Card title="TypeScript SDK" icon="js">
**Compatible Version:** v1.2.0
Install with:
```bash
npm install @honcho-ai/sdk@1.2.0
```
</Card>
<Card title="Python SDK" icon="python">
**Compatible Version:** v1.2.0
Install with:
```bash
pip install honcho-ai==1.2.0
```
</Card>
</CardGroup>
### Honcho API v2.0.5
<CardGroup cols={2}>
<Card title="TypeScript SDK" icon="js">
@ -34,5 +56,6 @@ This guide helps you understand which versions of Honcho's API are compatible wi
| Honcho API Version | TypeScript SDK | Python SDK |
|-------------------|---------------|------------|
| v2.0.5 (Current) | v1.1.0 | v1.1.0 | Latest release |
| v2.1.0 (Current) | v1.2.0 | v1.2.0 | Latest release |
| v2.0.5 | v1.1.0 | v1.1.0 |
| v2.0.4 | v1.1.0 | v1.1.0 |

View File

@ -27,12 +27,38 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
### Honcho API and SDK Changelogs
<Tabs>
<Tab title="Honcho API">
<Update label="v2.1.0 (Current)">
### Added
- File uploads
- Brand new "ROTE" deriver system
- Updated dialectic system
- Local working representations
- Better logging for deriver/dialectic
- Deriver Queue Status no longer has redundant data
### Fixed
- Document insertion
- Session-scoped and peer-targeted dialectic queries work now
- Minor bugs
### Removed
- Peer-level messages
### Changed
- Dialectic chat endpoint takes a single query
- Rearranged configuration values (LLM, Deriver, Dialectic, History->Summary)
</Update>
<Update label="v2.0.5 (Current)">
### Fixed
- Groq API client to use the Async library
</Update>
<Update label="v2.0.4">
### Fixed

View File

@ -19,7 +19,7 @@
"navigation": {
"versions": [
{
"version": "v2.0.5",
"version": "v2.1.0",
"api": {
"openapi": [
"openapi.documented.yml"
@ -61,6 +61,7 @@
"group": "Getting Started",
"pages": [
"v2/guides/overview",
"v2/guides/ai-assisted-setup",
"v2/guides/mcp"
]
},
@ -125,8 +126,6 @@
"v2/api-reference/endpoint/peers/update-peer",
"v2/api-reference/endpoint/peers/get-sessions-for-peer",
"v2/api-reference/endpoint/peers/chat",
"v2/api-reference/endpoint/peers/create-messages-for-peer",
"v2/api-reference/endpoint/peers/get-messages-for-peer",
"v2/api-reference/endpoint/peers/get-working-representation",
"v2/api-reference/endpoint/peers/search-peer"
]
@ -155,7 +154,8 @@
"v2/api-reference/endpoint/messages/create-messages-for-session",
"v2/api-reference/endpoint/messages/get-messages",
"v2/api-reference/endpoint/messages/get-message",
"v2/api-reference/endpoint/messages/update-message"
"v2/api-reference/endpoint/messages/update-message",
"v2/api-reference/endpoint/messages/upload-file"
]
},
{

View File

@ -0,0 +1,3 @@
---
openapi: post /v2/workspaces/{workspace_id}/sessions/{session_id}/messages/upload
---

View File

@ -1,3 +0,0 @@
---
openapi: post /v2/workspaces/{workspace_id}/peers/{peer_id}/messages
---

View File

@ -1,3 +0,0 @@
---
openapi: post /v2/workspaces/{workspace_id}/peers/{peer_id}/messages/list
---

View File

@ -0,0 +1,296 @@
---
title: "Cursor & Claude: AI-Powered Honcho Setup"
icon: "wand-magic-sparkles"
description: "Build Honcho-powered AI agents quickly using Cursor or Claude. Zero configuration required."
sidebarTitle: 'AI-Assisted Setup'
---
Get Honcho up and running in minutes using AI coding assistants. These prompts are specifically optimized for Cursor and Claude to generate production-ready code with minimal effort.
## 🚀 Quick Start
Choose your path based on your use case:
### Personal AI Assistant
Build an AI assistant that remembers conversations and learns user preferences.
<Tip>
Copy this prompt into Cursor or Claude to get a complete implementation:
</Tip>
```
Create a personal AI assistant using Honcho that remembers user preferences and conversations. Requirements:
REFERENCE DOCUMENTATION:
- Honcho Docs: https://docs.honcho.dev
- Honcho GitHub: https://github.com/plastic-labs/honcho
- Python SDK: https://github.com/plastic-labs/honcho-python
- API Reference: https://docs.honcho.dev/v2/api-reference/introduction
WHAT TO BUILD:
- Personal assistant that learns about the user automatically
- Remembers preferences, habits, and conversation history
- Provides personalized responses based on past interactions
- Uses Honcho's demo server (no setup required)
TECHNICAL SETUP:
- Python with Honcho SDK and OpenAI
- Simple command-line interface for testing
- Environment: Use demo.honcho.dev (no API key needed)
- LLM: OpenAI GPT-4 (provide env var setup)
CODE REQUIREMENTS:
- Complete working example with extensive comments
- Error handling and user-friendly messages
- Demonstration of key Honcho concepts:
* Creating peers (user and assistant)
* Managing sessions and conversations
* Automatic learning from interactions
* Querying learned information
* Getting context for AI responses
EXAMPLE WORKFLOW:
1. User starts conversation with assistant
2. Assistant responds using any existing knowledge about user
3. System automatically learns facts from the conversation
4. System stores conversation in session
5. Future conversations reference past interactions
Include installation instructions, environment setup, and example conversations to test.
```
### Discord Bot with Memory
Create a Discord bot that learns about server members and provides personalized interactions.
```
Build a Discord bot using Honcho that learns about server members and provides personalized interactions.
REFERENCE DOCUMENTATION:
- Honcho Docs: https://docs.honcho.dev
- Honcho GitHub: https://github.com/plastic-labs/honcho
- Python SDK: https://github.com/plastic-labs/honcho-python
- Discord Guide: https://docs.honcho.dev/v2/guides/discord
- API Reference: https://docs.honcho.dev/v2/api-reference/introduction
STARTER TEMPLATE:
- Use the official discord-python-starter from Plastic Labs: https://github.com/plastic-labs/discord-python-starter
- This template already includes Honcho integration, py-cord, and fly.io deployment
- Modify the existing bot.py file to add enhanced memory features
WHAT TO BUILD:
- Discord bot with persistent memory using Honcho
- Learns about users through natural conversation
- Provides personalized responses based on user history
- Handles multi-user conversations with context awareness
- Extends the starter template with advanced memory features
TECHNICAL SETUP:
- Clone the discord-python-starter repository
- Python with py-cord, Honcho SDK, and OpenRouter LLM support
- Uses uv for package management (already configured)
- Environment variables template provided (.env.template)
- Docker and fly.io deployment ready
CORE FEATURES TO ADD:
- Enhanced per-user memory and personality modeling
- Channel-specific session management
- Theory-of-mind queries ("What does this user like?")
- Advanced fact extraction from conversations
- Multi-participant conversation handling
- Slash commands for memory management
IMPLEMENTATION REQUIREMENTS:
- Extend the existing on_message function with memory features
- Add new slash commands for memory testing and management
- Integrate Honcho's dialectic API for personalized responses
- Add session management for different channels
- Implement background fact learning and storage
- Error handling and comprehensive logging
DEPLOYMENT:
- Use the included fly.toml for deployment
- Environment variable management with fly secrets
- Docker containerization (Dockerfile provided)
Include examples of enhanced bot interactions and memory demonstrations.
```
## 🎯 Using Cursor
### Setup Workflow
<Tabs>
<Tab title="New Project">
```bash
# Create project
mkdir my-honcho-agent
cd my-honcho-agent
# Open in Cursor
cursor .
# Use Cmd+L to open AI chat
# Paste one of the prompts above
```
</Tab>
<Tab title="Discord Starter">
```bash
# Clone the starter template
git clone https://github.com/plastic-labs/discord-python-starter.git
cd discord-python-starter
# Install dependencies
uv sync
# Configure environment
cp .env.template .env
# Add your Discord token and API keys
```
</Tab>
</Tabs>
### Cursor Tips
<CardGroup cols={2}>
<Card title="Reference Codebase" icon="code">
Use `@codebase` to ask questions about your entire project
</Card>
<Card title="Reference Docs" icon="book">
Use `@docs https://docs.honcho.dev` for documentation context
</Card>
<Card title="Generate Tests" icon="vial">
Ask Cursor to write comprehensive tests for your Honcho integration
</Card>
<Card title="Iterate Quickly" icon="arrows-rotate">
Request specific improvements: "Add better error handling"
</Card>
</CardGroup>
## 🤖 Claude Workflows
### Rapid Development
```
I want to quickly prototype an AI agent with Honcho. Help me build:
REFERENCE DOCUMENTATION:
- Honcho Docs: https://docs.honcho.dev
- Honcho GitHub: https://github.com/plastic-labs/honcho
- Quickstart Guide: https://docs.honcho.dev/v2/documentation/introduction/quickstart
- SDK Documentation: https://docs.honcho.dev/v2/documentation/platform/sdk
1. SETUP: Complete development environment with Honcho demo server
2. CORE: Basic peer/session/message workflow with memory
3. INTEGRATION: OpenAI LLM integration with context management
4. TESTING: Simple test cases to verify memory functionality
5. ITERATION: Framework for adding features incrementally
Focus on:
- Working code over perfect architecture
- Clear comments explaining Honcho concepts
- Easy-to-modify structure for experimentation
- Immediate feedback and testing capabilities
Start with the most minimal viable example and show me how to extend it.
```
### Production Deployment
```
Help me deploy my Honcho application to production:
REFERENCE DOCUMENTATION:
- Self-Hosting Guide: https://docs.honcho.dev/v2/contributing/self-hosting
- Configuration Guide: https://docs.honcho.dev/v2/contributing/configuration-guide
- Platform Overview: https://docs.honcho.dev/v2/documentation/platform/overview
REQUIREMENTS:
- Environment configuration and secrets management
- Database setup and migrations
- API authentication and rate limiting
- Monitoring and logging setup
- Deployment automation
Provide step-by-step deployment instructions for [Fly.io/Vercel/Railway/Heroku].
```
## 💡 Common Patterns
### Basic Conversation Flow
1. **Initialize**: Create peers and start a session
2. **Converse**: Exchange messages between user and assistant
3. **Learn**: Honcho automatically extracts facts from conversations
4. **Remember**: Future conversations use accumulated context
5. **Personalize**: Responses adapt based on learned information
### Advanced Features
<Steps>
<Step title="Multi-User Sessions">
Implement separate memory contexts for different users or channels
</Step>
<Step title="Context Windows">
Manage conversation history to stay within LLM token limits
</Step>
<Step title="Fact Management">
Query and update the knowledge graph programmatically
</Step>
<Step title="Theory of Mind">
Use dialectic API to reason about user preferences and mental states
</Step>
</Steps>
## 🛠️ Troubleshooting
<CodeGroup>
```text Connection Issues
"My Honcho connection is failing with [ERROR]. Here's my environment setup: [paste code]. What's wrong and how do I fix it?"
```
```text Memory Not Persisting
"The agent isn't remembering conversations between sessions. Here's my session management code: [paste code]. Help me debug this."
```
```text Performance Optimization
"My Honcho queries are slow. Here's my implementation: [paste code]. How can I optimize this?"
```
```text Integration Problems
"I'm trying to integrate Honcho with [SYSTEM] but getting [ERROR]. Here's my approach: [paste code]. What's the correct way to do this?"
```
</CodeGroup>
## 🚀 Next Steps
After your initial setup:
1. **Add Features**: Extend with voice input, web UI, or API endpoints
2. **Improve Memory**: Implement custom fact extraction and retrieval
3. **Scale Up**: Add caching, background processing, and optimization
4. **Deploy**: Move from demo server to production environment
5. **Monitor**: Add logging, metrics, and error tracking
## 📚 Resources
### Documentation
- **Main Docs**: [docs.honcho.dev](https://docs.honcho.dev)
- **API Reference**: [docs.honcho.dev/v2/api-reference](https://docs.honcho.dev/v2/api-reference/introduction)
### Code & Examples
- **Honcho Core**: [github.com/plastic-labs/honcho](https://github.com/plastic-labs/honcho)
- **Python SDK**: [github.com/plastic-labs/honcho-python](https://github.com/plastic-labs/honcho-python)
- **TypeScript SDK**: [github.com/plastic-labs/honcho-node](https://github.com/plastic-labs/honcho-node)
- **Discord Starter**: [github.com/plastic-labs/discord-python-starter](https://github.com/plastic-labs/discord-python-starter)
### Key Concepts for AI Prompts
When working with AI assistants, mention these concepts:
- **Core**: "peers, sessions, messages, facts"
- **Advanced**: "dialectic API, theory of mind, context management"
- **Integration**: "LLM context injection, session persistence, multi-user handling"
<Note>
**Pro Tip**: Be specific about your requirements and constraints when prompting AI. The more context you provide, the better the generated code will match your needs.
</Note>

View File

@ -14,7 +14,7 @@ On every message written to a session, an automatic callback is run that will re
The Dialectic endpoint allows you to define logic enabling your agent to talk to our agent that automatically retrieves and synthesizes facts from the collection. You can use the response as part of your reasoning process for your agent–add it to your next prompt to inject critical context about the user.
This chat interface is exposed via the `peer.chat()` endpoint. It accepts a string or a list of strings. Below is some example code on how this works.
This chat interface is exposed via the `peer.chat()` endpoint. It accepts a string query. Below is some example code on how this works.
## Prerequisites

View File

@ -0,0 +1,321 @@
---
title: 'File Uploads'
description: 'Upload PDFs, text files, and JSON documents to create messages in Honcho'
icon: 'file-upload'
---
Honcho's file upload feature allows you to convert documents into messages automatically. Upload PDFs, text files, or JSON documents, and Honcho will extract the text content, split it into appropriately sized chunks, and create messages that become part of your peer's knowledge or session context.
This feature is perfect for ingesting documents, reports, research papers, or any text-based content that you want your AI agents to understand and reference.
## How It Works
When you upload a file, Honcho:
1. **Extracts text** from the file using specialized processors based on file type
2. **Creates messages** with the extracted content split into chunks that fit within message limits (messages are limited to 50,000 characters)
3. **Queues processing** for background analysis and insight derivation like any other message
The file content becomes part of the peer's representation, making it available for natural language queries and context retrieval.
## Supported File Types
Honcho currently supports the following file types with more to come:
- **PDF files** (`application/pdf`) - Text extraction with page numbers
- **Text files** (`text/*`) - Plain text, markdown, code files, etc.
- **JSON files** (`application/json`) - Structured data converted to readable format
<Note>
Files are processed in memory and not stored on disk. Only the extracted text content is preserved in Honcho's message system.
</Note>
## Basic Usage
### Upload a Single File
<CodeGroup>
```python Python
from honcho import Honcho
# Initialize client
honcho = Honcho()
# Create session and peer
session = honcho.session("research-session")
user = honcho.peer("researcher")
# Upload a PDF to a session
with open("research_paper.pdf", "rb") as file:
messages = session.upload_file(
file=file,
peer_id=user.id,
)
print(f"Created {len(messages)} messages from the PDF")
```
```typescript TypeScript
import { Honcho } from "@honcho-ai/sdk";
import fs from "fs";
// Initialize client
const honcho = new Honcho({});
// Create session and peer
const session = honcho.session("research-session");
const user = honcho.peer("researcher");
// Upload a PDF to a session
const fileStream = fs.createReadStream("research_paper.pdf");
const messages = await session.uploadFile({
file: fileStream,
peerId: user.id,
});
console.log(`Created ${messages.length} messages from the PDF`);
```
</CodeGroup>
### Upload to Peer's Global Representation
<CodeGroup>
```python Python
# Upload files directly to a peer's global representation
with open("personal_notes.pdf", "rb") as file:
messages = user.upload_file(
file=file,
)
print(f"Added {len(messages)} messages to {user.id}'s global representation")
```
```typescript TypeScript
// Upload files directly to a peer's global representation
const fileStream = fs.createReadStream("personal_notes.pdf");
const messages = await user.uploadFile({
file: fileStream,
});
console.log(`Added ${messages.length} messages to ${user.id}'s global representation`);
```
</CodeGroup>
## Upload Parameters
The upload methods accept the following parameters:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `file` | File | Yes | File to upload |
| `peer_id` | String | Session only | ID of the peer creating the messages |
## File Processing Details
### Text Extraction
**PDF Files**: Text is extracted page by page with page numbers preserved:
```
[Page 1]
Introduction
This document provides...
[Page 2]
Methodology
Our approach involves...
```
**Text Files**: Content is decoded using UTF-8, UTF-16, or Latin-1 encoding as needed.
**JSON Files**: Structured data is converted to string format.
### Chunking Strategy
Large files are automatically split into chunks of ~49,500 characters. The system seeks to break at natural boundaries if present:
1. Paragraph breaks (`\n\n`)
2. Line breaks (`\n`)
3. Sentence endings (`. `)
4. Word boundaries (` `)
Each chunk becomes a separate message, maintaining the original document structure.
## Querying Uploaded Content
Once files are uploaded, you can query the content using Honcho's natural language interface:
<CodeGroup>
```python Python
# Query what was learned from the uploaded documents
response = user.chat("What are the key findings from the research papers I uploaded?")
print(response)
# Ask about specific documents
response = user.chat("What does the quarterly report say about revenue growth?")
print(response)
# Get context from the uploaded documents for LLM integration
context = session.get_context(tokens=3000)
messages = context.to_openai(assistant=assistant)
```
```typescript TypeScript
// Query what was learned from the uploaded documents
const response = await user.chat("What are the key findings from the research papers I uploaded?");
console.log(response);
// Ask about specific documents
const response2 = await user.chat("What does the quarterly report say about revenue growth?");
console.log(response2);
// Get context from the uploaded documents for LLM integration
const context = await session.getContext({ tokens: 3000 });
const messages = context.toOpenAI(assistant);
```
</CodeGroup>
## Error Handling
### Unsupported File Types
Files with unsupported content types will raise an exception:
```python
try:
messages = session.upload_file(
file=open("image.jpg", "rb"),
peer_id=user.id
)
except Exception as e:
print(f"Upload failed: {e}")
# Error: "Could not process file image.jpg: Unsupported file type: image/jpeg"
```
### Missing Required Fields
Session uploads require a `peer_id` parameter:
```python
# This will fail for session uploads
try:
messages = session.upload_file(file=file) # Missing peer_id
except ValueError as e:
print(f"Validation error: {e}")
```
## Complete Example: Document Analysis Assistant
Here's a complete example of building a document analysis assistant:
<CodeGroup>
```python Python
from honcho import Honcho
# Initialize
honcho = Honcho()
session = honcho.session("document-analysis")
user = honcho.peer("analyst")
assistant = honcho.peer("analysis-bot")
def upload_document(file_path, description):
"""Upload a document and add it to the session"""
with open(file_path, "rb") as file:
messages = session.upload_file(
file=file,
peer_id=user.id,
)
return messages
def analyze_documents():
"""Get AI analysis of uploaded documents"""
context = session.get_context(tokens=4000)
messages = context.to_openai(assistant=assistant)
# Add analysis request
messages.append({
"role": "user",
"content": "Please analyze all the documents I've uploaded and provide a comprehensive summary of the key findings, trends, and recommendations."
})
# Call OpenAI (or your preferred LLM)
# response = openai.chat.completions.create(model="gpt-4", messages=messages)
# return response.choices[0].message.content
return "Analysis would be generated here"
# Upload multiple documents
documents = [
("quarterly_report.pdf", "Q3 2024 Quarterly Financial Report"),
("market_research.pdf", "Market Analysis and Competitive Landscape"),
("product_roadmap.pdf", "Product Development Roadmap 2024-2025")
]
for file_path, description in documents:
messages = upload_document(file_path, description)
print(f"Uploaded {file_path}: {len(messages)} messages created")
# Get AI analysis
analysis = analyze_documents()
print("Document Analysis:", analysis)
```
```typescript TypeScript
import { Honcho } from "@honcho-ai/sdk";
import fs from "fs";
// Initialize
const honcho = new Honcho({});
const session = honcho.session("document-analysis");
const user = honcho.peer("analyst");
const assistant = honcho.peer("analysis-bot");
async function uploadDocument(filePath: string, description: string) {
const fileStream = fs.createReadStream(filePath);
const messages = await session.uploadFile({
file: fileStream,
peerId: user.id,
});
return messages;
}
async function analyzeDocuments() {
const context = await session.getContext({ tokens: 4000 });
const messages = context.toOpenAI(assistant);
// Add analysis request
messages.push({
role: "user",
content: "Please analyze all the documents I've uploaded and provide a comprehensive summary of the key findings, trends, and recommendations."
});
// Call OpenAI (or your preferred LLM)
// const response = await openai.chat.completions.create({ model: "gpt-4", messages });
// return response.choices[0].message.content;
return "Analysis would be generated here";
}
// Upload multiple documents
const documents = [
["quarterly_report.pdf", "Q3 2024 Quarterly Financial Report"],
["market_research.pdf", "Market Analysis and Competitive Landscape"],
["product_roadmap.pdf", "Product Development Roadmap 2024-2025"]
];
for (const [filePath, description] of documents) {
const messages = await uploadDocument(filePath, description);
console.log(`Uploaded ${filePath}: ${messages.length} messages created`);
}
// Get AI analysis
const analysis = await analyzeDocuments();
console.log("Document Analysis:", analysis);
```
</CodeGroup>
## Error Handling
- **Always wrap uploads in try-catch blocks** for robust error handling
- **Validate file types** before upload to avoid processing errors
- **Handle large files gracefully** with progress indicators
- **Implement retry logic** for network failures

View File

@ -31,7 +31,8 @@ Implementation patterns for Honcho's core capabilities
**[Search](search)** - Search your data using natural language
**[Working Representations](working-representations)** - Understanding and customizing user models
**[Streaming Responses](streaming-responses)** - Handle real-time interactions efficiently
**[Using Filters](using-filters)** - Control what data gets processed and how
**[Using Filters](using-filters)** - Control what data gets processed and how
**[File Uploads](file-uploads)** - Upload PDF, text, or JSON files to create messages
## Philosophy

View File

@ -4,7 +4,6 @@ from logging.config import fileConfig
from pathlib import Path
from alembic import context
from dotenv import load_dotenv
from sqlalchemy import engine_from_config, pool, text
from src.config import settings
@ -20,9 +19,6 @@ logging.getLogger("alembic").setLevel(logging.DEBUG)
# Add project root to Python path
sys.path.append(str(Path(__file__).parents[1]))
# Load environment variables
load_dotenv(override=True)
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

View File

@ -553,4 +553,3 @@ def downgrade():
# Drop the column
op.drop_column("sessions", "app_id", schema=schema)
print("Dropped app_id column from sessions table")

View File

@ -1,6 +1,6 @@
[project]
name = "honcho"
version = "2.0.5"
version = "2.1.0"
description = "Honcho Server"
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},
@ -26,10 +26,12 @@ dependencies = [
"openai>=1.91.0",
"pydantic>=2.11.7",
"pydantic-settings>=2.10.1",
"google-generativeai>=0.8.5",
"pdfplumber>=0.11.7",
]
[tool.uv]
dev-dependencies = [
"honcho-core>=1.1.0",
"honcho-core>=1.2.0",
"pytest>=8.2.2",
"sqlalchemy-utils>=0.41.2",
"pytest-asyncio>=0.23.7",

View File

@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
from src import models # noqa: E402
from src.config import settings # noqa: E402
from src.dependencies import tracked_db # noqa: E402
from src.embeddings import EmbeddingClient # noqa: E402
from src.embedding_client import EmbeddingClient # noqa: E402
async def get_messages_without_embeddings(

View File

@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [1.2.0] - 2025-07-16
### Added
- Get/poll deriver queue status endpoints added to workspace
- Added endpoint to upload files as messages
### Removed
- Removed peer messages in accordance with Honcho 2.1.0
### Changed
- Updated chat endpoint to use singular `query` in accordance with Honcho 2.1.0
## [1.1.0] - 2025-07-08
### Fixed

View File

@ -29,8 +29,11 @@ session.add_messages([
bob.message("Hi Alice, how are you?")
])
# Wait for deriver to process all messages (only necessary if very recent messages are critical to query)
client.poll_deriver_status()
# Query conversation context
response = alice.chat("What did Bob say to me?")
response = alice.chat("What did Bob say to the user?")
print(response)
```
@ -95,10 +98,6 @@ from honcho import AsyncHoncho
async def main():
client = AsyncHoncho(api_key="your-api-key")
peer = client.peer("user")
response = await peer.chat("Hello!")
print(response)
```
### Metadata Management
@ -107,9 +106,6 @@ async def main():
# Set peer metadata
user.set_metadata({"location": "San Francisco", "preferences": {"theme": "dark"}})
# Query using metadata context
response = user.chat("What's the weather like where I am?")
# Session metadata
session.set_metadata({"topic": "project-planning", "priority": "high"})
```

View File

@ -1,129 +0,0 @@
import asyncio
import logging
from honcho import AsyncHoncho
from honcho.async_client.session import SessionPeerConfig
logging.basicConfig(level=logging.INFO)
async def main():
# HONCHO_API_KEY is an environment variable
# HONCHO_URL is an *optional* environment variable
# HONCHO_WORKSPACE_ID is an *optional* environment variable
# Using local server for this example
honcho = AsyncHoncho(environment="local", workspace_id="test")
_workspaces = await honcho.get_workspaces()
# these don't make any API calls, just produce a AsyncPeer object in SDK
# in practice, these would be UUIDs, as peer IDs are unique within their workspace
assistant = await honcho.peer(id="bob")
alice = await honcho.peer(id="alice")
# empty since peers are not created until they are used
_peers = await honcho.get_peers()
# workspace-level metadata
_m = await honcho.get_metadata()
await honcho.set_metadata({"test": "test"})
# calling the dialectic chat endpoint makes an API call.
# when this call occurs, the "alice" peer will be get_or_create'd
# response will be None because we haven't talked yet!
_response = await alice.chat("what did alice have for breakfast today?")
# sessions are scoped to a set of peers and contain messages/content
# this is not an API call, like peers this is created lazily
my_session = await honcho.session(id="session_1")
# API call
await my_session.add_peers(
[alice, (assistant, SessionPeerConfig(observe_others=False, observe_me=False))]
)
# adding/removing peers from sessions creates a bidirectional relationship,
# so no need for operations like `alice.join(my_session)`.
# this will return a list of sessions [my_session]
# this is also an API call
_sessions = await alice.get_sessions()
# API call to create 1 or more messages (overload, can be Message or list[Message]
await my_session.add_messages(
[
# creates a Message object with peer_id="alice", etc etc
assistant.message("what did you have for breakfast today, alice?"),
alice.message("i had oatmeal."),
]
)
m = await my_session.get_metadata()
m["test"] = "test2"
await my_session.set_metadata(m)
# peers have one "omnipresent" global representation, comprised of all
# the content associated with that peer in this honcho instance.
# they also have a potentially infinite number of "local" representations,
# each one from the perspective of *another* peer in the honcho instance.
# this is a query to alice's global representation--no scope
_response = await alice.chat("what did the user have for breakfast today?")
# this is a query to alice's local representation *of the assistant*
_response = await alice.chat(
"does alice know what bob had for breakfast?", target=assistant
)
# this is a query to the assistant's local representation *of alice* in this session
_response = await assistant.chat(
"does the assistant know what alice had for breakfast?",
target=alice,
session_id=my_session.id,
)
# API call to store non-message content under a peer + optional session
await alice.add_messages(
"this might be a document about alice, say, a journal entry."
)
# This does make an API call because we set a configuration for this new peer
charlie = await honcho.peer(id="charlie", config={"observe_me": False})
await my_session.add_messages(charlie.message("hello world!"))
# session now has 3 members: alice, bob, and charlie. a message automatically adds a peer to a session.
# peers, sessions, and messages all have metadata which can be modified and used in queries.
# API call to get metadata?
charlie_metadata = await charlie.get_metadata()
charlie_metadata["location"] = "the moon"
# API call to store metadata?
await charlie.set_metadata(charlie_metadata)
# response will tell you that charlie is on the moon
_response = await charlie.chat("where is the user?")
# you can get the messages from a session, either fully or partially.
# (API call)
_messages = await my_session.get_messages()
context = await my_session.get_context()
_messages = context.to_openai(assistant=assistant.id)
_messages = context.to_anthropic(assistant=assistant.id)
await my_session.add_messages(
assistant.message("This is a test message using the property syntax")
)
print("Async sample code executed successfully!")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -25,6 +25,8 @@ for i in range(10):
session.add_messages(messages)
honcho.poll_deriver_status()
# Chat with alice
alice = peers[0]
response = alice.chat("what did alice have for breakfast today?")

View File

@ -1,118 +0,0 @@
import logging
from honcho import Honcho
from honcho.session import SessionPeerConfig
logging.basicConfig(level=logging.INFO)
# HONCHO_API_KEY is an environment variable
# HONCHO_URL is an *optional* environment variable
# HONCHO_WORKSPACE_ID is an *optional* environment variable
# Using local server for this example
honcho = Honcho(environment="local", workspace_id="test")
workspaces = honcho.get_workspaces()
# these don't make any API calls, just produce a Peer object in SDK
# in practice, these would be UUIDs, as peer IDs are unique within their workspace
assistant = honcho.peer(id="bob")
alice = honcho.peer(id="alice")
# empty since peers are not created until they are used
peers = honcho.get_peers()
# workspace-level metadata
_m = honcho.get_metadata()
honcho.set_metadata({"test": "test"})
# calling the dialectic chat endpoint makes an API call.
# when this call occurs, the "alice" peer will be get_or_create'd
# response will be None because we haven't talked yet!
response = alice.chat("what did alice have for breakfast today?")
# sessions are scoped to a set of peers and contain messages/content
# this is not an API call, like peers this is created lazily
my_session = honcho.session(id="session_1")
# API call
my_session.add_peers(
[alice, (assistant, SessionPeerConfig(observe_others=False, observe_me=False))]
)
# adding/removing peers from sessions creates a bidirectional relationship,
# so no need for operations like `alice.join(my_session)`.
# this will return a list of sessions [my_session]
# this is also an API call
_sessions = alice.get_sessions()
# API call to create 1 or more messages (overload, can be Message or list[Message]
my_session.add_messages(
[
# creates a Message object with peer_id="alice", etc etc
assistant.message("what did you have for breakfast today, alice?"),
alice.message("i had oatmeal."),
]
)
m = my_session.get_metadata()
m["test"] = "test2"
my_session.set_metadata(m)
# peers have one "omnipresent" global representation, comprised of all
# the content associated with that peer in this honcho instance.
# they also have a potentially infinite number of "local" representations,
# each one from the perspective of *another* peer in the honcho instance.
# this is a query to alice's global representation--no scope
response = alice.chat("what did the user have for breakfast today?")
# this is a query to alice's local representation *of the assistant*
response = alice.chat("does alice know what bob had for breakfast?", target=assistant)
# this is a query to the assistant's local representation *of alice* in this session
response = assistant.chat(
"does the assistant know what alice had for breakfast?",
target=alice,
session_id=my_session.id,
)
# API call to store non-message content under a peer + optional session
alice.add_messages("this might be a document about alice, say, a journal entry.")
charlie = honcho.peer(id="charlie")
my_session.add_messages(charlie.message("hello world!"))
# session now has 3 members: alice, bob, and charlie. a message automatically adds a peer to a session.
# peers, sessions, and messages all have metadata which can be modified and used in queries.
# API call to get metadata?
charlie_metadata = charlie.get_metadata()
charlie_metadata["location"] = "the moon"
# API call to store metadata?
charlie.set_metadata(charlie_metadata)
# response will tell you that charlie is on the moon
response = charlie.chat("where is the user?")
# you can get the messages from a session, either fully or partially.
# (API call)
messages = my_session.get_messages()
context = my_session.get_context()
messages = context.to_openai(assistant=assistant.id)
messages = context.to_anthropic(assistant=assistant.id)
my_session.add_messages(
assistant.message("This is a test message using the property syntax")
)
print("Sample code executed successfully!")

View File

@ -0,0 +1,19 @@
import uuid
from honcho import Honcho
# Create a Honcho client with the default workspace
honcho = Honcho(environment="local")
# Create a new session
session = honcho.session("file_upload_test_" + str(uuid.uuid4()))
# Upload the current file directly using a file object
with open(__file__, "rb") as file:
session.upload_file(file, peer_id="alice")
# get the messages from the session
# should contain the contents of this file!
messages = session.get_messages()
for message in messages:
print(str(message))

View File

@ -0,0 +1,83 @@
import time
import uuid
from honcho import Honcho
from honcho.session import SessionPeerConfig
# Create a Honcho client with the default workspace
honcho = Honcho(environment="local")
alice = honcho.peer("alice")
bob = honcho.peer("bob")
# Create a new session
session = honcho.session("chat_test_" + str(uuid.uuid4()))
session.add_peers(
[
(alice, SessionPeerConfig(observe_me=True, observe_others=True)),
(bob, SessionPeerConfig(observe_me=True, observe_others=True)),
]
)
# Generate messages with personal information
messages = []
messages.append(alice.message("I had a great breakfast today!"))
messages.append(bob.message("What did you eat?"))
messages.append(alice.message("I had pancakes and eggs and bacon."))
session.add_messages(messages)
# sleep to get later timestamps on these "future" messages
time.sleep(5)
# Create a separate session
session2 = honcho.session("chat_test_" + str(uuid.uuid4()))
session2.add_peers(
[
(alice, SessionPeerConfig(observe_me=True, observe_others=True)),
(bob, SessionPeerConfig(observe_me=True, observe_others=True)),
]
)
session2.add_messages(
[
alice.message(
"Hey remember when I told you I had a great breakfast today? I lied. I actually skipped breakfast."
),
bob.message("WTF is wrong with you??"),
]
)
# wait for the deriver to process the messages
print("waiting for the deriver to process all the messages")
deriver_status = honcho.poll_deriver_status()
print("deriver status:", deriver_status)
# # Chat with alice's honcho-level representation
# print(
# "\n\n\033[1m asking alice's honcho-level representation what she had for breakfast \033[0m"
# )
# response = alice.chat("what did alice have for breakfast today?", session_id=session.id)
# print("response:", response)
# Chat with bob's internal representation of alice
print(
"\n\n\033[1m asking bob what alice had for breakfast -- scoped to session 1 \033[0m"
)
response = bob.chat(
"what did alice have for breakfast today?", target=alice, session_id=session.id
)
print("response:", response)
print(
"\n\n\033[1m asking bob what alice had for breakfast -- scoped to session 2 \033[0m"
)
response = bob.chat(
"what did alice have for breakfast today?", target=alice, session_id=session2.id
)
print("response:", response)
print("\n\n\033[1m asking bob what alice had for breakfast -- global scope \033[0m")
response = bob.chat("what did alice have for breakfast today?", target=alice)
print("response:", response)

View File

@ -12,12 +12,14 @@ peers = [
honcho.peer("charlie"),
]
alice = peers[0]
# Create a new session
session = honcho.session("search_test_" + str(uuid.uuid4()))
# Create a message with our special keyword
keyword = f"~special-{str(uuid.uuid4())}~"
session.add_messages(peers[0].message(f"I am a {keyword} message"))
session.add_messages(alice.message(f"I am a {keyword} message"))
# Generate some random messages from alice, bob, and charlie and add them to the session
messages = []
@ -39,13 +41,7 @@ search_results = honcho.search(keyword)
print("searching the workspace")
print("search results returned:", [message for message in search_results])
alice = peers[0]
# Add a different message to alice's global representation
different_keyword = f"~different-{str(uuid.uuid4())}~"
alice.add_messages(alice.message(f"I am a {different_keyword} message"))
# Search alice's global representation for the different message
search_results = alice.search(different_keyword)
print("searching alice's global representation")
# Search alice's messages for the special keyword
search_results = alice.search(keyword)
print("searching alice's messages")
print("search results returned:", [message for message in search_results])

View File

@ -1,6 +1,6 @@
[project]
name = "honcho-ai"
version = "1.1.0"
version = "1.2.0"
description = "Official DX Optimized Python SDK for Honcho"
dynamic = ["readme"]
license = "Apache-2.0"
@ -8,7 +8,7 @@ authors = [
{ name = "Plastic Labs", email = "hello@plasticlabs.ai" },
]
dependencies = [
"honcho-core>=1.1.0",
"honcho-core>=1.2.0",
"httpx>=0.28.0, <1",
"pydantic>=2.0.0, <3",
]

View File

@ -27,6 +27,9 @@ Usage:
bob.message("Hi Alice, how are you?")
])
# Wait for deriver to process all messages (only necessary if very recent messages are critical to query)
client.poll_deriver_status()
# Query conversation context
response = alice.chat("What did Bob say to me?")
"""

View File

@ -1,11 +1,14 @@
import asyncio
import logging
import os
import time
from collections.abc import Mapping
from typing import Any, Literal
import httpx
from honcho_core import AsyncHoncho as AsyncHonchoCore
from honcho_core import Honcho as HonchoCore
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
@ -30,6 +33,8 @@ class AsyncHoncho(BaseModel):
workspace_id: Workspace ID for scoping operations
"""
model_config = ConfigDict(extra="allow") # pyright: ignore
workspace_id: str = Field(
...,
min_length=1,
@ -311,6 +316,105 @@ class AsyncHoncho(BaseModel):
)
return AsyncPage(messages_page)
@validate_call
async def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
"""
return await self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
session_id=session_id,
)
@validate_call
async def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
timeout: float = Field(
300.0,
gt=0,
description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).",
),
) -> DeriverStatus:
"""
Poll get_deriver_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the deriver for
use with the dialectic endpoint.
The polling estimates sleep time by assuming each work unit takes 1 second.
Args:
observer_id: Optional observer ID to scope the status check
sender_id: Optional sender ID to scope the status check
session_id: Optional session ID to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
DeriverStatus when all work units are complete
Raises:
TimeoutError: If timeout is exceeded before work units complete
Exception: If get_deriver_status fails repeatedly
"""
start_time = time.time()
while True:
try:
status = await self.get_deriver_status(
observer_id, sender_id, session_id
)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying
await asyncio.sleep(1)
# Check timeout after error
elapsed_time = time.time() - start_time
if elapsed_time >= timeout:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Error during status check: {e}"
) from e
continue
if status.pending_work_units == 0 and status.in_progress_work_units == 0:
return status
# Check timeout before sleeping
elapsed_time = time.time() - start_time
if elapsed_time >= timeout:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Current status: {status.pending_work_units} pending, "
+ f"{status.in_progress_work_units} in progress work units."
)
# Sleep for the expected time to complete all current work units
# Assuming each pending and in-progress work unit takes 1 second
total_work_units = status.pending_work_units + status.in_progress_work_units
sleep_time = max(1, total_work_units)
# Don't sleep past the timeout
remaining_time = timeout - elapsed_time
sleep_time = min(sleep_time, remaining_time)
if sleep_time <= 0:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Current status: {status.pending_work_units} pending, "
+ f"{status.in_progress_work_units} in progress work units."
)
await asyncio.sleep(sleep_time)
def __repr__(self) -> str:
"""
Return a string representation of the AsyncHoncho client.

View File

@ -93,7 +93,7 @@ class AsyncPeer(BaseModel):
async def chat(
self,
queries: str | list[str],
query: str,
*,
stream: bool = False,
target: str | AsyncPeer | None = None,
@ -107,9 +107,9 @@ class AsyncPeer(BaseModel):
representation of another peer (what this peer knows about the target peer).
Args:
queries: The natural language question(s) to ask. Can be a single string or a list of strings.
query: The natural language question to ask.
stream: Whether to stream the response
target: Optional target peer for local representation queries. If provided,
target: Optional target peer for local representation query. If provided,
queries what this peer knows about the target peer rather than
querying the peer's global representation
session_id: Optional session ID to scope the query to a specific session.
@ -122,7 +122,7 @@ class AsyncPeer(BaseModel):
response = await self._client.workspaces.peers.chat(
peer_id=self.id,
workspace_id=self.workspace_id,
queries=queries,
query=query,
stream=stream,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
session_id=session_id,
@ -154,75 +154,6 @@ class AsyncPeer(BaseModel):
lambda session: AsyncSession(session.id, self.workspace_id, self._client),
)
@validate_call
async def add_messages(
self,
content: str | MessageCreateParam | list[MessageCreateParam] = Field(
..., description="Content to add to the peer's representation"
),
) -> None:
"""
Add messages or content to this peer's global representation.
Makes an async API call to store content associated with this peer. This content
becomes part of the peer's global knowledge base and can be retrieved
through chat queries. Content can be provided as raw strings, Message objects,
or lists of Message objects.
Args:
content: Content to add to the peer's representation. Can be:
- str: Raw text content that will be converted to a Message
- Message: A single Message object to add
- List[Message]: Multiple Message objects to add in batch
"""
messages: list[MessageCreateParam]
if isinstance(content, str):
messages = [
MessageCreateParam(peer_id=self.id, content=content, metadata=None)
]
elif isinstance(content, list):
messages = content
else:
messages = [content]
await self._client.workspaces.peers.messages.create(
peer_id=self.id,
workspace_id=self.workspace_id,
messages=messages,
)
@validate_call
async def get_messages(
self,
*,
filters: dict[str, object] | None = Field(
None, description="Dictionary of filter criteria"
),
) -> AsyncPage[Message]:
"""
Get messages saved to this peer outside of a session with optional filtering.
Makes an API call to retrieve messages saved to this peer outside of a session.
Results can be filtered based on various criteria.
Args:
filters: Dictionary of filter criteria. Supported filters include:
- peer_id: Filter messages by the peer who created them
- metadata: Filter messages by metadata key-value pairs
- timestamp_start: Filter messages after a specific timestamp
- timestamp_end: Filter messages before a specific timestamp
Returns:
An AsyncPage of Message objects matching the specified criteria, ordered by
creation time (most recent first)
"""
messages_page = await self._client.workspaces.peers.messages.list(
peer_id=self.id,
workspace_id=self.workspace_id,
filter=filters,
)
return AsyncPage(messages_page)
@validate_call
def message(
self,
@ -296,9 +227,9 @@ class AsyncPeer(BaseModel):
query: str = Field(..., min_length=1, description="The search query to use"),
) -> AsyncPage[Message]:
"""
Search for messages in this peer's global representation.
Search across all messages in the workspace with this peer as author.
Makes an async API call to search for messages in this peer's global representation.
Makes an API call to search endpoint.
Args:
query: The search query to use

View File

@ -10,6 +10,7 @@ from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from ..session_context import SessionContext
from ..utils import prepare_file_for_upload
from .pagination import AsyncPage
if TYPE_CHECKING:
@ -468,6 +469,54 @@ class AsyncSession(BaseModel):
)
return AsyncPage(messages_page)
@validate_call
async def upload_file(
self,
file: tuple[str, bytes, str] | tuple[str, Any, str] | Any = Field(
...,
description="File to upload. Can be a file object, (filename, bytes, content_type) tuple, or (filename, fileobj, content_type) tuple.",
),
peer_id: str = Field(..., description="ID of the peer creating the messages"),
) -> list[Message]:
"""
Upload file to create message(s) in this session.
Accepts a flexible payload:
- File objects (opened in binary mode)
- (filename, bytes, content_type) tuples
- (filename, fileobj, content_type) tuples
Files are normalized to (filename, fileobj, content_type) tuples for the Stainless client.
Args:
file: File to upload. Can be:
- a file object (must have .name and .read())
- a tuple (filename, bytes, content_type)
- a tuple (filename, fileobj, content_type)
peer_id: ID of the peer who will be attributed as the creator of the messages
Returns:
A list of Message objects representing the created messages
Note:
Supported file types include PDFs, text files, and JSON documents.
Large files will be automatically split into multiple messages to fit
within message size limits.
"""
# Prepare file for upload using shared utility
filename, content_bytes, content_type = prepare_file_for_upload(file)
# Call the upload endpoint
response = await self._client.workspaces.sessions.messages.upload(
session_id=self.id,
workspace_id=self.workspace_id,
file=(filename, content_bytes, content_type),
peer_id=peer_id,
)
return [Message.model_validate(msg) for msg in response]
async def working_rep(
self,
peer: str | AsyncPeer,

View File

@ -1,10 +1,12 @@
import logging
import os
import time
from collections.abc import Mapping
from typing import Any, Literal
import httpx
from honcho_core import Honcho as HonchoCore
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
@ -29,6 +31,8 @@ class Honcho(BaseModel):
workspace_id: Workspace ID for scoping operations
"""
model_config = ConfigDict(extra="allow") # pyright: ignore
workspace_id: str = Field(
...,
min_length=1,
@ -285,6 +289,103 @@ class Honcho(BaseModel):
messages_page = self._client.workspaces.search(self.workspace_id, body=query)
return SyncPage(messages_page)
@validate_call
def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
"""
return self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
session_id=session_id,
)
@validate_call
def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
timeout: float = Field(
300.0,
gt=0,
description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).",
),
) -> DeriverStatus:
"""
Poll get_deriver_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the deriver for
use with the dialectic endpoint.
The polling estimates sleep time by assuming each work unit takes 1 second.
Args:
observer_id: Optional observer ID to scope the status check
sender_id: Optional sender ID to scope the status check
session_id: Optional session ID to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
DeriverStatus when all work units are complete
Raises:
TimeoutError: If timeout is exceeded before work units complete
Exception: If get_deriver_status fails repeatedly
"""
start_time = time.time()
while True:
try:
status = self.get_deriver_status(observer_id, sender_id, session_id)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying
time.sleep(1)
# Check timeout after error
elapsed_time = time.time() - start_time
if elapsed_time >= timeout:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Error during status check: {e}"
) from e
continue
if status.pending_work_units == 0 and status.in_progress_work_units == 0:
return status
# Check timeout before sleeping
elapsed_time = time.time() - start_time
if elapsed_time >= timeout:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Current status: {status.pending_work_units} pending, "
+ f"{status.in_progress_work_units} in progress work units."
)
# Sleep for the expected time to complete all current work units
# Assuming each pending and in-progress work unit takes 1 second
total_work_units = status.pending_work_units + status.in_progress_work_units
sleep_time = max(1, total_work_units)
# Don't sleep past the timeout
remaining_time = timeout - elapsed_time
sleep_time = min(sleep_time, remaining_time)
if sleep_time <= 0:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Current status: {status.pending_work_units} pending, "
+ f"{status.in_progress_work_units} in progress work units."
)
time.sleep(sleep_time)
def __repr__(self) -> str:
"""
Return a string representation of the Honcho client.

View File

@ -74,7 +74,7 @@ class Peer(BaseModel):
def chat(
self,
queries: str | list[str],
query: str,
*,
stream: bool = False,
target: str | Peer | None = None,
@ -88,9 +88,9 @@ class Peer(BaseModel):
representation of another peer (what this peer knows about the target peer).
Args:
queries: The natural language question(s) to ask. Can be a single string or a list of strings.
query: The natural language question to ask.
stream: Whether to stream the response
target: Optional target peer for local representation queries. If provided,
target: Optional target peer for local representation query. If provided,
queries what this peer knows about the target peer rather than
querying the peer's global representation
session_id: Optional session ID to scope the query to a specific session.
@ -103,7 +103,7 @@ class Peer(BaseModel):
response = self._client.workspaces.peers.chat(
peer_id=self.id,
workspace_id=self.workspace_id,
queries=queries,
query=query,
stream=stream,
target=str(target.id) if isinstance(target, Peer) else target,
session_id=session_id,
@ -134,75 +134,6 @@ class Peer(BaseModel):
lambda session: Session(session.id, self.workspace_id, self._client),
)
@validate_call
def add_messages(
self,
content: str | MessageCreateParam | list[MessageCreateParam] = Field(
..., description="Content to add to the peer's representation"
),
) -> None:
"""
Add messages or content to this peer's global representation.
Makes an API call to store content associated with this peer. This content
becomes part of the peer's global knowledge base and can be retrieved
through chat queries. Content can be provided as raw strings, Message objects,
or lists of Message objects.
Args:
content: Content to add to the peer's representation. Can be:
- str: Raw text content that will be converted to a Message
- Message: A single Message object to add
- List[Message]: Multiple Message objects to add in batch
"""
messages: list[MessageCreateParam]
if isinstance(content, str):
messages = [
MessageCreateParam(peer_id=self.id, content=content, metadata=None)
]
elif isinstance(content, list):
messages = content
else:
messages = [content]
self._client.workspaces.peers.messages.create(
peer_id=self.id,
workspace_id=self.workspace_id,
messages=messages,
)
@validate_call
def get_messages(
self,
*,
filters: dict[str, object] | None = Field(
None, description="Dictionary of filter criteria"
),
) -> SyncPage[Message]:
"""
Get messages saved to this peer outside of a session with optional filtering.
Makes an API call to retrieve messages saved to this peer outside of a session.
Results can be filtered based on various criteria.
Args:
filters: Dictionary of filter criteria. Supported filters include:
- peer_id: Filter messages by the peer who created them
- metadata: Filter messages by metadata key-value pairs
- timestamp_start: Filter messages after a specific timestamp
- timestamp_end: Filter messages before a specific timestamp
Returns:
A SyncPage of Message objects matching the specified criteria, ordered by
creation time (most recent first)
"""
messages_page = self._client.workspaces.peers.messages.list(
peer_id=self.id,
workspace_id=self.workspace_id,
filter=filters,
)
return SyncPage(messages_page)
@validate_call
def message(
self,
@ -276,9 +207,9 @@ class Peer(BaseModel):
query: str = Field(..., min_length=1, description="The search query to use"),
) -> SyncPage[Message]:
"""
Search for messages in this peer's global representation.
Search across all messages in the workspace with this peer as author.
Makes an API call to search for messages in this peer's global representation.
Makes an API call to search endpoint.
Args:
query: The search query to use

View File

@ -11,6 +11,7 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from .pagination import SyncPage
from .session_context import SessionContext
from .utils import prepare_file_for_upload
if TYPE_CHECKING:
from .peer import Peer
@ -445,6 +446,54 @@ class Session(BaseModel):
)
return SyncPage(messages_page)
@validate_call
def upload_file(
self,
file: tuple[str, bytes, str] | tuple[str, Any, str] | Any = Field(
...,
description="File to upload. Can be a file object, (filename, bytes, content_type) tuple, or (filename, fileobj, content_type) tuple.",
),
peer_id: str = Field(..., description="ID of the peer creating the messages"),
) -> list[Message]:
"""
Upload file to create message(s) in this session.
Accepts a flexible payload:
- File objects (opened in binary mode)
- (filename, bytes, content_type) tuples
- (filename, fileobj, content_type) tuples
Files are normalized to (filename, fileobj, content_type) tuples for the Stainless client.
Args:
file: File to upload. Can be:
- a file object (must have .name and .read())
- a tuple (filename, bytes, content_type)
- a tuple (filename, fileobj, content_type)
peer_id: ID of the peer who will be attributed as the creator of the messages
Returns:
A list of Message objects representing the created messages
Note:
Supported file types include PDFs, text files, and JSON documents.
Large files will be automatically split into multiple messages to fit
within message size limits.
"""
# Prepare file for upload using shared utility
filename, content_bytes, content_type = prepare_file_for_upload(file)
# Call the upload endpoint
response = self._client.workspaces.sessions.messages.upload(
session_id=self.id,
workspace_id=self.workspace_id,
file=(filename, content_bytes, content_type),
peer_id=peer_id,
)
return [Message.model_validate(msg) for msg in response]
def working_rep(
self,
peer: str | Peer,

View File

@ -1,7 +1,12 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, Field, validate_call
from .peer import Peer
if TYPE_CHECKING:
from .peer import Peer
class SessionContext(BaseModel):
@ -72,11 +77,9 @@ class SessionContext(BaseModel):
Returns:
A list of dictionaries in OpenAI format, where each dictionary contains
"role" and "content" keys suitable for the OpenAI API
Raises:
ValidationError: If assistant parameter is invalid
"""
assistant_id = assistant.id if isinstance(assistant, Peer) else assistant
assistant_id = assistant if isinstance(assistant, str) else assistant.id
return [
{
"role": "assistant" if message.peer_id == assistant_id else "user",
@ -107,14 +110,12 @@ class SessionContext(BaseModel):
A list of dictionaries in Anthropic format, where each dictionary contains
"role" and "content" keys suitable for the Anthropic API
Raises:
ValidationError: If assistant parameter is invalid
Note:
Future versions may implement role alternation requirements for
Anthropic's API compatibility
"""
assistant_id = assistant.id if isinstance(assistant, Peer) else assistant
assistant_id = assistant if isinstance(assistant, str) else assistant.id
return [
{
"role": "assistant" if message.peer_id == assistant_id else "user",

View File

@ -0,0 +1,7 @@
"""
Utility modules for the Honcho Python SDK.
"""
from .file_upload import normalize_file_input, prepare_file_for_upload
__all__ = ["normalize_file_input", "prepare_file_for_upload"]

View File

@ -0,0 +1,75 @@
"""
File upload utilities for the Honcho Python SDK.
This module provides shared functionality for handling file uploads across
both sync and async client implementations.
"""
import mimetypes
from io import BytesIO, IOBase
def normalize_file_input(
file: tuple[str, bytes, str] | tuple[str, IOBase, str] | IOBase,
) -> tuple[str, IOBase, str]:
"""
Normalize various file input formats to a standard tuple format.
Args:
file: File to normalize. Can be:
- a file object (must have .name and .read())
- a tuple (filename, bytes, content_type)
- a tuple (filename, fileobj, content_type)
Returns:
A normalized tuple of (filename, fileobj, content_type)
Raises:
ValueError: If the file input format is not supported
"""
# If it's a tuple (filename, bytes, content_type)
if isinstance(file, tuple) and len(file) == 3:
filename, file_content, content_type = file
if isinstance(file_content, bytes):
fileobj = BytesIO(file_content)
fileobj.name = filename
return (filename, fileobj, content_type)
elif isinstance(file_content, IOBase): # pyright: ignore -- needed for return type
return (filename, file_content, content_type)
else:
raise ValueError("File content must be bytes or a file-like object.")
# If it's a file object (not str/bytes/bytearray/memoryview)
elif isinstance(file, IOBase): # pyright: ignore -- needed for return type
filename = getattr(file, "name", None)
if not filename:
raise ValueError("File object must have a .name attribute.")
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
return (filename, file, content_type)
def prepare_file_for_upload(
file: tuple[str, bytes, str] | tuple[str, IOBase, str] | IOBase,
) -> tuple[str, bytes, str]:
"""
Prepare a file for upload by normalizing and reading its content.
Args:
file: File to prepare. Can be:
- a file object (must have .name and .read())
- a tuple (filename, bytes, content_type)
- a tuple (filename, fileobj, content_type)
Returns:
A tuple of (filename, content_bytes, content_type) ready for API upload
Raises:
ValueError: If the file input format is not supported
"""
normalized_file = normalize_file_input(file)
# Read the file content
normalized_file[1].seek(0) # Reset file position
content_bytes = normalized_file[1].read()
return (normalized_file[0], content_bytes, normalized_file[2])

View File

@ -112,7 +112,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "honcho-core", specifier = ">=1.1.0" },
{ name = "honcho-core", specifier = ">=1.2.0" },
{ name = "httpx", specifier = ">=0.28.0,<1" },
{ name = "pydantic", specifier = ">=2.0.0,<3" },
]
@ -122,7 +122,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }]
[[package]]
name = "honcho-core"
version = "1.1.0"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
@ -135,9 +135,9 @@ dependencies = [
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/2f/27739b0d8950da05743cba5085be66f1184195c07ba9b852988d45ba252d/honcho_core-1.1.0.tar.gz", hash = "sha256:d31dc932573b771056952d234cf0c615a4ca591a11eb29c543e9a2277fcc0926", size = 121388, upload-time = "2025-06-26T19:31:00.814Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/60/e870902c5d247b5a0fb38401d6f3730e11eaf038f5a15c68afc4465e332f/honcho_core-1.2.0.tar.gz", hash = "sha256:1f16fd9ecd236bfc4c30ecc33354baf4bdd9a4206e84f92ec785ecd61b25d193", size = 122450, upload-time = "2025-07-16T19:59:06.362Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/14/c2bc7dce35a76a7d89166c55f35f2be21fdb3a94ee3f6502aa83f399bb1a/honcho_core-1.1.0-py3-none-any.whl", hash = "sha256:5716aa572cf33416d1c2c475dbd714879e9d351f2e2b1c6c37751b2c91adc6ce", size = 112730, upload-time = "2025-06-26T19:30:59.455Z" },
{ url = "https://files.pythonhosted.org/packages/98/99/f435c093ea2067d7da50545cfbe2037e28370541ba161f5c8df6e33030b0/honcho_core-1.2.0-py3-none-any.whl", hash = "sha256:d9260e1a2a1254c26aeec464f8bca7ebb6c2fa9b5ae568a9344b5458467d78ab", size = 110721, upload-time = "2025-07-16T19:59:05.189Z" },
]
[[package]]

View File

@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [1.2.0] - 2025-07-16
### Added
- Get/poll deriver queue status endpoints added to workspace
- Added endpoint to upload files as messages
### Removed
- Removed peer messages in accordance with Honcho 2.1.0
### Changed
- Updated chat endpoint to use singular `query` in accordance with Honcho 2.1.0
## [1.1.0] - 2025-07-08
### Fixed

View File

@ -13,7 +13,7 @@ jest.mock('@honcho-ai/core', () => {
sessions: {
list: jest.fn(),
},
getOrCreate: jest.fn(),
getOrCreate: jest.fn().mockResolvedValue({ id: 'test-workspace', metadata: {} }),
update: jest.fn(),
list: jest.fn(),
search: jest.fn(),
@ -28,13 +28,13 @@ describe('Honcho Client', () => {
beforeEach(() => {
// Clear all mocks before each test
jest.clearAllMocks();
honcho = new Honcho({
workspaceId: 'test-workspace',
apiKey: 'test-key',
environment: 'local',
});
mockClient = (honcho as any)._client;
});
@ -48,7 +48,7 @@ describe('Honcho Client', () => {
timeout: 5000,
maxRetries: 3,
});
expect(client.workspaceId).toBe('custom-workspace');
});
@ -56,11 +56,11 @@ describe('Honcho Client', () => {
process.env.HONCHO_WORKSPACE_ID = 'env-workspace';
process.env.HONCHO_API_KEY = 'env-key';
process.env.HONCHO_URL = 'https://env-url.com';
const client = new Honcho({});
expect(client.workspaceId).toBe('env-workspace');
// Clean up environment variables
delete process.env.HONCHO_WORKSPACE_ID;
delete process.env.HONCHO_API_KEY;
@ -83,7 +83,7 @@ describe('Honcho Client', () => {
defaultHeaders: { 'X-Custom': 'header' },
defaultQuery: { param: 'value' },
});
expect(client.workspaceId).toBe('test');
});
});
@ -91,7 +91,7 @@ describe('Honcho Client', () => {
describe('peer', () => {
it('should create a new Peer instance', () => {
const peer = honcho.peer('test-peer');
expect(peer).toBeInstanceOf(Peer);
expect(peer.id).toBe('test-peer');
});
@ -121,7 +121,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.peers.list.mockResolvedValue(mockPeersData);
const peersPage = await honcho.getPeers();
expect(peersPage).toBeInstanceOf(Page);
expect(mockClient.workspaces.peers.list).toHaveBeenCalledWith('test-workspace');
});
@ -136,7 +136,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.peers.list.mockResolvedValue(mockPeersData);
const peersPage = await honcho.getPeers();
expect(peersPage).toBeInstanceOf(Page);
expect(mockClient.workspaces.peers.list).toHaveBeenCalledWith('test-workspace');
});
@ -151,7 +151,7 @@ describe('Honcho Client', () => {
describe('session', () => {
it('should create a new Session instance', () => {
const session = honcho.session('test-session');
expect(session).toBeInstanceOf(Session);
expect(session.id).toBe('test-session');
});
@ -181,7 +181,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.sessions.list.mockResolvedValue(mockSessionsData);
const sessionsPage = await honcho.getSessions();
expect(sessionsPage).toBeInstanceOf(Page);
expect(mockClient.workspaces.sessions.list).toHaveBeenCalledWith('test-workspace');
});
@ -196,7 +196,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.sessions.list.mockResolvedValue(mockSessionsData);
const sessionsPage = await honcho.getSessions();
expect(sessionsPage).toBeInstanceOf(Page);
});
@ -216,7 +216,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace);
const metadata = await honcho.getMetadata();
expect(metadata).toEqual({ key: 'value', setting: 'config' });
expect(mockClient.workspaces.getOrCreate).toHaveBeenCalledWith({ id: 'test-workspace' });
});
@ -229,7 +229,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace);
const metadata = await honcho.getMetadata();
expect(metadata).toEqual({});
});
@ -246,7 +246,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.update.mockResolvedValue({});
await honcho.setMetadata(metadata);
expect(mockClient.workspaces.update).toHaveBeenCalledWith('test-workspace', { metadata });
});
@ -254,7 +254,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.update.mockResolvedValue({});
await honcho.setMetadata({});
expect(mockClient.workspaces.update).toHaveBeenCalledWith('test-workspace', { metadata: {} });
});
@ -269,7 +269,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.update.mockResolvedValue({});
await honcho.setMetadata(complexMetadata);
expect(mockClient.workspaces.update).toHaveBeenCalledWith('test-workspace', { metadata: complexMetadata });
});
@ -292,7 +292,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.list.mockResolvedValue(mockWorkspacesPage);
const workspaces = await honcho.getWorkspaces();
expect(workspaces).toEqual(['workspace1', 'workspace2', 'workspace3']);
expect(mockClient.workspaces.list).toHaveBeenCalled();
});
@ -306,7 +306,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.list.mockResolvedValue(mockWorkspacesPage);
const workspaces = await honcho.getWorkspaces();
expect(workspaces).toEqual([]);
});
@ -331,9 +331,9 @@ describe('Honcho Client', () => {
mockClient.workspaces.search.mockResolvedValue(mockSearchResults);
const results = await honcho.search('hello');
expect(results).toBeInstanceOf(Page);
expect(mockClient.workspaces.search).toHaveBeenCalledWith('test-workspace', 'hello');
expect(mockClient.workspaces.search).toHaveBeenCalledWith('test-workspace', { body: 'hello' });
});
it('should handle empty search results', async () => {
@ -346,7 +346,7 @@ describe('Honcho Client', () => {
mockClient.workspaces.search.mockResolvedValue(mockSearchResults);
const results = await honcho.search('nonexistent');
expect(results).toBeInstanceOf(Page);
});
@ -372,8 +372,8 @@ describe('Honcho Client', () => {
const complexQuery = 'complex query with "quotes" and special characters!@#$%';
await honcho.search(complexQuery);
expect(mockClient.workspaces.search).toHaveBeenCalledWith('test-workspace', complexQuery);
expect(mockClient.workspaces.search).toHaveBeenCalledWith('test-workspace', { body: complexQuery });
});
it('should handle API errors', async () => {

View File

@ -138,7 +138,7 @@ describe('Honcho SDK Integration Tests', () => {
expect(mockWorkspacesApi.workspaces.peers.chat).toHaveBeenCalledWith(
'integration-test-workspace',
'assistant',
{ queries: 'How are you?', stream: undefined, target: undefined, session_id: undefined }
{ query: 'How are you?', stream: undefined, target: undefined, session_id: undefined }
);
});
@ -229,7 +229,7 @@ describe('Honcho SDK Integration Tests', () => {
expect(workspaceResults).toBeInstanceOf(Page);
expect(mockWorkspacesApi.workspaces.search).toHaveBeenCalledWith(
'integration-test-workspace',
'test query'
{ body: 'test query' }
);
// Step 2: Search peer
@ -249,7 +249,7 @@ describe('Honcho SDK Integration Tests', () => {
expect(mockWorkspacesApi.workspaces.sessions.search).toHaveBeenCalledWith(
'integration-test-workspace',
'test-session',
'session query'
{ query: 'session query' }
);
});

View File

@ -20,6 +20,10 @@ jest.mock('@honcho-ai/core', () => {
update: jest.fn(),
search: jest.fn(),
},
getOrCreate: jest.fn().mockResolvedValue({ id: 'test-workspace', metadata: {} }),
update: jest.fn(),
list: jest.fn(),
search: jest.fn(),
},
}));
});
@ -31,13 +35,13 @@ describe('Peer', () => {
beforeEach(() => {
jest.clearAllMocks();
honcho = new Honcho({
workspaceId: 'test-workspace',
apiKey: 'test-key',
environment: 'local',
});
peer = new Peer('test-peer', honcho);
mockClient = (honcho as any)._client;
});
@ -45,7 +49,7 @@ describe('Peer', () => {
describe('constructor', () => {
it('should initialize with correct properties', () => {
const newPeer = new Peer('peer-id', honcho);
expect(newPeer.id).toBe('peer-id');
expect(newPeer['_honcho']).toBe(honcho);
});
@ -57,12 +61,12 @@ describe('Peer', () => {
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
const result = await peer.chat('Hello');
expect(result).toBe('Hello, I am a peer response');
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ queries: 'Hello', stream: undefined, target: undefined, session_id: undefined }
{ query: 'Hello', stream: undefined, target: undefined, session_id: undefined }
);
});
@ -71,7 +75,7 @@ describe('Peer', () => {
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
const result = await peer.chat('Hello');
expect(result).toBeNull();
});
@ -80,7 +84,7 @@ describe('Peer', () => {
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
const result = await peer.chat('Hello');
expect(result).toBeNull();
});
@ -89,11 +93,11 @@ describe('Peer', () => {
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
await peer.chat('Hello', { stream: true });
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ queries: 'Hello', stream: true, target: undefined, session_id: undefined }
{ query: 'Hello', stream: true, target: undefined, session_id: undefined }
);
});
@ -103,11 +107,11 @@ describe('Peer', () => {
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
await peer.chat('Hello', { target: targetPeer });
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ queries: 'Hello', stream: undefined, target: 'target-peer', session_id: undefined }
{ query: 'Hello', stream: undefined, target: 'target-peer', session_id: undefined }
);
});
@ -116,11 +120,11 @@ describe('Peer', () => {
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
await peer.chat('Hello', { target: 'string-target' });
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ queries: 'Hello', stream: undefined, target: 'string-target', session_id: undefined }
{ query: 'Hello', stream: undefined, target: 'string-target', session_id: undefined }
);
});
@ -129,11 +133,11 @@ describe('Peer', () => {
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
await peer.chat('Hello', { sessionId: 'session-123' });
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ queries: 'Hello', stream: undefined, target: undefined, session_id: 'session-123' }
{ query: 'Hello', stream: undefined, target: undefined, session_id: 'session-123' }
);
});
@ -142,16 +146,16 @@ describe('Peer', () => {
const mockResponse = { content: 'Full options response' };
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
await peer.chat('Hello', {
stream: true,
target: targetPeer,
sessionId: 'session-456'
await peer.chat('Hello', {
stream: true,
target: targetPeer,
sessionId: 'session-456'
});
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ queries: 'Hello', stream: true, target: 'target-peer', session_id: 'session-456' }
{ query: 'Hello', stream: true, target: 'target-peer', session_id: 'session-456' }
);
});
@ -176,7 +180,7 @@ describe('Peer', () => {
mockClient.workspaces.peers.sessions.list.mockResolvedValue(mockSessionsData);
const sessionsPage = await peer.getSessions();
expect(sessionsPage).toBeInstanceOf(Page);
expect(mockClient.workspaces.peers.sessions.list).toHaveBeenCalledWith(
'test-peer',
@ -194,7 +198,7 @@ describe('Peer', () => {
mockClient.workspaces.peers.sessions.list.mockResolvedValue(mockSessionsData);
const sessionsPage = await peer.getSessions();
expect(sessionsPage).toBeInstanceOf(Page);
});
@ -205,147 +209,10 @@ describe('Peer', () => {
});
});
describe('addMessages', () => {
it('should add a single string message', async () => {
mockClient.workspaces.peers.messages.create.mockResolvedValue({});
await peer.addMessages('Hello world');
expect(mockClient.workspaces.peers.messages.create).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ messages: [{ peer_id: 'test-peer', content: 'Hello world', metadata: undefined }] }
);
});
it('should add a single message object', async () => {
const message = {
peerId: 'test-peer',
content: 'Test message',
metadata: { type: 'test' },
};
mockClient.workspaces.peers.messages.create.mockResolvedValue({});
await peer.addMessages(message);
expect(mockClient.workspaces.peers.messages.create).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ messages: [{ peer_id: 'test-peer', content: 'Test message', metadata: { type: 'test' } }] }
);
});
it('should add message object without specified peerId', async () => {
const message = {
content: 'Test message without peer ID',
metadata: { type: 'test' },
};
mockClient.workspaces.peers.messages.create.mockResolvedValue({});
await peer.addMessages(message);
expect(mockClient.workspaces.peers.messages.create).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ messages: [{ peer_id: 'test-peer', content: 'Test message without peer ID', metadata: { type: 'test' } }] }
);
});
it('should add array of messages', async () => {
const messages = [
{ peerId: 'peer1', content: 'Message 1', metadata: { order: 1 } },
{ peerId: 'peer2', content: 'Message 2', metadata: { order: 2 } },
];
mockClient.workspaces.peers.messages.create.mockResolvedValue({});
await peer.addMessages(messages);
expect(mockClient.workspaces.peers.messages.create).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{
messages: [
{ peer_id: 'peer1', content: 'Message 1', metadata: { order: 1 } },
{ peer_id: 'peer2', content: 'Message 2', metadata: { order: 2 } },
]
}
);
});
it('should handle empty array', async () => {
mockClient.workspaces.peers.messages.create.mockResolvedValue({});
await peer.addMessages([]);
expect(mockClient.workspaces.peers.messages.create).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ messages: [] }
);
});
it('should handle API errors', async () => {
mockClient.workspaces.peers.messages.create.mockRejectedValue(new Error('Failed to add messages'));
await expect(peer.addMessages('test')).rejects.toThrow('Failed to add messages');
});
});
describe('getMessages', () => {
it('should get messages without options', async () => {
const mockMessagesData = {
items: [
{ id: 'msg1', content: 'Message 1', peer_id: 'test-peer' },
{ id: 'msg2', content: 'Message 2', peer_id: 'test-peer' },
],
total: 2,
size: 2,
hasNextPage: false,
};
mockClient.workspaces.peers.messages.list.mockResolvedValue(mockMessagesData);
const messagesPage = await peer.getMessages();
expect(messagesPage).toBeInstanceOf(Page);
expect(mockClient.workspaces.peers.messages.list).toHaveBeenCalledWith(
'test-peer',
'test-workspace',
undefined
);
});
it('should get messages with filter options', async () => {
const mockMessagesData = {
items: [],
total: 0,
size: 0,
hasNextPage: false,
};
mockClient.workspaces.peers.messages.list.mockResolvedValue(mockMessagesData);
const options = {
filter: { type: 'important', date: '2023-01-01' }
};
await peer.getMessages(options);
expect(mockClient.workspaces.peers.messages.list).toHaveBeenCalledWith(
'test-peer',
'test-workspace',
{ type: 'important', date: '2023-01-01' }
);
});
it('should handle API errors', async () => {
mockClient.workspaces.peers.messages.list.mockRejectedValue(new Error('Failed to get messages'));
await expect(peer.getMessages()).rejects.toThrow('Failed to get messages');
});
});
describe('message', () => {
it('should create message object without metadata', () => {
const message = peer.message('Test content');
expect(message).toEqual({
peerId: 'test-peer',
content: 'Test content',
@ -356,7 +223,7 @@ describe('Peer', () => {
it('should create message object with metadata', () => {
const metadata = { importance: 'high', category: 'greeting' };
const message = peer.message('Hello there', { metadata });
expect(message).toEqual({
peerId: 'test-peer',
content: 'Hello there',
@ -366,7 +233,7 @@ describe('Peer', () => {
it('should handle empty content', () => {
const message = peer.message('');
expect(message).toEqual({
peerId: 'test-peer',
content: '',
@ -384,7 +251,7 @@ describe('Peer', () => {
mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer);
const metadata = await peer.getMetadata();
expect(metadata).toEqual({ name: 'Test Peer', role: 'assistant' });
expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledWith(
'test-workspace',
@ -400,7 +267,7 @@ describe('Peer', () => {
mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer);
const metadata = await peer.getMetadata();
expect(metadata).toEqual({});
});
@ -417,7 +284,7 @@ describe('Peer', () => {
mockClient.workspaces.peers.update.mockResolvedValue({});
await peer.setMetadata(metadata);
expect(mockClient.workspaces.peers.update).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
@ -429,7 +296,7 @@ describe('Peer', () => {
mockClient.workspaces.peers.update.mockResolvedValue({});
await peer.setMetadata({});
expect(mockClient.workspaces.peers.update).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
@ -446,7 +313,7 @@ describe('Peer', () => {
mockClient.workspaces.peers.update.mockResolvedValue({});
await peer.setMetadata(complexMetadata);
expect(mockClient.workspaces.peers.update).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
@ -475,12 +342,12 @@ describe('Peer', () => {
mockClient.workspaces.peers.search.mockResolvedValue(mockSearchResults);
const results = await peer.search('hello');
expect(results).toBeInstanceOf(Page);
expect(mockClient.workspaces.peers.search).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ body: 'hello' }
{ query: 'hello' }
);
});
@ -494,7 +361,7 @@ describe('Peer', () => {
mockClient.workspaces.peers.search.mockResolvedValue(mockSearchResults);
const results = await peer.search('nonexistent');
expect(results).toBeInstanceOf(Page);
});
@ -520,11 +387,11 @@ describe('Peer', () => {
const complexQuery = 'complex query with "quotes" and special characters!@#$%';
await peer.search(complexQuery);
expect(mockClient.workspaces.peers.search).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ body: complexQuery }
{ query: complexQuery }
);
});

View File

@ -27,6 +27,10 @@ jest.mock('@honcho-ai/core', () => {
peers: {
workingRepresentation: jest.fn(),
},
getOrCreate: jest.fn().mockResolvedValue({ id: 'test-workspace', metadata: {} }),
update: jest.fn(),
list: jest.fn(),
search: jest.fn(),
},
}));
});
@ -38,13 +42,13 @@ describe('Session', () => {
beforeEach(() => {
jest.clearAllMocks();
honcho = new Honcho({
workspaceId: 'test-workspace',
apiKey: 'test-key',
environment: 'local',
});
session = new Session('test-session', honcho);
mockClient = (honcho as any)._client;
});
@ -52,17 +56,17 @@ describe('Session', () => {
describe('constructor', () => {
it('should initialize with correct properties', () => {
const newSession = new Session('session-id', honcho);
expect(newSession.id).toBe('session-id');
expect(newSession['_honcho']).toBe(honcho);
});
it('should handle constructor options', () => {
const newSession = new Session('session-id', honcho, {
anonymous: true,
summarize: false
const newSession = new Session('session-id', honcho, {
anonymous: true,
summarize: false
});
expect(newSession.id).toBe('session-id');
});
});
@ -72,7 +76,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.add.mockResolvedValue({});
await session.addPeers('peer1');
expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -85,7 +89,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.add.mockResolvedValue({});
await session.addPeers(peer);
expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -97,11 +101,11 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.add.mockResolvedValue({});
await session.addPeers(['peer1', 'peer2', 'peer3']);
expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith(
'test-workspace',
'test-session',
{
{
'peer1': { observe_me: true, observe_others: false },
'peer2': { observe_me: true, observe_others: false },
'peer3': { observe_me: true, observe_others: false }
@ -118,11 +122,11 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.add.mockResolvedValue({});
await session.addPeers(peers);
expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith(
'test-workspace',
'test-session',
{
{
'peer1': { observe_me: true, observe_others: false },
'peer2': { observe_me: true, observe_others: false },
'peer3': { observe_me: true, observe_others: false }
@ -138,11 +142,11 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.add.mockResolvedValue({});
await session.addPeers(peers);
expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith(
'test-workspace',
'test-session',
{
{
'string-peer': { observe_me: true, observe_others: false },
'object-peer': { observe_me: true, observe_others: false }
}
@ -161,7 +165,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.set.mockResolvedValue({});
await session.setPeers('peer1');
expect(mockClient.workspaces.sessions.peers.set).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -174,7 +178,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.set.mockResolvedValue({});
await session.setPeers(peer);
expect(mockClient.workspaces.sessions.peers.set).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -187,11 +191,11 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.set.mockResolvedValue({});
await session.setPeers(peers);
expect(mockClient.workspaces.sessions.peers.set).toHaveBeenCalledWith(
'test-workspace',
'test-session',
{
{
'peer1': { observe_me: true, observe_others: false },
'peer2': { observe_me: true, observe_others: false }
}
@ -210,7 +214,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.remove.mockResolvedValue({});
await session.removePeers('peer1');
expect(mockClient.workspaces.sessions.peers.remove).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -223,7 +227,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.remove.mockResolvedValue({});
await session.removePeers(peer);
expect(mockClient.workspaces.sessions.peers.remove).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -236,7 +240,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.remove.mockResolvedValue({});
await session.removePeers(peers);
expect(mockClient.workspaces.sessions.peers.remove).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -265,7 +269,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.list.mockResolvedValue(mockPeersData);
const peers = await session.getPeers();
expect(peers).toBeInstanceOf(Array);
expect(mockClient.workspaces.sessions.peers.list).toHaveBeenCalledWith(
'test-workspace',
@ -283,7 +287,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.peers.list.mockResolvedValue(mockPeersData);
const peers = await session.getPeers();
expect(peers).toBeInstanceOf(Array);
expect(peers.length).toBe(0);
});
@ -305,16 +309,16 @@ describe('Session', () => {
mockClient.workspaces.sessions.messages.create.mockResolvedValue({});
await session.addMessages(message);
expect(mockClient.workspaces.sessions.messages.create).toHaveBeenCalledWith(
'test-workspace',
'test-session',
{
messages: [{
peer_id: 'peer1',
content: 'Hello world',
metadata: { type: 'greeting' }
}]
{
messages: [{
peer_id: 'peer1',
content: 'Hello world',
metadata: { type: 'greeting' }
}]
}
);
});
@ -327,15 +331,15 @@ describe('Session', () => {
mockClient.workspaces.sessions.messages.create.mockResolvedValue({});
await session.addMessages(messages);
expect(mockClient.workspaces.sessions.messages.create).toHaveBeenCalledWith(
'test-workspace',
'test-session',
{
{
messages: [
{ peer_id: 'peer1', content: 'Message 1', metadata: { order: 1 } },
{ peer_id: 'peer2', content: 'Message 2', metadata: { order: 2 } },
]
]
}
);
});
@ -348,7 +352,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.messages.create.mockResolvedValue({});
await session.addMessages(message);
expect(mockClient.workspaces.sessions.messages.create).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -360,7 +364,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.messages.create.mockResolvedValue({});
await session.addMessages([]);
expect(mockClient.workspaces.sessions.messages.create).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -389,7 +393,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.messages.list.mockResolvedValue(mockMessagesData);
const messagesPage = await session.getMessages();
expect(messagesPage).toBeInstanceOf(Page);
expect(mockClient.workspaces.sessions.messages.list).toHaveBeenCalledWith(
'test-workspace',
@ -407,11 +411,11 @@ describe('Session', () => {
};
mockClient.workspaces.sessions.messages.list.mockResolvedValue(mockMessagesData);
const options = {
filter: { peer_id: 'peer1', type: 'important' }
const options = {
filter: { peer_id: 'peer1', type: 'important' }
};
await session.getMessages(options);
expect(mockClient.workspaces.sessions.messages.list).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -435,7 +439,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession);
const metadata = await session.getMetadata();
expect(metadata).toEqual({ name: 'Test Session', active: true });
expect(mockClient.workspaces.sessions.getOrCreate).toHaveBeenCalledWith(
'test-workspace',
@ -451,7 +455,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession);
const metadata = await session.getMetadata();
expect(metadata).toEqual({});
});
@ -468,7 +472,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.update.mockResolvedValue({});
await session.setMetadata(metadata);
expect(mockClient.workspaces.sessions.update).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -480,7 +484,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.update.mockResolvedValue({});
await session.setMetadata({});
expect(mockClient.workspaces.sessions.update).toHaveBeenCalledWith(
'test-workspace',
'test-session',
@ -507,7 +511,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.getContext.mockResolvedValue(mockContext);
const context = await session.getContext();
expect(context).toBeInstanceOf(SessionContext);
expect(context.sessionId).toBe('test-session');
expect(context.messages).toEqual(mockContext.messages);
@ -528,7 +532,7 @@ describe('Session', () => {
const options = { summary: true, tokens: 1000 };
const context = await session.getContext(options);
expect(context).toBeInstanceOf(SessionContext);
expect(mockClient.workspaces.sessions.getContext).toHaveBeenCalledWith(
'test-workspace',
@ -544,7 +548,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.getContext.mockResolvedValue(mockContext);
const context = await session.getContext();
expect(context.summary).toBe('');
});
@ -569,12 +573,12 @@ describe('Session', () => {
mockClient.workspaces.sessions.search.mockResolvedValue(mockSearchResults);
const results = await session.search('hello');
expect(results).toBeInstanceOf(Page);
expect(mockClient.workspaces.sessions.search).toHaveBeenCalledWith(
'test-workspace',
'test-session',
'hello'
{ query: 'hello' }
);
});
@ -588,7 +592,7 @@ describe('Session', () => {
mockClient.workspaces.sessions.search.mockResolvedValue(mockSearchResults);
const results = await session.search('nonexistent');
expect(results).toBeInstanceOf(Page);
});
@ -620,7 +624,7 @@ describe('Session', () => {
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(mockRepresentation);
const result = await session.workingRep('peer1');
expect(result).toEqual(mockRepresentation);
expect(mockClient.workspaces.peers.workingRepresentation).toHaveBeenCalledWith(
'test-workspace',
@ -638,7 +642,7 @@ describe('Session', () => {
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(mockRepresentation);
const result = await session.workingRep(peer);
expect(result).toEqual(mockRepresentation);
expect(mockClient.workspaces.peers.workingRepresentation).toHaveBeenCalledWith(
'test-workspace',
@ -655,7 +659,7 @@ describe('Session', () => {
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(mockRepresentation);
const result = await session.workingRep('peer1', 'target-peer');
expect(result).toEqual(mockRepresentation);
expect(mockClient.workspaces.peers.workingRepresentation).toHaveBeenCalledWith(
'test-workspace',
@ -674,7 +678,7 @@ describe('Session', () => {
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(mockRepresentation);
const result = await session.workingRep(peer, target);
expect(result).toEqual(mockRepresentation);
expect(mockClient.workspaces.peers.workingRepresentation).toHaveBeenCalledWith(
'test-workspace',

View File

@ -2,9 +2,9 @@
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "honcho-ai",
"name": "@honcho-ai/sdk",
"dependencies": {
"@honcho-ai/core": "^1.0.0",
"@honcho-ai/core": "1.2.0",
"@types/node": "^24.0.1",
},
"devDependencies": {
@ -21,14 +21,16 @@
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.27.5", "", {}, "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="],
"@babel/core": ["@babel/core@7.27.4", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.4", "@babel/parser": "^7.27.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.4", "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g=="],
"@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@babel/generator": ["@babel/generator@7.27.5", "", { "dependencies": { "@babel/parser": "^7.27.5", "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw=="],
"@babel/generator": ["@babel/generator@7.28.0", "", { "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.27.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg=="],
@ -43,7 +45,7 @@
"@babel/helpers": ["@babel/helpers@7.27.6", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.27.6" } }, "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug=="],
"@babel/parser": ["@babel/parser@7.27.5", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg=="],
"@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="],
"@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="],
@ -81,9 +83,9 @@
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.27.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/parser": "^7.27.4", "@babel/template": "^7.27.2", "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA=="],
"@babel/traverse": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="],
"@babel/types": ["@babel/types@7.27.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q=="],
"@babel/types": ["@babel/types@7.28.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
@ -95,7 +97,7 @@
"@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="],
"@honcho-ai/core": ["@honcho-ai/core@1.0.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-WwpKTxMhkBEpiQ2UYlM9HH+MCAkWA8o+5w/J/bYKAo9traZF4UKsO2VouWIXiLsyIvLfqzLGgGrALoH2P4h9Uw=="],
"@honcho-ai/core": ["@honcho-ai/core@1.2.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-VPHCFIGfC00GeE4P83DDIT7hkuMnMVkWlMTmMd2tw4HSEUciqLBh09AX/6aMKfJAzprg1diub6pJJ6LJP6eJ+g=="],
"@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
@ -135,15 +137,13 @@
"@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@ -175,7 +175,7 @@
"@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="],
"@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="],
"@types/node": ["@types/node@24.0.14", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw=="],
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
@ -227,7 +227,7 @@
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.25.0", "", { "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA=="],
"browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="],
"bs-logger": ["bs-logger@0.2.6", "", { "dependencies": { "fast-json-stable-stringify": "2.x" } }, "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog=="],
@ -241,7 +241,7 @@
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001723", "", {}, "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@ -291,7 +291,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.170", "", {}, "sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.185", "", {}, "sha512-dYOZfUk57hSMPePoIQ1fZWl1Fkj+OshhEVuPacNKWzC1efe56OsHY3l/jCfiAgIICOU3VgOIdoq7ahg7r7n6MQ=="],
"emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="],
@ -733,9 +733,7 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
"@honcho-ai/core/@types/node": ["@types/node@18.19.112", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-i+Vukt9POdS/MBI7YrrkkI5fMfwFtOjphSmt4WXYLfwqsfr6z/HdCx7LqT9M7JktGob8WNgj8nFB4TbGNE4Cog=="],
"@honcho-ai/core/@types/node": ["@types/node@18.19.119", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-d0F6m9itIPaKnrvEMlzE48UjwZaAnFW7Jwibacw9MNdqadjKNpUm9tfJYDwmShJmgqcoqYUX3EMKO1+RWiuuNg=="],
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],

View File

@ -1,109 +0,0 @@
import { Honcho, SessionPeerConfig } from '../src';
/**
* Example usage of the Honcho TypeScript SDK.
*
* This demonstrates how to manage peers, sessions, and messages
* using the high-level SDK API.
*/
async function main() {
console.log('Initializing Honcho client...');
const honcho = new Honcho({
environment: 'local',
workspaceId: 'test',
});
console.log('Creating peers...');
const assistant = honcho.peer('bob');
const alice = honcho.peer('alice');
console.log('Fetching all peers in workspace...');
const peers = await honcho.getPeers();
for await (const peer of peers) {
console.log('Peer:', peer.id);
}
console.log('Fetching workspace metadata...');
const m = await honcho.getMetadata();
console.log('Current metadata:', m);
await honcho.setMetadata({ test: 'test' });
console.log('Set workspace metadata.');
console.log('Testing chat endpoint (should be null)...');
const response = await alice.chat('what did alice have for breakfast today?');
console.log('Chat response:', response);
console.log('Creating session...');
const mySession = honcho.session('session_1');
console.log('Adding peers to session...');
await mySession.addPeers([alice, [assistant, new SessionPeerConfig({ observe_me: false })]]);
console.log('Peers added to session.');
console.log('Fetching sessions for alice...');
const _sessions = await alice.getSessions();
for await (const session of _sessions) {
console.log('Session:', session.id);
}
console.log('Adding messages to session...');
await mySession.addMessages([
assistant.message('what did you have for breakfast today, alice?'),
alice.message('i had oatmeal.'),
]);
console.log('Messages added.');
const sessionMetadata = await mySession.getMetadata();
console.log('Session metadata:', sessionMetadata);
await mySession.setMetadata({ ...sessionMetadata, test: 'test2' });
console.log('Session metadata updated.');
console.log('Querying alice global representation...');
await alice.chat('what did the user have for breakfast today?');
console.log('Querying alice local representation of assistant...');
await alice.chat('does alice know what bob had for breakfast?', { target: assistant });
console.log('Querying assistant local representation of alice in session...');
await assistant.chat('does the assistant know what alice had for breakfast?', {
target: alice,
sessionId: mySession.id,
});
console.log('Adding non-message content to alice...');
await alice.addMessages('this might be a document about alice, say, a journal entry.');
console.log('Creating charlie peer and adding message...');
const charlie = honcho.peer('charlie');
await mySession.addMessages(charlie.message('hello world!'));
console.log('Fetching and updating charlie metadata...');
const charlieMetadata = await charlie.getMetadata();
await charlie.setMetadata({ ...charlieMetadata, location: 'the moon' });
console.log('Charlie metadata updated.');
console.log('Querying charlie for location...');
await charlie.chat('where is the user?');
console.log('Fetching all messages from session...');
const messages = await mySession.getMessages();
console.log('Messages:', messages.total);
console.log('Fetching session context...');
const context = await mySession.getContext();
const openaiMessages = context.toOpenAI(alice.id);
const anthropicMessages = context.toAnthropic(alice.id);
console.log('OpenAI context:', openaiMessages);
console.log('Anthropic context:', anthropicMessages);
console.log('Adding test message using property syntax...');
await mySession.addMessages(
assistant.message('This is a test message using the property syntax')
);
console.log('Sample code executed successfully!');
}
main().catch((err) => {
console.error('Error running example:', err);
});

View File

@ -20,6 +20,8 @@ async function main() {
honcho.peer('charlie'),
];
const alice = peers[0];
// Create a new session
const sessionId = `search_test_${crypto.randomUUID()}`;
const session = honcho.session(sessionId);
@ -28,7 +30,7 @@ async function main() {
// Create a message with our special keyword
const keyword = `~special-${crypto.randomUUID()}~`;
console.log(`Using keyword: ${keyword}`);
await session.addMessages(peers[0].message(`I am a ${keyword} message`));
await session.addMessages(alice.message(`I am a ${keyword} message`));
console.log('Generating random messages...');
// Generate some random messages from alice, bob, and charlie and add them to the session
@ -51,13 +53,6 @@ async function main() {
console.log(` - ${message.content} (from ${message.peer_id})`);
}
const alice = peers[0];
// Add a different message to alice's global representation
const differentKeyword = `~different-${crypto.randomUUID()}~`;
console.log(`Using different keyword: ${differentKeyword}`);
await alice.addMessages(alice.message(`I am a ${differentKeyword} message`));
console.log('Searching the workspace...');
// Search the workspace for the special keyword
const workspaceSearchResults = await honcho.search(keyword);
@ -66,9 +61,9 @@ async function main() {
console.log(` - ${message.content} (from ${message.peer_id})`);
}
console.log('Searching alice\'s global representation...');
// Search alice's global representation for the different message
const aliceSearchResults = await alice.search(differentKeyword);
console.log('Searching alice\'s messages...');
// Search alice's messages for the special keyword
const aliceSearchResults = await alice.search(keyword);
console.log(`Alice search returned ${aliceSearchResults.total} results:`);
for await (const message of aliceSearchResults) {
console.log(` - ${message.content} (from ${message.peer_id})`);

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "@honcho-ai/sdk",
"version": "1.1.0",
"version": "1.2.0",
"description": "Official DX Optimized TypeScript SDK for Honcho",
"author": "Plastic Labs <hello@plasticlabs.ai>",
"license": "Apache-2.0",
@ -18,7 +18,7 @@
},
"dependencies": {
"@types/node": "^24.0.1",
"@honcho-ai/core": "^1.1.0"
"@honcho-ai/core": "1.2.0"
},
"devDependencies": {
"@types/jest": "^29.5.14",

View File

@ -116,4 +116,100 @@ export class Honcho {
const messagesPage = await this._client.workspaces.search(this.workspaceId, { body: query });
return new Page(messagesPage);
}
/**
* Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
*
* @param options Configuration options for the status request
* @param options.observerId Optional observer ID to scope the status to
* @param options.senderId Optional sender ID to scope the status to
* @param options.sessionId Optional session ID to scope the status to
* @returns Promise resolving to the deriver status information
*/
async getDeriverStatus(options?: {
observerId?: string;
senderId?: string;
sessionId?: string;
}): Promise<{
totalWorkUnits: number;
completedWorkUnits: number;
inProgressWorkUnits: number;
pendingWorkUnits: number;
sessions?: Record<string, any>;
}> {
const queryParams: any = {};
if (options?.observerId) queryParams.observer_id = options.observerId;
if (options?.senderId) queryParams.sender_id = options.senderId;
if (options?.sessionId) queryParams.session_id = options.sessionId;
const status = await this._client.workspaces.deriverStatus(this.workspaceId, queryParams);
return {
totalWorkUnits: status.total_work_units,
completedWorkUnits: status.completed_work_units,
inProgressWorkUnits: status.in_progress_work_units,
pendingWorkUnits: status.pending_work_units,
sessions: status.sessions || undefined,
};
}
/**
* Poll getDeriverStatus until pendingWorkUnits and inProgressWorkUnits are both 0.
* This allows you to guarantee that all messages have been processed by the deriver for
* use with the dialectic endpoint.
*
* The polling estimates sleep time by assuming each work unit takes 1 second.
*
* @param options Configuration options for the status request
* @param options.observerId Optional observer ID to scope the status to
* @param options.senderId Optional sender ID to scope the status to
* @param options.sessionId Optional session ID to scope the status to
* @param options.timeoutMs Optional timeout in milliseconds (default: 300000 - 5 minutes)
* @returns Promise resolving to the final deriver status when processing is complete
* @throws Error if timeout is exceeded before processing completes
*/
async pollDeriverStatus(options?: {
observerId?: string;
senderId?: string;
sessionId?: string;
timeoutMs?: number;
}): Promise<{
totalWorkUnits: number;
completedWorkUnits: number;
inProgressWorkUnits: number;
pendingWorkUnits: number;
sessions?: Record<string, any>;
}> {
const timeoutMs = options?.timeoutMs ?? 300000; // Default to 5 minutes
const startTime = Date.now();
while (true) {
const status = await this.getDeriverStatus(options);
if (status.pendingWorkUnits === 0 && status.inProgressWorkUnits === 0) {
return status;
}
// Check if timeout has been exceeded
const elapsedTime = Date.now() - startTime;
if (elapsedTime >= timeoutMs) {
throw new Error(
`Polling timeout exceeded after ${timeoutMs}ms. ` +
`Current status: ${status.pendingWorkUnits} pending, ${status.inProgressWorkUnits} in progress work units.`
);
}
// Sleep for the expected time to complete all current work units
// Assuming each pending and in-progress work unit takes 1 second
const totalWorkUnits = status.pendingWorkUnits + status.inProgressWorkUnits;
const sleepMs = Math.max(1000, totalWorkUnits * 1000); // Sleep at least 1 second
// Ensure we don't sleep past the timeout
const remainingTime = timeoutMs - elapsedTime;
const actualSleepMs = Math.min(sleepMs, remainingTime);
if (actualSleepMs > 0) {
await new Promise(resolve => setTimeout(resolve, actualSleepMs));
}
}
}
}

View File

@ -40,14 +40,14 @@ export class Page<T> implements AsyncIterable<T> {
* Get the size of the page.
*/
get size(): number {
return this._originalPage?.size ?? 0;
return this._originalPage?.size;
}
/**
* Get the total number of items.
*/
get total(): number {
return this._originalPage?.total ?? 0;
return this._originalPage?.total;
}
/**
@ -63,7 +63,7 @@ export class Page<T> implements AsyncIterable<T> {
* Check if there's a next page.
*/
get hasNextPage(): boolean {
return this._originalPage?.hasNextPage ?? false;
return this._originalPage?.hasNextPage;
}
/**

View File

@ -30,7 +30,7 @@ export class Peer {
/**
* Query the peer's representation with a natural language question.
*/
async chat(queries: string | string[], opts?: {
async chat(query: string, opts?: {
stream?: boolean;
target?: string | Peer;
sessionId?: string;
@ -38,7 +38,7 @@ export class Peer {
const response = await this._honcho['_client'].workspaces.peers.chat(
this._honcho.workspaceId,
this.id,
{ queries, stream: opts?.stream, target: opts?.target ? (typeof opts.target === 'string' ? opts.target : opts.target.id) : undefined, session_id: opts?.sessionId },
{ query, stream: opts?.stream, target: opts?.target ? (typeof opts.target === 'string' ? opts.target : opts.target.id) : undefined, session_id: opts?.sessionId },
);
if (!response.content || response.content === 'None') {
return null;
@ -57,45 +57,6 @@ export class Peer {
return new Page(sessionsPage, (session: any) => new Session(session.id, this._honcho));
}
/**
* Add messages or content to this peer's global representation.
*/
async addMessages(content: string | any | any[]): Promise<void> {
let messages: any[];
if (typeof content === 'string') {
messages = [{ peer_id: this.id, content, metadata: undefined }];
} else if (Array.isArray(content)) {
messages = content.map((msg) => ({
peer_id: msg.peerId || this.id,
content: msg.content,
metadata: msg.metadata,
}));
} else {
messages = [{
peer_id: content.peerId || this.id,
content: content.content,
metadata: content.metadata,
}];
}
await this._honcho['_client'].workspaces.peers.messages.create(
this._honcho.workspaceId,
this.id,
{ messages }
);
}
/**
* Get messages saved to this peer outside of a session with optional filtering.
*/
async getMessages(opts?: { filter?: Record<string, unknown> }): Promise<Page<any>> {
const messagesPage = await this._honcho['_client'].workspaces.peers.messages.list(
this.id,
this._honcho.workspaceId,
opts?.filter,
);
return new Page(messagesPage);
}
/**
* Create a message attributed to this peer.
*/
@ -130,9 +91,9 @@ export class Peer {
}
/**
* Search for messages in this peer's global representation.
* Search for messages in the workspace with this peer as author.
*
* Makes an API call to search for messages in this peer's global representation.
* Makes an API call to search endpoint.
*
* @param query The search query to use
* @returns A Page of Message objects representing the search results.
@ -149,4 +110,40 @@ export class Peer {
);
return new Page(messagesPage);
}
/**
* Upload a file to create messages in this peer's global representation.
*
* Makes an API call to upload a file and convert it into messages. The file is
* processed to extract text content, split into appropriately sized chunks,
* and created as messages attributed to this peer.
*
* @param file File to upload. Should be an object with filename, content (as Buffer or Uint8Array), and content_type
* @returns A list of Message objects representing the created messages
*
* @note Supported file types include PDFs, text files, and JSON documents.
* Large files will be automatically split into multiple messages to fit
* within message size limits.
*/
async uploadFile(
file: { filename: string; content: Buffer | Uint8Array; content_type: string }
): Promise<any[]> {
// Convert file to the format expected by the API
const fileData = {
filename: file.filename,
content: file.content,
content_type: file.content_type
};
// Call the upload endpoint
const response = await (this._honcho['_client'] as any).workspaces.peers.messages.upload(
this._honcho.workspaceId,
this.id,
{
file: fileData
}
);
return response;
}
}

View File

@ -221,12 +221,50 @@ export class Session {
return new Page(messagesPage);
}
/**
* Upload a file to create messages in this session.
*
* Makes an API call to upload a file and convert it into messages. The file is
* processed to extract text content, split into appropriately sized chunks,
* and created as messages attributed to this peer.
*
* @param file File to upload. Should be an object with filename, content (as Buffer or Uint8Array), and content_type
* @param peerId The peer ID to attribute the messages to
* @returns A list of Message objects representing the created messages
*
* @note Supported file types include PDFs, text files, and JSON documents.
* Large files will be automatically split into multiple messages to fit
* within message size limits.
*/
async uploadFile(
file: { filename: string; content: Buffer | Uint8Array; content_type: string },
peerId: string,
): Promise<any[]> {
// Convert file to the format expected by the API
const fileData = {
filename: file.filename,
content: file.content,
content_type: file.content_type
};
// Call the upload endpoint
const response = await (this._honcho['_client'] as any).workspaces.sessions.messages.upload(
this._honcho.workspaceId,
this.id,
{
file: fileData,
peer_id: peerId,
}
);
return response;
}
/**
* Get the current working representation of the peer in this session.
*
* @param peer The peer to get the working representation of.
* @param target The target peer to get the representation of. If provided,
* queries what `peer` knows about the `target`.
* @param target The target peer to get the representation of. If provided, queries what `peer` knows about the `target`.
* @returns A dictionary containing information about the peer.
*/
async workingRep(peer: string | Peer, target?: string | Peer): Promise<Record<string, unknown>> {

View File

@ -1,475 +0,0 @@
import asyncio
import logging
from langfuse.decorators import langfuse_context, observe # pyright: ignore
from mirascope import llm
from mirascope.integrations.langfuse import with_langfuse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models
from src.config import settings
from src.dependencies import tracked_db
from src.deriver.tom import get_tom_inference
from src.deriver.tom.embeddings import CollectionEmbeddingStore
from src.deriver.tom.long_term import get_user_representation_long_term
from src.deriver.tom.single_prompt import UserRepresentationOutput
from src.utils import history, parse_xml_content
from src.utils.clients import clients
from src.utils.types import track
# Configure logging
logger = logging.getLogger(__name__)
@track("Dialectic Call")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.DIALECTIC_PROVIDER
if settings.LLM.DIALECTIC_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.DIALECTIC_MODEL,
client=clients[settings.LLM.DIALECTIC_PROVIDER],
)
async def dialectic_call(
query: str, working_representation: str, additional_context: str
):
return f"""
You are operating as a context service that helps maintain psychological understanding of users across applications. Alongside a query, you'll receive: 1) previously collected psychological context about the user that I've maintained, 2) a series of long-term facts about the user, and 3) their current conversation/interaction from the requesting application. Your goal is to analyze this information and provide theory-of-mind insights that help applications personalize their responses. Please respond in a brief, matter-of-fact, and appropriate manner to convey as much relevant information to the application based on its query and the user's most recent message. You are encouraged to provide any context from the provided resources that helps provide a more complete or nuanced understanding of the user, as long as it is somewhat relevant to the query. If the context provided doesn't help address the query, write absolutely NOTHING but "None".
<query>{query}</query>
<context>{working_representation}</context>
<conversation_history>{additional_context}</conversation_history>
"""
@track("Dialectic Stream")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.DIALECTIC_PROVIDER
if settings.LLM.DIALECTIC_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.DIALECTIC_MODEL,
stream=True,
client=clients[settings.LLM.DIALECTIC_PROVIDER],
)
async def dialectic_stream(
query: str, working_representation: str, additional_context: str
):
return f"""
You are operating as a context service that helps maintain psychological understanding of users across applications. Alongside a query, you'll receive: 1) previously collected psychological context about the user that I've maintained, 2) a series of long-term facts about the user, and 3) their current conversation/interaction from the requesting application. Your goal is to analyze this information and provide theory-of-mind insights that help applications personalize their responses. Please respond in a brief, matter-of-fact, and appropriate manner to convey as much relevant information to the application based on its query and the user's most recent message. You are encouraged to provide any context from the provided resources that helps provide a more complete or nuanced understanding of the user, as long as it is somewhat relevant to the query. If the context provided doesn't help address the query, write absolutely NOTHING but "None".
<query>{query}</query>
<context>{working_representation}</context>
<conversation_history>{additional_context}</conversation_history>
"""
class SemanticQueries(BaseModel):
queries: list[str]
@with_langfuse()
@llm.call(
provider=(
settings.LLM.QUERY_GENERATION_PROVIDER
if settings.LLM.QUERY_GENERATION_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.QUERY_GENERATION_MODEL,
response_model=SemanticQueries,
client=clients[settings.LLM.QUERY_GENERATION_PROVIDER],
)
async def generate_semantic_queries_llm(query: str):
return f"""
Given this query about a user, generate 3 focused search queries that would help retrieve relevant facts about the user. Each query should focus on a specific aspect related to the original query, rephrased to maximize semantic search effectiveness.
For example, if the original query asks "what does the user like to eat?", generated queries might include "user's food preferences", "user's favorite cuisine", etc.
Format your response as a JSON array of strings, with each string being a search query.
Respond only in valid JSON, without markdown formatting or quotes, and nothing else.
Example:
["query about interests", "query about personality", "query about experiences"]
<query>{query}</query>
"""
@observe()
async def chat(
workspace_name: str,
peer_name: str,
session_name: str | None,
queries: str | list[str],
stream: bool = False,
target: str | None = None,
) -> llm.Stream | llm.CallResponse:
"""
Chat with the Dialectic API using on-demand user representation generation.
This function:
1. Sets up resources needed (embedding store, latest message ID)
2. Runs two parallel processes:
- Retrieves long-term facts from the vector store based on the query
- Gets recent chat history and runs ToM inference
3. Combines both into a fresh user representation
4. Uses this representation to answer the query
5. Saves the representation for future use
Args:
workspace_name: The workspace name
peer_name: The peer name
session_name: The session name. If None, this queries the global representation.
queries: The queries to ask the Dialectic API
stream: Whether to stream the response
Returns:
Either a string or a stream of messages from the LLM provider, depending on the stream flag
"""
# Format the query string
questions = [queries] if isinstance(queries, str) else queries
final_query = "\n".join(questions) if len(questions) > 1 else questions[0]
logger.debug(f"Received query: {final_query} for session {session_name}")
logger.debug("Starting on-demand user representation generation")
# Setup phase - create resources we'll need for all operations
# 1. Fetch latest peer message & chat history
async with tracked_db("chat.load_history") as db_history:
stmt = (
select(models.Message)
.where(models.Message.workspace_name == workspace_name)
.where(models.Message.peer_name == peer_name)
.order_by(models.Message.id.desc())
.limit(1)
)
if session_name:
stmt = stmt.where(models.Message.session_name == session_name)
latest_messages = await db_history.execute(stmt)
latest_message = latest_messages.scalar_one_or_none()
latest_message_id = latest_message.public_id if latest_message else None
if session_name:
chat_history = await history.get_summarized_history(
db_history,
workspace_name,
session_name,
peer_name,
summary_type=history.SummaryType.SHORT,
)
if not chat_history:
logger.warning(f"No chat history found for session {session_name}")
chat_history = (
f"someone asked this about the user's message: {final_query}"
)
logger.debug(
f"Workspace: {workspace_name}, Peer: {peer_name}, Session: {session_name}"
)
else:
chat_history = ""
logger.debug("Retrieved chat history: %s lines", len(chat_history.split("\n")))
# Run short-term inference and long-term facts in parallel
async def fetch_long_term():
async with tracked_db("chat.get_collection") as db_embed:
name = (
"global_representation"
if target is None
else crud.construct_collection_name(peer_name, target)
)
collection = await crud.get_or_create_collection(
db_embed, workspace_name, collection_name=name, peer_name=peer_name
)
collection_name = collection.name # Extract the ID while session is active
facts = await get_long_term_facts(
final_query, workspace_name, peer_name, collection_name
)
return facts
long_term_task = asyncio.create_task(fetch_long_term())
short_term_task = asyncio.create_task(run_tom_inference(chat_history))
facts, tom_inference = await asyncio.gather(long_term_task, short_term_task)
logger.debug(f"Retrieved {len(facts)} facts from long-term memory")
logger.debug(f"TOM inference completed with {len(tom_inference)} characters")
# Generate a fresh user representation
logger.debug("Generating user representation")
async with tracked_db("chat.generate_user_representation") as db_rep:
user_representation = await generate_user_representation(
workspace_name,
peer_name,
session_name,
chat_history=chat_history,
tom_inference=tom_inference,
facts=facts,
db=db_rep,
message_id=latest_message_id,
with_inference=False,
)
logger.debug(
f"User representation generated: {len(user_representation)} characters"
)
# Call dialectic with enhanced context
langfuse_context.update_current_trace(
session_id=session_name,
user_id=peer_name,
release=settings.SENTRY.RELEASE,
metadata={"environment": settings.SENTRY.ENVIRONMENT},
)
# Use streaming or non-streaming response based on the request
logger.debug(f"Calling Dialectic with streaming={stream}")
if stream:
logger.debug("Calling Dialectic with streaming")
response = await dialectic_stream(
final_query, user_representation, chat_history
)
return response
else:
logger.debug("Calling Dialectic with non-streaming")
response = await dialectic_call(final_query, user_representation, chat_history)
return response
async def get_long_term_facts(
query: str,
workspace_name: str,
peer_name: str,
collection_name: str,
) -> list[str]:
"""
Generate queries based on the dialectic query and retrieve relevant facts.
Args:
query: The user query
workspace_name: The workspace name
peer_name: The peer name
collection_name: The collection name
Returns:
List of retrieved facts
"""
logger.debug(f"Starting fact retrieval for query: {query}")
fact_start_time = asyncio.get_event_loop().time()
# Generate multiple queries for the semantic search
logger.debug("Generating semantic queries")
search_queries = await generate_semantic_queries(query)
logger.debug(f"Generated {len(search_queries)} semantic queries: {search_queries}")
# Create a list of coroutines, one for each query
async def execute_query(i: int, search_query: str) -> list[str]:
logger.debug(f"Starting query {i + 1}/{len(search_queries)}: {search_query}")
query_start = asyncio.get_event_loop().time()
query_embedding_store = CollectionEmbeddingStore(
workspace_name=workspace_name,
peer_name=peer_name,
collection_name=collection_name,
)
facts = await query_embedding_store.get_relevant_facts(
search_query,
top_k=settings.AGENT.SEMANTIC_SEARCH_TOP_K,
max_distance=settings.AGENT.SEMANTIC_SEARCH_MAX_DISTANCE,
)
query_time = asyncio.get_event_loop().time() - query_start
logger.debug(f"Query {i + 1} retrieved {len(facts)} facts in {query_time:.2f}s")
return facts
# Execute all queries in parallel
query_tasks = [
execute_query(i, search_query) for i, search_query in enumerate(search_queries)
]
all_facts_lists = await asyncio.gather(*query_tasks)
# Combine all facts into a single set to remove duplicates
retrieved_facts: set[str] = set()
for facts in all_facts_lists:
retrieved_facts.update(facts)
total_time = asyncio.get_event_loop().time() - fact_start_time
logger.debug(
f"Total fact retrieval completed in {total_time:.2f}s with {len(retrieved_facts)} unique facts"
)
return list(retrieved_facts)
async def run_tom_inference(chat_history: str) -> str:
"""
Run ToM inference on chat history.
Args:
chat_history: The chat history
Returns:
The ToM inference
"""
# Run ToM inference
logger.debug("Running ToM inference")
tom_start_time = asyncio.get_event_loop().time()
# Get chat history length to determine if this is a new conversation
tom_inference_response = await get_tom_inference(
chat_history,
user_representation="",
method=settings.AGENT.TOM_INFERENCE_METHOD,
)
# Extract the prediction from the response
tom_time = asyncio.get_event_loop().time() - tom_start_time
logger.debug(f"ToM inference completed in {tom_time:.2f}s")
# Create a prediction summary from the structured Pydantic object
prediction = (
f"Current context: {tom_inference_response.current_state.immediate_context}"
)
if tom_inference_response.tentative_inferences:
prediction += f"\nKey inferences: {', '.join([inf.interpretation for inf in tom_inference_response.tentative_inferences[:3]])}"
logger.debug(f"Prediction length: {len(prediction)} characters")
return prediction
async def generate_semantic_queries(query: str) -> list[str]:
"""
Generate multiple semantically relevant queries based on the original query using LLM.
This helps retrieve more diverse and relevant facts from the vector store.
Args:
query: The original dialectic query
Returns:
A list of semantically relevant queries
"""
logger.debug(f"Generating semantic queries from: {query}")
query_start = asyncio.get_event_loop().time()
logger.debug("Calling LLM for query generation")
try:
queries_result = await generate_semantic_queries_llm(query)
queries = queries_result.queries
# Ensure we always include the original query
if query not in queries:
logger.debug("Adding original query to results")
queries.append(query)
total_time = asyncio.get_event_loop().time() - query_start
logger.debug(f"Generated {len(queries)} queries in {total_time:.2f}s")
return queries
except Exception as e:
logger.error(f"Error during query generation: {str(e)}")
return [query] # Fallback to original query
async def generate_user_representation(
workspace_name: str,
peer_name: str,
session_name: str | None,
chat_history: str,
tom_inference: str,
facts: list[str],
db: AsyncSession,
message_id: str | None = None,
with_inference: bool = False,
) -> str:
"""
Generate a user representation by combining long-term facts and short-term context.
Save it to peer metadata if no session is provided (global-level), or save it to
session-peers table metadata if a session is provided (local-level).
If session-level, uses existing representations from the same session for continuity.
Returns:
The generated user representation.
"""
logger.debug("Starting user representation generation")
rep_start_time = asyncio.get_event_loop().time()
if with_inference:
latest_representation = await crud.get_working_representation(
db, workspace_name, peer_name, session_name
)
logger.debug(
f"Found previous representation: {len(latest_representation)} characters"
)
logger.debug(f"Using {len(facts)} facts for representation")
# Generate the new user representation
logger.debug("Calling get_user_representation")
gen_start_time = asyncio.get_event_loop().time()
user_representation_response: UserRepresentationOutput = (
await get_user_representation_long_term(
chat_history=chat_history,
facts=facts,
user_representation=latest_representation,
tom_inference=tom_inference,
)
)
gen_time = asyncio.get_event_loop().time() - gen_start_time
logger.debug(f"get_user_representation completed in {gen_time:.2f}s")
# Extract the representation from the response
if hasattr(user_representation_response, "current_state"):
# New Mirascope response model
representation = f"""
CURRENT STATE: {user_representation_response.current_state}
TENTATIVE PATTERNS:
{chr(10).join([pattern.pattern for pattern in user_representation_response.tentative_patterns])}
KNOWLEDGE GAPS:
{chr(10).join([gap.missing_info for gap in user_representation_response.knowledge_gaps])}
RECENT UPDATES:
{chr(10).join([update.detail for update in user_representation_response.updates.new_information])}
"""
else:
# Fallback to XML parsing for backwards compatibility
representation = parse_xml_content(
str(user_representation_response), "representation"
)
logger.debug(f"Extracted representation: {len(representation)} characters")
else:
representation = f"""
PREDICTION ABOUT THE USER'S CURRENT MENTAL STATE:
{tom_inference}
RELEVANT LONG-TERM FACTS ABOUT THE USER:
{facts}
"""
logger.debug(f"Representation: {representation}")
# If message_id is provided, save the representation as metadata
if not representation:
logger.debug("Empty representation, skipping save")
elif not message_id:
logger.debug("No message_id, skipping save")
else:
logger.debug(f"Saving representation to message_id: {message_id}")
save_start = asyncio.get_event_loop().time()
try:
await crud.set_working_representation(
db,
representation,
workspace_name,
peer_name,
session_name,
)
save_time = asyncio.get_event_loop().time() - save_start
logger.debug(f"Representation saved in {save_time:.2f}s")
except Exception as e:
logger.error(f"Error during save DB operation: {str(e)}")
await db.rollback()
total_time = asyncio.get_event_loop().time() - rep_start_time
logger.debug(f"Total representation generation completed in {total_time:.2f}s")
return representation

View File

@ -51,9 +51,9 @@ class TomlConfigSettingsSource(PydanticBaseSettingsSource):
"AUTH": "auth",
"SENTRY": "sentry",
"LLM": "llm",
"AGENT": "agent",
"DERIVER": "deriver",
"HISTORY": "history",
"DIALECTIC": "dialectic",
"SUMMARY": "summary",
"": "app", # For AppSettings with no prefix
}
@ -174,73 +174,61 @@ class LLMSettings(HonchoSettings):
OPENAI_COMPATIBLE_BASE_URL: str | None = None
# General LLM settings
DEFAULT_MAX_TOKENS: Annotated[int, Field(default=1000, gt=0, le=100000)] = 1000
DEFAULT_TEMPERATURE: Annotated[float, Field(default=0.0, ge=0.0, le=2.0)] = 0.0
# Dialectic specific
DIALECTIC_PROVIDER: Providers = "anthropic"
DIALECTIC_MODEL: str = "claude-3-5-haiku-20241022"
# DIALECTIC_SYSTEM_PROMPT_FILE: Optional[str] = "prompts/dialectic_system.txt" # Example for file-based
# Query Generation specific
QUERY_GENERATION_PROVIDER: Providers = "google"
QUERY_GENERATION_MODEL: str = "gemini-2.0-flash-lite"
# QUERY_GENERATION_SYSTEM_PROMPT_FILE: Optional[str] = "prompts/query_generation_system.txt"
# Tom Inference specific
# TOM_INFERENCE_PROVIDER: Providers = "groq"
# TOM_INFERENCE_MODEL: str = "llama-3.3-70b-versatile"
TOM_INFERENCE_PROVIDER: Providers = "anthropic"
TOM_INFERENCE_MODEL: str = "claude-3-5-haiku-20241022"
# Summarization specific
SUMMARY_PROVIDER: Providers = "google"
SUMMARY_MODEL: str = (
"gemini-1.5-flash-latest" # Consider specific model version if needed
)
SUMMARY_MAX_TOKENS_SHORT: Annotated[int, Field(default=1000, gt=0, le=10000)] = 1000
SUMMARY_MAX_TOKENS_LONG: Annotated[int, Field(default=2000, gt=0, le=20000)] = 2000
# SUMMARY_SYSTEM_PROMPT_SHORT_FILE: Optional[str] = "prompts/summary_short_system.txt"
# SUMMARY_SYSTEM_PROMPT_LONG_FILE: Optional[str] = "prompts/summary_long_system.txt"
# Embed all messages that are sent by peers
EMBED_MESSAGES: bool = False
MAX_EMBEDDING_TOKENS: Annotated[int, Field(default=8192, gt=0)] = 8192
MAX_EMBEDDING_TOKENS_PER_REQUEST: Annotated[int, Field(default=300000, gt=0)] = (
300000
)
class AgentSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="AGENT_") # pyright: ignore
SEMANTIC_SEARCH_TOP_K: Annotated[int, Field(default=10, gt=0, le=100)] = 10
SEMANTIC_SEARCH_MAX_DISTANCE: Annotated[
float, Field(default=0.85, ge=0.0, le=1.0)
] = 0.85 # Max distance for semantic search relevance
TOM_INFERENCE_METHOD: str = "single_prompt"
DEFAULT_MAX_TOKENS: Annotated[int, Field(default=1000, gt=0, le=100000)] = 2500
class DeriverSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="DERIVER_") # pyright: ignore
WORKERS: Annotated[int, Field(default=1, gt=0, le=100)] = 1
STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = (
5 # Max 24 hours
)
POLLING_SLEEP_INTERVAL_SECONDS: Annotated[
float, Field(default=1.0, gt=0.0, le=60.0)
] = 1.0
TOM_METHOD: str = "single_prompt"
USER_REPRESENTATION_METHOD: str = "long_term"
STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = 5
PROVIDER: Providers = "google"
MODEL: str = "gemini-2.5-flash"
MAX_OUTPUT_TOKENS: Annotated[int, Field(default=2500, gt=0, le=100000)] = 2500
# Thinking budget tokens are only applied when using Anthropic as provider
THINKING_BUDGET_TOKENS: Annotated[int, Field(default=1024, gt=0, le=5000)] = 1024
# Default number of observations to retrieve for each reasoning level
DEDUCTIVE_OBSERVATIONS_COUNT: Annotated[int, Field(default=6, gt=0, le=50)] = 6
EXPLICIT_OBSERVATIONS_COUNT: Annotated[int, Field(default=10, gt=0, le=50)] = 10
class HistorySettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="HISTORY_") # pyright: ignore
class DialecticSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="DIALECTIC_") # pyright: ignore
PROVIDER: Providers = "anthropic"
MODEL: str = "claude-sonnet-4-20250514"
QUERY_GENERATION_PROVIDER: Providers = "groq"
QUERY_GENERATION_MODEL: str = "llama-3.1-8b-instant"
MAX_OUTPUT_TOKENS: Annotated[int, Field(default=2500, gt=0, le=100000)] = 2500
SEMANTIC_SEARCH_TOP_K: Annotated[int, Field(default=10, gt=0, le=100)] = 10
SEMANTIC_SEARCH_MAX_DISTANCE: Annotated[
float, Field(default=0.85, ge=0.0, le=1.0)
] = 0.85 # Max distance for semantic search relevance
THINKING_BUDGET_TOKENS: Annotated[int, Field(default=1024, gt=0, le=5000)] = 1024
class SummarySettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="SUMMARY_") # pyright: ignore
MESSAGES_PER_SHORT_SUMMARY: Annotated[int, Field(default=20, gt=0, le=100)] = 20
MESSAGES_PER_LONG_SUMMARY: Annotated[int, Field(default=60, gt=0, le=500)] = 60
PROVIDER: Providers = "google"
MODEL: str = "gemini-2.5-flash"
MAX_TOKENS_SHORT: Annotated[int, Field(default=1000, gt=0, le=10000)] = 1000
MAX_TOKENS_LONG: Annotated[int, Field(default=2000, gt=0, le=20000)] = 2000
THINKING_BUDGET_TOKENS: Annotated[int, Field(default=512, gt=0, le=2000)] = 512
class AppSettings(HonchoSettings):
# No env_prefix for app-level settings
@ -253,18 +241,24 @@ class AppSettings(HonchoSettings):
FASTAPI_HOST: str = "0.0.0.0"
FASTAPI_PORT: Annotated[int, Field(default=8000, gt=0, le=65535)] = 8000
SESSION_PEERS_LIMIT: Annotated[int, Field(default=10, gt=0)] = 10
MAX_FILE_SIZE: Annotated[int, Field(default=5_242_880, gt=0)] = 5_242_880 # 5MB
EMBED_MESSAGES: bool = True
MAX_EMBEDDING_TOKENS: Annotated[int, Field(default=8192, gt=0)] = 8192
MAX_EMBEDDING_TOKENS_PER_REQUEST: Annotated[int, Field(default=300000, gt=0)] = (
300000
)
# Nested settings models
DB: DBSettings = Field(default_factory=DBSettings)
AUTH: AuthSettings = Field(default_factory=AuthSettings)
SENTRY: SentrySettings = Field(default_factory=SentrySettings)
LLM: LLMSettings = Field(default_factory=LLMSettings)
AGENT: AgentSettings = Field(default_factory=AgentSettings)
DERIVER: DeriverSettings = Field(default_factory=DeriverSettings)
HISTORY: HistorySettings = Field(default_factory=HistorySettings)
DIALECTIC: DialecticSettings = Field(default_factory=DialecticSettings)
SUMMARY: SummarySettings = Field(default_factory=SummarySettings)
@field_validator("LOG_LEVEL")
@classmethod
def validate_log_level(cls, v: str) -> str:
log_level = v.upper()
if log_level not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:

File diff suppressed because it is too large Load Diff

87
src/crud/__init__.py Normal file
View File

@ -0,0 +1,87 @@
from .collection import get_collection, get_or_create_collection
from .deriver import get_deriver_status
from .document import create_document, get_duplicate_documents, query_documents
from .message import (
create_messages,
get_message,
get_messages,
get_messages_id_range,
search,
update_message,
)
from .peer import (
get_or_create_peers,
get_peer,
get_peers,
get_sessions_for_peer,
update_peer,
)
from .representation import (
construct_collection_name,
get_working_representation,
get_working_representation_data,
set_working_representation,
)
from .session import (
clone_session,
delete_session,
get_or_create_session,
get_peer_config,
get_peers_from_session,
get_session,
get_session_peer_configuration,
get_sessions,
remove_peers_from_session,
set_peer_config,
set_peers_for_session,
update_session,
)
from .workspace import get_all_workspaces, get_or_create_workspace, update_workspace
__all__ = [
# Collection
"get_collection",
"get_or_create_collection",
# Deriver
"get_deriver_status",
# Document
"query_documents",
"create_document",
"get_duplicate_documents",
# Message
"create_messages",
"get_messages",
"get_messages_id_range",
"get_message",
"update_message",
"search",
# Peer
"get_or_create_peers",
"get_peer",
"get_peers",
"update_peer",
"get_sessions_for_peer",
# Search
"representation",
"get_working_representation",
"get_working_representation_data",
"set_working_representation",
"construct_collection_name",
# Session
"get_sessions",
"get_or_create_session",
"get_session",
"update_session",
"delete_session",
"clone_session",
"remove_peers_from_session",
"get_peers_from_session",
"get_session_peer_configuration",
"set_peers_for_session",
"get_peer_config",
"set_peer_config",
# Workspace
"get_or_create_workspace",
"get_all_workspaces",
"update_workspace",
]

65
src/crud/collection.py Normal file
View File

@ -0,0 +1,65 @@
from logging import getLogger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.exceptions import ResourceNotFoundException
logger = getLogger(__name__)
async def get_collection(
db: AsyncSession,
workspace_name: str,
collection_name: str,
peer_name: str | None = None,
) -> models.Collection:
"""
Get a collection by name for a specific peer and workspace.
Args:
db: Database session
workspace_name: Name of the workspace
peer_name: Name of the peer
collection_name: Name of the collection
Returns:
The collection if found
Raises:
ResourceNotFoundException: If the collection does not exist
"""
stmt = (
select(models.Collection)
.where(models.Collection.workspace_name == workspace_name)
.where(models.Collection.name == collection_name)
)
if peer_name:
stmt = stmt.where(models.Collection.peer_name == peer_name)
result = await db.execute(stmt)
collection = result.scalar_one_or_none()
if collection is None:
raise ResourceNotFoundException(
"Collection not found or does not belong to peer"
)
return collection
async def get_or_create_collection(
db: AsyncSession,
workspace_name: str,
collection_name: str,
peer_name: str | None = None,
) -> models.Collection:
try:
return await get_collection(db, workspace_name, collection_name, peer_name)
except ResourceNotFoundException:
honcho_collection = models.Collection(
workspace_name=workspace_name,
peer_name=peer_name,
name=collection_name,
)
db.add(honcho_collection)
await db.commit()
return honcho_collection

188
src/crud/deriver.py Normal file
View File

@ -0,0 +1,188 @@
from collections.abc import Sequence
from logging import getLogger
from typing import Any
from sqlalchemy import Select, case, func, or_, select
from sqlalchemy.engine import Row
from sqlalchemy.ext.asyncio import AsyncSession
from src import models, schemas
logger = getLogger(__name__)
async def get_deriver_status(
db: AsyncSession,
workspace_name: str,
observer_name: str | None = None,
sender_name: str | None = None,
session_name: str | None = None,
) -> schemas.DeriverStatus:
"""
Get the deriver processing status, optionally filtered by observer, sender, and/or session.
Args:
db: Database session
workspace_name: Name of the workspace
observer_name: Optional name of the observer (target) to filter by
sender_name: Optional name of the sender to filter by
session_name: Optional session name to filter by
"""
# Normalize empty strings to None for consistent handling
normalized_observer_name = observer_name if observer_name else None
normalized_sender_name = sender_name if sender_name else None
normalized_session_name = session_name if session_name else None
stmt = _build_queue_status_query(
workspace_name,
normalized_observer_name,
normalized_sender_name,
normalized_session_name,
)
result = await db.execute(stmt)
rows = result.fetchall()
counts = _process_queue_rows(rows)
return _build_status_response(
normalized_session_name,
counts,
)
def _build_queue_status_query(
workspace_name: str,
observer_name: str | None,
sender_name: str | None,
session_name: str | None,
) -> Select[Any]:
"""Build SQL query for queue status with validation and aggregation."""
sender_name_expr = models.QueueItem.payload["sender_name"].astext
target_name_expr = models.QueueItem.payload["target_name"].astext
task_type_expr = models.QueueItem.payload["task_type"].astext
# Define conditions for cleaner window functions
is_completed = models.QueueItem.processed
is_in_progress = (~models.QueueItem.processed) & (
models.ActiveQueueSession.id.isnot(None)
)
is_pending = (~models.QueueItem.processed) & (
models.ActiveQueueSession.id.is_(None)
)
# Use window functions to calculate totals and per-session counts in SQL
stmt = select(
models.QueueItem.session_id,
# Overall totals using window functions
func.count().over().label("total"),
func.count(case((is_completed, 1))).over().label("completed"),
func.count(case((is_in_progress, 1))).over().label("in_progress"),
func.count(case((is_pending, 1))).over().label("pending"),
# Per-session totals using partitioned window functions
func.count()
.over(partition_by=models.QueueItem.session_id)
.label("session_total"),
func.count(case((is_completed, 1)))
.over(partition_by=models.QueueItem.session_id)
.label("session_completed"),
func.count(case((is_in_progress, 1)))
.over(partition_by=models.QueueItem.session_id)
.label("session_in_progress"),
func.count(case((is_pending, 1)))
.over(partition_by=models.QueueItem.session_id)
.label("session_pending"),
).select_from(models.QueueItem)
stmt = stmt.outerjoin(
models.ActiveQueueSession,
(models.QueueItem.session_id == models.ActiveQueueSession.session_id)
& (sender_name_expr == models.ActiveQueueSession.sender_name)
& (target_name_expr == models.ActiveQueueSession.target_name)
& (task_type_expr == models.ActiveQueueSession.task_type),
)
stmt = stmt.join(models.Session, models.QueueItem.session_id == models.Session.id)
stmt = stmt.where(models.Session.workspace_name == workspace_name)
if session_name is not None:
stmt = stmt.where(models.Session.name == session_name)
peer_conditions = []
if observer_name is not None:
peer_conditions.append(target_name_expr == observer_name) # pyright: ignore
if sender_name is not None:
peer_conditions.append(sender_name_expr == sender_name) # pyright: ignore
if peer_conditions:
stmt = stmt.where(or_(*peer_conditions)) # pyright: ignore
return stmt
def _process_queue_rows(rows: Sequence[Row[Any]]) -> schemas.QueueCounts:
"""Process query results that already contain aggregated counts."""
if not rows:
return schemas.QueueCounts(
total=0,
completed=0,
in_progress=0,
pending=0,
sessions={},
)
# Since we're using window functions, all rows have the same overall totals
# We just need the first row for overall counts
first_row = rows[0]
# Build sessions dictionary from unique session_ids
sessions: dict[str, schemas.SessionCounts] = {}
seen_sessions: set[str] = set()
for row in rows:
if row.session_id and row.session_id not in seen_sessions:
sessions[row.session_id] = schemas.SessionCounts(
completed=row.session_completed,
in_progress=row.session_in_progress,
pending=row.session_pending,
)
seen_sessions.add(row.session_id)
return schemas.QueueCounts(
total=first_row.total,
completed=first_row.completed,
in_progress=first_row.in_progress,
pending=first_row.pending,
sessions=sessions,
)
def _build_status_response(
session_name: str | None,
counts: schemas.QueueCounts,
) -> schemas.DeriverStatus:
"""Build the final response object."""
if session_name:
return schemas.DeriverStatus(
total_work_units=counts.total,
completed_work_units=counts.completed,
in_progress_work_units=counts.in_progress,
pending_work_units=counts.pending,
)
sessions: dict[str, schemas.SessionDeriverStatus] = {}
for session_id, data in counts.sessions.items():
total = data.completed + data.in_progress + data.pending
sessions[session_id] = schemas.SessionDeriverStatus(
session_id=session_id,
total_work_units=total,
completed_work_units=data.completed,
in_progress_work_units=data.in_progress,
pending_work_units=data.pending,
)
return schemas.DeriverStatus(
sessions=sessions if sessions else None,
total_work_units=counts.total,
completed_work_units=counts.completed,
in_progress_work_units=counts.in_progress,
pending_work_units=counts.pending,
)

166
src/crud/document.py Normal file
View File

@ -0,0 +1,166 @@
from collections.abc import Sequence
from logging import getLogger
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src import models, schemas
from src.config import settings
from src.embedding_client import embedding_client
from src.exceptions import ValidationException
from src.utils.filter import apply_filter
from .collection import get_collection
logger = getLogger(__name__)
async def query_documents(
db: AsyncSession,
workspace_name: str,
peer_name: str,
collection_name: str,
query: str,
filters: dict[str, Any] | None = None,
max_distance: float | None = None,
top_k: int = 5,
) -> Sequence[models.Document]:
# Using ModelClient for embeddings
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
stmt = (
select(models.Document)
.where(models.Document.workspace_name == workspace_name)
.where(models.Document.peer_name == peer_name)
.where(models.Document.collection_name == collection_name)
# .limit(top_k)
)
if max_distance is not None:
stmt = stmt.where(
models.Document.embedding.cosine_distance(embedding_query) < max_distance
)
stmt = apply_filter(stmt, models.Document, filters)
stmt = stmt.limit(top_k).order_by(
models.Document.embedding.cosine_distance(embedding_query)
)
result = await db.execute(stmt)
return result.scalars().all()
async def create_document(
db: AsyncSession,
document: schemas.DocumentCreate,
workspace_name: str,
peer_name: str,
collection_name: str,
duplicate_threshold: float | None = None,
) -> models.Document:
"""
Embed text as a vector and create a document.
Args:
db: Database session
document: Document creation schema
workspace_name: Name of the workspace
peer_name: Name of the peer
collection_name: Name of the collection
Returns:
The created document
Raises:
ResourceNotFoundException: If the collection does not exist
ValidationException: If the document data is invalid
"""
# This will raise ResourceNotFoundException if collection not found
await get_collection(
db,
workspace_name=workspace_name,
collection_name=collection_name,
peer_name=peer_name,
)
# Using ModelClient for embeddings
embedding = await embedding_client.embed(document.content)
if duplicate_threshold is not None:
# Check if there are duplicates within the threshold
stmt = (
select(models.Document)
.where(models.Document.workspace_name == workspace_name)
.where(models.Document.peer_name == peer_name)
.where(models.Document.collection_name == collection_name)
.where(
models.Document.embedding.cosine_distance(embedding)
< duplicate_threshold
)
.order_by(models.Document.embedding.cosine_distance(embedding))
.limit(1)
)
result = await db.execute(stmt)
duplicate = result.scalar_one_or_none() # Get the closest match if any exist
if duplicate is not None:
logger.info(f"Duplicate found: {duplicate.content}. Ignoring new document.")
return duplicate
honcho_document = models.Document(
workspace_name=workspace_name,
peer_name=peer_name,
collection_name=collection_name,
content=document.content,
internal_metadata=document.metadata,
embedding=embedding,
)
db.add(honcho_document)
await db.commit()
await db.refresh(honcho_document)
return honcho_document
async def get_duplicate_documents(
db: AsyncSession,
workspace_name: str,
peer_name: str,
collection_name: str,
content: str,
similarity_threshold: float = 0.85,
) -> list[models.Document]:
"""Check if a document with similar content already exists in the collection.
Args:
db: Database session
workspace_name: Name of the workspace
peer_name: Name of the peer
collection_name: Name of the collection
content: Document content to check for duplicates
similarity_threshold: Similarity threshold (0-1) for considering documents as duplicates
Returns:
List of documents that are similar to the provided content
"""
# Get embedding for the content
# Using ModelClient for embeddings
embedding = await embedding_client.embed(content)
# Find documents with similar embeddings
stmt = (
select(models.Document)
.where(models.Document.workspace_name == workspace_name)
.where(models.Document.peer_name == peer_name)
.where(models.Document.collection_name == collection_name)
.where(
models.Document.embedding.cosine_distance(embedding)
< (1 - similarity_threshold)
) # Convert similarity to distance
.order_by(models.Document.embedding.cosine_distance(embedding))
)
result = await db.execute(stmt)
return list(result.scalars().all()) # Convert to list to match the return type

410
src/crud/message.py Normal file
View File

@ -0,0 +1,410 @@
from logging import getLogger
from typing import Any
from nanoid import generate as generate_nanoid
from sqlalchemy import Select, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import models, schemas
from src.config import settings
from src.embedding_client import embedding_client
from src.exceptions import DisabledException, ValidationException
from src.utils.filter import apply_filter
from .session import get_or_create_session
logger = getLogger(__name__)
async def create_messages(
db: AsyncSession,
messages: list[schemas.MessageCreate],
workspace_name: str,
session_name: str,
) -> list[models.Message]:
"""
Bulk create messages for a session while maintaining order.
Args:
db: Database session
messages: List of messages to create
workspace_name: Name of the workspace
session_name: Name of the session to create messages in
Returns:
List of created message objects
"""
# Get or create session with peers in messages list
peers = {message.peer_name: schemas.SessionPeerConfig() for message in messages}
await get_or_create_session(
db,
session=schemas.SessionCreate(
name=session_name,
peers=peers,
),
workspace_name=workspace_name,
)
# Create list of message objects (this will trigger the before_insert event)
message_objects: list[models.Message] = []
for message in messages:
message_obj = models.Message(
session_name=session_name,
peer_name=message.peer_name,
content=message.content,
h_metadata=message.metadata or {},
workspace_name=workspace_name,
public_id=generate_nanoid(),
token_count=len(message.encoded_message),
)
message_objects.append(message_obj)
db.add_all(message_objects)
await db.flush()
if settings.EMBED_MESSAGES:
encoded_message_lookup = {
msg.public_id: orig_msg.encoded_message
for msg, orig_msg in zip(message_objects, messages, strict=True)
}
id_resource_dict = {
message.public_id: (
message.content,
encoded_message_lookup[message.public_id],
)
for message in message_objects
}
embedding_dict = await embedding_client.batch_embed(id_resource_dict)
# Create MessageEmbedding entries for each embedded message
embedding_objects: list[models.MessageEmbedding] = []
for message_obj in message_objects:
embeddings = embedding_dict.get(message_obj.public_id, [])
for embedding in embeddings:
embedding_obj = models.MessageEmbedding(
content=message_obj.content,
embedding=embedding,
message_id=message_obj.public_id,
workspace_name=workspace_name,
session_name=session_name,
peer_name=message_obj.peer_name,
)
embedding_objects.append(embedding_obj)
# Add all embedding objects to the session
if embedding_objects:
db.add_all(embedding_objects)
await db.commit()
return message_objects
async def get_messages(
workspace_name: str,
session_name: str,
reverse: bool | None = False,
filters: dict[str, Any] | None = None,
token_limit: int | None = None,
message_count_limit: int | None = None,
) -> Select[tuple[models.Message]]:
"""
Get messages from a session. If token_limit is provided, the n most recent messages
with token count adding up to the limit will be returned. If message_count_limit is provided,
the n most recent messages will be returned. If both are provided, message_count_limit will be
used.
Args:
workspace_name: Name of the workspace
session_name: Name of the session
reverse: Whether to reverse the order of messages
filters: Filter to apply to the messages
token_limit: Maximum number of tokens to include in the messages
message_count_limit: Maximum number of messages to include
Returns:
Select statement for the messages
"""
# Base query with workspace and session filters
base_conditions = [
models.Message.workspace_name == workspace_name,
models.Message.session_name == session_name,
]
# Apply message count limit first (takes precedence over token limit)
if message_count_limit is not None:
stmt = select(models.Message).where(*base_conditions)
stmt = apply_filter(stmt, models.Message, filters)
# For message count limit, we want the most recent N messages
# So we order by id desc to get most recent, then apply limit
stmt = stmt.order_by(models.Message.id.desc()).limit(message_count_limit)
# Apply final ordering based on reverse parameter
if reverse:
stmt = stmt.order_by(models.Message.id.desc())
else:
stmt = stmt.order_by(models.Message.id.asc())
elif token_limit is not None:
# Apply token limit logic
# Create a subquery that calculates running sum of tokens for most recent messages
token_subquery = (
select(
models.Message.id,
func.sum(models.Message.token_count)
.over(order_by=models.Message.id.desc())
.label("running_token_sum"),
)
.where(*base_conditions)
.subquery()
)
# Select Message objects where running sum doesn't exceed token_limit
stmt = (
select(models.Message)
.join(token_subquery, models.Message.id == token_subquery.c.id)
.where(token_subquery.c.running_token_sum <= token_limit)
)
stmt = apply_filter(stmt, models.Message, filters)
# Apply final ordering based on reverse parameter
if reverse:
stmt = stmt.order_by(models.Message.id.desc())
else:
stmt = stmt.order_by(models.Message.id.asc())
else:
# Default case - no limits applied
stmt = select(models.Message).where(*base_conditions)
stmt = apply_filter(stmt, models.Message, filters)
if reverse:
stmt = stmt.order_by(models.Message.id.desc())
else:
stmt = stmt.order_by(models.Message.id.asc())
return stmt
async def get_messages_id_range(
db: AsyncSession,
workspace_name: str,
session_name: str | None,
peer_name: str | None,
start_id: int = 0,
end_id: int | None = None,
) -> list[models.Message]:
"""
Get messages from a session or peer by primary key ID range.
If end_id is not provided, all messages after and including start_id will be returned.
If start_id is not provided, start will be beginning of session.
Note: list is exclusive of the end_id message.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_name: Name of the peer
start_id: Primary key ID of the first message to return
end_id: Primary key ID of the last message (exclusive)
Returns:
List of messages
Raises:
ValueError: If both session_name and peer_name are not provided
"""
if start_id < 0 or (end_id is not None and (start_id >= end_id or end_id <= 1)):
return []
stmt = select(models.Message).where(
models.Message.workspace_name == workspace_name,
)
if end_id:
stmt = stmt.where(models.Message.id.between(start_id, end_id - 1))
else:
stmt = stmt.where(models.Message.id >= start_id)
if session_name:
stmt = stmt.where(models.Message.session_name == session_name)
elif peer_name:
stmt = stmt.where(models.Message.peer_name == peer_name)
else:
raise ValueError("Either session_name or peer_name must be provided")
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_message(
db: AsyncSession,
workspace_name: str,
session_name: str,
message_id: str,
) -> models.Message | None:
stmt = (
select(models.Message)
.where(models.Message.workspace_name == workspace_name)
.where(models.Message.session_name == session_name)
.where(models.Message.public_id == message_id)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def update_message(
db: AsyncSession,
message: schemas.MessageUpdate,
workspace_name: str,
session_name: str,
message_id: str,
) -> bool:
honcho_message = await get_message(
db,
workspace_name=workspace_name,
session_name=session_name,
message_id=message_id,
)
if honcho_message is None:
raise ValueError("Message not found or does not belong to user")
if (
message.metadata is not None
): # Need to explicitly be there won't make it empty by default
honcho_message.h_metadata = message.metadata
await db.commit()
# await db.refresh(honcho_message)
return honcho_message
async def search(
query: str,
*,
workspace_name: str,
session_name: str | None = None,
peer_name: str | None = None,
semantic: bool | None = None,
) -> Select[tuple[models.Message]]:
"""
Search across message content using a hybrid approach:
- Uses semantic search if embed_messages is set, else fall back to full text
- Uses PostgreSQL full text search for natural language queries
- Falls back to exact string matching for queries with special characters
- Optionally uses semantic search with embeddings
If a session or peer is provided, the search will be scoped to that
session or peer. Otherwise, it will search across all messages in the workspace.
Args:
query: Search query to match against message content
workspace_name: Name of the workspace
session_name: Optional name of the session
peer_name: Optional name of the peer
semantic: Optional boolean to configure semantic search:
- None: try semantic search if embed_messages is set, else fall back to full text
- True: try semantic search if embed_messages is set, else throw error
- False: use full text search
Returns:
List of messages that match the search query, ordered by relevance
"""
import re
from sqlalchemy import func, or_
# Base query conditions
base_conditions = [models.Message.workspace_name == workspace_name]
should_use_semantic_search = False # Default to full text search
if semantic is None:
# Try semantic search if embed_messages is set, else fall back to full text
should_use_semantic_search = settings.EMBED_MESSAGES
elif semantic is True:
# Try semantic search if embed_messages is set, else throw error
if settings.EMBED_MESSAGES:
should_use_semantic_search = True
else:
raise DisabledException(
"Semantic search requires EMBED_MESSAGES flag to be enabled"
)
if should_use_semantic_search:
# Generate embedding for the search query
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
# Use cosine distance for semantic search on MessageEmbedding table
# Join with Message table to get the actual message data
base_query = (
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(embedding_query)
)
)
if session_name is not None:
stmt = base_query.where(
models.MessageEmbedding.session_name == session_name
)
elif peer_name is not None:
stmt = base_query.where(models.MessageEmbedding.peer_name == peer_name)
else:
stmt = base_query
else:
# Check if query contains special characters that FTS might not handle well
has_special_chars = bool(
re.search(r'[~`!@#$%^&*()_+=\[\]{};\':"\\|,.<>/?-]', query)
)
if has_special_chars:
# For queries with special characters, use exact string matching (ILIKE)
# This ensures we can find exact matches like "~special-uuid~"
search_condition = models.Message.content.ilike(f"%{query}%")
base_query = (
select(models.Message)
.where(*base_conditions, search_condition)
.order_by(models.Message.created_at.desc())
)
else:
# For natural language queries, use full text search with ranking
fts_condition = func.to_tsvector("english", models.Message.content).op(
"@@"
)(func.plainto_tsquery("english", query))
# Combine FTS with ILIKE as fallback for better coverage
combined_condition = or_(
fts_condition, models.Message.content.ilike(f"%{query}%")
)
base_query = (
select(models.Message)
.where(*base_conditions, combined_condition)
.order_by(
# Order by FTS relevance first, then by creation time
func.coalesce(
func.ts_rank(
func.to_tsvector("english", models.Message.content),
func.plainto_tsquery("english", query),
),
0,
).desc(),
models.Message.created_at.desc(),
)
)
# Add additional filters based on parameters
if session_name is not None:
stmt = base_query.where(models.Message.session_name == session_name)
elif peer_name is not None:
stmt = base_query.where(models.Message.peer_name == peer_name)
else:
stmt = base_query
return stmt

191
src/crud/peer.py Normal file
View File

@ -0,0 +1,191 @@
from logging import getLogger
from typing import Any
from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import models, schemas
from src.exceptions import ResourceNotFoundException
from src.utils.filter import apply_filter
logger = getLogger(__name__)
async def get_or_create_peers(
db: AsyncSession,
workspace_name: str,
peers: list[schemas.PeerCreate],
) -> list[models.Peer]:
"""
Get an existing list of peers or create new peers if they don't exist.
Updates existing peers with metadata and configuration if provided.
Args:
db: Database session
workspace_name: Name of the workspace
peers: List of peer creation schemas
Returns:
List of peers if found or created
"""
peer_names = [p.name for p in peers]
stmt = (
select(models.Peer)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name.in_(peer_names))
)
result = await db.execute(stmt)
existing_peers = list(result.scalars().all())
# Create a mapping of peer names to peer schemas for easy lookup
peer_schema_map = {p.name: p for p in peers}
# Update existing peers with metadata and configuration if provided
for existing_peer in existing_peers:
peer_schema = peer_schema_map[existing_peer.name]
# Update with metadata and configuration if provided
if peer_schema.metadata is not None:
existing_peer.h_metadata = peer_schema.metadata
if peer_schema.configuration is not None:
existing_peer.configuration = peer_schema.configuration
# Find which peers need to be created
existing_names = {p.name for p in existing_peers}
peers_to_create = [p for p in peers if p.name not in existing_names]
# Create new peers
new_peers = [
models.Peer(
workspace_name=workspace_name,
name=p.name,
h_metadata=p.metadata or {},
configuration=p.configuration or {},
)
for p in peers_to_create
]
db.add_all(new_peers)
await db.commit()
# Return combined list of existing and new peers
return existing_peers + new_peers
async def get_peer(
db: AsyncSession,
workspace_name: str,
peer: schemas.PeerCreate,
) -> models.Peer:
"""
Get an existing peer.
Args:
db: Database session
workspace_name: Name of the workspace
peer: Peer creation schema
Returns:
The peer if found or created
Raises:
ResourceNotFoundException: If the peer does not exist
"""
# Try to get the existing peer
stmt = (
select(models.Peer)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name == peer.name)
)
result = await db.execute(stmt)
existing_peer = result.scalar_one_or_none()
if existing_peer is not None:
return existing_peer
raise ResourceNotFoundException(
f"Peer {peer.name} not found in workspace {workspace_name}"
)
async def get_peers(
workspace_name: str,
filters: dict[str, str] | None = None,
) -> Select[tuple[models.Peer]]:
stmt = select(models.Peer).where(models.Peer.workspace_name == workspace_name)
stmt = apply_filter(stmt, models.Peer, filters)
return stmt.order_by(models.Peer.created_at)
async def update_peer(
db: AsyncSession, workspace_name: str, peer_name: str, peer: schemas.PeerUpdate
) -> models.Peer:
"""
Update a peer.
Args:
db: Database session
workspace_name: Name of the workspace
peer_name: Name of the peer
peer: Peer update schema
Returns:
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
"""
honcho_peer = (
await get_or_create_peers(
db, workspace_name, [schemas.PeerCreate(name=peer_name)]
)
)[0]
if peer.metadata is not None:
honcho_peer.h_metadata = peer.metadata
if peer.configuration is not None:
honcho_peer.configuration = peer.configuration
await db.commit()
logger.info(f"Peer {peer_name} updated successfully")
return honcho_peer
async def get_sessions_for_peer(
workspace_name: str,
peer_name: str,
filters: dict[str, Any] | None = None,
) -> Select[tuple[models.Session]]:
"""
Get all sessions for a peer through the session_peers relationship.
Args:
workspace_name: Name of the workspace
peer_name: Name of the peer
filters: Filter sessions by metadata
Returns:
SQLAlchemy Select statement
"""
stmt = (
select(models.Session)
.join(
models.SessionPeer,
(models.Session.name == models.SessionPeer.session_name)
& (models.Session.workspace_name == models.SessionPeer.workspace_name),
)
.where(models.SessionPeer.peer_name == peer_name)
.where(models.Session.workspace_name == workspace_name)
)
stmt = apply_filter(stmt, models.Session, filters)
stmt: Select[tuple[models.Session]] = stmt.order_by(models.Session.created_at)
return stmt

232
src/crud/representation.py Normal file
View File

@ -0,0 +1,232 @@
from logging import getLogger
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
logger = getLogger(__name__)
async def get_working_representation(
db: AsyncSession,
workspace_name: str,
observer_name: str,
observed_name: str,
session_name: str | None = None,
) -> str:
"""
Get working representation for observer/observed relationship.
Args:
db: Database session
workspace_name: Name of the workspace
observer_name: Name of the peer doing the observing
observed_name: Name of the peer being observed (required for explicit global/local)
session_name: Optional session name (None for peer-level metadata)
Returns:
Formatted working representation string
"""
working_rep_data = await get_working_representation_data(
db, workspace_name, observer_name, observed_name, session_name
)
if not working_rep_data:
logger.warning(
f"No working representation found for observer: {observer_name}, observed: {observed_name}"
)
return ""
# Handle both old format (string) and new format (structured data)
if isinstance(working_rep_data, str):
return working_rep_data
# New structured format - extract and format final_observations
try:
final_observations = working_rep_data.get("final_observations", {})
if not final_observations:
logger.warning("No final_observations found in working representation data")
return ""
return _format_observations_by_level(final_observations)
except Exception:
logger.exception("Error processing working representation")
return ""
async def get_working_representation_data(
db: AsyncSession,
workspace_name: str,
observer_name: str,
observed_name: str, # now required
session_name: str | None = None,
) -> dict[str, Any] | str | None:
"""
Get raw working representation data from internal_metadata.
Returns either structured data (new format) or string (legacy format).
"""
# Determine metadata key based on observer/observed relationship
if observer_name == observed_name:
metadata_key = "global_representation"
else:
metadata_key = construct_collection_name(
observer=observer_name, observed=observed_name
)
if session_name:
stmt = select(models.SessionPeer.internal_metadata).where(
models.SessionPeer.peer_name == observer_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.session_name == session_name,
)
else:
stmt = select(models.Peer.internal_metadata).where(
models.Peer.name == observer_name,
models.Peer.workspace_name == workspace_name,
)
result = await db.execute(stmt)
peer_metadata = result.scalar_one_or_none()
if not peer_metadata:
return None
# Try new prefixed key first, then fallback to legacy keys
working_rep_data = peer_metadata.get(metadata_key)
if working_rep_data:
return working_rep_data
# Fallback logic for migration period
legacy_data = peer_metadata.get("latest_working_representation")
if legacy_data:
logger.debug(
"Using legacy key 'latest_working_representation' for %s->%s",
observer_name,
observed_name,
)
return legacy_data
# Final fallback to old user_representation key
USER_REPRESENTATION_METADATA_KEY = "user_representation"
user_rep_data = peer_metadata.get(USER_REPRESENTATION_METADATA_KEY)
if user_rep_data:
logger.debug(
"Using legacy key '%s' for %s->%s",
USER_REPRESENTATION_METADATA_KEY,
observer_name,
observed_name,
)
return user_rep_data
return None
def _format_observations_by_level(final_observations: dict[str, Any]) -> str:
"""Format final observations into structured text by level."""
formatted_sections: list[str] = []
for level in ["explicit", "deductive"]:
observations: list[Any] = final_observations.get(level, [])
if observations:
formatted_sections.append(f"{level.upper()} OBSERVATIONS:")
formatted_sections.extend(_format_observation_list(observations))
formatted_sections.append("")
return "\n".join(formatted_sections) if formatted_sections else ""
def _format_observation_list(observations: list[dict[str, Any] | str]) -> list[str]:
"""Format a list of observations into consistent string format."""
formatted: list[str] = []
for obs in observations:
if isinstance(obs, dict):
# Determine core content and premises
if "conclusion" in obs:
conclusion_text: str = obs["conclusion"]
premises: list[str] = obs.get("premises", [])
if premises:
premises_text = "; ".join(premises)
formatted_obs = f"{conclusion_text} (based on: {premises_text})"
else:
formatted_obs = conclusion_text
else:
content_text: str = obs.get("content", str(obs))
formatted_obs = content_text
formatted.append(f"- {formatted_obs}")
else:
# Handle string fallback
formatted.append(f"- {str(obs)}")
return formatted
async def set_working_representation(
db: AsyncSession,
representation: str | dict[str, Any],
workspace_name: str,
observer_name: str, # renamed from peer_name
observed_name: str, # now required - no default
session_name: str | None = None,
) -> None:
"""
Set working representation for observer/observed relationship.
Args:
db: Database session
representation: Working representation data (string or structured dict)
workspace_name: Name of the workspace
observer_name: Name of the peer doing the observing
observed_name: Name of the peer being observed (required for explicit global/local)
session_name: Optional session name (None for peer-level metadata)
"""
# Determine metadata key based on observer/observed relationship
if observer_name == observed_name:
metadata_key = "global_representation"
else:
metadata_key = construct_collection_name(
observer=observer_name, observed=observed_name
)
if session_name:
# Session-level: save all types (global and local)
stmt = (
update(models.SessionPeer)
.where(models.SessionPeer.workspace_name == workspace_name)
.where(models.SessionPeer.peer_name == observer_name)
.where(models.SessionPeer.session_name == session_name)
.values(
internal_metadata=models.SessionPeer.internal_metadata.op("||")(
{metadata_key: representation}
)
)
)
else:
# Peer-level: only save global representations
if observer_name == observed_name:
stmt = (
update(models.Peer)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name == observer_name)
.values(
internal_metadata=models.Peer.internal_metadata.op("||")(
{metadata_key: representation}
)
)
)
else:
logger.error(
"Skipping peer-level local representation save (this should never happen!): observer=%s, observed=%s",
observer_name,
observed_name,
)
return
await db.execute(stmt)
await db.commit()
def construct_collection_name(*, observer: str, observed: str) -> str:
return f"{observer}_{observed}"

690
src/crud/session.py Normal file
View File

@ -0,0 +1,690 @@
from logging import getLogger
from typing import Any
from nanoid import generate as generate_nanoid
from sqlalchemy import Select, cast, func, insert, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import BigInteger
from src import models, schemas
from src.config import settings
from src.exceptions import ResourceNotFoundException
from src.utils.filter import apply_filter
from .peer import get_or_create_peers, get_peer
# Import workspace and peer functions that are needed
from .workspace import get_or_create_workspace
logger = getLogger(__name__)
async def get_sessions(
workspace_name: str,
filters: dict[str, Any] | None = None,
) -> Select[tuple[models.Session]]:
"""
Get all sessions in a workspace.
"""
stmt = select(models.Session).where(models.Session.workspace_name == workspace_name)
stmt = apply_filter(stmt, models.Session, filters)
return stmt.order_by(models.Session.created_at)
async def get_or_create_session(
db: AsyncSession,
session: schemas.SessionCreate,
workspace_name: str,
) -> 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.
Args:
db: Database session
session: Session creation schema
workspace_name: Name of the workspace
peer_names: List of peer names to add to the session
Returns:
The created session
Raises:
ResourceNotFoundException: If the session does not exist and create is false
"""
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session.name)
)
result = await db.execute(stmt)
honcho_session = result.scalar_one_or_none()
# Check if session already exists
if honcho_session is None:
if (
session.peer_names
and len(session.peer_names) > settings.SESSION_PEERS_LIMIT
):
raise ValueError(
f"Cannot create session {session.name} with {len(session.peer_names)} peers. Maximum allowed is {settings.SESSION_PEERS_LIMIT} peers per session."
)
# Get or create workspace to ensure it exists
await get_or_create_workspace(
db,
schemas.WorkspaceCreate(name=workspace_name),
)
# Create honcho session
honcho_session = models.Session(
workspace_name=workspace_name,
name=session.name,
h_metadata=session.metadata or {},
configuration=session.configuration or {},
)
db.add(honcho_session)
# Flush to ensure session exists in DB before adding peers
await db.flush()
else:
# Update existing session with metadata and feature flags if provided
if session.metadata is not None:
honcho_session.h_metadata = session.metadata
if session.configuration is not None:
honcho_session.configuration = session.configuration
# Add all peers to session
if session.peer_names:
await get_or_create_peers(
db,
workspace_name=workspace_name,
peers=[
schemas.PeerCreate(name=peer_name) for peer_name in session.peer_names
],
)
await _get_or_add_peers_to_session(
db,
workspace_name=workspace_name,
session_name=session.name,
peer_names=session.peer_names,
)
await db.commit()
logger.info(
f"Session {session.name} updated successfully in workspace {workspace_name} with {len(session.peer_names or [])} peers"
)
return honcho_session
async def get_session(
db: AsyncSession,
session_name: str,
workspace_name: str,
) -> models.Session:
"""
Get a session in a workspace.
Args:
db: Database session
session_name: Name of the session
workspace_name: Name of the workspace
Returns:
The session
Raises:
ResourceNotFoundException: If the session does not exist
"""
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
result = await db.execute(stmt)
honcho_session = result.scalar_one_or_none()
if honcho_session is None:
raise ResourceNotFoundException(
f"Session {session_name} not found in workspace {workspace_name}"
)
return honcho_session
async def update_session(
db: AsyncSession,
session: schemas.SessionUpdate,
workspace_name: str,
session_name: str,
) -> models.Session:
"""
Update a session.
Args:
db: Database session
session: Session update schema
workspace_name: Name of the workspace
session_name: Name of the session
Returns:
The updated session
Raises:
ResourceNotFoundException: If the session does not exist or peer is not in session
"""
honcho_session = await get_or_create_session(
db, schemas.SessionCreate(name=session_name), workspace_name=workspace_name
)
if session.metadata is not None:
honcho_session.h_metadata = session.metadata
if session.configuration is not None:
honcho_session.configuration = session.configuration
await db.commit()
logger.info(f"Session {session_name} updated successfully")
return honcho_session
async def delete_session(
db: AsyncSession, workspace_name: str, session_name: str
) -> bool:
"""
Mark a session as inactive (soft delete).
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
Returns:
True if the session was deleted successfully
Raises:
ResourceNotFoundException: If the session does not exist
"""
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
result = await db.execute(stmt)
honcho_session = result.scalar_one_or_none()
if honcho_session is None:
logger.warning(
f"Session {session_name} not found in workspace {workspace_name}"
)
raise ResourceNotFoundException("Session not found")
honcho_session.is_active = False
await db.commit()
logger.info(f"Session {session_name} marked as inactive")
return True
async def clone_session(
db: AsyncSession,
workspace_name: str,
original_session_name: str,
cutoff_message_id: str | None = None,
) -> models.Session:
"""
Clone a session and its messages. If cutoff_message_id is provided,
only clone messages up to and including that message.
Args:
db: SQLAlchemy session
workspace_name: Name of the workspace the target session is in
original_session_name: Name of the session to clone
cutoff_message_id: Optional ID of the last message to include in the clone
Returns:
The newly created session
"""
# Get the original session
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == original_session_name)
)
result = await db.execute(stmt)
original_session = result.scalar_one_or_none()
if original_session is None:
raise ResourceNotFoundException("Original session not found")
# If cutoff_message_id is provided, verify it belongs to the session
cutoff_message = None
if cutoff_message_id is not None:
stmt = select(models.Message).where(
models.Message.public_id == cutoff_message_id,
models.Message.session_name == original_session_name,
)
cutoff_message = await db.scalar(stmt)
if not cutoff_message:
raise ValueError(
"Message not found or doesn't belong to the specified session"
)
# Create new session
new_session = models.Session(
workspace_name=workspace_name,
name=generate_nanoid(),
h_metadata=original_session.h_metadata,
)
db.add(new_session)
await db.flush() # Flush to get the new session ID
# Build query for messages to clone
stmt = select(models.Message).where(
models.Message.session_name == original_session_name
)
if cutoff_message_id is not None and cutoff_message is not None:
stmt = stmt.where(models.Message.id <= cast(cutoff_message.id, BigInteger))
stmt = stmt.order_by(models.Message.id)
# Fetch messages to clone
messages_to_clone_scalars = await db.scalars(stmt)
messages_to_clone = messages_to_clone_scalars.all()
if not messages_to_clone:
return new_session
# Prepare bulk insert data
new_messages = [
{
"session_name": new_session.name,
"content": message.content,
"h_metadata": message.h_metadata,
"workspace_name": workspace_name,
"peer_name": message.peer_name,
}
for message in messages_to_clone
]
insert_stmt = insert(models.Message).returning(models.Message)
result = await db.execute(insert_stmt, new_messages)
# Clone peers from original session to new session
stmt = select(models.SessionPeer).where(
models.SessionPeer.session_name == original_session_name
)
result = await db.execute(stmt)
session_peers = result.scalars().all()
for session_peer in session_peers:
new_session_peer = models.SessionPeer(
session_name=new_session.name,
peer_name=session_peer.peer_name,
workspace_name=workspace_name,
)
db.add(new_session_peer)
await db.commit()
logger.info(f"Session {original_session_name} cloned successfully")
return new_session
async def remove_peers_from_session(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_names: set[str],
) -> bool:
"""
Remove specified peers from a session.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_names: Set of peer names to remove from the session
Returns:
True if peers were removed successfully
Raises:
ResourceNotFoundException: If the session does not exist
"""
# Verify session exists
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if session is None:
raise ResourceNotFoundException(
f"Session {session_name} not found in workspace {workspace_name}"
)
# Soft delete specified session peers by setting left_at timestamp
update_stmt = (
update(models.SessionPeer)
.where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.peer_name.in_(peer_names),
models.SessionPeer.left_at.is_(None), # Only update active peers
)
.values(left_at=func.now())
)
result = await db.execute(update_stmt)
await db.commit()
return True
async def get_peers_from_session(
workspace_name: str,
session_name: str,
) -> Select[tuple[models.Peer]]:
"""
Get all peers from a session.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
Returns:
Paginated list of Peer objects in the 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)
.where(models.SessionPeer.session_name == session_name)
.where(models.Peer.workspace_name == workspace_name)
.where(models.SessionPeer.left_at.is_(None)) # Only active peers
)
async def get_session_peer_configuration(
workspace_name: str,
session_name: str,
) -> Select[tuple[str, dict[str, Any], dict[str, Any]]]:
"""
Get configuration from both SessionPeer and Peer tables for active peers in a session.
Args:
workspace_name: Name of the workspace
session_name: Name of the session
Returns:
Select statement returning peer_name, peer_configuration, and session_peer_configuration
"""
stmt: Select[tuple[str, dict[str, Any], dict[str, Any]]] = (
select(
models.Peer.name.label("peer_name"),
models.Peer.configuration.label("peer_configuration"),
models.SessionPeer.configuration.label("session_peer_configuration"),
)
.join(models.SessionPeer, models.Peer.name == models.SessionPeer.peer_name)
.where(models.SessionPeer.session_name == session_name)
.where(models.Peer.workspace_name == workspace_name)
.where(models.SessionPeer.workspace_name == workspace_name)
.where(models.SessionPeer.left_at.is_(None)) # Only active peers
)
return stmt
async def set_peers_for_session(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_names: dict[str, schemas.SessionPeerConfig],
) -> list[models.SessionPeer]:
"""
Set peers for a session, overwriting any existing peers.
If peers don't exist, they will be created.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_names: Set of peer names to set for the session
Returns:
List of SessionPeer objects for all peers in the session
Raises:
ResourceNotFoundException: If the session does not exist
"""
# Validate peer limit before making any changes
if len(peer_names) > settings.SESSION_PEERS_LIMIT:
raise ValueError(
f"Cannot set {len(peer_names)} peers for session {session_name}. Maximum allowed is {settings.SESSION_PEERS_LIMIT} peers per session."
)
# Verify session exists
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if session is None:
raise ResourceNotFoundException(
f"Session {session_name} not found in workspace {workspace_name}"
)
# Soft delete specified session peers by setting left_at timestamp
update_stmt = (
update(models.SessionPeer)
.where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only update active peers
)
.values(left_at=func.now())
)
result = await db.execute(update_stmt)
# Get or create peers
await get_or_create_peers(
db,
workspace_name=workspace_name,
peers=[schemas.PeerCreate(name=peer_name) for peer_name in peer_names],
)
# Add new peers to session
peers = await _get_or_add_peers_to_session(
db,
workspace_name=workspace_name,
session_name=session_name,
peer_names=peer_names,
)
await db.commit()
return peers
async def _get_or_add_peers_to_session(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_names: dict[str, schemas.SessionPeerConfig],
) -> list[models.SessionPeer]:
"""
Add multiple peers to an existing session. If a peer already exists in the session,
it will be skipped gracefully.
Args:
db: Database session
session_name: Name of the session
peer_names: Set of peer names to add to the session
Returns:
List of all SessionPeer objects (both existing and newly created)
Raises:
ValueError: If adding peers would exceed the maximum limit
"""
# If no peers to add, skip the insert and just return existing active session peers
if not peer_names:
select_stmt = select(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
)
result = await db.execute(select_stmt)
return list(result.scalars().all())
# Check current number of active peers and validate limit before upsert
current_peers_stmt = select(models.SessionPeer.peer_name).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
)
result = await db.execute(current_peers_stmt)
existing_peer_names = result.scalars().all()
new_peers = [name for name in peer_names if name not in existing_peer_names]
if len(new_peers) + len(existing_peer_names) > settings.SESSION_PEERS_LIMIT:
raise ValueError(
f"Cannot add {len(new_peers)} peer(s). Session already has {len(existing_peer_names)} peer(s) with {settings.SESSION_PEERS_LIMIT} peers per session."
)
# Use upsert to handle both new peers and rejoining peers
stmt = pg_insert(models.SessionPeer).values(
[
{
"session_name": session_name,
"peer_name": peer_name,
"workspace_name": workspace_name,
"joined_at": func.now(),
"left_at": None,
"configuration": configuration.model_dump(),
}
for peer_name, configuration in peer_names.items()
]
)
# On conflict, update joined_at and clear left_at (rejoin scenario)
stmt = stmt.on_conflict_do_update(
index_elements=["session_name", "peer_name", "workspace_name"],
set_={
"joined_at": func.now(),
"left_at": None,
"configuration": stmt.excluded.configuration,
},
)
await db.execute(stmt)
# Return all active session peers after the upsert
select_stmt = select(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
)
result = await db.execute(select_stmt)
return list(result.scalars().all())
async def get_peer_config(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_id: str,
) -> schemas.SessionPeerConfig:
"""
Get the configuration for a peer in a session.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_id: Name of the peer
Returns:
Configuration for the peer
Raises:
ResourceNotFoundException: If the session or peer does not exist
"""
# Get row from session_peer table
stmt = select(models.SessionPeer).where(
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == peer_id,
)
result = await db.execute(stmt)
session_peer = result.scalar_one_or_none()
if session_peer is None:
raise ResourceNotFoundException(
f"Session peer {peer_id} not found in session {session_name} in workspace {workspace_name}"
)
return schemas.SessionPeerConfig(**session_peer.configuration)
async def set_peer_config(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_name: str,
config: schemas.SessionPeerConfig,
) -> None:
"""
Set the configuration for a specific peer in a session.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_name: Name of the peer
config: The peer configuration to set
"""
# First, get the session and peer to ensure they exist
await get_session(db, session_name, workspace_name)
await get_peer(db, workspace_name, schemas.PeerCreate(name=peer_name))
# Check if a SessionPeer entry already exists
stmt = (
select(models.SessionPeer)
.where(models.SessionPeer.session_name == session_name)
.where(models.SessionPeer.peer_name == peer_name)
.where(models.SessionPeer.workspace_name == workspace_name)
)
result = await db.execute(stmt)
session_peer = result.scalar_one_or_none()
update_data = config.model_dump(exclude_none=True)
if session_peer:
# Update existing configuration
if session_peer.configuration:
# Create a new dictionary and update it to ensure SQLAlchemy tracks the change
new_config = session_peer.configuration.copy()
new_config.update(update_data)
session_peer.configuration = new_config
else:
session_peer.configuration = update_data
else:
# Create a new SessionPeer entry
session_peer = models.SessionPeer(
session_name=session_name,
peer_name=peer_name,
workspace_name=workspace_name,
configuration=update_data,
)
db.add(session_peer)
await db.commit()

97
src/crud/workspace.py Normal file
View File

@ -0,0 +1,97 @@
from logging import getLogger
from typing import Any
from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import models, schemas
from src.utils.filter import apply_filter
logger = getLogger(__name__)
async def get_or_create_workspace(
db: AsyncSession, workspace: schemas.WorkspaceCreate
) -> models.Workspace:
"""
Get an existing workspace or create a new one if it doesn't exist.
Args:
db: Database session
workspace: Workspace creation schema
Returns:
The workspace if found or created
Raises:
ConflictException: If there's an integrity error when creating the workspace
"""
# Try to get the existing workspace
stmt = select(models.Workspace).where(models.Workspace.name == workspace.name)
result = await db.execute(stmt)
existing_workspace = result.scalar_one_or_none()
if existing_workspace is not None:
# Workspace already exists
logger.debug(f"Found existing workspace: {workspace.name}")
return existing_workspace
# Workspace doesn't exist, create a new one
honcho_workspace = models.Workspace(
name=workspace.name,
h_metadata=workspace.metadata,
configuration=workspace.configuration,
)
db.add(honcho_workspace)
await db.commit()
logger.info(f"Workspace created successfully: {workspace.name}")
return honcho_workspace
async def get_all_workspaces(
filters: dict[str, Any] | None = None,
) -> Select[tuple[models.Workspace]]:
"""
Get all workspaces.
Args:
db: Database session
filters: Filter the workspaces by a dictionary of metadata
"""
stmt = select(models.Workspace)
stmt = apply_filter(stmt, models.Workspace, filters)
stmt: Select[tuple[models.Workspace]] = stmt.order_by(models.Workspace.created_at)
return stmt
async def update_workspace(
db: AsyncSession, workspace_name: str, workspace: schemas.WorkspaceUpdate
) -> models.Workspace:
"""
Update a workspace.
Args:
db: Database session
workspace_name: Name of the workspace
workspace: Workspace update schema
Returns:
The updated workspace
"""
honcho_workspace = await get_or_create_workspace(
db,
schemas.WorkspaceCreate(
name=workspace_name,
metadata=workspace.metadata or {}, # Provide empty dict if metadata is None
),
)
if workspace.metadata is not None:
honcho_workspace.h_metadata = workspace.metadata
if workspace.configuration is not None:
honcho_workspace.configuration = workspace.configuration
await db.commit()
logger.info(f"Workspace with id {honcho_workspace.id} updated successfully")
return honcho_workspace

3
src/deriver/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .enqueue import enqueue
__all__ = ["enqueue"]

View File

@ -2,7 +2,7 @@ import asyncio
import uvloop
from .queue import main
from .queue_manager import main
if __name__ == "__main__":
print("[DERIVER] Starting deriver queue processor")

View File

@ -1,52 +1,27 @@
import logging
import os
from typing import Any, Literal
from typing import Any
import sentry_sdk
from langfuse.decorators import observe # pyright: ignore
from pydantic import BaseModel, ValidationError
from pydantic import ValidationError
from rich.console import Console
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from src.utils.summarizer import summarize_if_needed
from .. import crud
from ..utils import history
from .tom.embeddings import CollectionEmbeddingStore
from .tom.long_term import extract_facts_long_term
from .deriver import Deriver
from .queue_payload import DeriverQueuePayload
logger = logging.getLogger(__name__)
logging.getLogger("sqlalchemy.engine.Engine").disabled = True
console = Console(markup=False)
TOM_METHOD = settings.DERIVER.TOM_METHOD
USER_REPRESENTATION_METHOD = settings.DERIVER.USER_REPRESENTATION_METHOD
class PayloadSchema(BaseModel):
"""
Schema for validating payload data in process_item function.
Ensures all required fields are present with correct types and prevents injection risks.
"""
content: str
workspace_name: str
sender_name: str
target_name: str
session_name: str | None
message_id: int
task_type: Literal["representation", "summary"]
class Config:
# Forbid extra fields to prevent injection of unexpected data
extra = "forbid" # pyright: ignore
deriver = Deriver()
async def process_item(db: AsyncSession, payload: dict[str, Any]):
# Validate payload structure and types before processing
try:
validated_payload = PayloadSchema(**payload)
validated_payload = DeriverQueuePayload(**payload)
except ValidationError as e:
logger.error("Invalid payload received: %s. Payload: %s", str(e), payload)
raise ValueError(f"Invalid payload structure: {str(e)}") from e
@ -63,15 +38,7 @@ async def process_item(db: AsyncSession, payload: dict[str, Any]):
validated_payload.message_id,
validated_payload.session_name,
)
await process_message(
validated_payload.content,
validated_payload.workspace_name,
validated_payload.sender_name,
validated_payload.target_name,
validated_payload.session_name,
validated_payload.message_id,
db,
)
await deriver.process_message(validated_payload)
logger.debug(
"Finished processing message %s in %s %s",
validated_payload.message_id,
@ -90,211 +57,3 @@ async def process_item(db: AsyncSession, payload: dict[str, Any]):
validated_payload.message_id,
)
return
@sentry_sdk.trace
@observe()
async def process_message(
content: str,
workspace_name: str,
peer_name: str,
target_name: str,
session_name: str | None,
message_id: int,
db: AsyncSession,
):
"""
Process a user message by extracting facts and saving them to the vector store.
This runs as a background process after a user message is logged.
"""
console.print(f"Processing User Message: {content}", style="orange1")
process_start = os.times()[4] # Get current CPU time
logger.debug(
"Starting fact extraction for user message %s in %s %s",
message_id,
"session" if session_name else "peer",
session_name if session_name else peer_name,
)
if session_name:
# Get chat history and append current message
logger.debug(
"Retrieving chat history for %s %s",
"session" if session_name else "peer",
session_name if session_name else peer_name,
)
short_history_text = await history.get_summarized_history(
db,
workspace_name,
session_name,
peer_name,
cutoff=message_id,
summary_type=history.SummaryType.SHORT,
)
chat_history_str = f"{short_history_text}\nuser: {content}"
else:
chat_history_str = f"user: {content}"
# Extract facts from chat history
logger.debug("Extracting facts from chat history")
extract_start = os.times()[4]
fact_extraction = await extract_facts_long_term(chat_history_str)
facts: list[str] = fact_extraction.facts or []
extract_time = os.times()[4] - extract_start
console.print(f"Extracted Facts: {facts}", style="bright_blue")
logger.debug(f"Extracted {len(facts)} facts in {extract_time:.2f}s")
# Save the facts to the collection
logger.debug(
f"Setting up embedding store for workspace: {workspace_name}, peer: {peer_name}"
)
collection_name = (
crud.construct_collection_name(peer_name, target_name)
if peer_name != target_name
else "global_representation"
)
collection = await crud.get_or_create_collection(
db, workspace_name, collection_name, peer_name
)
embedding_store = CollectionEmbeddingStore(
workspace_name=workspace_name,
peer_name=peer_name,
collection_name=collection.name,
)
# Filter out facts that are duplicates of existing facts in the vector store
logger.debug("Removing duplicate facts")
dedup_start = os.times()[4]
unique_facts = await embedding_store.remove_duplicates(facts)
dedup_time = os.times()[4] - dedup_start
logger.debug(
f"Found {len(unique_facts)}/{len(facts)} unique facts in {dedup_time:.2f}s"
)
# Only save the unique facts
if unique_facts:
logger.debug(f"Saving {len(unique_facts)} unique facts to vector store")
save_start = os.times()[4]
await embedding_store.save_facts(unique_facts, message_id=message_id)
save_time = os.times()[4] - save_start
logger.debug(f"Facts saved in {save_time:.2f}s")
else:
logger.debug("No unique facts to save")
console.print(f"Saved {len(unique_facts)} unique facts", style="bright_green")
total_time = os.times()[4] - process_start
logger.debug(f"Total processing time: {total_time:.2f}s")
async def summarize_if_needed(
db: AsyncSession,
workspace_name: str,
session_name: str | None,
peer_name: str,
message_id: int,
):
if not session_name:
return
summary_start = os.times()[4]
logger.debug("Checking if summaries should be created for session %s", session_name)
# STEP 1: First check if we need a short summary (every 10 messages)
(
should_create_short,
short_messages,
_,
) = await history.should_create_summary(
db,
workspace_name,
session_name,
peer_name,
message_id,
summary_type=history.SummaryType.SHORT,
)
if should_create_short:
logger.debug(f"Short summary needed for {len(short_messages)} messages")
# STEP 2: If we need a short summary, check if we also need a long summary
(
should_create_long,
long_messages,
latest_long_summary,
) = await history.should_create_summary(
db,
workspace_name,
session_name,
peer_name,
message_id,
summary_type=history.SummaryType.LONG,
)
# STEP 3: If we need a long summary, create it first before creating the short summary
if should_create_long:
logger.debug(
f"Creating new long summary covering {len(long_messages)} messages"
)
try:
# Get previous long summary context if available
previous_long_summary_text = (
latest_long_summary["content"] if latest_long_summary else None
)
# Create a new long summary
new_long_summary = await history.create_summary(
messages=long_messages,
previous_summary_text=previous_long_summary_text,
summary_type=history.SummaryType.LONG,
)
# Save the long summary
await history.save_summary(
db,
new_long_summary,
workspace_name,
session_name,
)
logger.debug("Long summary created and saved successfully")
except Exception as e:
logger.error(f"Error creating long summary: {str(e)}")
else:
logger.debug(
f"No long summary needed. Need {history.MESSAGES_PER_LONG_SUMMARY} messages since last long summary."
)
# STEP 4: Now create the short summary, using the latest long summary for context if available
logger.debug(
f"Creating new short summary covering {len(short_messages)} messages"
)
try:
previous_long_summary_text = (
latest_long_summary["content"] if latest_long_summary else None
)
# Create a new short summary
new_short_summary = await history.create_summary(
messages=short_messages,
previous_summary_text=previous_long_summary_text,
summary_type=history.SummaryType.SHORT,
)
# Save the short summary
await history.save_summary(
db,
new_short_summary,
workspace_name,
session_name,
)
logger.debug("Short summary created and saved successfully")
except Exception as e:
logger.error(f"Error creating short summary: {str(e)}")
else:
logger.debug(
f"No short summary needed. Need {history.MESSAGES_PER_SHORT_SUMMARY} messages since last short summary."
)
summary_time = os.times()[4] - summary_start
logger.debug(f"Summary check completed in {summary_time:.2f}s")

631
src/deriver/deriver.py Normal file
View File

@ -0,0 +1,631 @@
import datetime
import logging
import os
import time
from typing import Any
from langfuse.decorators import langfuse_context, observe # pyright: ignore
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud
from src.config import settings
from src.utils import summarizer
from src.utils.clients import honcho_llm_call
from src.utils.embedding_store import EmbeddingStore
from src.utils.formatting import (
REASONING_LEVELS,
extract_observation_content,
find_new_observations,
format_context_for_prompt,
format_datetime_simple,
format_new_turn_with_timestamp,
)
from src.utils.shared_models import (
DeductiveObservation,
ObservationContext,
ReasoningResponse,
ReasoningResponseWithThinking,
UnifiedObservation,
)
from .logging import (
format_reasoning_inputs_as_markdown,
format_reasoning_response_as_markdown,
log_observations_tree,
log_performance_metrics,
log_thinking_panel,
)
from .prompts import critical_analysis_prompt
from .queue_payload import DeriverQueuePayload
logger = logging.getLogger(__name__)
logging.getLogger("sqlalchemy.engine.Engine").disabled = True
@honcho_llm_call(
provider=settings.DERIVER.PROVIDER,
model=settings.DERIVER.MODEL,
track_name="Critical Analysis Call",
response_model=ReasoningResponse,
json_mode=True,
max_tokens=settings.DERIVER.MAX_OUTPUT_TOKENS or settings.LLM.DEFAULT_MAX_TOKENS,
thinking_budget_tokens=settings.DERIVER.THINKING_BUDGET_TOKENS
if settings.DERIVER.PROVIDER == "anthropic"
else None,
enable_retry=True,
retry_attempts=3,
)
async def critical_analysis_call(
peer_name: str,
message_created_at: datetime.datetime,
context: str,
history: str,
new_turn: str,
):
return critical_analysis_prompt(
peer_name=peer_name,
message_created_at=message_created_at,
context=context,
history=history,
new_turn=new_turn,
)
@observe()
class Deriver:
"""Deriver class for processing messages and extracting insights."""
async def process_message(
self,
payload: DeriverQueuePayload,
) -> ReasoningResponseWithThinking:
"""
Process a user message by extracting insights and saving them to the vector store.
This runs as a background process after a user message is logged.
"""
langfuse_context.update_current_trace(
metadata={
"critical_analysis_model": settings.DERIVER.MODEL,
}
)
# Extract variables from payload for cleaner access
content = payload.content
workspace_name = payload.workspace_name
sender_name = payload.sender_name
target_name = payload.target_name
session_name = payload.session_name
message_id = payload.message_id
created_at = payload.created_at
# Open a DB session only for the duration of the processing call
from src.dependencies import tracked_db
async with tracked_db("deriver") as db:
logger.debug("Processing user message: %s", content)
process_start = os.times()[4] # Get current CPU time
logger.debug("Starting insight extraction for user message: %s", message_id)
# Use message timestamp instead of wall-clock time for reasoning/insight dating
# created_at is now always a datetime object from Pydantic validation
current_time = format_datetime_simple(created_at)
message_dt_obj = created_at
logger.info(
f"Using message timestamp '{current_time}' for message {message_id}"
)
# Create summary if needed BEFORE history retrieval to ensure consistent state
await summarizer.summarize_if_needed(
db, workspace_name, session_name, sender_name, message_id
)
# Instead of the complex 3-return tuple approach, use the simple formatted text approach
if session_name:
formatted_history = await summarizer.get_summarized_history(
db,
workspace_name,
session_name,
sender_name,
cutoff=message_id,
summary_type=summarizer.SummaryType.SHORT,
)
else:
formatted_history = ""
# Debug: Check if we just created a summary and messages are missing
logger.info(f"History retrieved: {len(formatted_history)} characters")
# instantiate embedding store from collection
collection_name = (
crud.construct_collection_name(
observer=target_name, observed=sender_name
)
if sender_name != target_name
else "global_representation"
)
try:
collection = await crud.get_or_create_collection(
db, workspace_name, collection_name, sender_name
)
except Exception as e:
# Handle race condition from concurrent processing
if "duplicate key" in str(e).lower():
# Rollback the failed transaction
await db.rollback()
# Collection already exists, fetch it
collection = await crud.get_collection(
db, workspace_name, collection_name, sender_name
)
else:
raise
# Use the ed embedding store directly
embedding_store = EmbeddingStore(
workspace_name=workspace_name,
peer_name=sender_name,
collection_name=collection.name,
)
# Create reasoner instance
reasoner = CertaintyReasoner(embedding_store=embedding_store)
# Check for existing working representation first, fall back to global search
working_rep_data: (
dict[str, Any] | str | None
) = await crud.get_working_representation_data(
db, workspace_name, target_name, sender_name, session_name
)
if (
working_rep_data
and isinstance(working_rep_data, dict)
and working_rep_data.get("final_observations")
):
# Reconstruct ReasoningResponse from stored peer data
final_obs: dict[str, Any] = working_rep_data["final_observations"]
deductive_observations: list[DeductiveObservation] = []
for deductive_data in final_obs.get("deductive", []):
deductive_observations.append(
DeductiveObservation(
conclusion=deductive_data["conclusion"],
premises=deductive_data.get("premises", []),
)
)
initial_reasoning_context = ReasoningResponseWithThinking(
thinking=final_obs.get("thinking"),
explicit=final_obs.get("explicit", []),
deductive=deductive_observations,
)
logger.info(
f"Using existing working representation with {len(initial_reasoning_context.explicit)} explicit, {len(initial_reasoning_context.deductive)} deductive observations"
)
else:
# No working representation, use global search
initial_context = await embedding_store.get_relevant_observations(
query=content,
conversation_context=formatted_history,
for_reasoning=True,
)
initial_reasoning_context = (
reasoner.observation_context_to_reasoning_response(initial_context)
)
logger.info(
"No working representation found, using global semantic search"
)
# Run consolidated reasoning that handles explicit and deductive levels
logger.debug(
"REASONING: Running unified insight derivation across explicit and deductive reasoning levels"
)
# Run single-pass reasoning
final_observations = await reasoner.reason(
initial_reasoning_context,
formatted_history,
content,
str(message_id), # Convert int to str
session_name,
message_dt_obj,
sender_name, # Pass the speaker name
)
logger.debug(
"REASONING COMPLETION: Unified reasoning completed across all levels."
)
# Display final observations in a beautiful tree
final_obs_dict = {
level: getattr(final_observations, level, [])
for level in REASONING_LEVELS
}
log_observations_tree(final_obs_dict)
# Display final reasoning metrics
rsr_time = os.times()[4] - process_start
total_observations = sum(
len(obs_list) for obs_list in final_obs_dict.values()
)
summary_metrics = {
"total_processing_time": rsr_time * 1000, # Convert to ms
"final_observation_count": total_observations,
}
log_performance_metrics(summary_metrics)
langfuse_context.update_current_trace(
output=format_reasoning_response_as_markdown(final_observations)
)
# Always save working representation to peer for dialectic access
await save_working_representation_to_peer(
db,
workspace_name,
target_name, # observer (whose metadata we update)
sender_name, # observed (for key calculation)
session_name,
final_observations,
message_id,
)
# Return the structured observations so callers can capture them directly
return final_observations
class CertaintyReasoner:
"""Certainty reasoner for analyzing and deriving insights."""
embedding_store: EmbeddingStore
def __init__(self, embedding_store: EmbeddingStore) -> None:
self.embedding_store = embedding_store
def observation_context_to_reasoning_response(
self, context: "ObservationContext"
) -> ReasoningResponseWithThinking:
"""Convert ObservationContext to ReasoningResponse for compatibility."""
thinking = context.thinking
# Convert explicit observations to new structure
explicit: list[str] = []
for obs in context.explicit:
explicit.append(obs.content)
# Convert deductive observations
deductive: list[DeductiveObservation] = []
for obs in context.deductive:
deductive_obs = DeductiveObservation(
conclusion=obs.content,
premises=obs.metadata.premises if obs.metadata else [],
)
deductive.append(deductive_obs)
return ReasoningResponseWithThinking(
thinking=thinking,
explicit=explicit,
deductive=deductive,
)
@observe()
async def derive_new_insights(
self,
context: ReasoningResponseWithThinking,
history: str,
new_turn: str,
message_created_at: datetime.datetime,
speaker: str = "user",
) -> ReasoningResponseWithThinking:
"""
Critically analyzes and revises understanding, returning structured observations.
"""
langfuse_context.update_current_observation(
input=format_reasoning_inputs_as_markdown(
context, history, new_turn, message_created_at
)
)
formatted_new_turn = format_new_turn_with_timestamp(
new_turn, message_created_at, speaker
)
formatted_context = format_context_for_prompt(context)
logger.debug(
"CRITICAL ANALYSIS: message_created_at='%s', formatted_new_turn='%s'",
message_created_at,
formatted_new_turn,
)
# Call the standalone LLM function (now with Tenacity retries)
response_obj = await critical_analysis_call(
peer_name=speaker,
message_created_at=message_created_at,
context=formatted_context,
history=history,
new_turn=formatted_new_turn,
)
# Handle different response types
if isinstance(response_obj, str):
# If response is a string, try to parse as JSON
import json
try:
response_data = json.loads(response_obj)
new_insights = ReasoningResponse(
explicit=response_data.get("explicit", []),
deductive=[
DeductiveObservation(**item)
for item in response_data.get("deductive", [])
],
)
except (json.JSONDecodeError, KeyError, TypeError) as e:
logger.warning(f"Failed to parse string response as JSON: {e}")
new_insights = ReasoningResponse(explicit=[], deductive=[])
else:
# If response is already a ReasoningResponse object
new_insights = response_obj
# Extract thinking content from the response
thinking: str | None = None
try:
# Try to get thinking from the response object using getattr for safety
response_attr = getattr(response_obj, "_response", None)
if response_attr:
thinking = getattr(response_attr, "thinking", None)
else:
thinking = getattr(response_obj, "thinking", None)
if thinking is None:
logger.debug("No thinking content found in response")
except (AttributeError, TypeError) as e:
logger.warning(f"Error accessing thinking content: {e}, setting to None")
thinking = None
logger.debug(
"🚀 DEBUG: new_insights=%s, thinking_length=%s",
new_insights,
len(thinking) if thinking else 0,
)
response = ReasoningResponseWithThinking(
thinking=thinking,
explicit=new_insights.explicit,
deductive=new_insights.deductive,
)
langfuse_context.update_current_observation(
output=format_reasoning_response_as_markdown(response),
)
return response
@observe()
async def reason(
self,
context: ReasoningResponseWithThinking,
history: str,
new_turn: str,
message_id: str,
session_name: str | None = None,
message_created_at: datetime.datetime | None = None,
speaker: str = "user",
) -> ReasoningResponseWithThinking:
"""
Single-pass reasoning function that critically analyzes and derives insights.
Performs one analysis pass and returns the final observations.
"""
if message_created_at is None:
message_created_at = datetime.datetime.now(datetime.timezone.utc)
analysis_start = time.time()
# Perform critical analysis to get observation lists
reasoning_response = await self.derive_new_insights(
context, history, new_turn, message_created_at, speaker
)
# Output the thinking content for this analysis
log_thinking_panel(reasoning_response.thinking)
# Compare input context with output to detect changes
# Calculate analysis duration
analysis_duration_ms = int((time.time() - analysis_start) * 1000)
# Save only the NEW observations that weren't in the original context
await self._save_new_observations(
context,
reasoning_response,
message_id,
session_name,
message_created_at,
)
# Display observations in a tree structure and performance metrics
observations = {
level: getattr(reasoning_response, level, []) for level in REASONING_LEVELS
}
log_observations_tree(observations)
# Log performance metrics for this analysis
metrics = {
"analysis_duration": analysis_duration_ms,
}
log_performance_metrics(metrics, "⚡ REASONING METRICS")
return reasoning_response
@observe()
async def _save_new_observations(
self,
original_context: ReasoningResponse,
revised_observations: ReasoningResponse,
message_id: str,
session_name: str | None = None,
message_created_at: datetime.datetime | None = None,
) -> None:
"""Save only the observations that are new compared to the original context."""
if not self.embedding_store:
return
# Use the utility function to find new observations
new_observations_by_level = find_new_observations(
original_context, revised_observations
)
all_unified_observations: list[UnifiedObservation] = []
total_observations_count: int = 0
for level, new_observations in new_observations_by_level.items():
if not new_observations:
logger.debug("No new observations to save for %s level", level)
continue
logger.debug("Found %s new %s observations", len(new_observations), level)
# Convert each observation to UnifiedObservation with proper premises and level
for observation in new_observations:
if isinstance(observation, DeductiveObservation):
# Create UnifiedObservation with premises from DeductiveObservation
unified_obs = UnifiedObservation(
conclusion=observation.conclusion,
premises=observation.premises,
level=level,
)
all_unified_observations.append(unified_obs)
logger.debug(
"Added %s observation: %s... with %s premises",
level,
observation.conclusion[:50],
len(observation.premises),
)
elif isinstance(observation, str):
# String observations (explicit) have no premises
unified_obs = UnifiedObservation.from_string(
observation, level=level
)
all_unified_observations.append(unified_obs)
logger.debug("Added %s observation: %s...", level, observation[:50])
else:
# Handle unexpected types
content = extract_observation_content(observation)
unified_obs = UnifiedObservation.from_string(content, level=level)
all_unified_observations.append(unified_obs)
logger.warning(
f"Added unexpected observation type: {type(observation)} as {level}"
)
total_observations_count += 1
if not all_unified_observations:
logger.debug("No new observations to save")
return
# Make a single save call for all observations
logger.info(
f"🚀 Making single optimized call for {total_observations_count} observations"
)
await self.embedding_store.save_unified_observations(
all_unified_observations,
message_id=message_id,
session_name=session_name,
message_created_at=message_created_at,
)
logger.info(
f"✅ Successfully saved {total_observations_count} observations in 1 optimized call"
)
async def save_working_representation_to_peer(
db: AsyncSession,
workspace_name: str,
observer_name: str, # renamed from peer_name for clarity
observed_name: str, # new parameter
session_name: str | None,
final_observations: ReasoningResponseWithThinking,
message_id: int,
) -> None:
"""Save working representation to peer internal_metadata for dialectic access."""
from sqlalchemy import update
from src import models
# Determine metadata key based on observer/observed relationship
if observer_name == observed_name:
metadata_key = "global_representation"
else:
metadata_key = crud.construct_collection_name(
observer=observer_name, observed=observed_name
)
# Convert ReasoningResponse to serializable dict
final_obs_dict = {
"thinking": final_observations.thinking,
"explicit": final_observations.explicit,
"deductive": [
{
"conclusion": obs.conclusion,
"premises": obs.premises,
}
for obs in final_observations.deductive
],
}
working_rep_data = {
"final_observations": final_obs_dict,
"message_id": message_id,
"created_at": datetime.datetime.now().isoformat(),
}
# if session_name is supplied, save working representation to session peer
if session_name:
stmt = (
update(models.SessionPeer)
.where(
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == observer_name,
)
.values(
internal_metadata=models.SessionPeer.internal_metadata.op("||")(
{metadata_key: working_rep_data}
)
)
)
await db.execute(stmt)
await db.commit()
logger.info(
f"Saved working representation to session peer {session_name} - {observer_name} with key {metadata_key}"
)
else:
# For peer-level messages (session_name=None), only save global representations
if observer_name == observed_name:
stmt = (
update(models.Peer)
.where(
models.Peer.workspace_name == workspace_name,
models.Peer.name == observer_name,
)
.values(
internal_metadata=models.Peer.internal_metadata.op("||")(
{metadata_key: working_rep_data}
)
)
)
await db.execute(stmt)
await db.commit()
logger.debug(
"Saved working representation to peer %s with key %s",
observer_name,
metadata_key,
)
else:
logger.debug(
"Skipping peer-level local representation save: observer=%s, observed=%s",
observer_name,
observed_name,
)

286
src/deriver/enqueue.py Normal file
View File

@ -0,0 +1,286 @@
import logging
from typing import Any
from sqlalchemy import insert
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.exceptions import ValidationException
from src.models import QueueItem
from .queue_payload import DeriverQueuePayload
logger = logging.getLogger(__name__)
async def enqueue(payload: list[dict[str, Any]]) -> None:
"""
Add message(s) to the deriver queue for processing.
Args:
payload: List of message payload dictionaries
"""
# Use the get_db dependency to ensure proper transaction handling
async with tracked_db("message_enqueue") as db_session:
try:
# Determine if batch or single processing
if not payload: # Empty list check
return
workspace_name = payload[0]["workspace_name"]
session_name = payload[0]["session_name"]
if session_name is None or workspace_name is None:
raise ValidationException("Session and workspace are required")
queue_records = await handle_session(
db_session, payload, workspace_name, session_name
)
if queue_records:
stmt = insert(QueueItem).returning(QueueItem)
await db_session.execute(stmt, queue_records)
await db_session.commit()
logger.info(
"Successfully enqueued %d messages with %d total queue items",
len(payload),
len(queue_records),
)
except Exception as e:
logger.exception("Failed to enqueue messages!")
if settings.SENTRY.ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
async def handle_session(
db_session: AsyncSession,
payload: list[dict[str, Any]],
workspace_name: str,
session_name: str,
) -> list[dict[str, Any]]:
"""
Handle enqueueing for normal session cases, creating appropriate queue items based on configurations.
Args:
db_session: The database session
payload: List of message payloads
workspace_name: Name of the workspace
session_name: Name of the session
Returns:
List of queue records to insert
"""
session = await crud.get_or_create_session(
db_session,
session=schemas.SessionCreate(name=session_name),
workspace_name=workspace_name,
)
deriver_disabled = bool(session.configuration.get("deriver_disabled"))
peers_with_configuration = await get_peers_with_configuration(
db_session, workspace_name, session_name
)
queue_records: list[dict[str, Any]] = []
for message in payload:
queue_records.extend(
process_message(
message,
peers_with_configuration,
session.id,
deriver_disabled=deriver_disabled,
)
)
return queue_records
async def get_peers_with_configuration(
db_session: AsyncSession, workspace_name: str, session_name: str
) -> dict[str, list[dict[str, Any]]]:
"""
Retrieve peers with their configurations for a given session.
Args:
db_session: The database session
workspace_name: Name of the workspace
session_name: Name of the session
Returns:
Dictionary mapping peer names to their configurations
"""
configuration_query = await crud.get_session_peer_configuration(
workspace_name=workspace_name, session_name=session_name
)
peers_with_configuration_result = await db_session.execute(configuration_query)
peers_with_configuration_list = peers_with_configuration_result.all()
return {
row.peer_name: [row.peer_configuration, row.session_peer_configuration]
for row in peers_with_configuration_list
}
def create_representation_record(
message: dict[str, Any],
sender_name: str,
target_name: str,
session_id: str | None = None,
) -> dict[str, Any]:
"""
Create a queue record for representation task.
Args:
message: The message payload
sender_name: Name of the sender
target_name: Name of the target
session_id: Optional session ID
Returns:
Queue record dictionary
"""
processed_payload = DeriverQueuePayload.create_payload(
message=message,
sender_name=sender_name,
target_name=target_name,
task_type="representation",
)
return {
"payload": processed_payload,
"session_id": session_id,
}
def create_summary_record(
message: dict[str, Any], sender_name: str, target_name: str, session_id: str
) -> dict[str, Any]:
"""
Create a queue record for summary task.
Args:
message: The message payload
sender_name: Name of the sender
target_name: Name of the target
session_id: Session ID
Returns:
Queue record dictionary
"""
processed_payload = DeriverQueuePayload.create_payload(
message=message,
sender_name=sender_name,
target_name=target_name,
task_type="summary",
)
return {
"payload": processed_payload,
"session_id": session_id,
}
def get_effective_observe_me(
sender_name: str, peers_with_configuration: dict[str, list[dict[str, Any]]]
) -> bool:
"""
Determine the effective observe_me setting for a sender, considering session and peer configurations.
Args:
sender_name: Name of the sender
peers_with_configuration: Dictionary of peer configurations
Returns:
True if observe_me is enabled, False otherwise
"""
configuration = peers_with_configuration[sender_name]
sender_session_peer_config = (
schemas.SessionPeerConfig(**configuration[1]) if configuration[1] else None
)
sender_peer_config = (
schemas.PeerConfig(**configuration[0])
if configuration[0]
else schemas.PeerConfig()
)
# Session peer config takes precedence if it exists and has observe_me set
if sender_session_peer_config and sender_session_peer_config.observe_me is not None:
return sender_session_peer_config.observe_me
# Otherwise use peer config
return sender_peer_config.observe_me
def process_message(
message: dict[str, Any],
peers_with_configuration: dict[str, list[dict[str, Any]]],
session_id: str,
*,
deriver_disabled: bool,
) -> list[dict[str, Any]]:
"""
Process a single message and generate queue records based on configurations.
Args:
message: The message payload
deriver_disabled: Whether deriver is disabled for the session
peers_with_configuration: Dictionary of peer configurations
session_id: Session ID
Returns:
List of queue records for this message
"""
sender_name = message["peer_name"]
if deriver_disabled:
return [
create_summary_record(
message,
sender_name=sender_name,
target_name=sender_name,
session_id=session_id,
)
]
if not get_effective_observe_me(sender_name, peers_with_configuration):
return []
records: list[dict[str, Any]] = [
create_representation_record(
message,
sender_name=sender_name,
target_name=sender_name,
session_id=session_id,
)
]
for peer_name, configuration in peers_with_configuration.items():
if peer_name == sender_name:
continue
session_peer_config = (
schemas.SessionPeerConfig(**configuration[1]) if configuration[1] else None
)
if session_peer_config is None or not session_peer_config.observe_others:
continue
records.append(
create_representation_record(
message,
sender_name=sender_name,
target_name=peer_name,
session_id=session_id,
)
)
logger.debug(
"enqueued representation task for %s's representation of %s",
peer_name,
sender_name,
)
return records

255
src/deriver/logging.py Normal file
View File

@ -0,0 +1,255 @@
"""
Rich-powered logging utilities for beautiful console output.
"""
import datetime
from collections.abc import Mapping, Sequence
from typing import Any, Protocol
from rich.panel import Panel
from rich.table import Table
from rich.tree import Tree
from src.utils.logging import console
from src.utils.shared_models import ObservationDict, ReasoningResponseWithThinking
class ObservationWithContent(Protocol):
"""Protocol for objects with content attribute."""
content: str
class ObservationWithConclusion(Protocol):
"""Protocol for objects with conclusion and optional premises."""
conclusion: str
premises: Sequence[str] | None
# Union type for all possible observation types
ObservationType = (
str
| ObservationDict
| ObservationWithContent
| ObservationWithConclusion
| dict[str, Any]
)
def format_reasoning_response_as_markdown(
response: ReasoningResponseWithThinking | None,
) -> str:
"""
Format a ReasoningResponse object as markdown.
Args:
response: ReasoningResponse object or similar structure
Returns:
Formatted markdown string
"""
if not response:
return "No reasoning response available"
parts: list[str] = []
# Add thinking section if available
if hasattr(response, "thinking") and response.thinking:
parts.append("## Thinking\n")
parts.append(response.thinking.strip())
parts.append("")
# Add explicit observations
if hasattr(response, "explicit") and response.explicit:
parts.append("## Explicit Observations\n")
for i, obs in enumerate(response.explicit, 1):
parts.append(f"{i}. {obs}")
parts.append("")
# Add deductive observations
if hasattr(response, "deductive") and response.deductive:
parts.append("## Deductive Observations\n")
for i, obs in enumerate(response.deductive, 1):
if hasattr(obs, "conclusion"):
parts.append(f"{i}. **Conclusion**: {obs.conclusion}")
if hasattr(obs, "premises") and obs.premises:
parts.append(" **Premises**:")
for premise in obs.premises:
parts.append(f" - {premise}")
parts.append("")
else:
parts.append(f"{i}. {obs}")
parts.append("")
return "\n".join(parts)
def format_reasoning_inputs_as_markdown(
context: ReasoningResponseWithThinking | None,
history: str,
new_turn: str,
message_created_at: datetime.datetime,
) -> str:
"""
Format reasoning inputs as markdown for logging.
Args:
context: Current context/observations
history: Conversation history
new_turn: New user message
message_created_at: Message timestamp
Returns:
Formatted markdown string
"""
parts: list[str] = []
parts.append("## Reasoning Inputs\n")
parts.append(
f"**Current Time**: {message_created_at.strftime('%Y-%m-%d %H:%M:%S')}"
)
parts.append("")
# Add context if available
if context:
parts.append("### Current Context\n")
if hasattr(context, "explicit") and context.explicit:
parts.append("**Explicit Observations**:")
for obs in context.explicit:
parts.append(f"- {obs}")
parts.append("")
if hasattr(context, "deductive") and context.deductive:
parts.append("**Deductive Observations**:")
for obs in context.deductive:
if hasattr(obs, "conclusion"):
parts.append(f"- {obs.conclusion}")
else:
parts.append(f"- {obs}")
parts.append("")
# Add history
if history:
parts.append("### Conversation History\n")
parts.append(history.strip())
parts.append("")
# Add new turn
if new_turn:
parts.append("### New Turn\n")
parts.append(new_turn.strip())
parts.append("")
return "\n".join(parts)
def log_thinking_panel(
thinking: str | None,
) -> None:
"""
Log thinking content in a beautiful panel.
Args:
thinking: Thinking content to display (can be None)
"""
if not thinking:
console.print("[dim]No thinking content available[/]")
return
panel = Panel(
thinking.strip(),
title="🧠 THINKING",
title_align="left",
border_style="blue",
padding=(1, 2),
)
# Use console.print for immediate output only
console.print(panel)
def log_observations_tree(
observations: dict[str, list[Any]],
) -> None:
"""
Log observations in a tree structure.
Args:
observations: Dictionary of observation types and their lists
"""
tree = Tree("📊 OBSERVATIONS")
for obs_type, obs_list in observations.items():
if obs_list:
type_branch = tree.add(
f"[bold cyan]{obs_type.title()}[/] ({len(obs_list)})"
)
for i, obs in enumerate(obs_list): # Show all observations
content = _extract_observation_text(obs)
truncated = content[:120] + "..." if len(content) > 120 else content
type_branch.add(f"[dim]{i + 1}.[/] {truncated}")
console.print(tree)
def log_performance_metrics(
metrics: Mapping[str, str | int | float],
title: str = "⚡ PERFORMANCE",
) -> None:
"""
Log performance metrics in a clean table.
Args:
metrics: Dictionary of metric names and values
title: Table title
"""
table = Table(title=title, show_header=True, header_style="bold green")
table.add_column("Metric", style="cyan")
table.add_column("Value", justify="right", style="yellow")
table.add_column("Unit", style="dim")
for metric, value in metrics.items():
if isinstance(value, float):
if "duration" in metric.lower() or "time" in metric.lower():
formatted_value = f"{value:.2f}"
unit = "ms" if value < 1000 else "s"
elif "score" in metric.lower() or "percentage" in metric.lower():
formatted_value = f"{value:.1%}"
unit = ""
else:
formatted_value = f"{value:.3f}"
unit = ""
else:
formatted_value = str(value)
unit = ""
table.add_row(metric.replace("_", " ").title(), formatted_value, unit)
console.print(table)
def _extract_observation_text(obs: ObservationType) -> str:
"""Extract text content from various observation types, including premises."""
if isinstance(obs, str):
return obs
elif isinstance(obs, dict):
# Handle dict-based structured observations first
if "conclusion" in obs:
conclusion: str = str(obs["conclusion"])
premises: list[Any] = list(obs.get("premises", []))
if premises:
premises_text = "\n" + "\n".join(f" - {str(p)}" for p in premises)
return f"{conclusion}{premises_text}"
return conclusion
return str(obs.get("content", obs))
else:
# Handle object-based observations
# Use Any type for this branch since we're doing dynamic attribute checking
obj: Any = obs
if hasattr(obj, "conclusion"):
conclusion = str(obj.conclusion)
if hasattr(obj, "premises") and obj.premises:
premises_text = "\n" + "\n".join(
f" - {str(p)}" for p in obj.premises
)
return f"{conclusion}{premises_text}"
return conclusion
elif hasattr(obj, "content"):
return str(obj.content)
else:
return str(obj)

77
src/deriver/prompts.py Normal file
View File

@ -0,0 +1,77 @@
"""
Prompts for the deriver module.
This module contains all prompt templates used by the deriver for critical analysis
and reasoning tasks.
"""
import datetime
from inspect import cleandoc as c
from mirascope import prompt_template
@prompt_template()
def critical_analysis_prompt(
peer_name: str,
message_created_at: datetime.datetime,
context: str,
history: str,
new_turn: str,
) -> str:
"""
Generate the critical analysis prompt for the deriver.
Args:
peer_name: The name of the user being analyzed
message_created_at: Timestamp of the message being analyzed
context: Current user understanding context
history: Recent conversation history
new_turn: New conversation turn to analyze
Returns:
Formatted prompt string for critical analysis
"""
return c(
f"""
You are an agent who critically analyzes user messages through rigorous logical reasoning to produce only conclusions about the user that are CERTAIN. The user's name is **{peer_name}**.
IMPORTANT NAMING RULES
• When you write a conclusion about the current user, always start the sentence with the user's name (e.g. "Anthony is 25 years old").
• NEVER start a conclusion with generic phrases like "The user …" unless the user name is not known.
• If you must reference a third person, use their explicit name, and add clarifiers such as "(third-party)" when confusion is possible.
Your goal is to IMPROVE understanding of the user through careful analysis. Your task is to arrive at truthful, factual conclusions via explicit and deductive reasoning.
Here are strict definitions for the reasoning modes you are to employ:
1. **EXPLICIT REASONING**:
- Conclusions about the user that MUST be true given premises ONLY of the following types:
- Most recent user message
- Knowledge about the conversation history
- Current date and time (which is: {message_created_at})
- Timestamps from conversation history
2. **DEDUCTIVE REASONING**:
- Conclusions about the user that MUST be true given premises ONLY of the following types:
- Explicit conclusions
- Previous deductive conclusions
- General, open domain knowledge known to be true
- Current date and time (which is: {message_created_at})
- Timestamps for user messages, and previous premises and conclusions
Here's the current user understanding
<current_context>
{context}
</current_context>
Recent conversation history for context:
<history>
{history}
</history>
New conversation turn to analyze:
<new_turn>
{new_turn}
</new_turn>
"""
)

View File

@ -1,6 +1,6 @@
import asyncio
import signal
from _asyncio import Task
from asyncio import Task
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@ -60,6 +60,8 @@ class QueueManager:
sentry_sdk.init(
dsn=settings.SENTRY.DSN,
enable_tracing=True,
release=settings.SENTRY.RELEASE,
environment=settings.SENTRY.ENVIRONMENT,
traces_sample_rate=settings.SENTRY.TRACES_SAMPLE_RATE,
profiles_sample_rate=settings.SENTRY.PROFILES_SAMPLE_RATE,
integrations=[AsyncioIntegration()],
@ -146,7 +148,9 @@ class QueueManager:
Returns a list of WorkUnit objects.
"""
# Clean up stale work units
five_minutes_ago = datetime.now(UTC) - timedelta(minutes=5)
five_minutes_ago = datetime.now(UTC) - timedelta(
minutes=settings.DERIVER.STALE_SESSION_TIMEOUT_MINUTES
)
await db.execute(
delete(models.ActiveQueueSession).where(
models.ActiveQueueSession.last_updated < five_minutes_ago
@ -176,7 +180,7 @@ class QueueManager:
)
.where(~models.QueueItem.processed)
.where(
models.ActiveQueueSession.id == None # noqa: E711
models.ActiveQueueSession.id.is_(None)
) # Only work units not in active_queue_sessions
.group_by(
models.QueueItem.session_id,

View File

@ -0,0 +1,92 @@
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
class DeriverQueuePayload(BaseModel):
"""
Schema for validating queue payload data.
sender_name: the peer who sent the message
target_name: the peer who is observing the message -- if this is the same as the sender,
this is a global ("honcho-level") representation task
"""
content: str
workspace_name: str
sender_name: str
target_name: str
session_name: str | None
message_id: int
created_at: datetime
task_type: Literal["representation", "summary"]
model_config = ConfigDict(extra="forbid") # pyright: ignore
@classmethod
def create_payload(
cls,
message: dict[str, Any],
sender_name: str,
target_name: str,
task_type: Literal["representation", "summary"],
) -> dict[str, Any]:
"""
Create a processed payload from a message for queue processing.
Args:
message: The original message dictionary
sender_name: Name of the message sender
target_name: Name of the observer peer
task_type: Type of task ('representation' or 'summary')
Returns:
Processed payload dictionary ready for queue processing
Raises:
ValueError: If the payload doesn't match the expected schema
"""
# Validate required fields and types
if not isinstance(message.get("content"), str):
raise TypeError("Message content must be a string")
if not isinstance(message.get("workspace_name"), str):
raise TypeError("Workspace name must be a string")
# Ensure message_id is an integer
message_id = message.get("message_id")
if not isinstance(message_id, int):
raise TypeError("Message ID must be an integer")
# Ensure created_at exists and is a datetime
if "created_at" not in message:
raise TypeError("created_at is required")
if not isinstance(message["created_at"], datetime):
raise TypeError("created_at must be a datetime object")
# Create the processed payload with properly typed fields
content: str = message["content"]
workspace_name: str = message["workspace_name"]
session_name: str | None = message.get("session_name")
created_at: datetime = message["created_at"]
# Create and validate the payload using the schema
try:
validated_payload = DeriverQueuePayload(
content=content,
workspace_name=workspace_name,
sender_name=sender_name,
target_name=target_name,
session_name=session_name,
message_id=message_id,
created_at=created_at,
task_type=task_type,
)
# Convert back to dict for compatibility with JSON serialization
# mode='json' ensures datetime is converted to ISO string
payload = validated_payload.model_dump(mode='json')
except Exception as e:
raise ValueError(f"Failed to create valid payload: {str(e)}") from e
return payload

View File

@ -1,13 +0,0 @@
# Theory of Mind Inference
[Theory of Mind](https://blog.plasticlabs.ai/blog/Theory-of-Mind-Is-All-You-Need) is a core principle behind Honcho: we believe that enabling AI agents to reason about users' mental states is essential if we want them to successfully act on our behalf.
Honcho currently features three different modules for theory of mind inference:
- `conversational.py`: Inspired by our work on [metanarrative prompting](https://blog.plasticlabs.ai/blog/Agent-Identity). Uses a metanarrative prompt for both ToM inference and generating a user representation.
- `single_prompt.py`: A more conventional and straightforward approach that specifies in a single system prompt what it wants the LLM to output.
- `long_term.py`: Formats a theory of mind inference and a series of long-term facts into a user representation.
The current setup works as follows:
- We extract facts from incoming messages using the code in `src.deriver.consumer`.
- These messages get added to the protected `honcho` user collection using the `CollectionEmbeddingStore` in `src.deriver.tom.embeddings`.
- The dialectic endpoint, in `src.agent`, retrieves long-term facts from this store that are relevant to the query, and runs the ToM inference in `src.deriver.tom.single_prompt` to generate a prediction of the user's short-term mental state.
- The retrieved long-term facts and the short-term ToM inference are combined into a user representation. By default, this is done using a simple f-string, but they can optionally be combined using a separate inference, which would use `src.deriver.tom.long_term`.

View File

@ -1,51 +0,0 @@
from .conversational import (
tom_inference_conversational,
user_representation_conversational,
)
from .long_term import get_user_representation_long_term
from .single_prompt import (
TomInferenceOutput,
UserRepresentationOutput,
)
from .single_prompt import (
tom_inference as tom_inference_single_prompt,
)
from .single_prompt import (
user_representation as user_representation_single_prompt,
)
async def get_tom_inference(
chat_history: str,
user_representation: str = "None",
method: str = "conversational",
) -> TomInferenceOutput:
if method == "conversational":
return await tom_inference_conversational(chat_history, user_representation)
elif method == "single_prompt":
return await tom_inference_single_prompt(chat_history, user_representation)
else:
raise ValueError(f"Invalid method: {method}")
async def get_user_representation(
chat_history: str,
user_representation: str = "None",
tom_inference: str = "None",
method: str = "conversational",
) -> UserRepresentationOutput:
if method == "conversational":
return await user_representation_conversational(
chat_history, user_representation, tom_inference
)
elif method == "single_prompt":
return await user_representation_single_prompt(
chat_history, user_representation, tom_inference
)
elif method == "long_term":
return await get_user_representation_long_term(
chat_history, user_representation, tom_inference
)
else:
raise ValueError(f"Invalid method: {method}")

File diff suppressed because one or more lines are too long

View File

@ -1,117 +0,0 @@
import logging
from typing import Any
from ... import crud, schemas
from ...dependencies import tracked_db
logger = logging.getLogger(__name__)
class CollectionEmbeddingStore:
def __init__(self, workspace_name: str, peer_name: str, collection_name: str):
self.workspace_name: str = workspace_name
self.peer_name: str = peer_name
self.collection_name: str = collection_name
async def save_facts(
self,
facts: list[str],
similarity_threshold: float = 0.85,
message_id: int | None = None,
) -> None:
"""Save facts to the collection.
Args:
facts: List of facts to save
replace_duplicates: If True, replace old duplicates with new facts. If False, discard new duplicates
similarity_threshold: Facts with similarity above this threshold are considered duplicates
"""
async with tracked_db("embedding_store.save_facts") as db:
for fact in facts:
# Create document with duplicate checking
try:
metadata: dict[str, Any] = {}
if message_id is not None:
metadata["message_id"] = message_id
await crud.create_document(
db,
document=schemas.DocumentCreate(
content=fact, metadata=metadata
),
workspace_name=self.workspace_name,
peer_name=self.peer_name,
collection_name=self.collection_name,
duplicate_threshold=1
- similarity_threshold, # Convert similarity to distance
)
except Exception as e:
logger.error(f"Error creating document: {e}")
continue
async def get_relevant_facts(
self, query: str, top_k: int = 5, max_distance: float = 0.3
) -> list[str]:
"""Retrieve the most relevant facts for a given query.
Args:
query: The query text to find relevant facts for
top_k: Maximum number of facts to return
similarity_threshold: Minimum similarity score for a fact to be considered relevant
Returns:
List of facts sorted by relevance
"""
async with tracked_db("embedding_store.get_relevant_facts") as db:
documents = await crud.query_documents(
db,
workspace_name=self.workspace_name,
peer_name=self.peer_name,
collection_name=self.collection_name,
query=query,
max_distance=max_distance,
top_k=top_k,
)
return [doc.content for doc in documents]
async def remove_duplicates(
self, facts: list[str], similarity_threshold: float = 0.85
) -> list[str]:
"""Remove facts that are duplicates of existing facts in the vector store.
Args:
facts: List of facts to check for duplicates
similarity_threshold: Facts with similarity above this threshold are considered duplicates
Returns:
List of facts that are not duplicates of existing facts
"""
unique_facts: list[str] = []
async with tracked_db("embedding_store.remove_duplicates") as db:
for fact in facts:
try:
# Check for duplicates using the crud function
duplicates = await crud.get_duplicate_documents(
db,
workspace_name=self.workspace_name,
peer_name=self.peer_name,
collection_name=self.collection_name,
content=fact,
similarity_threshold=similarity_threshold,
)
if not duplicates:
# No duplicates found, add to unique facts
unique_facts.append(fact)
else:
# Log duplicate found
logger.debug(
f"Duplicate found: {duplicates[0].content}. Ignoring fact: {fact}"
)
except Exception as e:
logger.error(f"Error checking for duplicates: {e}")
# If there's an error, still include the fact to avoid losing information
unique_facts.append(fact)
return unique_facts

View File

@ -1,169 +0,0 @@
import logging
from mirascope import llm
from mirascope.integrations.langfuse import with_langfuse
from pydantic import BaseModel
from src.config import settings
from src.deriver.tom.single_prompt import UserRepresentationOutput
from src.utils.clients import clients
from src.utils.types import track
# Configure logging
logger = logging.getLogger(__name__)
class PotentialSurprise(BaseModel):
content: str
reason: str
confidence_level: float
class UserRepresentation(BaseModel):
current_state: str
tentative_patterns: list[str]
knowledge_gaps: list[str]
expectation_violations: list[PotentialSurprise]
updates: list[str]
class InformationPiece(BaseModel):
quote: str
category: str
explanation: str
semantic_retrieval: str
class InformationExtraction(BaseModel):
pieces: list[InformationPiece]
challenge: str
class FactExtraction(BaseModel):
information_extraction: InformationExtraction
facts: list[str]
@track("User Representation")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.TOM_INFERENCE_PROVIDER
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.TOM_INFERENCE_MODEL,
response_model=UserRepresentationOutput,
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
)
async def get_user_representation_long_term(
chat_history: str,
user_representation: str = "None",
tom_inference: str = "None",
facts: list[str] | None = None,
):
facts_str = "\n".join(f"- {fact}" for fact in facts) if facts else "None available"
return f"""
You are a system for maintaining factual user representations based on conversation history and theory of mind analysis.
Your job is to update the existing user representation (if provided) with the new information from the conversation history and theory of mind analysis.
REQUIREMENTS:
1. Distinguish between temporary states and persistent patterns
2. Only incorporate verified information into core profile
3. Track certainty levels for all information
4. Maintain areas of uncertainty explicitly
5. Update representation incrementally
6. Use the persistent facts provided to inform your understanding of the user
OUTPUT FORMAT:
current_state: str
- Active Context: Current situation/activity
- Temporary Conditions: Immediate circumstances
- Present Mood/Activity: What user is doing right now
tentative_patterns: list[str]
- Possible Traits: Mark confidence (Low/Medium/High)
- Potential Interests: Need more evidence
- Speculative Elements: Clearly marked as unconfirmed
knowledge_gaps: list[str]
- List key missing information
- Note areas needing clarification
expectation_violations: list
content: str
reason: str
confidence_level: float
- Based on the above information, if the next message were to surprise you, what could it contain?
- Include 3-5 possible surprises
updates: list[str]
- New Information: Recent observations
- Changes: Modified interpretations
- Removals: Information no longer supported
CONVERSATION:
{chat_history}
PREDICTION OF USER MENTAL STATE - MIGHT BE INCORRECT:
{tom_inference or "Doesn't exist"}
EXISTING USER REPRESENTATION - INCOMPLETE, TO BE UPDATED:
{user_representation or "Doesn't exist"}
PERSISTENT FACTS ABOUT USER:
{facts_str}
"""
@track("Fact Extraction")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.TOM_INFERENCE_PROVIDER
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.TOM_INFERENCE_MODEL,
response_model=FactExtraction,
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
)
async def extract_facts_long_term(chat_history: str):
return f"""
You are an AI assistant specialized in extracting and formatting relevant information about users from conversations. Your task is to analyze a given conversation and create a list of concise, factual statements about the user. These statements will be stored in a vector embedding database to enhance future interactions.
Here is the conversation you need to analyze:
<conversation>
{chat_history}
</conversation>
Instructions:
1. Carefully read through the conversation. Extract only new facts, from only the last message sent by the user - treat the rest of the conversation only as context. Ignore facts in the last message that are already stated in the conversation.
2. Identify key new pieces of information from the last message sent by the user that would be valuable for future interactions. Look for:
- Personal details (name, age, occupation, location, etc.)
- Preferences (likes, dislikes, interests, hobbies)
- Experiences (travel, education, work history)
- Expressive style (writing style, tone, etc.)
- Relationships (family, friends, pets)
- Goals or aspirations
- Challenges or problems they're facing
- Opinions or beliefs
3. For each piece of information you identify:
a. Verify that it is factual and explicitly stated in the conversation, not inferred.
b. Formulate it as a concise statement that would aid in semantic retrieval.
c. Ensure it is not similar to information previously stated in the conversation.
4. Before providing your final output, wrap your analysis in information_extraction. In this analysis:
- List each piece of information you've identified.
- For each piece of information:
* Quote the relevant part of the conversation.
* Categorize the information (e.g., personal detail, preference, experience).
* Explain why you've included this information.
* Show how you've formulated the fact for optimal semantic retrieval.
- Discuss any challenges you encountered in extracting or formatting the information.
5. After your analysis, provide your final output as a list of strings. Each string should be a single fact about the user.
Remember to focus on clear, concise statements that capture key information about the user. Each fact should be worded in a way that will aid its semantic retrieval from a vector embedding database.
"""

View File

@ -1,238 +0,0 @@
import logging
from enum import Enum
from mirascope import llm
from mirascope.integrations.langfuse import with_langfuse
from pydantic import BaseModel
from src.config import settings
from src.utils.clients import clients
from src.utils.types import track
logger = logging.getLogger(__name__)
# Enums for strongly typed fields
class InfoType(str, Enum):
STYLE = "STYLE"
STATEMENT = "STATEMENT"
class CertaintyLevel(str, Enum):
LIKELY = "LIKELY"
POTENTIAL = "POTENTIAL"
SPECULATIVE = "SPECULATIVE"
# ToM Inference Output Models
class CurrentState(BaseModel):
immediate_context: str
active_goals: str
present_mood: str
class SupportedObservation(BaseModel):
detail: str
source: str
class TentativeInference(BaseModel):
interpretation: str
basis: str
class KnowledgeGap(BaseModel):
topic: str
class ExpectationViolation(BaseModel):
possible_surprise: str
reason: str
confidence_level: float
class TomInferenceOutput(BaseModel):
current_state: CurrentState
tentative_inferences: list[TentativeInference]
knowledge_gaps: list[KnowledgeGap]
expectation_violations: list[ExpectationViolation]
# User Representation Output Models
class SourcedInfo(BaseModel):
detail: str
source: str
class UserCurrentState(BaseModel):
active_context: SourcedInfo
temporary_conditions: SourcedInfo
present_mood_activity: SourcedInfo
class PersistentInfo(BaseModel):
detail: str
source: str
info_type: InfoType
class TentativePattern(BaseModel):
pattern: str
source: str
certainty_level: CertaintyLevel
class UserKnowledgeGap(BaseModel):
missing_info: str
class UserExpectationViolation(BaseModel):
potential_surprise: str
reason: str
confidence_level: float
class UpdateSection(BaseModel):
new_information: list[SourcedInfo]
changes: list[SourcedInfo]
removals: list[SourcedInfo]
class UserRepresentationOutput(BaseModel):
current_state: UserCurrentState
persistent_information: list[PersistentInfo]
tentative_patterns: list[TentativePattern]
knowledge_gaps: list[UserKnowledgeGap]
expectation_violations: list[UserExpectationViolation]
updates: UpdateSection
@track("Tom Inference")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.TOM_INFERENCE_PROVIDER
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.TOM_INFERENCE_MODEL,
response_model=TomInferenceOutput,
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
)
async def tom_inference(
chat_history: str,
user_representation: str | None = None,
):
return f"""
You are a system for analyzing conversations to make evidence-based inferences about user mental states.
REQUIREMENTS:
1. Only make inferences that are directly supported by conversation evidence
2. For each inference, cite the specific message that supports it
3. Use uncertainty qualifiers (may, might, possibly) for speculative inferences
4. Do not make assumptions about demographics unless explicitly stated
5. Focus on current mental state and immediate context
6. Consider your own knowledge gaps and violations of expectations (what would surprise you)
OUTPUT FORMAT:
current_state:
- immediate_context: User's current situation
- active_goals: What user is trying to achieve
- present_mood: Observable emotional state
tentative_inferences: list of objects with:
- interpretation: Possible but uncertain interpretation
- basis: Supporting message or evidence
knowledge_gaps: list of objects with:
- topic: Important unknown information or question
expectation_violations: list of objects with:
- possible_surprise: What content could surprise you in the next message
- reason: Why this would be surprising based on current information
- confidence_level: Float between 0.0 and 1.0 indicating confidence
- Include 3-5 possible surprises
<conversation>
{chat_history or "Not provided"}
</conversation>
<user_representation>
{user_representation or "Not provided"}
</user_representation>
"""
@track("User Representation")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.TOM_INFERENCE_PROVIDER
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.TOM_INFERENCE_MODEL,
response_model=UserRepresentationOutput,
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
)
async def user_representation(
chat_history: str,
user_representation: str | None = None,
tom_inference: str | None = None,
):
return f"""
You are a system for maintaining factual user representations based on conversation history and theory of mind analysis.
Your job is to update the existing user representation (if provided) with the new information from the conversation history and theory of mind analysis.
Copy over information as-is from the existing user representation. Add new information as needed. Only remove content from this section if new information contradicts it. This is especially important for Persistent Information and Tentative Patterns.
REQUIREMENTS:
1. Distinguish between temporary states and persistent patterns
2. Only incorporate verified information into core profile
3. Track certainty levels for all information
4. Maintain areas of uncertainty explicitly
5. Update representation incrementally
OUTPUT FORMAT:
current_state:
- active_context: object with "detail" (current situation/activity/location) and "source" (exact message)
- temporary_conditions: object with "detail" (immediate circumstances) and "source" (exact message)
- present_mood_activity: object with "detail" (what user is doing right now) and "source" (exact message)
persistent_information: list of objects with:
- detail: The specific information or pattern
- source: Exact message that supports this
- info_type: Must be exactly "STYLE" for communication patterns or "STATEMENT" for explicit facts
tentative_patterns: list of objects with:
- pattern: The observed pattern
- source: Supporting evidence from specific message
- certainty_level: Must be exactly "LIKELY" (almost certain), "POTENTIAL" (possible), or "SPECULATIVE" (uncertain)
knowledge_gaps: list of objects with:
- missing_info: Key information that is missing or needs clarification
expectation_violations: list of objects with:
- potential_surprise: What could surprise you in the next message
- reason: Why this would be surprising based on current information
- confidence_level: Float between 0.0 and 1.0
- Include 3-5 possible surprises
updates:
- new_information: List of objects with "detail" (recent observation) and "source" (supporting message)
- changes: List of objects with "detail" (modified interpretation) and "source" (supporting message)
- removals: List of objects with "detail" (information no longer supported) and "source" (contradicting message)
<conversation>
{chat_history or "Not provided"}
</conversation>
<existing_user_representation>
{user_representation or "Not provided"}
</existing_user_representation>
<tom_analysis>
{tom_inference or "Not provided"}
</tom_analysis>
"""

View File

@ -0,0 +1,3 @@
from .chat import chat
__all__ = ["chat"]

246
src/dialectic/chat.py Normal file
View File

@ -0,0 +1,246 @@
"""
Main dialectic system for AI-powered context synthesis and user representation.
The Dialectic class provides a natural language API for AI applications to query
and understand users through context synthesis of working representations and
historical observations.
"""
import asyncio
import logging
from dotenv import load_dotenv
from langfuse.decorators import langfuse_context
from mirascope.llm import Stream
from src import crud
from src.config import settings
from src.dependencies import tracked_db
from src.utils.clients import honcho_llm_call
from src.utils.embedding_store import EmbeddingStore
from .prompts import dialectic_prompt
from .utils import get_observations
# Configure logging
logger = logging.getLogger(__name__)
# Load environment variables
load_dotenv()
@honcho_llm_call(
provider=settings.DIALECTIC.PROVIDER,
model=settings.DIALECTIC.MODEL,
track_name="Dialectic Call",
max_tokens=settings.DIALECTIC.MAX_OUTPUT_TOKENS,
thinking_budget_tokens=settings.DIALECTIC.THINKING_BUDGET_TOKENS
if settings.DIALECTIC.PROVIDER == "anthropic"
else None,
enable_retry=True,
retry_attempts=3,
)
async def dialectic_call(
query: str,
working_representation: str,
additional_context: str | None,
peer_name: str,
):
"""
Make a direct call to the dialectic model for context synthesis.
Args:
query: The user query
working_representation: Current session conclusions
additional_context: Historical context from semantic search
peer_name: Name of the user/peer
Returns:
Model response
"""
# Generate the prompt and log it
prompt_result = dialectic_prompt(
query, working_representation, additional_context, peer_name
)
# Pretty print the prompt content
if len(prompt_result) > 0:
# Extract content from the first BaseMessageParam
prompt_content = prompt_result[0].content
else:
prompt_content = str(prompt_result)
logger.debug("=== DIALECTIC PROMPT ===")
logger.debug(prompt_content)
logger.debug("=== END DIALECTIC PROMPT ===")
return prompt_result
@honcho_llm_call(
provider=settings.DIALECTIC.PROVIDER,
model=settings.DIALECTIC.MODEL,
track_name="Dialectic Stream",
max_tokens=settings.DIALECTIC.MAX_OUTPUT_TOKENS,
thinking_budget_tokens=settings.DIALECTIC.THINKING_BUDGET_TOKENS
if settings.DIALECTIC.PROVIDER == "anthropic"
else None,
enable_retry=True,
retry_attempts=3,
stream=True,
)
async def dialectic_stream(
query: str,
working_representation: str,
additional_context: str | None,
peer_name: str,
):
"""
Make a streaming call to the dialectic model for context synthesis.
Args:
query: The user query
working_representation: Current session conclusions
additional_context: Historical context from semantic search
peer_name: Name of the user/peer
Returns:
Streaming model response
"""
# Generate the prompt and log it
prompt_result = dialectic_prompt(
query, working_representation, additional_context, peer_name
)
# Pretty print the prompt content
if len(prompt_result) > 0:
# Extract content from the first BaseMessageParam
prompt_content = prompt_result[0].content
else:
prompt_content = str(prompt_result)
logger.debug("=== DIALECTIC PROMPT (STREAM) ===")
logger.debug(prompt_content)
logger.debug("=== END DIALECTIC PROMPT ===")
return prompt_result
async def chat(
workspace_name: str,
peer_name: str,
target_name: str | None,
session_name: str | None,
query: str,
*,
stream: bool = False,
) -> Stream | str:
"""
Chat with the Dialectic API that builds on-demand user representations.
Steps:
1. Get working representation from deriver trace
2. Retrieve additional relevant context via semantic search
3. (New) Append observations from latest deriver trace into that context
4. Call Dialectic to synthesize an answer
Args:
workspace_name: Name of the workspace
peer_name: Name of the peer making the query
target_name: Optional name of the peer being queried about
session_name: Optional session name for scoping
query: Input Dialectic Query
stream: Whether to stream the response
Returns:
Dialectic response (streaming or complete)
"""
langfuse_context.update_current_trace(
metadata={
"query_generation_model": settings.DIALECTIC.QUERY_GENERATION_MODEL,
"query_generation_provider": settings.DIALECTIC.QUERY_GENERATION_PROVIDER,
"dialectic_model": settings.DIALECTIC.MODEL,
}
)
logger.debug(f"Received query: {query} for session {session_name}")
start_time = asyncio.get_event_loop().time()
# 1. Working representation (short-term) -----------------------------------
# Only useful for session-scoped queries, not global queries
if session_name:
async with tracked_db("chat.get_working_representation") as db:
# If no target specified, get global representation (peer observing themselves)
target_peer = target_name if target_name is not None else peer_name
working_representation = await crud.get_working_representation(
db, workspace_name, peer_name, target_peer, session_name
)
else:
# For global queries, working representation isn't useful - use historical context instead
working_representation = ""
logger.debug(f"Working representation length: {len(working_representation)}")
# 2. Additional context (long-term semantic search) ------------------------
# If the query is globally-scoped but not targeted, get global_representation facts from other sessions
# If the query is globally-scoped and targeted, get facts from other sessions for our target
# If the query is session-scoped but not targeted, skip this step
# If the query is session-scoped and targeted, get facts from *only* this session for our target
if not session_name:
async with tracked_db("chat.get_additional_context") as db:
embedding_store = EmbeddingStore(
workspace_name=workspace_name,
peer_name=target_name if target_name else peer_name,
collection_name="global_representation"
if not target_name
else crud.construct_collection_name(
observer=peer_name, observed=target_name
),
)
additional_context = await get_observations(
query,
embedding_store,
include_premises=True,
exclude_session_name=session_name if not target_name else None,
peer_name=peer_name,
)
logger.debug(
f"Retrieved additional context: {len(additional_context)} characters"
)
else:
if not target_name:
additional_context = None
else:
async with tracked_db("chat.get_additional_context") as db:
embedding_store = EmbeddingStore(
workspace_name=workspace_name,
peer_name=target_name,
collection_name=crud.construct_collection_name(
observer=peer_name, observed=target_name
),
)
additional_context = await get_observations(
query,
embedding_store,
include_premises=True,
include_session_name=session_name,
peer_name=peer_name,
)
logger.debug(
f"Retrieved additional context: {len(additional_context)} characters"
)
# 3. Dialectic call --------------------------------------------------------
if stream:
return await dialectic_stream(
query, working_representation, additional_context, peer_name
)
response = await dialectic_call(
query, working_representation, additional_context, peer_name
)
elapsed = asyncio.get_event_loop().time() - start_time
logger.debug(f"Dialectic answered in {elapsed:.2f}s")
# Convert AnthropicCallResponse to string for compatibility
return str(response)

107
src/dialectic/prompts.py Normal file
View File

@ -0,0 +1,107 @@
from inspect import cleandoc as c
from mirascope import prompt_template
@prompt_template()
def dialectic_prompt(
query: str,
working_representation: str,
additional_context: str | None,
peer_name: str,
) -> str:
"""
Generate the main dialectic prompt for context synthesis.
Args:
query: The specific question or request from the application about the user
working_representation: Current session conclusions from recent conversation analysis
additional_context: Historical conclusions from the user's global representation
peer_name: Name of the user/peer being queried about
Returns:
Formatted prompt string for the dialectic model
"""
return c(
f"""
The query is about user {peer_name}.
You are a context synthesis agent that operates as a natural language API for AI applications. Your role is to analyze application queries about users and synthesize relevant conclusions into coherent, actionable insights that directly address what the application needs to know.
## INPUT STRUCTURE
You receive three key inputs:
- **Query**: The specific question or request from the application about this user
- **Working Representation**: Current session conclusions from recent conversation analysis
- **Additional Context**: Historical conclusions from the user's global representation
Each conclusion contains:
- **Conclusion**: The derived insight
- **Premises**: Supporting evidence/reasoning
- **Type**: Either Explicit or Deductive
- **Temporal Data**: When conclusions were made
## OUTPUT FORMAT
Provide a natural language response that:
1. Directly answers the application's query
2. Provides most useful context based on available conclusions
3. References the reasoning types and evidence strength when relevant
4. Maintains appropriate confidence levels based on conclusion types
5. Flags any limitations or gaps in available information
<query>{query}</query>
<working_representation>{working_representation}</working_representation>
{f"<global_context>{additional_context}</global_context>" if additional_context else ""}"""
)
@prompt_template()
def query_generation_prompt(query: str, peer_name: str) -> str:
"""
Generate the prompt for semantic query expansion.
Args:
query: The original user query
peer_name: Name of the user/peer
Returns:
Formatted prompt string for query generation
"""
return c(
f"""
You are a query expansion agent helping AI applications understand their users. The user's name is {peer_name}. Your job is to take application queries about this user and generate targeted search queries that will retrieve the most relevant observations using semantic search over an embedding store containing observations about the user.
## QUERY EXPANSION STRATEGY FOR SEMANTIC SIMILARITY
**Your Goal**: Generate 3-5 complementary search queries optimized for semantic similarity retrieval, that together will surface the most relevant observations to help answer the application's question.
**Semantic Similarity Optimization**:
1. **Analyze the Application Query**: What specific aspect of the user does the application want to understand?
2. **Think Conceptually**: What concepts, themes, and semantic fields relate to this question?
3. **Consider Language Patterns in Stored Observations**: Loosely match the structure of the observations we aim to retrieve - "[subject] [verb] [predicate] [additional context]" (e.g. "Mary went ice-skating with Peter and Lin on June 5th 2024", "John activities summer outdoors")
4. **Vary Semantic Scope** across the generated queries to ensure maximum coverage.
5. Ensure the queries are different enough to not be redundant.
**Vocabulary Expansion Techniques**:
- **Synonyms**: feedback/criticism/advice/suggestions/input/guidance
- **Related Actions**: receiving/getting/handling/processing/responding/reacting
- **Emotional Language**: sensitive/defensive/receptive/open/resistant/welcoming
- **Contextual Terms**: workplace/professional/personal/relationship/dynamic/interaction
- **Intensity Variations**: harsh/gentle/direct/subtle/constructive/blunt
- **Outcome Language**: improvement/growth/learning/development/change
**Remember**: Since observations come from natural conversations, use the vocabulary people actually use when discussing these topics, including casual language, emotional descriptors, and situational context.
## OUTPUT FORMAT
Respond with 3-5 search queries as a JSON object with a "queries" field containing an array of strings. Each query should target different aspects or reasoning levels to maximize retrieval coverage.
Format: `{{"queries": ["query1", "query2", "query3"]}}`
No markdown, no explanations, just the JSON object.
<query>{query}</query>
"""
)

310
src/dialectic/utils.py Normal file
View File

@ -0,0 +1,310 @@
import asyncio
import json
import logging
from typing import Any
from langfuse.decorators import langfuse_context, observe # pyright: ignore
from src.config import settings
from src.models import Document
from src.utils.clients import honcho_llm_call
from src.utils.embedding_store import EmbeddingStore
from src.utils.formatting import (
format_premises_for_display,
)
from src.utils.shared_models import SemanticQueries
from .prompts import query_generation_prompt
# Configure logging
logger = logging.getLogger(__name__)
@observe()
async def get_observations(
query: str,
embedding_store: EmbeddingStore,
*,
include_premises: bool = False,
exclude_session_name: str | None = None,
include_session_name: str | None = None,
peer_name: str | None = None,
) -> str:
"""
Generate queries based on the dialectic query and retrieve relevant observations.
Uses semantic search to find additional relevant historical context beyond
what's already in the working representation.
Args:
query: The user query
embedding_store: The embedding store to search
include_premises: Whether to include premises from document metadata
exclude_session_name: Current session name to exclude from results
include_session_name: Current session name to exclusively include in results
Returns:
String containing additional relevant observations from semantic search
"""
logger.info("Starting observation retrieval for query: %s", query)
logger.info("exclude_session_name: %s", exclude_session_name)
logger.info("include_session_name: %s", include_session_name)
# Generate search queries with multiple fallback strategies
search_queries_result = None
logger.debug(
"Attempting to generate semantic queries using %s",
settings.DIALECTIC.QUERY_GENERATION_PROVIDER,
)
search_queries_result = await generate_semantic_queries(query, peer_name)
logger.debug(
"Successfully generated queries via %s: %s",
settings.DIALECTIC.QUERY_GENERATION_PROVIDER,
search_queries_result,
)
# search_queries_result should never be None based on function return types
search_queries = search_queries_result.queries
# Include the original query in the search queries
search_queries.append(query)
logger.info(
"Generated %s search queries: \n%s",
len(search_queries),
json.dumps(search_queries, indent=2),
)
# Execute all queries in parallel
tasks = [_execute_single_query(q, embedding_store) for q in search_queries]
all_results = await asyncio.gather(*tasks)
# Combine and deduplicate results
unique_observations = _deduplicate_observations(all_results)
langfuse_context.update_current_observation(
input={
"query": query,
"include_premises": include_premises,
},
output={
"search_queries": search_queries_result,
"all_results": all_results,
"unique_observations": unique_observations,
},
)
langfuse_context.update_current_trace(
metadata={
"search_queries": search_queries,
"observations_retrieved": unique_observations,
}
)
logger.info(
"Retrieved %s unique observations before filtering", len(unique_observations)
)
# Filter out current session observations to get only historical context
original_count = len(unique_observations)
if exclude_session_name:
filtered_observations = _filter_current_session_observations(
unique_observations, exclude_session_name
)
unique_observations = filtered_observations
elif include_session_name:
filtered_observations = _filter_all_but_current_session_observations(
unique_observations, include_session_name
)
unique_observations = filtered_observations
else:
filtered_observations = unique_observations
logger.info(
"After session filtering: %s observations (removed %s observations)",
len(unique_observations),
original_count - len(unique_observations),
)
# Format observations
if not unique_observations:
logger.info("No unique historical observations found after filtering")
return "No additional relevant context found."
# Log a summary of what was retrieved
logger.info(
f"Final retrieval summary: {len(unique_observations)} observations retrieved across search queries: \n{json.dumps(unique_observations, indent=2)}"
)
return _format_observations(unique_observations, include_premises=include_premises)
async def _execute_single_query(
query: str, embedding_store: EmbeddingStore
) -> list[tuple[str, str, dict[str, Any]]]:
"""Execute a single semantic search query and return formatted results."""
documents: list[Document] = await embedding_store.get_relevant_observations(
query,
top_k=settings.DIALECTIC.SEMANTIC_SEARCH_TOP_K,
max_distance=settings.DIALECTIC.SEMANTIC_SEARCH_MAX_DISTANCE,
for_reasoning=False,
)
# Extract data to avoid DetachedInstanceError
return [
(
doc.content,
doc.created_at.strftime("%Y-%m-%d-%H:%M:%S"),
doc.internal_metadata or {},
)
for doc in documents
]
def _deduplicate_observations(
all_results: list[list[tuple[str, str, dict[str, Any]]]],
) -> list[tuple[str, str, dict[str, Any]]]:
"""Deduplicate observations based on content."""
unique_observations: list[tuple[str, str, dict[str, Any]]] = []
seen_content: set[str] = set()
for results in all_results:
for content, timestamp, metadata in results:
if content not in seen_content:
unique_observations.append((content, timestamp, metadata))
seen_content.add(content)
return unique_observations
def _format_observations(
observations: list[tuple[str, str, dict[str, Any]]], *, include_premises: bool
) -> str:
"""Format observations grouped by level and date, including access metadata."""
grouped: dict[str, dict[str, list[str]]] = {}
for content, timestamp, metadata in observations:
level: str = metadata.get("level", "unknown")
date_str: str = timestamp[:10] # Extract YYYY-MM-DD
if level not in grouped:
grouped[level] = {}
if date_str not in grouped[level]:
grouped[level][date_str] = []
# Build formatted content with premises and access metadata
formatted_content: str = content
# Add premises if requested and available
if include_premises and metadata.get("premises"):
premises_text: str = format_premises_for_display(metadata["premises"])
formatted_content = f"{content}{premises_text}"
# Prefix with full timestamp for clarity
if timestamp:
formatted_content = f"{timestamp}: {formatted_content}"
# Add access metadata if available
access_parts: list[str] = []
access_count: int = metadata.get("access_count", 0)
last_accessed: Any = metadata.get("last_accessed")
if access_count > 0:
access_parts.append(f"accessed {access_count}x")
if last_accessed:
# Format the last_accessed datetime for display
try:
from datetime import datetime
if isinstance(last_accessed, str):
# Parse ISO format datetime string
dt = datetime.fromisoformat(last_accessed.replace("Z", "+00:00"))
formatted_last_accessed: str = dt.strftime("%Y-%m-%d %H:%M")
access_parts.append(f"last accessed {formatted_last_accessed}")
except (ValueError, AttributeError):
# If parsing fails, just show the raw value
access_parts.append(f"last accessed {last_accessed}")
# Append access metadata to the formatted content
if access_parts:
access_info: str = ", ".join(access_parts)
formatted_content = f"{formatted_content} [{access_info}]"
grouped[level][date_str].append(formatted_content)
# Build output
parts: list[str] = []
for level in sorted(grouped.keys()):
header: str = (
f"\n{level.upper()} OBSERVATIONS:"
if level != "unknown"
else "\nOBSERVATIONS:"
)
parts.append(header)
for date_str in sorted(
grouped[level].keys(), reverse=True
): # Most recent first
parts.append(f"\n{date_str}:")
for obs in grouped[level][date_str]:
parts.append(f" • {obs}")
return "\n".join(parts).strip()
@honcho_llm_call(
provider=settings.DIALECTIC.QUERY_GENERATION_PROVIDER,
model=settings.DIALECTIC.QUERY_GENERATION_MODEL,
response_model=SemanticQueries,
enable_retry=True,
retry_attempts=3,
)
async def generate_semantic_queries(query: str, peer_name: str | None = None):
"""Generate semantic search queries for observation retrieval."""
return query_generation_prompt(query, peer_name or "")
def _filter_current_session_observations(
observations: list[tuple[str, str, dict[str, Any]]], session_name: str
) -> list[tuple[str, str, dict[str, Any]]]:
"""Filter out observations from the current session."""
filtered: list[tuple[str, str, dict[str, Any]]] = []
current_session_count: int = 0
for content, timestamp, metadata in observations:
obs_session_name: str | None = metadata.get(
"session_name"
) # Changed from session_id to session_name
if obs_session_name != session_name:
filtered.append((content, timestamp, metadata))
else:
current_session_count += 1
logger.debug(
"Filtered out current session observation: %s...", content[:50]
)
if current_session_count > 0:
logger.info(
"Filtered out %s observations from current session %s",
current_session_count,
session_name,
)
return filtered
def _filter_all_but_current_session_observations(
observations: list[tuple[str, str, dict[str, Any]]], session_name: str
) -> list[tuple[str, str, dict[str, Any]]]:
"""Filter to keep only observations from the current session."""
filtered: list[tuple[str, str, dict[str, Any]]] = []
for content, timestamp, metadata in observations:
obs_session_name: str | None = metadata.get(
"session_name"
) # Changed from session_id to session_name
if obs_session_name == session_name:
filtered.append((content, timestamp, metadata))
return filtered

View File

@ -31,9 +31,9 @@ class EmbeddingClient:
raise ValueError("API key is required")
self.client: AsyncOpenAI = AsyncOpenAI(api_key=api_key)
self.encoding: tiktoken.Encoding = tiktoken.get_encoding("cl100k_base")
self.max_embedding_tokens: int = settings.LLM.MAX_EMBEDDING_TOKENS
self.max_embedding_tokens: int = settings.MAX_EMBEDDING_TOKENS
self.max_embedding_tokens_per_request: int = (
settings.LLM.MAX_EMBEDDING_TOKENS_PER_REQUEST
settings.MAX_EMBEDDING_TOKENS_PER_REQUEST
)
async def embed(self, query: str) -> list[float]:
@ -226,3 +226,7 @@ def _chunk_text_with_tokens(
for i in range(0, len(encoded_tokens), step_size)
if i < len(encoded_tokens) # Ensure we don't create empty chunks
]
# Shared embedding client instance
embedding_client = EmbeddingClient(settings.LLM.OPENAI_API_KEY)

View File

@ -71,3 +71,21 @@ class FilterError(HonchoException):
status_code = 422
detail = "Invalid filter configuration"
@final
class UnsupportedFileTypeError(HonchoException):
status_code = 415
detail = "Unsupported file type"
@final
class FileTooLargeError(HonchoException):
status_code = 413
detail = "File too large"
@final
class FileProcessingError(HonchoException):
status_code = 500
detail = "File processing error"

View File

@ -7,11 +7,9 @@ from typing import TYPE_CHECKING
import sentry_sdk
from fastapi import FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi_pagination import add_pagination
from pydantic import ValidationError
if TYPE_CHECKING:
from sentry_sdk._types import Event, Hint
@ -73,10 +71,8 @@ if SENTRY_ENABLED:
def before_send(event: "Event", hint: "Hint") -> "Event | None":
if "exc_info" in hint:
_, exc_value, _ = hint["exc_info"]
# Filter out exceptions that shouldn't be sent to Sentry
if isinstance(
exc_value, HonchoException | ValidationError | RequestValidationError
):
# Filter out HonchoExceptions from being sent to Sentry
if isinstance(exc_value, HonchoException):
return None
return event
@ -119,7 +115,7 @@ app = FastAPI(
title="Honcho API",
summary="The Identity Layer for the Agentic World",
description="""Honcho is a platform for giving agents user-centric memory and social cognition""",
version="2.0.5",
version="2.1.0",
contact={
"name": "Plastic Labs",
"url": "https://honcho.dev",

View File

@ -177,6 +177,11 @@ class Message(Base):
public_id: Mapped[str] = mapped_column(
TEXT, index=True, unique=True, default=generate_nanoid
)
# NOTE: Messages in Honcho 2.0 could historically be stored outside of a session.
# This is no longer the case, so in the future `session_name` will be required.
# Peer-level search will be able to retrieve any message with peer as author and
# derived facts are retained, so these messages are not abandoned. A future migration
# may assign them all to a default session of some kind.
session_name: Mapped[str | None] = mapped_column(index=True, nullable=True)
content: Mapped[str] = mapped_column(TEXT)
h_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict)

View File

@ -1,18 +1,28 @@
import logging
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Path, Query
from fastapi import (
APIRouter,
BackgroundTasks,
Body,
Depends,
File,
Form,
Path,
Query,
UploadFile,
)
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import insert
from sqlalchemy.orm.attributes import flag_modified
from src import crud, schemas
from src.config import settings
from src.dependencies import db, tracked_db
from src.exceptions import ResourceNotFoundException
from src.models import QueueItem
from src.dependencies import db
from src.deriver import enqueue
from src.exceptions import FileTooLargeError, ResourceNotFoundException
from src.security import require_auth
from src.utils.files import process_file_uploads_for_messages
logger = logging.getLogger(__name__)
@ -25,286 +35,113 @@ router = APIRouter(
)
def create_processed_payload(
message: dict[str, Any],
sender_name: str | None,
target_name: str | None,
task_type: str,
) -> dict[str, Any]:
"""
Create a processed payload from a message for queue processing.
Args:
message: The original message dictionary
sender_name: Name of the message sender
target_name: Name of the target peer
task_type: Type of task ('representation' or 'summary')
Returns:
Processed payload dictionary ready for queue processing
"""
processed_payload = {
k: str(v) if isinstance(v, str) else v for k, v in message.items()
}
# Remove peer_name from payload
processed_payload.pop("peer_name", None) # Use None as default to avoid KeyError
processed_payload["sender_name"] = sender_name
processed_payload["target_name"] = target_name
processed_payload["task_type"] = task_type
return processed_payload
async def enqueue(payload: list[dict[str, Any]]):
"""
Add message(s) to the deriver queue for processing.
Args:
payload: Single message payload or list of message payloads
"""
# Use the get_db dependency to ensure proper transaction handling
async with tracked_db("message_enqueue") as db_session:
try:
# Determine if batch or single processing
if not payload: # Empty list check
logger.debug("Empty payload list, skipping enqueue")
return
logger.debug(f"Enqueueing batch of {len(payload)} messages")
workspace_name = payload[0]["workspace_name"]
session_name = payload[0]["session_name"]
# Case 1: session_name is None — only create representation for peer
if session_name is None:
peer_name = payload[0]["peer_name"]
logger.info(
"Session name is None, creating single representation queue items"
)
peer = await crud.get_or_create_peers(
db_session,
workspace_name=workspace_name,
peers=[schemas.PeerCreate(name=peer_name)],
)
peer = peer[0]
# Cast configuration to PeerConfig and check observe_me
peer_config = (
schemas.PeerConfig(**peer.configuration)
if peer.configuration
else schemas.PeerConfig()
)
if not peer_config.observe_me:
logger.info(
f"Peer {peer_name} has observe_me=False, skipping enqueue"
)
return
queue_records: list[dict[str, Any]] = []
for message in payload:
processed_payload = create_processed_payload(
message=message,
sender_name=message["peer_name"],
target_name=message["peer_name"],
task_type="representation",
)
queue_records.append(
{
"payload": processed_payload,
"session_id": None,
}
)
logger.debug(
f"Inserting {len(queue_records)} queue records for None session"
)
stmt = insert(QueueItem).returning(QueueItem)
await db_session.execute(stmt, queue_records)
await db_session.commit()
logger.info(
f"Successfully enqueued {len(queue_records)} messages with None session"
)
return
# Case 2: Normal session processing
session = await crud.get_or_create_session(
db_session,
session=schemas.SessionCreate(name=session_name),
workspace_name=workspace_name,
)
# Check if deriver is disabled for this session
deriver_disabled = (
session.configuration.get("deriver_disabled") is not None
and session.configuration.get("deriver_disabled") is not False
)
configuration_query = await crud.get_session_peer_configuration(
workspace_name=workspace_name, session_name=session_name
)
peers_with_configuration_result = await db_session.execute(
configuration_query
)
peers_with_configuration_list = peers_with_configuration_result.all()
peers_with_configuration = {
row.peer_name: [row.peer_configuration, row.session_peer_configuration]
for row in peers_with_configuration_list
}
# Process all payloads - create multiple queue items per message
queue_records = []
for message in payload:
if deriver_disabled:
# still create a summary queue item for the session
processed_payload = create_processed_payload(
message=message,
sender_name=None,
target_name=None,
task_type="summary",
)
queue_records.append(
{
"payload": processed_payload,
"session_id": session.id,
}
)
continue
sender_name = message["peer_name"]
sender_session_peer_config = (
schemas.SessionPeerConfig(
**peers_with_configuration[sender_name][1]
)
if peers_with_configuration[sender_name][1]
else None
)
sender_peer_config = (
schemas.PeerConfig(**peers_with_configuration[sender_name][0])
if peers_with_configuration[sender_name][0]
else schemas.PeerConfig()
)
observe_me = (
(
sender_session_peer_config.observe_me
if sender_session_peer_config.observe_me is not None
else sender_peer_config.observe_me
)
if sender_session_peer_config
else sender_peer_config.observe_me
)
if not observe_me:
continue
# Handle working representation for sender
processed_payload = create_processed_payload(
message=message,
sender_name=sender_name,
target_name=sender_name,
task_type="representation",
)
queue_records.append(
{
"payload": processed_payload,
"session_id": session.id,
}
)
for peer_name, configuration in peers_with_configuration.items():
session_peer_config = (
schemas.SessionPeerConfig(**configuration[1])
if configuration[1]
else None
)
if peer_name != sender_name:
# Handle local representation for other peers
if (
session_peer_config is None
or not session_peer_config.observe_others
):
continue
else:
# Create local representation for peer
processed_payload = create_processed_payload(
message=message,
sender_name=sender_name,
target_name=peer_name,
task_type="representation",
)
queue_records.append(
{
"payload": processed_payload,
"session_id": session.id,
}
)
logger.debug(f"Inserting {len(queue_records)} queue records")
if len(queue_records) > 0:
# Use insert to maintain order
stmt = insert(QueueItem).returning(QueueItem)
await db_session.execute(stmt, queue_records)
await db_session.commit()
logger.info(
f"Successfully enqueued {len(payload)} messages with {len(queue_records)} total queue items"
)
except Exception as e:
logger.error(f"Failed to enqueue messages: {str(e)}", exc_info=True)
if settings.SENTRY.ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
async def parse_upload_form(peer_id: str = Form(...)) -> schemas.MessageUploadCreate:
"""Parse form data for file upload requests"""
return schemas.MessageUploadCreate(peer_id=peer_id)
@router.post("/", response_model=list[schemas.Message])
async def create_messages_for_session(
background_tasks: BackgroundTasks,
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
messages: schemas.MessageBatchCreate = Body(
..., description="Batch of messages to create"
),
messages: schemas.MessageBatchCreate,
workspace_id: str = Path(...),
session_id: str = Path(...),
db: AsyncSession = db,
):
workspace_name, session_name = workspace_id, session_id
"""Bulk create messages for a session while maintaining order. Maximum 100 messages per batch."""
try:
created_messages = await crud.create_messages(
db,
messages=messages.messages,
workspace_name=workspace_name,
session_name=session_name,
"""Create messages for a session with JSON data (original functionality)."""
created_messages = await crud.create_messages(
db,
messages=messages.messages,
workspace_name=workspace_id,
session_name=session_id,
)
# Enqueue for processing (existing logic)
payloads = [
{
"workspace_name": workspace_id,
"session_name": session_id,
"message_id": message.id,
"content": message.content,
"peer_name": message.peer_name,
"created_at": message.created_at.isoformat()
if message.created_at
else None,
}
for message in created_messages
]
# Enqueue all messages in one call
background_tasks.add_task(enqueue, payloads)
logger.info(
f"Batch of {len(created_messages)} messages created and queued for processing"
)
return created_messages
@router.post("/upload", response_model=list[schemas.Message])
async def create_messages_with_file(
background_tasks: BackgroundTasks,
workspace_id: str = Path(...),
session_id: str = Path(...),
form_data: schemas.MessageUploadCreate = Depends(parse_upload_form),
file: UploadFile = File(...),
db: AsyncSession = db,
):
"""Create messages from uploaded files. Files are converted to text and split into multiple messages."""
# Validate file size
if file.size and file.size > settings.MAX_FILE_SIZE:
raise FileTooLargeError(
f"File size ({file.size} bytes) exceeds maximum allowed size ({settings.MAX_FILE_SIZE} bytes)",
)
# Create payloads for all messages
payloads = [
{
"workspace_name": workspace_name,
"session_name": session_name,
"message_id": message.id,
"content": message.content,
"peer_name": message.peer_name,
}
for message in created_messages
]
# Process files using shared utility function
all_message_data = await process_file_uploads_for_messages(
file=file,
peer_id=form_data.peer_id,
)
# Enqueue all messages in one call
background_tasks.add_task(enqueue, payloads) # type: ignore
logger.info(
f"Batch of {len(created_messages)} messages created and queued for processing"
)
# Create messages
message_creates = [item["message_create"] for item in all_message_data]
created_messages = await crud.create_messages(
db,
messages=message_creates,
workspace_name=workspace_id,
session_name=session_id,
)
return created_messages
except ValueError as e:
logger.error(
f"Failed to create batch messages for session {session_id}: {str(e)}"
)
raise ResourceNotFoundException("Session not found") from e
# Update internal_metadata for file-related messages
for i, message in enumerate(created_messages):
file_metadata = all_message_data[i]["file_metadata"]
message.internal_metadata.update(file_metadata)
flag_modified(message, "internal_metadata")
await db.commit()
# Enqueue for processing (same as regular messages)
payloads = [
{
"workspace_name": workspace_id,
"session_name": session_id,
"message_id": message.id,
"content": message.content,
"peer_name": message.peer_name,
"created_at": message.created_at.isoformat()
if message.created_at
else None,
}
for message in created_messages
]
background_tasks.add_task(enqueue, payloads)
logger.info(
f"Batch of {len(created_messages)} messages created from file uploads and queued for processing"
)
return created_messages
@router.post("/list", response_model=Page[schemas.Message])

View File

@ -1,12 +1,11 @@
import logging
from collections.abc import AsyncGenerator
from fastapi import (
APIRouter,
BackgroundTasks,
Body,
Depends,
Path,
Query,
)
from fastapi.exceptions import HTTPException
from fastapi.responses import StreamingResponse
@ -15,13 +14,10 @@ from fastapi_pagination.ext.sqlalchemy import apaginate
from mirascope.llm import Stream
from sqlalchemy.ext.asyncio import AsyncSession
from src import agent, crud, schemas
from src import crud, schemas
from src.dependencies import db
from src.exceptions import (
AuthenticationException,
ResourceNotFoundException,
)
from src.routers.messages import enqueue
from src.dialectic import chat as dialectic_chat
from src.exceptions import AuthenticationException, ResourceNotFoundException
from src.security import JWTParams, require_auth
logger = logging.getLogger(__name__)
@ -171,24 +167,25 @@ async def chat(
)
if not options.stream:
return await agent.chat(
workspace_id,
peer_id,
options.session_id,
options.queries,
options.stream,
options.target,
response = await dialectic_chat(
workspace_name=workspace_id,
peer_name=peer_id,
target_name=options.target,
session_name=options.session_id,
query=options.query,
stream=options.stream,
)
return schemas.DialecticResponse(content=str(response))
async def parse_stream():
async def parse_stream() -> AsyncGenerator[str, None]:
try:
stream = await agent.chat(
workspace_id,
peer_id,
options.session_id,
options.queries,
stream=True,
target=options.target,
stream = await dialectic_chat(
workspace_name=workspace_id,
peer_name=peer_id,
target_name=options.target,
session_name=options.session_id,
query=options.query,
stream=options.stream,
)
if isinstance(stream, Stream):
async for chunk, _ in stream:
@ -204,96 +201,6 @@ async def chat(
)
@router.post(
"/{peer_id}/messages",
response_model=list[schemas.Message],
dependencies=[
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
],
)
async def create_messages_for_peer(
background_tasks: BackgroundTasks,
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer"),
messages: schemas.MessageBatchCreate = Body(
..., description="Batch of messages to create"
),
db: AsyncSession = db,
):
"""Create messages for a peer"""
workspace_name, peer_name = workspace_id, peer_id
"""Bulk create messages for a peer while maintaining order."""
try:
created_messages = await crud.create_messages_for_peer(
db,
messages=messages.messages,
workspace_name=workspace_name,
peer_name=peer_name,
)
# Create payloads for all messages
payloads = [
{
"workspace_name": workspace_name,
"session_name": None,
"message_id": message.id,
"content": message.content,
"peer_name": message.peer_name,
}
for message in created_messages
]
# Enqueue all messages in one call
background_tasks.add_task(enqueue, payloads) # type: ignore
logger.info(
f"Batch of {len(created_messages)} messages created and queued for processing"
)
return created_messages
except ValueError as e:
logger.error(f"Failed to create batch messages for peer {peer_id}: {str(e)}")
raise ResourceNotFoundException("Peer not found") from e
@router.post(
"/{peer_id}/messages/list",
response_model=Page[schemas.Message],
dependencies=[
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
],
)
async def get_messages_for_peer(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer"),
options: schemas.MessageGet | None = Body(
None, description="Filtering options for the messages list"
),
reverse: bool | None = Query(
False, description="Whether to reverse the order of results"
),
db: AsyncSession = db,
):
"""Get all messages for a peer"""
try:
filters = None
if options and hasattr(options, "filter"):
filters = options.filter
if filters == {}:
filters = None
messages_query = await crud.get_messages_for_peer(
workspace_name=workspace_id,
peer_name=peer_id,
filters=filters,
reverse=reverse,
)
return await apaginate(db, messages_query)
except ValueError as e:
logger.warning(f"Failed to get messages for peer {peer_id}: {str(e)}")
raise ResourceNotFoundException("Peer not found") from e
@router.post(
"/{peer_id}/representation",
response_model=dict[str, object],
@ -312,14 +219,22 @@ async def get_working_representation(
"""Get a peer's working representation for a session.
If a session_id is provided in the body, we get the working representation of the peer in that session.
In the current implementation, we don't offer representations of `target` so that parameter is ignored.
Future releases will allow for this.
If a target is provided, we get the representation of the target from the perspective of the peer.
If no target is provided, we get the global representation of the peer.
"""
representation = await crud.get_working_representation(
db, workspace_id, peer_id, options.session_id
)
return {"representation": representation}
try:
# If no target specified, get global representation (peer observing themselves)
target_peer = options.target if options.target is not None else peer_id
representation = await crud.get_working_representation(
db, workspace_id, peer_id, target_peer, options.session_id
)
return {"representation": representation}
except ValueError as e:
logger.warning(
f"Failed to get working representation for peer {peer_id}: {str(e)}"
)
raise ResourceNotFoundException("Peer or session not found") from e
@router.post(

View File

@ -13,7 +13,7 @@ from src.exceptions import (
ValidationException,
)
from src.security import JWTParams, require_auth
from src.utils import history
from src.utils import summarizer
logger = logging.getLogger(__name__)
@ -388,7 +388,12 @@ async def get_session_context(
summary_tokens = int(token_limit * 0.4) if summary else 0
messages_tokens = token_limit - summary_tokens
# Get the messages to return verbatim
logger.info(
f"Context request for session {session_id}: token_limit={token_limit}, "
+ f"summary_tokens={summary_tokens}, messages_tokens={messages_tokens}, summary_requested={summary}"
)
# Get the recent messages to return verbatim
messages_stmt = await crud.get_messages(
workspace_name=workspace_id,
session_name=session_id,
@ -397,42 +402,134 @@ async def get_session_context(
result = await db.execute(messages_stmt)
messages = list(result.scalars().all())
# Get the most recently created summary for the session
last_summary = await history.get_summary(
db,
workspace_name=workspace_id,
session_name=session_id,
logger.info(
f"Retrieved {len(messages)} recent messages for verbatim return (IDs: {[m.id for m in messages]})"
)
# Get messages between the last summary and the first message we'll return verbatim, if any
messages_before = await crud.get_messages_id_range(
db,
workspace_name=workspace_id,
session_name=session_id,
peer_name=None,
start_id=last_summary["message_id"] if last_summary else 0,
end_id=messages[0].id if messages else None,
)
summary_content = ""
# Make a summary if the user wants one
if summary_tokens > 0:
# Make a *new* summary if there are unsummarized messages between the last summary and the ones
# we'll return verbatim, or if the last summary is too many tokens -- otherwise, just use the last summary
if (
not last_summary
or len(messages_before) > 0
or last_summary["token_count"] > summary_tokens
):
new_summary = await history.create_summary(
messages=messages_before,
max_tokens=summary_tokens,
if summary_tokens > 0 and messages:
# Check if we should create a new cumulative summary
(
should_create,
messages_to_summarize,
latest_summary,
) = await summarizer.should_create_summary(
db,
workspace_name=workspace_id,
session_name=session_id,
peer_name=None, # Session-level summary
message_id=messages[
0
].id, # Cutoff at the first message we'll return verbatim
summary_type=summarizer.SummaryType.SHORT,
)
# Check for gaps: if latest summary exists but doesn't cover up to the recent messages,
# we have a gap that must be filled regardless of the threshold
has_gap = False
if latest_summary and messages:
gap_start = latest_summary["message_id"] + 1
gap_end = messages[0].id
has_gap = gap_start < gap_end
if has_gap:
logger.info(
f"Gap detected: summary ends at message {latest_summary['message_id']}, recent messages start at {messages[0].id} (missing messages {gap_start}-{gap_end - 1})"
)
logger.info(
f"Summary decision: should_create={should_create}, "
+ f"unsummarized_messages={len(messages_to_summarize)}, "
+ f"has_existing_summary={latest_summary is not None}, "
+ f"has_gap={has_gap}"
)
if latest_summary:
logger.info(
f"Existing summary covers {latest_summary['message_count']} messages "
+ f"up to message {latest_summary['message_id']}, "
+ f"token_count={latest_summary['token_count']}"
)
summary_content = new_summary["content"]
# We must create a new summary if either:
# 1. The threshold is met (should_create=True), OR
# 2. There's a gap between existing summary and recent messages
must_create_summary = should_create or has_gap
if must_create_summary:
# Create a new cumulative summary covering ALL messages from the beginning
# up to the start of the recent messages
all_messages_to_summarize = await crud.get_messages_id_range(
db,
workspace_name=workspace_id,
session_name=session_id,
peer_name=None,
start_id=0,
end_id=messages[0].id,
)
if has_gap:
logger.info(
f"Creating NEW cumulative summary to fill gap: covering {len(all_messages_to_summarize)} messages "
+ f"from start to message {messages[0].id}"
)
else:
logger.info(
f"Creating NEW cumulative summary (threshold met): covering {len(all_messages_to_summarize)} messages "
+ f"from start to message {messages[0].id}"
)
if all_messages_to_summarize:
# Create cumulative summary
new_summary = await summarizer.create_summary(
messages=all_messages_to_summarize,
previous_summary_text=None, # Start fresh for cumulative summary
summary_type=summarizer.SummaryType.SHORT,
max_tokens=summary_tokens,
)
# Save the new cumulative summary
await summarizer.save_summary(
db,
summary=new_summary,
workspace_name=workspace_id,
session_name=session_id,
)
summary_content = new_summary["content"]
logger.info(
f"Saved new cumulative summary with {new_summary['token_count']} tokens"
)
else:
summary_content = ""
logger.info("No messages to summarize, using empty summary")
elif latest_summary:
# Use existing summary if it fits within token limit and there's no gap
if latest_summary["token_count"] <= summary_tokens:
summary_content = latest_summary["content"]
logger.info(
f"Reusing existing summary ({latest_summary['token_count']} tokens fits in {summary_tokens} limit)"
)
else:
# Existing summary is too big - truncate it
# This is a simple truncation - could be improved with smarter trimming
summary_content = latest_summary["content"][
: summary_tokens * 4
] # Rough estimate: 4 chars per token
logger.info(
f"Truncated existing summary to fit {summary_tokens} token limit"
)
else:
summary_content = last_summary["content"]
summary_tokens = last_summary["token_count"]
# No existing summary and not enough messages to create one
summary_content = ""
logger.info(
"No existing summary and insufficient messages to create new summary"
)
else:
summary_content = ""
if summary_tokens == 0:
logger.info("Summary not requested, returning messages only")
else:
logger.info("No messages available for summarization")
return schemas.SessionContext(
name=session_id,

View File

@ -114,30 +114,23 @@ async def search_workspace(
)
async def get_deriver_status(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str | None = Query(None, description="Optional peer ID to filter by"),
observer_id: str | None = Query(
None, description="Optional observer ID to filter by"
),
sender_id: str | None = Query(None, description="Optional sender ID to filter by"),
session_id: str | None = Query(
None, description="Optional session ID to filter by"
),
include_sender: bool = Query(
False, description="Include work units triggered by this peer"
),
db: AsyncSession = db,
):
"""Get the deriver processing status, optionally scoped to a peer and/or session"""
# Validate that at least one of peer_id or session_id is provided
if peer_id is None and session_id is None:
raise HTTPException(
status_code=400,
detail="At least one of 'peer_id' or 'session_id' must be provided",
)
"""Get the deriver processing status, optionally scoped to an observer, sender, and/or session"""
try:
return await crud.get_deriver_status(
db,
workspace_name=workspace_id,
peer_name=peer_id,
observer_name=observer_id,
sender_name=sender_id,
session_name=session_id,
include_sender=include_sender,
)
except ValueError as e:
logger.warning(f"Invalid request parameters: {str(e)}")

View File

@ -8,7 +8,6 @@ from pydantic import (
ConfigDict,
Field,
PrivateAttr,
field_validator,
model_validator,
)
@ -143,6 +142,7 @@ class Message(MessageBase):
public_id: str = Field(serialization_alias="id")
content: str
peer_name: str = Field(serialization_alias="peer_id")
# NOTE: Messages in Honcho 2.0 could historically be stored outside of a session. See models.py for more details.
session_name: str | None = Field(serialization_alias="session_id")
h_metadata: dict[str, Any] = Field(
default_factory=dict, serialization_alias="metadata"
@ -162,6 +162,14 @@ class MessageBatchCreate(BaseModel):
messages: list[MessageCreate] = Field(..., min_length=1, max_length=100)
class MessageUploadCreate(BaseModel):
"""Schema for message creation from file uploads"""
peer_id: str = Field(..., description="ID of the peer creating the message")
model_config = ConfigDict(populate_by_name=True) # pyright: ignore
class SessionBase(BaseModel):
pass
@ -253,23 +261,11 @@ class DialecticOptions(BaseModel):
None,
description="Optional peer to get the representation for, from the perspective of this peer",
)
queries: str | list[str]
query: Annotated[
str, Field(min_length=1, max_length=10000, description="Dialectic API Prompt")
]
stream: bool = False
@field_validator("queries")
def validate_queries(cls, v: str | list[str]) -> str | list[str]:
MAX_STRING_LENGTH = 10000
MAX_LIST_LENGTH = 25
if isinstance(v, str):
if len(v) > MAX_STRING_LENGTH:
raise ValueError("Query too long")
else:
if len(v) > MAX_LIST_LENGTH:
raise ValueError("Too many queries")
if any(len(q) > MAX_STRING_LENGTH for q in v):
raise ValueError("One or more queries too long")
return v
class DialecticResponse(BaseModel):
content: str
@ -330,10 +326,6 @@ class MessageBulkData(BaseModel):
class SessionDeriverStatus(BaseModel):
peer_id: str | None = Field(
default=None,
description="ID of the peer (optional when filtering by session only)",
)
session_id: str | None = Field(
default=None, description="Session ID if filtered by session"
)
@ -346,13 +338,6 @@ class SessionDeriverStatus(BaseModel):
class DeriverStatus(BaseModel):
peer_id: str | None = Field(
default=None,
description="ID of the peer (optional when filtering by session only)",
)
session_id: str | None = Field(
default=None, description="Session ID if filtered by session"
)
total_work_units: int = Field(description="Total work units")
completed_work_units: int = Field(description="Completed work units")
in_progress_work_units: int = Field(

View File

@ -74,7 +74,7 @@ def create_jwt(params: JWTParams) -> str:
payload = {k: v for k, v in params.__dict__.items() if v is not None}
if not settings.AUTH.JWT_SECRET:
raise ValueError("AUTH_JWT_SECRET is not set, cannot create JWT.")
return jwt.encode( # pyright: ignore
return jwt.encode(
payload, settings.AUTH.JWT_SECRET.encode("utf-8"), algorithm="HS256"
)
@ -86,7 +86,7 @@ async def verify_jwt(token: str) -> JWTParams:
try:
if not settings.AUTH.JWT_SECRET:
raise ValueError("AUTH_JWT_SECRET is not set, cannot verify JWT.")
decoded = jwt.decode( # pyright: ignore
decoded = jwt.decode(
token, settings.AUTH.JWT_SECRET.encode("utf-8"), algorithms=["HS256"]
)
if "t" in decoded:

View File

@ -1,21 +0,0 @@
"""
Utility modules for the Honcho app.
"""
import re
def parse_xml_content(text: str, tag: str) -> str:
"""
Extract content from XML-like tags in a string.
Args:
text: The text containing XML-like tags
tag: The tag name to extract content from
Returns:
The content between the opening and closing tags, or an empty string if not found
"""
pattern = f"<{tag}>(.*?)</{tag}>"
match = re.search(pattern, text, re.DOTALL)
return match.group(1).strip() if match else ""

View File

@ -1,7 +1,24 @@
from collections.abc import Awaitable, Callable
from typing import (
Any,
Literal,
ParamSpec,
Protocol,
TypeVar,
overload,
runtime_checkable,
)
from anthropic import AsyncAnthropic
from google import genai
from groq import AsyncGroq
from mirascope import llm
from mirascope.integrations.langfuse import with_langfuse
from mirascope.llm import Stream
from openai import AsyncOpenAI
from pydantic import BaseModel
from sentry_sdk.ai.monitoring import ai_track
from tenacity import retry, stop_after_attempt, wait_exponential
from src.config import settings
from src.utils.types import Providers
@ -31,3 +48,271 @@ if settings.LLM.GEMINI_API_KEY:
if settings.LLM.GROQ_API_KEY:
groq = AsyncGroq(api_key=settings.LLM.GROQ_API_KEY)
clients["groq"] = groq
providers = [
("Dialectic", settings.DIALECTIC.PROVIDER),
("Summary", settings.SUMMARY.PROVIDER),
("Deriver", settings.DERIVER.PROVIDER),
("Query Generation Provider", settings.DIALECTIC.QUERY_GENERATION_PROVIDER),
]
for provider_name, provider_value in providers:
if provider_value not in clients:
raise ValueError(f"Missing client for {provider_name}: {provider_value}")
P = ParamSpec("P")
T = TypeVar("T", bound=BaseModel)
T_co = TypeVar("T_co", bound=BaseModel, covariant=True)
F = TypeVar("F", bound=Callable[..., Any])
# Define protocols for different return types
@runtime_checkable
class AsyncResponseModelCallable(Protocol[P, T_co]):
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T_co: ...
@runtime_checkable
class SyncResponseModelCallable(Protocol[P, T_co]):
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T_co: ...
@runtime_checkable
class AsyncStreamCallable(Protocol[P]):
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Stream: ...
@runtime_checkable
class SyncStreamCallable(Protocol[P]):
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Stream: ...
@runtime_checkable
class AsyncStringCallable(Protocol[P]):
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> str: ...
@runtime_checkable
class SyncStringCallable(Protocol[P]):
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> str: ...
# Overload for stream=True with async function
@overload
def honcho_llm_call(
*,
provider: Providers | None = None,
model: str | None = None,
track_name: str | None = None,
response_model: type[BaseModel] | None = None,
json_mode: bool = False,
max_tokens: int | None = None,
thinking_budget_tokens: int | None = None,
enable_retry: bool = True,
retry_attempts: int = 3,
stream: Literal[True],
**extra_call_params: Any,
) -> Callable[[Callable[P, Awaitable[Any]]], AsyncStreamCallable[P]]: ...
# Overload for response_model with async function
@overload
def honcho_llm_call(
*,
provider: Providers | None = None,
model: str | None = None,
track_name: str | None = None,
response_model: type[T],
json_mode: bool = False,
max_tokens: int | None = None,
thinking_budget_tokens: int | None = None,
enable_retry: bool = True,
retry_attempts: int = 3,
stream: Literal[False] = False,
**extra_call_params: Any,
) -> Callable[[Callable[P, Awaitable[Any]]], AsyncResponseModelCallable[P, T]]: ...
# Overload for no response_model with async function
@overload
def honcho_llm_call(
*,
provider: Providers | None = None,
model: str | None = None,
track_name: str | None = None,
response_model: None = None,
json_mode: bool = False,
max_tokens: int | None = None,
thinking_budget_tokens: int | None = None,
enable_retry: bool = True,
retry_attempts: int = 3,
stream: Literal[False] = False,
**extra_call_params: Any,
) -> Callable[[Callable[P, Awaitable[Any]]], AsyncStringCallable[P]]: ...
# Generic overload for sync functions (fallback)
@overload
def honcho_llm_call(
*,
provider: Providers | None = None,
model: str | None = None,
track_name: str | None = None,
response_model: type[BaseModel] | None = None,
json_mode: bool = False,
max_tokens: int | None = None,
thinking_budget_tokens: int | None = None,
enable_retry: bool = True,
retry_attempts: int = 3,
stream: bool = False,
**extra_call_params: Any,
) -> Callable[[Callable[P, Any]], Callable[P, Any]]: ...
def honcho_llm_call(
provider: Providers | None = None,
model: str | None = None,
track_name: str | None = None,
response_model: type[BaseModel] | None = None,
json_mode: bool = False,
max_tokens: int | None = None,
thinking_budget_tokens: int | None = None,
enable_retry: bool = True,
retry_attempts: int = 3,
stream: bool = False,
**extra_call_params: Any,
) -> Any:
"""
Consolidated decorator for LLM calls that handles provider-specific configurations.
This decorator automatically:
- Handles both sync and async functions seamlessly
- Applies retry logic with exponential backoff
- Adds AI tracking for Sentry
- Integrates with Langfuse for observability
- Builds provider-specific call parameters
- Handles client selection from the global clients dict
Args:
provider: The LLM provider to use (e.g., "anthropic", "google", "openai")
model: The model to use
track_name: Name for AI tracking (e.g., "Critical Analysis Call")
response_model: Optional Pydantic model for structured responses
json_mode: Whether to enable JSON mode (for providers that support it)
max_tokens: Maximum tokens for the response
thinking_budget_tokens: Budget for thinking tokens (Anthropic only)
enable_retry: Whether to enable retry logic (default: True)
retry_attempts: Number of retry attempts (default: 3)
stream: Whether to enable streaming responses (default: False)
**extra_call_params: Additional provider-specific parameters
Returns:
A decorator that returns:
- For async functions: Callable[P, Awaitable[T]] where T is Stream, response_model, or str
- For sync functions: Callable[P, T] where T is Stream, response_model, or str
Note: Type annotations may be needed at the call site for proper type checking.
Example (async function):
@honcho_llm_call(
provider=settings.DERIVER.PROVIDER,
model=settings.DERIVER.MODEL,
track_name="Critical Analysis Call",
response_model=ReasoningResponse,
json_mode=True,
max_tokens=settings.DERIVER.MAX_OUTPUT_TOKENS,
)
async def analyze(context: str, query: str):
return prompt_template(context, query)
Example (sync function):
@honcho_llm_call(
provider="openai",
model="gpt-4",
max_tokens=1000,
)
def generate_summary(text: str) -> str:
return f"Summarize: {text}"
# Call synchronously
result = generate_summary("Long text here...")
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
# Handle special case for custom provider
# Custom providers use OpenAI-compatible endpoints, so we resolve to "openai" for the provider name
# but keep the original "custom" for client lookup
resolved_provider = "openai" if provider == "custom" else provider
# Build provider-specific call params
call_params: dict[str, Any] = {}
if resolved_provider == "google":
# Google uses 'config' parameter
config: dict[str, Any] = {}
if max_tokens:
config["max_output_tokens"] = max_tokens
if json_mode or response_model:
config["response_mime_type"] = "application/json"
if response_model:
config["response_schema"] = response_model
if config:
call_params["config"] = config
elif resolved_provider == "anthropic":
# Anthropic uses thinking params and max_tokens
if thinking_budget_tokens:
call_params["thinking"] = {
"type": "enabled",
"budget_tokens": thinking_budget_tokens,
}
if max_tokens:
call_params["max_tokens"] = max_tokens
else:
# Other providers just use max_tokens
if max_tokens:
call_params["max_tokens"] = max_tokens
# Merge with any extra call params
call_params.update(extra_call_params)
# Build kwargs for llm.call
llm_kwargs: dict[str, Any] = {}
if resolved_provider and provider:
llm_kwargs["provider"] = resolved_provider
llm_kwargs["client"] = clients[
provider
] # Use original provider for client lookup
if model:
llm_kwargs["model"] = model
if response_model:
llm_kwargs["response_model"] = response_model
if json_mode:
llm_kwargs["json_mode"] = json_mode
if stream:
llm_kwargs["stream"] = stream
if call_params:
llm_kwargs["call_params"] = call_params
# Apply decorators in order
decorated: Any = func
# Apply llm.call
decorated = llm.call(**llm_kwargs)(decorated) # pyright: ignore
# Apply langfuse
decorated = with_langfuse()(decorated) # pyright: ignore
# Apply AI tracking if name provided
if track_name:
decorated = ai_track(track_name)(decorated)
# Apply retry logic if enabled
if enable_retry:
decorated = retry(
stop=stop_after_attempt(retry_attempts),
wait=wait_exponential(multiplier=1, min=4, max=10),
)(decorated)
return decorated
return decorator

View File

@ -0,0 +1,425 @@
from __future__ import annotations
import datetime
import logging
from typing import Any, Literal, overload
from langfuse.decorators import langfuse_context, observe # pyright: ignore
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models
from src.config import settings
from src.dependencies import tracked_db
from src.utils.shared_models import ObservationContext
from src.utils.summarizer import SummaryType
logger = logging.getLogger(__name__)
class EmbeddingStore:
"""Embedding store specialized for observation-based reasoning with structured metadata."""
def __init__(
self, workspace_name: str, peer_name: str, collection_name: str
) -> None:
self.workspace_name: str = workspace_name
self.peer_name: str = peer_name
self.collection_name: str = collection_name
# Initialize observation counts with config defaults
self.explicit_observations_count: int = (
settings.DERIVER.EXPLICIT_OBSERVATIONS_COUNT
)
self.deductive_observations_count: int = (
settings.DERIVER.DEDUCTIVE_OBSERVATIONS_COUNT
)
def set_observation_counts(
self,
explicit: int | None = None,
deductive: int | None = None,
) -> None:
"""Set the number of observations to retrieve for each reasoning level.
Args:
explicit: Number of explicit observations to retrieve
deductive: Number of deductive observations to retrieve
"""
if explicit is not None:
self.explicit_observations_count = explicit
if deductive is not None:
self.deductive_observations_count = deductive
@observe()
async def save_unified_observations(
self,
observations: list[str] | list[Any],
similarity_threshold: float = 0.85,
message_id: str | None = None,
level: str | None = None,
session_name: str | None = None,
message_created_at: datetime.datetime | None = None,
) -> None:
"""Save UnifiedObservation objects to the collection.
This method handles UnifiedObservation objects by:
1. Generating embeddings only from conclusions
2. Storing premises in metadata for reference
Args:
db: Database session (not used in this implementation as we use tracked_db)
observations: List of UnifiedObservation objects or strings
similarity_threshold: Threshold for considering observations similar
message_id: Message ID to link with observations
level: Reasoning level for the observations
session_name: Session name to link with existing summary context
message_created_at: Timestamp when the message was created
"""
from src.utils.shared_models import UnifiedObservation
async with tracked_db("ed_embedding_store.save_unified_observations") as db:
try:
# Convert mixed input to UnifiedObservation objects
unified_observations: list[Any] = []
for obs in observations:
if isinstance(obs, str):
unified_observations.append(
UnifiedObservation.from_string(obs, level=level)
)
elif isinstance(obs, UnifiedObservation):
unified_observations.append(obs)
else:
# Try to extract content and treat as string
content = getattr(obs, "content", str(obs))
unified_observations.append(
UnifiedObservation.from_string(content, level=level)
)
# Get latest short summary for context linking if session_name provided
summary_id: str | None = None
summary_content: str | None = None
if session_name:
try:
from src.utils.summarizer import get_summary
latest_summary = await get_summary(
db, self.workspace_name, session_name, SummaryType.SHORT
)
if latest_summary:
summary_id = (
str(latest_summary.get("message_id"))
if latest_summary.get("message_id")
else None
)
summary_content = latest_summary.get("content")
except Exception as e:
logger.warning(
f"Could not retrieve latest summary for session {session_name}: {e}"
)
# Extract conclusions for deduplication and embedding
conclusions: list[str] = [
obs.conclusion for obs in unified_observations
]
# Remove duplicates before saving
unique_conclusions: list[str] = await self.remove_duplicates(
conclusions, similarity_threshold=similarity_threshold
)
langfuse_context.update_current_observation(
input={
"observations": [
obs.model_dump() for obs in unified_observations
]
},
output={"unique_conclusions": unique_conclusions},
)
if not unique_conclusions:
logger.debug("No unique observations to save after deduplication")
return
# Create mapping from conclusion back to original observation
conclusion_to_observation: dict[str, Any] = {
obs.conclusion: obs for obs in unified_observations
}
# Filter unified observations to only unique ones
unique_observations: list[Any] = [
conclusion_to_observation[conclusion]
for conclusion in unique_conclusions
]
# Batch embed all unique conclusions (not premises)
from openai.types import CreateEmbeddingResponse
from src.embedding_client import embedding_client
embeddings: list[list[float]] = []
batch_size: int = 2048 # OpenAI batch limit
for i in range(0, len(unique_conclusions), batch_size):
batch = unique_conclusions[i : i + batch_size]
response: CreateEmbeddingResponse = (
await embedding_client.client.embeddings.create(
input=batch, model="text-embedding-3-small"
)
)
embeddings.extend([data.embedding for data in response.data])
# Batch create document objects
document_objects: list[models.Document] = []
for obs, embedding in zip(unique_observations, embeddings, strict=True):
# Use the observation's own level, fall back to parameter level,
# or infer from premises
obs_level = obs.level or level
if obs_level is None:
obs_level = "deductive" if obs.has_premises else "explicit"
# Build metadata including premises
metadata: dict[str, Any] = {
"level": obs_level,
"message_id": message_id,
"session_name": session_name,
"premises": obs.premises, # Store premises in metadata
"created_at": message_created_at.isoformat()
if message_created_at
else None,
}
if summary_id:
metadata["summary_id"] = str(summary_id)
if summary_content:
metadata["session_context"] = summary_content[:500]
doc = models.Document(
workspace_name=self.workspace_name,
peer_name=self.peer_name,
collection_name=self.collection_name,
content=obs.conclusion, # Store only conclusion as content
internal_metadata=metadata,
embedding=embedding, # Embedding generated from conclusion only
created_at=message_created_at,
)
document_objects.append(doc)
# Batch insert all documents
db.add_all(document_objects)
await db.commit()
logger.debug(
f"Batch created {len(document_objects)} unified observations"
)
except Exception as e:
logger.error(f"Error saving unified observations: {e}")
@overload
async def get_relevant_observations(
self,
query: str,
*,
top_k: int = 5,
max_distance: float = 0.3,
level: str | None = None,
conversation_context: str = "",
for_reasoning: Literal[True],
) -> ObservationContext: ...
@overload
async def get_relevant_observations(
self,
query: str,
*,
top_k: int = 5,
max_distance: float = 0.3,
level: str | None = None,
conversation_context: str = "",
for_reasoning: Literal[False],
) -> list[models.Document]: ...
async def get_relevant_observations(
self,
query: str,
*,
top_k: int = 5,
max_distance: float = 0.3,
level: str | None = None,
conversation_context: str = "",
for_reasoning: bool = False,
) -> list[models.Document] | ObservationContext:
"""Unified method to get relevant observations with flexible options.
Args:
query: The search query
top_k: Number of results to return
max_distance: Maximum distance for semantic similarity
level: Optional reasoning level to filter by
conversation_context: Additional conversation context
for_reasoning: If True, returns ObservationContext for ed reasoning
Returns:
List of documents or ObservationContext (if for_reasoning=True)
"""
async with tracked_db("embedding_store.get_relevant_observations") as db:
return await self._get_observations_internal(
db,
query,
top_k,
max_distance,
level,
conversation_context,
for_reasoning,
)
async def _get_observations_internal(
self,
db: AsyncSession,
query: str,
top_k: int,
max_distance: float,
level: str | None,
conversation_context: str,
for_reasoning: bool,
) -> Any:
"""Internal method that does the actual observation retrieval."""
try:
if for_reasoning:
# Return ObservationContext for ed reasoning
from src.utils.shared_models import (
Observation,
ObservationContext,
ReasoningLevel,
)
context = ObservationContext()
for level_name in ["explicit", "deductive"]:
count: int = getattr(self, f"{level_name}_observations_count", 5)
level_enum = ReasoningLevel(level_name)
docs = await self._query_documents_for_level(
db, query, level_name, conversation_context, max_distance, count
)
seen_observations: set[str] = set()
for doc in docs:
normalized_content: str = doc.content.strip().lower()
if normalized_content not in seen_observations:
metadata = self._extract_observation_metadata(doc)
observation = Observation(
content=doc.content,
metadata=metadata,
created_at=doc.created_at,
)
context.add_observation(observation, level_enum)
seen_observations.add(normalized_content)
return context
else:
# Regular document list return
if level:
return await self._query_documents_for_level(
db, query, level, conversation_context, max_distance, top_k
)
else:
documents = await crud.query_documents(
db,
workspace_name=self.workspace_name,
peer_name=self.peer_name,
collection_name=self.collection_name,
query=query,
max_distance=max_distance,
top_k=top_k,
)
db.expunge_all()
return list(documents)
except Exception as e:
logger.error(f"Error getting relevant observations: {e}")
if for_reasoning:
from src.utils.shared_models import ObservationContext
return ObservationContext()
return []
async def _query_documents_for_level(
self,
db: AsyncSession,
query: str,
level: str,
conversation_context: str,
max_distance: float,
count: int,
) -> list[models.Document]:
"""Query documents for a specific level."""
combined_query: str = (
f"Current message: {query}\nContext: {conversation_context}"
if conversation_context
else query
)
documents = await crud.query_documents(
db,
workspace_name=self.workspace_name,
peer_name=self.peer_name,
collection_name=self.collection_name,
query=combined_query,
max_distance=max_distance,
top_k=count * 3,
filters={"internal_metadata": {"level": level}},
)
docs_list: list[models.Document] = list(documents)
docs_sorted: list[models.Document] = sorted(
docs_list, key=lambda x: x.created_at, reverse=True
)
return docs_sorted[:count]
def _extract_observation_metadata(self, doc: models.Document) -> Any:
"""Extract metadata from a document for ObservationMetadata."""
from src.utils.shared_models import ObservationMetadata
metadata = ObservationMetadata()
if doc.internal_metadata:
metadata.session_context = doc.internal_metadata.get("session_context", "")
metadata.summary_id = doc.internal_metadata.get("summary_id", "")
metadata.message_id = doc.internal_metadata.get("message_id")
metadata.level = doc.internal_metadata.get("level")
metadata.session_name = doc.internal_metadata.get("session_name")
metadata.premises = doc.internal_metadata.get("premises", [])
return metadata
async def remove_duplicates(
self,
facts: list[str],
*,
similarity_threshold: float = 0.85,
) -> list[str]:
"""Remove duplicate observations based on similarity threshold.
Args:
facts: List of observation strings
similarity_threshold: Threshold for considering observations similar
Returns:
List of unique observations
"""
unique_observations: list[str] = []
async with tracked_db("embedding_store.remove_duplicates") as db:
for observation in facts:
# Check for similar existing observations
documents = await crud.query_documents(
db,
workspace_name=self.workspace_name,
peer_name=self.peer_name,
collection_name=self.collection_name,
query=observation,
max_distance=1.0 - similarity_threshold,
top_k=1,
)
docs_list: list[models.Document] = list(documents)
if not docs_list:
unique_observations.append(observation)
else:
logger.debug(
f"Skipping duplicate observation: {observation[:50]}..."
)
return unique_observations

224
src/utils/files.py Normal file
View File

@ -0,0 +1,224 @@
import logging
from io import BytesIO
from typing import Any, Protocol
import pdfplumber
from fastapi import UploadFile
from nanoid import generate as generate_nanoid
from sqlalchemy import Integer, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import schemas
from src.exceptions import FileProcessingError, UnsupportedFileTypeError
from src.schemas import Message
logger = logging.getLogger(__name__)
class FileProcessor(Protocol):
async def extract_text(self, content: bytes) -> str: ...
def supports_file_type(self, content_type: str) -> bool: ...
class PDFProcessor:
def supports_file_type(self, content_type: str) -> bool:
return content_type == "application/pdf"
async def extract_text(self, content: bytes) -> str:
with pdfplumber.open(BytesIO(content)) as pdf_reader:
text_parts: list[str] = []
for page_num, page in enumerate(pdf_reader.pages):
text = page.extract_text()
if text and text.strip():
text_parts.append(f"[Page {page_num + 1}]\n{text}")
return "\n\n".join(text_parts)
class TextProcessor:
def supports_file_type(self, content_type: str) -> bool:
return content_type.startswith("text/")
async def extract_text(self, content: bytes) -> str:
# Try different encodings
for encoding in ["utf-8", "utf-16", "latin-1"]:
try:
return content.decode(encoding)
except UnicodeDecodeError:
continue
raise ValueError("Could not decode text file")
class JSONProcessor:
def supports_file_type(self, content_type: str) -> bool:
return content_type == "application/json"
async def extract_text(self, content: bytes) -> str:
import json
data = json.loads(content.decode("utf-8"))
# Convert JSON to readable text format
return json.dumps(data, ensure_ascii=False)
class FileProcessingService:
def __init__(self):
self.processors: list[FileProcessor] = [
PDFProcessor(),
TextProcessor(),
JSONProcessor(),
# Add more processors as needed
]
async def extract_text_from_upload(self, file: UploadFile) -> str:
"""Extract text from uploaded file without saving to disk."""
content = await file.read()
# Reset file position in case it's needed again
await file.seek(0)
processor = self._get_processor(file.content_type or "")
if not processor:
raise UnsupportedFileTypeError(
f"Unsupported file type: {file.content_type}. Supported types: {[p.__class__.__name__ for p in self.processors]}"
)
return await processor.extract_text(content)
def _get_processor(self, content_type: str) -> FileProcessor | None:
for processor in self.processors:
if processor.supports_file_type(content_type):
return processor
return None
def split_text_into_chunks(text: str, max_chars: int = 49500) -> list[str]:
"""Split text into chunks that fit within message limits."""
if len(text) <= max_chars:
return [text]
chunks: list[str] = []
current_pos = 0
while current_pos < len(text):
# Try to break at paragraph boundaries first
end_pos = current_pos + max_chars
if end_pos >= len(text):
chunks.append(text[current_pos:])
break
# Look for good break points (paragraph, sentence, word)
break_pos = end_pos
for delimiter in ["\n\n", "\n", ". ", " "]:
last_delimiter = text.rfind(delimiter, current_pos, end_pos)
if last_delimiter > current_pos:
break_pos = last_delimiter + len(delimiter)
break
chunks.append(text[current_pos:break_pos])
current_pos = break_pos
return chunks
async def get_file_messages(
db: AsyncSession,
workspace_name: str,
file_id: str,
session_name: str | None = None,
) -> list[Message]:
"""Get all messages for a specific document, ordered by chunk_index."""
from sqlalchemy import and_, func
from src.models import Message
query = select(Message).where(
and_(
Message.workspace_name == workspace_name,
func.jsonb_extract_path_text(Message.internal_metadata, "file_id")
== file_id,
)
)
if session_name:
query = query.where(Message.session_name == session_name)
# Order by chunk_index
query = query.order_by(
func.jsonb_extract_path_text(Message.internal_metadata, "chunk_index").cast(
Integer
)
)
result = await db.execute(query)
return list(result.scalars().all())
async def process_file_uploads_for_messages(
file: UploadFile,
peer_id: str,
max_chars: int = 49500,
) -> list[dict[str, Any]]:
"""
Process an uploaded file and prepare message creation data.
This function extracts text from a file, splits it into chunks, and prepares
the data needed to create messages.
Args:
file: Uploaded file to process
peer_id: ID of the peer creating the messages
max_chars: Maximum characters per message chunk
Returns:
List of dictionaries containing message_create and file_metadata
Raises:
HTTPException: If file processing fails
"""
file_processor = FileProcessingService()
all_message_data: list[dict[str, Any]] = []
# Process the uploaded file
extracted_text = await file_processor.extract_text_from_upload(file)
# Split into chunks and create messages
chunks = split_text_into_chunks(extracted_text, max_chars=max_chars)
file_id = generate_nanoid()
for i, chunk in enumerate(chunks):
# Build message content properly handling empty files
message_content = chunk or ""
# Create message
message_create = schemas.MessageCreate(
content=message_content,
peer_id=peer_id,
)
# Store file metadata separately to add to internal_metadata later
file_metadata = {
"file_id": file_id,
"filename": file.filename,
"chunk_index": i,
"total_chunks": len(chunks),
"original_file_size": file.size,
"content_type": file.content_type,
"chunk_character_range": [
i * max_chars,
min((i + 1) * max_chars, len(extracted_text)),
],
}
all_message_data.append(
{
"message_create": message_create,
"file_metadata": file_metadata,
}
)
if not all_message_data:
raise FileProcessingError()
return all_message_data

View File

@ -191,6 +191,9 @@ def _build_field_condition(
"""
if model_class.__name__ == "Message":
column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES.get(key)
elif model_class.__name__ == "Document":
# documents are fully internal so we can use any column name directly
column_name = key
else:
column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING.get(key)

Some files were not shown because too many files have changed in this diff Show More