Merge branch 'main' into vince/dev-1259

This commit is contained in:
Benjamin McCormick 2025-12-10 10:57:40 -05:00
commit f0d24af827
203 changed files with 47377 additions and 5944 deletions

View File

@ -101,6 +101,8 @@ jobs:
SENTRY_ENABLED: false
LLM_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LLM_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
LLM_OPENAI_COMPATIBLE_API_KEY: test-key
LLM_OPENAI_COMPATIBLE_BASE_URL: http://localhost:8000
DERIVER_PROVIDER: openai
DERIVER_MODEL: test
DIALECTIC_PROVIDER: openai

View File

@ -5,6 +5,41 @@ 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.5.0] - 2025-12-03
### Added
- Message level configurations
- CRUD operations for observations
- Comprehensive test cases for harness
- Peer level get_context
- Set Peer Card Method
- Manual dreaming trigger endpoint
### Changed
- Configurations to support more flags for fine-grained control of the deriver, peer cards, summaries, etc.
- Working Representations to support more fine-grained parameters
### Fixed
- File uploads to match `MessageCreate` structure
- Cache invalidation strategy
## [2.4.3] - 2025-11-20
### Added
- Redis caching to improve DB IO
- Backup LLM provider to avoid failures when a provider is down
### Changed
- QueueItems to use standardized columns
- Improved Deduplication logic for Representation Tasks
- More finegrained metrics for representation, summary, and peer card tasks
- DB constraint to follow standard naming conventions
## [2.4.2] - 2025-11-03
### Fixed

View File

@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What is Honcho?
Honcho is an infrastructure layer for building AI agents with social cognition and theory of mind capabilities. Its primary purposes include:
Honcho is an infrastructure layer for building AI agents with memory and social cognition. Its primary purposes include:
- Imbuing agents with a sense of identity
- Personalizing user experiences through understanding user psychology
@ -14,7 +14,7 @@ Honcho is an infrastructure layer for building AI agents with social cognition a
- Supporting development of LLM-powered applications that adapt to end users
- Enabling multi-peer sessions where multiple participants (users or agents) can interact
Honcho leverages the inherent theory-of-mind capabilities of LLMs to build coherent models of user psychology over time, enabling more personalized and effective AI interactions.
Honcho leverages the inherent reasoning capabilities of LLMs to build coherent models of user psychology over time, enabling more personalized and effective AI interactions.
## Core Concepts
@ -32,7 +32,7 @@ Honcho uses a peer-based model where both users and agents are represented as "p
- **Peer** (formerly User): Any participant in the system (human or AI)
- **Session**: A conversation context that can involve multiple peers
- **Message**: Data units that can represent communication between peers OR arbitrary data ingested by a peer to enhance its global representation
- **Collections & Documents**: Internal vector storage for theory-of-mind representations (not exposed via API)
- **Collections & Documents**: Internal vector storage for peer representations (not exposed via API)
## Architecture Overview
@ -50,7 +50,7 @@ All API routes follow the pattern: `/v1/{resource}/{id}/{action}`
#### Dialectic API (`/peers/{peer_id}/chat`)
- Provides theory-of-mind informed responses
- Provides bespoke responses informed by the representation
- Integrates long-term facts from vector storage
- Supports streaming responses
- Configurable LLM providers
@ -59,18 +59,11 @@ All API routes follow the pattern: `/v1/{resource}/{id}/{action}`
1. Messages created via API (batch or single)
2. Enqueued for background processing:
- `representation`: Update peer's theory of mind
- `representation`: Update peer's context
- `summary`: Create session summaries
3. Session-based queue processing ensures order
4. Results stored internally in vector DB
#### Theory of Mind System
- Multiple implementation methods (conversational, single_prompt, long_term)
- Facts extracted from messages and stored in collections
- Representations combine short-term inference with long-term facts
- Configurable via peer and session feature flags
### Configuration
- Hierarchical config: config.toml + environment variables
@ -179,7 +172,6 @@ src/
### Key Architectural Decisions
1. **Multi-Peer Sessions**: Sessions can have multiple participants with different observation settings
2. **Flexible Theory of Mind**: Pluggable ToM implementations (conversational, single_prompt, long_term)
3. **Background Processing**: Async queue system for expensive operations
4. **Provider Abstraction**: Model client supports multiple LLM providers
5. **Scoped Authentication**: JWTs can be scoped to workspace, peer, or session level

View File

@ -8,7 +8,7 @@
---
![Static Badge](https://img.shields.io/badge/Version-2.4.2-blue)
![Static Badge](https://img.shields.io/badge/Version-2.5.0-blue)
[![PyPI version](https://img.shields.io/pypi/v/honcho-ai.svg)](https://pypi.org/project/honcho-ai/)
[![NPM version](https://img.shields.io/npm/v/@honcho-ai/sdk.svg)](https://npmjs.org/package/@honcho-ai/sdk)
[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/plasticlabs)
@ -429,7 +429,7 @@ Then modify the values as needed. The TOML file is organized into sections:
- `[cache]` - Redis cache configuration
- `[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
- `[deriver]` - Background worker settings and representation configuration
- `[peer_card]` - Peer card generation settings
- `[summary]` - Session summarization settings
- `[dream]` - Dream processing configuration
@ -501,8 +501,8 @@ Honcho uses a peer-based model where both users and agents are represented as "p
#### Key Features
- **Theory-of-Mind System**: Multiple implementation methods that extract facts from interactions and build comprehensive models of peer psychology
- **Dialectic API**: Provides theory-of-mind informed responses that integrate long-term facts with current context
- **Rich Reasoning System**: Multiple implementation methods that extract facts from interactions and build comprehensive models of peer psychology
- **Dialectic API**: Provides reasoned informed responses that integrate long-term facts with current context
- **Background Processing**: Asynchronous processing pipeline for expensive operations like representation updates and session summarization
- **Multi-Provider Support**: Configurable LLM providers for different use cases
@ -567,7 +567,7 @@ The `Message` represents an atomic data unit that can exist at two levels:
- **Session-level Messages**: Communication between peers within a session context
All messages are labeled by their source peer and can be processed
asynchronously to update theory-of-mind models. This flexible design allows for
asynchronously to update their representations. This flexible design allows for
both conversational interactions and broader data ingestion for personality
modeling.
@ -578,8 +578,7 @@ familiar with RAG based applications will be familiar with these. `Collections`
store vector embedded data that developers and agents can retrieve against using
functions like cosine similarity.
Collections are also used internally by Honcho while creating theory-of-mind
representations of peers.
Collections are also used internally by Honcho while creating representations of peers.
#### Documents
@ -596,7 +595,7 @@ A high level summary of the pipeline is as follows:
1. Messages are created via the API
2. Derivation Tasks are enqueued for background processing including:
- `representation`: To update theory-of-mind representations of `Peers`
- `representation`: To update representations of `Peers`
- `summary`: To create summaries of `Sessions`
3. Session-based queue processing ensures proper ordering
4. Results are stored internally
@ -638,7 +637,7 @@ A developer's application can treat Honcho as an oracle to the `Peer` and
consult it when necessary. Some examples of how to leverage the Dialectic
API include:
- Asking Honcho for a theory-of-mind insight about the `Peer`
- Asking Honcho for a generic or specific insight about the `Peer`
- Asking Honcho to hydrate a prompt with data about the `Peer`s behavior
- Asking Honcho for a 2nd opinion or approach about how to respond to the Peer
- Getting personalized responses that incorporate long-term facts and context

View File

@ -8,23 +8,23 @@ This guide helps you understand which versions of Honcho's API are compatible wi
## Version Compatibility
### Honcho API v2.4.2 (Current)
### Honcho API v2.5.0 (Current)
<CardGroup cols={2}>
<Card title="TypeScript SDK" icon="js">
**Compatible Version:** v1.5.0
**Compatible Version:** v1.6.0
Install with:
```bash
npm install @honcho-ai/sdk@1.5.0
npm install @honcho-ai/sdk@1.6.0
```
</Card>
<Card title="Python SDK" icon="python">
**Compatible Version:** v1.5.0
**Compatible Version:** v1.6.0
Install with:
```bash
pip install honcho-ai==1.5.0
pip install honcho-ai==1.6.0
```
</Card>
</CardGroup>
@ -34,7 +34,9 @@ This guide helps you understand which versions of Honcho's API are compatible wi
| Honcho API Version | TypeScript SDK | Python SDK |
|-------------------|---------------|------------|
| v2.4.2 (Current) | v1.5.0 | v1.5.0 |
| v2.5.0 (Current) | v1.6.0 | v1.6.0 |
| v2.4.3 | v1.5.0 | v1.5.0 |
| v2.4.2 | v1.5.0 | v1.5.0 |
| v2.4.1 | v1.5.0 | v1.5.0 |
| v2.4.0 | v1.5.0 | v1.5.0 |
| v2.3.3 | v1.4.1 | v1.4.1 |

View File

@ -27,7 +27,42 @@ 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.4.2 (Current)">
<Update label="v2.5.0 (Current)">
### Added
- Message level configurations
- CRUD operations for observations
- Comprehensive test cases for harness
- Peer level get_context
- Set Peer Card Method
- Manual dreaming trigger endpoint
### Changed
- Configurations to support more flags for fine-grained control of the deriver, peer cards, summaries, etc.
- Working Representations to support more fine-grained parameters
### Fixed
- File uploads to match `MessageCreate` structure
- Cache invalidation strategy
</Update>
<Update label="v2.4.3">
### Added
- Redis caching to improve DB IO
- Backup LLM provider to avoid failures when a provider is down
### Changed
- QueueItems to use standardized columns
- Improved Deduplication logic for Representation Tasks
- More finegrained metrics for representation, summary, and peer card tasks
- DB constraint to follow standard naming conventions
</Update>
<Update label="v2.4.2">
### Fixed
- Langfuse tracing to have readable waterfalls
@ -365,6 +400,19 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
<Tab title="Python SDK">
[Python SDK](https://pypi.org/project/honcho-ai/)
<Update label="v1.6.0 (Current)">
### Added
- metadata and configuration fields to Workspace, Peer, Session, and Message objects
- Session Clone methods
- Peer level get_context method
- `ObservationScope` object to perform CRUD operations on observations
- Representation object for WorkingRepresentations
### Changed
- methods that take IDs, can all optionally take an object of the same type
</Update>
<Update label="v1.5.0">
### Added
@ -439,6 +487,19 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
<Tab title="TypeScript SDK">
[TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk)
<Update label="v1.6.0 (Current)">
### Added
- metadata and configuration fields to Workspace, Peer, Session, and Message objects
- Session Clone methods
- Peer level get_context method
- `ObservationScope` object to perform CRUD operations on observations
- Representation object for WorkingRepresentations
### Changed
- methods that take IDs, can all optionally take an object of the same type
</Update>
<Update label="v1.5.0">
### Added

View File

@ -9,14 +9,21 @@
},
"favicon": "/favicon.svg",
"contextual": {
"options": ["copy", "view", "chatgpt", "claude"]
"options": [
"copy",
"view",
"chatgpt",
"claude"
]
},
"navigation": {
"versions": [
{
"version": "v2.4.2",
"version": "v2.5.0",
"api": {
"openapi": ["openapi.documented.yml"]
"openapi": [
"openapi.json"
]
},
"tabs": [
{
@ -125,7 +132,9 @@
"groups": [
{
"group": "API Documentation",
"pages": ["v2/api-reference/introduction"]
"pages": [
"v2/api-reference/introduction"
]
},
{
"group": "workspaces",
@ -135,7 +144,8 @@
"v2/api-reference/endpoint/workspaces/update-workspace",
"v2/api-reference/endpoint/workspaces/delete-workspace",
"v2/api-reference/endpoint/workspaces/search-workspace",
"v2/api-reference/endpoint/workspaces/get-deriver-status"
"v2/api-reference/endpoint/workspaces/get-deriver-status",
"v2/api-reference/endpoint/workspaces/trigger-dream"
]
},
{
@ -147,8 +157,10 @@
"v2/api-reference/endpoint/peers/get-sessions-for-peer",
"v2/api-reference/endpoint/peers/chat",
"v2/api-reference/endpoint/peers/get-working-representation",
"v2/api-reference/endpoint/peers/search-peer",
"v2/api-reference/endpoint/peers/get-peer-card"
"v2/api-reference/endpoint/peers/get-peer-card",
"v2/api-reference/endpoint/peers/set-peer-card",
"v2/api-reference/endpoint/peers/get-peer-context",
"v2/api-reference/endpoint/peers/search-peer"
]
},
{
@ -180,6 +192,15 @@
"v2/api-reference/endpoint/messages/create-messages-with-file"
]
},
{
"group": "observations",
"pages": [
"v2/api-reference/endpoint/observations/create-observations",
"v2/api-reference/endpoint/observations/list-observations",
"v2/api-reference/endpoint/observations/query-observations",
"v2/api-reference/endpoint/observations/delete-observation"
]
},
{
"group": "webhooks",
"pages": [
@ -215,7 +236,9 @@
{
"version": "v1.1.0",
"api": {
"openapi": ["openapi.json"]
"openapi": [
"openapi.json"
]
},
"tabs": [
{
@ -245,15 +268,23 @@
"groups": [
{
"group": "Getting Started",
"pages": ["v1/guides/overview", "v1/guides/streaming-response"]
"pages": [
"v1/guides/overview",
"v1/guides/streaming-response"
]
},
{
"group": "Application Interfaces",
"pages": ["v1/guides/discord", "v1/guides/honcho-mcp"]
"pages": [
"v1/guides/discord",
"v1/guides/honcho-mcp"
]
},
{
"group": "Personal Memory",
"pages": ["v1/guides/dialectic-endpoint"]
"pages": [
"v1/guides/dialectic-endpoint"
]
}
]
},
@ -262,7 +293,9 @@
"groups": [
{
"group": "API Documentation",
"pages": ["v1/api-reference/introduction"]
"pages": [
"v1/api-reference/introduction"
]
},
{
"group": "apps",
@ -310,7 +343,9 @@
},
{
"group": "keys",
"pages": ["v1/api-reference/endpoint/keys/create-key"]
"pages": [
"v1/api-reference/endpoint/keys/create-key"
]
},
{
"group": "metamessages",

View File

@ -5,7 +5,7 @@
"main": ".pnp.js",
"scripts": {
"dev": "mint dev",
"openapi": "npx @mintlify/scraping openapi-file openapi.documented.yml -o api-reference/endpoint",
"openapi": "npx @mintlify/scraping openapi-file v2/openapi.json -o v2/api-reference/endpoint",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",

View File

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

View File

@ -0,0 +1,3 @@
---
openapi: delete /v2/workspaces/{workspace_id}/observations/{observation_id}
---

View File

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

View File

@ -0,0 +1,3 @@
---
openapi: post /v2/workspaces/{workspace_id}/observations/query
---

View File

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

View File

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

View File

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

View File

@ -216,7 +216,7 @@ Honcho supports Redis caching to improve performance by caching frequently acces
CACHE_ENABLED=false # Set to true to enable caching
# Redis connection
CACHE_URL=redis://localhost:6379/0?suppress=false
CACHE_URL=redis://localhost:6379/0?suppress=true
# Cache namespace and TTL
CACHE_NAMESPACE=honcho # Prefix for all cache keys

View File

@ -21,7 +21,7 @@ Honcho is a memory system that reasons. Read more on the approach [here](https:/
## Why Use Honcho?
Honcho streamlines the agent building process by offering elegant, flexible primitives for managing context. It also reasons over that context to give developers access to far richer insights only accessible through reasoning.
Honcho streamlines the agent building process by offering elegant, flexible primitives for managing context. It also reasons over that context to give developers access to far richer insights only accessible through reasoning.
Take the following scenario:

View File

@ -34,13 +34,12 @@ the Welcome page with integration guidance and links to documentation.
</Frame>
Each organization has dedicated infrastructure running to isolate your
workloads. Until you activate a subscription under the
[Billing](https://app.honcho.dev/billing) page, the infrastructure will remain
inactive.
workloads. Once you add a valid payment method under the
[Billing](https://app.honcho.dev/billing) page, your instance will turn on.
## 2. Activate your Honcho instance
Navigate to the [Billing](https://app.honcho.dev/billing) page to activate your subscription. Your Honcho instance provisions automatically, and you can monitor the deployment on the [Instance Status](https://app.honcho.dev/status) page until all systems show a green check mark.
Navigate to the [Billing](https://app.honcho.dev/billing) page to add a payment method. Your Honcho instance provisions automatically, and you can monitor the deployment on the [Instance Status](https://app.honcho.dev/status) page until all systems show a green check mark.
<Frame>
<img src="/images/app-screenshots/status-page.png" alt="Instance Status Page" width="1200" height="800" loading="lazy" decoding="async" fetchpriority="low" />

View File

@ -111,7 +111,7 @@ response = alice.chat("What does the user know about weather?")
response = alice.chat("What does the user know about the assistant?", target=assistant)
# Query scoped to a specific session
response = alice.chat("What happened in our conversation?", session_id=session.id)
response = alice.chat("What happened in our conversation?", session=session.id)
```
```typescript TypeScript
@ -258,7 +258,7 @@ print(f"Workspace: {alice.workspace_id}")
# Chat with peer's representations (supports streaming)
response = alice.chat("What did I have for breakfast?")
response = alice.chat("What do I know about Bob?", target="bob")
response = alice.chat("What happened in session-1?", session_id="session-1")
response = alice.chat("What happened in session-1?", session="session-1")
# Add content to a session with a peer
session = honcho.session("session-1")
@ -435,6 +435,148 @@ const bobSearch = await bobObs.query("work history");
```
</CodeGroup>
### Peer Context
The `get_context()` method on peers retrieves both the working representation and peer card in a single API call:
<CodeGroup>
```python Python
# Get peer's own context
context = alice.get_context()
print(context.representation) # Working representation
print(context.peer_card) # Peer card as list of strings
# Get context about another peer (what alice knows about bob)
bob_context = alice.get_context(target="bob")
# Get context with semantic search
context = alice.get_context(
target="bob",
search_query="work preferences",
search_top_k=10,
search_max_distance=0.8,
include_most_derived=True,
max_observations=50
)
```
```typescript TypeScript
// Get peer's own context
const context = await alice.getContext();
console.log(context.representation); // Working representation
console.log(context.peerCard); // Peer card as array of strings
// Get context about another peer (what alice knows about bob)
const bobContext = await alice.getContext("bob");
// Get context with semantic search
const searchedContext = await alice.getContext("bob", {
searchQuery: "work preferences",
searchTopK: 10,
searchMaxDistance: 0.8,
includeMostDerived: true,
maxObservations: 50
});
```
</CodeGroup>
### Observations
Peers can access their observations (facts derived from messages) through the `observations` property and `observations_of()` method:
<CodeGroup>
```python Python
# Access self-observations (what honcho knows about alice)
self_obs = alice.observations
# List self-observations
obs_list = self_obs.list()
# Search self-observations semantically
results = self_obs.query("food preferences")
# Delete an observation
self_obs.delete("observation-id")
# Access observations of another peer (what alice knows about bob)
bob_obs = alice.observations_of("bob")
bob_obs_list = bob_obs.list()
bob_search = bob_obs.query("work history")
```
```typescript TypeScript
// Access self-observations (what honcho knows about alice)
const selfObs = alice.observations;
// List self-observations
const obsList = await selfObs.list();
// Search self-observations semantically
const results = await selfObs.query("food preferences");
// Delete an observation
await selfObs.delete("observation-id");
// Access observations of another peer (what alice knows about bob)
const bobObs = alice.observationsOf("bob");
const bobObsList = await bobObs.list();
const bobSearch = await bobObs.query("work history");
```
</CodeGroup>
#### Creating Observations Manually
You can also create observations directly, which is useful for importing data or adding explicit facts:
<CodeGroup>
```python Python
# Create observations for what alice knows about bob
bob_obs = alice.observations_of("bob")
# Create a single observation
created = bob_obs.create([
{"content": "User prefers dark mode", "session_id": "session-1"}
])
# Create multiple observations in batch
created = bob_obs.create([
{"content": "User prefers dark mode", "session_id": "session-1"},
{"content": "User works late at night", "session_id": "session-1"},
{"content": "User enjoys programming", "session_id": "session-1"},
])
# Returns list of created Observation objects with IDs
for obs in created:
print(f"Created observation: {obs.id} - {obs.content}")
```
```typescript TypeScript
// Create observations for what alice knows about bob
const bobObs = alice.observationsOf("bob");
// Create a single observation
const created = await bobObs.create([
{ content: "User prefers dark mode", sessionId: "session-1" }
]);
// Create multiple observations in batch
const batchCreated = await bobObs.create([
{ content: "User prefers dark mode", sessionId: "session-1" },
{ content: "User works late at night", sessionId: "session-1" },
{ content: "User enjoys programming", sessionId: "session-1" },
]);
// Returns array of created Observation objects with IDs
for (const obs of batchCreated) {
console.log(`Created observation: ${obs.id} - ${obs.content}`);
}
```
</CodeGroup>
<Info>
Manually created observations are marked as "explicit" and are treated the same as system-derived observations. Each observation must be tied to a session and the content length is validated against the embedding token limit.
</Info>
### Session
Manages multi-party conversations:
@ -504,11 +646,19 @@ searched_rep = session.working_rep(
# Upload a file to create messages
messages = session.upload_file(
file=open("document.pdf", "rb"),
peer_id="user",
peer="user",
metadata={"source": "upload"},
created_at="2024-01-15T10:30:00Z"
)
# Clone a session (creates a copy with all data)
# Copies: messages, metadata, configuration, peers, and peer configurations
cloned = session.clone()
# Clone up to a specific message (inclusive)
# Only messages up to and including the specified message are copied
cloned_partial = session.clone(message_id="msg-123")
# Delete session (async - returns 202)
session.delete()
@ -581,6 +731,14 @@ const messages = await session.uploadFile(
}
);
// Clone a session (creates a copy with all data)
// Copies: messages, metadata, configuration, peers, and peer configurations
const cloned = await session.clone();
// Clone up to a specific message (inclusive)
// Only messages up to and including the specified message are copied
const clonedPartial = await session.clone("msg-123");
// Delete session (async - returns 202)
await session.delete();
@ -713,7 +871,7 @@ group_chat.add_messages([
# Query different perspectives
user_perspective = users[0].chat("What are people's concerns?")
moderator_view = moderator.chat("What feedback am I getting?", session_id=group_chat.id)
moderator_view = moderator.chat("What feedback am I getting?", session=group_chat.id)
```
```typescript TypeScript

View File

@ -12,17 +12,26 @@ These guides provide concrete examples and implementation patterns for building
Each guide focuses on a specific use case with practical examples. The goal is to get you from idea to working prototype as quickly as possible, then provide the depth you need to scale and customize.
## Getting Started
Quick integration guides to get up and running:
<CardGroup cols={2}>
<Card title="MCP Integration" icon="link" href="/v2/integrations/mcp">
Get Honcho running with a single prompt in Claude Code
</Card>
<Card title="LangGraph" icon="diagram-project" href="/v2/integrations/langgraph">
Add persistent memory and theory of mind to your LangGraph agents
</Card>
</CardGroup>
## Application Interfaces
Ready-to-use integration patterns for popular platforms:
<CardGroup cols={3}>
<CardGroup cols={2}>
<Card title="Discord Bot" icon="discord" href="/v2/guides/discord">
Build a Discord bot that remembers users across conversations
</Card>
<Card title="Telegram Bot" icon="telegram" href="/v2/guides/telegram">
Create a Telegram bot with persistent user understanding
</Card>
<Card title="MCP" icon="link" href="/v2/guides/mcp">
Get Honcho running with a single prompt in Cursor or Claude Code
</Card>
</CardGroup>

View File

@ -0,0 +1,296 @@
---
title: 'Migrating from Mem0'
description: 'A guide to migrate from Mem0 to Honcho'
icon: 'arrow-right-arrow-left'
---
Interested in transferring your data from Mem0 to Honcho? This guide covers why to switch, how to migrate your data, and differences between the two products.
## Why Honcho?
Mem0 & Honcho both store your data. Only Honcho reasons about it. [Read more about our approach](https://blog.plasticlabs.ai/blog/Memory-as-Reasoning).
**Compounding Insights** - Honcho extracts insights that build on each other over time. The more your users interact, the richer and more accurate their profiles become.
**Superior Performance** - Higher accuracy on memory retrieval benchmarks with faster inference times (more details soon!).
**Competitive Pricing** - Mem0 charges for retrieval, not ingestion. Meaning you pay to access your own data. Honcho offers straightforward pricing with a generous free tier.
**Advanced Multi-Peer Sessions** - Honcho offers configurable observation settings (who builds memories about whom), representation-based queries between participants, and first-class peer objects.
<Note>
We would love to support the transfer and cost—just [book a call!](https://cal.com/team/plasticlabs/migration-to-honcho)
</Note>
## Quick Migration
For the best results, we recommend importing your raw messages directly into Honcho. This gives Honcho the full context to build rich, accurate representations and enables features like session summaries.
However, if you'd like to get started quickly, you can migrate your existing Mem0 memories directly as **observations**.
<Info>
Get your API key at [app.honcho.dev/api-keys](https://app.honcho.dev/api-keys). New accounts start with $100 credits.
</Info>
<CodeGroup>
```python Python
# pip install mem0ai honcho-ai
from mem0 import MemoryClient
from honcho import Honcho
# Export from Mem0
mem0 = MemoryClient(api_key="your-mem0-api-key")
memories = mem0.get_all(filters={"user_id": "user123"}, page_size=100)
# Initialize Honcho
honcho = Honcho(api_key="your-honcho-api-key")
user = honcho.peer("user123")
session = honcho.session("imported")
session.add_peers([user])
# Import memories directly as observations
observations = []
for memory in memories['results']:
content = memory.get("memory") or memory.get("messages", [{}])[0].get("content", "")
if content:
observations.append({"content": content, "session_id": "imported"})
# Batch create observations (up to 100 at a time)
if observations:
user.observations.create(observations)
print(f"Migrated {len(observations)} memories as observations!")
```
```typescript TypeScript
// npm install mem0ai @honcho-ai/sdk
import MemoryClient from "mem0ai";
import { Honcho } from "@honcho-ai/sdk";
// Export from Mem0
const mem0 = new MemoryClient({ apiKey: "your-mem0-api-key" });
const memories = await mem0.getAll({ filters: { user_id: "user123" }, page_size: 100 });
// Initialize Honcho
const honcho = new Honcho({ apiKey: "your-honcho-api-key" });
const user = await honcho.peer("user123");
const session = await honcho.session("imported");
await session.addPeers([user]);
// Import memories directly as observations
const observations = memories.results
.map(memory => ({
content: memory.memory || memory.messages?.[0]?.content || "",
session_id: "imported"
}))
.filter(obs => obs.content);
// Batch create observations (up to 100 at a time)
if (observations.length > 0) {
await user.observations.create(observations);
}
console.log(`Migrated ${observations.length} memories as observations!`);
```
</CodeGroup>
That's it! The user's Mem0 memories are now searchable in Honcho as observations. For richer representations with deductive reasoning and session summaries, consider importing your raw messages as described in the [Step-by-Step Migration](#step-by-step-migration) section.
For more details on replacing Mem0 API calls with Honcho equivalents go to [API Comparison](#api-comparison).
## Step-by-Step Migration
Prefer a more detailed walkthrough? Follow these steps:
### 1. Export User Messages
Importing raw user messages gives Honcho the full conversational context to build the most accurate representations. We recommend using a data structure that preserves the session and peer structure.
<Note>
If you need any help with this transfer or have any questions, please reach out at hello@plasticlabs.ai or [book a call!](https://cal.com/team/plasticlabs/migration-to-honcho)
</Note>
Alternatively, if you want to import the Mem0 memories, follow the example above and find more info in Mem0's [export API documentation](https://docs.mem0.ai/cookbooks/essentials/exporting-memories).
### 2. Install the Honcho SDK
<CodeGroup>
```bash Python (uv)
uv add honcho-ai
```
```bash Python (pip)
pip install honcho-ai
```
```bash TypeScript (npm)
npm install @honcho-ai/sdk
```
```bash TypeScript (yarn)
yarn add @honcho-ai/sdk
```
```bash TypeScript (pnpm)
pnpm add @honcho-ai/sdk
```
</CodeGroup>
### 3. Initialize the Honcho Client
<Info>
Get your API key at [app.honcho.dev/api-keys](https://app.honcho.dev/api-keys). New accounts start with $100 credits.
</Info>
<CodeGroup>
```python Python
from honcho import Honcho
honcho = Honcho( api_key="your-api-key" )
```
```typescript TypeScript
import { Honcho } from '@honcho-ai/sdk';
const honcho = new Honcho({apiKey: process.env.HONCHO_API_KEY!});
```
</CodeGroup>
### 4. Import Your Data
This is a possible implementation using raw user messages. Adapt the data structure to match your exported format.
<CodeGroup>
```python Python
# Example data structure (preserving message history with timestamps):
exported_data = {
"session-1": {
"user123": [
{"content": "I prefer dark mode", "timestamp": "2024-01-15T10:30:00Z"},
{"content": "My name is Alex", "timestamp": "2024-01-15T10:31:00Z"},
],
"user456": [
{"content": "I work in finance", "timestamp": "2024-01-15T11:00:00Z"},
{"content": "I like concise responses", "timestamp": "2024-01-15T11:02:00Z"},
],
},
"session-2": {
"user123": [
{"content": "Meeting notes from last week...", "timestamp": "2024-01-16T09:00:00Z"},
],
}
}
# Import into Honcho
for session_name, users in exported_data.items():
session = honcho.session(session_name)
for user_id, messages in users.items():
peer = honcho.peer(user_id)
session.add_peers([peer])
# Sort by timestamp to preserve message order
sorted_messages = sorted(messages, key=lambda m: m["timestamp"])
session.add_messages([peer.message(m["content"]) for m in sorted_messages])
```
```typescript TypeScript
// Example data structure (preserving message history with timestamps):
interface Message {
content: string;
timestamp: string;
}
const exportedData: Record<string, Record<string, Message[]>> = {
"session-1": {
"user123": [
{ content: "I prefer dark mode", timestamp: "2024-01-15T10:30:00Z" },
{ content: "My name is Alex", timestamp: "2024-01-15T10:31:00Z" },
],
"user456": [
{ content: "I work in finance", timestamp: "2024-01-15T11:00:00Z" },
{ content: "I like concise responses", timestamp: "2024-01-15T11:02:00Z" },
],
},
"session-2": {
"user123": [
{ content: "Meeting notes from last week...", timestamp: "2024-01-16T09:00:00Z" },
],
}
};
// Import into Honcho
for (const [sessionName, users] of Object.entries(exportedData)) {
const session = await honcho.session(sessionName);
for (const [userId, messages] of Object.entries(users)) {
const peer = await honcho.peer(userId);
await session.addPeers([peer]);
// Sort by timestamp to preserve message order
const sortedMessages = messages.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
await session.addMessages(sortedMessages.map((m) => peer.message(m.content)));
}
}
```
</CodeGroup>
### 5. Update Your Application Code
Reference the [API Comparison](#api-comparison) to replace your Mem0 API calls with the Honcho equivalents.
## API Comparison
### Core Operations
| Operation | Mem0 | Honcho | Notes |
|-----------|------|--------|-------|
| **Initialize** | `MemoryClient(api_key=...)` | `Honcho(api_key=...)` | |
| **Identity** | `user_id` string param | `peer = honcho.peer("id")` | Peers can be users or AI agents |
| **Add messages** | `client.add(messages, user_id=...)` | `session.add_messages([peer.message(...)])` | Session-scoped, triggers reasoning |
| **Add observations** | | `peer.observations.create([...])` | Direct observation or "memory" import, no processing |
| **Search** | `client.search(query, filters={"user_id": ...})` | `peer.search(query)` or `peer.observations.query(...)` | Scoped to peer or session |
| **List all** | `client.get_all(filters={"user_id": ...})` | `session.get_messages()` or `peer.observations.list()` | Messages or observations |
| **Update** | `client.update(memory_id, data=...)` | `honcho.update_message(message, metadata=...)` | Metadata updates only |
| **Delete** | `client.delete(memory_id)` | `peer.observations.delete(id)` or `session.delete()` | Observation or session-level |
### Honcho-Only Capabilities
Mem0 requires manual assembly of context from `search()` results. Honcho's `session.get_context()` returns a ready-to-use `SessionContext` object with built-in token limits, auto-included summaries, and format helpers (`.to_openai()`, `.to_anthropic()`).
<Card title="Get Context" icon="window-restore" href="../../documentation/core-concepts/features/get-context">
Learn more about token-optimized context retrieval
</Card>
Mem0's `search()` returns basic vector, semantic, or raw memory matches. Honcho's `peer.chat()` enables your agent to *reason* about what it knows—returning synthesized natural language insights with streaming support and scoped queries.
<Card title="Dialectic Endpoint" icon="brain" href="../../documentation/core-concepts/features/dialectic-endpoint">
Learn more about inference-powered queries
</Card>
Additional features with **no Mem0 equivalent**:
| Honcho Method | Description | Use Case |
|---------------|-------------|----------|
| `peer.card()` | Stable biographical facts (name, preferences, background) | User profiles, personalization |
| `session.working_rep(peer)` | Cached psychological analysis (mental state, intentions) | Real-time adaptation |
| `session.get_summaries()` | Auto-generated short/long session summaries | Conversation continuity |
| `SessionPeerConfig` | Configure observation settings (who learns about whom) | Privacy controls, role-based learning |
## Next Steps
<CardGroup cols={3}>
<Card title="Architecture" icon="rocket" href="../../documentation/core-concepts/architecture">
Understand peers and sessions
</Card>
<Card title="Dialectic API" icon="brain" href="../../documentation/core-concepts/features/dialectic-endpoint">
Inference responses
</Card>
<Card title="Guides" icon="book" href="../../guides/overview">
Integration examples
</Card>
</CardGroup>
Questions? Join our [Discord](https://discord.gg/honcho) or open an issue on [GitHub](https://github.com/plastic-labs/honcho/issues).

File diff suppressed because it is too large Load Diff

4441
docs/v2/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -0,0 +1,62 @@
# Honcho CrewAI Integration
Build CrewAI agents with persistent memory and reasoning capabilities powered by Honcho.
## Installation
```bash
pip install honcho-crewai
```
## Quick Start
```python
from crewai import Agent, Task, Crew, Process
from crewai.memory.external.external_memory import ExternalMemory
from honcho_crewai import HonchoStorage
# Initialize Honcho storage
storage = HonchoStorage(user_id="user-123")
external_memory = ExternalMemory(storage=storage)
# Create agent with memory
agent = Agent(
role="AI Assistant",
goal="Help users with persistent memory",
backstory="You remember past conversations.",
)
# Create crew with external memory
crew = Crew(
agents=[agent],
tasks=[task],
external_memory=external_memory
)
```
## Features
- **Automatic Memory**: CrewAI agents automatically store and retrieve conversation context
- **Semantic Search**: Find relevant past messages using vector similarity
- **Logical Reasoning**: Query what the system knows about users via the Dialectic API
- **Multi-Agent Support**: Give each agent distinct memory and identity
- **Tools Integration**: `HonchoGetContextTool`, `HonchoDialecticTool`, and `HonchoSearchTool` for explicit memory control
## Documentation
For comprehensive guides, examples, and API reference, visit:
**[https://docs.honcho.dev/v2/integrations/crewai](https://docs.honcho.dev/v2/integrations/crewai)**
## Examples
Check out complete examples in the [GitHub repository](https://github.com/plastic-labs/honcho/tree/main/examples/crewai/python/examples).
## License
AGPL-3.0-or-later
## Support
- Report issues: [GitHub Issues](https://github.com/plastic-labs/honcho/issues)
- Documentation: [docs.honcho.dev](https://docs.honcho.dev)
- Website: [honcho.dev](https://honcho.dev)

View File

@ -0,0 +1,99 @@
"""
Hybrid Memory Example: Combining Automatic Memory + Explicit Tools
Demonstrates combining automatic memory (HonchoStorage) with explicit memory tools.
The agent gets baseline context automatically but can also make targeted queries.
"""
from dotenv import load_dotenv
from crewai import Agent, Task, Crew, Process
from crewai.memory.external.external_memory import ExternalMemory
from honcho import Honcho
from honcho_crewai import (
HonchoStorage,
HonchoSearchTool,
HonchoDialecticTool,
)
load_dotenv()
def main():
"""Hybrid memory example with automatic baseline + explicit tools."""
# Initialize Honcho
honcho = Honcho()
user_id = "hybrid-demo-user"
session_id = "hybrid-demo-session"
# Setup automatic memory
storage = HonchoStorage(
user_id=user_id,
session_id=session_id,
honcho_client=honcho
)
external_memory = ExternalMemory(storage=storage)
# Add conversation history
messages = [
("user", "I'm planning a trip to Japan next spring"),
("assistant", "How exciting! Japan is beautiful in spring."),
("user", "I love Japanese cuisine, especially ramen and sushi"),
("assistant", "You'll find amazing food there!"),
("user", "My budget is around $3000 for the whole trip"),
("assistant", "That's a good budget for a memorable trip."),
("user", "I prefer cultural experiences over touristy attractions"),
]
for role, message in messages:
external_memory.save(message, metadata={"agent": role})
# Create memory tools for targeted queries
search_tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
dialectic_tool = HonchoDialecticTool(
honcho=honcho, session_id=session_id, peer_id=user_id
)
# Create agent with both automatic memory AND tools
travel_agent = Agent(
role="Travel Advisor",
goal="Create personalized travel recommendations using memory",
backstory=(
"You are a travel advisor with access to conversation history. "
"You can use tools to search for specific details or understand preferences."
),
tools=[search_tool, dialectic_tool],
verbose=True,
allow_delegation=False
)
# Create task
task = Task(
description=(
"Create a 3-day Tokyo itinerary for the user.\n\n"
"Use search_tool to find their budget and food preferences.\n"
"Use query_peer_knowledge to understand their travel style.\n"
"Then create a personalized itinerary with activities and restaurant recommendations."
),
expected_output="A 3-day Tokyo itinerary with daily activities and dining suggestions",
agent=travel_agent
)
# Execute with hybrid memory: automatic baseline + explicit tools
crew = Crew(
agents=[travel_agent],
tasks=[task],
process=Process.sequential,
external_memory=external_memory, # Automatic memory!
verbose=True
)
result = crew.kickoff()
print("\n" + "=" * 70)
print("RESULT")
print("=" * 70)
print(result.raw)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,115 @@
"""
CrewAI Integration with Honcho and OpenAI
This example demonstrates how to build AI agents with persistent memory using
CrewAI for agent orchestration, OpenAI for the AI model, and Honcho for memory
management via the honcho_crewai package.
"""
from typing import Optional
from dotenv import load_dotenv
from crewai import Agent, Task, Crew, Process
from crewai.memory.external.external_memory import ExternalMemory
from honcho_crewai import HonchoStorage
load_dotenv()
def run_conversation_turn(
user_id: str,
user_input: str,
session_id: Optional[str] = None,
storage: Optional[HonchoStorage] = None
) -> tuple[str, HonchoStorage]:
"""
Run a single conversation turn with the CrewAI agent.
Args:
user_id: Unique identifier for the user
user_input: User's message
session_id: Optional session ID for conversation continuity
storage: Optional existing HonchoStorage instance
Returns:
Tuple of (agent_response, storage_instance)
"""
# Initialize or reuse storage
if storage is None:
if not session_id:
session_id = f"session_{user_id}"
storage = HonchoStorage(user_id=user_id, session_id=session_id)
# Create ExternalMemory wrapper for automatic context retrieval
external_memory = ExternalMemory(storage=storage)
# Save user input to memory
external_memory.save(user_input, metadata={"agent": "user"})
# Create an agent with memory
agent = Agent(
role="AI Assistant",
goal="Help users with their questions and remember context from previous conversations",
backstory=(
"You are a helpful AI assistant with the ability to remember past conversations. "
"You use context from previous interactions to provide personalized and relevant responses."
),
verbose=False,
allow_delegation=False
)
# Create task for the agent
task = Task(
description=f"Respond to the user's message: {user_input}",
expected_output="A helpful and contextually relevant response that considers conversation history",
agent=agent
)
# Create crew with external memory - enables automatic context retrieval
crew = Crew(
agents=[agent],
tasks=[task],
process=Process.sequential,
external_memory=external_memory,
verbose=False
)
# Execute - CrewAI automatically retrieves relevant context from Honcho
result = crew.kickoff()
# Save assistant response back to memory
response_text = str(result.raw)
external_memory.save(response_text, metadata={"agent": "assistant"})
return response_text, storage
def main():
"""Interactive chat loop with CrewAI agent powered by Honcho memory."""
print("Welcome to the AI Assistant powered by CrewAI and Honcho!")
print("Type 'quit' or 'exit' to end the conversation.\n")
user_id = "demo-user-123"
storage = None
while True:
user_input = input("You: ")
if user_input.lower() in ['quit', 'exit']:
print("Goodbye!")
break
if not user_input.strip():
continue
try:
response, storage = run_conversation_turn(
user_id=user_id,
user_input=user_input,
storage=storage
)
print(f"Assistant: {response}\n")
except Exception as e:
print(f"Error: {e}\n")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,72 @@
"""
Simple Honcho + CrewAI Example
A minimal example showing how to use Honcho's ExternalMemory with CrewAI agents.
This demonstrates the basic pattern for persistent conversation memory.
"""
from dotenv import load_dotenv
from crewai import Agent, Task, Crew, Process
from crewai.memory.external.external_memory import ExternalMemory
from honcho_crewai import HonchoStorage
load_dotenv()
def main():
"""Simple example of CrewAI agent with Honcho memory."""
# Initialize Honcho storage
storage = HonchoStorage(user_id="simple-demo-user")
external_memory = ExternalMemory(storage=storage)
# Add some conversation history
messages = [
("user", "I'm learning Python programming"),
("assistant", "Great! Python is an excellent language to learn."),
("user", "I'm particularly interested in web development"),
]
for role, message in messages:
external_memory.save(message, metadata={"agent": role})
# Create agent with memory
agent = Agent(
role="Programming Mentor",
goal="Help users learn programming by remembering their interests and progress",
backstory=(
"You are a patient programming mentor who remembers what students "
"have told you about their learning journey and interests."
),
verbose=True,
allow_delegation=False
)
# Create task
task = Task(
description=(
"Based on what you know about the user's interests, "
"suggest a simple web development project they could build to practice Python."
),
expected_output="A specific project suggestion with brief explanation",
agent=agent
)
# Execute with memory - CrewAI automatically retrieves relevant context!
crew = Crew(
agents=[agent],
tasks=[task],
process=Process.sequential,
external_memory=external_memory,
verbose=True
)
result = crew.kickoff()
print("\n" + "=" * 70)
print("RESULT")
print("=" * 70)
print(result.raw)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,125 @@
"""
Honcho Tools with CrewAI Example
Demonstrates how to equip CrewAI agents with Honcho's memory tools:
- HonchoGetContextTool: Retrieve session context with token limits
- HonchoDialecticTool: Query representations about peers
- HonchoSearchTool: Perform semantic search across session messages
These tools give agents explicit control over memory retrieval, beyond the
automatic memory provided by ExternalMemory.
"""
from dotenv import load_dotenv
from crewai import Agent, Task, Crew, Process
from honcho import Honcho
from honcho_crewai import (
HonchoGetContextTool,
HonchoDialecticTool,
HonchoSearchTool,
)
load_dotenv()
def main():
"""Demonstrate Honcho tools with CrewAI agents."""
print("=" * 70)
print("HONCHO TOOLS + CREWAI EXAMPLE")
print("=" * 70 + "\n")
# Step 1: Setup session with conversation history
print("1. Setting up session with conversation history...\n")
honcho = Honcho()
user_id = "demo-user-45"
session_id = "tools-demo-session"
user = honcho.peer(user_id)
session = honcho.session(session_id)
# Add conversation history
messages = [
"I'm planning a trip to Japan in March",
"I love trying authentic local cuisine, especially ramen and sushi",
"My budget is around $3000 for a 10-day trip",
"I'm interested in visiting both Tokyo and Kyoto",
"I prefer staying in traditional ryokans over hotels",
]
for msg in messages:
print(f" • {msg}")
session.add_messages([user.message(msg)])
print("\n ✓ Session created with 5 messages\n")
# Step 2: Create Honcho tools
print("2. Creating Honcho memory tools...\n")
context_tool = HonchoGetContextTool(
honcho=honcho, session_id=session_id, peer_id=user_id
)
print(" ✓ get_session_context - Retrieve conversation context")
dialectic_tool = HonchoDialecticTool(
honcho=honcho, session_id=session_id, peer_id=user_id
)
print(" ✓ query_peer_knowledge - Ask about user preferences")
search_tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
print(" ✓ search_session_messages - Semantic search messages\n")
# Step 3: Create agent with tools
print("3. Creating travel planning agent with memory tools...\n")
travel_agent = Agent(
role="Travel Planning Specialist",
goal="Create personalized travel recommendations using memory tools",
backstory=(
"You are an expert travel planner with access to conversation memory tools. "
"Use the tools to understand the user's preferences before making recommendations."
),
tools=[context_tool, dialectic_tool, search_tool],
verbose=True,
allow_delegation=False
)
print(" ✓ Agent created with 3 Honcho tools\n")
# Step 4: Create task
print("4. Creating task...\n")
task = Task(
description=(
"Create a personalized 3-day Tokyo itinerary. "
"Use the memory tools to understand:\n"
" • Food preferences (search for 'cuisine' or 'food')\n"
" • Travel style and budget (query user knowledge)\n"
" • Recent context (get conversation context)\n"
"Then create a detailed plan matching their interests."
),
expected_output=(
"A 3-day Tokyo itinerary with:\n"
" • Daily activities matching user interests\n"
" • Restaurant recommendations\n"
" • Accommodation suggestions\n"
" • Budget considerations"
),
agent=travel_agent
)
print(" ✓ Task created\n")
# Step 5: Execute
print("5. Executing crew (agent will use tools to retrieve memory)...\n")
print("-" * 70 + "\n")
crew = Crew(
agents=[travel_agent],
tasks=[task],
process=Process.sequential,
verbose=True
)
crew.kickoff()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,47 @@
[project]
name = "honcho-crewai"
version = "0.1.0"
description = "CrewAI integration with Honcho for persistent agent memory"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "AGPL-3.0-or-later"}
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"}
]
maintainers = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"}
]
keywords = [
"honcho",
"crewai",
"ai-agents",
"memory",
"agent-memory",
"persistent-memory"
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Software Development :: Libraries :: Python Modules",
"Framework :: Pydantic",
]
dependencies = [
"crewai>=0.134.0",
"honcho-ai>=0.2.0",
"openai>=1.0.0",
"python-dotenv>=1.0.0",
]
[project.urls]
Homepage = "https://honcho.dev"
Documentation = "https://docs.honcho.dev/v2/integrations/crewai"
Repository = "https://github.com/plastic-labs/honcho"
"Bug Tracker" = "https://github.com/plastic-labs/honcho/issues"
Changelog = "https://github.com/plastic-labs/honcho/blob/main/CHANGELOG.md"

View File

@ -0,0 +1,63 @@
"""
Honcho CrewAI Integration
This package provides seamless integration between Honcho and CrewAI,
enabling AI agents to maintain persistent memory across conversations.
Example:
```python
from honcho_crewai import HonchoStorage, HonchoSearchTool, HonchoGetContextTool, HonchoDialecticTool
from crewai.memory.external.external_memory import ExternalMemory
from crewai import Agent, Task, Crew
from honcho import Honcho
# Initialize Honcho client and storage
honcho = Honcho()
storage = HonchoStorage(user_id="user123", honcho_client=honcho)
external_memory = ExternalMemory(storage=storage)
# Create tools for agents
search_tool = HonchoSearchTool(honcho=honcho, session_id=storage.session_id)
context_tool = HonchoGetContextTool(honcho=honcho, session_id=storage.session_id, peer_id="user123")
dialectic_tool = HonchoDialecticTool(honcho=honcho, session_id=storage.session_id, peer_id="user123")
# Create agent with memory and tools
agent = Agent(
role="AI Assistant",
goal="Help users with persistent memory",
backstory="You remember past conversations.",
tools=[search_tool, context_tool, dialectic_tool],
)
# Define a task for the crew
task = Task(
description="Help the user with their request",
expected_output="A helpful response",
agent=agent,
)
# Create crew with external memory
crew = Crew(
agents=[agent],
tasks=[task],
external_memory=external_memory
)
```
"""
from honcho_crewai.exceptions import HonchoDependencyError
from honcho_crewai.storage import HonchoStorage
from honcho_crewai.tools import (
HonchoDialecticTool,
HonchoGetContextTool,
HonchoSearchTool,
)
__version__ = "0.1.0"
__all__ = [
"HonchoDependencyError",
"HonchoDialecticTool",
"HonchoGetContextTool",
"HonchoSearchTool",
"HonchoStorage",
]

View File

@ -0,0 +1,14 @@
"""
Exception classes for CrewAI integration.
"""
class HonchoDependencyError(ImportError):
"""Raised when required CrewAI dependencies are not installed."""
def __init__(self, framework: str, install_command: str) -> None:
self.framework = framework
self.install_command = install_command
super().__init__(
f"{framework} dependencies not found. Install with: {install_command}"
)

View File

@ -0,0 +1,188 @@
"""
Honcho Storage for CrewAI External Memory
This module provides a Honcho-backed storage provider for CrewAI's external memory
system, enabling AI agents to maintain persistent conversation memory across sessions.
"""
import logging
import uuid
from typing import Any, Optional
from crewai.memory.storage.interface import Storage
from honcho import Honcho
logger = logging.getLogger(__name__)
class HonchoStorage(Storage):
"""
Honcho-backed storage provider for CrewAI external memory.
Implements CrewAI's Storage interface using Honcho's session-based memory,
allowing agents to maintain context across conversations.
Attributes:
honcho: The Honcho client instance
user: Peer representing the user
assistant: Peer representing the AI assistant
session: The conversation session
session_id: Unique identifier for the session
Example:
```python
from honcho_crewai import HonchoStorage
from crewai.memory.external.external_memory import ExternalMemory
# Initialize storage
storage = HonchoStorage(user_id="user123")
# Use with CrewAI's external memory
external_memory = ExternalMemory(storage=storage)
```
"""
def __init__(
self,
user_id: str,
session_id: Optional[str] = None,
honcho_client: Optional[Honcho] = None,
) -> None:
"""
Initialize Honcho storage for a specific user and session.
Args:
user_id: Unique identifier for the user
session_id: Optional session ID. If not provided, a UUID will be generated
honcho_client: Optional Honcho client instance. If not provided, creates one
using the demo environment (https://demo.honcho.dev)
"""
self.honcho = honcho_client or Honcho()
# Initialize user and assistant peers
self.user = self.honcho.peer(user_id)
self.assistant = self.honcho.peer("assistant")
# Create or use existing session
if not session_id:
session_id = str(uuid.uuid4())
self.session = self.honcho.session(session_id)
self.session_id = session_id
def save(self, value: Any, metadata: dict[str, Any]) -> None:
"""
Save a message to Honcho session.
This method is called by CrewAI to store messages and context. Messages
are associated with the appropriate peer (user or assistant) based on
the metadata.
Args:
value: Message content to save
metadata: Metadata dict that may contain 'role', 'agent', or 'type' info
Common keys: 'role', 'agent', 'type'
"""
try:
# Determine if this is from user or assistant based on metadata
# Check various metadata keys that might indicate the role
role = metadata.get("role", metadata.get("agent", "assistant"))
is_user = role == "user"
peer = self.user if is_user else self.assistant
content_str = str(value)
# Add message to session
self.session.add_messages([peer.message(content_str, metadata=metadata)])
logger.debug(
"Saved message from %s: %s...",
metadata.get("name", role),
content_str[:100],
)
except Exception:
logger.exception("Error saving to Honcho")
raise
def search(
self,
query: str,
limit: int = 10,
score_threshold: float = 0.5,
filters: Optional[dict[str, Any]] = None,
) -> list[dict[str, Any]]:
"""
Search for relevant messages using semantic search.
This method uses Honcho's semantic vector search to find messages most
relevant to the query.
Args:
query: Search query used for semantic matching
limit: Maximum number of messages to retrieve
score_threshold: Minimum relevance score (not currently used by Honcho API)
filters: Optional filters to scope the search. Supports Honcho's filter syntax
including logical operators (AND, OR, NOT), comparison operators
(gt, gte, lt, lte, eq, ne), and metadata filtering.
Example: {"peer_id": "user123"} or {"metadata": {"type": "important"}}
See: https://docs.honcho.dev/v2/documentation/core-concepts/features/using-filters
Returns:
List of message dictionaries in CrewAI expected format.
Each dict contains:
- content: The message content
- memory: The message content (required by CrewAI)
- context: The message content (for compatibility)
- metadata: Message metadata including peer_id, created_at, and custom metadata
"""
try:
results = []
# Use semantic search to find relevant messages
# This performs vector similarity search on message content
messages = self.session.search(query=query, filters=filters, limit=limit)
# Convert to CrewAI expected format
for msg in messages:
# Build base metadata with peer_id and created_at
metadata = {
"peer_id": msg.peer_id,
"created_at": str(msg.created_at) if hasattr(msg, "created_at") else None,
}
# Merge custom metadata if present
if hasattr(msg, "metadata") and msg.metadata:
metadata.update(msg.metadata)
results.append(
{
"content": msg.content,
"memory": msg.content,
"context": msg.content,
"metadata": metadata,
}
)
logger.debug("Search for '%s' returned %d results", query, len(results))
return results
except Exception:
logger.exception("Error searching Honcho")
raise
def reset(self) -> None:
"""
Create a new session, effectively resetting memory.
This creates a new Honcho session with a fresh UUID, allowing the agent
to start a new conversation without the previous context.
"""
try:
new_session_id = str(uuid.uuid4())
self.session = self.honcho.session(new_session_id)
self.session_id = new_session_id
logger.debug("Reset session. New session ID: %s", new_session_id)
except Exception:
logger.exception("Error resetting Honcho session")
raise

View File

@ -0,0 +1,303 @@
"""
Honcho Tools for CrewAI
This module provides tools that allow CrewAI agents to interact with Honcho's
session context, dialectic API, and semantic search capabilities.
"""
import logging
from typing import Any, Optional
from crewai.tools import BaseTool
from honcho import Honcho
from pydantic import BaseModel, Field, PrivateAttr
logger = logging.getLogger(__name__)
# Input Schemas
class GetContextInput(BaseModel):
"""Input schema for get_context tool."""
tokens: Optional[int] = Field(
default=None, gt=0, description="Maximum number of tokens to include in the context"
)
peer_target: Optional[str] = Field(
default=None, description="A peer ID to get context for (retrieves representation and peer card)"
)
summary: bool = Field(
default=True, description="Whether to include session summary in the context"
)
peer_perspective: Optional[str] = Field(
default=None, description="Peer ID to use as the perspective for context retrieval"
)
class DialecticInput(BaseModel):
"""Input schema for dialectic (chat) tool."""
query: str = Field(..., min_length=1, description="Natural language question to ask")
target: Optional[str] = Field(
default=None, description="Optional target peer for local representation query"
)
session_id: Optional[str] = Field(
default=None, description="Optional session ID to scope query to specific session"
)
class SearchInput(BaseModel):
"""Input schema for search tool."""
query: str = Field(..., min_length=1, description="Search query for semantic matching")
limit: int = Field(default=10, ge=1, le=100, description="Number of results to return (1-100)")
filters: Optional[dict[str, Any]] = Field(
default=None,
description=(
"Optional filters to scope the search. Supports Honcho's filter syntax including "
"logical operators (AND, OR, NOT), comparison operators (gt, gte, lt, lte, eq, ne), "
"and metadata filtering. Examples: {'peer_id': 'user123'}, {'metadata': {'priority': 'high'}}, "
"{'created_at': {'gte': '2024-01-01'}}"
),
)
# Tool Implementations
class HonchoGetContextTool(BaseTool):
"""
Tool to retrieve session context with token limits.
This tool fetches the conversation history and session summary within
a specified token budget, optimized for LLM context windows.
"""
name: str = "get_session_context"
description: str = (
"Retrieve recent conversation context within token limits. "
"Returns formatted messages with optional summary and peer information. "
"Useful for getting optimized context that fits within token budgets."
)
args_schema: type[BaseModel] = GetContextInput
_honcho: Honcho = PrivateAttr()
_session_id: str = PrivateAttr()
_peer_id: str = PrivateAttr()
def __init__(self, honcho: Honcho, session_id: str, peer_id: str) -> None:
"""
Initialize the get_context tool.
Args:
honcho: Honcho client instance
session_id: ID of the session to get context from
peer_id: ID of the peer requesting context
"""
super().__init__()
self._honcho = honcho
self._session_id = session_id
self._peer_id = peer_id
def _run(
self,
tokens: Optional[int] = None,
peer_target: Optional[str] = None,
*,
summary: bool = True,
peer_perspective: Optional[str] = None,
) -> str:
"""
Execute get_context and format results.
Args:
tokens: Maximum tokens to include
peer_target: Target peer ID for representation
summary: Whether to include summary
peer_perspective: Peer ID to use as perspective
Returns:
Formatted string containing context information
"""
try:
session = self._honcho.session(self._session_id)
context = session.get_context(
summary=summary,
tokens=tokens,
peer_target=peer_target,
peer_perspective=peer_perspective,
)
# Format for agent consumption
result = []
# Add summary if present
if context.summary:
result.append("=== Session Summary ===")
result.append(context.summary.content)
result.append("")
# Add peer representation if present
if context.peer_representation:
result.append("=== Peer Representation ===")
result.append(context.peer_representation)
result.append("")
# Add peer card if present
if context.peer_card:
result.append("=== Peer Card ===")
result.extend(context.peer_card)
result.append("")
# Add messages
if context.messages:
result.append(f"=== Messages ({len(context.messages)}) ===")
for msg in context.messages:
result.append(f"{msg.peer_id}: {msg.content}")
return "\n".join(result) if result else "No context available"
except Exception as e:
logger.exception("Error retrieving context")
return f"Error retrieving context: {e!s}"
class HonchoDialecticTool(BaseTool):
"""
Tool to query Honcho's dialectic API (peer representations).
This tool allows agents to ask questions about what the system knows
about users or other peers, leveraging Honcho's reasoning capabilities.
"""
name: str = "query_peer_knowledge"
description: str = (
"Query the system's representation about peers. "
"Ask questions like 'What does the user like?' or 'What are their preferences?' "
"to retrieve information from the peer's long-term representation. "
"Can optionally query what one peer knows about another (local representation)."
)
args_schema: type[BaseModel] = DialecticInput
_honcho: Honcho = PrivateAttr()
_session_id: str = PrivateAttr()
_peer_id: str = PrivateAttr()
def __init__(self, honcho: Honcho, session_id: str, peer_id: str) -> None:
"""
Initialize the dialectic tool.
Args:
honcho: Honcho client instance
session_id: Default session ID for scoped queries
peer_id: ID of the peer to query about
"""
super().__init__()
self._honcho = honcho
self._session_id = session_id
self._peer_id = peer_id
def _run(
self,
query: str,
target: Optional[str] = None,
session_id: Optional[str] = None,
) -> str:
"""
Execute dialectic query.
Args:
query: Natural language question to ask
target: Optional target peer for local representation
session_id: Optional session ID to scope the query
Returns:
String response from the dialectic API
"""
try:
peer = self._honcho.peer(self._peer_id)
# Use provided session_id or fall back to default
scope_session_id = session_id or self._session_id
# Query the dialectic API (non-streaming)
response = peer.chat(
query=query,
stream=False,
target=target,
session_id=scope_session_id,
)
# Return the response or a default message
if response:
return str(response)
else:
return "No relevant information found."
except Exception as e:
logger.exception("Error querying dialectic API")
return f"Error querying peer knowledge: {e!s}"
class HonchoSearchTool(BaseTool):
"""
Tool to perform semantic search across session messages.
This tool enables agents to find relevant past messages using
semantic similarity search, useful for retrieving specific information
from conversation history.
"""
name: str = "search_session_messages"
description: str = (
"Search through session messages using semantic similarity. "
"Finds messages that are semantically related to the query, "
"useful for retrieving specific information from past conversations."
)
args_schema: type[BaseModel] = SearchInput
_honcho: Honcho = PrivateAttr()
_session_id: str = PrivateAttr()
def __init__(self, honcho: Honcho, session_id: str) -> None:
"""
Initialize the search tool.
Args:
honcho: Honcho client instance
session_id: ID of the session to search in
"""
super().__init__()
self._honcho = honcho
self._session_id = session_id
def _run(self, query: str, limit: int = 10, filters: Optional[dict[str, Any]] = None) -> str:
"""
Execute semantic search.
Args:
query: Search query for semantic matching
limit: Number of results to return (1-100)
filters: Optional filters to apply to search results
Returns:
Formatted string with search results
"""
try:
session = self._honcho.session(self._session_id)
# Perform semantic search
messages = session.search(query=query, limit=limit, filters=filters)
if not messages:
return f"No messages found matching '{query}'"
# Format results for agent consumption
result = [f"=== Search Results for '{query}' ({len(messages)} found) ==="]
for i, msg in enumerate(messages, 1):
result.append(f"\n{i}. [{msg.peer_id}] {msg.content}")
if hasattr(msg, "created_at") and msg.created_at:
result.append(f" Created: {msg.created_at}")
return "\n".join(result)
except Exception as e:
logger.exception("Error searching messages: %s", e)
return f"Error searching messages: {e!s}"

View File

@ -0,0 +1,62 @@
"""
Basic tests for honcho_crewai package
Validates package structure, imports, and basic functionality.
"""
import pytest
def test_package_import():
"""Test that honcho_crewai imports successfully."""
import honcho_crewai
assert honcho_crewai is not None
def test_storage_import():
"""Test that HonchoStorage can be imported."""
from honcho_crewai import HonchoStorage
assert HonchoStorage is not None
def test_tools_import():
"""Test that tool classes can be imported."""
from honcho_crewai import (
HonchoGetContextTool,
HonchoDialecticTool,
HonchoSearchTool,
)
assert HonchoGetContextTool is not None
assert HonchoDialecticTool is not None
assert HonchoSearchTool is not None
class TestPackageMetadata:
"""Test package metadata and structure."""
def test_package_has_version(self):
"""Test that package exposes version information."""
import honcho_crewai
assert hasattr(honcho_crewai, "__version__")
assert isinstance(honcho_crewai.__version__, str)
assert len(honcho_crewai.__version__) > 0
def test_package_all_exports(self):
"""Test that __all__ contains expected exports."""
import honcho_crewai
assert hasattr(honcho_crewai, "__all__")
expected_exports = [
"HonchoStorage",
"HonchoGetContextTool",
"HonchoDialecticTool",
"HonchoSearchTool",
"HonchoDependencyError",
]
for export in expected_exports:
assert export in honcho_crewai.__all__, f"{export} not in __all__"

View File

@ -0,0 +1,174 @@
"""
Tests for HonchoStorage
Tests the CrewAI-Honcho integration layer, focusing on:
- CrewAI Storage interface compliance
- Metadata mapping (agent/role -> peer_id)
- Format conversion (Honcho -> CrewAI format)
"""
from honcho_crewai import HonchoStorage
class TestHonchoStorage:
"""Tests for HonchoStorage integration layer."""
def test_initialization(self):
"""Test that HonchoStorage initializes with correct peers and session."""
storage = HonchoStorage(user_id="test_user")
assert storage is not None
assert storage.session_id is not None
assert storage.user is not None
assert storage.assistant is not None
assert storage.session is not None
def test_initialization_with_custom_session(self):
"""Test that custom session_id is preserved."""
custom_session_id = "my_custom_session"
storage = HonchoStorage(user_id="test_user", session_id=custom_session_id)
assert storage.session_id == custom_session_id
def test_save_with_different_roles(self):
"""Test that save handles different agent/role metadata."""
storage = HonchoStorage(user_id="test_user_roles")
# Save with different metadata patterns
storage.save("User via agent", metadata={"agent": "user"})
storage.save("User via role", metadata={"role": "user"})
storage.save("Assistant via agent", metadata={"agent": "assistant"})
storage.save("Default (no metadata)", metadata={})
# If no exceptions raised, metadata mapping works
def test_search_returns_crewai_format(self):
"""Test that search returns results in CrewAI format."""
storage = HonchoStorage(user_id="test_user_search")
# Add a message
storage.save("Test message", metadata={"agent": "user"})
# Search
results = storage.search("test", limit=10)
# Verify CrewAI format
assert isinstance(results, list)
for result in results:
# Required keys for CrewAI
assert "memory" in result
assert "context" in result
assert "content" in result
assert "metadata" in result
def test_search_includes_all_required_fields(self):
"""Test that all search results have required CrewAI fields."""
storage = HonchoStorage(user_id="test_user_format")
# Add a message
storage.save("Test message", metadata={"agent": "user"})
# Search
results = storage.search("test", limit=5)
# Verify all results have required fields with correct types
for result in results:
assert isinstance(result["content"], str)
assert isinstance(result["memory"], str)
assert isinstance(result["context"], str)
assert isinstance(result["metadata"], dict)
def test_search_formats_summaries_correctly(self):
"""Test that session summaries are formatted with [Session Summary] prefix."""
storage = HonchoStorage(user_id="test_user_summaries")
# Add enough messages to potentially trigger summaries
for i in range(25):
storage.save(
f"Message {i}",
metadata={"agent": "user" if i % 2 == 0 else "assistant"},
)
# Search
results = storage.search("message", limit=10)
# Check summary formatting (if summaries exist)
summary_results = [r for r in results if r["metadata"].get("type") == "summary"]
for summary in summary_results:
# Verify our formatting logic
assert "summary_type" in summary["metadata"]
assert "[Session Summary]" in summary["context"] # Our formatting
def test_reset_creates_new_session_id(self):
"""Test that reset() creates a new session with different ID."""
storage = HonchoStorage(user_id="test_user_reset")
original_session_id = storage.session_id
# Reset
storage.reset()
# Verify new session ID was created
assert storage.session_id != original_session_id
def test_search_with_filters(self):
"""Test that search accepts and uses filters parameter."""
storage = HonchoStorage(user_id="test_user_filters")
# Add messages with different metadata
storage.save("User question about Python", metadata={"agent": "user", "topic": "python"})
storage.save("Assistant answer about Python", metadata={"agent": "assistant", "topic": "python"})
storage.save("User question about JavaScript", metadata={"agent": "user", "topic": "javascript"})
# Search with peer_id filter - filter to only user messages
results = storage.search(
"programming",
limit=10,
filters={"peer_id": storage.user.id}
)
# Verify results are returned and in correct format
assert isinstance(results, list)
for result in results:
assert "memory" in result
assert "content" in result
assert "context" in result
assert "metadata" in result
def test_search_with_metadata_filters(self):
"""Test that search works with metadata filters."""
storage = HonchoStorage(user_id="test_user_metadata_filters")
# Add messages with specific metadata
storage.save("Important message", metadata={"agent": "user", "priority": "high"})
storage.save("Regular message", metadata={"agent": "user", "priority": "low"})
# Search with metadata filter
results = storage.search(
"message",
limit=10,
filters={"metadata": {"priority": "high"}}
)
# Verify results are in correct format
assert isinstance(results, list)
for result in results:
assert "memory" in result
assert "metadata" in result
def test_search_without_filters(self):
"""Test that search works without filters."""
storage = HonchoStorage(user_id="test_user_no_filters")
# Add a message
storage.save("Test message for search", metadata={"agent": "user"})
# Search without filters
results = storage.search("test", limit=5)
# Verify it works and returns correct format
assert isinstance(results, list)
for result in results:
assert "memory" in result
assert "content" in result

View File

@ -0,0 +1,194 @@
"""
Tests for Honcho CrewAI Tools
Tests the CrewAI-Honcho tool integration layer using real Honcho SDK.
Focuses on tool interface compliance and result formatting.
"""
from honcho import Honcho
from honcho_crewai import (
HonchoDialecticTool,
HonchoGetContextTool,
HonchoSearchTool,
)
class TestGetContextTool:
"""Tests for HonchoGetContextTool."""
def test_initialization(self):
"""Test that tool initializes with correct attributes."""
honcho = Honcho()
tool = HonchoGetContextTool(
honcho=honcho, session_id="test_session", peer_id="test_peer"
)
assert tool is not None
assert tool.name == "get_session_context"
assert tool.description is not None
assert tool.args_schema is not None
def test_returns_formatted_context(self):
"""Test that tool returns formatted context string."""
honcho = Honcho()
peer = honcho.peer("context_test_user")
session_id = "context_test_session"
session = honcho.session(session_id)
# Add test message
session.add_messages([peer.message("Test message for context")])
# Create and execute tool
tool = HonchoGetContextTool(
honcho=honcho, session_id=session_id, peer_id="context_test_user"
)
result = tool._run()
# Verify result is a formatted string
assert isinstance(result, str)
assert len(result) > 0
class TestDialecticTool:
"""Tests for HonchoDialecticTool."""
def test_initialization(self):
"""Test that tool initializes with correct attributes."""
honcho = Honcho()
tool = HonchoDialecticTool(
honcho=honcho, session_id="test_session", peer_id="test_peer"
)
assert tool is not None
assert tool.name == "query_peer_knowledge"
assert tool.description is not None
def test_returns_response(self):
"""Test that tool returns a response string."""
honcho = Honcho()
peer = honcho.peer("dialectic_test_user")
session_id = "dialectic_test_session"
session = honcho.session(session_id)
# Add test messages
session.add_messages([peer.message("I love pizza and Italian food")])
# Create and execute tool
tool = HonchoDialecticTool(
honcho=honcho, session_id=session_id, peer_id="dialectic_test_user"
)
result = tool._run(query="What does the user like?")
# Verify result is a string
assert isinstance(result, str)
assert len(result) > 0
class TestSearchTool:
"""Tests for HonchoSearchTool."""
def test_initialization(self):
"""Test that tool initializes with correct attributes."""
honcho = Honcho()
tool = HonchoSearchTool(honcho=honcho, session_id="test_session")
assert tool is not None
assert tool.name == "search_session_messages"
assert tool.description is not None
def test_returns_formatted_results(self):
"""Test that tool returns formatted search results."""
honcho = Honcho()
peer = honcho.peer("search_test_user")
session_id = "search_test_session"
session = honcho.session(session_id)
# Add test messages
session.add_messages([peer.message("I love pizza and pasta")])
# Create and execute tool
tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
result = tool._run(query="food", limit=5)
# Verify result is a formatted string
assert isinstance(result, str)
assert len(result) > 0
# Should have either results or "No messages found"
assert "Search Results" in result or "No messages found" in result
def test_search_with_filters(self):
"""Test that search tool accepts and uses filters parameter."""
honcho = Honcho()
peer = honcho.peer("search_filter_test_user")
session_id = "search_filter_test_session"
session = honcho.session(session_id)
# Add test messages
session.add_messages([peer.message("Important message about Python")])
# Create and execute tool with filters
tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
result = tool._run(
query="Python",
limit=5,
filters={"peer_id": peer.id}
)
# Verify result is a formatted string
assert isinstance(result, str)
assert len(result) > 0
def test_search_with_metadata_filters(self):
"""Test that search tool works with metadata filters."""
honcho = Honcho()
peer = honcho.peer("search_metadata_filter_user")
session_id = "search_metadata_filter_session"
session = honcho.session(session_id)
# Add test messages with metadata
session.add_messages([peer.message("High priority task", metadata={"priority": "high"})])
# Create and execute tool with metadata filter
tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
result = tool._run(
query="task",
limit=5,
filters={"metadata": {"priority": "high"}}
)
# Verify result is a formatted string
assert isinstance(result, str)
assert len(result) > 0
class TestToolsWorkTogether:
"""Test that all tools can work together."""
def test_all_tools_in_same_session(self):
"""Test that all three tools can be used in the same session."""
honcho = Honcho()
peer = honcho.peer("combo_test_user")
session_id = "combo_test_session"
session = honcho.session(session_id)
# Add messages
session.add_messages([peer.message("I enjoy coding in Python")])
# Create all tools
context_tool = HonchoGetContextTool(
honcho=honcho, session_id=session_id, peer_id="combo_test_user"
)
dialectic_tool = HonchoDialecticTool(
honcho=honcho, session_id=session_id, peer_id="combo_test_user"
)
search_tool = HonchoSearchTool(honcho=honcho, session_id=session_id)
# Execute all tools
context_result = context_tool._run()
dialectic_result = dialectic_tool._run(query="What does the user like?")
search_result = search_tool._run(query="coding", limit=5)
# Verify all return valid strings
assert isinstance(context_result, str) and len(context_result) > 0
assert isinstance(dialectic_result, str) and len(dialectic_result) > 0
assert isinstance(search_result, str) and len(search_result) > 0

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,85 @@
"""
LangGraph Integration with Honcho and OpenAI
This module demonstrates how to build a stateful conversational AI agent using
LangGraph for orchestration, OpenAI for the AI model, and Honcho for memory
management. It creates a chatbot that remembers conversations across sessions.
"""
import os
from dotenv import load_dotenv
from typing_extensions import TypedDict
from honcho import Honcho, Peer, Session
from openai import OpenAI
from langgraph.graph import StateGraph, START, END
load_dotenv()
honcho = Honcho()
llm = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
class State(TypedDict):
user_message: str
assistant_response: str
user: Peer
assistant: Peer
session: Session
def chatbot(state: State):
user_message = state["user_message"]
# Get objects from state
user = state["user"]
assistant = state["assistant"]
session = state["session"]
session.add_messages([user.message(user_message)])
# Get context in OpenAI format with token limit
# tokens=2000 limits the context to 2000 tokens to manage costs and fit within model limits
messages = session.get_context(tokens=2000).to_openai(assistant=assistant)
# Generate response
response = llm.chat.completions.create(
model="gpt-5.1",
messages=messages
)
assistant_response = response.choices[0].message.content
# Store assistant response
session.add_messages([assistant.message(assistant_response)])
return {"assistant_response": assistant_response}
graph = StateGraph(State) \
.add_node("chatbot", chatbot) \
.add_edge(START, "chatbot") \
.add_edge("chatbot", END) \
.compile()
def run_conversation_turn(user_id: str, user_input: str, session_id: str | None = None):
if not session_id:
session_id = f"session_{user_id}"
# Initialize Honcho objects
user = honcho.peer(user_id)
assistant = honcho.peer("assistant")
session = honcho.session(session_id)
result = graph.invoke({
"user_message": user_input,
"user": user,
"assistant": assistant,
"session": session
})
return result["assistant_response"]
if __name__ == "__main__":
print("Welcome to the AI Assistant! How can I help you today?")
user_id = "test-user-1234"
while True:
user_input = input("You: ")
if user_input.lower() in ['quit', 'exit']:
break
response = run_conversation_turn(user_id, user_input)
print(f"Assistant: {response}\n")

View File

@ -0,0 +1,13 @@
[project]
name = "honcho-langgraph-example"
version = "0.1.0"
description = "LangGraph integration with Honcho for stateful conversational AI"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"honcho-ai",
"langchain-core>=1.0.6",
"langgraph>=1.0.3",
"openai>=1.99.7",
"python-dotenv>=1.1.1",
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,179 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "honcho-langgraph-example",
"dependencies": {
"@honcho-ai/sdk": "^1.5.0",
"@langchain/langgraph": "^1.0.2",
"dotenv": "^17.2.3",
"openai": "^6.9.1",
"zod": "4.0.0",
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2",
},
},
},
"packages": {
"@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="],
"@honcho-ai/core": ["@honcho-ai/core@1.5.1", "", { "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-lbYtMTcL2AxdcIl5ZKenogeTlVMnE7buJWvAFOCLp0yQxcezyA/R9FPvLz2UGRApLsiplLNWhftML+3QBjIIJA=="],
"@honcho-ai/sdk": ["@honcho-ai/sdk@1.5.0", "", { "dependencies": { "@honcho-ai/core": "^1.5.1", "@types/node": "^24.0.1", "zod": "4.0.0" } }, "sha512-1V3wnIxyoRw0oYDE1p6GG4vRpwnn0PkwYsb4z4NeJkmWaafS9So1G32+IPhm01i2s4RnxsU31kCVL07BRsqtUw=="],
"@langchain/core": ["@langchain/core@1.0.6", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", "langsmith": "^0.3.64", "mustache": "^4.2.0", "p-queue": "^6.6.2", "p-retry": "4", "uuid": "^10.0.0", "zod": "^3.25.76 || ^4" } }, "sha512-rDSjXATujCdJlL+OJFfyZhEca8kLmqGr4W2ebJvSHiUgXEDqu/IOWC+ZWgoKKHkGOGFdVTqQ7Qi0j2RnYS9Qlg=="],
"@langchain/langgraph": ["@langchain/langgraph@1.0.2", "", { "dependencies": { "@langchain/langgraph-checkpoint": "^1.0.0", "@langchain/langgraph-sdk": "~1.0.0", "uuid": "^10.0.0" }, "peerDependencies": { "@langchain/core": "^1.0.1", "zod": "^3.25.32 || ^4.1.0", "zod-to-json-schema": "^3.x" }, "optionalPeers": ["zod-to-json-schema"] }, "sha512-syxzzWTnmpCL+RhUEvalUeOXFoZy/KkzHa2Da2gKf18zsf9Dkbh3rfnRDrTyUGS1XSTejq07s4rg1qntdEDs2A=="],
"@langchain/langgraph-checkpoint": ["@langchain/langgraph-checkpoint@1.0.0", "", { "dependencies": { "uuid": "^10.0.0" }, "peerDependencies": { "@langchain/core": "^1.0.1" } }, "sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A=="],
"@langchain/langgraph-sdk": ["@langchain/langgraph-sdk@1.0.0", "", { "dependencies": { "p-queue": "^6.6.2", "p-retry": "4", "uuid": "^9.0.0" }, "peerDependencies": { "@langchain/core": "^1.0.1", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@langchain/core", "react", "react-dom"] }, "sha512-g25ti2W7Dl5wUPlNK+0uIGbeNFqf98imhHlbdVVKTTkDYLhi/pI1KTgsSSkzkeLuBIfvt2b0q6anQwCs7XBlbw=="],
"@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
"@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"console-table-printer": ["console-table-printer@2.15.0", "", { "dependencies": { "simple-wcswidth": "^1.1.2" } }, "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="],
"langsmith": ["langsmith@0.3.80", "", { "dependencies": { "@types/uuid": "^10.0.0", "chalk": "^4.1.2", "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "p-retry": "4", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai"] }, "sha512-BWpbB9/Hkx06S5X4nJE3W5Wm1mH/j6SIqWcM/WAuT+yulohE9knstIJGmBpmSBULb46nCj+cfjRkyF1Nrc4UmA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="],
"p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="],
"p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="],
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"simple-wcswidth": ["simple-wcswidth@1.1.2", "", {}, "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"zod": ["zod@4.0.0", "", {}, "sha512-9diLdTPc/L7w/5jI4C3gHYNiGHDV9IZYxo1e5LSD8cabi65WVTWWb+g2BGPEpUUCOxR4D+6O5B0AzyMdUAXwrw=="],
"@honcho-ai/core/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@honcho-ai/sdk/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@langchain/langgraph-sdk/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"@types/node-fetch/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@honcho-ai/core/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@honcho-ai/sdk/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@types/node-fetch/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

View File

@ -0,0 +1,110 @@
/**
* LangGraph Integration with Honcho and OpenAI
*
* This module demonstrates how to build a stateful conversational AI agent using
* LangGraph for orchestration, OpenAI for the AI model, and Honcho for memory
* management. It creates a chatbot that remembers conversations across sessions.
*/
import * as dotenv from "dotenv";
import { Honcho, Peer, Session } from "@honcho-ai/sdk";
import OpenAI from "openai";
import { Annotation } from "@langchain/langgraph";
import { StateGraph, START, END } from "@langchain/langgraph";
import * as readline from "readline/promises";
dotenv.config();
const honcho = new Honcho({});
const llm = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
const StateAnnotation = Annotation.Root({
userMessage: Annotation<string>(),
assistantResponse: Annotation<string>(),
user: Annotation<Peer>(),
assistant: Annotation<Peer>(),
session: Annotation<Session>(),
});
type State = typeof StateAnnotation.State;
async function chatbot(state: State) {
const userMessage = state.userMessage;
// Get objects from state
const user = state.user;
const assistant = state.assistant;
const session = state.session;
await session.addMessages([user.message(userMessage)]);
// Get context in OpenAI format with token limit
// tokens: 2000 limits the context to 2000 tokens to manage costs and fit within model limits
const messages = (await session.getContext({ tokens: 2000 })).toOpenAI(assistant);
// Generate response
const response = await llm.chat.completions.create({
model: "gpt-4o",
messages: messages
});
const assistantResponse = response.choices[0].message.content!;
// Store assistant response
await session.addMessages([assistant.message(assistantResponse)]);
return { assistantResponse: assistantResponse };
}
const graph = new StateGraph(StateAnnotation)
.addNode("chatbot", chatbot)
.addEdge(START, "chatbot")
.addEdge("chatbot", END)
.compile();
async function runConversationTurn(
userId: string,
userInput: string,
sessionId?: string
): Promise<string> {
if (!sessionId) {
sessionId = `session_${userId}`;
}
// Initialize Honcho objects
const user = await honcho.peer(userId);
const assistant = await honcho.peer("assistant");
const session = await honcho.session(sessionId);
const result = await graph.invoke({
userMessage: userInput,
user: user,
assistant: assistant,
session: session,
});
return result.assistantResponse;
}
async function main() {
console.log("Welcome to the AI Assistant! How can I help you today?");
const userId = "test-user-1234";
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
while (true) {
const userInput = await rl.question("You: ");
if (userInput.toLowerCase() === "quit" || userInput.toLowerCase() === "exit") {
rl.close();
break;
}
const response = await runConversationTurn(userId, userInput);
console.log(`Assistant: ${response}\n`);
}
}
main();

View File

@ -0,0 +1,31 @@
{
"name": "honcho-langgraph-example",
"version": "1.0.0",
"description": "LangGraph integration with Honcho for stateful conversational AI",
"main": "main.ts",
"type": "module",
"scripts": {
"start": "bun run main.ts",
"dev": "bun --watch main.ts"
},
"keywords": [
"honcho",
"langgraph",
"openai",
"chatbot",
"ai"
],
"author": "",
"license": "MIT",
"dependencies": {
"@honcho-ai/sdk": "^1.5.0",
"@langchain/langgraph": "^1.0.2",
"dotenv": "^17.2.3",
"openai": "^6.9.1",
"zod": "4.0.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}

View File

@ -2,7 +2,7 @@
## What is Honcho?
Honcho is an infrastructure layer for building AI agents with social cognition and theory of mind capabilities. It enables personalized AI interactions by building coherent models of user psychology over time. The Honcho MCP server simplifies the integration to just 3 essential functions. Here's how to use them:
Honcho is an infrastructure layer for building AI agents with memory and social cognition. It enables personalized AI interactions by building coherent models of user psychology over time. The Honcho MCP server simplifies the integration to just 3 essential functions. Here's how to use them:
### Step 1: Start New Conversation (First Message Only)
@ -21,7 +21,7 @@ Before responding to any user message, you can query for personalization insight
```text
get_personalization_insights
session_id: [SESSION_ID_FROM_STEP_1]
query: [YOUR_THEORY_OF_MIND_QUESTION]
query: [YOUR_QUESTION]
```
This query takes a bit of time, so it's best to only perform it when you need personalized insights. If the query can be responded to effectively using what you already know about the user, just go ahead and answer it. However, the insights endpoint is extremely perceptive. It has the capability to reveal aspects of the user's personality, historical use of the application you are operating in, and more.
@ -129,7 +129,7 @@ For subsequent messages in the same conversation:
## Best Practices for Personalization Queries
Ask theory-of-mind questions that reveal:
Ask questions that reveal:
**Communication Style**: "How formal/casual should I be?" "What does this reveal about their preferences?"
@ -151,6 +151,6 @@ Ask theory-of-mind questions that reveal:
1. **Always start with `start_conversation` for new conversations**
2. **Store every message exchange with `add_turn`**
3. **Use `get_personalization_insights` strategically for better responses**
4. **Ask thoughtful theory-of-mind questions**
4. **Ask thoughtful questions about `peer` representation**
5. **Never expose technical details to the user**
6. **The system maintains context automatically between sessions**

View File

@ -143,7 +143,7 @@ def run_migrations_online() -> None:
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
echo=True,
echo=False,
connect_args={
"prepare_threshold": None,
"options": "-c statement_timeout=300000", # 5 minutes in milliseconds

View File

@ -68,6 +68,8 @@ def constraint_exists(
elif type == "primary":
constraint = inspector.get_pk_constraint(table_name, schema=schema)
return constraint["name"] == constraint_name
elif type == "foreignkey":
constraints = inspector.get_foreign_keys(table_name, schema=schema)
else:
raise ValueError(f"Invalid constraint type: {type}")
return any(constraint["name"] == constraint_name for constraint in constraints)
@ -151,9 +153,11 @@ def make_column_non_nullable_safe(table_name: str, column_name: str) -> None:
)
# Step 5: Drop the redundant CHECK constraint
op.drop_constraint(
constraint_name,
table_name,
type_="check",
schema=schema,
conn.execute(
sa.text(
f"""
ALTER TABLE {quoted_schema}.{quoted_table}
DROP CONSTRAINT {quoted_constraint}
"""
)
)

View File

@ -197,8 +197,12 @@ def upgrade() -> None:
# Step 9: Update the collections table name_length check constraint from 512 to 1025
print("Updating collections table name_length check constraint from 512 to 1025")
# Check for both old name and naming-convention-generated name
if constraint_exists("collections", "name_length", "check"):
op.drop_constraint("name_length", "collections", schema=schema)
elif constraint_exists("collections", "ck_collections_name_length", "check"):
op.drop_constraint("ck_collections_name_length", "collections", schema=schema)
op.create_check_constraint(
"name_length", "collections", "length(name) <= 1025", schema=schema
)
@ -215,8 +219,12 @@ def downgrade() -> None:
"Reverting collections table name_length check constraint from 1025 back to 512"
)
# Check for both old name and naming-convention-generated name
if constraint_exists("collections", "name_length", "check"):
op.drop_constraint("name_length", "collections", schema=schema)
elif constraint_exists("collections", "ck_collections_name_length", "check"):
op.drop_constraint("ck_collections_name_length", "collections", schema=schema)
op.create_check_constraint(
"name_length", "collections", "length(name) <= 512", schema=schema
)

View File

@ -0,0 +1,114 @@
"""rename_deriver_disabled_to_deriver_enabled
Revision ID: 110bdf470272
Revises: baa22cad81e2
Create Date: 2025-10-31 13:04:31.029856
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from migrations.utils import get_schema
# revision identifiers, used by Alembic.
revision: str = "110bdf470272"
down_revision: str | None = "baa22cad81e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = get_schema()
def upgrade() -> None:
"""
Convert deriver_disabled to deriver_enabled in configuration JSONB.
- deriver_disabled: true -> deriver_enabled: false
- deriver_disabled: false -> deriver_enabled: true
- Remove deriver_disabled key from configuration
"""
# Update sessions table in batches
# Combine all cases in one pass: set deriver_enabled based on deriver_disabled value,
# or just remove deriver_disabled if it's null or other value
bind = op.get_bind()
batch_size = 5000
while True:
result = bind.execute(
sa.text(
f"""
WITH batch AS (
SELECT id
FROM "{schema}".sessions
WHERE configuration ? 'deriver_disabled'
LIMIT :batch_size
)
UPDATE "{schema}".sessions s
SET configuration = CASE
WHEN s.configuration->>'deriver_disabled' = 'true' THEN
s.configuration - 'deriver_disabled' || jsonb_build_object('deriver_enabled', false)
WHEN s.configuration->>'deriver_disabled' = 'false' THEN
s.configuration - 'deriver_disabled' || jsonb_build_object('deriver_enabled', true)
ELSE
s.configuration - 'deriver_disabled'
END
FROM batch b
WHERE s.id = b.id
AND s.configuration ? 'deriver_disabled'
"""
),
{"batch_size": batch_size},
)
rowcount = result.rowcount
result.close()
if rowcount == 0:
break
def downgrade() -> None:
"""
Convert deriver_enabled back to deriver_disabled in configuration JSONB.
- deriver_enabled: false -> deriver_disabled: true
- deriver_enabled: true -> deriver_disabled: false
- Remove deriver_enabled key from configuration
"""
# Update sessions table in batches
# Combine all cases in one pass: set deriver_disabled based on deriver_enabled value,
# or just remove deriver_enabled if it's null or other value
bind = op.get_bind()
batch_size = 5000
while True:
result = bind.execute(
sa.text(
f"""
WITH batch AS (
SELECT id
FROM "{schema}".sessions
WHERE configuration ? 'deriver_enabled'
LIMIT :batch_size
)
UPDATE "{schema}".sessions s
SET configuration = CASE
WHEN s.configuration->>'deriver_enabled' = 'false' THEN
s.configuration - 'deriver_enabled' || jsonb_build_object('deriver_disabled', true)
WHEN s.configuration->>'deriver_enabled' = 'true' THEN
s.configuration - 'deriver_enabled' || jsonb_build_object('deriver_disabled', false)
ELSE
s.configuration - 'deriver_enabled'
END
FROM batch b
WHERE s.id = b.id
AND s.configuration ? 'deriver_enabled'
"""
),
{"batch_size": batch_size},
)
rowcount = result.rowcount
result.close()
if rowcount == 0:
break

View File

@ -10,8 +10,9 @@ from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy import text
from migrations.utils import constraint_exists
from migrations.utils import constraint_exists, get_schema
# revision identifiers, used by Alembic.
revision: str = "20f89a421aff"
@ -21,6 +22,9 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
schema = get_schema()
conn = op.get_bind()
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"metamessages",
@ -63,13 +67,38 @@ def upgrade() -> None:
)
# Rename check constraint
if constraint_exists("metamessages", "metamessage_type_length", "check"):
op.execute("ALTER TABLE metamessages DROP CONSTRAINT metamessage_type_length;")
op.create_check_constraint("label_length", "metamessages", "length(label) <= 512")
# Handle both naming convention and non-convention names for backward compatibility
if constraint_exists(
"metamessages", "ck_metamessages_metamessage_type_length", "check"
):
conn.execute(
text(
f"ALTER TABLE {schema}.metamessages DROP CONSTRAINT ck_metamessages_metamessage_type_length"
)
)
elif constraint_exists("metamessages", "metamessage_type_length", "check"):
conn.execute(
text(
f"ALTER TABLE {schema}.metamessages DROP CONSTRAINT metamessage_type_length"
)
)
conn.execute(
text(
f"""
ALTER TABLE {schema}.metamessages
ADD CONSTRAINT label_length
CHECK (length(label) <= 512)
"""
)
)
# ### end Alembic commands ###
def downgrade() -> None:
schema = get_schema()
conn = op.get_bind()
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"metamessages",
@ -112,10 +141,16 @@ def downgrade() -> None:
)
# Revert check constraint rename
op.execute("ALTER TABLE metamessages DROP CONSTRAINT label_length;")
op.create_check_constraint(
"metamessage_type_length",
"metamessages",
"length(metamessage_type) <= 512",
conn.execute(
text(f"ALTER TABLE {schema}.metamessages DROP CONSTRAINT label_length")
)
conn.execute(
text(
f"""
ALTER TABLE {schema}.metamessages
ADD CONSTRAINT metamessage_type_length
CHECK (length(metamessage_type) <= 512)
"""
)
)
# ### end Alembic commands ###

View File

@ -10,6 +10,7 @@ from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy import text
from migrations.utils import constraint_exists, get_schema
@ -27,13 +28,18 @@ def upgrade() -> None:
connection = op.get_bind()
inspector = sa.inspect(connection)
# Drop CHECK constraint for level
# Drop CHECK constraint for level (check both naming convention and plain names)
# On old databases: "level_valid"
# On new databases with naming convention: "ck_documents_level_valid"
if constraint_exists("documents", "level_valid", "check", inspector):
op.drop_constraint(
"level_valid",
"documents",
type_="check",
schema=schema,
connection.execute(
text(f'ALTER TABLE {schema}.documents DROP CONSTRAINT "level_valid"')
)
elif constraint_exists("documents", "ck_documents_level_valid", "check", inspector):
connection.execute(
text(
f'ALTER TABLE {schema}.documents DROP CONSTRAINT "ck_documents_level_valid"'
)
)
@ -66,11 +72,14 @@ def downgrade() -> None:
if result.rowcount == 0:
break
# Recreate CHECK constraint for level
# Recreate CHECK constraint for level (use plain name to match original migration)
if not constraint_exists("documents", "level_valid", "check", inspector):
op.create_check_constraint(
"level_valid",
"documents",
"level IN ('explicit', 'deductive')",
schema=schema,
connection.execute(
text(
f"""
ALTER TABLE {schema}.documents
ADD CONSTRAINT level_valid
CHECK (level IN ('explicit', 'deductive'))
"""
)
)

View File

@ -222,10 +222,14 @@ def upgrade() -> None:
)
if not constraint_exists:
op.create_check_constraint(
"message_requires_session",
"metamessages",
"(message_id IS NULL) OR (session_id IS NOT NULL)",
conn.execute(
text(
f"""
ALTER TABLE {schema}.metamessages
ADD CONSTRAINT message_requires_session
CHECK ((message_id IS NULL) OR (session_id IS NOT NULL))
"""
)
)
print("Created message_requires_session constraint")
else:

View File

@ -100,11 +100,14 @@ def upgrade() -> None:
# Step 6: Add CHECK constraint for level
if not constraint_exists("documents", "level_valid", "check", inspector):
op.create_check_constraint(
"level_valid",
"documents",
"level IN ('explicit', 'deductive')",
schema=schema,
connection.execute(
text(
f"""
ALTER TABLE {schema}.documents
ADD CONSTRAINT level_valid
CHECK (level IN ('explicit', 'deductive'))
"""
)
)
@ -115,11 +118,8 @@ def downgrade() -> None:
# Step 1: Drop CHECK constraint for level
if constraint_exists("documents", "level_valid", "check", inspector):
op.drop_constraint(
"level_valid",
"documents",
type_="check",
schema=schema,
connection.execute(
text(f"ALTER TABLE {schema}.documents DROP CONSTRAINT level_valid")
)
# Step 2: Copy level and times_derived back to internal_metadata in batches (optional, for safety)

View File

@ -0,0 +1,525 @@
"""standardize_constraint_names
Revision ID: baa22cad81e2
Revises: 29ade7350c19
Create Date: 2025-11-15 01:16:40.937103
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy import text
from migrations.utils import constraint_exists, get_schema, index_exists
# revision identifiers, used by Alembic.
revision: str = "baa22cad81e2"
down_revision: str | None = "29ade7350c19"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = get_schema()
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# ============================================================
# CONSTRAINT RENAMES (using raw SQL)
# ============================================================
# Unique Constraints
if constraint_exists(
"active_queue_sessions", "unique_work_unit_key", "unique", inspector
):
conn.execute(
text(
f'ALTER TABLE {schema}.active_queue_sessions RENAME CONSTRAINT "unique_work_unit_key" TO "uq_active_queue_sessions_work_unit_key"'
)
)
if constraint_exists(
"collections", "unique_observer_observed_collection", "unique", inspector
):
conn.execute(
text(
f'ALTER TABLE {schema}.collections RENAME CONSTRAINT "unique_observer_observed_collection" TO "uq_collections_observer_observed_workspace_name"'
)
)
if constraint_exists("peers", "unique_name_workspace_peer", "unique", inspector):
conn.execute(
text(
f'ALTER TABLE {schema}.peers RENAME CONSTRAINT "unique_name_workspace_peer" TO "uq_peers_name_workspace_name"'
)
)
if constraint_exists("sessions", "unique_session_name", "unique", inspector):
conn.execute(
text(
f'ALTER TABLE {schema}.sessions RENAME CONSTRAINT "unique_session_name" TO "uq_sessions_name_workspace_name"'
)
)
if constraint_exists("messages", "uq_messages_session_seq", "unique", inspector):
conn.execute(
text(
f'ALTER TABLE {schema}.messages RENAME CONSTRAINT "uq_messages_session_seq" TO "uq_messages_workspace_name_session_name_seq_in_session"'
)
)
if constraint_exists("workspaces", "uq_apps_name", "unique", inspector):
conn.execute(
text(
f'ALTER TABLE {schema}.workspaces RENAME CONSTRAINT "uq_apps_name" TO "uq_workspaces_name"'
)
)
# Foreign Keys
if constraint_exists(
"message_embeddings",
"message_embeddings_message_id_fkey",
"foreignkey",
inspector,
):
conn.execute(
text(
f'ALTER TABLE {schema}.message_embeddings RENAME CONSTRAINT "message_embeddings_message_id_fkey" TO "fk_message_embeddings_message_id_messages"'
)
)
# ============================================================
# INDEX RENAMES (using raw SQL)
# ============================================================
# Refresh inspector after constraint renames to ensure we see current state
inspector = sa.inspect(conn)
# Collections
if index_exists("collections", "idx_collections_observed", inspector):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_collections_observed" RENAME TO "ix_collections_observed"'
)
)
if index_exists("collections", "idx_collections_observer", inspector):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_collections_observer" RENAME TO "ix_collections_observer"'
)
)
# Documents
if index_exists("documents", "idx_documents_embedding_hnsw", inspector):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_documents_embedding_hnsw" RENAME TO "ix_documents_embedding_hnsw"'
)
)
if index_exists("documents", "idx_documents_observed", inspector):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_documents_observed" RENAME TO "ix_documents_observed"'
)
)
if index_exists("documents", "idx_documents_observer", inspector):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_documents_observer" RENAME TO "ix_documents_observer"'
)
)
if index_exists("documents", "idx_documents_session_name", inspector):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_documents_session_name" RENAME TO "ix_documents_session_name"'
)
)
# Message Embeddings
if index_exists(
"message_embeddings", "idx_message_embeddings_created_at", inspector
):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_message_embeddings_created_at" RENAME TO "ix_message_embeddings_created_at"'
)
)
if index_exists(
"message_embeddings", "idx_message_embeddings_embedding_hnsw", inspector
):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_message_embeddings_embedding_hnsw" RENAME TO "ix_message_embeddings_embedding_hnsw"'
)
)
if index_exists(
"message_embeddings", "idx_message_embeddings_message_id", inspector
):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_message_embeddings_message_id" RENAME TO "ix_message_embeddings_message_id"'
)
)
if index_exists(
"message_embeddings", "idx_message_embeddings_peer_name", inspector
):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_message_embeddings_peer_name" RENAME TO "ix_message_embeddings_peer_name"'
)
)
if index_exists(
"message_embeddings", "idx_message_embeddings_session_name", inspector
):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_message_embeddings_session_name" RENAME TO "ix_message_embeddings_session_name"'
)
)
if index_exists(
"message_embeddings", "idx_message_embeddings_workspace_name", inspector
):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_message_embeddings_workspace_name" RENAME TO "ix_message_embeddings_workspace_name"'
)
)
# Messages
if index_exists("messages", "idx_messages_content_gin", inspector):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_messages_content_gin" RENAME TO "ix_messages_content_gin"'
)
)
if index_exists("messages", "idx_messages_session_lookup", inspector):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_messages_session_lookup" RENAME TO "ix_messages_session_lookup"'
)
)
# Webhook Endpoints
if index_exists(
"webhook_endpoints", "idx_webhook_endpoints_workspace_lookup", inspector
):
conn.execute(
text(
f'ALTER INDEX {schema}."idx_webhook_endpoints_workspace_lookup" RENAME TO "ix_webhook_endpoints_workspace_name"'
)
)
# ============================================================
# INDEX DELETIONS (redundant indexes being removed)
# ============================================================
# Drop redundant indexes on primary keys and unique columns
if index_exists("messages", "ix_messages_id", inspector):
op.drop_index("ix_messages_id", table_name="messages", schema=schema)
if index_exists("messages", "ix_messages_public_id", inspector):
op.drop_index("ix_messages_public_id", table_name="messages", schema=schema)
if index_exists("peers", "ix_peers_name", inspector):
op.drop_index("ix_peers_name", table_name="peers", schema=schema)
if index_exists("peers", "idx_peers_workspace_lookup", inspector):
op.drop_index("idx_peers_workspace_lookup", table_name="peers", schema=schema)
if index_exists("workspaces", "ix_workspaces_name", inspector):
op.drop_index("ix_workspaces_name", table_name="workspaces", schema=schema)
# Drop redundant index on active_queue_sessions.work_unit_key (unique constraint auto-creates index)
if index_exists(
"active_queue_sessions", "ix_active_queue_sessions_work_unit_key", inspector
):
op.drop_index(
"ix_active_queue_sessions_work_unit_key",
table_name="active_queue_sessions",
schema=schema,
)
# Drop old queue indexes that will be replaced
if index_exists("queue", "ix_queue_workspace_name_processed", inspector):
op.drop_index(
"ix_queue_workspace_name_processed", table_name="queue", schema=schema
)
# ============================================================
# NEW INDEXES (being created, not renamed)
# ============================================================
# Queue - new indexes
if not index_exists("queue", "ix_queue_processed", inspector):
op.create_index(
"ix_queue_processed", "queue", ["processed"], unique=False, schema=schema
)
if not index_exists("queue", "ix_queue_work_unit_key_processed_id", inspector):
op.create_index(
"ix_queue_work_unit_key_processed_id",
"queue",
["work_unit_key", "processed", "id"],
unique=False,
schema=schema,
)
# Sessions - new index
if not index_exists("sessions", "ix_sessions_workspace_name", inspector):
op.create_index(
"ix_sessions_workspace_name",
"sessions",
["workspace_name"],
unique=False,
schema=schema,
)
# Messages - drop FK that's being removed
if constraint_exists(
"messages", "fk_messages_workspace_name_workspaces", "foreignkey", inspector
):
op.drop_constraint(
"fk_messages_workspace_name_workspaces",
"messages",
schema=schema,
type_="foreignkey",
)
# ============================================================
# FIX FK CASCADE (message_embeddings -> messages)
# ============================================================
# Refresh inspector to get latest state after potential renames
inspector = sa.inspect(conn)
# Drop and recreate FK with ON DELETE CASCADE
# This FK should exist at this point either from:
# 1. Being renamed from old name "message_embeddings_message_id_fkey", or
# 2. Already having the new name "fk_message_embeddings_message_id_messages" (from main branch)
if constraint_exists(
"message_embeddings",
"fk_message_embeddings_message_id_messages",
"foreignkey",
inspector,
):
conn.execute(
text(
f"ALTER TABLE {schema}.message_embeddings DROP CONSTRAINT fk_message_embeddings_message_id_messages"
)
)
conn.execute(
text(
f"""
ALTER TABLE {schema}.message_embeddings
ADD CONSTRAINT fk_message_embeddings_message_id_messages
FOREIGN KEY (message_id) REFERENCES {schema}.messages(public_id)
ON DELETE CASCADE
"""
)
)
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# ============================================================
# REVERSE: FIX FK CASCADE (message_embeddings -> messages)
# ============================================================
# Drop FK with CASCADE and recreate without CASCADE
if constraint_exists(
"message_embeddings",
"fk_message_embeddings_message_id_messages",
"foreignkey",
inspector,
):
conn.execute(
text(
f"ALTER TABLE {schema}.message_embeddings DROP CONSTRAINT fk_message_embeddings_message_id_messages"
)
)
conn.execute(
text(
f"""
ALTER TABLE {schema}.message_embeddings
ADD CONSTRAINT fk_message_embeddings_message_id_messages
FOREIGN KEY (message_id) REFERENCES {schema}.messages(public_id)
"""
)
)
# ============================================================
# REVERSE: NEW INDEXES AND FKS
# ============================================================
# Recreate FK
op.create_foreign_key(
"fk_messages_workspace_name_workspaces",
"messages",
"workspaces",
["workspace_name"],
["name"],
source_schema=schema,
referent_schema=schema,
)
# Drop new indexes
op.drop_index("ix_sessions_workspace_name", table_name="sessions", schema=schema)
op.drop_index("ix_queue_processed", table_name="queue", schema=schema)
# ============================================================
# REVERSE: INDEX DELETIONS (recreate them)
# ============================================================
# Recreate redundant index on active_queue_sessions.work_unit_key
op.create_index(
"ix_active_queue_sessions_work_unit_key",
"active_queue_sessions",
["work_unit_key"],
unique=False,
schema=schema,
)
op.create_index(
"ix_queue_workspace_name_processed",
"queue",
["workspace_name", "processed"],
unique=False,
schema=schema,
)
op.create_index(
"ix_workspaces_name", "workspaces", ["name"], unique=False, schema=schema
)
op.create_index(
"idx_peers_workspace_lookup",
"peers",
["workspace_name", "name"],
unique=False,
schema=schema,
)
op.create_index("ix_peers_name", "peers", ["name"], unique=False, schema=schema)
op.create_index(
"ix_messages_public_id", "messages", ["public_id"], unique=False, schema=schema
)
op.create_index("ix_messages_id", "messages", ["id"], unique=False, schema=schema)
# ============================================================
# REVERSE: INDEX RENAMES (using raw SQL)
# ============================================================
conn.execute(
text(
f'ALTER INDEX {schema}."ix_webhook_endpoints_workspace_name" RENAME TO "idx_webhook_endpoints_workspace_lookup"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_messages_session_lookup" RENAME TO "idx_messages_session_lookup"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_messages_content_gin" RENAME TO "idx_messages_content_gin"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_message_embeddings_workspace_name" RENAME TO "idx_message_embeddings_workspace_name"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_message_embeddings_session_name" RENAME TO "idx_message_embeddings_session_name"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_message_embeddings_peer_name" RENAME TO "idx_message_embeddings_peer_name"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_message_embeddings_message_id" RENAME TO "idx_message_embeddings_message_id"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_message_embeddings_embedding_hnsw" RENAME TO "idx_message_embeddings_embedding_hnsw"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_message_embeddings_created_at" RENAME TO "idx_message_embeddings_created_at"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_documents_session_name" RENAME TO "idx_documents_session_name"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_documents_observer" RENAME TO "idx_documents_observer"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_documents_observed" RENAME TO "idx_documents_observed"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_documents_embedding_hnsw" RENAME TO "idx_documents_embedding_hnsw"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_collections_observer" RENAME TO "idx_collections_observer"'
)
)
conn.execute(
text(
f'ALTER INDEX {schema}."ix_collections_observed" RENAME TO "idx_collections_observed"'
)
)
# ============================================================
# REVERSE: CONSTRAINT RENAMES
# ============================================================
# Foreign Keys
conn.execute(
text(
f'ALTER TABLE {schema}.message_embeddings RENAME CONSTRAINT "fk_message_embeddings_message_id_messages" TO "message_embeddings_message_id_fkey"'
)
)
# Unique Constraints
conn.execute(
text(
f'ALTER TABLE {schema}.workspaces RENAME CONSTRAINT "uq_workspaces_name" TO "uq_apps_name"'
)
)
conn.execute(
text(
f'ALTER TABLE {schema}.messages RENAME CONSTRAINT "uq_messages_workspace_name_session_name_seq_in_session" TO "uq_messages_session_seq"'
)
)
conn.execute(
text(
f'ALTER TABLE {schema}.sessions RENAME CONSTRAINT "uq_sessions_name_workspace_name" TO "unique_session_name"'
)
)
conn.execute(
text(
f'ALTER TABLE {schema}.peers RENAME CONSTRAINT "uq_peers_name_workspace_name" TO "unique_name_workspace_peer"'
)
)
conn.execute(
text(
f'ALTER TABLE {schema}.collections RENAME CONSTRAINT "uq_collections_observer_observed_workspace_name" TO "unique_observer_observed_collection"'
)
)
conn.execute(
text(
f'ALTER TABLE {schema}.active_queue_sessions RENAME CONSTRAINT "uq_active_queue_sessions_work_unit_key" TO "unique_work_unit_key"'
)
)

View File

@ -1,6 +1,6 @@
[project]
name = "honcho"
version = "2.4.2"
version = "2.5.0"
description = "Honcho Server"
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},
@ -50,6 +50,7 @@ dev-dependencies = [
"pytest-cov>=6.2.1",
"honcho-ai",
"fakeredis>=2.32.0",
"scipy>=1.15.3",
]
[tool.uv.workspace]

View File

@ -5,6 +5,20 @@ 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.6.0] - 2025-12-03
### Added
- metadata and configuration fields to Workspace, Peer, Session, and Message objects
- Session Clone methods
- Peer level get_context method
- `ObservationScope` object to perform CRUD operations on observations
- Representation object for WorkingRepresentations
### Changed
- methods that take IDs, can all optionally take an object of the same type
## [1.5.0] - 2025-10-09
### Added

View File

@ -33,7 +33,7 @@ response = alice.chat("what did alice have for breakfast today?")
print("response returned:", response)
# Chat with alice in the session
response = alice.chat("what did alice have for breakfast today?", session_id=session.id)
response = alice.chat("what did alice have for breakfast today?", session=session.id)
print("response returned:", response)
# Chat with alice in the session with a target

View File

@ -10,7 +10,7 @@ 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")
session.upload_file(file, peer="alice")
# get the messages from the session
# should contain the contents of this file!

View File

@ -1,37 +0,0 @@
import random
import uuid
from honcho import Honcho
# Create a Honcho client with the default workspace
honcho = Honcho(environment="local")
peers = [
honcho.peer("alice"),
honcho.peer("bob"),
honcho.peer("charlie"),
]
# Create a new session
session = honcho.session("context_test_" + str(uuid.uuid4()))
# Generate some random messages from alice, bob, and charlie and add them to the session
messages = []
for i in range(10):
random_peer = random.choice(peers)
messages.append(
random_peer.message(f"Hello from {random_peer}! This is message {i}.")
)
session.add_messages(messages)
alice = peers[0]
bob = peers[1]
# Get alice's working representation in the session
representation = session.working_rep(alice)
print("working representation returned:", representation)
# Get alice's working representation *of bob* in the session
representation = session.working_rep(alice, target=bob)
print("working representation returned:", representation)

View File

@ -60,7 +60,7 @@ async def get_summaries_async():
async_client = AsyncHoncho(api_key=api_key)
# Get a session
async_session = async_client.session("my-conversation-session")
async_session = await async_client.session("my-conversation-session")
# Get summaries asynchronously
summaries = await async_session.get_summaries()

View File

@ -66,7 +66,7 @@ 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
"what did alice have for breakfast today?", target=alice, session=session.id
)
print("response:", response)
@ -74,7 +74,7 @@ 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
"what did alice have for breakfast today?", target=alice, session=session2.id
)
print("response:", response)

View File

@ -1,6 +1,6 @@
[project]
name = "honcho-ai"
version = "1.5.0"
version = "1.6.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.5.1",
"honcho-core>=1.6.1",
"httpx>=0.28.0, <1",
"pydantic>=2.0.0, <3",
"typing-extensions>=4.12.0; python_version < \"3.12\"",

View File

@ -40,28 +40,45 @@ from .async_client import (
AsyncPeer,
AsyncSession,
)
from .base import PeerBase, SessionBase
from .client import Honcho
from .observations import AsyncObservationScope, Observation, ObservationScope
from .pagination import SyncPage
from .peer import Peer
from .session import Session
from .session_context import SessionContext, SessionSummaries, Summary
from .types import DialecticStreamResponse
from .types import (
DeductiveObservation,
DialecticStreamResponse,
ExplicitObservation,
PeerContext,
Representation,
)
__version__ = "1.5.0"
__version__ = "1.6.0"
__author__ = "Plastic Labs"
__email__ = "hello@plasticlabs.ai"
__all__ = [
"AsyncHoncho",
"AsyncObservationScope",
"AsyncPeer",
"AsyncSession",
"AsyncPage",
"Honcho",
"Observation",
"ObservationScope",
"Peer",
"PeerBase",
"PeerContext",
"Session",
"SessionBase",
"SessionContext",
"SessionSummaries",
"Summary",
"SyncPage",
"DialecticStreamResponse",
"Representation",
"ExplicitObservation",
"DeductiveObservation",
]

View File

@ -14,6 +14,7 @@ from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from ..base import PeerBase, SessionBase
from .pagination import AsyncPage
from .peer import AsyncPeer
from .session import AsyncSession
@ -33,9 +34,11 @@ class AsyncHoncho(BaseModel):
`core` property to use functionality not exposed through this SDK.
Attributes:
api_key: API key for authentication
base_url: Base URL for the Honcho API
workspace_id: Workspace ID for scoping operations
metadata: Cached metadata for this workspace. May be stale if not recently
fetched. Call get_metadata() for fresh data.
configuration: Cached configuration for this workspace. May be stale if not
recently fetched. Call get_config() for fresh data.
core: Access to the underlying honcho_core client for advanced usage
"""
@ -46,8 +49,20 @@ class AsyncHoncho(BaseModel):
min_length=1,
description="Workspace ID for scoping operations",
)
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: dict[str, object] | None = PrivateAttr(default=None)
_client: AsyncHonchoCore = PrivateAttr()
@property
def metadata(self) -> dict[str, object] | None:
"""Cached metadata for this workspace. May be stale. Use get_metadata() for fresh data."""
return self._metadata
@property
def configuration(self) -> dict[str, object] | None:
"""Cached configuration for this workspace. May be stale. Use get_config() for fresh data."""
return self._configuration
@property
def core(self) -> AsyncHonchoCore:
"""
@ -218,7 +233,14 @@ class AsyncHoncho(BaseModel):
workspace_id=self.workspace_id, filters=filters
)
return AsyncPage(
peers_page, lambda peer: AsyncPeer(peer.id, self.workspace_id, self._client)
peers_page,
lambda peer: AsyncPeer(
peer.id,
self.workspace_id,
self._client,
metadata=peer.metadata,
config=peer.configuration,
),
)
@validate_call
@ -283,7 +305,13 @@ class AsyncHoncho(BaseModel):
)
return AsyncPage(
sessions_page,
lambda session: AsyncSession(session.id, self.workspace_id, self._client),
lambda session: AsyncSession(
session.id,
self.workspace_id,
self._client,
metadata=session.metadata,
config=session.configuration,
),
)
async def get_metadata(self) -> dict[str, object]:
@ -292,14 +320,16 @@ class AsyncHoncho(BaseModel):
Makes an async API call to retrieve metadata associated with the current workspace.
Workspace metadata can include settings, configuration, or any other
key-value data associated with the workspace.
key-value data associated with the workspace. This method also updates the
cached metadata attribute.
Returns:
A dictionary containing the workspace's metadata. Returns an empty
dictionary if no metadata is set
"""
workspace = await self._client.workspaces.get_or_create(id=self.workspace_id)
return workspace.metadata or {}
self._metadata = workspace.metadata or {}
return self._metadata
@validate_call
async def set_metadata(
@ -311,12 +341,64 @@ class AsyncHoncho(BaseModel):
Makes an async API call to update the metadata associated with the current workspace.
This will overwrite any existing metadata with the provided values.
This method also updates the cached metadata attribute.
Args:
metadata: A dictionary of metadata to associate with the workspace.
Keys must be strings, values can be any JSON-serializable type
"""
await self._client.workspaces.update(self.workspace_id, metadata=metadata)
self._metadata = metadata
async def get_config(self) -> dict[str, object]:
"""
Get configuration for the current workspace.
Makes an async API call to retrieve configuration associated with the current workspace.
Configuration includes settings that control workspace behavior.
This method also updates the cached configuration attribute.
Returns:
A dictionary containing the workspace's configuration. Returns an empty
dictionary if no configuration is set
"""
workspace = await self._client.workspaces.get_or_create(id=self.workspace_id)
self._configuration = workspace.configuration or {}
return self._configuration
@validate_call
async def set_config(
self,
configuration: dict[str, object] = Field(
..., description="Configuration dictionary"
),
) -> None:
"""
Set configuration for the current workspace.
Makes an async API call to update the configuration associated with the current workspace.
This will overwrite any existing configuration with the provided values.
This method also updates the cached configuration attribute.
Args:
configuration: A dictionary of configuration to associate with the workspace.
Keys must be strings, values can be any JSON-serializable type
"""
await self._client.workspaces.update(
self.workspace_id, configuration=configuration
)
self._configuration = configuration
async def refresh(self) -> None:
"""
Refresh cached metadata and configuration for the current workspace.
Makes a single async API call to retrieve the latest metadata and configuration
associated with the current workspace and updates the cached attributes.
"""
workspace = await self._client.workspaces.get_or_create(id=self.workspace_id)
self._metadata = workspace.metadata or {}
self._configuration = workspace.configuration or {}
async def get_workspaces(
self, filters: dict[str, object] | None = None
@ -389,29 +471,50 @@ class AsyncHoncho(BaseModel):
limit=limit,
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
session: str | SessionBase | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or Peer object) to scope the status check
sender: Optional sender (ID string or Peer object) to scope the status check
session: Optional session (ID string or Session object) to scope the status check
"""
return await self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
session_id=session_id,
resolved_observer_id = (
None
if observer is None
else (observer if isinstance(observer, str) else observer.id)
)
resolved_sender_id = (
None
if sender is None
else (sender if isinstance(sender, str) else sender.id)
)
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
@validate_call
return await self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
session_id=resolved_session_id,
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
session: str | SessionBase | None = None,
timeout: float = Field(
300.0,
gt=0,
@ -426,9 +529,9 @@ class AsyncHoncho(BaseModel):
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
observer: Optional observer (ID string or AsyncPeer object) to scope the status check
sender: Optional sender (ID string or AsyncPeer object) to scope the status check
session: Optional session (ID string or AsyncSession object) to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
@ -442,9 +545,7 @@ class AsyncHoncho(BaseModel):
while True:
try:
status = await self.get_deriver_status(
observer_id, sender_id, session_id
)
status = await self.get_deriver_status(observer, sender, session)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying
@ -497,10 +598,9 @@ class AsyncHoncho(BaseModel):
metadata: dict[str, object] = Field(
..., description="The metadata to update for the message"
),
session_id: str | None = Field(
session: str | SessionBase | None = Field(
None,
min_length=1,
description="The ID of the session (required if message is a string ID)",
description="The session (ID string or Session object) - required if message is a string ID",
),
) -> Message:
"""
@ -511,7 +611,7 @@ class AsyncHoncho(BaseModel):
Args:
message: Either a Message object or a message ID string
metadata: The metadata to update for the message
session_id: The ID of the session (required if message is a string ID, ignored if message is a Message object)
session: The session (ID string or Session object) - required if message is a string ID, ignored if message is a Message object
Returns:
The updated Message object
@ -524,9 +624,9 @@ class AsyncHoncho(BaseModel):
resolved_session_id = message.session_id
else:
message_id = message
if not session_id:
raise ValueError("session_id is required when message is a string ID")
resolved_session_id = session_id
if not session:
raise ValueError("session is required when message is a string ID")
resolved_session_id = session if isinstance(session, str) else session.id
return await self._client.workspaces.sessions.messages.update(
message_id=message_id,

View File

@ -1,25 +1,33 @@
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, cast
from honcho_core import AsyncHoncho as AsyncHonchoCore
from honcho_core._types import omit
from honcho_core.types.workspaces import PeerCardResponse
from honcho_core.types.workspaces.peer_working_representation_response import (
PeerWorkingRepresentationResponse,
)
from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from honcho_core.types.workspaces.sessions.message_create_param import Configuration
from pydantic import ConfigDict, Field, PrivateAttr, validate_call
from ..base import PeerBase, SessionBase
from ..types import DialecticStreamResponse
from .pagination import AsyncPage
if TYPE_CHECKING:
from .session import AsyncSession
from ..observations import AsyncObservationScope
from ..types import PeerContext, Representation
from .session import AsyncSession
class AsyncPeer(BaseModel):
class AsyncPeer(PeerBase):
"""
Represents a peer in the Honcho system with async operations.
@ -29,15 +37,27 @@ class AsyncPeer(BaseModel):
Attributes:
id: Unique identifier for this peer
_client: Reference to the parent AsyncHoncho client instance
workspace_id: Workspace ID for scoping operations
metadata: Cached metadata for this peer. May be stale if not recently
fetched. Call get_metadata() for fresh data.
configuration: Cached configuration for this peer. May be stale if not
recently fetched. Call get_config() for fresh data.
"""
id: str = Field(..., min_length=1, description="Unique identifier for this peer")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: dict[str, object] | None = PrivateAttr(default=None)
_client: AsyncHonchoCore = PrivateAttr()
@property
def metadata(self) -> dict[str, object] | None:
"""Cached metadata for this peer. May be stale. Use get_metadata() for fresh data."""
return self._metadata
@property
def configuration(self) -> dict[str, object] | None:
"""Cached configuration for this peer. May be stale. Use get_config() for fresh data."""
return self._configuration
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def __init__(
self,
@ -52,6 +72,9 @@ class AsyncPeer(BaseModel):
client: AsyncHonchoCore = Field(
..., description="Reference to the parent AsyncHoncho client instance"
),
*,
metadata: dict[str, object] | None = None,
config: dict[str, object] | None = None,
) -> None:
"""
Initialize a new AsyncPeer.
@ -60,9 +83,16 @@ class AsyncPeer(BaseModel):
peer_id: Unique identifier for this peer within the workspace
workspace_id: Workspace ID for scoping operations
client: Reference to the parent AsyncHoncho client instance
metadata: Optional metadata to initialize the cached value
config: Optional configuration to initialize the cached value
"""
super().__init__(id=peer_id, workspace_id=workspace_id)
super().__init__(
id=peer_id,
workspace_id=workspace_id,
)
self._client = client
self._metadata = metadata
self._configuration = config
@classmethod
async def create(
@ -92,25 +122,30 @@ class AsyncPeer(BaseModel):
Returns:
A new AsyncPeer instance
"""
peer = cls(peer_id, workspace_id, client)
if config is not None or metadata is not None:
await client.workspaces.peers.get_or_create(
peer_data = await client.workspaces.peers.get_or_create(
workspace_id=workspace_id,
id=peer_id,
configuration=config if config is not None else omit,
metadata=metadata if metadata is not None else omit,
)
return cls(
peer_id,
workspace_id,
client,
metadata=peer_data.metadata,
config=peer_data.configuration,
)
return peer
return cls(peer_id, workspace_id, client)
async def chat(
self,
query: str,
*,
stream: bool = False,
target: str | AsyncPeer | None = None,
session_id: str | None = None,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
) -> str | DialecticStreamResponse | None:
"""
Query the peer's representation with a natural language question.
@ -124,14 +159,28 @@ class AsyncPeer(BaseModel):
stream: Whether to stream the response
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.
If provided, only information from that session is considered
querying the peer's global representation. Can be a peer ID string
or an AsyncPeer object.
session: Optional session to scope the query to. If provided, only
information from that session is considered. Can be a session
ID string or an AsyncSession object.
Returns:
For non-streaming: Response string containing the answer, or None if no relevant information
For streaming: DialecticStreamResponse object that can be iterated over and provides final response
"""
# Extract IDs from objects if needed
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
if stream:
async def stream_response() -> AsyncGenerator[str]:
@ -143,8 +192,8 @@ class AsyncPeer(BaseModel):
workspace_id=self.workspace_id,
query=query,
stream=True,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
session_id=session_id,
target=target_id,
session_id=resolved_session_id,
) as response:
response.http_response.raise_for_status()
async for line in response.iter_lines():
@ -168,8 +217,8 @@ class AsyncPeer(BaseModel):
workspace_id=self.workspace_id,
query=query,
stream=stream,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
session_id=session_id,
target=target_id,
session_id=resolved_session_id,
)
# "If the context provided doesn't help address the query, write absolutely NOTHING but "None""
if response.content in ("", None, "None"):
@ -208,6 +257,10 @@ class AsyncPeer(BaseModel):
..., min_length=1, description="The text content for the message"
),
*,
config: Configuration | None = Field(
None,
description="Optional configuration dictionary to associate with the message",
),
metadata: dict[str, object] | None = Field(
None, description="Optional metadata dictionary"
),
@ -238,6 +291,7 @@ class AsyncPeer(BaseModel):
return MessageCreateParam(
peer_id=self.id,
content=content,
configuration=config,
metadata=metadata,
created_at=created_at_str,
)
@ -248,7 +302,7 @@ class AsyncPeer(BaseModel):
Makes an async API call to retrieve metadata associated with this peer. Metadata
can include custom attributes, settings, or any other key-value data
associated with the peer.
associated with the peer. This method also updates the cached metadata attribute.
Returns:
A dictionary containing the peer's metadata. Returns an empty dictionary
@ -258,7 +312,8 @@ class AsyncPeer(BaseModel):
workspace_id=self.workspace_id,
id=self.id,
)
return peer.metadata or {}
self._metadata = peer.metadata or {}
return self._metadata
@validate_call
async def set_metadata(
@ -272,6 +327,7 @@ class AsyncPeer(BaseModel):
Makes an async API call to update the metadata associated with this peer.
This will overwrite any existing metadata with the provided values.
This method also updates the cached metadata attribute.
Args:
metadata: A dictionary of metadata to associate with this peer.
@ -282,13 +338,15 @@ class AsyncPeer(BaseModel):
workspace_id=self.workspace_id,
metadata=metadata,
)
self._metadata = metadata
async def get_peer_config(self) -> dict[str, object]:
async def get_config(self) -> dict[str, object]:
"""
Get the current workspace-level configuration for this peer.
Makes an API call to retrieve configuration associated with this peer.
Configuration currently includes one optional flag, `observe_me`.
This method also updates the cached configuration attribute.
Returns:
A dictionary containing the peer's configuration
@ -297,10 +355,11 @@ class AsyncPeer(BaseModel):
workspace_id=self.workspace_id,
id=self.id,
)
return peer.configuration or {}
self._configuration = peer.configuration or {}
return self._configuration
@validate_call
async def set_peer_config(
async def set_config(
self,
config: dict[str, object] = Field(
..., description="Configuration dictionary to associate with this peer"
@ -313,6 +372,7 @@ class AsyncPeer(BaseModel):
Makes an API call to update the configuration associated with this peer.
This will overwrite any existing configuration with the provided values.
This method also updates the cached configuration attribute.
Args:
config: A dictionary of configuration to associate with this peer.
@ -323,6 +383,51 @@ class AsyncPeer(BaseModel):
workspace_id=self.workspace_id,
configuration=config,
)
self._configuration = config
async def get_peer_config(self) -> dict[str, object]:
"""
Get the current workspace-level configuration for this peer.
.. deprecated::
Use :meth:`get_config` instead.
Returns:
A dictionary containing the peer's configuration
"""
return await self.get_config()
@validate_call
async def set_peer_config(
self,
config: dict[str, object] = Field(
..., description="Configuration dictionary to associate with this peer"
),
) -> None:
"""
Set the configuration for this peer.
.. deprecated::
Use :meth:`set_config` instead.
Args:
config: A dictionary of configuration to associate with this peer
"""
return await self.set_config(config)
async def refresh(self) -> None:
"""
Refresh cached metadata and configuration for this peer.
Makes a single async API call to retrieve the latest metadata and configuration
associated with this peer and updates the cached attributes.
"""
peer = await self._client.workspaces.peers.get_or_create(
workspace_id=self.workspace_id,
id=self.id,
)
self._metadata = peer.metadata or {}
self._configuration = peer.configuration or {}
@validate_call
async def search(
@ -359,7 +464,7 @@ class AsyncPeer(BaseModel):
async def card(
self,
target: str | AsyncPeer | None = None,
target: str | PeerBase | None = None,
) -> str:
"""
Get the peer card for this peer.
@ -379,10 +484,15 @@ class AsyncPeer(BaseModel):
if isinstance(target, str) and len(target.strip()) == 0:
raise ValueError("target string cannot be empty")
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
response: PeerCardResponse = await self._client.workspaces.peers.card(
peer_id=self.id,
workspace_id=self.workspace_id,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
target=target_id,
)
if response.peer_card is None:
@ -391,6 +501,221 @@ class AsyncPeer(BaseModel):
items: list[str] = response.peer_card
return "\n".join(items)
async def working_rep(
self,
session: str | SessionBase | None = None,
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get a working representation for this peer.
Args:
session: Optional session to scope the representation to.
target: Optional target peer to get the representation of. If provided,
returns the representation of the target from the perspective of this peer.
search_query: Semantic search query to filter relevant observations
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A Representation object containing explicit and deductive observations
Example:
```python
# Get global representation
rep = await peer.working_rep()
print(rep)
# Get representation scoped to a session
session_rep = await peer.working_rep(session='session-123')
# Get representation with semantic search
searched_rep = await peer.working_rep(
search_query='preferences',
search_top_k=10,
max_observations=50
)
```
"""
from ..types import Representation as _Representation
session_id = (
None
if session is None
else session
if isinstance(session, str)
else session.id
)
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
data: PeerWorkingRepresentationResponse = (
await self._client.workspaces.peers.working_representation(
peer_id=self.id,
workspace_id=self.workspace_id,
session_id=session_id,
target=target_id,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations
if max_observations is not None
else omit,
)
)
representation = data.get("representation")
if representation is not None:
return _Representation.from_dict(cast(dict[str, object], representation))
else:
return _Representation.from_dict(data)
async def get_context(
self,
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "PeerContext":
"""
Get context for this peer, including representation and peer card.
This is a convenience method that retrieves both the working representation
and peer card in a single API call.
Args:
target: Optional target peer to get context for. If provided, returns
the context for the target from this peer's perspective.
Can be an AsyncPeer object or peer ID string.
search_query: Semantic search query to filter relevant observations
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A PeerContext object containing the representation and peer card
Example:
```python
# Get own context
context = await peer.get_context()
print(context.representation)
print(context.peer_card)
# Get context for another peer
context = await peer.get_context(target='other-peer-id')
# Get context with semantic search
context = await peer.get_context(
search_query='preferences',
search_top_k=10
)
```
"""
from ..types import PeerContext as _PeerContext
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
response = await self._client.workspaces.peers.get_context(
peer_id=self.id,
workspace_id=self.workspace_id,
target=target_id,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
return _PeerContext.from_api_response(response)
@property
def observations(self) -> "AsyncObservationScope":
"""
Access this peer's self-observations (where observer == observed == self).
This property provides a convenient way to access observations that this peer
has made about themselves. Use this for self-observation scenarios.
Returns:
An AsyncObservationScope scoped to this peer's self-observations
Example:
```python
# List self-observations
obs_list = await peer.observations.list()
# Search self-observations
results = await peer.observations.query("preferences")
# Delete a self-observation
await peer.observations.delete("obs-123")
```
"""
from ..observations import AsyncObservationScope as _AsyncObservationScope
return _AsyncObservationScope(self._client, self.workspace_id, self.id, self.id)
def observations_of(self, target: str | PeerBase) -> "AsyncObservationScope":
"""
Access observations this peer has made about another peer.
This method provides scoped access to observations where this peer is the
observer and the target is the observed peer.
Args:
target: The target peer (either an AsyncPeer object or peer ID string)
Returns:
An AsyncObservationScope scoped to this peer's observations of the target
Example:
```python
# Get observations about another peer
bob_observations = peer.observations_of("bob")
# List observations
obs_list = await bob_observations.list()
# Search observations
results = await bob_observations.query("work history")
# Get the representation from these observations
rep = await bob_observations.get_representation()
```
"""
from ..observations import AsyncObservationScope as _AsyncObservationScope
target_id = target.id if isinstance(target, PeerBase) else target
return _AsyncObservationScope(
self._client, self.workspace_id, self.id, target_id
)
def __repr__(self) -> str:
"""
Return a string representation of the AsyncPeer.

View File

@ -1,22 +1,27 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import asyncio
import time
import logging
import time
from typing import TYPE_CHECKING, Any
import json
from datetime import datetime
from honcho_core import AsyncHoncho as AsyncHonchoCore
from honcho_core._types import omit
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from honcho_core.types.workspaces.sessions.message_create_param import Configuration
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from ..base import PeerBase, SessionBase
from ..session_context import SessionContext, SessionSummaries, Summary
from ..utils import prepare_file_for_upload
from .pagination import AsyncPage
if TYPE_CHECKING:
from ..types import Representation
from .peer import AsyncPeer
logger = logging.getLogger(__name__)
@ -33,7 +38,7 @@ class SessionPeerConfig(BaseModel):
)
class AsyncSession(BaseModel):
class AsyncSession(SessionBase):
"""
Represents a session in Honcho with async operations.
@ -43,17 +48,27 @@ class AsyncSession(BaseModel):
Attributes:
id: Unique identifier for this session
_client: Reference to the parent AsyncHoncho client instance
anonymous: Whether this is an anonymous session
summarize: Whether automatic summarization is enabled
workspace_id: Workspace ID for scoping operations
metadata: Cached metadata for this session. May be stale if not recently
fetched. Call get_metadata() for fresh data.
configuration: Cached configuration for this session. May be stale if not
recently fetched. Call get_config() for fresh data.
"""
id: str = Field(..., min_length=1, description="Unique identifier for this session")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: dict[str, object] | None = PrivateAttr(default=None)
_client: AsyncHonchoCore = PrivateAttr()
@property
def metadata(self) -> dict[str, object] | None:
"""Cached metadata for this session. May be stale. Use get_metadata() for fresh data."""
return self._metadata
@property
def configuration(self) -> dict[str, object] | None:
"""Cached configuration for this session. May be stale. Use get_config() for fresh data."""
return self._configuration
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def __init__(
self,
@ -66,6 +81,9 @@ class AsyncSession(BaseModel):
client: AsyncHonchoCore = Field(
..., description="Reference to the parent AsyncHoncho client instance"
),
*,
metadata: dict[str, object] | None = None,
config: dict[str, object] | None = None,
) -> None:
"""
Initialize a new AsyncSession.
@ -74,12 +92,16 @@ class AsyncSession(BaseModel):
session_id: Unique identifier for this session within the workspace
workspace_id: Workspace ID for scoping operations
client: Reference to the parent AsyncHoncho client instance
metadata: Optional metadata to initialize the cached value
config: Optional configuration to initialize the cached value
"""
super().__init__(
id=session_id,
workspace_id=workspace_id,
)
self._client = client
self._metadata = metadata
self._configuration = config
@classmethod
async def create(
@ -109,27 +131,32 @@ class AsyncSession(BaseModel):
Returns:
A new AsyncSession instance
"""
session = cls(session_id, workspace_id, client)
if config is not None or metadata is not None:
await client.workspaces.sessions.get_or_create(
session_data = await client.workspaces.sessions.get_or_create(
workspace_id=workspace_id,
id=session_id,
configuration=config if config is not None else omit,
metadata=metadata if metadata is not None else omit,
)
return cls(
session_id,
workspace_id,
client,
metadata=session_data.metadata,
config=session_data.configuration,
)
return session
return cls(session_id, workspace_id, client)
async def add_peers(
self,
peers: str
| AsyncPeer
| PeerBase
| tuple[str, SessionPeerConfig]
| tuple[AsyncPeer, SessionPeerConfig]
| list[AsyncPeer | str]
| list[tuple[AsyncPeer | str, SessionPeerConfig]]
| list[AsyncPeer | str | tuple[AsyncPeer | str, SessionPeerConfig]] = Field(
| tuple[PeerBase, SessionPeerConfig]
| list[PeerBase | str]
| list[tuple[PeerBase | str, SessionPeerConfig]]
| list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] = Field(
..., description="Peers to add to the session"
),
) -> None:
@ -174,12 +201,12 @@ class AsyncSession(BaseModel):
async def set_peers(
self,
peers: str
| AsyncPeer
| PeerBase
| tuple[str, SessionPeerConfig]
| tuple[AsyncPeer, SessionPeerConfig]
| list[AsyncPeer | str]
| list[tuple[AsyncPeer | str, SessionPeerConfig]]
| list[AsyncPeer | str | tuple[AsyncPeer | str, SessionPeerConfig]] = Field(
| tuple[PeerBase, SessionPeerConfig]
| list[PeerBase | str]
| list[tuple[PeerBase | str, SessionPeerConfig]]
| list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] = Field(
..., description="Peers to set for the session"
),
) -> None:
@ -222,7 +249,7 @@ class AsyncSession(BaseModel):
async def remove_peers(
self,
peers: str | AsyncPeer | list[AsyncPeer | str] = Field(
peers: str | PeerBase | list[PeerBase | str] = Field(
..., description="Peers to remove from the session"
),
) -> None:
@ -272,15 +299,14 @@ class AsyncSession(BaseModel):
for peer in peers_page.items
]
async def get_peer_config(self, peer: str | AsyncPeer) -> SessionPeerConfig:
async def get_peer_config(self, peer: str | PeerBase) -> SessionPeerConfig:
"""
Get the configuration for a peer in this session.
"""
from .peer import AsyncPeer
peer_id = peer if isinstance(peer, str) else peer.id
peer_get_config_response = (
await self._client.workspaces.sessions.peers.get_config(
peer_id=str(peer.id) if isinstance(peer, AsyncPeer) else peer,
peer_id=peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
)
@ -291,15 +317,14 @@ class AsyncSession(BaseModel):
)
async def set_peer_config(
self, peer: str | AsyncPeer, config: SessionPeerConfig
self, peer: str | PeerBase, config: SessionPeerConfig
) -> None:
"""
Set the configuration for a peer in this session.
"""
from .peer import AsyncPeer
peer_id = peer if isinstance(peer, str) else peer.id
await self._client.workspaces.sessions.peers.set_config(
peer_id=str(peer.id) if isinstance(peer, AsyncPeer) else peer,
peer_id=peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
observe_others=omit
@ -314,7 +339,7 @@ class AsyncSession(BaseModel):
messages: MessageCreateParam | list[MessageCreateParam] = Field(
..., description="Messages to add to the session"
),
) -> None:
) -> list[Message]:
"""
Add one or more messages to this session.
@ -330,7 +355,7 @@ class AsyncSession(BaseModel):
if not isinstance(messages, list):
messages = [messages]
await self._client.workspaces.sessions.messages.create(
return await self._client.workspaces.sessions.messages.create(
session_id=self.id,
workspace_id=self.workspace_id,
messages=[MessageCreateParam(**message) for message in messages],
@ -370,21 +395,75 @@ class AsyncSession(BaseModel):
async def delete(self) -> None:
"""
Delete this session.
Delete this session and all associated data.
Makes an async API call to delete this session.
Makes an async API call to permanently delete this session and all related data including:
- Messages
- Message embeddings
- Observations
- Session-Peer associations
- Background processing queue items
This action cannot be undone.
"""
await self._client.workspaces.sessions.delete(
session_id=self.id,
workspace_id=self.workspace_id,
)
async def clone(
self,
*,
message_id: str | None = None,
) -> "AsyncSession":
"""
Clone this session, optionally up to a specific message.
Makes an async API call to create a copy of this session with a new ID.
All messages and peers from the original session are copied to the new session.
If a message_id is provided, only messages up to and including that message
are copied.
Args:
message_id: Optional message ID to cut off the clone at. If provided,
the cloned session will only contain messages up to and
including this message.
Returns:
A new AsyncSession object representing the cloned session
Example:
```python
# Clone entire session
cloned = await session.clone()
# Clone session up to a specific message
cloned = await session.clone(message_id="msg_abc123")
```
"""
# Make the API call using the core SDK's clone method
cloned_session_data = await self._client.workspaces.sessions.clone(
session_id=self.id,
workspace_id=self.workspace_id,
message_id=message_id if message_id is not None else omit,
)
# Return a new AsyncSession object with the cloned session's data
return AsyncSession(
cloned_session_data.id,
self.workspace_id,
self._client,
metadata=cloned_session_data.metadata,
config=cloned_session_data.configuration,
)
async def get_metadata(self) -> dict[str, object]:
"""
Get metadata for this session.
Makes an async API call to retrieve the current metadata associated with this session.
Metadata can include custom attributes, settings, or any other key-value data.
This method also updates the cached metadata attribute.
Returns:
A dictionary containing the session's metadata. Returns an empty dictionary
@ -394,7 +473,8 @@ class AsyncSession(BaseModel):
workspace_id=self.workspace_id,
id=self.id,
)
return session.metadata or {}
self._metadata = session.metadata or {}
return self._metadata
@validate_call
async def set_metadata(
@ -408,6 +488,7 @@ class AsyncSession(BaseModel):
Makes an async API call to update the metadata associated with this session.
This will overwrite any existing metadata with the provided values.
This method also updates the cached metadata attribute.
Args:
metadata: A dictionary of metadata to associate with this session.
@ -418,6 +499,65 @@ class AsyncSession(BaseModel):
workspace_id=self.workspace_id,
metadata=metadata,
)
self._metadata = metadata
async def get_config(self) -> dict[str, object]:
"""
Get configuration for this session.
Makes an async API call to retrieve the current configuration associated with this session.
Configuration includes settings that control session behavior.
This method also updates the cached configuration attribute.
Returns:
A dictionary containing the session's configuration. Returns an empty dictionary
if no configuration is set
"""
session = await self._client.workspaces.sessions.get_or_create(
workspace_id=self.workspace_id,
id=self.id,
)
self._configuration = session.configuration or {}
return self._configuration
@validate_call
async def set_config(
self,
configuration: dict[str, object] = Field(
..., description="Configuration dictionary to associate with this session"
),
) -> None:
"""
Set configuration for this session.
Makes an async API call to update the configuration associated with this session.
This will overwrite any existing configuration with the provided values.
This method also updates the cached configuration attribute.
Args:
configuration: A dictionary of configuration to associate with this session.
Keys must be strings, values can be any JSON-serializable type
"""
await self._client.workspaces.sessions.update(
session_id=self.id,
workspace_id=self.workspace_id,
configuration=configuration,
)
self._configuration = configuration
async def refresh(self) -> None:
"""
Refresh cached metadata and configuration for this session.
Makes a single async API call to retrieve the latest metadata and configuration
associated with this session and updates the cached attributes.
"""
session = await self._client.workspaces.sessions.get_or_create(
workspace_id=self.workspace_id,
id=self.id,
)
self._metadata = session.metadata or {}
self._configuration = session.configuration or {}
@validate_call
async def get_context(
@ -439,6 +579,32 @@ class AsyncSession(BaseModel):
None,
description="A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`.",
),
limit_to_session: bool = Field(
False,
description="Whether to limit the representation to this session only. If True, only observations from this session will be included.",
),
search_top_k: int | None = Field(
None,
ge=1,
le=100,
description="Number of semantically relevant facts to return when searching with `last_user_message`.",
),
search_max_distance: float | None = Field(
None,
ge=0.0,
le=1.0,
description="Maximum semantic distance for search results (0.0-1.0) when searching with `last_user_message`.",
),
include_most_derived: bool | None = Field(
None,
description="Whether to include the most derived observations in the representation.",
),
max_observations: int | None = Field(
None,
ge=1,
le=100,
description="Maximum number of observations to include in the representation.",
),
) -> SessionContext:
"""
Get optimized context for this session within a token limit.
@ -455,6 +621,11 @@ class AsyncSession(BaseModel):
peer_target: A peer ID to get context for. If given *without* `peer_perspective`, a representation and peer card will be included from the omniscient Honcho-level view of `peer_target`. If given *with* `peer_perspective`, will get the representation and card for `peer_target` *from the perspective of `peer_perspective`*.
last_user_message: The most recent message (string or Message object), used to fetch semantically relevant observations and returned as part of the context object. Use this alongside `peer_target` to get a more focused context -- does nothing if `peer_target` is not provided.
peer_perspective: A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`.
limit_to_session: Whether to limit the representation to this session only. If True, only observations from this session will be included.
search_top_k: Number of semantically relevant facts to return when searching with `last_user_message`.
search_max_distance: Maximum semantic distance for search results (0.0-1.0) when searching with `last_user_message`.
include_most_derived: Whether to include the most derived observations in the representation.
max_observations: Maximum number of observations to include in the representation.
Returns:
A SessionContext object containing the optimized message history and
@ -491,6 +662,15 @@ class AsyncSession(BaseModel):
else omit,
peer_target=peer_target if peer_target is not None else omit,
peer_perspective=peer_perspective if peer_perspective is not None else omit,
limit_to_session=limit_to_session,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
# Convert the honcho_core summary to our Summary if it exists
@ -600,14 +780,28 @@ class AsyncSession(BaseModel):
limit=limit,
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
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"),
peer: str | PeerBase = Field(
..., description="The peer creating the messages (ID string or Peer object)"
),
metadata: dict[str, object] | None = Field(
None,
description="Optional metadata dictionary to associate with the messages",
),
configuration: Configuration | None = Field(
None,
description="Optional configuration dictionary to associate with the messages",
),
created_at: str | datetime | None = Field(
None,
description="Optional created-at timestamp for the messages. Should be an ISO 8601 formatted string.",
),
) -> list[Message]:
"""
Upload file to create message(s) in this session.
@ -624,7 +818,11 @@ class AsyncSession(BaseModel):
- 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
peer: The peer who will be attributed as the creator of the messages.
Can be a peer ID string or an AsyncPeer object.
metadata: Optional metadata dictionary to associate with the messages
configuration: Optional configuration dictionary to associate with the messages
created_at: Optional created-at timestamp for the messages. Should be an ISO 8601 formatted string.
Returns:
A list of Message objects representing the created messages
@ -638,22 +836,44 @@ class AsyncSession(BaseModel):
# Prepare file for upload using shared utility
filename, content_bytes, content_type = prepare_file_for_upload(file)
# Call the upload endpoint
# Extract peer ID from AsyncPeer object if needed
resolved_peer_id = peer if isinstance(peer, str) else peer.id
# Build extra_body dict with optional fields as JSON strings (backend expects Form fields)
extra_body_data: dict[str, str] = {}
if metadata is not None:
extra_body_data["metadata"] = json.dumps(metadata)
if configuration is not None:
extra_body_data["configuration"] = json.dumps(configuration)
if created_at is not None:
# Ensure created_at is a string (ISO format)
if isinstance(created_at, datetime):
extra_body_data["created_at"] = created_at.isoformat()
else:
extra_body_data["created_at"] = created_at
# Call the upload endpoint with extra_body for the additional form fields
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,
peer_id=resolved_peer_id,
extra_body=extra_body_data if extra_body_data else None,
)
return [Message.model_validate(msg) for msg in response]
async def working_rep(
self,
peer: str | AsyncPeer,
peer: str | PeerBase,
*,
target: str | AsyncPeer | None = None,
) -> dict[str, object]:
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get the current working representation of the peer in this session.
@ -661,40 +881,94 @@ class AsyncSession(BaseModel):
peer: Peer to get the working representation of.
target: Optional target peer to get the representation of. If provided,
queries what `peer` knows about the `target`.
search_query: Semantic search query to filter relevant observations
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A dictionary containing information about the peer.
"""
from .peer import AsyncPeer
A Representation object containing explicit and deductive observations
return await self._client.workspaces.peers.working_representation(
str(peer.id) if isinstance(peer, AsyncPeer) else peer,
workspace_id=self.workspace_id,
session_id=self.id,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
Example:
```python
# Get peer's representation in this session
rep = await session.working_rep('user123')
print(rep)
# Get what user123 knows about assistant in this session
local_rep = await session.working_rep('user123', target='assistant')
# Get representation with semantic search
searched_rep = await session.working_rep(
'user123',
search_query='preferences',
search_top_k=10
)
```
"""
from ..types import Representation as _Representation
peer_id = peer if isinstance(peer, str) else peer.id
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
@validate_call
data = await self._client.workspaces.peers.working_representation(
peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
target=target_id,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
return _Representation.from_dict(data) # type: ignore
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or AsyncPeer object) to scope the status check
sender: Optional sender (ID string or AsyncPeer object) to scope the status check
"""
resolved_observer_id = (
None
if observer is None
else (observer if isinstance(observer, str) else observer.id)
)
resolved_sender_id = (
None
if sender is None
else (sender if isinstance(sender, str) else sender.id)
)
return await self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
session_id=self.id,
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
timeout: float = Field(
300.0,
gt=0,
@ -709,8 +983,8 @@ class AsyncSession(BaseModel):
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
observer: Optional observer (ID string or AsyncPeer object) to scope the status check
sender: Optional sender (ID string or AsyncPeer object) to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
@ -724,7 +998,7 @@ class AsyncSession(BaseModel):
while True:
try:
status = await self.get_deriver_status(observer_id, sender_id)
status = await self.get_deriver_status(observer, sender)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying

View File

@ -0,0 +1,45 @@
"""Base classes for Honcho SDK entities.
This module provides base classes that contain only the essential data fields
shared by both sync and async variants of Peer and Session. These base classes
can be imported anywhere without causing circular import issues, enabling
type-safe method signatures like `str | PeerBase`.
"""
from pydantic import BaseModel, Field
class PeerBase(BaseModel):
"""Base class for Peer objects (sync and async variants).
This class contains only the essential data fields shared by both
Peer and AsyncPeer. Use this type in method signatures to accept
either a peer ID string or any Peer object.
Attributes:
id: Unique identifier for this peer
workspace_id: Workspace ID for scoping operations
"""
id: str = Field(..., min_length=1, description="Unique identifier for this peer")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
class SessionBase(BaseModel):
"""Base class for Session objects (sync and async variants).
This class contains only the essential data fields shared by both
Session and AsyncSession. Use this type in method signatures to accept
either a session ID string or any Session object.
Attributes:
id: Unique identifier for this session
workspace_id: Workspace ID for scoping operations
"""
id: str = Field(..., min_length=1, description="Unique identifier for this session")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)

View File

@ -12,6 +12,7 @@ from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from .base import PeerBase, SessionBase
from .pagination import SyncPage
from .peer import Peer
from .session import Session
@ -31,9 +32,11 @@ class Honcho(BaseModel):
`core` property to use functionality not exposed through this SDK.
Attributes:
api_key: API key for authentication
base_url: Base URL for the Honcho API
workspace_id: Workspace ID for scoping operations
metadata: Cached metadata for this workspace. May be stale if not recently
fetched. Call get_metadata() for fresh data.
configuration: Cached configuration for this workspace. May be stale if not
recently fetched. Call get_config() for fresh data.
core: Access to the underlying honcho_core client for advanced usage
"""
@ -44,8 +47,20 @@ class Honcho(BaseModel):
min_length=1,
description="Workspace ID for scoping operations",
)
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: dict[str, object] | None = PrivateAttr(default=None)
_client: HonchoCore = PrivateAttr()
@property
def metadata(self) -> dict[str, object] | None:
"""Cached metadata for this workspace. May be stale. Use get_metadata() for fresh data."""
return self._metadata
@property
def configuration(self) -> dict[str, object] | None:
"""Cached configuration for this workspace. May be stale. Use get_config() for fresh data."""
return self._configuration
@property
def core(self) -> HonchoCore:
"""
@ -183,6 +198,7 @@ class Honcho(BaseModel):
Raises:
ValidationError: If the peer ID is empty or invalid
"""
# Peer constructor handles API call and caching when metadata/config provided
return Peer(
id, self.workspace_id, self._client, config=config, metadata=metadata
)
@ -204,7 +220,14 @@ class Honcho(BaseModel):
workspace_id=self.workspace_id, filters=filters
)
return SyncPage(
peers_page, lambda peer: Peer(peer.id, self.workspace_id, self._client)
peers_page,
lambda peer: Peer(
peer.id,
self.workspace_id,
self._client,
metadata=peer.metadata,
config=peer.configuration,
),
)
@validate_call
@ -267,7 +290,13 @@ class Honcho(BaseModel):
)
return SyncPage(
sessions_page,
lambda session: Session(session.id, self.workspace_id, self._client),
lambda session: Session(
session.id,
self.workspace_id,
self._client,
metadata=session.metadata,
config=session.configuration,
),
)
def get_metadata(self) -> dict[str, object]:
@ -276,14 +305,16 @@ class Honcho(BaseModel):
Makes an API call to retrieve metadata associated with the current workspace.
Workspace metadata can include settings, configuration, or any other
key-value data associated with the workspace.
key-value data associated with the workspace. This method also updates the
cached metadata attribute.
Returns:
A dictionary containing the workspace's metadata. Returns an empty
dictionary if no metadata is set
"""
workspace = self._client.workspaces.get_or_create(id=self.workspace_id)
return workspace.metadata or {}
self._metadata = workspace.metadata or {}
return self._metadata
@validate_call
def set_metadata(
@ -295,12 +326,62 @@ class Honcho(BaseModel):
Makes an API call to update the metadata associated with the current workspace.
This will overwrite any existing metadata with the provided values.
This method also updates the cached metadata attribute.
Args:
metadata: A dictionary of metadata to associate with the workspace.
Keys must be strings, values can be any JSON-serializable type
"""
self._client.workspaces.update(self.workspace_id, metadata=metadata)
self._metadata = metadata
def get_config(self) -> dict[str, object]:
"""
Get configuration for the current workspace.
Makes an API call to retrieve configuration associated with the current workspace.
Configuration includes settings that control workspace behavior.
This method also updates the cached configuration attribute.
Returns:
A dictionary containing the workspace's configuration. Returns an empty
dictionary if no configuration is set
"""
workspace = self._client.workspaces.get_or_create(id=self.workspace_id)
self._configuration = workspace.configuration or {}
return self._configuration
@validate_call
def set_config(
self,
configuration: dict[str, object] = Field(
..., description="Configuration dictionary"
),
) -> None:
"""
Set configuration for the current workspace.
Makes an API call to update the configuration associated with the current workspace.
This will overwrite any existing configuration with the provided values.
This method also updates the cached configuration attribute.
Args:
configuration: A dictionary of configuration to associate with the workspace.
Keys must be strings, values can be any JSON-serializable type
"""
self._client.workspaces.update(self.workspace_id, configuration=configuration)
self._configuration = configuration
def refresh(self) -> None:
"""
Refresh cached metadata and configuration for the current workspace.
Makes a single API call to retrieve the latest metadata and configuration
associated with the current workspace and updates the cached attributes.
"""
workspace = self._client.workspaces.get_or_create(id=self.workspace_id)
self._metadata = workspace.metadata or {}
self._configuration = workspace.configuration or {}
def get_workspaces(self, filters: dict[str, object] | None = None) -> list[str]:
"""
@ -365,29 +446,50 @@ class Honcho(BaseModel):
self.workspace_id, query=query, filters=filters, limit=limit
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
session: str | SessionBase | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or Peer object) to scope the status check
sender: Optional sender (ID string or Peer object) to scope the status check
session: Optional session (ID string or Session object) to scope the status check
"""
return self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
session_id=session_id,
resolved_observer_id = (
None
if observer is None
else (observer if isinstance(observer, str) else observer.id)
)
resolved_sender_id = (
None
if sender is None
else (sender if isinstance(sender, str) else sender.id)
)
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
@validate_call
return self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
session_id=resolved_session_id,
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
session_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
session: str | SessionBase | None = None,
timeout: float = Field(
300.0,
gt=0,
@ -402,9 +504,9 @@ class Honcho(BaseModel):
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
observer: Optional observer (ID string or Peer object) to scope the status check
sender: Optional sender (ID string or Peer object) to scope the status check
session: Optional session (ID string or Session object) to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
@ -418,7 +520,7 @@ class Honcho(BaseModel):
while True:
try:
status = self.get_deriver_status(observer_id, sender_id, session_id)
status = self.get_deriver_status(observer, sender, session)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying
@ -471,10 +573,9 @@ class Honcho(BaseModel):
metadata: dict[str, object] = Field(
..., description="The metadata to update for the message"
),
session_id: str | None = Field(
session: str | SessionBase | None = Field(
None,
min_length=1,
description="The ID of the session (required if message is a string ID)",
description="The session (ID string or Session object) - required if message is a string ID",
),
) -> Message:
"""
@ -485,7 +586,7 @@ class Honcho(BaseModel):
Args:
message: Either a Message object or a message ID string
metadata: The metadata to update for the message
session_id: The ID of the session (required if message is a string ID, ignored if message is a Message object)
session: The session (ID string or Session object) - required if message is a string ID, ignored if message is a Message object
Returns:
The updated Message object
@ -498,9 +599,9 @@ class Honcho(BaseModel):
resolved_session_id = message.session_id
else:
message_id = message
if not session_id:
raise ValueError("session_id is required when message is a string ID")
resolved_session_id = session_id
if not session:
raise ValueError("session is required when message is a string ID")
resolved_session_id = session if isinstance(session, str) else session.id
return self._client.workspaces.sessions.messages.update(
message_id=message_id,

View File

@ -0,0 +1,589 @@
"""Observation types and scoped access for the Honcho SDK."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from .base import SessionBase
if TYPE_CHECKING:
from .types import ObservationCreateParam, Representation
def _convert_observation(item: Any) -> dict[str, Any]:
"""Convert a core SDK Observations model to a dict for our Observation class."""
if hasattr(item, "model_dump"):
# Pydantic model - use model_dump()
return item.model_dump() # type: ignore[no-any-return]
elif isinstance(item, dict):
return cast(dict[str, Any], item)
else:
# Fallback: access as object attributes
return {
"id": getattr(item, "id", ""),
"content": getattr(item, "content", ""),
"observer_id": getattr(item, "observer_id", ""),
"observed_id": getattr(item, "observed_id", ""),
"session_id": getattr(item, "session_id", ""),
"created_at": str(getattr(item, "created_at", "")),
}
class Observation:
"""
An observation from the theory-of-mind system.
Observations are facts derived from messages that help build a representation
of a peer.
Attributes:
id: Unique identifier for this observation
content: The observation content/text
observer_id: The peer who made the observation
observed_id: The peer being observed
session_id: The session where this observation was made
created_at: When the observation was created
"""
id: str
content: str
observer_id: str
observed_id: str
session_id: str
created_at: str
def __init__(
self,
id: str,
content: str,
observer_id: str,
observed_id: str,
session_id: str,
created_at: str,
):
self.id = id
self.content = content
self.observer_id = observer_id
self.observed_id = observed_id
self.session_id = session_id
self.created_at = created_at
@classmethod
def from_api_response(cls, data: dict[str, Any]) -> "Observation":
"""Create an Observation from an API response dict."""
return cls(
id=data.get("id", ""),
content=data.get("content", ""),
observer_id=data.get("observer_id", ""),
observed_id=data.get("observed_id", ""),
session_id=data.get("session_id", ""),
created_at=str(data.get("created_at", "")),
)
def __repr__(self) -> str:
truncated = (
f"{self.content[:50]}..." if len(self.content) > 50 else self.content
)
return f"Observation(id={self.id!r}, content={truncated!r})"
class ObservationScope:
"""
Scoped access to observations for a specific observer/observed relationship.
This class provides convenient methods to list, query, create, and delete observations
that are automatically scoped to a specific observer/observed pair.
Typically accessed via `peer.observations` (for self-observations) or
`peer.observations_of(target)` (for observations about another peer).
Example:
```python
# Get self-observations
observations = peer.observations
obs_list = observations.list()
search_results = observations.query("preferences")
# Get observations about another peer
bob_observations = peer.observations_of("bob")
bob_list = bob_observations.list()
```
"""
_client: Any
workspace_id: str
observer: str
observed: str
def __init__(
self,
client: Any,
workspace_id: str,
observer: str,
observed: str,
):
"""
Initialize an ObservationScope.
Args:
client: The Honcho client instance
workspace_id: The workspace ID
observer: The observer peer ID
observed: The observed peer ID
"""
self._client = client
self.workspace_id = workspace_id
self.observer = observer
self.observed = observed
def list(
self,
page: int = 1,
size: int = 50,
session: str | SessionBase | None = None,
) -> list[Observation]:
"""
List observations in this scope.
Args:
page: Page number (1-indexed)
size: Number of results per page
session: Optional session (ID string or Session object) to filter by
Returns:
List of Observation objects
"""
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
if resolved_session_id:
filters["session_id"] = resolved_session_id
response = self._client.workspaces.observations.list(
workspace_id=self.workspace_id,
filters=filters,
page=page,
size=size,
)
# response.items is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(item))
for item in response.items
]
def query(
self,
query: str,
top_k: int = 10,
distance: float | None = None,
) -> list[Observation]:
"""
Semantic search for observations in this scope.
Args:
query: The search query string
top_k: Maximum number of results to return
distance: Maximum cosine distance threshold (0.0-1.0)
Returns:
List of matching Observation objects
"""
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
response = self._client.workspaces.observations.query(
workspace_id=self.workspace_id,
query=query,
top_k=top_k,
distance=distance,
filters=filters,
)
# response is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(item))
for item in response
]
def delete(self, observation_id: str) -> None:
"""
Delete an observation by ID.
Args:
observation_id: The ID of the observation to delete
"""
self._client.workspaces.observations.delete(
workspace_id=self.workspace_id,
observation_id=observation_id,
)
def create(
self,
observations: "ObservationCreateParam | list[ObservationCreateParam]",
) -> list[Observation]:
"""
Create observations in this scope.
Args:
observations: Single observation or list of observations to create.
Each observation must have 'content' and 'session_id' keys.
Returns:
List of created Observation objects
Example:
```python
# Create a single observation
observations = peer.observations.create(
{"content": "User prefers dark mode", "session_id": "session1"}
)
# Create multiple observations
observations = peer.observations.create([
{"content": "User prefers dark mode", "session_id": "session1"},
{"content": "User is interested in AI", "session_id": "session1"},
])
```
"""
# Normalize to list
if not isinstance(observations, list):
observations = [observations]
# Build the request body with observer/observed from scope
request_observations = [
{
"content": obs["content"],
"session_id": obs["session_id"]
if isinstance(obs["session_id"], str)
else obs["session_id"].id,
"observer_id": self.observer,
"observed_id": self.observed,
}
for obs in observations
]
response = self._client.workspaces.observations.create( # type: ignore[attr-defined]
workspace_id=self.workspace_id,
observations=request_observations,
)
# response is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(item))
for item in response
]
def get_representation(
self,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get the computed representation for this scope.
This returns the working representation (narrative) built from the
observations in this scope.
Args:
search_query: Optional semantic search query to curate the representation
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A Representation object containing explicit and deductive observations
"""
from honcho_core._types import omit
from .types import Representation
response = self._client.workspaces.peers.working_representation(
peer_id=self.observer,
workspace_id=self.workspace_id,
target=self.observed,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
representation = response.get("representation")
if representation is not None:
return Representation.from_dict(cast(dict[str, Any], representation))
else:
return Representation.from_dict(response)
def __repr__(self) -> str:
return (
f"ObservationScope(workspace_id={self.workspace_id!r}, "
f"observer={self.observer!r}, observed={self.observed!r})"
)
class AsyncObservationScope:
"""
Async scoped access to observations for a specific observer/observed relationship.
This class provides convenient async methods to list, query, create, and delete observations
that are automatically scoped to a specific observer/observed pair.
Typically accessed via `peer.observations` (for self-observations) or
`peer.observations_of(target)` (for observations about another peer).
Example:
```python
# Get self-observations
observations = peer.observations
obs_list = await observations.list()
search_results = await observations.query("preferences")
# Get observations about another peer
bob_observations = peer.observations_of("bob")
bob_list = await bob_observations.list()
```
"""
_client: Any
workspace_id: str
observer: str
observed: str
def __init__(
self,
client: Any,
workspace_id: str,
observer: str,
observed: str,
):
"""
Initialize an AsyncObservationScope.
Args:
client: The AsyncHoncho client instance
workspace_id: The workspace ID
observer: The observer peer ID
observed: The observed peer ID
"""
self._client = client
self.workspace_id = workspace_id
self.observer = observer
self.observed = observed
async def list(
self,
page: int = 1,
size: int = 50,
session: str | SessionBase | None = None,
) -> list[Observation]:
"""
List observations in this scope.
Args:
page: Page number (1-indexed)
size: Number of results per page
session: Optional session (ID string or AsyncSession object) to filter by
Returns:
List of Observation objects
"""
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
if resolved_session_id:
filters["session_id"] = resolved_session_id
response = await self._client.workspaces.observations.list(
workspace_id=self.workspace_id,
filters=filters,
page=page,
size=size,
)
# response.items is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(item))
for item in response.items
]
async def query(
self,
query: str,
top_k: int = 10,
distance: float | None = None,
) -> list[Observation]:
"""
Semantic search for observations in this scope.
Args:
query: The search query string
top_k: Maximum number of results to return
distance: Maximum cosine distance threshold (0.0-1.0)
Returns:
List of matching Observation objects
"""
filters: dict[str, Any] = {
"observer": self.observer,
"observed": self.observed,
}
response = await self._client.workspaces.observations.query(
workspace_id=self.workspace_id,
query=query,
top_k=top_k,
distance=distance,
filters=filters,
)
# response is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(item))
for item in response
]
async def delete(self, observation_id: str) -> None:
"""
Delete an observation by ID.
Args:
observation_id: The ID of the observation to delete
"""
await self._client.workspaces.observations.delete(
workspace_id=self.workspace_id,
observation_id=observation_id,
)
async def create(
self,
observations: "ObservationCreateParam | list[ObservationCreateParam]",
) -> list[Observation]:
"""
Create observations in this scope.
Args:
observations: Single observation or list of observations to create.
Each observation must have 'content' and 'session_id' keys.
Returns:
List of created Observation objects
Example:
```python
# Create a single observation
observations = await peer.observations.create(
{"content": "User prefers dark mode", "session_id": "session1"}
)
# Create multiple observations
observations = await peer.observations.create([
{"content": "User prefers dark mode", "session_id": "session1"},
{"content": "User is interested in AI", "session_id": "session1"},
])
```
"""
# Normalize to list
if not isinstance(observations, list):
observations = [observations]
# Build the request body with observer/observed from scope
request_observations = [
{
"content": obs["content"],
"session_id": obs["session_id"]
if isinstance(obs["session_id"], str)
else obs["session_id"].id,
"observer_id": self.observer,
"observed_id": self.observed,
}
for obs in observations
]
response = await self._client.workspaces.observations.create( # type: ignore[attr-defined]
workspace_id=self.workspace_id,
observations=request_observations,
)
# response is List[Observations] (Pydantic models)
return [
Observation.from_api_response(_convert_observation(item))
for item in response
]
async def get_representation(
self,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get the computed representation for this scope.
This returns the working representation (narrative) built from the
observations in this scope.
Args:
search_query: Optional semantic search query to curate the representation
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A Representation object containing explicit and deductive observations
"""
from honcho_core._types import omit
from .types import Representation
response = await self._client.workspaces.peers.working_representation(
peer_id=self.observer,
workspace_id=self.workspace_id,
target=self.observed,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
representation = response.get("representation")
if representation is not None:
return Representation.from_dict(cast(dict[str, Any], representation))
else:
return Representation.from_dict(response)
def __repr__(self) -> str:
return (
f"AsyncObservationScope(workspace_id={self.workspace_id!r}, "
f"observer={self.observer!r}, observed={self.observed!r})"
)

View File

@ -1,8 +1,8 @@
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from collections.abc import Generator
from typing import TYPE_CHECKING, cast
from honcho_core import Honcho as HonchoCore
from honcho_core._types import omit
@ -10,16 +10,21 @@ from honcho_core.types.workspaces import PeerCardResponse
from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from honcho_core.types.workspaces.sessions.message_create_param import Configuration
from pydantic import ConfigDict, Field, PrivateAttr, validate_call
from .types import DialecticStreamResponse
from .base import PeerBase, SessionBase
from .pagination import SyncPage
from .types import DialecticStreamResponse
if TYPE_CHECKING:
from .session import Session
from .observations import ObservationScope
from .types import PeerContext, Representation
from .session import Session
class Peer(BaseModel):
class Peer(PeerBase):
"""
Represents a peer in the Honcho system.
@ -29,15 +34,27 @@ class Peer(BaseModel):
Attributes:
id: Unique identifier for this peer
_client: Reference to the parent Honcho client instance
workspace_id: Workspace ID for scoping operations
metadata: Cached metadata for this peer. May be stale if not recently
fetched. Call get_metadata() for fresh data.
configuration: Cached configuration for this peer. May be stale if not
recently fetched. Call get_config() for fresh data.
"""
id: str = Field(..., min_length=1, description="Unique identifier for this peer")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: dict[str, object] | None = PrivateAttr(default=None)
_client: HonchoCore = PrivateAttr()
@property
def metadata(self) -> dict[str, object] | None:
"""Cached metadata for this peer. May be stale. Use get_metadata() for fresh data."""
return self._metadata
@property
def configuration(self) -> dict[str, object] | None:
"""Cached configuration for this peer. May be stale. Use get_config() for fresh data."""
return self._configuration
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def __init__(
self,
@ -77,24 +94,32 @@ class Peer(BaseModel):
config: Optional configuration to set for this peer.
If set, will get/create peer immediately with flags.
"""
super().__init__(id=peer_id, workspace_id=workspace_id)
super().__init__(
id=peer_id,
workspace_id=workspace_id,
)
self._client = client
self._metadata = metadata
self._configuration = config
if config is not None or metadata is not None:
self._client.workspaces.peers.get_or_create(
peer_data = self._client.workspaces.peers.get_or_create(
workspace_id=workspace_id,
id=peer_id,
configuration=config if config is not None else omit,
metadata=metadata if metadata is not None else omit,
)
# Update cached values with API response
self._metadata = peer_data.metadata
self._configuration = peer_data.configuration
def chat(
self,
query: str,
*,
stream: bool = False,
target: str | Peer | None = None,
session_id: str | None = None,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
) -> str | DialecticStreamResponse | None:
"""
Query the peer's representation with a natural language question.
@ -108,14 +133,28 @@ class Peer(BaseModel):
stream: Whether to stream the response
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.
If provided, only information from that session is considered
querying the peer's global representation. Can be a peer ID string
or a Peer object.
session: Optional session to scope the query to. If provided, only
information from that session is considered. Can be a session
ID string or a Session object.
Returns:
For non-streaming: Response string containing the answer, or None if no relevant information
For streaming: DialecticStreamResponse object that can be iterated over and provides final response
"""
# Extract IDs from objects if needed
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
resolved_session_id = (
None
if session is None
else (session if isinstance(session, str) else session.id)
)
if stream:
def stream_response() -> Generator[str, None, None]:
@ -127,8 +166,8 @@ class Peer(BaseModel):
workspace_id=self.workspace_id,
query=query,
stream=True,
target=str(target.id) if isinstance(target, Peer) else target,
session_id=session_id,
target=target_id,
session_id=resolved_session_id,
) as response:
response.http_response.raise_for_status()
for line in response.iter_lines():
@ -152,8 +191,8 @@ class Peer(BaseModel):
workspace_id=self.workspace_id,
query=query,
stream=stream,
target=str(target.id) if isinstance(target, Peer) else target,
session_id=session_id,
target=target_id,
session_id=resolved_session_id,
)
if response.content in ("", None, "None"):
return None
@ -194,6 +233,10 @@ class Peer(BaseModel):
metadata: dict[str, object] | None = Field(
None, description="Optional metadata dictionary"
),
config: Configuration | None = Field(
None,
description="Optional configuration dictionary to associate with the message",
),
created_at: datetime.datetime | str | None = Field(
None,
description="Optional created-at timestamp for the message. Accepts a datetime which will be converted to an ISO 8601 string, or a preformatted string.",
@ -221,6 +264,7 @@ class Peer(BaseModel):
return MessageCreateParam(
peer_id=self.id,
content=content,
configuration=config,
metadata=metadata,
created_at=created_at_str,
)
@ -231,7 +275,7 @@ class Peer(BaseModel):
Makes an API call to retrieve metadata associated with this peer. Metadata
can include custom attributes, settings, or any other key-value data
associated with the peer.
associated with the peer. This method also updates the cached metadata attribute.
Returns:
A dictionary containing the peer's metadata. Returns an empty dictionary
@ -241,7 +285,8 @@ class Peer(BaseModel):
workspace_id=self.workspace_id,
id=self.id,
)
return peer.metadata or {}
self._metadata = peer.metadata or {}
return self._metadata
@validate_call
def set_metadata(
@ -255,6 +300,7 @@ class Peer(BaseModel):
Makes an API call to update the metadata associated with this peer.
This will overwrite any existing metadata with the provided values.
This method also updates the cached metadata attribute.
Args:
metadata: A dictionary of metadata to associate with this peer.
@ -265,13 +311,15 @@ class Peer(BaseModel):
workspace_id=self.workspace_id,
metadata=metadata,
)
self._metadata = metadata
def get_peer_config(self) -> dict[str, object]:
def get_config(self) -> dict[str, object]:
"""
Get the current workspace-level configuration for this peer.
Makes an API call to retrieve configuration associated with this peer.
Configuration currently includes one optional flag, `observe_me`.
This method also updates the cached configuration attribute.
Returns:
A dictionary containing the peer's configuration
@ -280,10 +328,11 @@ class Peer(BaseModel):
workspace_id=self.workspace_id,
id=self.id,
)
return peer.configuration or {}
self._configuration = peer.configuration or {}
return self._configuration
@validate_call
def set_peer_config(
def set_config(
self,
config: dict[str, object] = Field(
..., description="Configuration dictionary to associate with this peer"
@ -296,6 +345,7 @@ class Peer(BaseModel):
Makes an API call to update the configuration associated with this peer.
This will overwrite any existing configuration with the provided values.
This method also updates the cached configuration attribute.
Args:
config: A dictionary of configuration to associate with this peer.
@ -306,6 +356,51 @@ class Peer(BaseModel):
workspace_id=self.workspace_id,
configuration=config,
)
self._configuration = config
def get_peer_config(self) -> dict[str, object]:
"""
Get the current workspace-level configuration for this peer.
.. deprecated::
Use :meth:`get_config` instead.
Returns:
A dictionary containing the peer's configuration
"""
return self.get_config()
@validate_call
def set_peer_config(
self,
config: dict[str, object] = Field(
..., description="Configuration dictionary to associate with this peer"
),
) -> None:
"""
Set the configuration for this peer.
.. deprecated::
Use :meth:`set_config` instead.
Args:
config: A dictionary of configuration to associate with this peer
"""
return self.set_config(config)
def refresh(self) -> None:
"""
Refresh cached metadata and configuration for this peer.
Makes a single API call to retrieve the latest metadata and configuration
associated with this peer and updates the cached attributes.
"""
peer = self._client.workspaces.peers.get_or_create(
workspace_id=self.workspace_id,
id=self.id,
)
self._metadata = peer.metadata or {}
self._configuration = peer.configuration or {}
@validate_call
def search(
@ -342,7 +437,7 @@ class Peer(BaseModel):
def card(
self,
target: str | Peer | None = None,
target: str | PeerBase | None = None,
) -> str:
"""
Get the peer card for this peer.
@ -362,10 +457,15 @@ class Peer(BaseModel):
if isinstance(target, str) and len(target.strip()) == 0:
raise ValueError("target string cannot be empty")
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
response: PeerCardResponse = self._client.workspaces.peers.card(
peer_id=self.id,
workspace_id=self.workspace_id,
target=str(target.id) if isinstance(target, Peer) else target,
target=target_id,
)
if response.peer_card is None:
return ""
@ -374,6 +474,215 @@ class Peer(BaseModel):
return "\n".join(items)
def working_rep(
self,
session: str | SessionBase | None = None,
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get a working representation for this peer.
Args:
session: Optional session to scope the representation to.
target: Optional target peer to get the representation of. If provided,
returns the representation of the target from the perspective of this peer.
search_query: Semantic search query to filter relevant observations
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A Representation object containing explicit and deductive observations
Example:
```python
# Get global representation
rep = peer.working_rep()
print(rep)
# Get representation scoped to a session
session_rep = peer.working_rep(session='session-123')
# Get representation with semantic search
searched_rep = peer.working_rep(
search_query='preferences',
search_top_k=10,
max_observations=50
)
```
"""
from .types import Representation as _Representation
session_id = (
None
if session is None
else session
if isinstance(session, str)
else session.id
)
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
data = self._client.workspaces.peers.working_representation(
peer_id=self.id,
workspace_id=self.workspace_id,
session_id=session_id,
target=target_id,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
representation = data.get("representation")
if representation is not None:
return _Representation.from_dict(cast(dict[str, object], representation))
else:
return _Representation.from_dict(data)
def get_context(
self,
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "PeerContext":
"""
Get context for this peer, including representation and peer card.
This is a convenience method that retrieves both the working representation
and peer card in a single API call.
Args:
target: Optional target peer to get context for. If provided, returns
the context for the target from this peer's perspective.
Can be a Peer object or peer ID string.
search_query: Semantic search query to filter relevant observations
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A PeerContext object containing the representation and peer card
Example:
```python
# Get own context
context = peer.get_context()
print(context.representation)
print(context.peer_card)
# Get context for another peer
context = peer.get_context(target='other-peer-id')
# Get context with semantic search
context = peer.get_context(
search_query='preferences',
search_top_k=10
)
```
"""
from .types import PeerContext as _PeerContext
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
response = self._client.workspaces.peers.get_context(
peer_id=self.id,
workspace_id=self.workspace_id,
target=target_id,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
return _PeerContext.from_api_response(response)
@property
def observations(self) -> "ObservationScope":
"""
Access this peer's self-observations (where observer == observed == self).
This property provides a convenient way to access observations that this peer
has made about themselves. Use this for self-observation scenarios.
Returns:
An ObservationScope scoped to this peer's self-observations
Example:
```python
# List self-observations
obs_list = peer.observations.list()
# Search self-observations
results = peer.observations.query("preferences")
# Delete a self-observation
peer.observations.delete("obs-123")
```
"""
from .observations import ObservationScope as _ObservationScope
return _ObservationScope(self._client, self.workspace_id, self.id, self.id)
def observations_of(self, target: str | PeerBase) -> "ObservationScope":
"""
Access observations this peer has made about another peer.
This method provides scoped access to observations where this peer is the
observer and the target is the observed peer.
Args:
target: The target peer (either a Peer object or peer ID string)
Returns:
An ObservationScope scoped to this peer's observations of the target
Example:
```python
# Get observations about another peer
bob_observations = peer.observations_of("bob")
# List observations
obs_list = bob_observations.list()
# Search observations
results = bob_observations.query("work history")
# Get the representation from these observations
rep = bob_observations.get_representation()
```
"""
from .observations import ObservationScope as _ObservationScope
target_id = target.id if isinstance(target, PeerBase) else target
return _ObservationScope(self._client, self.workspace_id, self.id, target_id)
def __repr__(self) -> str:
"""
Return a string representation of the Peer.

View File

@ -3,19 +3,24 @@ from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING, Any
import json
from datetime import datetime
from honcho_core import Honcho as HonchoCore
from honcho_core._types import omit
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from honcho_core.types.workspaces.sessions.message_create_param import Configuration
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from .base import PeerBase, SessionBase
from .pagination import SyncPage
from .session_context import SessionContext, SessionSummaries, Summary
from .utils import prepare_file_for_upload
if TYPE_CHECKING:
from .types import Representation
from .peer import Peer
logger = logging.getLogger(__name__)
@ -32,7 +37,7 @@ class SessionPeerConfig(BaseModel):
)
class Session(BaseModel):
class Session(SessionBase):
"""
Represents a session in Honcho.
@ -42,17 +47,27 @@ class Session(BaseModel):
Attributes:
id: Unique identifier for this session
_honcho: Reference to the parent Honcho client instance
anonymous: Whether this is an anonymous session
summarize: Whether automatic summarization is enabled
workspace_id: Workspace ID for scoping operations
metadata: Cached metadata for this session. May be stale if not recently
fetched. Call get_metadata() for fresh data.
configuration: Cached configuration for this session. May be stale if not
recently fetched. Call get_config() for fresh data.
"""
id: str = Field(..., min_length=1, description="Unique identifier for this session")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: dict[str, object] | None = PrivateAttr(default=None)
_client: HonchoCore = PrivateAttr()
@property
def metadata(self) -> dict[str, object] | None:
"""Cached metadata for this session. May be stale. Use get_metadata() for fresh data."""
return self._metadata
@property
def configuration(self) -> dict[str, object] | None:
"""Cached configuration for this session. May be stale. Use get_config() for fresh data."""
return self._configuration
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def __init__(
self,
@ -95,24 +110,29 @@ class Session(BaseModel):
workspace_id=workspace_id,
)
self._client = client
self._metadata = metadata
self._configuration = config
if config is not None or metadata is not None:
self._client.workspaces.sessions.get_or_create(
session_data = self._client.workspaces.sessions.get_or_create(
workspace_id=workspace_id,
id=session_id,
configuration=config if config is not None else omit,
metadata=metadata if metadata is not None else omit,
)
# Update cached values with API response
self._metadata = session_data.metadata
self._configuration = session_data.configuration
def add_peers(
self,
peers: str
| Peer
| PeerBase
| tuple[str, SessionPeerConfig]
| tuple[Peer, SessionPeerConfig]
| list[Peer | str]
| list[tuple[Peer | str, SessionPeerConfig]]
| list[Peer | str | tuple[Peer | str, SessionPeerConfig]] = Field(
| tuple[PeerBase, SessionPeerConfig]
| list[PeerBase | str]
| list[tuple[PeerBase | str, SessionPeerConfig]]
| list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] = Field(
..., description="Peers to add to the session"
),
) -> None:
@ -157,12 +177,12 @@ class Session(BaseModel):
def set_peers(
self,
peers: str
| Peer
| PeerBase
| tuple[str, SessionPeerConfig]
| tuple[Peer, SessionPeerConfig]
| list[Peer | str]
| list[tuple[Peer | str, SessionPeerConfig]]
| list[Peer | str | tuple[Peer | str, SessionPeerConfig]] = Field(
| tuple[PeerBase, SessionPeerConfig]
| list[PeerBase | str]
| list[tuple[PeerBase | str, SessionPeerConfig]]
| list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]] = Field(
..., description="Peers to set for the session"
),
) -> None:
@ -205,7 +225,7 @@ class Session(BaseModel):
def remove_peers(
self,
peers: str | Peer | list[Peer | str] = Field(
peers: str | PeerBase | list[PeerBase | str] = Field(
..., description="Peers to remove from the session"
),
) -> None:
@ -254,14 +274,13 @@ class Session(BaseModel):
Peer(peer.id, self.workspace_id, self._client) for peer in peers_page.items
]
def get_peer_config(self, peer: str | Peer) -> SessionPeerConfig:
def get_peer_config(self, peer: str | PeerBase) -> SessionPeerConfig:
"""
Get the configuration for a peer in this session.
"""
from .peer import Peer
peer_id = peer if isinstance(peer, str) else peer.id
peer_get_config_response = self._client.workspaces.sessions.peers.get_config(
peer_id=str(peer.id) if isinstance(peer, Peer) else peer,
peer_id=peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
)
@ -270,14 +289,13 @@ class Session(BaseModel):
observe_me=peer_get_config_response.observe_me,
)
def set_peer_config(self, peer: str | Peer, config: SessionPeerConfig) -> None:
def set_peer_config(self, peer: str | PeerBase, config: SessionPeerConfig) -> None:
"""
Set the configuration for a peer in this session.
"""
from .peer import Peer
peer_id = peer if isinstance(peer, str) else peer.id
self._client.workspaces.sessions.peers.set_config(
peer_id=str(peer.id) if isinstance(peer, Peer) else peer,
peer_id=peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
observe_others=omit
@ -292,7 +310,7 @@ class Session(BaseModel):
messages: MessageCreateParam | list[MessageCreateParam] = Field(
..., description="Messages to add to the session"
),
) -> None:
) -> list[Message]:
"""
Add one or more messages to this session.
@ -308,7 +326,7 @@ class Session(BaseModel):
if not isinstance(messages, list):
messages = [messages]
self._client.workspaces.sessions.messages.create(
return self._client.workspaces.sessions.messages.create(
session_id=self.id,
workspace_id=self.workspace_id,
messages=[MessageCreateParam(**message) for message in messages],
@ -352,30 +370,83 @@ class Session(BaseModel):
Makes an API call to retrieve the current metadata associated with this session.
Metadata can include custom attributes, settings, or any other key-value data.
This method also updates the cached metadata attribute.
Returns:
A dictionary containing the session's metadata. Returns an empty dictionary
if no metadata is set
"""
return (
self._client.workspaces.sessions.get_or_create(
workspace_id=self.workspace_id,
id=self.id,
).metadata
or {}
session_data = self._client.workspaces.sessions.get_or_create(
workspace_id=self.workspace_id,
id=self.id,
)
self._metadata = session_data.metadata or {}
return self._metadata
def delete(self) -> None:
"""
Delete this session
Delete this session and all associated data.
Makes an API call to mark this session as inactive.
Makes an API call to permanently delete this session and all related data including:
- Messages
- Message embeddings
- Observations
- Session-Peer associations
- Background processing queue items
This action cannot be undone.
"""
self._client.workspaces.sessions.delete(
session_id=self.id,
workspace_id=self.workspace_id,
)
def clone(
self,
*,
message_id: str | None = None,
) -> "Session":
"""
Clone this session, optionally up to a specific message.
Makes an API call to create a copy of this session with a new ID.
All messages and peers from the original session are copied to the new session.
If a message_id is provided, only messages up to and including that message
are copied.
Args:
message_id: Optional message ID to cut off the clone at. If provided,
the cloned session will only contain messages up to and
including this message.
Returns:
A new Session object representing the cloned session
Example:
```python
# Clone entire session
cloned = session.clone()
# Clone session up to a specific message
cloned = session.clone(message_id="msg_abc123")
```
"""
# Make the API call using the core SDK's clone method
cloned_session_data = self._client.workspaces.sessions.clone(
session_id=self.id,
workspace_id=self.workspace_id,
message_id=message_id if message_id is not None else omit,
)
# Return a new Session object with the cloned session's data
return Session(
cloned_session_data.id,
self.workspace_id,
self._client,
metadata=cloned_session_data.metadata,
config=cloned_session_data.configuration,
)
@validate_call
def set_metadata(
self,
@ -388,6 +459,7 @@ class Session(BaseModel):
Makes an API call to update the metadata associated with this session.
This will overwrite any existing metadata with the provided values.
This method also updates the cached metadata attribute.
Args:
metadata: A dictionary of metadata to associate with this session.
@ -398,6 +470,65 @@ class Session(BaseModel):
workspace_id=self.workspace_id,
metadata=metadata,
)
self._metadata = metadata
def get_config(self) -> dict[str, object]:
"""
Get configuration for this session.
Makes an API call to retrieve the current configuration associated with this session.
Configuration includes settings that control session behavior.
This method also updates the cached configuration attribute.
Returns:
A dictionary containing the session's configuration. Returns an empty dictionary
if no configuration is set
"""
session_data = self._client.workspaces.sessions.get_or_create(
workspace_id=self.workspace_id,
id=self.id,
)
self._configuration = session_data.configuration or {}
return self._configuration
@validate_call
def set_config(
self,
configuration: dict[str, object] = Field(
..., description="Configuration dictionary to associate with this session"
),
) -> None:
"""
Set configuration for this session.
Makes an API call to update the configuration associated with this session.
This will overwrite any existing configuration with the provided values.
This method also updates the cached configuration attribute.
Args:
configuration: A dictionary of configuration to associate with this session.
Keys must be strings, values can be any JSON-serializable type
"""
self._client.workspaces.sessions.update(
session_id=self.id,
workspace_id=self.workspace_id,
configuration=configuration,
)
self._configuration = configuration
def refresh(self) -> None:
"""
Refresh cached metadata and configuration for this session.
Makes a single API call to retrieve the latest metadata and configuration
associated with this session and updates the cached attributes.
"""
session_data = self._client.workspaces.sessions.get_or_create(
workspace_id=self.workspace_id,
id=self.id,
)
self._metadata = session_data.metadata or {}
self._configuration = session_data.configuration or {}
@validate_call
def get_context(
@ -419,6 +550,32 @@ class Session(BaseModel):
None,
description="A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`.",
),
limit_to_session: bool = Field(
False,
description="Whether to limit the representation to this session only. If True, only observations from this session will be included.",
),
search_top_k: int | None = Field(
None,
ge=1,
le=100,
description="Number of semantically relevant facts to return when searching with `last_user_message`.",
),
search_max_distance: float | None = Field(
None,
ge=0.0,
le=1.0,
description="Maximum semantic distance for search results (0.0-1.0) when searching with `last_user_message`.",
),
include_most_derived: bool | None = Field(
None,
description="Whether to include the most derived observations in the representation.",
),
max_observations: int | None = Field(
None,
ge=1,
le=100,
description="Maximum number of observations to include in the representation.",
),
) -> SessionContext:
"""
Get optimized context for this session within a token limit.
@ -435,6 +592,11 @@ class Session(BaseModel):
peer_target: A peer ID to get context for. If given *without* `peer_perspective`, a representation and peer card will be included from the omniscient Honcho-level view of `peer_target`. If given *with* `peer_perspective`, will get the representation and card for `peer_target` *from the perspective of `peer_perspective`*.
last_user_message: The most recent message (string or Message object), used to fetch semantically relevant observations and returned as part of the context object. Use this alongside `peer_target` to get a more focused context -- does nothing if `peer_target` is not provided.
peer_perspective: A peer ID to get context *from the perspective of*. If given, response will attempt to include representation and card from the perspective of `peer_perspective`. Must be provided with `peer_target`.
limit_to_session: Whether to limit the representation to this session only. If True, only observations from this session will be included.
search_top_k: Number of semantically relevant facts to return when searching with `last_user_message`.
search_max_distance: Maximum semantic distance for search results (0.0-1.0) when searching with `last_user_message`.
include_most_derived: Whether to include the most derived observations in the representation.
max_observations: Maximum number of observations to include in the representation.
Returns:
A SessionContext object containing the optimized message history and
@ -471,6 +633,15 @@ class Session(BaseModel):
else omit,
peer_target=peer_target if peer_target is not None else omit,
peer_perspective=peer_perspective if peer_perspective is not None else omit,
limit_to_session=limit_to_session,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
# Convert the honcho_core summary to our Summary if it exists
@ -580,14 +751,28 @@ class Session(BaseModel):
limit=limit,
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
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"),
peer: str | PeerBase = Field(
..., description="The peer creating the messages (ID string or Peer object)"
),
metadata: dict[str, object] | None = Field(
None,
description="Optional metadata dictionary to associate with the messages",
),
configuration: Configuration | None = Field(
None,
description="Optional configuration dictionary to associate with the messages",
),
created_at: str | datetime | None = Field(
None,
description="Optional created-at timestamp for the messages. Should be an ISO 8601 formatted string.",
),
) -> list[Message]:
"""
Upload file to create message(s) in this session.
@ -604,7 +789,11 @@ class Session(BaseModel):
- 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
peer: The peer who will be attributed as the creator of the messages.
Can be a peer ID string or a Peer object.
metadata: Optional metadata dictionary to associate with the messages
configuration: Optional configuration dictionary to associate with the messages
created_at: Optional created-at timestamp for the messages. Should be an ISO 8601 formatted string.
Returns:
A list of Message objects representing the created messages
@ -618,22 +807,44 @@ class Session(BaseModel):
# Prepare file for upload using shared utility
filename, content_bytes, content_type = prepare_file_for_upload(file)
# Call the upload endpoint
# Extract peer ID from Peer object if needed
resolved_peer_id = peer if isinstance(peer, str) else peer.id
# Build extra_body dict with optional fields as JSON strings (backend expects Form fields)
extra_body_data: dict[str, str] = {}
if metadata is not None:
extra_body_data["metadata"] = json.dumps(metadata)
if configuration is not None:
extra_body_data["configuration"] = json.dumps(configuration)
if created_at is not None:
# Ensure created_at is a string (ISO format)
if isinstance(created_at, datetime):
extra_body_data["created_at"] = created_at.isoformat()
else:
extra_body_data["created_at"] = created_at
# Call the upload endpoint with extra_body for the additional form fields
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,
peer_id=resolved_peer_id,
extra_body=extra_body_data if extra_body_data else None,
)
return [Message.model_validate(msg) for msg in response]
def working_rep(
self,
peer: str | Peer,
peer: str | PeerBase,
*,
target: str | Peer | None = None,
) -> dict[str, object]:
target: str | PeerBase | None = None,
search_query: str | None = None,
search_top_k: int | None = None,
search_max_distance: float | None = None,
include_most_derived: bool | None = None,
max_observations: int | None = None,
) -> "Representation":
"""
Get the current working representation of the peer in this session.
@ -641,40 +852,94 @@ class Session(BaseModel):
peer: Peer to get the working representation of.
target: Optional target peer to get the representation of. If provided,
queries what `peer` knows about the `target`.
search_query: Semantic search query to filter relevant observations
search_top_k: Number of semantically relevant facts to return
search_max_distance: Maximum semantic distance for search results (0.0-1.0)
include_most_derived: Whether to include the most derived observations
max_observations: Maximum number of observations to include
Returns:
A dictionary containing information about the peer.
"""
from .peer import Peer
A Representation object containing explicit and deductive observations
return self._client.workspaces.peers.working_representation(
str(peer.id) if isinstance(peer, Peer) else peer,
workspace_id=self.workspace_id,
session_id=self.id,
target=str(target.id) if isinstance(target, Peer) else target,
Example:
```python
# Get peer's representation in this session
rep = session.working_rep('user123')
print(rep)
# Get what user123 knows about assistant in this session
local_rep = session.working_rep('user123', target='assistant')
# Get representation with semantic search
searched_rep = session.working_rep(
'user123',
search_query='preferences',
search_top_k=10
)
```
"""
from .types import Representation as _Representation
peer_id = peer if isinstance(peer, str) else peer.id
target_id = (
None
if target is None
else (target if isinstance(target, str) else target.id)
)
@validate_call
data = self._client.workspaces.peers.working_representation(
peer_id,
workspace_id=self.workspace_id,
session_id=self.id,
target=target_id,
search_query=search_query if search_query is not None else omit,
search_top_k=search_top_k if search_top_k is not None else omit,
search_max_distance=search_max_distance
if search_max_distance is not None
else omit,
include_most_derived=include_most_derived
if include_most_derived is not None
else omit,
max_observations=max_observations if max_observations is not None else omit,
)
return _Representation.from_dict(data) # type: ignore
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
Args:
observer: Optional observer (ID string or Peer object) to scope the status check
sender: Optional sender (ID string or Peer object) to scope the status check
"""
resolved_observer_id = (
None
if observer is None
else (observer if isinstance(observer, str) else observer.id)
)
resolved_sender_id = (
None
if sender is None
else (sender if isinstance(sender, str) else sender.id)
)
return self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
observer_id=resolved_observer_id,
sender_id=resolved_sender_id,
session_id=self.id,
)
@validate_call
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
observer: str | PeerBase | None = None,
sender: str | PeerBase | None = None,
timeout: float = Field(
300.0,
gt=0,
@ -689,8 +954,8 @@ class Session(BaseModel):
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
observer: Optional observer (ID string or Peer object) to scope the status check
sender: Optional sender (ID string or Peer object) to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
@ -704,7 +969,7 @@ class Session(BaseModel):
while True:
try:
status = self.get_deriver_status(observer_id, sender_id)
status = self.get_deriver_status(observer, sender)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying

View File

@ -2,7 +2,339 @@
from __future__ import annotations
from collections.abc import Iterator, AsyncIterator
from collections.abc import AsyncIterator, Iterator
from datetime import datetime
from typing import TYPE_CHECKING, Any, cast
from typing_extensions import Required, TypedDict
from pydantic import BaseModel, Field
# Re-export observation types from dedicated module
from .observations import AsyncObservationScope, Observation, ObservationScope
if TYPE_CHECKING:
from .base import SessionBase
__all__ = [
"AsyncObservationScope",
"DeductiveObservation",
"DeductiveObservationBase",
"DialecticStreamResponse",
"ExplicitObservation",
"ExplicitObservationBase",
"Observation",
"ObservationCreateParam",
"ObservationMetadata",
"ObservationScope",
"PeerContext",
"Representation",
]
class ObservationCreateParam(TypedDict, total=False):
"""Parameters for creating an observation.
Attributes:
content: The observation content/text (required)
session_id: The session this observation relates to (ID string or Session object) (required)
"""
content: Required[str]
session_id: "Required[str | SessionBase]"
class ObservationMetadata(BaseModel):
"""Metadata associated with an observation."""
created_at: datetime
message_ids: list[int]
session_name: str
class ExplicitObservationBase(BaseModel):
"""Base model for explicit observations - facts literally stated."""
content: str = Field(description="The explicit observation")
class DeductiveObservationBase(BaseModel):
"""Base model for deductive observations - logical conclusions."""
premises: list[str] = Field(
description="Supporting premises or evidence for this conclusion",
default_factory=list,
)
conclusion: str = Field(description="The deductive conclusion")
class ExplicitObservation(ExplicitObservationBase, ObservationMetadata):
"""
Explicit observation with content and metadata.
Represents facts LITERALLY stated - direct quotes or clear paraphrases only.
"""
def __str__(self) -> str:
"""Format observation with timestamp and content."""
return f"[{self.created_at.replace(microsecond=0)}] {self.content}"
def __hash__(self) -> int:
"""
Make ExplicitObservation hashable for use in sets.
"""
return hash((self.content, self.created_at, self.session_name))
def __eq__(self, other: object) -> bool:
"""
Define equality for ExplicitObservation objects.
Two observations are equal if content, created_at, and session_name match.
NOTE: message_ids are not included in the equality check.
"""
if not isinstance(other, ExplicitObservation):
return False
return (
self.content == other.content
and self.created_at == other.created_at
and self.session_name == other.session_name
)
class DeductiveObservation(DeductiveObservationBase, ObservationMetadata):
"""
Deductive observation with multiple premises and one conclusion, plus metadata.
Represents conclusions that MUST be true given explicit facts and premises.
"""
def __str__(self) -> str:
"""Format observation with timestamp, conclusion, and premises."""
premises_text = "\n".join(f" - {premise}" for premise in self.premises)
return f"[{self.created_at.replace(microsecond=0)}] {self.conclusion}\n{premises_text}"
def str_no_timestamps(self) -> str:
"""Format observation without timestamps."""
premises_text = "\n".join(f" - {premise}" for premise in self.premises)
return f"{self.conclusion}\n{premises_text}"
def __hash__(self) -> int:
"""
Make DeductiveObservation hashable for use in sets.
NOTE: premises are not included in the hash.
"""
return hash((self.conclusion, self.created_at, self.session_name))
def __eq__(self, other: object) -> bool:
"""
Define equality for DeductiveObservation objects.
Two observations are equal if all their fields match.
NOTE: premises are not included in the equality check.
"""
if not isinstance(other, DeductiveObservation):
return False
return (
self.conclusion == other.conclusion
and self.created_at == other.created_at
and self.session_name == other.session_name
)
class Representation(BaseModel):
"""
A Representation is a traversable and diffable map of observations.
At the base, we have a list of explicit observations, derived from a peer's messages.
From there, deductive observations can be made by establishing logical relationships
between explicit observations.
All of a peer's observations are stored as documents in a collection. These documents
can be queried in various ways to produce this Representation object.
A "working representation" is a version of this data structure representing the most
recent observations within a single session.
A representation can have a maximum number of observations, which is applied
individually to each level of reasoning. If a maximum is set, observations are
added and removed in FIFO order.
"""
explicit: list[ExplicitObservation] = Field(
description="Facts LITERALLY stated by the user - direct quotes or clear paraphrases only, no interpretation or inference",
default_factory=list,
)
deductive: list[DeductiveObservation] = Field(
description="Conclusions that MUST be true given explicit facts and premises - strict logical necessities",
default_factory=list,
)
def is_empty(self) -> bool:
"""
Check if the representation is empty.
"""
return len(self.explicit) == 0 and len(self.deductive) == 0
def diff_representation(self, other: "Representation") -> "Representation":
"""
Given this and another representation, return a new representation with only
observations that are unique to the other.
Note: This only removes literal duplicates, not semantically equivalent ones.
Args:
other: The representation to compare against
Returns:
A new Representation containing only observations unique to other
"""
diff = Representation()
diff.explicit = [o for o in other.explicit if o not in self.explicit]
diff.deductive = [o for o in other.deductive if o not in self.deductive]
return diff
def merge_representation(
self, other: "Representation", max_observations: int | None = None
) -> None:
"""
Merge another representation object into this one.
This will automatically deduplicate explicit and deductive observations.
This *preserves order* of observations so that they retain FIFO order.
NOTE: observations with the *same* timestamp will not have order preserved.
That's fine though, because they are from the same timestamp...
Args:
other: The representation to merge into this one
max_observations: Optional maximum number of observations to keep per type
"""
# removing duplicates by going list->set->list
self.explicit = list(set(self.explicit + other.explicit))
self.deductive = list(set(self.deductive + other.deductive))
# sort by created_at
self.explicit.sort(key=lambda x: x.created_at)
self.deductive.sort(key=lambda x: x.created_at)
if max_observations:
self.explicit = self.explicit[-max_observations:]
self.deductive = self.deductive[-max_observations:]
def __str__(self) -> str:
"""
Format representation into a clean, readable string for LLM prompts.
NOTE: we always strip subsecond precision from the timestamps.
Returns:
Formatted string with clear sections and bullet points including temporal metadata
Example:
EXPLICIT:
1. [2025-01-01 12:00:00] The user has a dog named Rover
2. [2025-01-01 12:01:00] The user's dog is 5 years old
DEDUCTIVE:
1. [2025-01-01 12:01:00] Rover is 5 years old
- The user has a dog named Rover
- The user's dog is 5 years old
"""
parts: list[str] = []
parts.append("EXPLICIT:\n")
for i, observation in enumerate(self.explicit, 1):
parts.append(f"{i}. {observation}")
parts.append("")
parts.append("DEDUCTIVE:\n")
for i, observation in enumerate(self.deductive, 1):
parts.append(f"{i}. {observation}")
parts.append("")
return "\n".join(parts)
def str_no_timestamps(self) -> str:
"""
Format representation into a clean, readable string for LLM prompts... but without timestamps.
Returns:
Formatted string with clear sections and bullet points without temporal metadata
Example:
EXPLICIT:
1. The user has a dog named Rover
2. The user's dog is 5 years old
DEDUCTIVE:
1. Rover is 5 years old
- The user has a dog named Rover
- The user's dog is 5 years old
"""
parts: list[str] = []
parts.append("EXPLICIT:\n")
for i, observation in enumerate(self.explicit, 1):
parts.append(f"{i}. {observation.content}")
parts.append("")
parts.append("DEDUCTIVE:\n")
for i, observation in enumerate(self.deductive, 1):
parts.append(f"{i}. {observation.str_no_timestamps()}")
parts.append("")
return "\n".join(parts)
def format_as_markdown(self) -> str:
"""
Format a Representation object as markdown.
NOTE: we always strip subsecond precision from the timestamps.
Returns:
Formatted markdown string with headers and lists
"""
parts: list[str] = []
# Add explicit observations
parts.append("## Explicit Observations\n")
for i, obs in enumerate(self.explicit, 1):
parts.append(f"{i}. {obs}")
parts.append("")
# Add deductive observations
parts.append("## Deductive Observations\n")
for i, obs in enumerate(self.deductive, 1):
parts.append(f"{i}. **Conclusion**: {obs.conclusion}")
if obs.premises:
parts.append(" **Premises**:")
for premise in obs.premises:
parts.append(f" - {premise}")
parts.append("")
parts.append("")
return "\n".join(parts)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Representation":
"""
Create a Representation from a dictionary (typically from API response).
Args:
data: Dictionary containing 'explicit' and 'deductive' observation lists
Returns:
A new Representation instance
Raises:
ValidationError: If observation data is missing required fields
"""
explicit_data: Any = data.get("explicit", [])
deductive_data: Any = data.get("deductive", [])
explicit_list = cast(
list[Any], explicit_data if isinstance(explicit_data, list) else []
)
deductive_list = cast(
list[Any], deductive_data if isinstance(deductive_data, list) else []
)
return cls(
explicit=[ExplicitObservation(**obs) for obs in explicit_list],
deductive=[DeductiveObservation(**obs) for obs in deductive_list],
)
class DialecticStreamResponse:
@ -62,7 +394,7 @@ class DialecticStreamResponse:
try:
if not isinstance(self._iterator, Iterator):
raise TypeError("iterator must be an sync iterator, got async iterator")
chunk = next(self._iterator) # type: ignore
chunk = next(self._iterator)
self._accumulated_content.append(chunk)
return chunk
except StopIteration:
@ -80,7 +412,7 @@ class DialecticStreamResponse:
try:
if not isinstance(self._iterator, AsyncIterator):
raise TypeError("iterator must be an async iterator, got sync iterator")
chunk = await self._iterator.__anext__() # type: ignore
chunk = await self._iterator.__anext__()
self._accumulated_content.append(chunk)
return chunk
except StopAsyncIteration:
@ -104,3 +436,81 @@ class DialecticStreamResponse:
def is_complete(self) -> bool:
"""Check if the stream has finished."""
return self._is_complete
class PeerContext:
"""
Context for a peer, including representation and peer card.
This class holds both the working representation and peer card for a peer,
typically returned from the get_context API call.
Attributes:
peer_id: The ID of the observer peer
target_id: The ID of the target peer being observed
representation: The working representation (may be None if no observations exist)
peer_card: List of peer card strings (may be None if no card exists)
"""
peer_id: str
target_id: str
representation: Representation | None
peer_card: list[str] | None
def __init__(
self,
peer_id: str,
target_id: str,
representation: Representation | None = None,
peer_card: list[str] | None = None,
):
self.peer_id = peer_id
self.target_id = target_id
self.representation = representation
self.peer_card = peer_card
@classmethod
def from_api_response(cls, response: Any) -> "PeerContext":
"""
Create a PeerContext from an API response.
Args:
response: API response object with peer_id, target_id, representation, and peer_card
Returns:
A new PeerContext instance
"""
peer_id = getattr(response, "peer_id", "") or ""
target_id = getattr(response, "target_id", "") or ""
representation = None
rep_data = getattr(response, "representation", None)
if rep_data is not None:
if isinstance(rep_data, dict):
representation = Representation.from_dict(
cast(dict[str, Any], rep_data)
)
elif hasattr(rep_data, "explicit") and hasattr(rep_data, "deductive"):
representation = Representation.from_dict(
{
"explicit": rep_data.explicit,
"deductive": rep_data.deductive,
}
)
peer_card = getattr(response, "peer_card", None)
return cls(
peer_id=peer_id,
target_id=target_id,
representation=representation,
peer_card=peer_card,
)
def __repr__(self) -> str:
has_rep = self.representation is not None
has_card = self.peer_card is not None and len(self.peer_card) > 0
return (
f"PeerContext(peer_id={self.peer_id!r}, target_id={self.target_id!r}, "
f"has_representation={has_rep}, has_peer_card={has_card})"
)

View File

@ -1,509 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.8"
resolution-markers = [
"python_full_version >= '3.9'",
"python_full_version < '3.9'",
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.5.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.9'" },
{ name = "idna", marker = "python_full_version < '3.9'" },
{ name = "sniffio", marker = "python_full_version < '3.9'" },
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/f9/9a7ce600ebe7804daf90d4d48b1c0510a4561ddce43a596be46676f82343/anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b", size = 171293, upload-time = "2024-10-13T22:18:03.307Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/b4/f7e396030e3b11394436358ca258a81d6010106582422f23443c16ca1873/anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f", size = 89766, upload-time = "2024-10-13T22:18:01.524Z" },
]
[[package]]
name = "anyio"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9'",
]
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" },
{ name = "idna", marker = "python_full_version >= '3.9'" },
{ name = "sniffio", marker = "python_full_version >= '3.9'" },
{ name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
]
[[package]]
name = "certifi"
version = "2025.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
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.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "honcho-ai"
version = "1.4.0"
source = { editable = "." }
dependencies = [
{ name = "honcho-core" },
{ name = "httpx" },
{ name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "honcho-core", specifier = ">=1.4.0" },
{ name = "httpx", specifier = ">=0.28.0,<1" },
{ name = "pydantic", specifier = ">=2.0.0,<3" },
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.11.13" }]
[[package]]
name = "honcho-core"
version = "1.4.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'" },
{ name = "anyio", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
{ name = "distro" },
{ name = "httpx" },
{ name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
{ name = "sniffio" },
{ 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.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/c2/4d3737d5c0a75e2f59f324bed7428a9593c8edfcb7500cf402c46ad7378a/honcho_core-1.4.0.tar.gz", hash = "sha256:b30de9247763c01c3c37ed22b5d04d3c611440dd1983168f3d7d93f2cc0ecf7d", size = 126164, upload-time = "2025-08-12T19:06:54.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ac/6eb2e38376736bb1dfd26bbdfd96490bfbb6f0ddcb00b39913e93799e7d8/honcho_core-1.4.0-py3-none-any.whl", hash = "sha256:a84397fd9daf546a04f5458d7233ebd5eeafcb97af75ec60a9327db89abdaf03", size = 117602, upload-time = "2025-08-12T19:06:53.667Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "anyio", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "pydantic"
version = "2.10.6"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
dependencies = [
{ name = "annotated-types", marker = "python_full_version < '3.9'" },
{ name = "pydantic-core", version = "2.27.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681, upload-time = "2025-01-24T01:42:12.693Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696, upload-time = "2025-01-24T01:42:10.371Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9'",
]
dependencies = [
{ name = "annotated-types", marker = "python_full_version >= '3.9'" },
{ name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
{ name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
{ name = "typing-inspection", marker = "python_full_version >= '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
]
[[package]]
name = "pydantic-core"
version = "2.27.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
dependencies = [
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938, upload-time = "2024-12-18T11:27:14.406Z" },
{ url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684, upload-time = "2024-12-18T11:27:16.489Z" },
{ url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169, upload-time = "2024-12-18T11:27:22.16Z" },
{ url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227, upload-time = "2024-12-18T11:27:25.097Z" },
{ url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695, upload-time = "2024-12-18T11:27:28.656Z" },
{ url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662, upload-time = "2024-12-18T11:27:30.798Z" },
{ url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370, upload-time = "2024-12-18T11:27:33.692Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813, upload-time = "2024-12-18T11:27:37.111Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287, upload-time = "2024-12-18T11:27:40.566Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414, upload-time = "2024-12-18T11:27:43.757Z" },
{ url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301, upload-time = "2024-12-18T11:27:47.36Z" },
{ url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685, upload-time = "2024-12-18T11:27:50.508Z" },
{ url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876, upload-time = "2024-12-18T11:27:53.54Z" },
{ url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421, upload-time = "2024-12-18T11:27:55.409Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998, upload-time = "2024-12-18T11:27:57.252Z" },
{ url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167, upload-time = "2024-12-18T11:27:59.146Z" },
{ url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071, upload-time = "2024-12-18T11:28:02.625Z" },
{ url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244, upload-time = "2024-12-18T11:28:04.442Z" },
{ url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470, upload-time = "2024-12-18T11:28:07.679Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291, upload-time = "2024-12-18T11:28:10.297Z" },
{ url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613, upload-time = "2024-12-18T11:28:13.362Z" },
{ url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355, upload-time = "2024-12-18T11:28:16.587Z" },
{ url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661, upload-time = "2024-12-18T11:28:18.407Z" },
{ url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261, upload-time = "2024-12-18T11:28:21.471Z" },
{ url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361, upload-time = "2024-12-18T11:28:23.53Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484, upload-time = "2024-12-18T11:28:25.391Z" },
{ url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102, upload-time = "2024-12-18T11:28:28.593Z" },
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload-time = "2024-12-18T11:28:30.346Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload-time = "2024-12-18T11:28:32.521Z" },
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload-time = "2024-12-18T11:28:34.507Z" },
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload-time = "2024-12-18T11:28:36.488Z" },
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload-time = "2024-12-18T11:28:39.409Z" },
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload-time = "2024-12-18T11:28:41.221Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload-time = "2024-12-18T11:28:44.709Z" },
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload-time = "2024-12-18T11:28:46.839Z" },
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload-time = "2024-12-18T11:28:48.896Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload-time = "2024-12-18T11:28:50.755Z" },
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload-time = "2024-12-18T11:28:54.122Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload-time = "2024-12-18T11:28:56.074Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload-time = "2024-12-18T11:28:58.107Z" },
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload-time = "2024-12-18T11:29:01.335Z" },
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" },
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" },
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" },
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" },
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" },
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" },
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" },
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" },
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" },
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" },
{ url = "https://files.pythonhosted.org/packages/43/53/13e9917fc69c0a4aea06fd63ed6a8d6cda9cf140ca9584d49c1650b0ef5e/pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506", size = 1899595, upload-time = "2024-12-18T11:29:40.887Z" },
{ url = "https://files.pythonhosted.org/packages/f4/20/26c549249769ed84877f862f7bb93f89a6ee08b4bee1ed8781616b7fbb5e/pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320", size = 1775010, upload-time = "2024-12-18T11:29:44.823Z" },
{ url = "https://files.pythonhosted.org/packages/35/eb/8234e05452d92d2b102ffa1b56d801c3567e628fdc63f02080fdfc68fd5e/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145", size = 1830727, upload-time = "2024-12-18T11:29:46.904Z" },
{ url = "https://files.pythonhosted.org/packages/8f/df/59f915c8b929d5f61e5a46accf748a87110ba145156f9326d1a7d28912b2/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1", size = 1868393, upload-time = "2024-12-18T11:29:49.098Z" },
{ url = "https://files.pythonhosted.org/packages/d5/52/81cf4071dca654d485c277c581db368b0c95b2b883f4d7b736ab54f72ddf/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228", size = 2040300, upload-time = "2024-12-18T11:29:51.43Z" },
{ url = "https://files.pythonhosted.org/packages/9c/00/05197ce1614f5c08d7a06e1d39d5d8e704dc81971b2719af134b844e2eaf/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046", size = 2738785, upload-time = "2024-12-18T11:29:55.001Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a3/5f19bc495793546825ab160e530330c2afcee2281c02b5ffafd0b32ac05e/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5", size = 1996493, upload-time = "2024-12-18T11:29:57.13Z" },
{ url = "https://files.pythonhosted.org/packages/ed/e8/e0102c2ec153dc3eed88aea03990e1b06cfbca532916b8a48173245afe60/pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a", size = 1998544, upload-time = "2024-12-18T11:30:00.681Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a3/4be70845b555bd80aaee9f9812a7cf3df81550bce6dadb3cfee9c5d8421d/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d", size = 2007449, upload-time = "2024-12-18T11:30:02.985Z" },
{ url = "https://files.pythonhosted.org/packages/e3/9f/b779ed2480ba355c054e6d7ea77792467631d674b13d8257085a4bc7dcda/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9", size = 2129460, upload-time = "2024-12-18T11:30:06.55Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f0/a6ab0681f6e95260c7fbf552874af7302f2ea37b459f9b7f00698f875492/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da", size = 2159609, upload-time = "2024-12-18T11:30:09.428Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2b/e1059506795104349712fbca647b18b3f4a7fd541c099e6259717441e1e0/pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b", size = 1819886, upload-time = "2024-12-18T11:30:11.777Z" },
{ url = "https://files.pythonhosted.org/packages/aa/6d/df49c17f024dfc58db0bacc7b03610058018dd2ea2eaf748ccbada4c3d06/pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad", size = 1980773, upload-time = "2024-12-18T11:30:14.828Z" },
{ url = "https://files.pythonhosted.org/packages/27/97/3aef1ddb65c5ccd6eda9050036c956ff6ecbfe66cb7eb40f280f121a5bb0/pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993", size = 1896475, upload-time = "2024-12-18T11:30:18.316Z" },
{ url = "https://files.pythonhosted.org/packages/ad/d3/5668da70e373c9904ed2f372cb52c0b996426f302e0dee2e65634c92007d/pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308", size = 1772279, upload-time = "2024-12-18T11:30:20.547Z" },
{ url = "https://files.pythonhosted.org/packages/8a/9e/e44b8cb0edf04a2f0a1f6425a65ee089c1d6f9c4c2dcab0209127b6fdfc2/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4", size = 1829112, upload-time = "2024-12-18T11:30:23.255Z" },
{ url = "https://files.pythonhosted.org/packages/1c/90/1160d7ac700102effe11616e8119e268770f2a2aa5afb935f3ee6832987d/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf", size = 1866780, upload-time = "2024-12-18T11:30:25.742Z" },
{ url = "https://files.pythonhosted.org/packages/ee/33/13983426df09a36d22c15980008f8d9c77674fc319351813b5a2739b70f3/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76", size = 2037943, upload-time = "2024-12-18T11:30:28.036Z" },
{ url = "https://files.pythonhosted.org/packages/01/d7/ced164e376f6747e9158c89988c293cd524ab8d215ae4e185e9929655d5c/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118", size = 2740492, upload-time = "2024-12-18T11:30:30.412Z" },
{ url = "https://files.pythonhosted.org/packages/8b/1f/3dc6e769d5b7461040778816aab2b00422427bcaa4b56cc89e9c653b2605/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630", size = 1995714, upload-time = "2024-12-18T11:30:34.358Z" },
{ url = "https://files.pythonhosted.org/packages/07/d7/a0bd09bc39283530b3f7c27033a814ef254ba3bd0b5cfd040b7abf1fe5da/pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54", size = 1997163, upload-time = "2024-12-18T11:30:37.979Z" },
{ url = "https://files.pythonhosted.org/packages/2d/bb/2db4ad1762e1c5699d9b857eeb41959191980de6feb054e70f93085e1bcd/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f", size = 2005217, upload-time = "2024-12-18T11:30:40.367Z" },
{ url = "https://files.pythonhosted.org/packages/53/5f/23a5a3e7b8403f8dd8fc8a6f8b49f6b55c7d715b77dcf1f8ae919eeb5628/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362", size = 2127899, upload-time = "2024-12-18T11:30:42.737Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ae/aa38bb8dd3d89c2f1d8362dd890ee8f3b967330821d03bbe08fa01ce3766/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96", size = 2155726, upload-time = "2024-12-18T11:30:45.279Z" },
{ url = "https://files.pythonhosted.org/packages/98/61/4f784608cc9e98f70839187117ce840480f768fed5d386f924074bf6213c/pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e", size = 1817219, upload-time = "2024-12-18T11:30:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/57/82/bb16a68e4a1a858bb3768c2c8f1ff8d8978014e16598f001ea29a25bf1d1/pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67", size = 1985382, upload-time = "2024-12-18T11:30:51.871Z" },
{ url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159, upload-time = "2024-12-18T11:30:54.382Z" },
{ url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331, upload-time = "2024-12-18T11:30:58.178Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467, upload-time = "2024-12-18T11:31:00.6Z" },
{ url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797, upload-time = "2024-12-18T11:31:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839, upload-time = "2024-12-18T11:31:09.775Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861, upload-time = "2024-12-18T11:31:13.469Z" },
{ url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582, upload-time = "2024-12-18T11:31:17.423Z" },
{ url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985, upload-time = "2024-12-18T11:31:19.901Z" },
{ url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715, upload-time = "2024-12-18T11:31:22.821Z" },
{ url = "https://files.pythonhosted.org/packages/29/0e/dcaea00c9dbd0348b723cae82b0e0c122e0fa2b43fa933e1622fd237a3ee/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656", size = 1891733, upload-time = "2024-12-18T11:31:26.876Z" },
{ url = "https://files.pythonhosted.org/packages/86/d3/e797bba8860ce650272bda6383a9d8cad1d1c9a75a640c9d0e848076f85e/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278", size = 1768375, upload-time = "2024-12-18T11:31:29.276Z" },
{ url = "https://files.pythonhosted.org/packages/41/f7/f847b15fb14978ca2b30262548f5fc4872b2724e90f116393eb69008299d/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb", size = 1822307, upload-time = "2024-12-18T11:31:33.123Z" },
{ url = "https://files.pythonhosted.org/packages/9c/63/ed80ec8255b587b2f108e514dc03eed1546cd00f0af281e699797f373f38/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd", size = 1979971, upload-time = "2024-12-18T11:31:35.755Z" },
{ url = "https://files.pythonhosted.org/packages/a9/6d/6d18308a45454a0de0e975d70171cadaf454bc7a0bf86b9c7688e313f0bb/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc", size = 1987616, upload-time = "2024-12-18T11:31:38.534Z" },
{ url = "https://files.pythonhosted.org/packages/82/8a/05f8780f2c1081b800a7ca54c1971e291c2d07d1a50fb23c7e4aef4ed403/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b", size = 1998943, upload-time = "2024-12-18T11:31:41.853Z" },
{ url = "https://files.pythonhosted.org/packages/5e/3e/fe5b6613d9e4c0038434396b46c5303f5ade871166900b357ada4766c5b7/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b", size = 2116654, upload-time = "2024-12-18T11:31:44.756Z" },
{ url = "https://files.pythonhosted.org/packages/db/ad/28869f58938fad8cc84739c4e592989730bfb69b7c90a8fff138dff18e1e/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2", size = 2152292, upload-time = "2024-12-18T11:31:48.613Z" },
{ url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961, upload-time = "2024-12-18T11:31:52.446Z" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9'",
]
dependencies = [
{ name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" },
{ url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" },
{ url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" },
{ url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" },
{ url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" },
{ url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" },
{ url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" },
{ url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" },
{ url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" },
{ url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" },
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" },
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" },
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" },
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" },
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" },
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" },
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" },
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
{ url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677, upload-time = "2025-04-23T18:32:27.227Z" },
{ url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735, upload-time = "2025-04-23T18:32:29.019Z" },
{ url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467, upload-time = "2025-04-23T18:32:31.119Z" },
{ url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041, upload-time = "2025-04-23T18:32:33.655Z" },
{ url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503, upload-time = "2025-04-23T18:32:35.519Z" },
{ url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079, upload-time = "2025-04-23T18:32:37.659Z" },
{ url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508, upload-time = "2025-04-23T18:32:39.637Z" },
{ url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693, upload-time = "2025-04-23T18:32:41.818Z" },
{ url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224, upload-time = "2025-04-23T18:32:44.033Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403, upload-time = "2025-04-23T18:32:45.836Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331, upload-time = "2025-04-23T18:32:47.618Z" },
{ url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571, upload-time = "2025-04-23T18:32:49.401Z" },
{ url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504, upload-time = "2025-04-23T18:32:51.287Z" },
{ url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" },
{ url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" },
{ url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" },
{ url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" },
{ url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" },
{ url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" },
{ url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" },
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" },
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" },
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" },
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" },
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" },
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" },
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
{ url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034, upload-time = "2025-04-23T18:33:32.843Z" },
{ url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578, upload-time = "2025-04-23T18:33:34.912Z" },
{ url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858, upload-time = "2025-04-23T18:33:36.933Z" },
{ url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498, upload-time = "2025-04-23T18:33:38.997Z" },
{ url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428, upload-time = "2025-04-23T18:33:41.18Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854, upload-time = "2025-04-23T18:33:43.446Z" },
{ url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859, upload-time = "2025-04-23T18:33:45.56Z" },
{ url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059, upload-time = "2025-04-23T18:33:47.735Z" },
{ url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661, upload-time = "2025-04-23T18:33:49.995Z" },
]
[[package]]
name = "ruff"
version = "0.12.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/da/5bd7565be729e86e1442dad2c9a364ceeff82227c2dece7c29697a9795eb/ruff-0.12.8.tar.gz", hash = "sha256:4cb3a45525176e1009b2b64126acf5f9444ea59066262791febf55e40493a033", size = 5242373, upload-time = "2025-08-07T19:05:47.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/1e/c843bfa8ad1114fab3eb2b78235dda76acd66384c663a4e0415ecc13aa1e/ruff-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:63cb5a5e933fc913e5823a0dfdc3c99add73f52d139d6cd5cc8639d0e0465513", size = 11675315, upload-time = "2025-08-07T19:05:06.15Z" },
{ url = "https://files.pythonhosted.org/packages/24/ee/af6e5c2a8ca3a81676d5480a1025494fd104b8896266502bb4de2a0e8388/ruff-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a9bbe28f9f551accf84a24c366c1aa8774d6748438b47174f8e8565ab9dedbc", size = 12456653, upload-time = "2025-08-07T19:05:09.759Z" },
{ url = "https://files.pythonhosted.org/packages/99/9d/e91f84dfe3866fa648c10512904991ecc326fd0b66578b324ee6ecb8f725/ruff-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2fae54e752a3150f7ee0e09bce2e133caf10ce9d971510a9b925392dc98d2fec", size = 11659690, upload-time = "2025-08-07T19:05:12.551Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ac/a363d25ec53040408ebdd4efcee929d48547665858ede0505d1d8041b2e5/ruff-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0acbcf01206df963d9331b5838fb31f3b44fa979ee7fa368b9b9057d89f4a53", size = 11896923, upload-time = "2025-08-07T19:05:14.821Z" },
{ url = "https://files.pythonhosted.org/packages/58/9f/ea356cd87c395f6ade9bb81365bd909ff60860975ca1bc39f0e59de3da37/ruff-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae3e7504666ad4c62f9ac8eedb52a93f9ebdeb34742b8b71cd3cccd24912719f", size = 11477612, upload-time = "2025-08-07T19:05:16.712Z" },
{ url = "https://files.pythonhosted.org/packages/1a/46/92e8fa3c9dcfd49175225c09053916cb97bb7204f9f899c2f2baca69e450/ruff-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb82efb5d35d07497813a1c5647867390a7d83304562607f3579602fa3d7d46f", size = 13182745, upload-time = "2025-08-07T19:05:18.709Z" },
{ url = "https://files.pythonhosted.org/packages/5e/c4/f2176a310f26e6160deaf661ef60db6c3bb62b7a35e57ae28f27a09a7d63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dbea798fc0065ad0b84a2947b0aff4233f0cb30f226f00a2c5850ca4393de609", size = 14206885, upload-time = "2025-08-07T19:05:21.025Z" },
{ url = "https://files.pythonhosted.org/packages/87/9d/98e162f3eeeb6689acbedbae5050b4b3220754554526c50c292b611d3a63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49ebcaccc2bdad86fd51b7864e3d808aad404aab8df33d469b6e65584656263a", size = 13639381, upload-time = "2025-08-07T19:05:23.423Z" },
{ url = "https://files.pythonhosted.org/packages/81/4e/1b7478b072fcde5161b48f64774d6edd59d6d198e4ba8918d9f4702b8043/ruff-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ac9c570634b98c71c88cb17badd90f13fc076a472ba6ef1d113d8ed3df109fb", size = 12613271, upload-time = "2025-08-07T19:05:25.507Z" },
{ url = "https://files.pythonhosted.org/packages/e8/67/0c3c9179a3ad19791ef1b8f7138aa27d4578c78700551c60d9260b2c660d/ruff-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:560e0cd641e45591a3e42cb50ef61ce07162b9c233786663fdce2d8557d99818", size = 12847783, upload-time = "2025-08-07T19:05:28.14Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2a/0b6ac3dd045acf8aa229b12c9c17bb35508191b71a14904baf99573a21bd/ruff-0.12.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:71c83121512e7743fba5a8848c261dcc454cafb3ef2934a43f1b7a4eb5a447ea", size = 11702672, upload-time = "2025-08-07T19:05:30.413Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ee/f9fdc9f341b0430110de8b39a6ee5fa68c5706dc7c0aa940817947d6937e/ruff-0.12.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:de4429ef2ba091ecddedd300f4c3f24bca875d3d8b23340728c3cb0da81072c3", size = 11440626, upload-time = "2025-08-07T19:05:32.492Z" },
{ url = "https://files.pythonhosted.org/packages/89/fb/b3aa2d482d05f44e4d197d1de5e3863feb13067b22c571b9561085c999dc/ruff-0.12.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a2cab5f60d5b65b50fba39a8950c8746df1627d54ba1197f970763917184b161", size = 12462162, upload-time = "2025-08-07T19:05:34.449Z" },
{ url = "https://files.pythonhosted.org/packages/18/9f/5c5d93e1d00d854d5013c96e1a92c33b703a0332707a7cdbd0a4880a84fb/ruff-0.12.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:45c32487e14f60b88aad6be9fd5da5093dbefb0e3e1224131cb1d441d7cb7d46", size = 12913212, upload-time = "2025-08-07T19:05:36.541Z" },
{ url = "https://files.pythonhosted.org/packages/71/13/ab9120add1c0e4604c71bfc2e4ef7d63bebece0cfe617013da289539cef8/ruff-0.12.8-py3-none-win32.whl", hash = "sha256:daf3475060a617fd5bc80638aeaf2f5937f10af3ec44464e280a9d2218e720d3", size = 11694382, upload-time = "2025-08-07T19:05:38.468Z" },
{ url = "https://files.pythonhosted.org/packages/f6/dc/a2873b7c5001c62f46266685863bee2888caf469d1edac84bf3242074be2/ruff-0.12.8-py3-none-win_amd64.whl", hash = "sha256:7209531f1a1fcfbe8e46bcd7ab30e2f43604d8ba1c49029bb420b103d0b5f76e", size = 12740482, upload-time = "2025-08-07T19:05:40.391Z" },
{ url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718, upload-time = "2025-08-07T19:05:42.866Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "typing-extensions"
version = "4.13.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
]
[[package]]
name = "typing-extensions"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
]

View File

@ -5,6 +5,20 @@ 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.6.0] - 2025-12-03
### Added
- metadata and configuration fields to Workspace, Peer, Session, and Message objects
- Session Clone methods
- Peer level get_context method
- `ObservationScope` object to perform CRUD operations on observations
- Representation object for WorkingRepresentations
### Changed
- methods that take IDs, can all optionally take an object of the same type
## [1.5.0] - 2025-10-09
### Added

View File

@ -104,6 +104,12 @@ describe('Honcho Client', () => {
const metadata = { name: 'Test Peer' };
const config = { observe_me: false };
mockClient.workspaces.peers.getOrCreate.mockResolvedValue({
id: 'test-peer',
metadata: metadata,
configuration: config,
});
await honcho.peer('test-peer', { metadata, config });
expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledWith(
@ -176,6 +182,12 @@ describe('Honcho Client', () => {
const metadata = { name: 'Test Session' };
const config = { anonymous: true };
mockClient.workspaces.sessions.getOrCreate.mockResolvedValue({
id: 'test-session',
metadata: metadata,
configuration: config,
});
await honcho.session('test-session', { metadata, config });
expect(mockClient.workspaces.sessions.getOrCreate).toHaveBeenCalledWith(
@ -429,9 +441,9 @@ describe('Honcho Client', () => {
mockClient.workspaces.deriverStatus.mockResolvedValue(mockStatus);
const status = await honcho.getDeriverStatus({
observerId: 'observer1',
senderId: 'sender1',
sessionId: 'session1',
observer: 'observer1',
sender: 'sender1',
session: 'session1',
});
expect(status).toEqual({
@ -548,7 +560,7 @@ describe('Honcho Client', () => {
const metadata = { updated: true };
await expect(honcho.updateMessage(messageId, metadata)).rejects.toThrow(
'sessionId is required when message is a string ID'
'session is required when message is a string ID'
);
});

View File

@ -3,6 +3,7 @@ import { Peer } from '../src/peer'
import { Session } from '../src/session'
import { SessionContext } from '../src/session_context'
import { Page } from '../src/pagination'
import { Representation } from '../src/representation'
// Mock the @honcho-ai/core module
let mockWorkspacesApi: any
@ -369,16 +370,35 @@ describe('Honcho SDK Integration Tests', () => {
})
it('should handle working representation queries', async () => {
const mockWorkingRep = {
peer_id: 'alice',
knowledge: 'Alice likes coffee and works as a developer',
relationships: ['bob', 'charlie'],
context: 'session-specific context',
const mockWorkingRepData = {
explicit: [
{
content: 'Alice likes coffee',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'working-rep-session',
},
{
content: 'Alice works as a developer',
created_at: '2024-01-01T00:01:00Z',
message_ids: [[3, 4]],
session_name: 'working-rep-session',
},
],
deductive: [
{
conclusion: 'Alice is a coffee-drinking developer',
premises: ['Alice likes coffee', 'Alice works as a developer'],
created_at: '2024-01-01T00:02:00Z',
message_ids: [[5, 6]],
session_name: 'working-rep-session',
},
],
}
mockWorkspacesApi.workspaces.peers.workingRepresentation.mockResolvedValue(
mockWorkingRep
)
mockWorkspacesApi.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockWorkingRepData,
})
const session = await honcho.session('working-rep-session')
const alice = await honcho.peer('alice')
@ -386,7 +406,12 @@ describe('Honcho SDK Integration Tests', () => {
// Test working representation without target
const globalRep = await session.workingRep('alice')
expect(globalRep).toEqual(mockWorkingRep)
expect(globalRep).toBeInstanceOf(Representation)
expect(globalRep.explicit).toHaveLength(2)
expect(globalRep.explicit[0].content).toBe('Alice likes coffee')
expect(globalRep.explicit[1].content).toBe('Alice works as a developer')
expect(globalRep.deductive).toHaveLength(1)
expect(globalRep.deductive[0].conclusion).toBe('Alice is a coffee-drinking developer')
expect(
mockWorkspacesApi.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('integration-test-workspace', 'alice', {
@ -395,7 +420,8 @@ describe('Honcho SDK Integration Tests', () => {
})
// Test working representation with target
await session.workingRep(alice, bob)
const targetRep = await session.workingRep(alice, bob)
expect(targetRep).toBeInstanceOf(Representation)
expect(
mockWorkspacesApi.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('integration-test-workspace', 'alice', {

View File

@ -0,0 +1,565 @@
import { Honcho } from '../src/client';
import { Peer } from '../src/peer';
import { Session } from '../src/session';
// Mock the @honcho-ai/core module
jest.mock('@honcho-ai/core', () => {
return jest.fn().mockImplementation(() => ({
workspaces: {
peers: {
list: jest.fn(),
getOrCreate: jest.fn(),
update: jest.fn(),
},
sessions: {
list: jest.fn(),
getOrCreate: jest.fn(),
update: jest.fn(),
},
getOrCreate: jest.fn(),
update: jest.fn(),
},
}));
});
describe('Metadata and Configuration Caching', () => {
let honcho: Honcho;
let mockClient: any;
beforeEach(() => {
jest.clearAllMocks();
honcho = new Honcho({
workspaceId: 'test-workspace',
apiKey: 'test-key',
environment: 'local',
});
mockClient = (honcho as any)._client;
});
describe('Workspace Metadata Caching', () => {
it('should initialize with undefined metadata', () => {
expect(honcho.metadata).toBeUndefined();
});
it('should cache metadata after getMetadata call', async () => {
const mockWorkspace = {
id: 'test-workspace',
metadata: { theme: 'dark', version: '1.0' },
};
mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace);
const metadata = await honcho.getMetadata();
expect(metadata).toEqual({ theme: 'dark', version: '1.0' });
expect(honcho.metadata).toEqual({ theme: 'dark', version: '1.0' });
});
it('should cache empty object when metadata is null', async () => {
const mockWorkspace = {
id: 'test-workspace',
metadata: null,
};
mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace);
const metadata = await honcho.getMetadata();
expect(metadata).toEqual({});
expect(honcho.metadata).toEqual({});
});
it('should update cached metadata after setMetadata call', async () => {
mockClient.workspaces.update.mockResolvedValue({});
const newMetadata = { theme: 'light', version: '2.0' };
await honcho.setMetadata(newMetadata);
expect(honcho.metadata).toEqual(newMetadata);
});
it('should maintain cached value across multiple calls', async () => {
const mockWorkspace = {
id: 'test-workspace',
metadata: { count: 1 },
};
mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace);
await honcho.getMetadata();
expect(honcho.metadata).toEqual({ count: 1 });
// Update cache
mockClient.workspaces.update.mockResolvedValue({});
await honcho.setMetadata({ count: 2 });
expect(honcho.metadata).toEqual({ count: 2 });
// Verify cache persists
expect(honcho.metadata).toEqual({ count: 2 });
});
});
describe('Peer Metadata and Configuration Caching', () => {
describe('Peer Constructor with metadata/config', () => {
it('should initialize peer with provided metadata and config', async () => {
const metadata = { name: 'Test Peer', role: 'assistant' };
const config = { observe_me: false };
mockClient.workspaces.peers.getOrCreate.mockResolvedValue({
id: 'peer1',
metadata: metadata,
configuration: config,
});
const peer = await honcho.peer('peer1', { metadata, config });
expect(peer.metadata).toEqual(metadata);
expect(peer.configuration).toEqual(config);
expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledWith(
'test-workspace',
{ id: 'peer1', metadata, configuration: config }
);
});
it('should initialize peer without metadata/config', async () => {
const peer = await honcho.peer('peer1');
expect(peer.metadata).toBeUndefined();
expect(peer.configuration).toBeUndefined();
expect(mockClient.workspaces.peers.getOrCreate).not.toHaveBeenCalled();
});
});
describe('Peer Metadata Caching', () => {
let peer: Peer;
beforeEach(() => {
peer = new Peer('test-peer', 'test-workspace', mockClient);
});
it('should cache metadata after getMetadata call', async () => {
const mockPeer = {
id: 'test-peer',
metadata: { name: 'Alice', role: 'user' },
};
mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer);
const metadata = await peer.getMetadata();
expect(metadata).toEqual({ name: 'Alice', role: 'user' });
expect(peer.metadata).toEqual({ name: 'Alice', role: 'user' });
});
it('should cache empty object when metadata is null', async () => {
const mockPeer = {
id: 'test-peer',
metadata: null,
};
mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer);
const metadata = await peer.getMetadata();
expect(metadata).toEqual({});
expect(peer.metadata).toEqual({});
});
it('should update cached metadata after setMetadata call', async () => {
mockClient.workspaces.peers.update.mockResolvedValue({});
const newMetadata = { name: 'Bob', role: 'admin' };
await peer.setMetadata(newMetadata);
expect(peer.metadata).toEqual(newMetadata);
});
it('should maintain cached value across operations', async () => {
mockClient.workspaces.peers.getOrCreate.mockResolvedValue({
id: 'test-peer',
metadata: { score: 100 },
});
mockClient.workspaces.peers.update.mockResolvedValue({});
// Get initial metadata
await peer.getMetadata();
expect(peer.metadata).toEqual({ score: 100 });
// Update metadata
await peer.setMetadata({ score: 200 });
expect(peer.metadata).toEqual({ score: 200 });
// Verify cache persists
expect(peer.metadata).toEqual({ score: 200 });
});
});
describe('Peer Configuration Caching', () => {
let peer: Peer;
beforeEach(() => {
peer = new Peer('test-peer', 'test-workspace', mockClient);
});
it('should cache configuration after getConfig call', async () => {
const mockPeer = {
id: 'test-peer',
configuration: { observe_me: true, observe_others: false },
};
mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer);
const config = await peer.getConfig();
expect(config).toEqual({ observe_me: true, observe_others: false });
expect(peer.configuration).toEqual({ observe_me: true, observe_others: false });
});
it('should cache empty object when configuration is null', async () => {
const mockPeer = {
id: 'test-peer',
configuration: null,
};
mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer);
const config = await peer.getConfig();
expect(config).toEqual({});
expect(peer.configuration).toEqual({});
});
it('should update cached configuration after setConfig call', async () => {
mockClient.workspaces.peers.update.mockResolvedValue({});
const newConfig = { observe_me: false, observe_others: true };
await peer.setConfig(newConfig);
expect(peer.configuration).toEqual(newConfig);
});
it('should support deprecated getPeerConfig method', async () => {
const mockPeer = {
id: 'test-peer',
configuration: { observe_me: true },
};
mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer);
const config = await peer.getPeerConfig();
expect(config).toEqual({ observe_me: true });
expect(peer.configuration).toEqual({ observe_me: true });
});
it('should support deprecated setPeerConfig method', async () => {
mockClient.workspaces.peers.update.mockResolvedValue({});
const newConfig = { observe_me: false };
await peer.setPeerConfig(newConfig);
expect(peer.configuration).toEqual(newConfig);
});
});
describe('Peer List with Cached Data', () => {
it('should populate metadata and config when listing peers', async () => {
const mockPeersData = {
items: [
{
id: 'peer1',
metadata: { name: 'Alice' },
configuration: { observe_me: true },
},
{
id: 'peer2',
metadata: { name: 'Bob' },
configuration: { observe_me: false },
},
],
total: 2,
size: 2,
hasNextPage: false,
};
mockClient.workspaces.peers.list.mockResolvedValue(mockPeersData);
const peersPage = await honcho.getPeers();
const peers = peersPage.items;
expect(peers[0].metadata).toEqual({ name: 'Alice' });
expect(peers[0].configuration).toEqual({ observe_me: true });
expect(peers[1].metadata).toEqual({ name: 'Bob' });
expect(peers[1].configuration).toEqual({ observe_me: false });
});
it('should handle null metadata and config in peer list', async () => {
const mockPeersData = {
items: [
{
id: 'peer1',
metadata: null,
configuration: null,
},
],
total: 1,
size: 1,
hasNextPage: false,
};
mockClient.workspaces.peers.list.mockResolvedValue(mockPeersData);
const peersPage = await honcho.getPeers();
const peers = peersPage.items;
expect(peers[0].metadata).toBeUndefined();
expect(peers[0].configuration).toBeUndefined();
});
});
});
describe('Session Metadata and Configuration Caching', () => {
describe('Session Constructor with metadata/config', () => {
it('should initialize session with provided metadata and config', async () => {
const metadata = { title: 'Test Session', tags: ['important'] };
const config = { anonymous: false };
mockClient.workspaces.sessions.getOrCreate.mockResolvedValue({
id: 'session1',
metadata: metadata,
configuration: config,
});
const session = await honcho.session('session1', { metadata, config });
expect(session.metadata).toEqual(metadata);
expect(session.configuration).toEqual(config);
expect(mockClient.workspaces.sessions.getOrCreate).toHaveBeenCalledWith(
'test-workspace',
{ id: 'session1', metadata, configuration: config }
);
});
it('should initialize session without metadata/config', async () => {
const session = await honcho.session('session1');
expect(session.metadata).toBeUndefined();
expect(session.configuration).toBeUndefined();
expect(mockClient.workspaces.sessions.getOrCreate).not.toHaveBeenCalled();
});
});
describe('Session Metadata Caching', () => {
let session: Session;
beforeEach(() => {
session = new Session('test-session', 'test-workspace', mockClient);
});
it('should cache metadata after getMetadata call', async () => {
const mockSession = {
id: 'test-session',
metadata: { title: 'Chat Session', active: true },
};
mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession);
const metadata = await session.getMetadata();
expect(metadata).toEqual({ title: 'Chat Session', active: true });
expect(session.metadata).toEqual({ title: 'Chat Session', active: true });
});
it('should cache empty object when metadata is null', async () => {
const mockSession = {
id: 'test-session',
metadata: null,
};
mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession);
const metadata = await session.getMetadata();
expect(metadata).toEqual({});
expect(session.metadata).toEqual({});
});
it('should update cached metadata after setMetadata call', async () => {
mockClient.workspaces.sessions.update.mockResolvedValue({});
const newMetadata = { title: 'Updated Session', active: false };
await session.setMetadata(newMetadata);
expect(session.metadata).toEqual(newMetadata);
});
});
describe('Session Configuration Caching', () => {
let session: Session;
beforeEach(() => {
session = new Session('test-session', 'test-workspace', mockClient);
});
it('should cache configuration after getConfig call', async () => {
const mockSession = {
id: 'test-session',
configuration: { anonymous: true, summarize: false },
};
mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession);
const config = await session.getConfig();
expect(config).toEqual({ anonymous: true, summarize: false });
expect(session.configuration).toEqual({ anonymous: true, summarize: false });
});
it('should cache empty object when configuration is null', async () => {
const mockSession = {
id: 'test-session',
configuration: null,
};
mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession);
const config = await session.getConfig();
expect(config).toEqual({});
expect(session.configuration).toEqual({});
});
it('should update cached configuration after setConfig call', async () => {
mockClient.workspaces.sessions.update.mockResolvedValue({});
const newConfig = { anonymous: false, summarize: true };
await session.setConfig(newConfig);
expect(session.configuration).toEqual(newConfig);
});
});
describe('Session List with Cached Data', () => {
it('should populate metadata and config when listing sessions', async () => {
const mockSessionsData = {
items: [
{
id: 'session1',
metadata: { title: 'Session 1' },
configuration: { anonymous: true },
},
{
id: 'session2',
metadata: { title: 'Session 2' },
configuration: { anonymous: false },
},
],
total: 2,
size: 2,
hasNextPage: false,
};
mockClient.workspaces.sessions.list.mockResolvedValue(mockSessionsData);
const sessionsPage = await honcho.getSessions();
const sessions = sessionsPage.items;
expect(sessions[0].metadata).toEqual({ title: 'Session 1' });
expect(sessions[0].configuration).toEqual({ anonymous: true });
expect(sessions[1].metadata).toEqual({ title: 'Session 2' });
expect(sessions[1].configuration).toEqual({ anonymous: false });
});
it('should handle null metadata and config in session list', async () => {
const mockSessionsData = {
items: [
{
id: 'session1',
metadata: null,
configuration: null,
},
],
total: 1,
size: 1,
hasNextPage: false,
};
mockClient.workspaces.sessions.list.mockResolvedValue(mockSessionsData);
const sessionsPage = await honcho.getSessions();
const sessions = sessionsPage.items;
expect(sessions[0].metadata).toBeUndefined();
expect(sessions[0].configuration).toBeUndefined();
});
});
});
describe('Integration: Combined Metadata and Configuration Operations', () => {
it('should cache both metadata and config for peers independently', async () => {
const peer = new Peer('test-peer', 'test-workspace', mockClient);
// Set up mocks
mockClient.workspaces.peers.getOrCreate.mockResolvedValue({
id: 'test-peer',
metadata: { name: 'Test' },
configuration: { observe_me: true },
});
mockClient.workspaces.peers.update.mockResolvedValue({});
// Get both metadata and config
await peer.getMetadata();
await peer.getConfig();
expect(peer.metadata).toEqual({ name: 'Test' });
expect(peer.configuration).toEqual({ observe_me: true });
// Update metadata only
await peer.setMetadata({ name: 'Updated' });
expect(peer.metadata).toEqual({ name: 'Updated' });
expect(peer.configuration).toEqual({ observe_me: true }); // Should remain unchanged
// Update config only
await peer.setConfig({ observe_me: false });
expect(peer.metadata).toEqual({ name: 'Updated' }); // Should remain unchanged
expect(peer.configuration).toEqual({ observe_me: false });
});
it('should cache both metadata and config for sessions independently', async () => {
const session = new Session('test-session', 'test-workspace', mockClient);
// Set up mocks
mockClient.workspaces.sessions.getOrCreate.mockResolvedValue({
id: 'test-session',
metadata: { title: 'Test' },
configuration: { anonymous: true },
});
mockClient.workspaces.sessions.update.mockResolvedValue({});
// Get both metadata and config
await session.getMetadata();
await session.getConfig();
expect(session.metadata).toEqual({ title: 'Test' });
expect(session.configuration).toEqual({ anonymous: true });
// Update metadata only
await session.setMetadata({ title: 'Updated' });
expect(session.metadata).toEqual({ title: 'Updated' });
expect(session.configuration).toEqual({ anonymous: true }); // Should remain unchanged
// Update config only
await session.setConfig({ anonymous: false });
expect(session.metadata).toEqual({ title: 'Updated' }); // Should remain unchanged
expect(session.configuration).toEqual({ anonymous: false });
});
it('should reduce API calls by using cached values', async () => {
const peer = new Peer('test-peer', 'test-workspace', mockClient);
// Initial fetch
mockClient.workspaces.peers.getOrCreate.mockResolvedValue({
id: 'test-peer',
metadata: { name: 'Test' },
});
await peer.getMetadata();
expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledTimes(1);
// Access cached value directly (without API call)
const cachedMetadata = peer.metadata;
expect(cachedMetadata).toEqual({ name: 'Test' });
expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledTimes(1); // Still only 1 call
});
});
});

View File

@ -2,6 +2,7 @@ import { Peer } from '../src/peer';
import { Session } from '../src/session';
import { Page } from '../src/pagination';
import { Honcho } from '../src/client';
import { Representation } from '../src/representation';
// Mock the @honcho-ai/core module
jest.mock('@honcho-ai/core', () => {
@ -125,7 +126,7 @@ describe('Peer', () => {
const mockResponse = { content: 'Session-specific response' };
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
await peer.chat('Hello', { sessionId: 'session-123' });
await peer.chat('Hello', { session: 'session-123' });
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
@ -208,6 +209,8 @@ describe('Peer', () => {
peer_id: 'test-peer',
content: 'Test content',
metadata: undefined,
configuration: undefined,
created_at: undefined,
});
});
@ -219,6 +222,39 @@ describe('Peer', () => {
peer_id: 'test-peer',
content: 'Hello there',
metadata: { importance: 'high', category: 'greeting' },
configuration: undefined,
created_at: undefined,
});
});
it('should create message object with configuration', () => {
const configuration = { deriver: { enabled: false } };
const message = peer.message('Test content', { configuration });
expect(message).toEqual({
peer_id: 'test-peer',
content: 'Test content',
metadata: undefined,
configuration: { deriver: { enabled: false } },
created_at: undefined,
});
});
it('should create message object with metadata, configuration, and timestamp', () => {
const metadata = { importance: 'high' };
const configuration = { deriver: { enabled: false }, peer_card: { create: false } };
const message = peer.message('Full options test', {
metadata,
configuration,
created_at: '2024-01-15T10:30:00Z',
});
expect(message).toEqual({
peer_id: 'test-peer',
content: 'Full options test',
metadata: { importance: 'high' },
configuration: { deriver: { enabled: false }, peer_card: { create: false } },
created_at: '2024-01-15T10:30:00Z',
});
});
@ -229,6 +265,8 @@ describe('Peer', () => {
peer_id: 'test-peer',
content: '',
metadata: undefined,
configuration: undefined,
created_at: undefined,
});
});
});
@ -514,4 +552,423 @@ describe('Peer', () => {
await expect(peer.card()).rejects.toThrow('Card fetch failed');
});
});
describe('workingRep', () => {
beforeEach(() => {
mockClient.workspaces.peers.workingRepresentation = jest.fn();
});
it('should get working representation with no parameters', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'Observation 1',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
{
content: 'Observation 2',
created_at: '2024-01-01T00:01:00Z',
message_ids: [[3, 4]],
session_name: 'test-session',
},
],
deductive: [
{
conclusion: 'Conclusion 1',
premises: ['Observation 1', 'Observation 2'],
created_at: '2024-01-01T00:02:00Z',
message_ids: [[5, 6]],
session_name: 'test-session',
},
],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep();
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(2);
expect(result.explicit[0].content).toBe('Observation 1');
expect(result.explicit[1].content).toBe('Observation 2');
expect(result.deductive).toHaveLength(1);
expect(result.deductive[0].conclusion).toBe('Conclusion 1');
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with session as string', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'Session-scoped observation',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'session-123',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep('session-123');
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('Session-scoped observation');
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: 'session-123',
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with session as Session object', async () => {
const session = new Session('session-123', 'test-workspace', mockClient);
const mockRepresentationData = {
explicit: [
{
content: 'Session object observation',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'session-123',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(session);
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('Session object observation');
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: 'session-123',
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with target as string', async () => {
const mockRepresentationData = {
explicit: [
{
content: "Observer's view of target",
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(undefined, 'target-peer');
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe("Observer's view of target");
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: 'target-peer',
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with target as Peer object', async () => {
const targetPeer = new Peer('target-peer', 'test-workspace', mockClient);
const mockRepresentationData = {
explicit: [
{
content: "Observer's view of target peer object",
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(undefined, targetPeer);
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe("Observer's view of target peer object");
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: 'target-peer',
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with search query', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'Query-curated observation',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(
undefined,
undefined,
{ searchQuery: 'programming' }
);
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('Query-curated observation');
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: 'programming',
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: undefined,
});
});
it('should get working representation with custom size', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'Limited observations',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(undefined, undefined, { maxObservations: 10 });
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('Limited observations');
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 10,
});
});
it('should get working representation with all parameters', async () => {
const session = new Session('session-123', 'test-workspace', mockClient);
const targetPeer = new Peer('target-peer', 'test-workspace', mockClient);
const mockRepresentationData = {
explicit: [
{
content: 'Fully parameterized observation',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'session-123',
},
],
deductive: [
{
conclusion: 'Conclusion with all params',
premises: ['Fully parameterized observation'],
created_at: '2024-01-01T00:01:00Z',
message_ids: [[3, 4]],
session_name: 'session-123',
},
],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(
session,
targetPeer,
{ searchQuery: 'Python programming', maxObservations: 25 }
);
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('Fully parameterized observation');
expect(result.deductive).toHaveLength(1);
expect(result.deductive[0].conclusion).toBe('Conclusion with all params');
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: 'session-123',
target: 'target-peer',
search_query: 'Python programming',
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 25,
});
});
it('should get working representation with string session and string target', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'String params observation',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'session-456',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
const result = await peer.workingRep(
'session-456',
'target-peer-123',
{ searchQuery: 'machine learning', maxObservations: 50 }
);
expect(result).toBeInstanceOf(Representation);
expect(result.explicit).toHaveLength(1);
expect(result.explicit[0].content).toBe('String params observation');
expect(result.deductive).toHaveLength(0);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'test-peer', {
session_id: 'session-456',
target: 'target-peer-123',
search_query: 'machine learning',
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 50,
});
});
it('should handle boundary size values', async () => {
const mockRepresentationData = {
explicit: [
{
content: 'Boundary test',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
};
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
});
// Test size = 1
const result1 = await peer.workingRep(undefined, undefined, { maxObservations: 1 });
expect(result1).toBeInstanceOf(Representation);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenLastCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 1,
});
// Test size = 100
const result2 = await peer.workingRep(undefined, undefined, { maxObservations: 100 });
expect(result2).toBeInstanceOf(Representation);
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenLastCalledWith('test-workspace', 'test-peer', {
session_id: undefined,
target: undefined,
search_query: undefined,
search_top_k: undefined,
search_max_distance: undefined,
include_most_derived: undefined,
max_observations: 100,
});
});
it('should handle API errors', async () => {
mockClient.workspaces.peers.workingRepresentation.mockRejectedValue(
new Error('Working representation fetch failed')
);
await expect(peer.workingRep()).rejects.toThrow(
'Working representation fetch failed'
);
});
});
});

View File

@ -3,6 +3,7 @@ import { Peer } from '../src/peer'
import { Page } from '../src/pagination'
import { SessionContext } from '../src/session_context'
import { Honcho } from '../src/client'
import { Representation } from '../src/representation'
// Mock the @honcho-ai/core module
jest.mock('@honcho-ai/core', () => {
@ -25,6 +26,7 @@ jest.mock('@honcho-ai/core', () => {
getOrCreate: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
clone: jest.fn(),
getContext: jest.fn(),
search: jest.fn(),
},
@ -568,6 +570,62 @@ describe('Session', () => {
],
})
})
it('should return list of messages when created', async () => {
const mockMessages = [
{
id: 'msg1',
peer_id: 'peer1',
content: 'Hello',
created_at: '2023-01-01T00:00:00Z',
metadata: {},
},
{
id: 'msg2',
peer_id: 'peer2',
content: 'Hi there',
created_at: '2023-01-01T00:00:01Z',
metadata: {},
},
]
mockClient.workspaces.sessions.messages.create.mockResolvedValue(
mockMessages
)
const messages = [
{ peer_id: 'peer1', content: 'Hello' },
{ peer_id: 'peer2', content: 'Hi there' },
]
const result = await session.addMessages(messages)
expect(result).toEqual(mockMessages)
expect(result).toHaveLength(2)
expect(result[0].id).toBe('msg1')
expect(result[0].content).toBe('Hello')
expect(result[1].id).toBe('msg2')
expect(result[1].content).toBe('Hi there')
})
it('should return single message when adding single message', async () => {
const mockMessage = {
id: 'msg1',
peer_id: 'peer1',
content: 'Hello',
created_at: '2023-01-01T00:00:00Z',
metadata: {},
}
mockClient.workspaces.sessions.messages.create.mockResolvedValue([
mockMessage,
])
const message = { peer_id: 'peer1', content: 'Hello' }
const result = await session.addMessages(message)
expect(result).toHaveLength(1)
expect(result[0]).toEqual(mockMessage)
expect(result[0].id).toBe('msg1')
expect(result[0].content).toBe('Hello')
})
})
describe('getMessages', () => {
@ -847,18 +905,27 @@ describe('Session', () => {
describe('workingRep', () => {
it('should get working representation with peer string', async () => {
const mockRepresentation = {
peer_id: 'peer1',
knowledge: 'Some knowledge about the peer',
relationships: ['peer2', 'peer3'],
const mockRepresentationData = {
explicit: [
{
content: 'Some knowledge about the peer',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
}
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(
mockRepresentation
)
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
})
const result = await session.workingRep('peer1')
expect(result).toEqual(mockRepresentation)
expect(result).toBeInstanceOf(Representation)
expect(result.explicit).toHaveLength(1)
expect(result.explicit[0].content).toBe('Some knowledge about the peer')
expect(result.deductive).toHaveLength(0)
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'peer1', {
@ -869,17 +936,27 @@ describe('Session', () => {
it('should get working representation with Peer object', async () => {
const peer = new Peer('peer1', 'test-workspace', mockClient)
const mockRepresentation = {
peer_id: 'peer1',
knowledge: 'Some knowledge',
const mockRepresentationData = {
explicit: [
{
content: 'Some knowledge',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
}
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(
mockRepresentation
)
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
})
const result = await session.workingRep(peer)
expect(result).toEqual(mockRepresentation)
expect(result).toBeInstanceOf(Representation)
expect(result.explicit).toHaveLength(1)
expect(result.explicit[0].content).toBe('Some knowledge')
expect(result.deductive).toHaveLength(0)
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'peer1', {
@ -889,17 +966,27 @@ describe('Session', () => {
})
it('should get working representation with target peer string', async () => {
const mockRepresentation = {
peer_id: 'peer1',
target_knowledge: 'What peer1 knows about target',
const mockRepresentationData = {
explicit: [
{
content: 'What peer1 knows about target',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
}
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(
mockRepresentation
)
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
})
const result = await session.workingRep('peer1', 'target-peer')
expect(result).toEqual(mockRepresentation)
expect(result).toBeInstanceOf(Representation)
expect(result.explicit).toHaveLength(1)
expect(result.explicit[0].content).toBe('What peer1 knows about target')
expect(result.deductive).toHaveLength(0)
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'peer1', {
@ -911,17 +998,27 @@ describe('Session', () => {
it('should get working representation with target Peer object', async () => {
const peer = new Peer('peer1', 'test-workspace', mockClient)
const target = new Peer('target-peer', 'test-workspace', mockClient)
const mockRepresentation = {
peer_id: 'peer1',
target_knowledge: 'What peer1 knows about target',
const mockRepresentationData = {
explicit: [
{
content: 'What peer1 knows about target',
created_at: '2024-01-01T00:00:00Z',
message_ids: [[1, 2]],
session_name: 'test-session',
},
],
deductive: [],
}
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(
mockRepresentation
)
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue({
representation: mockRepresentationData,
})
const result = await session.workingRep(peer, target)
expect(result).toEqual(mockRepresentation)
expect(result).toBeInstanceOf(Representation)
expect(result.explicit).toHaveLength(1)
expect(result.explicit[0].content).toBe('What peer1 knows about target')
expect(result.deductive).toHaveLength(0)
expect(
mockClient.workspaces.peers.workingRepresentation
).toHaveBeenCalledWith('test-workspace', 'peer1', {
@ -959,6 +1056,74 @@ describe('Session', () => {
})
})
describe('clone', () => {
it('should clone the session without messageId', async () => {
const mockClonedSession = {
id: 'cloned-session-id',
workspace_id: 'test-workspace',
metadata: { cloned: true },
configuration: { test: 'config' },
}
mockClient.workspaces.sessions.clone.mockResolvedValue(mockClonedSession)
const clonedSession = await session.clone()
expect(clonedSession).toBeInstanceOf(Session)
expect(clonedSession.id).toBe('cloned-session-id')
expect(clonedSession.workspaceId).toBe('test-workspace')
expect(clonedSession.metadata).toEqual({ cloned: true })
expect(clonedSession.configuration).toEqual({ test: 'config' })
expect(mockClient.workspaces.sessions.clone).toHaveBeenCalledWith(
'test-workspace',
'test-session',
{}
)
})
it('should clone the session with messageId cutoff', async () => {
const mockClonedSession = {
id: 'cloned-session-cutoff',
workspace_id: 'test-workspace',
metadata: null,
configuration: null,
}
mockClient.workspaces.sessions.clone.mockResolvedValue(mockClonedSession)
const clonedSession = await session.clone('msg-123')
expect(clonedSession).toBeInstanceOf(Session)
expect(clonedSession.id).toBe('cloned-session-cutoff')
expect(mockClient.workspaces.sessions.clone).toHaveBeenCalledWith(
'test-workspace',
'test-session',
{ message_id: 'msg-123' }
)
})
it('should handle null metadata and configuration', async () => {
const mockClonedSession = {
id: 'cloned-session-null',
workspace_id: 'test-workspace',
metadata: null,
configuration: null,
}
mockClient.workspaces.sessions.clone.mockResolvedValue(mockClonedSession)
const clonedSession = await session.clone()
expect(clonedSession.metadata).toBeUndefined()
expect(clonedSession.configuration).toBeUndefined()
})
it('should handle API errors', async () => {
mockClient.workspaces.sessions.clone.mockRejectedValue(
new Error('Failed to clone session')
)
await expect(session.clone()).rejects.toThrow('Failed to clone session')
})
})
describe('getDeriverStatus', () => {
it('should return deriver status without options', async () => {
const mockStatus = {
@ -995,8 +1160,8 @@ describe('Session', () => {
mockClient.workspaces.deriverStatus.mockResolvedValue(mockStatus)
const status = await session.getDeriverStatus({
observerId: 'observer1',
senderId: 'sender1',
observer: 'observer1',
sender: 'sender1',
})
expect(status).toEqual({

View File

@ -4,7 +4,7 @@
"": {
"name": "@honcho-ai/sdk",
"dependencies": {
"@honcho-ai/core": "^1.5.1",
"@honcho-ai/core": "^1.6.1",
"@types/node": "^24.0.1",
"zod": "4.0.0",
},
@ -20,11 +20,11 @@
"packages": {
"@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.28.4", "", {}, "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
"@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "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-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="],
"@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "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-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@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=="],
@ -38,13 +38,13 @@
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@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=="],
@ -82,33 +82,31 @@
"@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.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
"@biomejs/biome": ["@biomejs/biome@2.2.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-x64": "2.2.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg=="],
"@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@honcho-ai/core": ["@honcho-ai/core@1.5.1", "", { "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-lbYtMTcL2AxdcIl5ZKenogeTlVMnE7buJWvAFOCLp0yQxcezyA/R9FPvLz2UGRApLsiplLNWhftML+3QBjIIJA=="],
"@honcho-ai/core": ["@honcho-ai/core@1.6.1", "", { "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-sfKIqAIybP/yj6iXGQLFgrqlX1dA7OuAK86p8sy8XiT64ZpEYpEz6viifAsm65LRZMgb0HPTFeBGodseUSoqVQ=="],
"@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="],
@ -158,40 +156,6 @@
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
"@swc/core": ["@swc/core@1.13.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.24" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.5", "@swc/core-darwin-x64": "1.13.5", "@swc/core-linux-arm-gnueabihf": "1.13.5", "@swc/core-linux-arm64-gnu": "1.13.5", "@swc/core-linux-arm64-musl": "1.13.5", "@swc/core-linux-x64-gnu": "1.13.5", "@swc/core-linux-x64-musl": "1.13.5", "@swc/core-win32-arm64-msvc": "1.13.5", "@swc/core-win32-ia32-msvc": "1.13.5", "@swc/core-win32-x64-msvc": "1.13.5" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.5", "", { "os": "win32", "cpu": "x64" }, "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
"@tsconfig/node10": ["@tsconfig/node10@1.0.11", "", {}, "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw=="],
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
"@tsconfig/node14": ["@tsconfig/node14@1.0.3", "", {}, "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="],
"@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@ -210,22 +174,18 @@
"@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.6.1", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
@ -236,8 +196,6 @@
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
@ -254,13 +212,13 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.26.2", "", { "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", "electron-to-chromium": "^1.5.218", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A=="],
"browserslist": ["browserslist@4.28.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", "electron-to-chromium": "^1.5.249", "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ=="],
"bs-logger": ["bs-logger@0.2.6", "", { "dependencies": { "fast-json-stable-stringify": "2.x" } }, "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog=="],
@ -274,7 +232,7 @@
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001746", "", {}, "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001757", "", {}, "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@ -288,7 +246,7 @@
"co": ["co@4.6.0", "", {}, "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ=="],
"collect-v8-coverage": ["collect-v8-coverage@1.0.2", "", {}, "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q=="],
"collect-v8-coverage": ["collect-v8-coverage@1.0.3", "", {}, "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@ -302,8 +260,6 @@
"create-jest": ["create-jest@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", "exit": "^0.1.2", "graceful-fs": "^4.2.9", "jest-config": "^29.7.0", "jest-util": "^29.7.0", "prompts": "^2.0.1" }, "bin": { "create-jest": "bin/create-jest.js" } }, "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q=="],
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@ -316,13 +272,11 @@
"detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="],
"diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.228", "", {}, "sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.263", "", {}, "sha512-DrqJ11Knd+lo+dv+lltvfMDLU27g14LMdH2b0O3Pio4uk0x+z7OR+JrmyacTPN2M8w3BrZ7/RTwG3R9B7irPlg=="],
"emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="],
@ -360,7 +314,7 @@
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
@ -492,7 +446,7 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@ -546,7 +500,7 @@
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
"node-releases": ["node-releases@2.0.21", "", {}, "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@ -590,7 +544,7 @@
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="],
@ -598,7 +552,7 @@
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@ -642,9 +596,7 @@
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"ts-jest": ["ts-jest@29.4.4", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.7.2", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "optionalPeers": ["@babel/core", "@jest/transform", "@jest/types", "babel-jest", "jest-util"], "bin": { "ts-jest": "cli.js" } }, "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw=="],
"ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="],
"ts-jest": ["ts-jest@29.4.6", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.7.3", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "optionalPeers": ["@babel/core", "@jest/transform", "@jest/types", "babel-jest", "jest-util"], "bin": { "ts-jest": "cli.js" } }, "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA=="],
"type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="],
@ -654,11 +606,9 @@
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
"undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
"v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="],
@ -688,8 +638,6 @@
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.0.0", "", {}, "sha512-9diLdTPc/L7w/5jI4C3gHYNiGHDV9IZYxo1e5LSD8cabi65WVTWWb+g2BGPEpUUCOxR4D+6O5B0AzyMdUAXwrw=="],
@ -698,9 +646,7 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@honcho-ai/core/@types/node": ["@types/node@18.19.129", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A=="],
"@honcho-ai/core/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],

View File

@ -1,6 +1,6 @@
{
"name": "@honcho-ai/sdk",
"version": "1.5.0",
"version": "1.6.0",
"description": "Official DX Optimized TypeScript SDK for Honcho",
"author": "Plastic Labs <hello@plasticlabs.ai>",
"license": "Apache-2.0",
@ -20,7 +20,7 @@
"test:coverage": "jest --coverage"
},
"dependencies": {
"@honcho-ai/core": "^1.5.1",
"@honcho-ai/core": "^1.6.1",
"@types/node": "^24.0.1",
"zod": "4.0.0"
},

View File

@ -10,7 +10,6 @@ import { Peer } from './peer'
import { Session } from './session'
import {
type DeriverStatusOptions,
DeriverStatusOptionsSchema,
FilterSchema,
type Filters,
type HonchoConfig,
@ -28,6 +27,8 @@ import {
SessionIdSchema,
type SessionMetadata,
SessionMetadataSchema,
type WorkspaceConfig,
WorkspaceConfigSchema,
type WorkspaceMetadata,
WorkspaceMetadataSchema,
} from './validation'
@ -62,6 +63,36 @@ export class Honcho {
* Reference to the core Honcho client instance.
*/
private _client: HonchoCore
/**
* Private cached metadata for this workspace.
*/
private _metadata?: Record<string, unknown>
/**
* Private cached configuration for this workspace.
*/
private _configuration?: Record<string, unknown>
/**
* Cached metadata for this workspace. May be stale if the workspace
* was not recently fetched from the API.
*
* Call getMetadata() to get the latest metadata from the server,
* which will also update this cached value.
*/
get metadata(): Record<string, unknown> | undefined {
return this._metadata
}
/**
* Cached configuration for this workspace. May be stale if the workspace
* was not recently fetched from the API.
*
* Call getConfig() to get the latest configuration from the server,
* which will also update this cached value.
*/
get configuration(): Record<string, unknown> | undefined {
return this._configuration
}
/**
* Access the underlying @honcho-ai/core client. The @honcho-ai/core client is the raw Stainless-generated client,
@ -155,17 +186,26 @@ export class Honcho {
const validatedConfig = options?.config
? PeerConfigSchema.parse(options.config)
: undefined
const peer = new Peer(validatedId, this.workspaceId, this._client)
if (validatedConfig || validatedMetadata) {
await this._client.workspaces.peers.getOrCreate(this.workspaceId, {
id: peer.id,
configuration: validatedConfig,
metadata: validatedMetadata,
})
const peerData = await this._client.workspaces.peers.getOrCreate(
this.workspaceId,
{
id: validatedId,
configuration: validatedConfig,
metadata: validatedMetadata,
}
)
return new Peer(
validatedId,
this.workspaceId,
this._client,
peerData.metadata ?? undefined,
peerData.configuration ?? undefined
)
}
return peer
return new Peer(validatedId, this.workspaceId, this._client)
}
/**
@ -185,7 +225,14 @@ export class Honcho {
)
return new Page(
peersPage,
(peer) => new Peer(peer.id, this.workspaceId, this._client)
(peer) =>
new Peer(
peer.id,
this.workspaceId,
this._client,
peer.metadata ?? undefined,
peer.configuration ?? undefined
)
)
}
@ -224,17 +271,26 @@ export class Honcho {
const validatedConfig = options?.config
? SessionConfigSchema.parse(options.config)
: undefined
const session = new Session(validatedId, this.workspaceId, this._client)
if (validatedConfig || validatedMetadata) {
await this._client.workspaces.sessions.getOrCreate(this.workspaceId, {
id: session.id,
configuration: validatedConfig,
metadata: validatedMetadata,
})
const sessionData = await this._client.workspaces.sessions.getOrCreate(
this.workspaceId,
{
id: validatedId,
configuration: validatedConfig,
metadata: validatedMetadata,
}
)
return new Session(
validatedId,
this.workspaceId,
this._client,
sessionData.metadata ?? undefined,
sessionData.configuration ?? undefined
)
}
return session
return new Session(validatedId, this.workspaceId, this._client)
}
/**
@ -255,7 +311,14 @@ export class Honcho {
)
return new Page(
sessionsPage,
(session) => new Session(session.id, this.workspaceId, this._client)
(session) =>
new Session(
session.id,
this.workspaceId,
this._client,
session.metadata ?? undefined,
session.configuration ?? undefined
)
)
}
@ -264,7 +327,8 @@ export class Honcho {
*
* Makes an API call to retrieve metadata associated with the current workspace.
* Workspace metadata can include settings, configuration, or any other
* key-value data associated with the workspace.
* key-value data associated with the workspace. This method also updates the
* cached metadata property.
*
* @returns Promise resolving to a dictionary containing the workspace's metadata.
* Returns an empty dictionary if no metadata is set
@ -273,7 +337,8 @@ export class Honcho {
const workspace = await this._client.workspaces.getOrCreate({
id: this.workspaceId,
})
return workspace.metadata || {}
this._metadata = workspace.metadata || {}
return this._metadata
}
/**
@ -281,6 +346,7 @@ export class Honcho {
*
* Makes an API call to update the metadata associated with the current workspace.
* This will overwrite any existing metadata with the provided values.
* This method also updates the cached metadata property.
*
* @param metadata - A dictionary of metadata to associate with the workspace.
* Keys must be strings, values can be any JSON-serializable type
@ -290,6 +356,57 @@ export class Honcho {
await this._client.workspaces.update(this.workspaceId, {
metadata: validatedMetadata,
})
this._metadata = validatedMetadata
}
/**
* Get configuration for the current workspace.
*
* Makes an API call to retrieve configuration associated with the current workspace.
* Configuration includes settings that control workspace behavior.
* This method also updates the cached configuration property.
*
* @returns Promise resolving to a dictionary containing the workspace's configuration.
* Returns an empty dictionary if no configuration is set
*/
async getConfig(): Promise<Record<string, unknown>> {
const workspace = await this._client.workspaces.getOrCreate({
id: this.workspaceId,
})
this._configuration = workspace.configuration || {}
return this._configuration
}
/**
* Set configuration for the current workspace.
*
* Makes an API call to update the configuration associated with the current workspace.
* This will overwrite any existing configuration with the provided values.
* This method also updates the cached configuration property.
*
* @param configuration - A dictionary of configuration to associate with the workspace.
* Keys must be strings, values can be any JSON-serializable type
*/
async setConfig(configuration: WorkspaceConfig): Promise<void> {
const validatedConfig = WorkspaceConfigSchema.parse(configuration)
await this._client.workspaces.update(this.workspaceId, {
configuration: validatedConfig,
})
this._configuration = validatedConfig
}
/**
* Refresh cached metadata and configuration for the current workspace.
*
* Makes a single API call to retrieve the latest metadata and configuration
* associated with the current workspace and updates the cached properties.
*/
async refresh(): Promise<void> {
const workspace = await this._client.workspaces.getOrCreate({
id: this.workspaceId,
})
this._metadata = workspace.metadata || {}
this._configuration = workspace.configuration || {}
}
/**
@ -368,28 +485,47 @@ export class Honcho {
* The deriver is responsible for processing messages and updating peer representations.
*
* @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.observer - Optional observer (ID string or Peer object) to scope the status to
* @param options.sender - Optional sender (ID string or Peer object) to scope the status to
* @param options.session - Optional session (ID string or Session object) to scope the status to
* @returns Promise resolving to the deriver status information including work unit counts
*/
async getDeriverStatus(options?: DeriverStatusOptions): Promise<{
async getDeriverStatus(
options?: Omit<
DeriverStatusOptions,
'observerId' | 'senderId' | 'sessionId'
> & {
observer?: string | Peer
sender?: string | Peer
session?: string | Session
}
): Promise<{
totalWorkUnits: number
completedWorkUnits: number
inProgressWorkUnits: number
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
}> {
const validatedOptions = options
? DeriverStatusOptionsSchema.parse(options)
const resolvedObserverId = options?.observer
? typeof options.observer === 'string'
? options.observer
: options.observer.id
: undefined
const resolvedSenderId = options?.sender
? typeof options.sender === 'string'
? options.sender
: options.sender.id
: undefined
const resolvedSessionId = options?.session
? typeof options.session === 'string'
? options.session
: options.session.id
: undefined
const queryParams: WorkspaceDeriverStatusParams = {}
if (validatedOptions?.observerId)
queryParams.observer_id = validatedOptions.observerId
if (validatedOptions?.senderId)
queryParams.sender_id = validatedOptions.senderId
if (validatedOptions?.sessionId)
queryParams.session_id = validatedOptions.sessionId
if (resolvedObserverId) queryParams.observer_id = resolvedObserverId
if (resolvedSenderId) queryParams.sender_id = resolvedSenderId
if (resolvedSessionId) queryParams.session_id = resolvedSessionId
const status = await this._client.workspaces.deriverStatus(
this.workspaceId,
@ -413,28 +549,34 @@ export class Honcho {
* 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.observer - Optional observer (ID string or Peer object) to scope the status to
* @param options.sender - Optional sender (ID string or Peer object) to scope the status to
* @param options.session - Optional session (ID string or Session object) 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?: DeriverStatusOptions): Promise<{
async pollDeriverStatus(
options?: Omit<
DeriverStatusOptions,
'observerId' | 'senderId' | 'sessionId'
> & {
observer?: string | Peer
sender?: string | Peer
session?: string | Session
}
): Promise<{
totalWorkUnits: number
completedWorkUnits: number
inProgressWorkUnits: number
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
}> {
const validatedOptions = options
? DeriverStatusOptionsSchema.parse(options)
: undefined
const timeoutMs = validatedOptions?.timeoutMs ?? 300000 // Default to 5 minutes
const timeoutMs = options?.timeoutMs ?? 300000 // Default to 5 minutes
const startTime = Date.now()
while (true) {
const status = await this.getDeriverStatus(validatedOptions)
const status = await this.getDeriverStatus(options)
if (status.pendingWorkUnits === 0 && status.inProgressWorkUnits === 0) {
return status
}
@ -471,14 +613,14 @@ export class Honcho {
*
* @param message - Either a Message object or a message ID string
* @param metadata - The metadata to update for the message
* @param sessionId - The ID of the session (required if message is a string ID, ignored if message is a Message object)
* @param session - The session (ID string or Session object) - required if message is a string ID, ignored if message is a Message object
* @returns Promise resolving to the updated Message object
* @throws Error if message is a string ID but sessionId is not provided
* @throws Error if message is a string ID but session is not provided
*/
async updateMessage(
message: Message | string,
metadata: Record<string, unknown>,
sessionId?: string
session?: string | Session
): Promise<Message> {
const validatedMetadata = MessageMetadataSchema.parse(metadata)
let messageId: string
@ -486,10 +628,10 @@ export class Honcho {
if (typeof message === 'string') {
messageId = message
if (!sessionId) {
throw new Error('sessionId is required when message is a string ID')
if (!session) {
throw new Error('session is required when message is a string ID')
}
resolvedSessionId = sessionId
resolvedSessionId = typeof session === 'string' ? session : session.id
} else {
messageId = message.id
resolvedSessionId = message.session_id

View File

@ -3,19 +3,22 @@
export type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
export { Honcho } from './client'
export { Observation, ObservationScope } from './observations'
export { Page } from './pagination'
export { Peer } from './peer'
export { Peer, PeerContext } from './peer'
export { Session, SessionPeerConfig } from './session'
export {
SessionContext,
SessionSummaries,
Summary,
SummaryData,
type SummaryData,
} from './session_context'
export {
type DialecticStreamChunk,
type DialecticStreamDelta,
DialecticStreamResponse,
type Observation as ObservationData,
type ObservationQueryParams,
} from './types'
// Export validation types for advanced usage
@ -35,5 +38,6 @@ export type {
SessionConfig,
SessionMetadata,
WorkingRepParams,
WorkspaceConfig,
WorkspaceMetadata,
} from './validation'

View File

@ -0,0 +1,338 @@
import type HonchoCore from '@honcho-ai/core'
import {
Representation,
type RepresentationData,
type RepresentationOptions,
} from './representation'
import type { Session } from './session'
import type { ObservationCreateParam } from './types'
// Re-export for consumers who import from this module
export type { RepresentationOptions, ObservationCreateParam }
/**
* An observation from the theory-of-mind system.
*
* Observations are facts derived from messages that help build a representation
* of a peer.
*/
export class Observation {
/**
* Unique identifier for this observation.
*/
readonly id: string
/**
* The observation content/text.
*/
readonly content: string
/**
* The peer who made the observation.
*/
readonly observerId: string
/**
* The peer being observed.
*/
readonly observedId: string
/**
* The session where this observation was made.
*/
readonly sessionId: string
/**
* When the observation was created.
*/
readonly createdAt: string
constructor(
id: string,
content: string,
observerId: string,
observedId: string,
sessionId: string,
createdAt: string
) {
this.id = id
this.content = content
this.observerId = observerId
this.observedId = observedId
this.sessionId = sessionId
this.createdAt = createdAt
}
/**
* Create an Observation from an API response object.
*
* @param data - API response data
* @returns A new Observation instance
*/
static fromApiResponse(data: Record<string, unknown>): Observation {
return new Observation(
(data.id as string) ?? '',
(data.content as string) ?? '',
(data.observer_id as string) ?? '',
(data.observed_id as string) ?? '',
(data.session_id as string) ?? '',
(data.created_at as string) ?? ''
)
}
/**
* Return a string representation of the Observation.
*/
toString(): string {
const truncatedContent =
this.content.length > 50
? `${this.content.slice(0, 50)}...`
: this.content
return `Observation(id='${this.id}', content='${truncatedContent}')`
}
}
/**
* Scoped access to observations for a specific observer/observed relationship.
*
* This class provides convenient methods to list, query, and delete observations
* that are automatically scoped to a specific observer/observed pair.
*
* Typically accessed via `peer.observations` (for self-observations) or
* `peer.observationsOf(target)` (for observations about another peer).
*
* @example
* ```typescript
* // Get self-observations
* const observations = peer.observations
* const obsList = await observations.list()
* const searchResults = await observations.query('preferences')
*
* // Get observations about another peer
* const bobObservations = peer.observationsOf('bob')
* const bobList = await bobObservations.list()
* ```
*
* @note
* This class requires the core Honcho SDK to support observation endpoints.
* The observation endpoints are:
* - POST /workspaces/{workspace_id}/observations/list
* - POST /workspaces/{workspace_id}/observations/query
* - DELETE /workspaces/{workspace_id}/observations/{observation_id}
*/
export class ObservationScope {
private _client: HonchoCore
/**
* The workspace ID.
*/
readonly workspaceId: string
/**
* The observer peer ID.
*/
readonly observer: string
/**
* The observed peer ID.
*/
readonly observed: string
/**
* Initialize an ObservationScope.
*
* @param client - The Honcho client instance
* @param workspaceId - The workspace ID
* @param observer - The observer peer ID
* @param observed - The observed peer ID
*/
constructor(
client: HonchoCore,
workspaceId: string,
observer: string,
observed: string
) {
this._client = client
this.workspaceId = workspaceId
this.observer = observer
this.observed = observed
}
/**
* List observations in this scope.
*
* @param page - Page number (1-indexed)
* @param size - Number of results per page
* @param session - Optional session (ID string or Session object) to filter by
* @returns Promise resolving to list of Observation objects
*/
async list(
page: number = 1,
size: number = 50,
session?: string | Session
): Promise<Observation[]> {
const resolvedSessionId = session
? typeof session === 'string'
? session
: session.id
: undefined
const filters: Record<string, unknown> = {
observer: this.observer,
observed: this.observed,
}
if (resolvedSessionId) {
filters.session_id = resolvedSessionId
}
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include observations
const response = await (this._client.workspaces as any).observations.list(
this.workspaceId,
{
filters,
page,
size,
}
)
return (response.items ?? []).map((item: unknown) =>
Observation.fromApiResponse(item as Record<string, unknown>)
)
}
/**
* Semantic search for observations in this scope.
*
* @param query - The search query string
* @param topK - Maximum number of results to return
* @param distance - Maximum cosine distance threshold (0.0-1.0)
* @returns Promise resolving to list of matching Observation objects
*/
async query(
query: string,
topK: number = 10,
distance?: number
): Promise<Observation[]> {
const filters: Record<string, unknown> = {
observer: this.observer,
observed: this.observed,
}
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include observations
const response = await (this._client.workspaces as any).observations.query(
this.workspaceId,
{
query,
top_k: topK,
distance,
filters,
}
)
return (response ?? []).map((item: unknown) =>
Observation.fromApiResponse(item as Record<string, unknown>)
)
}
/**
* Delete an observation by ID.
*
* @param observationId - The ID of the observation to delete
*/
async delete(observationId: string): Promise<void> {
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include observations
await (this._client.workspaces as any).observations.delete(
this.workspaceId,
observationId
)
}
/**
* Create observations in this scope.
*
* @param observations - Single observation or array of observations with content and sessionId
* @returns Promise resolving to list of created Observation objects
*
* @example
* ```typescript
* // Create a single observation
* const observations = await peer.observations.create(
* { content: 'User prefers dark mode', sessionId: 'session1' }
* )
*
* // Create multiple observations
* const observations = await peer.observations.create([
* { content: 'User prefers dark mode', sessionId: 'session1' },
* { content: 'User is interested in AI', sessionId: 'session1' },
* ])
* ```
*/
async create(
observations: ObservationCreateParam | ObservationCreateParam[]
): Promise<Observation[]> {
// Normalize to array
const observationArray = Array.isArray(observations)
? observations
: [observations]
// Build the request body with observer/observed from scope
const requestObservations = observationArray.map((obs) => ({
content: obs.content,
session_id:
typeof obs.sessionId === 'string' ? obs.sessionId : obs.sessionId.id,
observer_id: this.observer,
observed_id: this.observed,
}))
// biome-ignore lint/suspicious/noExplicitAny: SDK workspaces type doesn't include observations
const response = await (this._client.workspaces as any).observations.create(
this.workspaceId,
{ observations: requestObservations }
)
return (response ?? []).map((item: unknown) =>
Observation.fromApiResponse(item as Record<string, unknown>)
)
}
/**
* Get the computed representation for this scope.
*
* This returns the working representation (narrative) built from the
* observations in this scope.
*
* @param options - Optional options to configure the representation
* @returns Promise resolving to a Representation object
*/
async getRepresentation(
options?: RepresentationOptions
): Promise<Representation> {
const response = await this._client.workspaces.peers.workingRepresentation(
this.workspaceId,
this.observer,
{
target: this.observed,
search_query: options?.searchQuery,
search_top_k: options?.searchTopK,
search_max_distance: options?.searchMaxDistance,
include_most_derived: options?.includeMostDerived,
max_observations: options?.maxObservations,
}
)
const maybe = response as
| RepresentationData
| { representation?: RepresentationData | null }
| null
const rep = (maybe && typeof maybe === 'object' && 'representation' in maybe
? (maybe as { representation?: RepresentationData | null }).representation
: maybe) ?? { explicit: [], deductive: [] }
return Representation.fromData(rep as RepresentationData)
}
/**
* Return a string representation of the ObservationScope.
*/
toString(): string {
return `ObservationScope(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')`
}
}

View File

@ -5,6 +5,7 @@ import type { Page as CorePage } from '@honcho-ai/core/pagination'
* Provides async iteration and transformation capabilities while preserving
* pagination functionality from the underlying core Page.
*/
// biome-ignore lint/suspicious/noExplicitAny: Generic type parameter with reasonable default for internal transform
export class Page<T, TOriginal = any> implements AsyncIterable<T> {
private _originalPage: CorePage<TOriginal>
private _transformFunc?: (item: TOriginal) => T

View File

@ -1,6 +1,12 @@
import type HonchoCore from '@honcho-ai/core'
import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
import { ObservationScope } from './observations'
import { Page } from './pagination'
import {
Representation,
type RepresentationData,
type RepresentationOptions,
} from './representation'
import { Session } from './session'
import { type DialecticStreamChunk, DialecticStreamResponse } from './types'
import {
@ -10,6 +16,7 @@ import {
LimitSchema,
MessageContentSchema,
MessageMetadataSchema,
PeerWorkingRepParamsSchema,
SearchQuerySchema,
type MessageCreate as ValidatedMessageCreate,
} from './validation'
@ -34,6 +41,36 @@ export class Peer {
* Reference to the parent Honcho client instance.
*/
private _client: HonchoCore
/**
* Private cached metadata for this peer.
*/
private _metadata?: Record<string, unknown>
/**
* Private cached configuration for this peer.
*/
private _configuration?: Record<string, unknown>
/**
* Cached metadata for this peer. May be stale if the peer
* was not recently fetched from the API.
*
* Call getMetadata() to get the latest metadata from the server,
* which will also update this cached value.
*/
get metadata(): Record<string, unknown> | undefined {
return this._metadata
}
/**
* Cached configuration for this peer. May be stale if the peer
* was not recently fetched from the API.
*
* Call getConfig() to get the latest configuration from the server,
* which will also update this cached value.
*/
get configuration(): Record<string, unknown> | undefined {
return this._configuration
}
/**
* Initialize a new Peer. **Do not call this directly, use the client.peer() method instead.**
@ -41,11 +78,21 @@ export class Peer {
* @param id - Unique identifier for this peer within the workspace
* @param workspaceId - Workspace ID for scoping operations
* @param client - Reference to the parent Honcho client instance
* @param metadata - Optional metadata to initialize the cached value
* @param configuration - Optional configuration to initialize the cached value
*/
constructor(id: string, workspaceId: string, client: HonchoCore) {
constructor(
id: string,
workspaceId: string,
client: HonchoCore,
metadata?: Record<string, unknown>,
configuration?: Record<string, unknown>
) {
this.id = id
this.workspaceId = workspaceId
this._client = client
this._metadata = metadata
this._configuration = configuration
}
/**
@ -59,9 +106,11 @@ export class Peer {
* @param stream - Whether to stream the response
* @param 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
* @param sessionId - Optional session ID to scope the query to a specific session.
* If provided, only information from that session is considered
* querying the peer's global representation. Can be a peer ID string
* or a Peer object.
* @param session - Optional session to scope the query to. If provided, only
* information from that session is considered. Can be a session
* ID string or a Session object.
* @returns Promise resolving to:
* - For non-streaming: response string or null if no relevant information
* - For streaming: DialecticStreamResponse that can be iterated over
@ -71,26 +120,33 @@ export class Peer {
options?: {
stream?: boolean
target?: string | Peer
sessionId?: string
session?: string | Session
}
): Promise<string | DialecticStreamResponse | null> {
const targetId = options?.target
? typeof options.target === 'string'
? options.target
: options.target.id
: undefined
const resolvedSessionId = options?.session
? typeof options.session === 'string'
? options.session
: options.session.id
: undefined
const chatParams = ChatQuerySchema.parse({
query,
stream: options?.stream,
target: options?.target,
sessionId: options?.sessionId,
target: targetId,
session: resolvedSessionId,
})
if (chatParams.stream) {
const body = {
query: chatParams.query,
stream: true,
target: chatParams.target
? typeof chatParams.target === 'string'
? chatParams.target
: chatParams.target.id
: undefined,
session_id: chatParams.sessionId,
target: chatParams.target,
session_id: chatParams.session,
}
const url = `${this._client.baseURL}/v2/workspaces/${this.workspaceId}/peers/${this.id}/chat`
@ -165,12 +221,8 @@ export class Peer {
{
query: chatParams.query,
stream: false,
target: chatParams.target
? typeof chatParams.target === 'string'
? chatParams.target
: chatParams.target.id
: undefined,
session_id: chatParams.sessionId,
target: chatParams.target,
session_id: chatParams.session,
}
)
if (!response.content || response.content === 'None') {
@ -211,14 +263,16 @@ export class Peer {
* The created message object can then be added to sessions or used in other operations.
*
* @param content - The text content for the message
* @param metadata - Optional metadata to associate with the message
* @param created_at - Optional ISO 8601 timestamp for the message
* @param options.metadata - Optional metadata to associate with the message
* @param options.configuration - Optional message-level configuration (e.g., deriver settings)
* @param options.created_at - Optional ISO 8601 timestamp for the message
* @returns A new message object with this peer's ID and the provided content
*/
message(
content: string,
options?: {
metadata?: Record<string, unknown>
configuration?: Record<string, unknown>
created_at?: string | Date
}
): ValidatedMessageCreate {
@ -236,6 +290,7 @@ export class Peer {
peer_id: this.id,
content: validatedContent,
metadata: validatedMetadata,
configuration: options?.configuration,
created_at: createdAt,
}
}
@ -245,7 +300,7 @@ export class Peer {
*
* Makes an API call to retrieve metadata associated with this peer. Metadata
* can include custom attributes, settings, or any other key-value data
* associated with the peer.
* associated with the peer. This method also updates the cached metadata property.
*
* @returns Promise resolving to a dictionary containing the peer's metadata.
* Returns an empty dictionary if no metadata is set
@ -255,7 +310,8 @@ export class Peer {
this.workspaceId,
{ id: this.id }
)
return peer.metadata || {}
this._metadata = peer.metadata || {}
return this._metadata
}
/**
@ -263,6 +319,7 @@ export class Peer {
*
* Makes an API call to update the metadata associated with this peer.
* This will overwrite any existing metadata with the provided values.
* This method also updates the cached metadata property.
*
* @param metadata - A dictionary of metadata to associate with this peer.
* Keys must be strings, values can be any JSON-serializable type
@ -271,6 +328,7 @@ export class Peer {
await this._client.workspaces.peers.update(this.workspaceId, this.id, {
metadata,
})
this._metadata = metadata
}
/**
@ -278,15 +336,17 @@ export class Peer {
*
* Makes an API call to retrieve configuration associated with this peer.
* Configuration currently includes one optional flag, `observe_me`.
* This method also updates the cached configuration property.
*
* @returns Promise resolving to a dictionary containing the peer's configuration
*/
async getPeerConfig(): Promise<Record<string, unknown>> {
async getConfig(): Promise<Record<string, unknown>> {
const peer = await this._client.workspaces.peers.getOrCreate(
this.workspaceId,
{ id: this.id }
)
return peer.configuration || {}
this._configuration = peer.configuration || {}
return this._configuration
}
/**
@ -296,14 +356,51 @@ export class Peer {
*
* Makes an API call to update the configuration associated with this peer.
* This will overwrite any existing configuration with the provided values.
* This method also updates the cached configuration property.
*
* @param config - A dictionary of configuration to associate with this peer.
* Keys must be strings, values can be any JSON-serializable type
*/
async setPeerConfig(config: Record<string, unknown>): Promise<void> {
async setConfig(config: Record<string, unknown>): Promise<void> {
await this._client.workspaces.peers.update(this.workspaceId, this.id, {
configuration: config,
})
this._configuration = config
}
/**
* Get the current workspace-level configuration for this peer.
*
* @deprecated Use getConfig() instead
* @returns Promise resolving to a dictionary containing the peer's configuration
*/
async getPeerConfig(): Promise<Record<string, unknown>> {
return this.getConfig()
}
/**
* Set the configuration for this peer.
*
* @deprecated Use setConfig() instead
* @param config - A dictionary of configuration to associate with this peer
*/
async setPeerConfig(config: Record<string, unknown>): Promise<void> {
return this.setConfig(config)
}
/**
* Refresh cached metadata and configuration for this peer.
*
* Makes a single API call to retrieve the latest metadata and configuration
* associated with this peer and updates the cached properties.
*/
async refresh(): Promise<void> {
const peer = await this._client.workspaces.peers.getOrCreate(
this.workspaceId,
{ id: this.id }
)
this._metadata = peer.metadata || {}
this._configuration = peer.configuration || {}
}
/**
@ -383,6 +480,197 @@ export class Peer {
return items.join('\n')
}
/**
* Get a working representation for this peer.
*
* Makes an API call to retrieve the working representation for this peer.
*
* @param session - Optional session to scope the representation to.
* @param target - Optional target peer to get the representation of. If provided,
* returns the representation of the target from the perspective of this peer.
* @param options - Optional representation options to filter and configure the results
* @returns Promise resolving to a Representation object containing explicit and deductive observations
*
* @example
* ```typescript
* // Get global representation
* const globalRep = await peer.workingRep()
* console.log(globalRep.toString())
*
* // Get representation scoped to a session
* const sessionRep = await peer.workingRep('session-123')
*
* // Get representation with semantic search
* const searchedRep = await peer.workingRep(undefined, undefined, {
* searchQuery: 'preferences',
* searchTopK: 10,
* maxObservations: 50
* })
* ```
*/
async workingRep(
session?: string | Session,
target?: string | Peer,
options?: RepresentationOptions
): Promise<Representation> {
const workingRepParams = PeerWorkingRepParamsSchema.parse({
session,
target,
options,
})
const sessionId = workingRepParams.session
? typeof workingRepParams.session === 'string'
? workingRepParams.session
: workingRepParams.session.id
: undefined
const targetId = workingRepParams.target
? typeof workingRepParams.target === 'string'
? workingRepParams.target
: workingRepParams.target.id
: undefined
const response = await this._client.workspaces.peers.workingRepresentation(
this.workspaceId,
this.id,
{
session_id: sessionId,
target: targetId,
search_query: workingRepParams.options?.searchQuery,
search_top_k: workingRepParams.options?.searchTopK,
search_max_distance: workingRepParams.options?.searchMaxDistance,
include_most_derived: workingRepParams.options?.includeMostDerived,
max_observations: workingRepParams.options?.maxObservations,
}
)
const maybe = response as
| RepresentationData
| { representation?: RepresentationData | null }
| null
const rep = (maybe && typeof maybe === 'object' && 'representation' in maybe
? (maybe as { representation?: RepresentationData | null }).representation
: maybe) ?? { explicit: [], deductive: [] }
return Representation.fromData(rep as RepresentationData)
}
/**
* Get context for this peer, including representation and peer card.
*
* This is a convenience method that retrieves both the working representation
* and peer card in a single API call.
*
* @param target - Optional target peer to get context for. If provided, returns
* the context for the target from this peer's perspective.
* @param options - Optional representation options to filter and configure the results
* @returns Promise resolving to a PeerContext object containing representation and peer card
*
* @example
* ```typescript
* // Get own context
* const context = await peer.getContext()
* console.log(context.representation?.toString())
* console.log(context.peerCard)
*
* // Get context for another peer
* const context = await peer.getContext('other-peer-id')
*
* // Get context with semantic search
* const context = await peer.getContext(undefined, {
* searchQuery: 'preferences',
* searchTopK: 10
* })
* ```
*/
async getContext(
target?: string | Peer,
options?: RepresentationOptions
): Promise<PeerContext> {
const targetId = target
? typeof target === 'string'
? target
: target.id
: undefined
const response = await this._client.workspaces.peers.getContext(
this.workspaceId,
this.id,
{
target: targetId,
search_query: options?.searchQuery,
search_top_k: options?.searchTopK,
search_max_distance: options?.searchMaxDistance,
include_most_derived: options?.includeMostDerived,
max_observations: options?.maxObservations,
}
)
return PeerContext.fromApiResponse(
response as unknown as Record<string, unknown>
)
}
/**
* Access this peer's self-observations (where observer == observed == self).
*
* This property provides a convenient way to access observations that this peer
* has made about themselves. Use this for self-observation scenarios.
*
* @returns An ObservationScope scoped to this peer's self-observations
*
* @example
* ```typescript
* // List self-observations
* const obsList = await peer.observations.list()
*
* // Search self-observations
* const results = await peer.observations.query('preferences')
*
* // Delete a self-observation
* await peer.observations.delete('obs-123')
* ```
*/
get observations(): ObservationScope {
return new ObservationScope(
this._client,
this.workspaceId,
this.id,
this.id
)
}
/**
* Access observations this peer has made about another peer.
*
* This method provides scoped access to observations where this peer is the
* observer and the target is the observed peer.
*
* @param target - The target peer (either a Peer object or peer ID string)
* @returns An ObservationScope scoped to this peer's observations of the target
*
* @example
* ```typescript
* // Get observations about another peer
* const bobObservations = peer.observationsOf('bob')
*
* // List observations
* const obsList = await bobObservations.list()
*
* // Search observations
* const results = await bobObservations.query('work history')
*
* // Get the representation from these observations
* const rep = await bobObservations.getRepresentation()
* ```
*/
observationsOf(target: string | Peer): ObservationScope {
const targetId = typeof target === 'string' ? target : target.id
return new ObservationScope(
this._client,
this.workspaceId,
this.id,
targetId
)
}
/**
* Return a string representation of the Peer.
*
@ -392,3 +680,76 @@ export class Peer {
return `Peer(id='${this.id}')`
}
}
/**
* Context for a peer, including representation and peer card.
*
* This class holds both the working representation and peer card for a peer,
* typically returned from the getContext API call.
*/
export class PeerContext {
/**
* The ID of the observer peer.
*/
readonly peerId: string
/**
* The ID of the target peer being observed.
*/
readonly targetId: string
/**
* The working representation (may be null if no observations exist).
*/
readonly representation: Representation | null
/**
* List of peer card strings (may be null if no card exists).
*/
readonly peerCard: string[] | null
constructor(
peerId: string,
targetId: string,
representation: Representation | null,
peerCard: string[] | null
) {
this.peerId = peerId
this.targetId = targetId
this.representation = representation
this.peerCard = peerCard
}
/**
* Create a PeerContext from an API response.
*
* @param response - API response object with peer_id, target_id, representation, and peer_card
* @returns A new PeerContext instance
*/
static fromApiResponse(response: Record<string, unknown>): PeerContext {
const peerId = (response.peer_id as string | undefined) ?? ''
const targetId = (response.target_id as string | undefined) ?? ''
let representation: Representation | null = null
if (response.representation) {
representation = Representation.fromData(
response.representation as RepresentationData
)
}
const peerCard = (response.peer_card as string[] | undefined) ?? null
return new PeerContext(peerId, targetId, representation, peerCard)
}
/**
* Return a string representation of the PeerContext.
*
* @returns A string representation suitable for debugging
*/
toString(): string {
const hasRep = this.representation !== null
const hasCard = this.peerCard !== null && this.peerCard.length > 0
return `PeerContext(peerId='${this.peerId}', targetId='${this.targetId}', hasRepresentation=${hasRep}, hasPeerCard=${hasCard})`
}
}

View File

@ -0,0 +1,390 @@
/**
* Options for representation retrieval.
*/
export interface RepresentationOptions {
/**
* Semantic search query to filter relevant observations.
*/
searchQuery?: string
/**
* Number of semantically relevant facts to return.
*/
searchTopK?: number
/**
* Maximum semantic distance for search results (0.0-1.0).
*/
searchMaxDistance?: number
/**
* Whether to include the most derived observations.
*/
includeMostDerived?: boolean
/**
* Maximum number of observations to include.
*/
maxObservations?: number
}
/**
* Metadata associated with an observation.
*/
export interface ObservationMetadata {
created_at: string
message_ids: Array<[number, number]>
session_name: string
}
/**
* An explicit observation with full metadata.
* Represents facts LITERALLY stated - direct quotes or clear paraphrases only.
*/
export interface ExplicitObservationBase {
content: string
}
/**
* Base interface for deductive observations - logical conclusions.
*/
export interface DeductiveObservationBase {
premises: string[]
conclusion: string
}
export interface ExplicitObservation
extends ExplicitObservationBase,
ObservationMetadata {}
/**
* A deductive observation with full metadata.
* Represents conclusions that MUST be true given explicit facts and premises.
*/
export interface DeductiveObservation
extends DeductiveObservationBase,
ObservationMetadata {}
/**
* Raw representation data structure returned from the API.
*/
export interface RepresentationData {
explicit: ExplicitObservation[]
deductive: DeductiveObservation[]
}
/**
* A Representation is a traversable and diffable map of observations.
*
* At the base, we have a list of explicit observations, derived from a peer's messages.
* From there, deductive observations can be made by establishing logical relationships
* between explicit observations.
*
* All of a peer's observations are stored as documents in a collection. These documents
* can be queried in various ways to produce this Representation object.
*
* A "working representation" is a version of this data structure representing the most
* recent observations within a single session.
*/
export class Representation {
/**
* Facts LITERALLY stated - direct quotes or clear paraphrases only, no interpretation or inference.
*/
explicit: ExplicitObservation[]
/**
* Conclusions that MUST be true given explicit facts and premises - strict logical necessities.
*/
deductive: DeductiveObservation[]
/**
* Create a new Representation from observation lists.
*
* @param explicit - List of explicit observations
* @param deductive - List of deductive observations
*/
constructor(
explicit: ExplicitObservation[] = [],
deductive: DeductiveObservation[] = []
) {
this.explicit = explicit
this.deductive = deductive
}
/**
* Check if the representation is empty.
*
* @returns True if both explicit and deductive observation lists are empty
*/
isEmpty(): boolean {
return this.explicit.length === 0 && this.deductive.length === 0
}
/**
* Given this and another representation, return a new representation with only
* observations that are unique to the other.
*
* Note: This only removes literal duplicates based on stringified comparison,
* not semantically equivalent ones.
*
* @param other - The representation to compare against
* @returns A new Representation containing only observations unique to other
*/
diff(other: Representation): Representation {
const thisExplicitSet = new Set(
this.explicit.map((obs) => this._hashExplicit(obs))
)
const thisDeductiveSet = new Set(
this.deductive.map((obs) => this._hashDeductive(obs))
)
const uniqueExplicit = other.explicit.filter(
(obs) => !thisExplicitSet.has(this._hashExplicit(obs))
)
const uniqueDeductive = other.deductive.filter(
(obs) => !thisDeductiveSet.has(this._hashDeductive(obs))
)
return new Representation(uniqueExplicit, uniqueDeductive)
}
/**
* Merge another representation into this one.
*
* This automatically deduplicates explicit and deductive observations.
* Preserves order of observations to retain FIFO order.
*
* Note: Observations with the same timestamp may not have order preserved,
* but that's acceptable since they're from the same timestamp.
*
* @param other - The representation to merge into this one
* @param maxObservations - Optional maximum number of observations to keep per type
*/
merge(other: Representation, maxObservations?: number): void {
// Deduplicate by converting to Set using hash, then back to array
const explicitMap = new Map<string, ExplicitObservation>()
const deductiveMap = new Map<string, DeductiveObservation>()
// Add existing observations
for (const obs of this.explicit) {
explicitMap.set(this._hashExplicit(obs), obs)
}
for (const obs of this.deductive) {
deductiveMap.set(this._hashDeductive(obs), obs)
}
// Add new observations (overwrites duplicates)
for (const obs of other.explicit) {
explicitMap.set(this._hashExplicit(obs), obs)
}
for (const obs of other.deductive) {
deductiveMap.set(this._hashDeductive(obs), obs)
}
// Convert back to arrays and sort by created_at
this.explicit = Array.from(explicitMap.values()).sort(
(a, b) =>
this._parseTimestampForSort(a.created_at) -
this._parseTimestampForSort(b.created_at)
)
this.deductive = Array.from(deductiveMap.values()).sort(
(a, b) =>
this._parseTimestampForSort(a.created_at) -
this._parseTimestampForSort(b.created_at)
)
// Apply max observations limit if specified
if (maxObservations !== undefined) {
this.explicit = this.explicit.slice(-maxObservations)
this.deductive = this.deductive.slice(-maxObservations)
}
}
/**
* Format representation into a clean, readable string for LLM prompts.
*
* Timestamps are stripped of subsecond precision for cleaner display.
*
* @returns Formatted string with clear sections and numbered items including timestamps
*
* @example
* ```
* EXPLICIT:
* 1. [2025-01-01T12:00:00Z] The user has a dog named Rover
* 2. [2025-01-01T12:01:00Z] The user's dog is 5 years old
*
* DEDUCTIVE:
* 1. [2025-01-01T12:01:00Z] Rover is 5 years old
* - The user has a dog named Rover
* - The user's dog is 5 years old
* ```
*/
toString(): string {
const parts: string[] = []
parts.push('EXPLICIT:\n')
for (let i = 0; i < this.explicit.length; i++) {
const obs = this.explicit[i]
const timestamp = this._stripMicroseconds(obs.created_at)
parts.push(`${i + 1}. [${timestamp}] ${obs.content}`)
}
parts.push('')
parts.push('DEDUCTIVE:\n')
for (let i = 0; i < this.deductive.length; i++) {
const obs = this.deductive[i]
const timestamp = this._stripMicroseconds(obs.created_at)
parts.push(`${i + 1}. [${timestamp}] ${obs.conclusion}`)
for (const premise of obs.premises) {
parts.push(` - ${premise}`)
}
}
parts.push('')
return parts.join('\n')
}
/**
* Format representation into a clean, readable string without timestamps.
*
* @returns Formatted string with clear sections and numbered items without temporal metadata
*
* @example
* ```
* EXPLICIT:
* 1. The user has a dog named Rover
* 2. The user's dog is 5 years old
*
* DEDUCTIVE:
* 1. Rover is 5 years old
* - The user has a dog named Rover
* - The user's dog is 5 years old
* ```
*/
toStringNoTimestamps(): string {
const parts: string[] = []
parts.push('EXPLICIT:\n')
for (let i = 0; i < this.explicit.length; i++) {
parts.push(`${i + 1}. ${this.explicit[i].content}`)
}
parts.push('')
parts.push('DEDUCTIVE:\n')
for (let i = 0; i < this.deductive.length; i++) {
const obs = this.deductive[i]
parts.push(`${i + 1}. ${obs.conclusion}`)
for (const premise of obs.premises) {
parts.push(` - ${premise}`)
}
}
parts.push('')
return parts.join('\n')
}
/**
* Format a Representation object as markdown.
*
* Timestamps are stripped of subsecond precision for cleaner display.
*
* @returns Formatted markdown string with headers and lists
*/
toMarkdown(): string {
const parts: string[] = []
parts.push('## Explicit Observations\n')
for (let i = 0; i < this.explicit.length; i++) {
const obs = this.explicit[i]
const timestamp = this._stripMicroseconds(obs.created_at)
parts.push(`${i + 1}. [${timestamp}] ${obs.content}`)
}
parts.push('')
parts.push('## Deductive Observations\n')
for (let i = 0; i < this.deductive.length; i++) {
const obs = this.deductive[i]
const timestamp = this._stripMicroseconds(obs.created_at)
parts.push(`${i + 1}. **Conclusion**: ${obs.conclusion}`)
parts.push(` **Created**: ${timestamp}`)
if (obs.premises.length > 0) {
parts.push(' **Premises**:')
for (const premise of obs.premises) {
parts.push(` - ${premise}`)
}
}
parts.push('')
}
return parts.join('\n')
}
/**
* Create a Representation from raw API response data.
*
* @param data - Raw representation data from the API
* @returns A new Representation instance
*/
static fromData(data: RepresentationData): Representation {
return new Representation(data.explicit, data.deductive)
}
/**
* Create a hash string for an explicit observation for deduplication.
* Based on content, created_at, and session_name.
*/
private _hashExplicit(obs: ExplicitObservation): string {
return JSON.stringify({
content: obs.content,
created_at: obs.created_at,
session_name: obs.session_name,
})
}
/**
* Create a hash string for a deductive observation for deduplication.
* Based on conclusion, created_at, and session_name (premises not included).
*/
private _hashDeductive(obs: DeductiveObservation): string {
return JSON.stringify({
conclusion: obs.conclusion,
created_at: obs.created_at,
session_name: obs.session_name,
})
}
/**
* Strip microseconds from ISO timestamp for cleaner display.
*/
private _stripMicroseconds(timestamp: string): string {
try {
const date = new Date(timestamp)
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
} catch {
return timestamp
}
}
/**
* Safely parse a timestamp and return milliseconds since epoch for sorting.
* Handles microsecond precision by truncating to milliseconds before parsing.
*
* @param timestamp - ISO 8601 timestamp string (may include microseconds)
* @returns Milliseconds since epoch, or 0 if parsing fails
*/
private _parseTimestampForSort(timestamp: string): number {
try {
// Normalize fractional seconds to 3 digits (milliseconds)
// Match pattern: YYYY-MM-DDTHH:mm:ss.SSSSSS(Z or timezone)
const normalized = timestamp.replace(
/(\.\d{3})\d+(Z|[+-]\d{2}:\d{2})$/,
'$1$2'
)
const time = new Date(normalized).getTime()
// Return 0 if parsing failed (NaN)
return Number.isNaN(time) ? 0 : time
} catch {
return 0
}
}
}

View File

@ -7,17 +7,24 @@ import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/mess
import type { Uploadable } from '@honcho-ai/core/uploads'
import { Page } from './pagination'
import { Peer } from './peer'
import {
Representation,
type RepresentationData,
type RepresentationOptions,
} from './representation'
import { SessionContext, SessionSummaries, Summary } from './session_context'
// Disabled: observations not ready for release
// import type { Observation, ObservationQueryParams } from './types'
import {
ContextParamsSchema,
type DeriverStatusOptions,
DeriverStatusOptionsSchema,
FileUploadSchema,
FilterSchema,
type Filters,
LimitSchema,
type MessageAddition,
MessageAdditionSchema,
// ObservationQueryParamsSchema, // Disabled: observations not ready for release
type PeerAddition,
PeerAdditionSchema,
type PeerRemoval,
@ -47,7 +54,7 @@ export class SessionPeerConfig {
* of other peers in the session. When false, this peer will not build local
* representations of other peers within this session.
*/
observe_others?: boolean
observe_others?: boolean | null
/**
* Initialize SessionPeerConfig with observation settings.
@ -55,7 +62,7 @@ export class SessionPeerConfig {
* @param observe_me - Whether other peers should observe this peer in the session
* @param observe_others - Whether this peer should observe others in the session
*/
constructor(observe_me?: boolean | null, observe_others?: boolean) {
constructor(observe_me?: boolean | null, observe_others?: boolean | null) {
const validatedConfig = SessionPeerConfigSchema.parse({
observe_me,
observe_others,
@ -112,6 +119,36 @@ export class Session {
* Reference to the parent Honcho client instance.
*/
private _client: HonchoCore
/**
* Private cached metadata for this session.
*/
private _metadata?: Record<string, unknown>
/**
* Private cached configuration for this session.
*/
private _configuration?: Record<string, unknown>
/**
* Cached metadata for this session. May be stale if the session
* was not recently fetched from the API.
*
* Call getMetadata() to get the latest metadata from the server,
* which will also update this cached value.
*/
get metadata(): Record<string, unknown> | undefined {
return this._metadata
}
/**
* Cached configuration for this session. May be stale if the session
* was not recently fetched from the API.
*
* Call getConfig() to get the latest configuration from the server,
* which will also update this cached value.
*/
get configuration(): Record<string, unknown> | undefined {
return this._configuration
}
/**
* Initialize a new Session. **Do not call this directly, use the client.session() method instead.**
@ -119,11 +156,21 @@ export class Session {
* @param id - Unique identifier for this session within the workspace
* @param workspaceId - Workspace ID for scoping operations
* @param client - Reference to the parent Honcho client instance
* @param metadata - Optional metadata to initialize the cached value
* @param configuration - Optional configuration to initialize the cached value
*/
constructor(id: string, workspaceId: string, client: HonchoCore) {
constructor(
id: string,
workspaceId: string,
client: HonchoCore,
metadata?: Record<string, unknown>,
configuration?: Record<string, unknown>
) {
this.id = id
this.workspaceId = workspaceId
this._client = client
this._metadata = metadata
this._configuration = configuration
}
/**
@ -364,12 +411,12 @@ export class Session {
* })
* ```
*/
async addMessages(messages: MessageAddition): Promise<void> {
async addMessages(messages: MessageAddition): Promise<Message[]> {
const validatedMessages = MessageAdditionSchema.parse(messages)
const messagesList = Array.isArray(validatedMessages)
? validatedMessages
: [validatedMessages]
await this._client.workspaces.sessions.messages.create(
return await this._client.workspaces.sessions.messages.create(
this.workspaceId,
this.id,
{
@ -403,7 +450,8 @@ export class Session {
*
* Makes an API call to retrieve the current metadata associated with this session.
* Metadata can include custom attributes, settings, or any other key-value data
* that provides context about the session.
* that provides context about the session. This method also updates the cached
* metadata property.
*
* @returns Promise resolving to a dictionary containing the session's metadata.
* Returns an empty dictionary if no metadata is set
@ -413,7 +461,8 @@ export class Session {
this.workspaceId,
{ id: this.id }
)
return session.metadata || {}
this._metadata = session.metadata || {}
return this._metadata
}
/**
@ -422,7 +471,8 @@ export class Session {
* Makes an API call to update the metadata associated with this session.
* This will overwrite any existing metadata with the provided values.
* Metadata is useful for storing custom attributes, configuration, or
* contextual information about the session.
* contextual information about the session. This method also updates the
* cached metadata property.
*
* @param metadata - A dictionary of metadata to associate with this session.
* Keys must be strings, values can be any JSON-serializable type
@ -431,17 +481,114 @@ export class Session {
await this._client.workspaces.sessions.update(this.workspaceId, this.id, {
metadata,
})
this._metadata = metadata
}
/**
* Delete this session.
* Get configuration for this session.
*
* Makes an API call to mark this session as inactive.
* Makes an API call to retrieve the current configuration associated with this session.
* Configuration includes settings that control session behavior. This method also
* updates the cached configuration property.
*
* @returns Promise resolving to a dictionary containing the session's configuration.
* Returns an empty dictionary if no configuration is set
*/
async getConfig(): Promise<Record<string, unknown>> {
const session = await this._client.workspaces.sessions.getOrCreate(
this.workspaceId,
{ id: this.id }
)
this._configuration = session.configuration || {}
return this._configuration
}
/**
* Set configuration for this session.
*
* Makes an API call to update the configuration associated with this session.
* This will overwrite any existing configuration with the provided values.
* This method also updates the cached configuration property.
*
* @param configuration - A dictionary of configuration to associate with this session.
* Keys must be strings, values can be any JSON-serializable type
*/
async setConfig(configuration: Record<string, unknown>): Promise<void> {
await this._client.workspaces.sessions.update(this.workspaceId, this.id, {
configuration,
})
this._configuration = configuration
}
/**
* Refresh cached metadata and configuration for this session.
*
* Makes a single API call to retrieve the latest metadata and configuration
* associated with this session and updates the cached properties.
*/
async refresh(): Promise<void> {
const session = await this._client.workspaces.sessions.getOrCreate(
this.workspaceId,
{ id: this.id }
)
this._metadata = session.metadata || {}
this._configuration = session.configuration || {}
}
/**
* Delete this session and all associated data.
*
* Makes an API call to permanently delete this session and all related data including:
* - Messages
* - Message embeddings
* - Observations
* - Session-Peer associations
* - Background processing queue items
*
* This action cannot be undone.
*/
async delete(): Promise<void> {
await this._client.workspaces.sessions.delete(this.workspaceId, this.id)
}
/**
* Clone this session, optionally up to a specific message.
*
* Makes an API call to create a copy of this session with a new ID.
* All messages and peers from the original session are copied to the new session.
* If a messageId is provided, only messages up to and including that message
* are copied.
*
* @param messageId - Optional message ID to cut off the clone at. If provided,
* the cloned session will only contain messages up to and
* including this message.
* @returns Promise resolving to a new Session object representing the cloned session
*
* @example
* ```typescript
* // Clone entire session
* const cloned = await session.clone()
*
* // Clone session up to a specific message
* const cloned = await session.clone('msg_abc123')
* ```
*/
async clone(messageId?: string): Promise<Session> {
const clonedSessionData = await this._client.workspaces.sessions.clone(
this.workspaceId,
this.id,
messageId ? { message_id: messageId } : {}
)
return new Session(
clonedSessionData.id,
this.workspaceId,
this._client,
clonedSessionData.metadata ?? undefined,
clonedSessionData.configuration ?? undefined
)
}
/**
* Get optimized context for this session within a token limit.
*
@ -478,7 +625,8 @@ export class Session {
tokens?: number,
peerTarget?: string | Peer,
lastUserMessage?: string | Message,
peerPerspective?: string | Peer
peerPerspective?: string | Peer,
representationOptions?: RepresentationOptions
): Promise<SessionContext>
async getContext(options?: {
summary?: boolean
@ -486,6 +634,8 @@ export class Session {
peerTarget?: string | Peer
lastUserMessage?: string | Message
peerPerspective?: string | Peer
limitToSession?: boolean
representationOptions?: RepresentationOptions
}): Promise<SessionContext>
async getContext(
summaryOrOptions?:
@ -496,11 +646,14 @@ export class Session {
peerTarget?: string | Peer
lastUserMessage?: string | Message
peerPerspective?: string | Peer
limitToSession?: boolean
representationOptions?: RepresentationOptions
},
tokens?: number,
peerTarget?: string | Peer,
lastUserMessage?: string | Message,
peerPerspective?: string | Peer
peerPerspective?: string | Peer,
representationOptions?: RepresentationOptions
): Promise<SessionContext> {
// Normalize positional arguments into options object
let options: {
@ -509,10 +662,13 @@ export class Session {
peerTarget?: string
lastUserMessage?: string
peerPerspective?: string
limitToSession?: boolean
representationOptions?: RepresentationOptions
}
if (
typeof summaryOrOptions === 'boolean' ||
// biome-ignore lint/complexity/noArguments: Need to detect which overload pattern is being used
(summaryOrOptions === undefined && arguments.length > 1)
) {
// Positional arguments pattern
@ -528,6 +684,7 @@ export class Session {
typeof peerPerspective === 'object'
? peerPerspective.id
: peerPerspective,
representationOptions,
}
} else {
// Options object pattern
@ -540,6 +697,8 @@ export class Session {
peerTarget: options.peerTarget,
lastUserMessage: options.lastUserMessage,
peerPerspective: options.peerPerspective,
limitToSession: options.limitToSession,
representationOptions: options.representationOptions,
})
// Extract message ID if lastUserMessage is a Message object
@ -557,6 +716,13 @@ export class Session {
last_message: lastMessageId,
peer_target: contextParams.peerTarget,
peer_perspective: contextParams.peerPerspective,
limit_to_session: contextParams.limitToSession,
search_top_k: contextParams.representationOptions?.searchTopK,
search_max_distance:
contextParams.representationOptions?.searchMaxDistance,
include_most_derived:
contextParams.representationOptions?.includeMostDerived,
max_observations: contextParams.representationOptions?.maxObservations,
}
)
// Convert the summary response to Summary object if present
@ -636,6 +802,91 @@ export class Session {
)
}
/**
* List all observations for this session.
*
* Observations are theory-of-mind data (documents) that peers have formed about each other.
* Returns paginated results that can be filtered by observer_id and observed_id.
*
* @param filters - Optional filters to scope the observations: see [filters documentation](https://docs.honcho.dev/v2/guides/using-filters).
* @returns A paginated list of Observation objects.
*
* @example
* ```typescript
* const observations = await session.listObservations()
* for await (const observation of observations) {
* console.log(`${observation.observer_id} observed: ${observation.content}`)
* }
* ```
*/
// Disabled: observations not ready for release
// async listObservations(filters?: Filters): Promise<Page<Observation>> {
// const validatedFilters = filters ? FilterSchema.parse(filters) : undefined
// const response = await this._client.workspaces.sessions.observations.list(
// this.workspaceId,
// this.id,
// { filters: validatedFilters }
// )
// return new Page(response)
// }
/**
* Query observations using semantic search.
*
* Performs vector similarity search on observations to find semantically relevant results.
* Use this to find observations related to a specific topic or concept.
*
* @param params - Query parameters
* @param params.query - The semantic search query
* @param params.top_k - Number of results to return (1-100, default: 10)
* @param params.distance - Maximum cosine distance threshold for results (0.0-1.0)
* @param params.filters - Optional filters to scope the query
* @returns A list of Observation objects matching the query
*
* @example
* ```typescript
* const observations = await session.queryObservations({
* query: "user preferences about music",
* top_k: 5,
* distance: 0.8
* })
* ```
*/
// Disabled: observations not ready for release
// async queryObservations(
// params: ObservationQueryParams
// ): Promise<Observation[]> {
// const validated = ObservationQueryParamsSchema.parse(params)
// return await this._client.workspaces.sessions.observations.query(
// this.workspaceId,
// this.id,
// validated
// )
// }
/**
* Delete a specific observation by ID.
*
* This permanently deletes the observation (document) from the theory-of-mind system.
* This action cannot be undone.
*
* @param observationId - The ID of the observation to delete
* @returns A promise that resolves when the observation is deleted
*
* @example
* ```typescript
* await session.deleteObservation('obs_123abc')
* ```
*/
// Disabled: observations not ready for release
// async deleteObservation(observationId: string): Promise<void> {
// await this._client.workspaces.sessions.observations.delete(
// this.workspaceId,
// this.id,
// observationId
// )
// }
/**
* Get the deriver processing status for this session, optionally scoped to an observer or sender.
*
@ -644,12 +895,18 @@ export class Session {
* This method automatically scopes the status to this 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.observer - Optional observer (ID string or Peer object) to scope the status to
* @param options.sender - Optional sender (ID string or Peer object) to scope the status to
* @returns Promise resolving to the deriver status information including work unit counts
*/
async getDeriverStatus(
options?: Omit<DeriverStatusOptions, 'sessionId'>
options?: Omit<
DeriverStatusOptions,
'sessionId' | 'observerId' | 'senderId'
> & {
observer?: string | Peer
sender?: string | Peer
}
): Promise<{
totalWorkUnits: number
completedWorkUnits: number
@ -657,16 +914,22 @@ export class Session {
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
}> {
const validatedOptions = options
? DeriverStatusOptionsSchema.parse(options)
const resolvedObserverId = options?.observer
? typeof options.observer === 'string'
? options.observer
: options.observer.id
: undefined
const resolvedSenderId = options?.sender
? typeof options.sender === 'string'
? options.sender
: options.sender.id
: undefined
const queryParams: WorkspaceDeriverStatusParams = {
session_id: this.id, // Always use this session's ID
}
if (validatedOptions?.observerId)
queryParams.observer_id = validatedOptions.observerId
if (validatedOptions?.senderId)
queryParams.sender_id = validatedOptions.senderId
if (resolvedObserverId) queryParams.observer_id = resolvedObserverId
if (resolvedSenderId) queryParams.sender_id = resolvedSenderId
const status = await this._client.workspaces.deriverStatus(
this.workspaceId,
@ -690,14 +953,20 @@ export class Session {
* 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.observer - Optional observer (ID string or Peer object) to scope the status to
* @param options.sender - Optional sender (ID string or Peer object) 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?: Omit<DeriverStatusOptions, 'sessionId'>
options?: Omit<
DeriverStatusOptions,
'sessionId' | 'observerId' | 'senderId'
> & {
observer?: string | Peer
sender?: string | Peer
}
): Promise<{
totalWorkUnits: number
completedWorkUnits: number
@ -705,14 +974,11 @@ export class Session {
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
}> {
const validatedOptions = options
? DeriverStatusOptionsSchema.parse(options)
: undefined
const timeoutMs = validatedOptions?.timeoutMs ?? 300000 // Default to 5 minutes
const timeoutMs = options?.timeoutMs ?? 300000 // Default to 5 minutes
const startTime = Date.now()
while (true) {
const status = await this.getDeriverStatus(validatedOptions)
const status = await this.getDeriverStatus(options)
if (status.pendingWorkUnits === 0 && status.inProgressWorkUnits === 0) {
return status
}
@ -754,7 +1020,11 @@ export class Session {
* - File objects (browser File API)
* - Buffer or Uint8Array with filename and content_type
* - { filename: string, content: Buffer | Uint8Array, content_type: string }
* @param peerId - The peer ID to attribute the created messages to
* @param peer - The peer (ID string or Peer object) to attribute the created messages to
* @param options - Optional parameters for the uploaded messages
* @param options.metadata - Optional metadata dictionary to associate with the messages
* @param options.configuration - Optional configuration dictionary to associate with the messages
* @param options.created_at - Optional created-at timestamp for the messages. Should be an ISO 8601 formatted string.
* @returns Promise resolving to a list of Message objects representing the created messages
*
* @note Supported file types include PDFs, text files, and JSON documents.
@ -766,17 +1036,59 @@ export class Session {
* // Upload a file
* const messages = await session.uploadFile(fileInput.files[0], 'user123')
* console.log(`Created ${messages.length} messages from file`)
*
* // Upload a file with metadata and timestamp
* const messages = await session.uploadFile(fileInput.files[0], 'user123', {
* metadata: { source: 'upload' },
* created_at: '2021-01-01T00:00:00.000Z'
* })
* ```
*/
async uploadFile(file: Uploadable, peerId: string): Promise<Message[]> {
const uploadParams = FileUploadSchema.parse({ file, peerId })
async uploadFile(
file: Uploadable,
peer: string | Peer,
options?: {
metadata?: Record<string, unknown>
configuration?: Record<string, unknown>
created_at?: string | Date
}
): Promise<Message[]> {
const createdAt =
options?.created_at instanceof Date
? options.created_at.toISOString()
: options?.created_at
const resolvedPeerId = typeof peer === 'string' ? peer : peer.id
const uploadParams = FileUploadSchema.parse({
file,
peer: resolvedPeerId,
metadata: options?.metadata,
configuration: options?.configuration,
created_at: createdAt,
})
// Build body with file and peer_id, plus optional fields as JSON strings
const body = {
file: uploadParams.file,
peer_id: resolvedPeerId,
...(uploadParams.metadata !== undefined && uploadParams.metadata !== null
? { metadata: JSON.stringify(uploadParams.metadata) }
: {}),
...(uploadParams.configuration !== undefined &&
uploadParams.configuration !== null
? { configuration: JSON.stringify(uploadParams.configuration) }
: {}),
...(uploadParams.created_at !== undefined &&
uploadParams.created_at !== null
? { created_at: uploadParams.created_at }
: {}),
}
const response = await this._client.workspaces.sessions.messages.upload(
this.workspaceId,
this.id,
{
file: uploadParams.file,
peer_id: uploadParams.peerId,
}
body
)
return response
@ -792,23 +1104,41 @@ export class Session {
* @param peer - The peer to get the working representation of. Can be peer ID string or Peer object
* @param target - Optional target peer. If provided, returns what `peer` knows about
* `target` within this session context rather than `peer`'s global representation
* @returns Promise resolving to a dictionary containing the peer's representation information,
* including facts, characteristics, and contextual knowledge
* @param options - Optional representation options to filter and configure the results
* @returns Promise resolving to a Representation object containing explicit and deductive observations
*
* @example
* ```typescript
* // Get peer's global representation in this session
* const globalRep = await session.workingRep('user123')
* console.log(globalRep.toString())
*
* // Get what user123 knows about assistant in this session
* const localRep = await session.workingRep('user123', 'assistant')
*
* // Get representation with semantic search
* const searchedRep = await session.workingRep('user123', undefined, {
* searchQuery: 'preferences',
* searchTopK: 10
* })
* ```
*/
async workingRep(
peer: string | Peer,
target?: string | Peer
): Promise<Record<string, unknown>> {
const workingRepParams = WorkingRepParamsSchema.parse({ peer, target })
target?: string | Peer,
options?: {
searchQuery?: string
searchTopK?: number
searchMaxDistance?: number
includeMostDerived?: boolean
maxObservations?: number
}
): Promise<Representation> {
const workingRepParams = WorkingRepParamsSchema.parse({
peer,
target,
options,
})
const peerId =
typeof workingRepParams.peer === 'string'
? workingRepParams.peer
@ -819,14 +1149,27 @@ export class Session {
: workingRepParams.target.id
: undefined
return await this._client.workspaces.peers.workingRepresentation(
const response = await this._client.workspaces.peers.workingRepresentation(
this.workspaceId,
peerId,
{
session_id: this.id,
target: targetId,
search_query: workingRepParams.options?.searchQuery,
search_top_k: workingRepParams.options?.searchTopK,
search_max_distance: workingRepParams.options?.searchMaxDistance,
include_most_derived: workingRepParams.options?.includeMostDerived,
max_observations: workingRepParams.options?.maxObservations,
}
)
const maybe = response as
| RepresentationData
| { representation?: RepresentationData | null }
| null
const rep = (maybe && typeof maybe === 'object' && 'representation' in maybe
? (maybe as { representation?: RepresentationData | null }).representation
: maybe) ?? { explicit: [], deductive: [] }
return Representation.fromData(rep as RepresentationData)
}
/**

View File

@ -1,7 +1,41 @@
import type { Session } from './session'
/**
* Shared types for the Honcho TypeScript SDK.
*/
/**
* Observation - external view of a document (theory-of-mind data).
*/
export interface Observation {
id: string
content: string
observer_id: string
observed_id: string
session_id: string
created_at: string
}
/**
* Parameters for creating an observation.
*/
export interface ObservationCreateParam {
/** The observation content/text */
content: string
/** The session this observation relates to (ID string or Session object) */
sessionId: string | Session
}
/**
* Parameters for semantic search of observations.
*/
export interface ObservationQueryParams {
query: string
top_k?: number
distance?: number
filters?: Record<string, unknown>
}
/**
* Delta object for streaming dialectic responses.
*/

View File

@ -68,7 +68,7 @@ export const SessionIdSchema = z
*/
export const SessionPeerConfigSchema = z.object({
observe_me: z.boolean().nullable().optional(),
observe_others: z.boolean().optional(),
observe_others: z.boolean().nullable().optional(),
})
/**
@ -88,6 +88,15 @@ export const MessageMetadataSchema = z
.record(z.string(), z.unknown())
.optional()
/**
* Schema for message configuration.
* Configuration can include deriver and peer_card settings.
*/
export const MessageConfigurationSchema = z
.record(z.string(), z.unknown())
.nullable()
.optional()
/**
* Schema for message creation.
*/
@ -95,6 +104,7 @@ export const MessageCreateSchema = z.object({
peer_id: PeerIdSchema,
content: MessageContentSchema,
metadata: MessageMetadataSchema,
configuration: MessageConfigurationSchema,
created_at: z.string().nullable().optional(),
})
@ -120,8 +130,18 @@ export const FilterSchema = z.record(z.string(), z.unknown()).optional()
export const ChatQuerySchema = z.object({
query: SearchQuerySchema,
stream: z.boolean().optional().default(false),
target: z.union([z.string(), z.object({ id: z.string() })]).optional(),
sessionId: z.string().optional(),
target: z
.union([z.string(), z.object({ id: z.string() })])
.optional()
.transform((val) =>
val ? (typeof val === 'string' ? val : val.id) : undefined
),
session: z
.union([z.string(), z.object({ id: z.string() })])
.optional()
.transform((val) =>
val ? (typeof val === 'string' ? val : val.id) : undefined
),
})
/**
@ -138,6 +158,30 @@ const MessageSchema: z.ZodType<Message> = z.object({
metadata: z.record(z.string(), z.unknown()).optional(),
}) as z.ZodType<Message>
/**
* Schema for representation options.
*/
export const RepresentationOptionsSchema = z.object({
searchTopK: z
.number()
.int()
.min(1, 'searchTopK must be at least 1')
.max(100, 'searchTopK must be at most 100')
.optional(),
searchMaxDistance: z
.number()
.min(0.0, 'searchMaxDistance must be at least 0.0')
.max(1.0, 'searchMaxDistance must be at most 1.0')
.optional(),
includeMostDerived: z.boolean().optional(),
maxObservations: z
.number()
.int()
.min(1, 'maxObservations must be at least 1')
.max(100, 'maxObservations must be at most 100')
.optional(),
})
/**
* Schema for context retrieval parameters.
*/
@ -156,6 +200,8 @@ export const ContextParamsSchema = z
.optional(),
peerTarget: PeerIdSchema.optional(),
peerPerspective: PeerIdSchema.optional(),
limitToSession: z.boolean().optional(),
representationOptions: RepresentationOptionsSchema.optional(),
})
.superRefine((data, ctx) => {
if (data.lastUserMessage && !data.peerTarget) {
@ -179,9 +225,9 @@ export const ContextParamsSchema = z
* Schema for deriver status options.
*/
export const DeriverStatusOptionsSchema = z.object({
observerId: z.string().optional(),
senderId: z.string().optional(),
sessionId: z.string().optional(),
observer: z.union([z.string(), z.object({ id: z.string() })]).optional(),
sender: z.union([z.string(), z.object({ id: z.string() })]).optional(),
session: z.union([z.string(), z.object({ id: z.string() })]).optional(),
timeoutMs: z
.number()
.positive('Timeout must be a positive number')
@ -216,7 +262,10 @@ export const FileUploadSchema = z.object({
'File must not be null or undefined'
),
]),
peerId: PeerIdSchema,
peer: z.union([PeerIdSchema, z.object({ id: z.string() })]),
metadata: MessageMetadataSchema,
configuration: z.record(z.string(), z.unknown()).optional(),
created_at: z.string().nullable().optional(),
})
/**
@ -225,6 +274,20 @@ export const FileUploadSchema = z.object({
export const WorkingRepParamsSchema = z.object({
peer: z.union([z.string(), z.object({ id: z.string() })]),
target: z.union([z.string(), z.object({ id: z.string() })]).optional(),
options: RepresentationOptionsSchema.extend({
searchQuery: SearchQuerySchema.optional(),
}).optional(),
})
/**
* Schema for peer working representation parameters.
*/
export const PeerWorkingRepParamsSchema = z.object({
session: z.union([z.string(), z.object({ id: z.string() })]).optional(),
target: z.union([z.string(), z.object({ id: z.string() })]).optional(),
options: RepresentationOptionsSchema.extend({
searchQuery: SearchQuerySchema.optional(),
}).optional(),
})
/**
@ -271,6 +334,11 @@ export const MessageAdditionSchema = z.union([
*/
export const WorkspaceMetadataSchema = z.record(z.string(), z.unknown())
/**
* Schema for workspace configuration.
*/
export const WorkspaceConfigSchema = z.record(z.string(), z.unknown())
/**
* Schema for limit.
*/
@ -280,6 +348,25 @@ export const LimitSchema = z
.min(1, 'Limit must be a positive integer')
.max(100, 'Limit must be less than or equal to 100')
/**
* Schema for observation query parameters.
*/
export const ObservationQueryParamsSchema = z.object({
query: SearchQuerySchema,
top_k: z
.number()
.int()
.min(1, 'top_k must be at least 1')
.max(100, 'top_k must be at most 100')
.optional(),
distance: z
.number()
.min(0.0, 'distance must be at least 0.0')
.max(1.0, 'distance must be at most 1.0')
.optional(),
filters: FilterSchema,
})
/**
* Type exports for use throughout the SDK.
*/
@ -296,8 +383,13 @@ export type ContextParams = z.infer<typeof ContextParamsSchema>
export type DeriverStatusOptions = z.infer<typeof DeriverStatusOptionsSchema>
export type FileUpload = z.infer<typeof FileUploadSchema>
export type WorkingRepParams = z.infer<typeof WorkingRepParamsSchema>
export type PeerWorkingRepParams = z.infer<typeof PeerWorkingRepParamsSchema>
export type PeerAddition = z.infer<typeof PeerAdditionSchema>
export type PeerRemoval = z.infer<typeof PeerRemovalSchema>
export type MessageAddition = z.infer<typeof MessageAdditionSchema>
export type WorkspaceMetadata = z.infer<typeof WorkspaceMetadataSchema>
export type WorkspaceConfig = z.infer<typeof WorkspaceConfigSchema>
export type Limit = z.infer<typeof LimitSchema>
export type ObservationQueryParams = z.infer<
typeof ObservationQueryParamsSchema
>

View File

@ -1,7 +1,9 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"module": "node16",
"moduleResolution": "node16",
"isolatedModules": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",

View File

@ -136,8 +136,8 @@ class BackupLLMSettingsMixin:
both fields are set together or both are None.
"""
BACKUP_PROVIDER: SupportedProviders | None = None
BACKUP_MODEL: str | None = None
BACKUP_PROVIDER: SupportedProviders | None = "custom"
BACKUP_MODEL: str | None = "x-ai/grok-4-fast"
@model_validator(mode="after")
def _validate_backup_configuration(self):
@ -236,7 +236,7 @@ class DeriverSettings(BackupLLMSettingsMixin, HonchoSettings):
# Thinking budget tokens are only applied when using Anthropic as provider
THINKING_BUDGET_TOKENS: Annotated[int, Field(default=1024, gt=0, le=5000)] = 1024
# Maximum number of observations to store in working representation
# Maximum number of observations to return in working representation
# This is applied to both explicit and deductive observations
WORKING_REPRESENTATION_MAX_OBSERVATIONS: Annotated[
int, Field(default=50, gt=0, le=500)
@ -330,7 +330,7 @@ class CacheSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="CACHE_", extra="ignore") # pyright: ignore
ENABLED: bool = False
URL: str = "redis://localhost:6379/0?suppress=false"
URL: str = "redis://localhost:6379/0?suppress=true"
NAMESPACE: str | None = None
DEFAULT_TTL_SECONDS: Annotated[int, Field(default=300, ge=1, le=86_400)] = (
300 # how long to keep items in cache
@ -351,8 +351,8 @@ class DreamSettings(BackupLLMSettingsMixin, HonchoSettings):
ENABLED_TYPES: list[str] = ["consolidate"]
# LLM settings for dream processing
PROVIDER: SupportedProviders = "openai"
MODEL: str = "gpt-4o-mini-2024-07-18"
PROVIDER: SupportedProviders = "google"
MODEL: str = "gemini-2.5-flash"
MAX_OUTPUT_TOKENS: Annotated[int, Field(default=2000, gt=0, le=10_000)] = 2000

View File

@ -2,7 +2,11 @@ from .collection import get_collection, get_or_create_collection
from .deriver import get_deriver_status
from .document import (
create_documents,
create_observations,
delete_document,
delete_document_by_id,
get_all_documents,
get_documents_with_filters,
query_documents,
)
from .message import (
@ -47,6 +51,7 @@ from .workspace import (
delete_workspace,
get_all_workspaces,
get_or_create_workspace,
get_workspace,
update_workspace,
)
@ -58,8 +63,12 @@ __all__ = [
"get_deriver_status",
# Document
"create_documents",
"create_observations",
"get_all_documents",
"get_documents_with_filters",
"query_documents",
"delete_document",
"delete_document_by_id",
# Message
"create_messages",
"get_messages",
@ -98,6 +107,7 @@ __all__ = [
# Workspace
"delete_workspace",
"get_or_create_workspace",
"get_workspace",
"get_all_workspaces",
"update_workspace",
]

View File

@ -98,10 +98,12 @@ def _build_queue_status_query(
models.QueueItem.work_unit_key == models.ActiveQueueSession.work_unit_key,
)
stmt = stmt.join(models.Session, models.QueueItem.session_id == models.Session.id)
stmt = stmt.where(models.Session.workspace_name == workspace_name)
stmt = stmt.where(models.QueueItem.workspace_name == workspace_name)
if session_name is not None:
stmt = stmt.join(
models.Session, models.QueueItem.session_id == models.Session.id
)
stmt = stmt.where(models.Session.name == session_name)
peer_conditions = []

View File

@ -2,42 +2,104 @@ from collections.abc import Sequence
from logging import getLogger
from typing import Any
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import Select
from src import models, schemas
from src.config import settings
from src.crud.collection import get_or_create_collection
from src.crud.peer import get_peer
from src.crud.session import get_session
from src.embedding_client import embedding_client
from src.exceptions import ValidationException
from src.exceptions import ResourceNotFoundException, ValidationException
from src.utils.filter import apply_filter
logger = getLogger(__name__)
async def get_all_documents(
db: AsyncSession,
def get_all_documents(
workspace_name: str,
*,
observer: str,
observed: str,
limit: int = 1000,
) -> Sequence[models.Document]:
filters: dict[str, Any] | None = None,
reverse: bool = False,
limit: int | None = None,
) -> Select[tuple[models.Document]]:
"""
Get all documents in a collection.
NOTE: Order is nondeterministic. Also this may return a massive amount of documents. Don't use this on large collections.
TODO: add pagination and update dreaming logic to deduplicate more effectively
Returns a Select query for pagination support via apaginate().
Results are ordered by created_at timestamp.
Args:
workspace_name: Name of the workspace
observer: Name of the observing peer
observed: Name of the observed peer
filters: Optional filters to apply
reverse: Whether to reverse the order (oldest first)
Returns:
Select query for documents
"""
stmt = (
select(models.Document)
.limit(limit)
.where(models.Document.workspace_name == workspace_name)
.where(models.Document.observer == observer)
.where(models.Document.observed == observed)
)
result = await db.execute(stmt)
return result.scalars().all()
# Apply additional filters if provided
stmt = apply_filter(stmt, models.Document, filters)
# Order by created_at (newest first by default)
if reverse:
stmt = stmt.order_by(models.Document.created_at.asc())
else:
stmt = stmt.order_by(models.Document.created_at.desc())
if limit is not None:
stmt = stmt.limit(limit)
return stmt
def get_documents_with_filters(
workspace_name: str,
*,
filters: dict[str, Any] | None = None,
reverse: bool = False,
) -> Select[tuple[models.Document]]:
"""
Get all documents using custom filters.
Returns a Select query for pagination support via apaginate().
Results are ordered by created_at timestamp.
Args:
workspace_name: Name of the workspace
filters: Optional filters to apply
reverse: Whether to reverse the order (oldest first)
Returns:
Select query for documents
"""
stmt = select(models.Document).where(
models.Document.workspace_name == workspace_name
)
# Apply additional filters if provided
stmt = apply_filter(stmt, models.Document, filters)
# Order by created_at (newest first by default)
if reverse:
stmt = stmt.order_by(models.Document.created_at.asc())
else:
stmt = stmt.order_by(models.Document.created_at.desc())
return stmt
async def query_documents(
@ -162,6 +224,174 @@ async def create_documents(
return len(honcho_documents)
async def delete_document(
db: AsyncSession,
workspace_name: str,
document_id: str,
*,
observer: str,
observed: str,
session_name: str | None = None,
) -> None:
"""
Delete a single document by ID.
Args:
db: Database session
workspace_name: Name of the workspace
document_id: ID of the document to delete
observer: Name of the observing peer (for authorization)
observed: Name of the observed peer (for authorization)
session_name: Optional session name to verify document belongs to session
Raises:
ResourceNotFoundException: If document not found or doesn't match criteria
"""
stmt = delete(models.Document).where(
models.Document.id == document_id,
models.Document.workspace_name == workspace_name,
models.Document.observer == observer,
models.Document.observed == observed,
)
# If session is specified, ensure document belongs to that session
if session_name is not None:
stmt = stmt.where(models.Document.session_name == session_name)
result = await db.execute(stmt)
await db.commit()
if result.rowcount == 0:
raise ResourceNotFoundException(
f"Document {document_id} not found or does not belong to the specified collection/session"
)
async def delete_document_by_id(
db: AsyncSession,
workspace_name: str,
document_id: str,
) -> None:
"""
Delete a single document by ID and workspace.
Args:
db: Database session
workspace_name: Name of the workspace
document_id: ID of the document to delete
Raises:
ResourceNotFoundException: If document not found or doesn't belong to the workspace
"""
stmt = delete(models.Document).where(
models.Document.id == document_id,
models.Document.workspace_name == workspace_name,
)
result = await db.execute(stmt)
await db.commit()
if result.rowcount == 0:
raise ResourceNotFoundException(
f"Document {document_id} not found or does not belong to workspace {workspace_name}"
)
async def create_observations(
db: AsyncSession,
observations: list[schemas.ObservationCreate],
workspace_name: str,
) -> list[models.Document]:
"""
Create multiple observations (documents) from user input.
This function validates all referenced resources, generates embeddings
in batch, and creates the documents.
Args:
db: Database session
observations: List of observation creation schemas
workspace_name: Name of the workspace
Returns:
List of created Document objects
Raises:
ResourceNotFoundException: If any session or peer is not found
ValidationException: If embedding generation fails or integrity constraint is violated
"""
if not observations:
return []
# Collect unique sessions and peer pairs to validate
sessions_to_validate: set[str] = set()
peers_to_validate: set[str] = set()
collection_pairs: set[tuple[str, str]] = set()
for obs in observations:
sessions_to_validate.add(obs.session_id)
peers_to_validate.add(obs.observer_id)
peers_to_validate.add(obs.observed_id)
collection_pairs.add((obs.observer_id, obs.observed_id))
# Validate all sessions exist
for session_name in sessions_to_validate:
await get_session(db, session_name, workspace_name)
# Validate all peers exist
for peer_name in peers_to_validate:
await get_peer(db, workspace_name, schemas.PeerCreate(name=peer_name))
# Get or create all collections
for observer, observed in collection_pairs:
await get_or_create_collection(
db, workspace_name, observer=observer, observed=observed
)
# Generate embeddings in batch
contents = [obs.content for obs in observations]
try:
embeddings = await embedding_client.simple_batch_embed(contents)
except ValueError as e:
raise ValidationException(str(e)) from e
# Create document objects
honcho_documents: list[models.Document] = []
for obs, embedding in zip(observations, embeddings, strict=True):
honcho_documents.append(
models.Document(
workspace_name=workspace_name,
observer=obs.observer_id,
observed=obs.observed_id,
content=obs.content,
level="explicit", # Manually created observations are always explicit
times_derived=1,
internal_metadata={}, # No message_ids since not derived from messages
embedding=embedding,
session_name=obs.session_id,
)
)
try:
db.add_all(honcho_documents)
await db.commit()
# Refresh all documents to get generated IDs and timestamps
for doc in honcho_documents:
await db.refresh(doc)
except IntegrityError as e:
await db.rollback()
raise ValidationException(
"Failed to create observations due to integrity constraint violation"
) from e
logger.debug(
"Created %d observations in workspace %s",
len(honcho_documents),
workspace_name,
)
return honcho_documents
async def is_rejected_duplicate(
db: AsyncSession,
doc: schemas.DocumentCreate,

View File

@ -11,6 +11,7 @@ from src.cache.client import cache, get_cache_namespace
from src.config import settings
from src.crud.workspace import get_or_create_workspace
from src.exceptions import ConflictException, ResourceNotFoundException
from src.models import Peer
from src.utils.filter import apply_filter
logger = getLogger(__name__)
@ -63,21 +64,37 @@ async def get_or_create_peers(
.where(models.Peer.name.in_(peer_names))
)
result = await db.execute(stmt)
existing_peers = list(result.scalars().all())
existing_peers: list[Peer] = 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}
# Track which peers actually changed
changed_peers: list[Peer] = []
# Update existing peers with metadata and configuration if provided
for existing_peer in existing_peers:
peer_schema = peer_schema_map[existing_peer.name]
changed = False
# Update with metadata and configuration if provided
if peer_schema.metadata is not None:
# Update with metadata if provided AND different
if (
peer_schema.metadata is not None
and existing_peer.h_metadata != peer_schema.metadata
):
existing_peer.h_metadata = peer_schema.metadata
changed = True
if peer_schema.configuration is not None:
# Update with configuration if provided AND different
if (
peer_schema.configuration is not None
and existing_peer.configuration != peer_schema.configuration
):
existing_peer.configuration = peer_schema.configuration
changed = True
if changed:
changed_peers.append(existing_peer)
# Find which peers need to be created
existing_names = {p.name for p in existing_peers}
@ -104,10 +121,15 @@ async def get_or_create_peers(
) from None
return await get_or_create_peers(db, workspace_name, peers, _retry=True)
# Invalidate cache for all updated/created peers - read-through pattern
for peer_obj in existing_peers + new_peers:
# Only invalidate cache for changed/new peers - read-through pattern
for peer_obj in changed_peers + new_peers:
cache_key = peer_cache_key(workspace_name, peer_obj.name)
await cache.delete(cache_key)
logger.debug(
"Peer %s cache invalidated in workspace %s (changed or new)",
peer_obj.name,
workspace_name,
)
# Return combined list of existing and new peers
return existing_peers + new_peers
@ -150,7 +172,7 @@ async def get_peer(
peer: Peer creation schema
Returns:
The peer if found or created
The peer if found
Raises:
ResourceNotFoundException: If the peer does not exist
@ -204,16 +226,31 @@ async def update_peer(
)
)[0]
if peer.metadata is not None:
honcho_peer.h_metadata = peer.metadata
needs_update = False
if peer.configuration is not None:
if peer.metadata is not None and honcho_peer.h_metadata != peer.metadata:
honcho_peer.h_metadata = peer.metadata
needs_update = True
if (
peer.configuration is not None
and honcho_peer.configuration != peer.configuration
):
honcho_peer.configuration = peer.configuration
needs_update = True
# Early exit if unchanged
if not needs_update:
logger.debug(
"Peer %s unchanged in workspace %s, skipping update",
peer_name,
workspace_name,
)
return honcho_peer
await db.commit()
await db.refresh(honcho_peer)
# Invalidate cache - read-through pattern
cache_key = peer_cache_key(workspace_name, honcho_peer.name)
await cache.delete(cache_key)

View File

@ -13,6 +13,7 @@ from src.config import settings
from src.dependencies import tracked_db
from src.dreamer.dream_scheduler import check_and_schedule_dream
from src.embedding_client import embedding_client
from src.schemas import ResolvedConfiguration
from src.utils.formatting import format_datetime_utc
from src.utils.logging import accumulate_metric
from src.utils.representation import (
@ -44,9 +45,10 @@ class RepresentationManager:
async def save_representation(
self,
representation: Representation,
message_id_range: tuple[int, int],
message_ids: list[int],
session_name: str,
message_created_at: datetime.datetime,
message_level_configuration: ResolvedConfiguration,
) -> int:
"""
Save Representation objects to the collection as a set of documents.
@ -85,7 +87,7 @@ class RepresentationManager:
batch_embed_duration = (time.perf_counter() - batch_embed_start) * 1000
accumulate_metric(
f"deriver_{message_id_range[1]}_{self.observer}",
f"deriver_{message_ids[-1]}_{self.observer}",
"embed_new_observations",
batch_embed_duration,
"ms",
@ -98,14 +100,15 @@ class RepresentationManager:
db,
all_observations,
embeddings,
message_id_range,
message_ids,
session_name,
message_created_at,
message_level_configuration,
)
create_document_duration = (time.perf_counter() - create_document_start) * 1000
accumulate_metric(
f"deriver_{message_id_range[1]}_{self.observer}",
f"deriver_{message_ids[-1]}_{self.observer}",
"save_new_observations",
create_document_duration,
"ms",
@ -118,9 +121,10 @@ class RepresentationManager:
db: AsyncSession,
all_observations: list[ExplicitObservation | DeductiveObservation],
embeddings: list[list[float]],
message_id_range: tuple[int, int],
message_ids: list[int],
session_name: str,
message_created_at: datetime.datetime,
message_level_configuration: ResolvedConfiguration,
) -> int:
# get_or_create_collection already handles IntegrityError with rollback and a retry
collection = await crud.get_or_create_collection(
@ -144,7 +148,7 @@ class RepresentationManager:
obs_premises = None
metadata: schemas.DocumentMetadata = schemas.DocumentMetadata(
message_ids=[message_id_range],
message_ids=message_ids,
premises=obs_premises,
message_created_at=format_datetime_utc(message_created_at),
)
@ -169,10 +173,11 @@ class RepresentationManager:
deduplicate=settings.DERIVER.DEDUPLICATE,
)
try:
await check_and_schedule_dream(db, collection)
except Exception as e:
logger.warning(f"Failed to check dream scheduling: {e}")
if message_level_configuration.dream.enabled:
try:
await check_and_schedule_dream(db, collection)
except Exception as e:
logger.warning(f"Failed to check dream scheduling: {e}")
return new_documents

View File

@ -3,7 +3,7 @@ from typing import Any
from cashews import NOT_NONE
from nanoid import generate as generate_nanoid
from sqlalchemy import Select, case, cast, func, insert, select, update
from sqlalchemy import Select, and_, case, cast, delete, func, insert, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@ -83,9 +83,13 @@ async def get_sessions(
filters: dict[str, Any] | None = None,
) -> Select[tuple[models.Session]]:
"""
Get all sessions in a workspace.
Get all active sessions in a workspace.
"""
stmt = select(models.Session).where(models.Session.workspace_name == workspace_name)
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.is_active == True) # noqa: E712
)
stmt = apply_filter(stmt, models.Session, filters)
@ -126,6 +130,15 @@ async def get_or_create_session(
if honcho_session is not None:
honcho_session = await db.merge(honcho_session, load=False)
# Reject operations on inactive sessions (marked for deletion)
if not honcho_session.is_active:
raise ResourceNotFoundException(
f"Session {session.name} not found in workspace {workspace_name}"
)
# Track if we need to update cache
needs_cache_update = False
# Check if session already exists
if honcho_session is None:
if session.peer_names:
@ -145,12 +158,16 @@ async def get_or_create_session(
workspace_name=workspace_name,
name=session.name,
h_metadata=session.metadata or {},
configuration=session.configuration or {},
configuration=session.configuration.model_dump(exclude_none=True)
if session.configuration
else {},
)
try:
db.add(honcho_session)
# Flush to ensure session exists in DB before adding peers
# Flush to ensure session exists in DB before adding peers and set flag to warm cache
await db.flush()
needs_cache_update = True
except IntegrityError:
await db.rollback()
logger.debug(
@ -163,10 +180,20 @@ async def get_or_create_session(
return await get_or_create_session(db, session, workspace_name, _retry=True)
else:
# Update existing session with metadata and feature flags if provided
if session.metadata is not None:
if (
session.metadata is not None
and honcho_session.h_metadata != session.metadata
):
honcho_session.h_metadata = session.metadata
needs_cache_update = True
if session.configuration is not None:
honcho_session.configuration = session.configuration
# Merge configuration instead of replacing to preserve existing keys
existing_config = (honcho_session.configuration or {}).copy()
incoming_config = session.configuration.model_dump(exclude_none=True)
merged_config = {**existing_config, **incoming_config}
if honcho_session.configuration != merged_config:
honcho_session.configuration = merged_config
needs_cache_update = True
# Add all peers to session
if session.peer_names:
@ -187,10 +214,16 @@ async def get_or_create_session(
await db.commit()
await db.refresh(honcho_session)
cache_key = session_cache_key(workspace_name, session.name)
await cache.set(
cache_key, honcho_session, expire=settings.CACHE.DEFAULT_TTL_SECONDS
)
# Only update cache if session data changed or was newly created
if needs_cache_update:
cache_key = session_cache_key(workspace_name, session.name)
await cache.set(
cache_key, honcho_session, expire=settings.CACHE.DEFAULT_TTL_SECONDS
)
logger.debug(
"Session %s cache updated in workspace %s", session.name, workspace_name
)
return honcho_session
@ -198,6 +231,8 @@ async def get_session(
db: AsyncSession,
session_name: str,
workspace_name: str,
*,
include_inactive: bool = False,
) -> models.Session:
"""
Get a session in a workspace.
@ -206,12 +241,14 @@ async def get_session(
db: Database session
session_name: Name of the session
workspace_name: Name of the workspace
include_inactive: If True, return sessions even if they are marked for deletion.
This should only be used for internal operations like the deletion task.
Returns:
The session
Raises:
ResourceNotFoundException: If the session does not exist
ResourceNotFoundException: If the session does not exist or is inactive
"""
session = await _fetch_session(db, workspace_name, session_name)
@ -220,6 +257,12 @@ async def get_session(
f"Session {session_name} not found in workspace {workspace_name}"
)
# Check if session is active (unless include_inactive is True)
if not include_inactive and not session.is_active:
raise ResourceNotFoundException(
f"Session {session_name} not found in workspace {workspace_name}"
)
# Merge cached object into session (cached objects are detached)
session = await db.merge(session, load=False)
@ -251,16 +294,36 @@ async def update_session(
db, schemas.SessionCreate(name=session_name), workspace_name=workspace_name
)
if session.metadata is not None:
# Track if anything changed
needs_update = False
if session.metadata is not None and honcho_session.h_metadata != session.metadata:
honcho_session.h_metadata = session.metadata
needs_update = True
if session.configuration is not None:
honcho_session.configuration = session.configuration
# Merge configuration instead of replacing to preserve existing keys
base_config = (honcho_session.configuration or {}).copy()
merged_config = {
**base_config,
**session.configuration.model_dump(exclude_none=True),
}
if honcho_session.configuration != merged_config:
honcho_session.configuration = merged_config
needs_update = True
if not needs_update:
logger.debug(
"Session %s unchanged in workspace %s, skipping update",
session_name,
workspace_name,
)
return honcho_session
await db.commit()
await db.refresh(honcho_session)
# Invalidate cache - read-through pattern
# Only invalidate if we actually updated
cache_key = session_cache_key(workspace_name, session_name)
await cache.delete(cache_key)
@ -268,11 +331,56 @@ async def update_session(
return honcho_session
async def _batch_delete_matching(
db: AsyncSession,
model: Any,
filter_conditions: list[Any],
batch_size: int = 5000,
) -> int:
"""
Delete records in batches that match the given filter conditions.
Args:
db: Database session
model: SQLAlchemy model class
filter_conditions: List of SQLAlchemy filter conditions
batch_size: Number of records to delete per batch
Returns:
Total number of records deleted
"""
total_deleted = 0
primary_key_column = model.__table__.primary_key.columns.values()[0]
while True:
subquery = (
select(primary_key_column).where(and_(*filter_conditions)).limit(batch_size)
)
delete_stmt = delete(model).where(primary_key_column.in_(subquery))
delete_result = await db.execute(delete_stmt)
batch_deleted = delete_result.rowcount or 0
total_deleted += batch_deleted
if batch_deleted == 0:
break
return total_deleted
async def delete_session(
db: AsyncSession, workspace_name: str, session_name: str
) -> bool:
"""
Mark a session as inactive (soft delete).
Delete a session and all associated data (hard delete).
This performs cascading deletes for all session-related data including:
- Active queue sessions
- Queue items
- Message embeddings (batched)
- Documents (theory-of-mind data, batched)
- Messages (batched)
- Session peer associations
- The session itself
Args:
db: Database session
@ -285,17 +393,83 @@ async def delete_session(
Raises:
ResourceNotFoundException: If the session does not exist
"""
honcho_session = await get_session(db, session_name, workspace_name)
honcho_session = await get_session(
db, session_name, workspace_name, include_inactive=True
)
honcho_session.is_active = False
await db.commit()
await db.refresh(honcho_session)
# Perform cascading deletes in order
# Order is important to avoid foreign key constraint violations
try:
# Delete ActiveQueueSession entries
# Work unit keys have format: {task_type}:{workspace_name}:{session_name}:{...}
await db.execute(
delete(models.ActiveQueueSession).where(
and_(
func.split_part(models.ActiveQueueSession.work_unit_key, ":", 2)
== workspace_name,
func.split_part(models.ActiveQueueSession.work_unit_key, ":", 3)
== session_name,
)
)
)
# Invalidate cache - read-through pattern
cache_key = session_cache_key(workspace_name, session_name)
await cache.delete(cache_key)
# Delete QueueItem entries
await db.execute(
delete(models.QueueItem).where(
models.QueueItem.session_id == honcho_session.id
)
)
# Delete MessageEmbedding entries in batches
await _batch_delete_matching(
db,
models.MessageEmbedding,
[
models.MessageEmbedding.session_name == session_name,
models.MessageEmbedding.workspace_name == workspace_name,
],
batch_size=5000,
)
# Delete Document entries associated with this session in batches
await _batch_delete_matching(
db,
models.Document,
[
models.Document.session_name == session_name,
models.Document.workspace_name == workspace_name,
],
batch_size=5000,
)
# Delete Message entries in batches
await _batch_delete_matching(
db,
models.Message,
[
models.Message.session_name == session_name,
models.Message.workspace_name == workspace_name,
],
batch_size=5000,
)
# Delete SessionPeer associations
await db.execute(
delete(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
)
)
# Finally, delete the session itself
await db.delete(honcho_session)
await db.commit()
logger.debug("Session %s and all associated data deleted", session_name)
except Exception as e:
logger.error("Failed to delete session %s: %s", session_name, e)
await db.rollback()
raise e
logger.debug("Session %s marked as inactive", session_name)
return True
@ -306,9 +480,17 @@ async def clone_session(
cutoff_message_id: str | None = None,
) -> models.Session:
"""
Clone a session and its messages. If cutoff_message_id is provided,
Clone a session and its data. If cutoff_message_id is provided,
only clone messages up to and including that message.
The following data is copied to the new session:
- Session metadata
- Session configuration
- All messages (or up to cutoff_message_id) with their content, metadata, and peer associations
- Session-peer associations with their configurations (observe_me, observe_others)
The new session gets a unique ID (nanoid) and fresh timestamps.
Args:
db: SQLAlchemy session
workspace_name: Name of the workspace the target session is in
@ -318,11 +500,12 @@ async def clone_session(
Returns:
The newly created session
"""
# Get the original session
# Get the original session (must be active)
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == original_session_name)
.where(models.Session.is_active == True) # noqa: E712
)
result = await db.execute(stmt)
original_session = result.scalar_one_or_none()
@ -347,6 +530,7 @@ async def clone_session(
workspace_name=workspace_name,
name=generate_nanoid(),
h_metadata=original_session.h_metadata,
configuration=original_session.configuration,
)
db.add(new_session)
await db.flush() # Flush to get the new session ID
@ -382,7 +566,7 @@ async def clone_session(
insert_stmt = insert(models.Message).returning(models.Message)
result = await db.execute(insert_stmt, new_messages)
# Clone peers from original session to new session
# Clone peers from original session to new session (including their configurations)
stmt = select(models.SessionPeer).where(
models.SessionPeer.session_name == original_session_name
)
@ -393,6 +577,7 @@ async def clone_session(
session_name=new_session.name,
peer_name=session_peer.peer_name,
workspace_name=workspace_name,
configuration=session_peer.configuration,
)
db.add(new_session_peer)

View File

@ -83,7 +83,7 @@ async def get_or_create_workspace(
honcho_workspace = models.Workspace(
name=workspace.name,
h_metadata=workspace.metadata,
configuration=workspace.configuration,
configuration=workspace.configuration.model_dump(exclude_none=True),
)
try:
db.add(honcho_workspace)
@ -172,16 +172,36 @@ async def update_workspace(
),
)
if workspace.metadata is not None:
# Track if anything changed
needs_update = False
if (
workspace.metadata is not None
and honcho_workspace.h_metadata != workspace.metadata
):
honcho_workspace.h_metadata = workspace.metadata
needs_update = True
if workspace.configuration is not None:
honcho_workspace.configuration = workspace.configuration
# Merge configuration instead of replacing to preserve existing keys
base_config = (honcho_workspace.configuration or {}).copy()
merged_config = {
**base_config,
**workspace.configuration.model_dump(exclude_none=True),
}
if honcho_workspace.configuration != merged_config:
honcho_workspace.configuration = merged_config
needs_update = True
# Early exit if unchanged
if not needs_update:
logger.debug("Workspace %s unchanged, skipping update", workspace_name)
return honcho_workspace
await db.commit()
await db.refresh(honcho_workspace)
# Invalidate cache
# Only invalidate if we actually updated
cache_key = workspace_cache_key(workspace_name)
await cache.delete(cache_key)

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