Merge branch 'main' into eri/dev-1430

This commit is contained in:
Eri Barrett 2026-04-23 15:53:02 -04:00 committed by GitHub
commit 9e0f24f387
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
260 changed files with 28351 additions and 9874 deletions

View File

@ -0,0 +1,117 @@
---
name: honcho-cli
description: Inspect and debug Honcho workspaces via the `honcho` CLI. Use when investigating peer representations, memory state, session context, queue status, or dialectic quality — any task that requires introspection of a Honcho deployment.
allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep
---
# Honcho CLI
`honcho` wraps the Honcho Python SDK with agent-friendly defaults: JSON output, structured errors, input validation. Use it to inspect workspace state, debug peer memory, and diagnose the dialectic.
## Output & config
- **TTY**: human-readable tables (default when interactive)
- **Piped / `--json`**: JSON — collection commands emit arrays, single-resource commands emit objects
- **Exit codes**: `0` success · `1` client error (bad input, not found) · `2` server error · `3` auth error
- **Config**: `~/.honcho/config.json` (shared with other Honcho tools). The CLI owns `apiKey` and `environmentUrl` at the top level; run `honcho init` to confirm or set them. Per-command scope (workspace / peer / session) is via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars.
## Command groups
- `honcho config` — CLI configuration
- `honcho workspace` — inspect, delete, search
- `honcho peer` — inspect, card, chat, search
- `honcho session` — inspect, messages, context, summaries
- `honcho message` — list and get
- `honcho conclusion` — list, search, create, delete
## Rules
- Always pass `--json` when processing output programmatically.
- Run `honcho peer inspect` before `honcho peer chat` to understand context.
- Use `honcho session context` to see exactly what an agent receives.
- Never run `honcho workspace delete` without `honcho workspace inspect` first.
- Check queue status when derivation seems stalled.
- Compare peer card with conclusions to understand memory state.
## Inspection tour
When orienting to a Honcho deployment, walk outside-in:
### 1. Understand the workspace
```bash
honcho workspace inspect --json
```
### 2. Find the peer
```bash
honcho peer list --json
honcho peer inspect <peer_id> --json
```
### 3. Check peer's memory
```bash
honcho peer card <peer_id> --json
honcho conclusion list --observer <peer_id> --json
honcho conclusion search "topic" --observer <peer_id> --json
```
### 4. Debug a session
```bash
honcho session inspect <session_id> --json
honcho message list <session_id> --last 20 --json
honcho session context <session_id> --json
honcho session summaries <session_id> --json
```
### 5. Search across workspace
```bash
honcho workspace search "query" --json
honcho peer search <peer_id> "query" --json
```
## Debugging playbook
### Peer not learning?
```bash
# Is observation enabled?
honcho peer inspect <peer_id> --json | jq '.configuration'
# Is the deriver queue processing messages?
honcho workspace queue-status --json
# What conclusions exist?
honcho conclusion list --observer <peer_id> --json
honcho conclusion search "expected topic" --observer <peer_id> --json
```
### Session context looks wrong?
```bash
# Raw context an agent would receive
honcho session context <session_id> --json
# Summaries feeding the context
honcho session summaries <session_id> --json
# Recent message history
honcho message list <session_id> --last 50 --json
```
### Dialectic giving bad answers?
```bash
# What the peer card says
honcho peer card <peer_id> --json
# Conclusions on the specific topic
honcho conclusion search "topic" --observer <peer_id> --json
# Exercise the dialectic directly
honcho peer chat <peer_id> "what do you know about X?" --json
```

View File

@ -91,6 +91,8 @@ Based on interview responses, implement the integration:
### Phase 4: Verification
- If the Honcho CLI is available, run `honcho doctor` to confirm connectivity before testing the integration code
- Use `honcho peer list` and `honcho peer chat` to verify peers exist and the dialectic endpoint works independently of the integration
- Ensure all message exchanges are stored to Honcho
- Verify AI peers have `observe_me=False` (unless user specifically wants AI observation)
- Check that the workspace ID is consistent across the codebase
@ -106,6 +108,16 @@ Based on interview responses, implement the integration:
2. **Get an API key** ask the user to get a Honcho API key from <https://app.honcho.dev> and add it to the environment.
3. **Verify with the CLI** (optional but recommended). If the user has the Honcho CLI installed (`pip install honcho-cli`), they can validate their setup before writing any integration code:
```bash
honcho init # persist API key + URL to ~/.honcho/config.json
honcho doctor # verify connectivity, config, workspace health
honcho peer chat # test the dialectic endpoint interactively
```
This is the fastest way to confirm the API key and URL are correct before debugging SDK code.
## Installation
### Python (use uv)
@ -139,8 +151,8 @@ response = peer.chat("What does this user prefer?")
# Async usage (FastAPI, Starlette)
from honcho import Honcho
honcho = Honcho(workspace_id="my-app", api_key=os.environ["HONCHO_API_KEY"])
peer = honcho.aio.peer("user-123")
response = await peer.chat("What does this user prefer?")
peer = await honcho.aio.peer("user-123")
response = await peer.aio.chat("What does this user prefer?")
```
Match the client to the framework — check whether the codebase uses `async def` handlers or sync `def` handlers and choose accordingly. The rest of this skill shows sync Python examples; swap to `.aio` equivalents for async codebases.
@ -188,7 +200,7 @@ Create peers for **every entity** in your business logic - users AND AI assistan
**Python:**
```python
from honcho import PeerConfig
from honcho.api_types import PeerConfig
# Human users
user = honcho.peer("user-123")
@ -524,6 +536,8 @@ When integrating Honcho into an existing codebase:
- [ ] Pre-fetch pattern for simpler integrations
- [ ] context() for conversation history
- [ ] Store messages after each exchange to build user models
- [ ] (Optional) Run `honcho doctor` to verify connectivity before testing integration code
- [ ] (Optional) Use `honcho peer chat` to test dialectic queries independently
## Common Mistakes to Avoid

View File

@ -13,7 +13,7 @@ from nanobot.honcho.client import get_honcho_client
if TYPE_CHECKING:
from honcho import Honcho
from honcho.session import SessionPeerConfig
from honcho.api_types import SessionPeerConfig
@dataclass
@ -101,7 +101,7 @@ class HonchoSessionManager:
"""
Get or create a Honcho peer.
Peers are lazy -- no API call until first use.
As of v2.1.0, peer() always makes a get-or-create API call.
Observation settings are controlled per-session via SessionPeerConfig.
Args:
@ -138,7 +138,7 @@ class HonchoSessionManager:
session = self.honcho.session(session_id)
# Configure peer observation settings
from honcho.session import SessionPeerConfig
from honcho.api_types import SessionPeerConfig
user_config = SessionPeerConfig(observe_me=True, observe_others=True)
ai_config = SessionPeerConfig(observe_me=False, observe_others=True)

View File

@ -476,3 +476,132 @@ from honcho.api_types import SessionPeerConfig
```
**Note:** `MessageCreateParam` (singular) is now `MessageCreateParams` (plural).
---
## 14. Card Method Deprecation and set_card (v2.0.1)
### Before (v2.0.0)
```python
card: list[str] | None = peer.card()
```
### After (v2.0.1+)
```python
# get_card() is the preferred method
card: list[str] | None = peer.get_card()
# card() still works but emits a deprecation warning
card = peer.card() # Deprecated
# New: set_card()
updated = peer.set_card(["Fact 1", "Fact 2"])
updated = peer.set_card(["Fact 1"], target="other-peer")
# Async variants
card = await peer.aio.get_card()
await peer.aio.set_card(["Fact 1"])
```
---
## 15. Strict Input Validation (v2.0.2)
All Pydantic input models now use `extra="forbid"`, raising `ValidationError` for unknown fields.
```python
from honcho.api_types import PeerConfig
# This now raises ValidationError instead of silently ignoring the typo
PeerConfig(observe_mee=True) # ValidationError: extra fields not permitted
```
---
## 16. peer() and session() Always Make API Calls (v2.1.0)
### Before (v2.0.x)
```python
# Without options: lazy object, no API call
peer = client.peer("user-123")
# peer.created_at was None
# With options: made API call
peer = client.peer("user-123", metadata={"key": "value"})
```
### After (v2.1.0+)
```python
# Always makes a get-or-create API call
peer = client.peer("user-123")
# peer.created_at is now always populated
# Async
peer = await client.aio.peer("user-123")
```
All Peer/Session objects now have `created_at` populated immediately after construction.
---
## 17. New Properties: created_at, is_active (v2.1.0)
```python
# Peer
peer = client.peer("user-123")
print(peer.created_at) # datetime | None
# Session
session = client.session("sess-1")
print(session.created_at) # datetime | None
print(session.is_active) # bool | None
# These are refreshed by get_metadata(), get_configuration(), and refresh()
peer.refresh()
session.refresh()
```
---
## 18. get_message() on Session (v2.1.0)
```python
# Fetch a single message by ID
msg = session.get_message("msg-abc123")
print(msg.content, msg.created_at)
# Async
msg = await session.aio.get_message("msg-abc123")
```
---
## 19. Pagination Parameters (v2.1.0)
All list methods now accept `page`, `size`, and `reverse`:
```python
# Defaults: page=1, size=50, reverse=False
peers_page = client.peers(page=2, size=25, reverse=True)
# Returns SyncPage / AsyncPage with:
print(peers_page.total) # Total items
print(peers_page.pages) # Total pages
print(peers_page.has_next_page())
# Works on:
# client.peers(), client.sessions()
# peer.sessions()
# session.messages()
# scope.list()
```
---
## 20. Broader HTTP Retry Logic (v2.1.1)
The SDK now catches `httpx.NetworkError` and `httpx.RemoteProtocolError` for retry in addition to `httpx.TimeoutException` and `httpx.ConnectError`. This is transparent — no code changes needed.

View File

@ -4,7 +4,7 @@ Use this checklist to track migration progress. Copy into your working notes and
## Dependencies
- [ ] Update `honcho` package to v2.0.0
- [ ] Update `honcho` package to v2.1.1
- [ ] Remove any `honcho-core` imports
## Async Architecture Changes
@ -113,6 +113,39 @@ Use this checklist to track migration progress. Copy into your working notes and
- `UnprocessableEntityError`, `RateLimitError`, `ServerError`
- `TimeoutError`, `ConnectionError`
## Card Method Updates (v2.0.1)
- [ ] Replace `peer.card()` with `peer.get_card()` (card() is deprecated)
- [ ] Use `peer.set_card(list[str])` if setting peer cards
## Strict Validation (v2.0.2)
- [ ] Verify no input models pass unknown/misspelled fields (now raises `ValidationError`)
- [ ] Check for typos in `PeerConfig`, `SessionConfiguration`, `WorkspaceConfiguration` fields
## peer() / session() API Call Change (v2.1.0)
- [ ] Update code that relied on lazy `peer()` / `session()` — they now always make API calls
- [ ] Add `await` if using async and previously didn't need it for lazy construction
## New Properties (v2.1.0)
- [ ] Use `peer.created_at` / `session.created_at` where creation time is needed
- [ ] Use `session.is_active` where session active status is needed
## New Methods (v2.1.0)
- [ ] Use `session.get_message(message_id)` to fetch single messages by ID
## Pagination Parameters (v2.1.0)
- [ ] Add `page`, `size`, `reverse` parameters to list calls where needed:
- [ ] `client.peers()`
- [ ] `client.sessions()`
- [ ] `peer.sessions()`
- [ ] `session.messages()`
- [ ] `scope.list()`
## Final Verification
- [ ] Run type checker (mypy/pyright) with no errors

View File

@ -1,13 +1,13 @@
---
name: migrate-honcho
description: Migrates Honcho Python SDK code from v1.6.0 to v2.0.0. Use when upgrading honcho package, fixing breaking changes after upgrade, or when errors mention AsyncHoncho, observations, Representation class, .core property, or get_config methods.
description: Migrates Honcho Python SDK code from v1.6.0 to v2.1.1. Use when upgrading honcho package, fixing breaking changes after upgrade, or when errors mention AsyncHoncho, observations, Representation class, .core property, or get_config methods.
---
# Honcho Python SDK Migration (v1.6.0 → v2.0.0)
# Honcho Python SDK Migration (v1.6.0 → v2.1.1)
## Overview
This skill migrates code from `honcho` Python SDK v1.6.0 to v2.0.0 (required for Honcho 3.0.0+).
This skill migrates code from `honcho` Python SDK v1.6.0 to v2.1.1 (required for Honcho 3.0.0+).
**Key breaking changes:**
@ -184,18 +184,92 @@ updated = client.update_message(message=msg, metadata={"key": "value"}, session=
updated = session.update_message(message=msg, metadata={"key": "value"})
```
### 10. Update card() return type
### 10. Update card() return type and method name
```python
# Before
card: str = peer.card() # Returns str
# After
card: list[str] | None = peer.card() # Returns list[str] | None
# After (v2.0.0+)
card: list[str] | None = peer.get_card() # Returns list[str] | None
if card:
print("\n".join(card))
# peer.card() still works but is deprecated — use get_card()
# New in v2.0.1: set_card()
peer.set_card(["Prefers dark mode", "Located in US"])
```
### 11. Strict input validation (v2.0.2+)
All input models now reject unknown fields via `extra="forbid"` Pydantic validation. Previously, misspelled or extraneous fields were silently ignored.
```python
# Before (v2.0.1 and earlier) — silently ignored
peer = client.peer("user-1", configuration=PeerConfig(observe_mee=True)) # typo silently ignored
# After (v2.0.2+) — raises ValidationError
peer = client.peer("user-1", configuration=PeerConfig(observe_mee=True)) # ValidationError!
```
### 12. peer() and session() always make API calls (v2.1.0+)
**Breaking**: `peer()` and `session()` now always make a get-or-create API call. Previously, calling without metadata/configuration returned a lazy object with no API call.
```python
# Before (v2.0.x) — no API call without options
peer = client.peer("user-123") # Lazy, no network request
# After (v2.1.0+) — always hits the API
peer = client.peer("user-123") # Makes POST to /peers (get-or-create)
# Async
peer = await client.aio.peer("user-123") # Also always hits API
```
### 13. New properties and methods (v2.1.0+)
```python
# created_at on Peer and Session
peer = client.peer("user-123")
print(peer.created_at) # datetime | None
session = client.session("sess-1")
print(session.created_at) # datetime | None
# is_active on Session
print(session.is_active) # bool | None
# get_message() on Session
msg = session.get_message("msg-id")
# Async: msg = await session.aio.get_message("msg-id")
```
### 14. Pagination parameters on list methods (v2.1.0+)
All list methods now accept `page`, `size`, and `reverse` parameters:
```python
# Before (v2.0.x) — only filters
peers_page = client.peers(filters={"metadata": {"role": "admin"}})
# After (v2.1.0+) — pagination controls
peers_page = client.peers(
filters={"metadata": {"role": "admin"}},
page=2,
size=25,
reverse=True
)
# Works on: client.peers(), client.sessions(), peer.sessions(),
# session.messages(), scope.list()
```
### 15. Broader HTTP retry logic (v2.1.1+)
The SDK now retries on `httpx.TimeoutException`, `httpx.NetworkError`, and `httpx.RemoteProtocolError` (previously only `httpx.TimeoutException` and `httpx.ConnectError`). These are mapped to the SDK's `TimeoutError` and `ConnectionError` respectively. No code changes needed — this is transparent.
## Quick Reference Table
| v1.6.0 | v2.0.0 |
@ -222,6 +296,8 @@ if card:
| `.get_peer_config()` | `.get_peer_configuration()` |
| `.set_peer_config()` | `.set_peer_configuration()` |
| `client.update_message()` | `session.update_message()` |
| `peer.card()` | `peer.get_card()` *(card() deprecated)* |
| *(new)* | `peer.set_card(list[str])` |
| `chat(stream=True)` | `chat_stream()` |
| `include_most_derived=` | `include_most_frequent=` |
| `max_observations=` | `max_conclusions=` |
@ -230,6 +306,10 @@ if card:
| `PeerContext` | `PeerContextResponse` |
| `DeriverStatus` | `QueueStatusResponse` |
| `client.core` | *(removed)* |
| *(new v2.1.0)* | `peer.created_at` / `session.created_at` |
| *(new v2.1.0)* | `session.is_active` |
| *(new v2.1.0)* | `session.get_message(id)` |
| *(new v2.1.0)* | `page=`, `size=`, `reverse=` on list methods |
## Detailed Reference

View File

@ -432,3 +432,152 @@ interface SummaryData {
tokenCount: number
}
```
---
## Post-v2.0.0 Changes
---
## Card Method Deprecation and setCard (v2.0.1)
### Before (v2.0.0)
```typescript
const card = await peer.card(target) // string[] | null
```
### After (v2.0.1+)
```typescript
// getCard() is the preferred method
const card = await peer.getCard(target) // string[] | null
// card() still works but is deprecated
const card = await peer.card(target) // Deprecated
// New: setCard()
const updated = await peer.setCard(['Fact 1', 'Fact 2'])
const updated = await peer.setCard(['Fact 1'], targetPeer)
```
---
## Strict Input Validation (v2.0.2)
Client constructor and all input schemas now use `.strict()` Zod validation.
```typescript
// Before (v2.0.1) — silently ignored
const honcho = new Honcho({ baseUrl: 'http://...' }) // typo fell back to default
// After (v2.0.2+) — ZodError thrown
const honcho = new Honcho({ baseUrl: 'http://...' }) // ZodError: Unrecognized key "baseUrl"
```
---
## peer() and session() Always Make API Calls (v2.1.0)
### Before (v2.0.x)
```typescript
// Without options: lazy object, no API call
const peer = honcho.peer('user-123')
// With options: made API call
const peer = await honcho.peer('user-123', { metadata: { key: 'value' } })
```
### After (v2.1.0+)
```typescript
// Always makes a get-or-create API call
const peer = await honcho.peer('user-123')
// peer.createdAt is now always populated
```
---
## New Properties: createdAt, isActive (v2.1.0)
```typescript
// Peer
const peer = await honcho.peer('user-123')
console.log(peer.createdAt) // string | undefined
// Session
const session = await honcho.session('sess-1')
console.log(session.createdAt) // string | undefined
console.log(session.isActive) // boolean | undefined
// Refreshed by getMetadata(), getConfiguration(), and refresh()
await session.refresh()
```
---
## getMessage() on Session (v2.1.0)
```typescript
// Fetch a single message by ID
const msg = await session.getMessage('msg-abc123')
console.log(msg.content, msg.createdAt)
```
---
## Pagination Parameters (v2.1.0)
All list methods now accept `page`, `size`, and `reverse`:
```typescript
// Defaults: page=1, size=50, reverse=false
const peersPage = await honcho.peers({
filters: { metadata: { role: 'admin' } },
page: 2,
size: 25,
reverse: true
})
// Page<T> properties:
console.log(peersPage.total) // Total items
console.log(peersPage.pages) // Total pages
console.log(peersPage.hasNextPage) // boolean
// Works on:
// honcho.peers(), honcho.sessions(), honcho.workspaces()
// peer.sessions()
// session.messages()
// scope.list()
```
---
## searchQuery Moved in context() (v2.1.0)
### Before (v2.0.x)
```typescript
const ctx = await session.context({
searchQuery: 'What are my preferences?',
representationOptions: { maxConclusions: 50 }
})
```
### After (v2.1.0+)
```typescript
const ctx = await session.context({
representationOptions: {
searchQuery: 'What are my preferences?',
maxConclusions: 50
}
})
```
---
## Broader Fetch Retry Logic (v2.1.1)
The SDK now retries on all `TypeError` network failures (connection resets, DNS errors, etc.) instead of only those containing `'fetch'` in the error message. This is transparent — no code changes needed.

View File

@ -5,7 +5,7 @@ Use this checklist to track migration progress. Copy into your working notes and
## Dependencies
- [ ] Remove `@honcho-ai/core` from dependencies
- [ ] Update `@honcho-ai/sdk` to v2.0.0
- [ ] Update `@honcho-ai/sdk` to v2.1.1
## Client-Level Changes
@ -101,6 +101,44 @@ Use this checklist to track migration progress. Copy into your working notes and
- [ ] Remove usage of `Representation` class methods (`.explicit`, `.deductive`, `.isEmpty()`, `.diff()`)
- [ ] Handle representation as plain string
## Card Method Updates (v2.0.1)
- [ ] Replace `peer.card()` with `peer.getCard()` (card() is deprecated)
- [ ] Use `peer.setCard(string[])` if setting peer cards
## Strict Validation (v2.0.2)
- [ ] Verify no constructor options or input schemas pass unknown/misspelled fields (now throws `ZodError`)
- [ ] Check for `baseUrl` vs `baseURL` typo in Honcho constructor
## peer() / session() API Call Change (v2.1.0)
- [ ] Update code that relied on lazy `peer()` / `session()` — they now always make API calls
- [ ] Ensure all `peer()` and `session()` calls are `await`ed
## New Properties (v2.1.0)
- [ ] Use `peer.createdAt` / `session.createdAt` where creation time is needed
- [ ] Use `session.isActive` where session active status is needed
## New Methods (v2.1.0)
- [ ] Use `session.getMessage(messageId)` to fetch single messages by ID
## Pagination Parameters (v2.1.0)
- [ ] Add `page`, `size`, `reverse` parameters to list calls where needed:
- [ ] `honcho.peers()`
- [ ] `honcho.sessions()`
- [ ] `honcho.workspaces()`
- [ ] `peer.sessions()`
- [ ] `session.messages()`
- [ ] `scope.list()`
## searchQuery Location Change (v2.1.0)
- [ ] Move `searchQuery` from top-level `context()` options to `representationOptions.searchQuery`
## Final Verification
- [ ] Run TypeScript compiler with no errors

View File

@ -1,13 +1,13 @@
---
name: migrate-honcho-ts
description: Migrates Honcho TypeScript SDK code from v1.6.0 to v2.0.0. Use when upgrading @honcho-ai/sdk, fixing breaking changes after upgrade, or when errors mention removed APIs like .core, getConfig, observations, or snake_case properties.
description: Migrates Honcho TypeScript SDK code from v1.6.0 to v2.1.1. Use when upgrading @honcho-ai/sdk, fixing breaking changes after upgrade, or when errors mention removed APIs like .core, getConfig, observations, or snake_case properties.
---
# Honcho TypeScript SDK Migration (v1.6.0 → v2.0.0)
# Honcho TypeScript SDK Migration (v1.6.0 → v2.1.1)
## Overview
This skill migrates code from `@honcho-ai/sdk` v1.6.0 to v2.0.0 (required for Honcho 3.0.0+).
This skill migrates code from `@honcho-ai/sdk` v1.6.0 to v2.1.1 (required for Honcho 3.0.0+).
**Key breaking changes:**
@ -148,6 +148,101 @@ await honcho.updateMessage(message, metadata, session)
await session.updateMessage(message, metadata)
```
### 11. Update card() to getCard() (v2.0.1+)
```typescript
// Before
const card = await peer.card(target)
// After (v2.0.1+)
const card = await peer.getCard(target) // Returns string[] | null
// peer.card() still works but is deprecated — use getCard()
// New: setPeerCard / setCard
await peer.setCard(['Prefers dark mode', 'Located in US'])
```
### 12. Strict input validation (v2.0.2+)
Client constructor and all input schemas now reject unknown options via `.strict()` Zod validation.
```typescript
// Before (v2.0.1 and earlier) — silently ignored
const honcho = new Honcho({ baseUrl: 'http://...' }) // typo: baseUrl vs baseURL — silently fell back to default
// After (v2.0.2+) — throws ZodError
const honcho = new Honcho({ baseUrl: 'http://...' }) // ZodError! Use baseURL
```
### 13. peer() and session() always make API calls (v2.1.0+)
**Breaking**: `peer()` and `session()` now always make a get-or-create API call. Previously, calling without metadata/configuration returned a lazy object with no API call.
```typescript
// Before (v2.0.x) — no API call without options
const session = honcho.session('my-session') // Lazy, no network request
// After (v2.1.0+) — always hits the API
const session = await honcho.session('my-session') // Makes POST to /sessions (get-or-create)
```
### 14. New properties and methods (v2.1.0+)
```typescript
// createdAt on Peer and Session
const peer = await honcho.peer('user-123')
console.log(peer.createdAt) // string | undefined
const session = await honcho.session('sess-1')
console.log(session.createdAt) // string | undefined
// isActive on Session
console.log(session.isActive) // boolean | undefined
// getMessage() on Session
const msg = await session.getMessage('msg-id')
```
### 15. Pagination parameters on list methods (v2.1.0+)
All list methods now accept `page`, `size`, and `reverse` parameters:
```typescript
// Before (v2.0.x) — only filters
const peers = await honcho.peers({ metadata: { role: 'admin' } })
// After (v2.1.0+) — pagination controls via options object
const peers = await honcho.peers({
filters: { metadata: { role: 'admin' } },
page: 2,
size: 25,
reverse: true
})
// Legacy raw-filter form still works:
const peers = await honcho.peers({ metadata: { role: 'admin' } })
// Works on: honcho.peers(), honcho.sessions(), honcho.workspaces(),
// peer.sessions(), session.messages(), scope.list()
```
### 16. searchQuery moved in context() (v2.1.0+)
**Breaking**: `searchQuery` removed from top-level `context()` options. Use `representationOptions.searchQuery` instead.
```typescript
// Before (v2.0.x)
await session.context({ searchQuery: '...' })
// After (v2.1.0+)
await session.context({ representationOptions: { searchQuery: '...' } })
```
### 17. Broader fetch retry logic (v2.1.1+)
The SDK now retries on all `TypeError` network failures (connection resets, DNS errors, etc.) instead of only those with `'fetch'` in the message. No code changes needed — this is transparent.
## Quick Reference Table
| v1.6.0 | v2.0.0 |
@ -172,15 +267,22 @@ await session.updateMessage(message, metadata)
| `session.workingRep()` | `session.representation()` |
| `session.peerConfig()` | `session.getPeerConfiguration()` |
| `session.setPeerConfig()` | `session.setPeerConfiguration()` |
| `{ timeoutMs: 60000 }` | `{ timeout: 60 }` |
| `{ timeoutMs: 60000 }` | `{ timeout: 60000 }` |
| `{ maxObservations: 50 }` | `{ maxConclusions: 50 }` |
| `{ includeMostDerived }` | `{ includeMostFrequent }` |
| `{ lastUserMessage }` | `{ searchQuery }` |
| `{ config: ... }` | `{ configuration: ... }` |
| `message.peer_id` | `message.peerId` |
| `message.created_at` | `message.createdAt` |
| `peer.card()` | `peer.getCard()` *(card() deprecated)* |
| *(new)* | `peer.setCard(string[])` |
| `Observation` | `Conclusion` |
| `ObservationScope` | `ConclusionScope` |
| *(new v2.1.0)* | `peer.createdAt` / `session.createdAt` |
| *(new v2.1.0)* | `session.isActive` |
| *(new v2.1.0)* | `session.getMessage(id)` |
| *(new v2.1.0)* | `page`, `size`, `reverse` on list methods |
| `context({ searchQuery })` | `context({ representationOptions: { searchQuery } })` |
## Detailed Reference

View File

@ -15,8 +15,13 @@ LOG_LEVEL=INFO
# Embedding settings
# EMBED_MESSAGES=true
# MAX_EMBEDDING_TOKENS=8192
# MAX_EMBEDDING_TOKENS_PER_REQUEST=300000
# EMBEDDING_VECTOR_DIMENSIONS=1536
# EMBEDDING_MAX_INPUT_TOKENS=8192
# EMBEDDING_MAX_TOKENS_PER_REQUEST=300000
# EMBEDDING_MODEL_CONFIG__TRANSPORT=openai
# EMBEDDING_MODEL_CONFIG__MODEL=text-embedding-3-small
# EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=
# EMBEDDING_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=
# LANGFUSE_HOST=
# LANGFUSE_PUBLIC_KEY=
@ -32,7 +37,7 @@ LOG_LEVEL=INFO
# =============================================================================
# Connection URI for PostgreSQL database with pgvector support
# Must use postgresql+psycopg prefix for SQLAlchemy compatibility
DB_CONNECTION_URI=postgresql+psycopg://testuser:testpwd@localhost:5432/honcho
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
# Optional database settings
# DB_SCHEMA=public
@ -57,160 +62,160 @@ AUTH_USE_AUTH=false
# AUTH_JWT_SECRET=your-secret-key-here
# =============================================================================
# LLM API Keys (REQUIRED for full functionality)
# LLM Provider (REQUIRED)
# =============================================================================
# OpenAI API key for embeddings
LLM_OPENAI_API_KEY=your-openai-api-key-here
# Anthropic API key for dialectic and deriver functionality
LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here
# Google API key for summarization (if using Gemini)
# LLM_GEMINI_API_KEY=your-google-api-key-here
# Groq API key for query generation (if using Groq)
# LLM_GROQ_API_KEY=your-groq-api-key-here
# Base URL for OpenAI Compatible Requests if you want to use a different provider
# LLM_OPENAI_COMPATIBLE_BASE_URL=
# LLM_OPENAI_COMPATIBLE_API_KEY=
# Separate vLLM endpoint (for local models)
# LLM_VLLM_API_KEY=
# LLM_VLLM_BASE_URL=
# Honcho uses LLMs for memory extraction, summarization, dialectic chat, and
# dream consolidation. The server will fail to start without a provider configured.
#
# Quick start: set LLM_OPENAI_API_KEY below to use the built-in defaults.
# Text-generation features default to transport = "openai" and
# model = "gpt-5.4-mini". Embeddings default to transport = "openai" and
# model = "text-embedding-3-small". For OpenAI-compatible proxies
# (OpenRouter, Together, Fireworks, vLLM, Ollama, LiteLLM), override
# MODEL_CONFIG__MODEL and MODEL_CONFIG__OVERRIDES__BASE_URL on each feature
# section you want to route through that endpoint.
# Models must support tool calling (function calling).
#
# Supported transports: openai, anthropic, gemini
# Each transport picks up its API key from the corresponding LLM_*_API_KEY.
# Base URLs are set per-module via MODEL_CONFIG__OVERRIDES__BASE_URL.
#
LLM_OPENAI_API_KEY=your-api-key-here
# LLM_ANTHROPIC_API_KEY=
# LLM_GEMINI_API_KEY=
# =============================================================================
# LLM Configuration
# =============================================================================
# Global LLM settings
# LLM_DEFAULT_MAX_TOKENS=2500
# LLM_EMBEDDING_PROVIDER=openai
# LLM_MAX_TOOL_OUTPUT_CHARS=10000 # Max chars for tool output (~2500 tokens)
# LLM_MAX_MESSAGE_CONTENT_CHARS=2000 # Max chars per message in tool results
# =============================================================================
# Deriver (Background Worker) Settings
# Deriver (Background Worker)
# =============================================================================
# DERIVER_ENABLED=true
# Defaults:
# DERIVER_MODEL_CONFIG__TRANSPORT=openai
# DERIVER_MODEL_CONFIG__MODEL=gpt-5.4-mini
# Optional overrides:
# DERIVER_MODEL_CONFIG__MODEL=your-model-here
# DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
# DERIVER_WORKERS=1
# DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
# DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
# DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # 30 days
# DERIVER_PROVIDER=google
# DERIVER_MODEL=gemini-2.5-flash-lite
# DERIVER_TEMPERATURE=
# DERIVER_MODEL_CONFIG__TEMPERATURE=
# DERIVER_MODEL_CONFIG__THINKING_EFFORT=minimal
# DERIVER_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 # Gemini/Anthropic only
# DERIVER_DEDUPLICATE=true
# DERIVER_MAX_OUTPUT_TOKENS=4096
# DERIVER_THINKING_BUDGET_TOKENS=1024
# DERIVER_MODEL_CONFIG__MAX_OUTPUT_TOKENS=4096
# DERIVER_LOG_OBSERVATIONS=false
# DERIVER_MAX_INPUT_TOKENS=23000
# DERIVER_WORKING_REPRESENTATION_MAX_OBSERVATIONS=100
# DERIVER_REPRESENTATION_BATCH_MAX_TOKENS=1024
# DERIVER_FLUSH_ENABLED=false # Bypass batch token threshold, process work immediately
# DERIVER_BACKUP_PROVIDER=
# DERIVER_BACKUP_MODEL=
# DERIVER_MODEL_CONFIG__FALLBACK__MODEL=
# DERIVER_MODEL_CONFIG__FALLBACK__TRANSPORT=
# DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=
# DERIVER_MODEL_CONFIG__OVERRIDES__API_KEY_ENV=
# =============================================================================
# Peer Card Configuration
# Peer Card
# =============================================================================
# PEER_CARD_ENABLED=true
# =============================================================================
# Dialectic Settings
# Dialectic
# =============================================================================
# Global dialectic settings
# DIALECTIC_MAX_OUTPUT_TOKENS=8192
# DIALECTIC_MAX_INPUT_TOKENS=100000
# DIALECTIC_HISTORY_TOKEN_LIMIT=8192
# DIALECTIC_SESSION_HISTORY_MAX_TOKENS=4096
#
# Per-level settings (reasoning_level parameter in API)
# Each level can have its own provider, model, thinking budget, tool iterations, and max output tokens
# MAX_OUTPUT_TOKENS is optional per level; if not set, uses global DIALECTIC_MAX_OUTPUT_TOKENS
# Minimal level
# DIALECTIC_LEVELS__minimal__PROVIDER=google
# DIALECTIC_LEVELS__minimal__MODEL=gemini-2.5-flash-lite
# DIALECTIC_LEVELS__minimal__THINKING_BUDGET_TOKENS=0
# Each level has its own nested MODEL_CONFIG, tool iterations, and max output tokens.
# MAX_OUTPUT_TOKENS is optional per level; if not set, uses global DIALECTIC_MAX_OUTPUT_TOKENS.
# Defaults:
# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__TRANSPORT=openai
# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=gpt-5.4-mini
# DIALECTIC_LEVELS__minimal__MAX_TOOL_ITERATIONS=1
# DIALECTIC_LEVELS__minimal__MAX_OUTPUT_TOKENS=250 # Reduced output for cost savings
# Low level
# DIALECTIC_LEVELS__low__PROVIDER=google
# DIALECTIC_LEVELS__low__MODEL=gemini-2.5-flash-lite
# DIALECTIC_LEVELS__low__THINKING_BUDGET_TOKENS=0
# DIALECTIC_LEVELS__minimal__MAX_OUTPUT_TOKENS=250
# DIALECTIC_LEVELS__minimal__TOOL_CHOICE=any
# DIALECTIC_LEVELS__low__MODEL_CONFIG__TRANSPORT=openai
# DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL=gpt-5.4-mini
# DIALECTIC_LEVELS__low__MAX_TOOL_ITERATIONS=5
# DIALECTIC_LEVELS__low__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
# Medium level
# DIALECTIC_LEVELS__medium__PROVIDER=anthropic
# DIALECTIC_LEVELS__medium__MODEL=claude-haiku-4-5
# DIALECTIC_LEVELS__medium__THINKING_BUDGET_TOKENS=1024
# DIALECTIC_LEVELS__low__TOOL_CHOICE=any
# DIALECTIC_LEVELS__medium__MODEL_CONFIG__TRANSPORT=openai
# DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL=gpt-5.4-mini
# DIALECTIC_LEVELS__medium__MAX_TOOL_ITERATIONS=2
# DIALECTIC_LEVELS__medium__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
# DIALECTIC_LEVELS__medium__TOOL_CHOICE=
# High level
# DIALECTIC_LEVELS__high__PROVIDER=anthropic
# DIALECTIC_LEVELS__high__MODEL=claude-haiku-4-5
# DIALECTIC_LEVELS__high__THINKING_BUDGET_TOKENS=1024
# DIALECTIC_LEVELS__high__MODEL_CONFIG__TRANSPORT=openai
# DIALECTIC_LEVELS__high__MODEL_CONFIG__MODEL=gpt-5.4-mini
# DIALECTIC_LEVELS__high__MAX_TOOL_ITERATIONS=4
# DIALECTIC_LEVELS__high__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
# Max level
# DIALECTIC_LEVELS__max__PROVIDER=anthropic
# DIALECTIC_LEVELS__max__MODEL=claude-haiku-4-5
# DIALECTIC_LEVELS__max__THINKING_BUDGET_TOKENS=2048
# DIALECTIC_LEVELS__max__MODEL_CONFIG__TRANSPORT=openai
# DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=gpt-5.4-mini
# DIALECTIC_LEVELS__max__MAX_TOOL_ITERATIONS=10
# DIALECTIC_LEVELS__max__MAX_OUTPUT_TOKENS=8192 # Optional: override global default
# Optional overrides:
# DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL=your-model-here
# DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL=your-model-here
# DIALECTIC_LEVELS__medium__MODEL_CONFIG__MODEL=your-model-here
# DIALECTIC_LEVELS__high__MODEL_CONFIG__MODEL=your-model-here
# DIALECTIC_LEVELS__max__MODEL_CONFIG__MODEL=your-model-here
# DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_EFFORT=medium
# DIALECTIC_LEVELS__max__MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024
# Optional backup per level (must set both or neither):
# DIALECTIC_LEVELS__max__BACKUP_PROVIDER=google
# DIALECTIC_LEVELS__max__BACKUP_MODEL=gemini-2.5-pro
# DIALECTIC_LEVELS__max__MODEL_CONFIG__FALLBACK__MODEL=gemini-2.5-pro
# DIALECTIC_LEVELS__max__MODEL_CONFIG__FALLBACK__TRANSPORT=gemini
# =============================================================================
# Summary Settings
# Summary
# =============================================================================
# SUMMARY_ENABLED=true
# Defaults:
# SUMMARY_MODEL_CONFIG__TRANSPORT=openai
# SUMMARY_MODEL_CONFIG__MODEL=gpt-5.4-mini
# Optional overrides:
# SUMMARY_MODEL_CONFIG__MODEL=your-model-here
# SUMMARY_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
# SUMMARY_MODEL_CONFIG__THINKING_EFFORT=minimal
# SUMMARY_MODEL_CONFIG__THINKING_BUDGET_TOKENS=1024 # Gemini/Anthropic only
# SUMMARY_MESSAGES_PER_SHORT_SUMMARY=20
# SUMMARY_MESSAGES_PER_LONG_SUMMARY=60
# SUMMARY_PROVIDER=google
# SUMMARY_MODEL=gemini-2.5-flash
# SUMMARY_MAX_TOKENS_SHORT=1000
# SUMMARY_MAX_TOKENS_LONG=4000
# SUMMARY_THINKING_BUDGET_TOKENS=512
# SUMMARY_BACKUP_PROVIDER=
# SUMMARY_BACKUP_MODEL=
# SUMMARY_MODEL_CONFIG__FALLBACK__MODEL=
# =============================================================================
# Dream Settings
# Dream
# =============================================================================
# DREAM_ENABLED=true
# Defaults:
# DREAM_DEDUCTION_MODEL_CONFIG__TRANSPORT=openai
# DREAM_DEDUCTION_MODEL_CONFIG__MODEL=gpt-5.4-mini
# DREAM_INDUCTION_MODEL_CONFIG__TRANSPORT=openai
# DREAM_INDUCTION_MODEL_CONFIG__MODEL=gpt-5.4-mini
# Optional overrides:
# DREAM_DEDUCTION_MODEL_CONFIG__MODEL=your-model-here
# DREAM_DEDUCTION_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
# DREAM_INDUCTION_MODEL_CONFIG__MODEL=your-model-here
# DREAM_INDUCTION_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
# DREAM_DOCUMENT_THRESHOLD=50
# DREAM_IDLE_TIMEOUT_MINUTES=60
# DREAM_MIN_HOURS_BETWEEN_DREAMS=8
# DREAM_ENABLED_TYPES=["omni"]
# DREAM_PROVIDER=anthropic
# DREAM_MODEL=claude-sonnet-4-20250514
# DREAM_MAX_OUTPUT_TOKENS=16384
# DREAM_THINKING_BUDGET_TOKENS=8192
# DREAM_MAX_TOOL_ITERATIONS=20
# DREAM_HISTORY_TOKEN_LIMIT=16384
# DREAM_BACKUP_PROVIDER=
# DREAM_BACKUP_MODEL=
# Specialist models (use same provider as main model)
# DREAM_DEDUCTION_MODEL=claude-haiku-4-5
# DREAM_INDUCTION_MODEL=claude-haiku-4-5
# Dream Surprisal Settings (Tree-based observation sampling for targeted reasoning)
# Surprisal sampling (advanced):
# DREAM_SURPRISAL__ENABLED=false
# DREAM_SURPRISAL__TREE_TYPE=kdtree # Options: kdtree, balltree, rptree, covertree, lsh, graph, prototype
# DREAM_SURPRISAL__TREE_K=5 # Number of neighbors for kNN-based trees
# DREAM_SURPRISAL__SAMPLING_STRATEGY=recent # Options: recent, random, all
# DREAM_SURPRISAL__SAMPLE_SIZE=200 # Number of observations to sample for tree building
# DREAM_SURPRISAL__TOP_PERCENT_SURPRISAL=0.10 # Top percentage of observations (0.10 = top 10%)
# DREAM_SURPRISAL__MIN_HIGH_SURPRISAL_FOR_REPLACE=10 # Hybrid mode: min observations to replace standard questions
# DREAM_SURPRISAL__INCLUDE_LEVELS=["explicit","deductive"] # Observation levels to include
# DREAM_SURPRISAL__TREE_TYPE=kdtree
# DREAM_SURPRISAL__TREE_K=5
# DREAM_SURPRISAL__SAMPLING_STRATEGY=recent
# DREAM_SURPRISAL__SAMPLE_SIZE=200
# DREAM_SURPRISAL__TOP_PERCENT_SURPRISAL=0.10
# DREAM_SURPRISAL__MIN_HIGH_SURPRISAL_FOR_REPLACE=10
# DREAM_SURPRISAL__INCLUDE_LEVELS=["explicit","deductive"]
# =============================================================================
# Webhook Settings

View File

@ -31,7 +31,7 @@ For more information on closing issues using keywords, please check https://docs
## **Changelog**
<!-- 📛📛📛📛
Log of changes introduced in this release in the style fo https://keepachangelog.com/en/1.1.0/
Log of changes introduced in this release in the style of https://keepachangelog.com/en/1.1.0/
📛📛📛📛 -->
### **Added**

2
.gitignore vendored
View File

@ -1,3 +1,4 @@
.worktrees/
api/**/*.db
api/data
api/docker-compose.yml
@ -181,6 +182,7 @@ docs/node_modules
timing_logs.csv
config.json
config.toml
.aider*

View File

@ -5,6 +5,87 @@ 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/).
## [Unreleased]
### Added
- New `src/llm/` package as the single owner of provider runtime: clients, backends, history adapters, tool loop, request builder, credentials, and caching policy
- `AttemptPlan` dataclass captures per-retry provider selection (client, model, reasoning_effort, thinking_budget_tokens, selected_config) and pins it across stream-final retries so streaming doesn't bounce back to primary after the tool loop has settled on fallback
- Gemini JSON-schema sanitizer for `function_declarations` — strips keywords Gemini's validator rejects (`additionalProperties`, `allOf`, etc.) while preserving semantics for all other backends
- Dreamer specialists derive `effective_max_tokens` from `model_config.max_output_tokens` with a per-specialist default fallback
- Regression tests covering fallback-config thinking-param reach, provider_params → extra_params boundary, OpenAI reasoning-model parameter routing, Gemini blocked finish_reason handling, and fail-fast `max_tool_iterations` validation
### Changed
- All LLM orchestration moved out of `src/utils/clients.py` into `src/llm/` with modules split by responsibility (api, executor, tool_loop, runtime, registry, conversation, request_builder, credentials, caching, backends, history_adapters)
- Default `ModelConfig` factories (deriver, summary, dreamer specialists, dialectic levels) normalized to `openai/gpt-5.4-mini` with no extra parameters set by default; operators add transport/thinking overrides explicitly
- OpenAI reasoning-model routing widened via `_uses_max_completion_tokens` heuristic covering `gpt-5.x` and `o1/o3/o4` — these models receive `max_completion_tokens` instead of `max_tokens`
- Override client factories switched from unbounded `@cache` to `@lru_cache(maxsize=128)` for predictable memory growth on long-running processes
- `get_backend` now delegates to `client_for_model_config`, so the live-test path and production path share one missing-API-key validation
- Blocked Gemini responses (`SAFETY`, `RECITATION`, `PROHIBITED_CONTENT`, `BLOCKLIST`) raise `LLMError` in the streaming path too (previously only the non-streaming path), ensuring retry/fallback logic fires uniformly
- Transport-change env overrides now strip transport-specific thinking params (thinking_budget_tokens vs. reasoning_effort) during config merge, including at the dialectic-level merge, so switching from Anthropic → OpenAI doesn't leave orphaned Anthropic-only params that the OpenAI backend would reject
- `max_tool_iterations` out-of-range inputs now raise `ValidationException` instead of being silently clamped
- Troubleshooting docs updated to reflect nested-env-var form for per-component thinking-budget overrides
### Fixed
- Fallback `ModelConfig` temperature and `thinking_budget_tokens` reach the backend on the final retry — previously the primary's values were pre-populated into caller kwargs early and clobbered fallback values via `effective_config_for_call(update=...)`
- Stream-final retries pin to the `AttemptPlan` that succeeded rather than re-running provider selection through the outer `current_attempt` ContextVar (which could roll streaming back to primary after the tool loop had already switched to fallback)
- OpenAI structured-output calls continue to use `chat.completions.parse()` with strict schema enforcement, while tool-calling paths use `chat.completions.create()` without `strict:True` for broader proxy compatibility (OpenRouter, vLLM, Ollama)
- Gemini `cached_content` reuse keys now include `system_instruction` and `tool_config` so cache hits don't cross configurations that differ only in those fields
### Removed
- `src/utils/clients.py` deleted; its responsibilities are split across `src/llm/registry.py`, `src/llm/credentials.py`, and the backend-specific modules
## [3.0.6] - 2026-04-10
### Changed
- Tightened transaction scopes across search, agent tools, queue manager, and webhook delivery to minimize DB connection hold time during external operations (#525)
- Search operations refactored to two-phase pattern — external work (embeddings, LLM calls) completes before opening a transaction (#525)
- Agent tool executor performs external operations before acquiring DB sessions (#525)
- Queue manager transaction scope reduced to only the critical section (#525)
- Webhook delivery no longer holds a DB session parameter (#525)
### Fixed
- Session leakage in non-session-scoped dialectic chat calls (#526)
### Added
- Health check endpoint (`/health`) for container orchestration and load balancer probes (#510)
## [3.0.5] - 2026-04-03
### Fixed
- explicit rollback on all transactions to force connection closed
## [3.0.4] - 2026-04-02
### Added
- JSONB metadata validation enforces 100 key limit and max depth of 5 (#419)
### Changed
- Schemas refactored from single `schemas.py` into `schemas/api.py`, `schemas/configuration.py`, and `schemas/internal.py` with backwards-compatible re-exports (#419)
### Fixed
- Missing `deleted_at` filter on `RepresentationManager._query_documents_recent()` and `._query_documents_most_derived()` allowed soft-deleted documents to leak into the deriver's working representation (#456)
- `CleanupStaleItemsCompletedEvent` emitted spuriously when no queue item was actually deleted (#454)
- Empty JSON file uploads caused unhandled errors; now returns normalized error responses (#434)
- Memory leak: `_observation_locks` switched to `WeakValueDictionary` to prevent unbounded growth (#419)
- SQL injection in `dependencies.py`: parameterized `set_config` calls to prevent injection via request context (#419)
- NUL byte crashes: string inputs (message content, queries, peer cards) now stripped at schema level (#419)
- Filter recursion depth capped at 5 to prevent stack overflow (#419)
- Dedup-skipped observations now correctly reflected in created counts (#477)
- External vector store support for message search — routes queries through configured external vector store with oversampling and
deduplication to handle chunked embeddings (#479)
- Dialectic agent no longer holds a DB connection during LLM calls — embeddings are pre-computed before tool execution, DB sessions isolated in `extract_preferences`, `query_documents` no longer accepts a DB session parameter (#477)
## [3.0.3] - 2026-02-25
### Added
@ -454,7 +535,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Changed
- `/list` endpoints to not require a request body
- `metamessage_type` to `label` with backwards compatability
- `metamessage_type` to `label` with backwards compatibility
- Database Provisioning to rely on alembic
- Database Session Manager to explicitly rollback transactions before closing
the connection
@ -628,7 +709,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Authentication Middleware now implemented using built-in FastAPI Security
module
- Get by name routes for users and collections now include "name" in slug
- Python SDK moved to separate [respository](https://github.com/plastic-labs/honcho-python)
- Python SDK moved to separate [repository](https://github.com/plastic-labs/honcho-python)
### Fixed
@ -699,7 +780,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Changed
- session_data is now metadata
- session_data is a JSON field used python `dict` for compatability
- session_data is a JSON field used python `dict` for compatibility
## [0.0.2] — 2024-02-01

View File

@ -8,7 +8,7 @@ Before you start contributing, please:
1. **Set up your development environment** - Follow the [Local Development guide](./README.md#local-development) in the README to get Honcho running locally.
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/plasticlabs) to discuss your changes, get help, or ask questions.
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions.
3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to.
@ -106,7 +106,7 @@ git commit -m "docs(readme): update installation instructions"
### Python Code Style
- Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines
- Use [Black](https://black.readthedocs.io/) for code formatting (we may add this to CI in the future)
- Use [ruff](https://docs.astral.sh/ruff/) for linting and code formatting
- Use type hints where possible
- Write docstrings for functions and classes using Google style docstrings
@ -164,7 +164,7 @@ When reporting bugs or requesting features:
## Questions and Support
- **General questions** - Join our [Discord](http://discord.gg/plasticlabs)
- **General questions** - Join our [Discord](http://discord.gg/honcho)
- **Bug reports** - Use GitHub issues
- **Feature requests** - Use GitHub issues with the feature request template
- **Security issues** - Please email us privately rather than opening a public issue

View File

@ -32,13 +32,16 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# Place executables in the environment at the front of the path
ENV PATH="/app/.venv/bin:$PATH"
ENV HOME=/app
ENV UV_CACHE_DIR=/tmp/uv-cache
# Create non-root user and set ownership
RUN addgroup --system app && adduser --system --group app && chown -R app:app /app
RUN addgroup --system app && adduser --system --group app && mkdir -p /tmp/uv-cache && chown -R app:app /app /tmp/uv-cache
COPY --chown=app:app src/ /app/src/
COPY --chown=app:app migrations/ /app/migrations/
COPY --chown=app:app scripts/ /app/scripts/
COPY --chown=app:app docker/ /app/docker/
COPY --chown=app:app alembic.ini /app/alembic.ini
# Copy config files - this will copy config.toml if it exists, and config.toml.example
COPY --chown=app:app config.toml* /app/
@ -48,7 +51,4 @@ USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/openapi.json')" || exit 1
CMD ["fastapi", "run", "--host", "0.0.0.0", "src/main.py"]

View File

@ -8,10 +8,10 @@
---
![Static Badge](https://img.shields.io/badge/Version-3.0.3-blue)
![Static Badge](https://img.shields.io/badge/Version-3.0.6-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)
[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/honcho)
Honcho is an open source memory library with a managed service for building stateful
agents. Use it with any model, framework, or architecture. It enables agents to build
@ -162,8 +162,8 @@ Server.
Honcho is developed using [python](https://www.python.org/) and [uv](https://docs.astral.sh/uv/).
The minimum python version is `3.9`
The minimum uv version is `0.4.9`
The minimum python version is `3.10`
The minimum uv version is `0.5.0`
### Setup
@ -221,11 +221,10 @@ Below are the required configurations:
```env
DB_CONNECTION_URI= # Connection uri for a postgres database (with postgresql+psycopg prefix)
# LLM Provider API Keys (at least one required depending on your configuration)
LLM_ANTHROPIC_API_KEY= # API Key for Anthropic (used for dialectic by default)
LLM_OPENAI_API_KEY= # API Key for OpenAI (optional, for embeddings if EMBED_MESSAGES=true)
LLM_GEMINI_API_KEY= # API Key for Google Gemini (used for summary/deriver by default)
LLM_GROQ_API_KEY= # API Key for Groq (used for query generation by default)
# LLM Provider API Keys
LLM_GEMINI_API_KEY= # API Key for Google Gemini (used for deriver, summary, and dialectic minimal/low by default)
LLM_ANTHROPIC_API_KEY= # API Key for Anthropic (used for dialectic medium/high/max and dream by default)
LLM_OPENAI_API_KEY= # API Key for OpenAI (used for embeddings when EMBED_MESSAGES=true)
```
> Note that the `DB_CONNECTION_URI` must have the prefix `postgresql+psycopg` to
@ -420,16 +419,17 @@ Then modify the values as needed. The TOML file is organized into sections:
All configuration values can be overridden using environment variables. The environment variable names follow this pattern:
- `{SECTION}_{KEY}` for nested settings
- `{SECTION}_{KEY}` for top-level section settings
- Use `__` inside `{KEY}` for nested settings
- Just `{KEY}` for app-level settings
Examples:
- `DB_CONNECTION_URI` - Database connection string
- `AUTH_JWT_SECRET` - JWT secret key
- `DIALECTIC_LEVELS__low__MODEL` - Model for low reasoning level
- `DERIVER_PROVIDER` - Provider for background deriver
- `SUMMARY_PROVIDER` - Summary generation provider
- `DERIVER_MODEL_CONFIG__TRANSPORT` - Transport for the background deriver
- `SUMMARY_MODEL_CONFIG__MODEL` - Summary model override
- `DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL` - Model for low reasoning level
- `LOG_LEVEL` - Application log level
- `METRICS_ENABLED` - Enable Prometheus metrics
- `TELEMETRY_ENABLED` - Enable CloudEvents telemetry
@ -455,14 +455,14 @@ If you have this in `config.toml`:
```toml
[db]
CONNECTION_URI = "postgresql://localhost/honcho_dev"
CONNECTION_URI = "postgresql+psycopg://localhost/honcho_dev"
POOL_SIZE = 10
```
You can override just the connection URI in production:
```bash
export DB_CONNECTION_URI="postgresql://prod-server/honcho_prod"
export DB_CONNECTION_URI="postgresql+psycopg://prod-server/honcho_prod"
```
The application will use the production connection URI while keeping the pool size from config.toml.

View File

@ -11,8 +11,6 @@ GET_CONTEXT_MAX_TOKENS = 100000
MAX_FILE_SIZE = 5242880 # 5MB
MAX_MESSAGE_SIZE = 25000 # Characters
EMBED_MESSAGES = true
MAX_EMBEDDING_TOKENS = 8192
MAX_EMBEDDING_TOKENS_PER_REQUEST = 300000
# LANGFUSE_HOST = "https://api.langfuse.com"
# LANGFUSE_PUBLIC_KEY = "your-public-key-here"
# COLLECT_METRICS_LOCAL = false
@ -51,21 +49,32 @@ PROFILES_SAMPLE_RATE = 0.1
# LLM settings
[llm]
DEFAULT_MAX_TOKENS = 2500
EMBEDDING_PROVIDER = "openai"
MAX_TOOL_OUTPUT_CHARS = 10000 # Max chars for tool output (~2500 tokens)
MAX_MESSAGE_CONTENT_CHARS = 2000 # Max chars per message in tool results
# API Keys for LLM providers
# API Keys for LLM providers (set the ones you need)
# Supported transports: openai, anthropic, gemini
# Base URLs are set per-module via model_config.overrides.base_url
# Built-in text-generation defaults use openai / gpt-5.4-mini.
# Embeddings default to openai / text-embedding-3-small.
OPENAI_API_KEY = "your-api-key-here"
# ANTHROPIC_API_KEY = "your-api-key"
# OPENAI_API_KEY = "your-api-key"
# OPENAI_COMPATIBLE_API_KEY = "your-api-key"
# GEMINI_API_KEY = "your-api-key"
# GROQ_API_KEY = "your-api-key"
# OPENAI_COMPATIBLE_BASE_URL = "your-base-url"
# Separate vLLM endpoint (for local models)
# VLLM_API_KEY = "your-api-key"
# VLLM_BASE_URL = "your-base-url"
# Embedding settings
[embedding]
VECTOR_DIMENSIONS = 1536
MAX_INPUT_TOKENS = 8192
MAX_TOKENS_PER_REQUEST = 300000
[embedding.model_config]
transport = "openai"
model = "text-embedding-3-small"
# Optional module-level endpoint overrides
# [embedding.model_config.overrides]
# base_url = "https://embedding-proxy.internal.example/v1"
# api_key_env = "EMBEDDING_CUSTOM_API_KEY"
# Deriver settings
[deriver]
@ -74,20 +83,38 @@ WORKERS = 1
POLLING_SLEEP_INTERVAL_SECONDS = 1.0
STALE_SESSION_TIMEOUT_MINUTES = 5
# QUEUE_ERROR_RETENTION_SECONDS = 2592000 # 30 days
PROVIDER = "google"
MODEL = "gemini-2.5-flash-lite"
# TEMPERATURE = 0.0
# BACKUP_PROVIDER = "anthropic"
# BACKUP_MODEL = "claude-haiku-4-5"
DEDUPLICATE = true
MAX_OUTPUT_TOKENS = 4096
THINKING_BUDGET_TOKENS = 1024
LOG_OBSERVATIONS = false
MAX_INPUT_TOKENS = 23000
WORKING_REPRESENTATION_MAX_OBSERVATIONS = 100
REPRESENTATION_BATCH_MAX_TOKENS = 1024
FLUSH_ENABLED = false # Bypass batch token threshold, process work immediately
[deriver.model_config]
transport = "openai"
model = "gpt-5.4-mini"
# temperature = 0.0
# thinking_effort = "minimal"
# thinking_budget_tokens = 1024
# max_output_tokens = 4096
# Optional module-level endpoint overrides
# transport = "openai"
# model = "my-local-model"
# [deriver.model_config.overrides]
# base_url = "https://llm.internal.example/v1"
# api_key_env = "DERIVER_CUSTOM_API_KEY"
# Optional fallback model
# [deriver.model_config.fallback]
# transport = "anthropic"
# model = "claude-haiku-4-5"
# [deriver.model_config.fallback.overrides]
# base_url = "https://llm-backup.internal.example/v1"
# api_key_env = "DERIVER_CUSTOM_BACKUP_API_KEY"
# [deriver.model_config.overrides.provider_params]
# verbosity = "low"
# Peer card settings
[peer_card]
ENABLED = true
@ -102,55 +129,64 @@ SESSION_HISTORY_MAX_TOKENS = 4096
# Per-level settings for reasoning levels
# MAX_OUTPUT_TOKENS is optional per level; if not set, uses global MAX_OUTPUT_TOKENS
[dialectic.levels.minimal]
PROVIDER = "google"
MODEL = "gemini-2.5-flash-lite"
THINKING_BUDGET_TOKENS = 0
MAX_TOOL_ITERATIONS = 1
MAX_OUTPUT_TOKENS = 250
TOOL_CHOICE = "any"
[dialectic.levels.minimal.model_config]
transport = "openai"
model = "gpt-5.4-mini"
[dialectic.levels.low]
PROVIDER = "google"
MODEL = "gemini-2.5-flash-lite"
THINKING_BUDGET_TOKENS = 0
MAX_TOOL_ITERATIONS = 5
# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default
TOOL_CHOICE = "any"
[dialectic.levels.low.model_config]
transport = "openai"
model = "gpt-5.4-mini"
[dialectic.levels.medium]
PROVIDER = "anthropic"
MODEL = "claude-haiku-4-5"
THINKING_BUDGET_TOKENS = 1024
MAX_TOOL_ITERATIONS = 2
# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default
[dialectic.levels.medium.model_config]
transport = "openai"
model = "gpt-5.4-mini"
[dialectic.levels.high]
PROVIDER = "anthropic"
MODEL = "claude-haiku-4-5"
THINKING_BUDGET_TOKENS = 1024
MAX_TOOL_ITERATIONS = 4
# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default
[dialectic.levels.high.model_config]
transport = "openai"
model = "gpt-5.4-mini"
[dialectic.levels.max]
PROVIDER = "anthropic"
MODEL = "claude-haiku-4-5"
THINKING_BUDGET_TOKENS = 2048
MAX_TOOL_ITERATIONS = 10
# MAX_OUTPUT_TOKENS = 8192 # Optional: override global default
# Backup provider example (optional, must set both or neither):
# BACKUP_PROVIDER = "google"
# BACKUP_MODEL = "gemini-2.5-pro"
[dialectic.levels.max.model_config]
transport = "openai"
model = "gpt-5.4-mini"
# [dialectic.levels.max.model_config.fallback]
# transport = "gemini"
# model = "gemini-2.5-pro"
# Summary settings
[summary]
ENABLED = true
MESSAGES_PER_SHORT_SUMMARY = 20
MESSAGES_PER_LONG_SUMMARY = 60
PROVIDER = "google"
MODEL = "gemini-2.5-flash"
MAX_TOKENS_SHORT = 1000
MAX_TOKENS_LONG = 4000
THINKING_BUDGET_TOKENS = 512
# BACKUP_PROVIDER = "google"
# BACKUP_MODEL = "gemini-2.5-flash"
[summary.model_config]
transport = "openai"
model = "gpt-5.4-mini"
# thinking_effort = "minimal"
# thinking_budget_tokens = 1024
# [summary.model_config.fallback]
# transport = "anthropic"
# model = "claude-haiku-4-5"
# Dream settings
[dream]
@ -159,18 +195,16 @@ DOCUMENT_THRESHOLD = 50
IDLE_TIMEOUT_MINUTES = 60
MIN_HOURS_BETWEEN_DREAMS = 8
ENABLED_TYPES = ["omni"]
PROVIDER = "anthropic"
MODEL = "claude-sonnet-4-20250514"
MAX_OUTPUT_TOKENS = 16384
THINKING_BUDGET_TOKENS = 8192
MAX_TOOL_ITERATIONS = 20
HISTORY_TOKEN_LIMIT = 16384
# BACKUP_PROVIDER = "google"
# BACKUP_MODEL = "gemini-2.5-flash"
# Specialist models (use same provider as main model)
DEDUCTION_MODEL = "claude-haiku-4-5"
INDUCTION_MODEL = "claude-haiku-4-5"
[dream.deduction_model_config]
transport = "openai"
model = "gpt-5.4-mini"
[dream.induction_model_config]
transport = "openai"
model = "gpt-5.4-mini"
# Surprisal-based sampling subsystem
[dream.surprisal]
@ -220,6 +254,8 @@ TYPE = "pgvector"
# Migration flag: set to true when migration from pgvector is complete
MIGRATED = false
NAMESPACE = "honcho"
# This should match embedding.vector_dimensions. pgvector and dual-write mode
# currently still require 1536 until a schema migration lands.
DIMENSIONS = 1536
# TURBOPUFFER_API_KEY = "your-turbopuffer-api-key"
# TURBOPUFFER_REGION = "us-east-1"

View File

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

8
docker/entrypoint.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Running database migrations..."
/app/.venv/bin/python scripts/provision_db.py
echo "Starting API server..."
exec /app/.venv/bin/fastapi run --host 0.0.0.0 src/main.py

View File

@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false

10
docker/prometheus.yml Normal file
View File

@ -0,0 +1,10 @@
global:
scrape_interval: 15s
scrape_configs:
- job_name: honcho-api
static_configs:
- targets: ["api:8000"]
- job_name: honcho-deriver
static_configs:
- targets: ["deriver:9090"]

View File

@ -6,7 +6,6 @@
"name": "honcho-docs",
"dependencies": {
"@mintlify/scraping": "^4.0.467",
"honcho-ai": "^0.0.11",
},
"devDependencies": {
"mint": "^4.2.204",
@ -298,8 +297,6 @@
"@types/node": ["@types/node@18.19.120", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WtCGHFXnVI8WHLxDAt5TbnCM4eSE+nI0QN2NJtwzcgMhht2eNz6V9evJrk+lwC8bCY8OWV5Ym8Jz7ZEyGnKnMA=="],
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
@ -326,8 +323,6 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"aggregate-error": ["aggregate-error@4.0.1", "", { "dependencies": { "clean-stack": "^4.0.0", "indent-string": "^5.0.0" } }, "sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
@ -682,12 +677,10 @@
"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-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
@ -788,8 +781,6 @@
"hex-rgb": ["hex-rgb@5.0.0", "", {}, "sha512-NQO+lgVUCtHxZ792FodgW0zflK+ozS9X9dwGp9XvvmPlH7pyxd588cn24TD3rmPm/N0AIRXF10Otah8yKqGw4w=="],
"honcho-ai": ["honcho-ai@0.0.11", "", { "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-SUl/PnMldTCz8G4S8faP00M2iFd9qWDkI5U8w0FQ7OC6SgKzTf1nJ/j3gyzctzR2IZ6LrOz/2d5OwO4f/PCMww=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
@ -802,8 +793,6 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"ico-endec": ["ico-endec@0.1.6", "", {}, "sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ=="],
"iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="],
@ -1136,9 +1125,7 @@
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
"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=="],
"node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@ -1590,8 +1577,6 @@
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"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=="],
@ -1646,8 +1631,6 @@
"@asyncapi/parser/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
"@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="],
"@inquirer/checkbox/@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="],
"@inquirer/checkbox/@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="],
@ -1714,6 +1697,8 @@
"@stoplight/better-ajv-errors/leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
"@stoplight/json-ref-readers/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=="],
"@stoplight/json-ref-readers/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"@stoplight/spectral-core/@stoplight/types": ["@stoplight/types@13.6.0", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-dzyuzvUjv3m1wmhPfq82lCVYGcXG0xUYgqnWfCq3PCVR4BKFhjdkHrnJ+jIDoMKvXb05AZP/ObQF6+NpDo29IQ=="],
@ -1724,6 +1709,8 @@
"@stoplight/spectral-parsers/@stoplight/types": ["@stoplight/types@14.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g=="],
"@stoplight/spectral-runtime/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=="],
"@stoplight/yaml/@stoplight/types": ["@stoplight/types@14.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@ -1772,8 +1759,6 @@
"glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"got/form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="],
"gray-matter/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=="],
"ink/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@ -1940,10 +1925,6 @@
"inquirer/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"is-online/got/form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="],
"public-ip/got/form-data-encoder": ["form-data-encoder@2.1.4", "", {}, "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"widest-line/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],

View File

@ -10,14 +10,14 @@ This guide helps you match the right SDK version to your Honcho API version. New
<CardGroup cols={2}>
<Card title="TypeScript SDK" icon="js">
**Latest:** v2.0.1
**Latest:** v2.1.1
```bash
npm install @honcho-ai/sdk
```
</Card>
<Card title="Python SDK" icon="python">
**Latest:** v2.0.1
**Latest:** v2.1.1
```bash
pip install honcho-ai
@ -30,7 +30,10 @@ This guide helps you match the right SDK version to your Honcho API version. New
| Honcho API Version | TypeScript SDK | Python SDK |
|-------------------|---------------|------------|
| v3.0.3 (Current) | v2.0.1 | v2.0.1 |
| v3.0.6 (Current) | v2.1.1 | v2.1.1 |
| v3.0.5 | v2.1.0 | v2.1.0 |
| v3.0.4 | v2.1.0 | v2.1.0 |
| v3.0.3 | v2.1.0 | v2.1.0 |
| v3.0.2 | v2.0.0+ | v2.0.0+ |
| v3.0.1 | v2.0.0+ | v2.0.0+ |
| v3.0.0 | v2.0.0+ | v2.0.0+ |

View File

@ -27,7 +27,54 @@ 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="v3.0.3 (Current)">
<Update label="v3.0.6 (Current)">
### Changed
- Tightened transaction scopes across search, agent tools, queue manager, and webhook delivery to minimize DB connection hold time during external operations (#525)
- Search operations refactored to two-phase pattern — external work (embeddings, LLM calls) completes before opening a transaction (#525)
- Agent tool executor performs external operations before acquiring DB sessions (#525)
- Queue manager transaction scope reduced to only the critical section (#525)
- Webhook delivery no longer holds a DB session parameter (#525)
### Fixed
- Session leakage in non-session-scoped dialectic chat calls (#526)
### Added
- Health check endpoint (`/health`) for container orchestration and load balancer probes (#510)
</Update>
<Update label="v3.0.5">
### Fixed
- explicit rollback on all transactions to force connection closed
</Update>
<Update label="v3.0.4">
### Added
- JSONB metadata validation enforces 100 key limit and max depth of 5 (#419)
### Changed
- Schemas refactored from single `schemas.py` into `schemas/api.py`, `schemas/configuration.py`, and `schemas/internal.py` with backwards-compatible re-exports (#419)
### Fixed
- Missing `deleted_at` filter on `RepresentationManager._query_documents_recent()` and `._query_documents_most_derived()` allowed soft-deleted documents to leak into the deriver's working representation (#456)
- `CleanupStaleItemsCompletedEvent` emitted spuriously when no queue item was actually deleted (#454)
- Empty JSON file uploads caused unhandled errors; now returns normalized error responses (#434)
- Memory leak: `_observation_locks` switched to `WeakValueDictionary` to prevent unbounded growth (#419)
- SQL injection in `dependencies.py`: parameterized `set_config` calls to prevent injection via request context (#419)
- NUL byte crashes: string inputs (message content, queries, peer cards) now stripped at schema level (#419)
- Filter recursion depth capped at 5 to prevent stack overflow (#419)
- Dedup-skipped observations now correctly reflected in created counts (#477)
- External vector store support for message search — routes queries through configured external vector store with oversampling and
deduplication to handle chunked embeddings (#479)
- Dialectic agent no longer holds a DB connection during LLM calls — embeddings are pre-computed before tool execution, DB sessions isolated in `extract_preferences`, `query_documents` no longer accepts a DB session parameter (#477)
</Update>
<Update label="v3.0.3">
### Added
- Consolidated session context into a single DB session with 40/60 token budget allocation between summary and messages
@ -477,7 +524,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
### Changed
- `/list` endpoints to not require a request body
- `metamessage_type` to `label` with backwards compatability
- `metamessage_type` to `label` with backwards compatibility
- Database Provisioning to rely on alembic
- Database Session Manager to explicitly rollback transactions before closing
the connection
@ -511,7 +558,35 @@ 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="v2.0.1 (Current)">
<Update label="v2.1.1 (Current)">
### Fixed
- Broadened HTTP retry logic to cover `httpx.NetworkError` and `httpx.RemoteProtocolError` in addition to `httpx.TimeoutException` and `httpx.ConnectError`, improving resilience against transient network failures
</Update>
<Update label="v2.1.0">
### Added
- `created_at` property on `Peer` and `Session` objects
- `is_active` property on `Session` objects
- `get_message(message_id)` method on `Session` (sync and async) to fetch a single message by ID
- `page`, `size`, and `reverse` pagination parameters on all list methods
### Changed
- **Breaking**: `peer()` and `session()` now always make a get-or-create API call — no more lazy initialization
- Response configuration models now tolerate unknown fields from newer servers for forward compatibility
### Fixed
- Sync and async `Session.get_metadata()`, `get_configuration()`, and `refresh()` now refresh cached `created_at` and `is_active` values along with metadata and configuration
- `honcho.__version__` now derives from package metadata, with a source-checkout fallback, so it stays aligned with released package versions
</Update>
<Update label="v2.0.2">
### Changed
- All input models now reject unknown fields via strict Pydantic validation (`extra="forbid"`). Previously, misspelled or extraneous fields were silently ignored. Now a `ValidationError` is raised with the unrecognized field name.
</Update>
<Update label="v2.0.1">
### Added
- `set_peer_card` method
@ -625,7 +700,59 @@ 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="v2.0.1 (Current)">
<Update label="v2.1.1 (Current)">
### Fixed
- Broadened fetch error retry logic to catch all `TypeError` network failures (connection resets, DNS errors, etc.) instead of only those with `'fetch'` in the message, improving resilience across runtimes (Node, Bun, browsers)
</Update>
<Update label="v2.1.0">
### Added
- `createdAt` property on `Peer` and `Session` wrapper objects
- `isActive` property on `Session` wrapper objects
- `getMessage(messageId)` method on `Session` to fetch a single message by ID
- `Peer.representation()`, `Session.representation()`, and `Session.context()` now accept `Message` objects for `searchQuery`
- `page`, `size`, and `reverse` pagination controls on all list methods
### Changed
- **Breaking**: `searchQuery` removed from top-level `context()` options — use `representationOptions.searchQuery` instead:
```typescript
// Before (v2.0.x)
await session.context({ searchQuery: "..." });
// After (v2.1.0)
await session.context({ representationOptions: { searchQuery: "..." } });
```
- List methods (`peers()`, `sessions()`, `messages()`, `workspaces()`) support both the new options object and the legacy raw-filter form
- Representation search options now accept strings and content-like objects, including `Message` instances, while rejecting whitespace-only or invalid runtime inputs
- **Breaking**: `peer()` and `session()` now always make a get-or-create API call — no more lazy initialization. If you relied on constructing SDK objects without triggering a network request, note that every `peer()` and `session()` call now hits the API:
```typescript
// Before (v2.0.x) — no API call
const session = honcho.session("my-session");
// After (v2.1.0) — makes a get-or-create API call
const session = await honcho.session("my-session");
```
- Response configuration models now tolerate unknown fields from newer servers for forward compatibility
- Moved `@types/node` from `dependencies` to `devDependencies`
### Fixed
- `uploadFile()` now rejects unsupported top-level binary/object inputs and only validates inputs the serializer can actually upload
- `uploadFile()` now serializes message configuration using API field names, matching `addMessages()`
- Session fetch methods now refresh cached `createdAt` and `isActive` values alongside metadata and configuration
</Update>
<Update label="v2.0.2">
### Changed
- Client constructor now rejects unknown options via `.strict()` Zod validation. Previously, misspelled options (e.g., `baseUrl` instead of `baseURL`) were silently ignored, causing the SDK to fall back to defaults. Now a `ZodError` is thrown with the unrecognized key name.
- All input schemas now use `.strict()` validation to reject unknown fields.
- `FileUploadSchema.configuration` now uses `MessageConfigurationSchema` instead of open record type.
### Fixed
- README example used `baseUrl` instead of `baseURL`.
</Update>
<Update label="v2.0.1">
### Added
- `setPeerCard` method
@ -745,4 +872,4 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
If you encounter issues using the Honcho API or its SDKs:
1. Open an issue on [GitHub](https://github.com/plastic-labs/honcho/issues)
2. Join our [Discord community](http://discord.gg/plasticlabs) for support
2. Join our [Discord community](http://discord.gg/honcho) for support

View File

@ -19,14 +19,21 @@
},
"favicon": "/favicon.svg",
"contextual": {
"options": ["copy", "view", "chatgpt", "claude"]
"options": [
"copy",
"view",
"chatgpt",
"claude"
]
},
"navigation": {
"versions": [
{
"version": "v3.0.3",
"version": "v3.0.5",
"api": {
"openapi": ["v3/openapi.json"]
"openapi": [
"v3/openapi.json"
]
},
"tabs": [
{
@ -77,7 +84,8 @@
"group": "Reference",
"pages": [
"v3/documentation/reference/platform",
"v3/documentation/reference/sdk"
"v3/documentation/reference/sdk",
"v3/documentation/reference/cli"
]
}
]
@ -87,17 +95,23 @@
"groups": [
{
"group": "Overview",
"pages": ["v3/guides/overview"]
"pages": [
"v3/guides/overview"
]
},
{
"group": "Integrations",
"pages": [
"v3/guides/integrations/claude-code",
"v3/guides/integrations/opencode",
"v3/guides/integrations/crewai",
"v3/guides/integrations/langgraph",
"v3/guides/integrations/mcp",
"v3/guides/integrations/n8n",
"v3/guides/integrations/openclaw",
"v3/guides/integrations/hermes",
"v3/guides/integrations/zo-computer",
"v3/guides/integrations/paperclip",
"v3/guides/integrations/sillytavern"
]
},
@ -105,20 +119,24 @@
"group": "Tutorials",
"pages": [
"v3/guides/discord",
"v3/guides/granola",
"v3/guides/telegram",
"v3/guides/integrations/reachy-mini"
"v3/guides/integrations/reachy-mini",
"v3/guides/gmail"
]
},
{
"group": "Community Integrations",
"pages": [
"v3/guides/community/agent0",
"v3/guides/community/hermes"
"v3/guides/community/pi-honcho-memory"
]
},
{
"group": "Migrations",
"pages": ["v3/guides/migrations/mem0"]
"pages": [
"v3/guides/migrations/mem0"
]
}
]
},
@ -129,7 +147,8 @@
"group": "Self-Hosting",
"pages": [
"v3/contributing/self-hosting",
"v3/contributing/configuration"
"v3/contributing/configuration",
"v3/contributing/troubleshooting"
]
},
{
@ -146,7 +165,9 @@
"groups": [
{
"group": "API Documentation",
"pages": ["v3/api-reference/introduction"]
"pages": [
"v3/api-reference/introduction"
]
},
{
"group": "workspaces",
@ -224,7 +245,9 @@
},
{
"group": "miscellaneous",
"pages": ["v3/api-reference/endpoint/keys/create-key"]
"pages": [
"v3/api-reference/endpoint/keys/create-key"
]
}
]
},
@ -245,7 +268,9 @@
{
"version": "v2.5.1",
"api": {
"openapi": ["v2/openapi.json"]
"openapi": [
"v2/openapi.json"
]
},
"tabs": [
{
@ -292,24 +317,31 @@
"groups": [
{
"group": "Getting Started",
"pages": ["v2/guides/overview"]
"pages": [
"v2/guides/overview"
]
},
{
"group": "Migrations",
"pages": ["v2/migrations/from-mem0"]
"pages": [
"v2/migrations/from-mem0"
]
},
{
"group": "Integrations",
"pages": [
"v2/integrations/crewai",
"v2/integrations/langgraph",
"v2/integrations/mcp",
"v2/integrations/n8n"
"v2/integrations/mcp"
]
},
{
"group": "Application Interfaces",
"pages": ["v2/guides/discord", "v2/guides/telegram"]
"pages": [
"v2/guides/discord",
"v2/guides/n8n",
"v2/guides/telegram"
]
}
]
},
@ -318,7 +350,9 @@
"groups": [
{
"group": "API Documentation",
"pages": ["v2/api-reference/introduction"]
"pages": [
"v2/api-reference/introduction"
]
},
{
"group": "workspaces",
@ -422,7 +456,9 @@
{
"version": "v1.1.0",
"api": {
"openapi": ["openapi.json"]
"openapi": [
"openapi.json"
]
},
"tabs": [
{
@ -452,15 +488,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"
]
}
]
},
@ -469,7 +513,9 @@
"groups": [
{
"group": "API Documentation",
"pages": ["v1/api-reference/introduction"]
"pages": [
"v1/api-reference/introduction"
]
},
{
"group": "apps",
@ -517,7 +563,9 @@
},
{
"group": "keys",
"pages": ["v1/api-reference/endpoint/keys/create-key"]
"pages": [
"v1/api-reference/endpoint/keys/create-key"
]
},
{
"group": "metamessages",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -11,8 +11,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@mintlify/scraping": "^4.0.467",
"honcho-ai": "^0.0.11"
"@mintlify/scraping": "^4.0.467"
},
"devDependencies": {
"mint": "^4.2.204"

View File

@ -0,0 +1,547 @@
{/*
GENERATED by honcho-cli/scripts/generate_cli_docs.py — do not edit.
Re-generate with: uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py
Source of truth: honcho-cli/src/honcho_cli/commands/
*/}
## honcho conclusion
List, search, create, and delete peer conclusions (Honcho's memory atoms).
<AccordionGroup>
<Accordion title="create">
Create a conclusion.
```bash
honcho conclusion create <content>
```
<ParamField path="content" type="string" required />
<ParamField path="--observer" type="string">
Observer peer ID.
</ParamField>
<ParamField path="--observed" type="string">
Observed peer ID.
</ParamField>
<ParamField path="--session" type="string">
Session context. Short alias: `-s`.
</ParamField>
</Accordion>
<Accordion title="delete">
Delete a conclusion.
```bash
honcho conclusion delete <conclusion_id>
```
<ParamField path="conclusion_id" type="string" required />
<ParamField path="--observer" type="string">
Observer peer ID.
</ParamField>
<ParamField path="--observed" type="string">
Observed peer ID.
</ParamField>
<ParamField path="--yes" type="boolean">
Skip confirmation. Short alias: `-y`.
</ParamField>
</Accordion>
<Accordion title="list">
List conclusions.
```bash
honcho conclusion list
```
<ParamField path="--observer" type="string">
Observer peer ID.
</ParamField>
<ParamField path="--observed" type="string">
Observed peer ID.
</ParamField>
<ParamField path="--limit" type="number" default="10">
Max results.
</ParamField>
</Accordion>
<Accordion title="search">
Semantic search over conclusions.
```bash
honcho conclusion search <query>
```
<ParamField path="query" type="string" required />
<ParamField path="--observer" type="string">
Observer peer ID.
</ParamField>
<ParamField path="--observed" type="string">
Observed peer ID.
</ParamField>
<ParamField path="--top-k" type="number" default="10">
Max results.
</ParamField>
</Accordion>
</AccordionGroup>
## honcho config
Inspect CLI configuration.
```bash
honcho config
```
## honcho doctor
Verify config and connectivity. Scope with -w / -p to check workspace, peer, and queue health.
```bash
honcho doctor
```
## honcho help
Show help message.
```bash
honcho help
```
## honcho init
Set API key and server URL in ~/.honcho/config.json.
Press Enter to keep the current value or type a replacement.
Workspace / peer / session scoping is per-command via -w / -p / -s
or HONCHO_* env vars — never persisted.
```bash
honcho init
```
<ParamField path="--api-key" type="string">
API key (admin JWT).
</ParamField>
<ParamField path="--base-url" type="string">
Honcho API URL (e.g. https://api.honcho.dev, http://localhost:8000).
</ParamField>
## honcho message
List, create, and get messages within a session.
<AccordionGroup>
<Accordion title="create">
Create a message in a session.
```bash
honcho message create <content>
```
<ParamField path="content" type="string" required />
<ParamField path="--peer" type="string" required>
Peer ID of the message sender. Short alias: `-p`.
</ParamField>
<ParamField path="--metadata" type="string">
JSON metadata to associate with the message.
</ParamField>
<ParamField path="--session" type="string">
Session ID. Short alias: `-s`.
</ParamField>
</Accordion>
<Accordion title="get">
Get a single message by ID.
```bash
honcho message get <message_id>
```
<ParamField path="message_id" type="string" required />
<ParamField path="--session" type="string">
Session ID. Short alias: `-s`.
</ParamField>
</Accordion>
<Accordion title="list">
List messages in a session. Scoped to a peer with -p.
```bash
honcho message list [<session_id>]
```
<ParamField path="session_id" type="string" />
<ParamField path="--last" type="number" default="20">
Number of recent messages.
</ParamField>
<ParamField path="--reverse" type="boolean">
Show oldest first (default is newest first).
</ParamField>
<ParamField path="--brief" type="boolean">
Show only IDs, peer, token count, and created_at (no content).
</ParamField>
<ParamField path="--peer" type="string">
Filter by peer ID. Short alias: `-p`.
</ParamField>
</Accordion>
</AccordionGroup>
## honcho peer
List, create, chat with, search, and manage peers and their representations.
<AccordionGroup>
<Accordion title="card">
Get raw peer card content.
```bash
honcho peer card [<peer_id>]
```
<ParamField path="peer_id" type="string" />
<ParamField path="--target" type="string">
Target peer for relationship card.
</ParamField>
</Accordion>
<Accordion title="chat">
Query the dialectic about a peer.
```bash
honcho peer chat <query>
```
<ParamField path="query" type="string" required />
<ParamField path="--target" type="string">
Target peer for perspective.
</ParamField>
<ParamField path="--reasoning" type="string">
Reasoning level: minimal, low, medium, high, max. Short alias: `-r`.
</ParamField>
</Accordion>
<Accordion title="create">
Create or get a peer.
```bash
honcho peer create <peer_id>
```
<ParamField path="peer_id" type="string" required />
<ParamField path="--observe-me" type="boolean">
Whether Honcho will form a representation of this peer. Negate with `--no-observe-me`.
</ParamField>
<ParamField path="--metadata" type="string">
JSON metadata to associate with the peer.
</ParamField>
</Accordion>
<Accordion title="get-metadata">
Get metadata for a peer.
```bash
honcho peer get-metadata [<peer_id>]
```
<ParamField path="peer_id" type="string" />
</Accordion>
<Accordion title="inspect">
Inspect a peer: card, session count, recent conclusions.
```bash
honcho peer inspect [<peer_id>]
```
<ParamField path="peer_id" type="string" />
</Accordion>
<Accordion title="list">
List all peers in the workspace.
```bash
honcho peer list
```
</Accordion>
<Accordion title="representation">
Get the formatted representation for a peer.
```bash
honcho peer representation [<peer_id>]
```
<ParamField path="peer_id" type="string" />
<ParamField path="--target" type="string">
Target peer to get representation about.
</ParamField>
<ParamField path="--search-query" type="string">
Semantic search query to filter conclusions.
</ParamField>
<ParamField path="--max-conclusions" type="number">
Maximum number of conclusions to include.
</ParamField>
</Accordion>
<Accordion title="search">
Search a peer's messages.
```bash
honcho peer search <query>
```
<ParamField path="query" type="string" required />
<ParamField path="--limit" type="number" default="10">
Max results.
</ParamField>
</Accordion>
<Accordion title="set-metadata">
Set metadata for a peer.
```bash
honcho peer set-metadata <metadata>
```
<ParamField path="metadata" type="string" required />
<ParamField path="--peer" type="string">
Peer ID (uses default if omitted). Short alias: `-p`.
</ParamField>
</Accordion>
</AccordionGroup>
## honcho session
List, inspect, create, delete, and manage conversation sessions and their peers.
<AccordionGroup>
<Accordion title="add-peers">
Add peers to a session.
```bash
honcho session add-peers <session_id> <peer_ids>
```
<ParamField path="session_id" type="string" required />
<ParamField path="peer_ids" type="string" required />
</Accordion>
<Accordion title="context">
Get session context (what an agent would see).
```bash
honcho session context [<session_id>]
```
<ParamField path="session_id" type="string" />
<ParamField path="--tokens" type="number">
Token budget.
</ParamField>
<ParamField path="--summary" type="boolean" default="true">
Include summary. Negate with `--no-summary`.
</ParamField>
</Accordion>
<Accordion title="create">
Create or get a session.
```bash
honcho session create <session_id>
```
<ParamField path="session_id" type="string" required />
<ParamField path="--peers" type="string">
Comma-separated peer IDs to add to the session.
</ParamField>
<ParamField path="--metadata" type="string">
JSON metadata to associate with the session.
</ParamField>
</Accordion>
<Accordion title="delete">
Delete a session and all its data. Destructive — requires --yes or interactive confirm.
```bash
honcho session delete [<session_id>]
```
<ParamField path="session_id" type="string" />
<ParamField path="--yes" type="boolean">
Skip confirmation. Short alias: `-y`.
</ParamField>
</Accordion>
<Accordion title="get-metadata">
Get metadata for a session.
```bash
honcho session get-metadata [<session_id>]
```
<ParamField path="session_id" type="string" />
</Accordion>
<Accordion title="inspect">
Inspect a session: peers, message count, summaries, config.
```bash
honcho session inspect [<session_id>]
```
<ParamField path="session_id" type="string" />
</Accordion>
<Accordion title="list">
List sessions in the workspace.
```bash
honcho session list
```
<ParamField path="--peer" type="string">
Filter by peer. Short alias: `-p`.
</ParamField>
</Accordion>
<Accordion title="peers">
List peers in a session.
```bash
honcho session peers [<session_id>]
```
<ParamField path="session_id" type="string" />
</Accordion>
<Accordion title="remove-peers">
Remove peers from a session.
```bash
honcho session remove-peers <session_id> <peer_ids>
```
<ParamField path="session_id" type="string" required />
<ParamField path="peer_ids" type="string" required />
</Accordion>
<Accordion title="representation">
Get the representation of a peer within a session.
```bash
honcho session representation <peer_id> [<session_id>]
```
<ParamField path="peer_id" type="string" required />
<ParamField path="session_id" type="string" />
<ParamField path="--target" type="string">
Target peer (what peer_id knows about target).
</ParamField>
<ParamField path="--search-query" type="string">
Semantic search query to filter conclusions.
</ParamField>
<ParamField path="--max-conclusions" type="number">
Maximum number of conclusions to include.
</ParamField>
</Accordion>
<Accordion title="search">
Search messages in a session.
```bash
honcho session search <query> [<session_id>]
```
<ParamField path="query" type="string" required />
<ParamField path="session_id" type="string" />
<ParamField path="--limit" type="number" default="10">
Max results.
</ParamField>
</Accordion>
<Accordion title="set-metadata">
Set metadata for a session.
```bash
honcho session set-metadata [<session_id>]
```
<ParamField path="session_id" type="string" />
<ParamField path="--data" type="string" required>
JSON metadata to set (e.g. '\{"key": "value"\}'). Short alias: `-d`.
</ParamField>
</Accordion>
<Accordion title="summaries">
Get session summaries (short + long).
```bash
honcho session summaries [<session_id>]
```
<ParamField path="session_id" type="string" />
</Accordion>
</AccordionGroup>
## honcho workspace
List, create, inspect, delete, and search workspaces.
<AccordionGroup>
<Accordion title="create">
Create or get a workspace.
```bash
honcho workspace create <workspace_id>
```
<ParamField path="workspace_id" type="string" required />
<ParamField path="--metadata" type="string">
JSON metadata to associate with the workspace.
</ParamField>
</Accordion>
<Accordion title="delete">
Delete a workspace. Use --dry-run first to see what will be deleted.
Requires --yes to skip confirmation, or will prompt interactively.
If sessions exist, requires --cascade to delete them first.
```bash
honcho workspace delete <workspace_id>
```
<ParamField path="workspace_id" type="string" required />
<ParamField path="--yes" type="boolean">
Skip confirmation prompt (for scripted/agent use). Short alias: `-y`.
</ParamField>
<ParamField path="--cascade" type="boolean">
Delete all sessions before deleting the workspace.
</ParamField>
<ParamField path="--dry-run" type="boolean">
Show what would be deleted without deleting.
</ParamField>
</Accordion>
<Accordion title="inspect">
Inspect a workspace: peers, sessions, config.
```bash
honcho workspace inspect [<workspace_id>]
```
<ParamField path="workspace_id" type="string" />
</Accordion>
<Accordion title="list">
List all accessible workspaces.
```bash
honcho workspace list
```
</Accordion>
<Accordion title="queue-status">
Get queue processing status.
```bash
honcho workspace queue-status
```
<ParamField path="--observer" type="string">
Filter by observer peer.
</ParamField>
<ParamField path="--sender" type="string">
Filter by sender peer.
</ParamField>
</Accordion>
<Accordion title="search">
Search messages across workspace.
```bash
honcho workspace search <query>
```
<ParamField path="query" type="string" required />
<ParamField path="--limit" type="number" default="10">
Max results.
</ParamField>
</Accordion>
</AccordionGroup>

View File

@ -11,7 +11,7 @@ indicate a feature or bug fix you are working on.
Once you have finished your contribution make a PR , and it will be reviewed by
a project manager. Feel free to join us in our
[discord](http://discord.gg/plasticlabs) to discuss your changes or get help.
[discord](http://discord.gg/honcho) to discuss your changes or get help.
Your changes will undergo a period of testing and discussion before finally
being entered into the `main` branch and being staged for release. For more

View File

@ -59,4 +59,4 @@ Finally, Claude needs instructions on how to use Honcho. The Desktop app doesn't
<Note>Be sure to update the \<app_name\> and \<user_name\> variables in the instructions.txt file.</Note>
Claude should then query for insights before responding and write your messages to storage! If you come up with more creative ways to get Claude to manage its own memory with Honcho, feel free to [let us know](https://discord.gg/plasticlabs) or make a PR on this [repo](https://github.com/plastic-labs/honcho-mcp/tree/main)!
Claude should then query for insights before responding and write your messages to storage! If you come up with more creative ways to get Claude to manage its own memory with Honcho, feel free to [let us know](https://discord.gg/honcho) or make a PR on this [repo](https://github.com/plastic-labs/honcho-mcp/tree/main)!

View File

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

View File

@ -11,7 +11,7 @@ Before you start contributing, please:
1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally.
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/plasticlabs) to discuss your changes, get help, or ask questions.
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions.
3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to.
@ -160,7 +160,7 @@ When reporting bugs or requesting features:
## Questions and Support
- **General questions** - Join our [Discord](http://discord.gg/plasticlabs)
- **General questions** - Join our [Discord](http://discord.gg/honcho)
- **Bug reports** - Use GitHub issues
- **Feature requests** - Use GitHub issues with the feature request template
- **Security issues** - Please email us privately rather than opening a public issue

View File

@ -59,7 +59,8 @@ OPENAI_API_KEY=your-openai-api-key
ANTHROPIC_API_KEY=your-anthropic-api-key
# Database will be created automatically by Docker
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/honcho
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
# Disable auth for local development
AUTH_USE_AUTH=false
@ -134,24 +135,21 @@ Download from [postgresql.org](https://www.postgresql.org/download/windows/)
```bash
docker run --name honcho-db \
-e POSTGRES_DB=honcho \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d pgvector/pgvector:pg15
```
### 3. Create Database and Enable Extensions
### 3. Enable Extensions
Connect to PostgreSQL and set up the database:
Connect to PostgreSQL and enable pgvector:
```bash
# Connect to PostgreSQL
psql -U postgres
# Create database and enable extensions
CREATE DATABASE honcho;
\c honcho
# Enable extensions on the default database
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
\q
@ -169,7 +167,7 @@ Edit `.env` with your configuration:
```bash
# Database connection
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/honcho
DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
# Optional API keys (required for LLM features)
OPENAI_API_KEY=your-openai-api-key
@ -279,7 +277,7 @@ const client = new Honcho({
- **Explore the API**: Check out the [API Reference](/v2/api-reference/introduction)
- **Try the SDKs**: See our [guides](/v2/guides) for examples
- **Configure Honcho**: Visit the [Configuration Guide](./configuration) for detailed settings
- **Join the community**: [Discord](https://discord.gg/plasticlabs)
- **Join the community**: [Discord](https://discord.gg/honcho)
## Troubleshooting
@ -310,7 +308,7 @@ const client = new Honcho({
### Getting Help
- **GitHub Issues**: [Report bugs](https://github.com/plastic-labs/honcho/issues)
- **Discord**: [Join our community](https://discord.gg/plasticlabs)
- **Discord**: [Join our community](https://discord.gg/honcho)
- **Documentation**: Check the [Configuration Guide](./configuration) for detailed settings
## Production Considerations

View File

@ -353,6 +353,20 @@ import { Honcho } from "@honcho-ai/sdk";
```
</CodeGroup>
### Observation and Peer Join Order
Reasoning tasks are scheduled at the time a message is created, based on which peers are in the session **at that moment**. Honcho does not retroactively schedule reasoning for peers that join later.
This means:
- If Peer C joins a session **after** messages from Peer A and Peer B have already been sent, Peer C will **not** receive reasoning tasks for those earlier messages—even if Peer C has `observe_others` enabled.
- Peer C will only begin observing new messages sent after they join the session.
- Similarly, if a peer leaves a session, they stop being included as an observer for any messages sent after their departure.
<Warning>
There is no retroactive reasoning. If your application needs an observer peer to reason about prior conversation history, add the peer to the session **before** messages are sent. Alternatively use the .chat() endpoint to include the conversation history in the agent's context, regardless of if they were reasoned against or not
</Warning>
## Full Configuration Schema Reference
### Workspace & Session Configuration

View File

@ -115,5 +115,5 @@ fundamental concepts </Card> </CardGroup>
## Community & Support
- **GitHub**: [plastic-labs/honcho](https://github.com/plastic-labs/honcho)
- **Discord**: [Join our community](http://discord.gg/plasticlabs)
- **Discord**: [Join our community](http://discord.gg/honcho)
- **Issues**: Report bugs and request features on GitHub

View File

@ -422,4 +422,4 @@ Congratulations! You've built a complete personal AI assistant with Honcho that
- [SDK Reference](/v2/documentation/reference/sdk)
- [API Reference](/v2/api-reference/introduction)
- [More Examples](/v2/guides/overview)
- [Discord Community](http://discord.gg/plasticlabs)
- [Discord Community](http://discord.gg/honcho)

View File

@ -206,7 +206,7 @@ Dive into our [API Reference](/v2/api-reference) to explore all available endpoi
<Card title="Sign up to Honcho Platform" icon="rocket" href="https://app.honcho.dev">
Get started with managed Honcho instances
</Card>
<Card title="Join our Discord" icon="discord" href="http://discord.gg/plasticlabs">
<Card title="Join our Discord" icon="discord" href="http://discord.gg/honcho">
Connect with 1000+ developers building with Honcho
</Card>
<Card title="Contribute to Honcho" icon="code" href="/v2/contributing/guidelines">

View File

@ -70,4 +70,4 @@ You may customize your assistant name and/or workspace ID. Both are optional.
4. Finally, Claude needs instructions on how to use Honcho. The Desktop app doesn't allow you to add system prompts directly, but you can create a project and paste these [instructions](https://raw.githubusercontent.com/plastic-labs/honcho/refs/heads/main/mcp/instructions.md) into the "Project Instructions" field.
Claude should then query for insights before responding and write your messages to storage! If you come up with more creative ways to get Claude to manage its own memory with Honcho, feel free to [let us know](https://discord.gg/plasticlabs) or make a PR on this [repo](https://github.com/plastic-labs/honcho/tree/main/mcp)!
Claude should then query for insights before responding and write your messages to storage! If you come up with more creative ways to get Claude to manage its own memory with Honcho, feel free to [let us know](https://discord.gg/honcho) or make a PR on this [repo](https://github.com/plastic-labs/honcho/tree/main/mcp)!

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ Before you start contributing, please:
1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally.
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/plasticlabs) to discuss your changes, get help, or ask questions.
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions.
3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to.
@ -160,7 +160,7 @@ When reporting bugs or requesting features:
## Questions and Support
- **General questions** - Join our [Discord](http://discord.gg/plasticlabs)
- **General questions** - Join our [Discord](http://discord.gg/honcho)
- **Bug reports** - Use GitHub issues
- **Feature requests** - Use GitHub issues with the feature request template
- **Security issues** - Please email us privately rather than opening a public issue

View File

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

View File

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

View File

@ -5,7 +5,7 @@ icon: "cubes"
---
<Info>
If you're using a coding agent (Claude Code, Cursor, etc.), the **`/honcho-integration` skill** walks you through these decisions interactively. It explores your codebase, interviews you about peers and sessions, and generates the integration code. The patterns below are the same ones the skill uses.
If you're using a coding agent (Claude Code, OpenCode, Cursor, etc.), the **`/honcho-integration` skill** walks you through these decisions interactively. It explores your codebase, interviews you about peers and sessions, and generates the integration code. The patterns below are the same ones the skill uses.
</Info>
## Quick Reference
@ -103,7 +103,7 @@ Not every peer needs a representation. Set `observe_me: false` on peers that beh
<CodeGroup>
```python Python
from honcho import PeerConfig
from honcho.api_types import PeerConfig
# The assistant doesn't need a representation
assistant = honcho.peer("assistant", configuration=PeerConfig(observe_me=False))

View File

@ -83,7 +83,7 @@ The approach balances quality with practical constraints. Custom models are smal
Honcho's reasoning capabilities are actively being improved. Current areas of development include enhanced inductive and abductive reasoning, multi-hop and temporal reasoning, and expanded file types and modalities. The system is designed to be extensible--new reasoning capabilities can be added without breaking existing functionality.
<Note>
If you find that the data you're uploading to Honcho isn't being reasoned over to your liking, we'd love to improve it for you and ingest your data for free--reach out via [Discord](https://discord.gg/plasticlabs) or [email](mailto:support@plasticlabs.ai)!
If you find that the data you're uploading to Honcho isn't being reasoned over to your liking, we'd love to improve it for you and ingest your data for free--reach out via [Discord](https://discord.gg/honcho) or [email](mailto:support@plasticlabs.ai)!
</Note>
## Next Steps

View File

@ -80,7 +80,8 @@ The `target` parameter controls which representation you retrieve:
<CodeGroup>
```python Python
from honcho import Honcho, SessionPeerConfig
from honcho import Honcho
from honcho.api_types import SessionPeerConfig
honcho = Honcho()
session = honcho.session("game-session")
@ -266,6 +267,20 @@ Directional representations update automatically through the reasoning pipeline
The pipeline respects scoping—Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant.
### Peer Join Order Matters
Reasoning tasks are scheduled at the time a message is created, based on which peers are in the session **at that moment**. Honcho does not retroactively schedule reasoning for peers that join later.
This means:
- If Peer C joins a session **after** messages from Peer A and Peer B have already been sent, Peer C will **not** receive reasoning tasks for those earlier messages—even if Peer C has `observe_others=true`.
- Peer C will only begin observing new messages sent after they join the session.
- Similarly, if a peer leaves a session, they stop being included as an observer for any messages sent after their departure.
<Warning>
There is no retroactive reasoning. If your application needs an observer peer to reason about prior conversation history, add the peer to the session **before** messages are sent. Alternatively, use `peer.chat()` to include conversation history in the agent's context whether or not those messages were previously reasoned over.
</Warning>
<Note>
Conclusions are cached for fast retrieval. Use `representation()` to retrieve stored conclusions for dashboards and analytics. Use `peer.chat()` when you need query-specific reasoning with natural language.
</Note>

View File

@ -165,8 +165,8 @@ context = session.context(
const context = await session.context({
tokens: 2000,
peerTarget: "user-123",
searchQuery: "What are my coding preferences?",
representationOptions: {
searchQuery: "What are my coding preferences?",
searchTopK: 10, // Number of relevant conclusions to fetch
searchMaxDistance: 0.8, // Max semantic distance (0.0-1.0)
includeMostFrequent: true, // Include most frequent conclusions

View File

@ -1,39 +1,120 @@
---
title: "AI-Powered Honcho Setup"
title: "Agentic Development"
icon: "wand-magic-sparkles"
description: "Agent skills and starter prompt for building with Honcho"
sidebarTitle: 'Vibecoding Setup'
description: "Agent skills, MCP server, and tools for building with Honcho"
sidebarTitle: 'Agentic Development'
---
These docs are designed to be easily consumable by LLMs. Each page has a button that lets you copy the page as Markdown or paste directly into ChatGPT or Claude.
We follow the llms.txt standard. There are both an llms.txt and llms-full.txt available:
## MCP Server
- [llms.txt](/llms.txt)
- [llms-full.txt](/llms-full.txt)
The fastest way to give any AI tool persistent memory is through the Honcho MCP server. It works with any client that supports the Model Context Protocol.
**Get started in 2 minutes:**
1. Get an API key at [app.honcho.dev](https://app.honcho.dev)
2. Add the config for your client below
3. Restart your client
See the [full MCP documentation](/v3/guides/integrations/mcp) for all available tools, advanced configuration, and setup instructions for every supported client.
<CodeGroup>
```json Claude Desktop
{
"mcpServers": {
"honcho": {
"command": "npx",
"args": [
"mcp-remote",
"https://mcp.honcho.dev",
"--header",
"Authorization:${AUTH_HEADER}",
"--header",
"X-Honcho-User-Name:${USER_NAME}"
],
"env": {
"AUTH_HEADER": "Bearer hch-your-key-here",
"USER_NAME": "YourName"
}
}
}
}
```
```json Cursor
{
"mcpServers": {
"honcho": {
"url": "https://mcp.honcho.dev",
"headers": {
"Authorization": "Bearer hch-your-key-here",
"X-Honcho-User-Name": "YourName"
}
}
}
}
```
```bash Claude Code
claude mcp add honcho \
--transport http \
--url "https://mcp.honcho.dev" \
--header "Authorization: Bearer hch-your-key-here" \
--header "X-Honcho-User-Name: YourName"
```
</CodeGroup>
---
## CLI
Inspect and debug a running Honcho deployment from your terminal. The honcho CLI wraps the Python SDK with agent-friendly defaults — JSON output, structured errors, and commands for every primitive (workspaces, peers, sessions, messages, conclusions).
**Get started:**
```bash
uv tool install honcho-cli
honcho init # configure apiKey + environmentUrl
honcho doctor # verify connectivity
```
The CLI also ships an agent skill. Install it with `npx skills add plastic-labs/honcho` and pick `honcho-cli` from the list.
See the [full CLI reference](/v3/documentation/reference/cli) for all commands, flags, and environment variables.
---
## Claude Code Plugin
Use Honcho to build with Honcho! The [plugin](/v3/guides/integrations/claudecode) provides claude code persistent memory that survives context wipes and session restarts.
Use Honcho to build with Honcho! The [plugin](/v3/guides/integrations/claudecode) provides Claude Code persistent memory that survives context wipes and session restarts.
```bash
/plugin marketplace add plastic-labs/claude-honcho
/plugin install honcho@honcho # Tools for Claude to use Honcho to manage it's own context
/plugin install honcho-dev@honcho # Skills to teach claude how to integrate Honcho
/plugin install honcho@honcho # Tools for Claude to use Honcho to manage its own context
/plugin install honcho-dev@honcho # Skills to teach Claude how to integrate Honcho
```
The markeplace also includes all the agent skills below, so you can use `/honcho-dev:integrate` directly after installing.
The marketplace also includes all the agent skills below, so you can use `/honcho-dev:integrate` directly after installing.
See the [full Claude Code integration guide](/v3/guides/integrations/claudecode) for setup details.
---
## OpenCode Plugin
The [OpenCode plugin](/v3/guides/integrations/opencode) gives OpenCode sessions persistent memory that survives context wipes, session restarts, and fresh chats.
```bash
bunx @honcho-ai/opencode-honcho install
```
Then run `/honcho:setup` inside OpenCode. See the [full OpenCode integration guide](/v3/guides/integrations/opencode) for setup details.
---
## Agent Skills
We provide agent skills for coding assistants like Claude Code, Cursor, Windsurf, and others.
We provide agent skills for coding assistants like Claude Code, OpenCode, Cursor, Windsurf, and others.
<CodeGroup>
```bash Install via npx (Recommended)
@ -58,6 +139,12 @@ curl -o ~/.claude/skills/honcho-integration.md https://raw.githubusercontent.com
Invoke with `/honcho-integration` in your coding agent.
#### honcho-cli
**For inspection & debugging.** Teaches your coding agent the right commands and flags for the [honcho CLI](#cli) — peer memory, session context, queue status, dialectic quality.
Invoke implicitly when you ask your agent to inspect a Honcho deployment.
#### migrate-honcho-py / migrate-honcho-ts
**For SDK upgrades.** Migrates code from v1.6.0 to v2.0.0 (required for Honcho 3.0.0+). Use when upgrading the SDK or seeing errors about removed APIs like `observations`, `Representation`, `.core`, or `get_config`.
@ -89,6 +176,7 @@ I want to start building with Honcho - an open source memory library for buildin
- Core repo: https://github.com/plastic-labs/honcho
- Python SDK: https://github.com/plastic-labs/honcho-python
- TypeScript SDK: https://github.com/plastic-labs/honcho-node
- CLI (inspect & debug a deployment): https://github.com/plastic-labs/honcho/tree/main/honcho-cli
- Discord bot starter: https://github.com/plastic-labs/discord-python-starter
- Telegram bot example: https://github.com/plastic-labs/telegram-python-starter

View File

@ -0,0 +1,220 @@
---
title: 'CLI Reference'
description: 'Command-line interface for Honcho — inspect workspaces, peers, sessions, and memory from your terminal'
icon: 'terminal'
---
import CliCommands from "/snippets/cli-commands.mdx";
## Install
<CodeGroup>
```bash uv (recommended)
uv tool install honcho-cli
```
```bash uvx (ephemeral)
uvx honcho-cli
```
</CodeGroup>
## Quick Start
```bash
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
honcho doctor # verify your config + connectivity
honcho # show banner + command list
```
## Configuration
The CLI resolves config in this order: **flag → env var → config file → default**.
| Value | File key | Env var | Flag | Persisted? |
|-------------|-------------------|------------------------|------------------------|------------|
| API key | `apiKey` | `HONCHO_API_KEY` | — | Yes |
| API URL | `environmentUrl` | `HONCHO_BASE_URL` | — | Yes |
| Workspace | — | `HONCHO_WORKSPACE_ID` | `-w` / `--workspace` | No |
| Peer | — | `HONCHO_PEER_ID` | `-p` / `--peer` | No |
| Session | — | `HONCHO_SESSION_ID` | `-s` / `--session` | No |
| JSON output | — | `HONCHO_JSON` | `--json` | No |
### Persisted config
The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns only
`apiKey` and `environmentUrl` at the top level — everything else (`hosts`,
`sessions`, etc.) is written by other tools and left untouched on save.
```json
{
"apiKey": "hch-v3-...",
"environmentUrl": "https://api.honcho.dev",
"hosts": { "claude_code": { "...": "..." } }
}
```
<Info>
Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s`
flags or `HONCHO_*` env vars. **Not** persisted as CLI defaults. This is
deliberate: every invocation is explicit about what it operates on.
</Info>
### Runtime overrides
Workspace, peer, and session scoping are **per-command only** — pass flags or
`HONCHO_*` env vars on every invocation.
```bash
# Per-command flags
honcho peer card -w prod -p user
# Or export once per shell
export HONCHO_WORKSPACE_ID=prod
export HONCHO_PEER_ID=user
honcho peer card
# One-off against a different server
HONCHO_BASE_URL=http://localhost:8000 honcho workspace list
# CI/CD — env vars only, no config file needed
export HONCHO_API_KEY=hch-v3-xxx
export HONCHO_BASE_URL=https://api.honcho.dev
honcho workspace list
```
## Output & exit codes
Every command adapts its output to the context:
- **TTY** — human-readable tables via Rich.
- **Piped or redirected** — JSON automatically (detected via `isatty`).
- **`--json` flag / `HONCHO_JSON=1`** — force JSON regardless of terminal.
Collection commands emit JSON arrays; single-resource commands emit JSON objects. Errors are always structured:
```json
{
"error": {
"code": "PEER_NOT_FOUND",
"message": "Peer 'abc' not found in workspace 'my-ws'",
"details": {"workspace_id": "my-ws", "peer_id": "abc"}
}
}
```
| Exit code | Meaning |
|-----------|---------|
| `0` | Success |
| `1` | Client error (bad input, resource not found) |
| `2` | Server error |
| `3` | Auth error (missing or invalid API key) |
CI pipelines and agent runtimes can branch on these without parsing stderr.
## Command reference
<CliCommands />
## Workflows
### Inspect an unfamiliar workspace
When you pick up a workspace and need to orient — start broad, narrow to the peer and session you care about.
<Steps>
<Step title="Survey the workspace">
```bash
honcho workspace inspect --json
honcho peer list --json
```
</Step>
<Step title="Inspect a specific peer">
```bash
honcho peer inspect <peer_id> --json
honcho peer card <peer_id> --json
```
</Step>
<Step title="Review the peer's memory">
```bash
honcho conclusion list --observer <peer_id> --json
honcho conclusion search "topic" --observer <peer_id> --json
```
</Step>
<Step title="Debug a session">
```bash
honcho session inspect <session_id> --json
honcho message list <session_id> --last 20 --json
honcho session context <session_id> --json
honcho session summaries <session_id> --json
```
</Step>
</Steps>
<Tip>
`honcho session context` shows exactly what an agent would receive at inference time — check it before `honcho peer chat` if a response surprises you.
</Tip>
### A peer isn't learning
If new messages aren't producing new conclusions, work down the diagnostic ladder.
```bash
# Is observation enabled for this peer?
honcho peer inspect <peer_id> --json | jq '.configuration'
# Is the deriver actually processing?
honcho workspace queue-status --json
# Do any conclusions exist at all? Any for the expected topic?
honcho conclusion list --observer <peer_id> --json
honcho conclusion search "expected topic" --observer <peer_id> --json
```
### Session context looks wrong
When an agent's responses don't reflect what you expect it to know.
```bash
honcho session context <session_id> --json
honcho session summaries <session_id> --json
honcho message list <session_id> --last 50 --json
```
### Dialectic returns bad answers
When `honcho peer chat` or the dialectic API is hallucinating or missing context.
```bash
# What does the peer card actually say?
honcho peer card <peer_id> --json
# Any conclusions for this topic?
honcho conclusion search "topic" --observer <peer_id> --json
# Reproduce the query against the CLI
honcho peer chat <peer_id> "what do you know about X?" --json
```
## Scripting & automation
Pipe commands into `jq` for inline transforms, or set `HONCHO_*` env vars for a CI/CD environment with no config file:
```bash
# Pipe to jq
honcho peer list --json | jq '.[].id'
honcho workspace inspect --json | jq '.peers'
# Machine-parseable health check — exit code for CI, details for logs
honcho doctor --json
# CI/CD — env vars only, no ~/.honcho/config.json
export HONCHO_API_KEY=hch-v3-xxx
export HONCHO_BASE_URL=https://api.honcho.dev
honcho workspace list
```
Non-interactive onboarding:
```bash
# Pre-seed via flags / env vars; init still prompts for anything missing
HONCHO_API_KEY=hch-v3-xxx honcho init --base-url https://api.honcho.dev
```

View File

@ -163,7 +163,7 @@ Dive into our [API Reference](/v3/api-reference) to explore all available endpoi
<Card title="Sign up to Honcho Platform" icon="rocket" href="https://app.honcho.dev">
Get started with managed Honcho instances
</Card>
<Card title="Join our Discord" icon="discord" href="http://discord.gg/plasticlabs">
<Card title="Join our Discord" icon="discord" href="http://discord.gg/honcho">
Connect with 1000+ developers building with Honcho
</Card>
<Card title="Contribute to Honcho" icon="code" href="/v3/contributing/guidelines">

View File

@ -218,6 +218,14 @@ const session = await honcho.session(id);
// List all peers in workspace (returns Page<Peer>)
const peers = await honcho.peers();
// List with pagination and filtering
const filtered = await honcho.peers({
filters: { metadata: { role: "user" } },
page: 1,
size: 25,
reverse: true
});
// List all sessions in workspace (returns Page<Session>)
const sessions = await honcho.sessions();
@ -234,7 +242,7 @@ const workspaces = await honcho.workspaces();
</CodeGroup>
<Info>
Peer and session creation is **lazy** - no API calls are made until you actually use the peer or session.
`peer()` and `session()` always make a get-or-create API call, returning objects with cached metadata, configuration, and timestamps.
</Info>
### Peer
@ -243,7 +251,7 @@ Represents an entity that can participate in conversations:
<CodeGroup>
```python Python
# Create peers (lazy creation - no API call yet)
# Create peers (get-or-create API call)
alice = honcho.peer("alice")
assistant = honcho.peer("assistant")
@ -254,6 +262,7 @@ alice = honcho.peer("bob", config={"role": "user", "active": True}, metadata={"l
# Peer properties
print(f"Peer ID: {alice.id}")
print(f"Workspace: {alice.workspace_id}")
print(f"Created: {alice.created_at}") # Available after API fetch
# Chat with peer's representations (supports streaming)
response = alice.chat("What did I have for breakfast?")
@ -305,6 +314,7 @@ const assistant = await honcho.peer("assistant");
// Peer properties
console.log(`Peer ID: ${alice.id}`);
console.log(`Created: ${alice.createdAt}`); // Available after API fetch
// Chat with peer's representations (supports streaming)
const response = await alice.chat("What did I have for breakfast?");
@ -551,7 +561,7 @@ Manages multi-party conversations:
<CodeGroup>
```python Python
# Create session (like peers, lazy creation)
# Create session (get-or-create API call)
session = honcho.session("conversation-1")
# Create with immediate configuration
@ -561,6 +571,8 @@ session = honcho.session("meeting-1", config={"type": "meeting", "max_peers": 10
# Session properties
print(f"Session ID: {session.id}")
print(f"Workspace: {session.workspace_id}")
print(f"Created: {session.created_at}") # Available after API fetch
print(f"Active: {session.is_active}") # Available after API fetch
# Peer management
session.add_peers([alice, assistant])
@ -579,8 +591,12 @@ session.add_messages([
assistant.message("Hi Alice! How can I help today?")
])
# Get messages
# Get messages (with optional pagination)
messages = session.messages()
messages = session.messages(page=1, size=100, reverse=True)
# Get a single message by ID
message = session.get_message("message-id")
# Get conversation context
context = session.context(summary=True, tokens=2000)
@ -641,6 +657,8 @@ const session = await honcho.session("conversation-1");
// Session properties
console.log(`Session ID: ${session.id}`);
console.log(`Created: ${session.createdAt}`); // Available after API fetch
console.log(`Active: ${session.isActive}`); // Available after API fetch
// Peer management
await session.addPeers([alice, assistant]);
@ -658,8 +676,12 @@ await session.addMessages([
assistant.message("Hi Alice! How can I help today?")
]);
// Get messages
// Get messages (with optional pagination)
const messages = await session.messages();
const paged = await session.messages({ page: 1, size: 100, reverse: true });
// Get a single message by ID
const message = await session.getMessage("message-id");
// Get conversation context
const context = await session.context({ summary: true, tokens: 2000 });
@ -668,10 +690,10 @@ const context = await session.context({ summary: true, tokens: 2000 });
const richContext = await session.context({
tokens: 2000,
peerTarget: "user",
searchQuery: "What are my preferences?",
peerPerspective: "assistant",
limitToSession: true,
representationOptions: {
searchQuery: "What are my preferences?",
searchTopK: 10,
searchMaxDistance: 0.8,
includeMostFrequent: true,
@ -729,7 +751,7 @@ const metadata = await session.getMetadata();
<CodeGroup>
```python Python
from honcho import SessionPeerConfig
from honcho.api_types import SessionPeerConfig
# Configure peer observation settings
config = SessionPeerConfig(
@ -811,12 +833,12 @@ The SessionContext object has the following structure:
| `tokens` | `int` | Maximum tokens to include |
| `peer_target` | `str` | Peer ID to get representation for |
| `peer_perspective` | `str` | Peer ID for perspective (requires peer_target) |
| `search_query` | `str` or `Message` | Query string or Message object for semantic search |
| `limit_to_session` | `bool` | Limit representation to session only |
| `search_top_k` | `int` | Number of semantic search results (1-100) |
| `search_max_distance` | `float` | Max semantic distance (0.0-1.0) |
| `include_most_frequent` | `bool` | Include most frequent conclusions |
| `max_conclusions` | `int` | Max conclusions to include (1-100) |
| `representationOptions.searchQuery` | `str` or `Message` | Query string or Message object for semantic search |
| `representationOptions.searchTopK` | `int` | Number of semantic search results (1-100) |
| `representationOptions.searchMaxDistance` | `float` | Max semantic distance (0.0-1.0) |
| `representationOptions.includeMostFrequent` | `bool` | Include most frequent conclusions |
| `representationOptions.maxConclusions` | `int` | Max conclusions to include (1-100) |
## Advanced Usage
@ -988,23 +1010,46 @@ const actionItems = await session.messages({
### Pagination
All list methods support `page`, `size`, and `reverse` parameters:
<CodeGroup>
```python Python
# Iterate through all sessions
# Default pagination (page 1, size 50)
for session in honcho.sessions():
print(f"Session: {session.id}")
# Iterate through session messages
for message in session.messages():
print(f" {message.peer_id}: {message.content}")
# Custom page size
for message in session.messages(size=100):
print(f" {message.peer_id}: {message.content}")
# Start at a specific page
page3 = session.messages(page=3, size=25)
# Reverse ordering
recent_first = session.messages(reverse=True)
# Combine with filters
filtered = session.messages(filters={"peer_id": "alice"}, size=10)
```
```typescript TypeScript
// Get paginated results
// Default pagination (page 1, size 50)
const peersPage = await honcho.peers();
// Iterate through all items
for await (const peer of peersPage) {
// Custom page size and filtering
const filtered = await honcho.peers({
filters: { metadata: { role: "user" } },
size: 25
});
// Start at a specific page
const page3 = await session.messages({ page: 3, size: 25 });
// Reverse ordering
const recent = await session.messages({ reverse: true });
// Iterate through all items (auto-paginates)
for await (const peer of await honcho.peers()) {
console.log(`Peer: ${peer.id}`);
}
@ -1048,8 +1093,8 @@ const supportAgent = await honcho.peer(`agent-${agentId}`);
<CodeGroup>
```python Python
# Lazy creation - no API calls until needed
peers = [honcho.peer(f"user-{i}") for i in range(100)] # Fast
# Create peers (each makes a get-or-create call)
peers = [honcho.peer(f"user-{i}") for i in range(100)]
# Batch operations when possible
session.add_messages([peer.message(f"Message {i}") for i, peer in enumerate(peers)])
@ -1059,7 +1104,7 @@ context = session.context(tokens=1500) # Limit context size
```
```typescript TypeScript
// Lazy creation - no API calls until needed
// Create peers (each makes a get-or-create call)
const peers = await Promise.all(
Array.from({ length: 100 }, (_, i) => honcho.peer(`user-${i}`))
);

View File

@ -1,33 +0,0 @@
---
title: "Hermes Agent"
icon: 'bolt'
description: "Add AI-native memory to Hermes Agent"
sidebarTitle: 'Hermes Agent'
---
[Hermes Agent](https://github.com/NousResearch/hermes-agent) is an open-source AI agent from Nous Research with advanced tool-calling capabilities, terminal access, a skills system, and multi-platform deployment (Telegram, Discord, Slack, WhatsApp). The Honcho integration gives Hermes persistent cross-session memory and user modeling.
## Getting Started
Honcho support is built into Hermes Agent. See the [Hermes Agent README](https://github.com/NousResearch/hermes-agent) for full installation and configuration instructions.
The integration is opt-in and requires:
1. A Honcho API key from [app.honcho.dev](https://app.honcho.dev)
2. The `honcho-ai` package (`pip install hermes-agent[honcho]`)
3. Enabling Honcho in your Hermes config
## How It Works
The integration runs alongside Hermes's existing `USER.md` memory system. Honcho adds cross-session reasoning — prefetching user context into each turn, syncing exchanges for ongoing modeling, and exposing a dialectic tool (`query_user_context`) for the agent to query its understanding mid-conversation.
## Next Steps
<CardGroup cols={2}>
<Card title="Hermes Agent" icon="github" href="https://github.com/NousResearch/hermes-agent">
Source code, installation, and full documentation.
</Card>
<Card title="Honcho Architecture" icon="sitemap" href="../../documentation/core-concepts/architecture">
Learn about peers, sessions, and dialectic reasoning.
</Card>
</CardGroup>

View File

@ -0,0 +1,38 @@
---
title: "Pi"
icon: 'pi'
description: "Persistent memory extension for the pi coding agent"
sidebarTitle: 'Pi'
---
[pi-honcho-memory](https://github.com/agneym/pi-honcho-memory) is a persistent memory extension for [pi](https://pi.dev), a coding agent CLI. It gives pi long-term memory across sessions — user preferences, project context, and past decisions are remembered and automatically injected into the system prompt.
## Getting Started
Install the extension inside pi:
```bash
pi install npm:@agney/pi-honcho-memory
```
The integration requires:
1. A Honcho API key from [app.honcho.dev](https://app.honcho.dev)
2. Running `/honcho-setup` inside pi for interactive configuration, or setting `HONCHO_API_KEY` in your environment
The Honcho plugin is a community integration. See the [plugin README](https://github.com/agneym/pi-honcho-memory/blob/main/README.md) for full installation and configuration instructions.
## How It Works
The extension hooks into pi's extension system. It automatically syncs user and assistant messages to Honcho after each agent response, injects cached user profile and project context into the system prompt with zero network latency, and exposes LLM tools (`honcho_search`, `honcho_chat`, `honcho_remember`) for active memory operations. Session scoping is configurable — memory can be shared per repo, per git branch, or per directory. If Honcho is unavailable, pi continues working normally.
## Next Steps
<CardGroup cols={2}>
<Card title="Extension Repository" icon="github" href="https://github.com/agneym/pi-honcho-memory">
Source code, installation, and full documentation.
</Card>
<Card title="Honcho Architecture" icon="sitemap" href="../../documentation/core-concepts/architecture">
Learn about peers, sessions, and dialectic reasoning.
</Card>
</CardGroup>

631
docs/v3/guides/gmail.mdx Normal file
View File

@ -0,0 +1,631 @@
---
title: "Gmail"
icon: 'envelope'
description: "Load Gmail threads into Honcho to give your AI agents memory of email conversations."
sidebarTitle: 'Gmail'
---
In this tutorial, we'll walk through how to ingest your Gmail emails into Honcho. By the end, each email thread will be a Honcho session and each participant will be a peer — giving your agents memory of who said what across your email history.
This guide includes a ready-to-run Python script that handles everything: Gmail OAuth, thread fetching, participant extraction, and Honcho ingestion. You can run it as-is or use the full tutorial below to understand each piece as you go.
<Note>
The full script is available on [GitHub](https://github.com/plastic-labs/honcho/tree/main/examples/gmail). This is a developer-focused tutorial — it requires creating a Google Cloud project and OAuth credentials.
</Note>
## TL;DR
If you just want to get your emails into Honcho, here's everything you need.
### 1. Set Up Google Cloud Credentials
Follow Google's official [Gmail API Python Quickstart](https://developers.google.com/gmail/api/quickstart/python) to:
1. Create a Google Cloud project and enable the Gmail API
2. Configure the OAuth consent screen
3. Create OAuth credentials (select **Desktop app** as the application type)
4. Download the credentials JSON into the same directory as the script
The script auto-detects Google's default `client_secret_*.json` filename, so no renaming needed. The script only needs the `gmail.readonly` scope.
### 2. Install Dependencies
<CodeGroup>
```bash uv
uv pip install google-api-python-client google-auth-oauthlib honcho-ai
```
```bash pip
pip install google-api-python-client google-auth-oauthlib honcho-ai
```
</CodeGroup>
### 3. Preview with a Dry Run
<CodeGroup>
```bash uv
uv run honcho_gmail.py --dry-run --max-threads 5
```
```bash python
python honcho_gmail.py --dry-run --max-threads 5
```
</CodeGroup>
On first run, a browser window opens for OAuth consent. After authorizing, a `token.json` file is created — future runs skip this step.
### 4. Load into Honcho
<CodeGroup>
```bash uv
export HONCHO_API_KEY=your_api_key
uv run honcho_gmail.py --workspace gmail-inbox --max-threads 20
```
```bash python
export HONCHO_API_KEY=your_api_key
python honcho_gmail.py --workspace gmail-inbox --max-threads 20
```
</CodeGroup>
You can filter threads with Gmail search syntax:
<CodeGroup>
```bash uv
uv run honcho_gmail.py --query "from:alice@example.com"
uv run honcho_gmail.py --label INBOX
uv run honcho_gmail.py --query "after:2024/01/01 has:attachment" --max-threads 50
```
```bash python
python honcho_gmail.py --query "from:alice@example.com"
python honcho_gmail.py --label INBOX
python honcho_gmail.py --query "after:2024/01/01 has:attachment" --max-threads 50
```
</CodeGroup>
That's it — your emails are now queryable in Honcho. Read on if you want to understand how the script works and the design decisions behind it.
---
## Full Tutorial
### How Gmail Maps to Honcho
The core idea is straightforward: each Gmail thread becomes a Honcho session, and each email participant becomes a peer. Here's the full mapping:
| Gmail Concept | Honcho Concept | Details |
|---------------|----------------|---------|
| Your Gmail account | Workspace (`gmail`) | One workspace for all email data |
| Email participant | Peer | Email address as ID for deduplication |
| Email thread | Session (`gmail-thread-{id}`) | One session per thread, all participants attached |
| Individual email | Message | Attributed to the sender with original timestamp |
### Email as Peer ID
The script normalizes email addresses into URL-safe peer IDs — `alice@example.com` becomes `alice-example-com`. This means the same person is automatically deduplicated across threads. If Alice emails you in 10 different threads, all of those conversations accumulate under a single peer.
```python
def peer_id_from_email(email: str) -> str:
"""Convert email to a valid Honcho peer ID."""
return email.replace("@", "-").replace(".", "-")
```
This also means peers are consistent across data sources. If you import Granola meetings and Gmail threads for the same person, they merge under the same peer ID.
### Extracting Participants
Every email has a sender, recipients, and optionally CC/BCC addresses. The script extracts all of these to build a complete picture of who's involved in each thread:
```python
for m in msgs:
register_peer(m["from"])
for addr in parse_address_list(m["to"]):
register_peer(addr)
for addr in parse_address_list(m["cc"]):
register_peer(addr)
for addr in parse_address_list(m["bcc"]):
register_peer(addr)
```
Display names are extracted when available (e.g., `Alice Smith <alice@example.com>` → name: "Alice Smith"). When only an email is present, the script generates a name from the local part.
### Message Attribution and Timestamps
Each email becomes a message attributed to its sender via `peer.message()`. The original email timestamp is preserved using `created_at`, so Honcho sees the conversation in chronological order — not the order you imported it.
```python
honcho_msgs.append(peer.message(
content,
metadata={
"gmail_id": m["id"],
"subject": m["subject"],
"from": m["from"],
"to": m["to"],
"labels": m["labels"],
},
created_at=m["timestamp"],
))
```
### Multi-Peer Sessions
Each thread's session is linked to all participants using `session.add_peers()`. This means when you query Honcho about a peer, it has context not just from their messages but from the full conversations they participated in.
```python
session = honcho.session(session_id, metadata={
"gmail_thread_id": tid,
"subject": subject,
"source": "gmail",
"message_count": len(msgs),
})
session.add_peers(thread_peers)
```
### Stripping Quoted Replies
Email threads are full of quoted replies — each message repeats everything above it. The script strips these out so only the new content is stored per message, avoiding duplication in Honcho's memory:
```python
def strip_quoted_replies(text: str) -> str:
"""Strip quoted reply text, keeping only the new content."""
lines = text.split("\n")
clean_lines = []
for line in lines:
stripped = line.strip()
if re.match(r"^On .+wrote:\s*$", stripped):
break
if stripped.startswith(">"):
break
# ... other reply markers
clean_lines.append(line)
return "\n".join(clean_lines).rstrip()
```
### Querying After Import
Once your emails are in Honcho, you can query any peer:
```python
import os
from honcho import Honcho
honcho = Honcho(workspace_id="gmail-inbox", api_key=os.environ["HONCHO_API_KEY"])
alice = honcho.peer("alice-example-com")
print(alice.chat("What has Alice been discussing with me?"))
print(alice.chat("What action items has Alice mentioned?"))
```
---
## CLI Reference
```
usage: honcho_gmail.py [-h] [--workspace WORKSPACE] [--query QUERY]
[--label LABEL] [--max-threads N] [--dry-run]
[--credentials PATH] [--token PATH]
options:
--workspace, -w Honcho workspace ID (default: gmail)
--query, -q Gmail search query (e.g., 'from:alice@example.com')
--label, -l Gmail label to filter by (e.g., INBOX)
--max-threads, -n Max threads to fetch (default: 10)
--dry-run Preview without writing to Honcho
--credentials, -c Path to OAuth credentials JSON (auto-detects client_secret*.json)
--token, -t Path to store access token (default: token.json)
```
## Troubleshooting
### "No client_secret*.json file found"
Download OAuth credentials from Google Cloud Console and place the `client_secret_*.json` file in the same directory as the script.
### "Access blocked: This app's request is invalid"
Your OAuth consent screen may not be configured correctly. Ensure you've added the `gmail.readonly` scope.
### "Token has been expired or revoked"
Delete `token.json` and run the script again to re-authenticate.
### Rate Limits
The script includes a small delay when creating peers to avoid hitting Honcho's rate limits. For large imports (100+ threads), consider running in batches.
### Unique Messages
Use an AI assistant in your inbox? Want to parse out its messages differently? Feel free to modify and improve the structure of this script to fit your bespoke email setup. This script was written for agents and as such is easy to update with your coding assistant.
## Full Script
<Accordion title="honcho_gmail.py">
```python
#!/usr/bin/env python3
"""Load Gmail messages into Honcho.
Uses the Gmail API directly (with OAuth) to fetch emails and the Honcho Python SDK to store them.
Each Gmail thread becomes a Honcho session, each sender becomes a peer.
Prerequisites:
1. Create a Google Cloud project and enable the Gmail API
2. Create OAuth 2.0 credentials (Desktop app type)
3. Download the credentials JSON (client_secret_*.json) into this directory
4. Install dependencies:
pip install google-api-python-client google-auth-oauthlib honcho-ai
On first run, a browser window will open for OAuth consent. After authorizing,
a 'token.json' file will be created to store your credentials for future runs.
"""
import argparse
import base64
import glob
import os
import re
import time
from datetime import datetime, timezone
from email.header import decode_header, make_header
from email.utils import getaddresses, parseaddr
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
PEER_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
def find_credentials() -> str:
"""Find a Google OAuth credentials file in the current directory."""
matches = glob.glob("client_secret*.json")
if matches:
return matches[0]
raise FileNotFoundError(
"No client_secret*.json file found.\n"
"Download OAuth credentials from Google Cloud Console:\n"
"1. Go to console.cloud.google.com\n"
"2. Create/select a project and enable Gmail API\n"
"3. Create OAuth 2.0 credentials (Desktop app)\n"
"4. Download the JSON into this directory"
)
def get_gmail_service(credentials_file: str | None = None, token_file: str = "token.json"):
"""Authenticate and return a Gmail API service instance."""
creds = None
if os.path.exists(token_file):
creds = Credentials.from_authorized_user_file(token_file, SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
print("Refreshing expired credentials...")
creds.refresh(Request())
else:
if credentials_file is None:
credentials_file = find_credentials()
print(f"Using credentials: {credentials_file}")
print("Opening browser for OAuth consent...")
flow = InstalledAppFlow.from_client_secrets_file(credentials_file, SCOPES)
creds = flow.run_local_server(port=0)
with open(token_file, "w") as token:
token.write(creds.to_json())
print(f"Credentials saved to {token_file}")
return build("gmail", "v1", credentials=creds)
def list_threads(service, query: str = None, label_ids: list = None, max_results: int = 10) -> list[dict]:
"""List Gmail threads with pagination support."""
all_threads = []
page_token = None
while len(all_threads) < max_results:
try:
params = {
"userId": "me",
"maxResults": min(100, max_results - len(all_threads)),
}
if query:
params["q"] = query
if label_ids:
params["labelIds"] = label_ids
if page_token:
params["pageToken"] = page_token
response = service.users().threads().list(**params).execute()
threads = response.get("threads", [])
all_threads.extend(threads)
page_token = response.get("nextPageToken")
if not page_token:
break
except HttpError as e:
print(f"Error listing threads: {e}")
break
return all_threads[:max_results]
def get_thread(service, thread_id: str) -> dict:
"""Fetch a complete Gmail thread with all messages."""
try:
return service.users().threads().get(
userId="me",
id=thread_id,
format="full"
).execute()
except HttpError as e:
print(f"Error fetching thread {thread_id}: {e}")
return {}
def _decode_header_str(header: str) -> str:
"""Decode an RFC 2047 encoded header string to plain Unicode."""
return str(make_header(decode_header(header)))
def extract_email(from_header: str) -> str:
"""Extract bare email from an RFC 5322 header value."""
_, addr = parseaddr(_decode_header_str(from_header))
return addr.lower().strip()
def extract_name(from_header: str) -> str:
"""Extract display name from an RFC 5322 header value."""
name, _ = parseaddr(_decode_header_str(from_header))
return name.strip() or from_header.strip()
def decode_body(payload: dict) -> str:
"""Recursively extract plain text from a Gmail message payload."""
if payload.get("mimeType") == "text/plain":
data = payload.get("body", {}).get("data", "")
if data:
return base64.urlsafe_b64decode(data).decode("utf-8", errors="replace")
parts = payload.get("parts", [])
for part in parts:
text = decode_body(part)
if text:
return text
return ""
def strip_quoted_replies(text: str) -> str:
"""Strip quoted reply text from an email body, keeping only the new content."""
lines = text.split("\n")
clean_lines = []
for line in lines:
stripped = line.strip()
if re.match(r"^On .+wrote:\s*$", stripped):
break
if stripped.startswith("---------- Forwarded message"):
break
if stripped.startswith(">"):
break
if re.match(r"^[-_]{10,}$", stripped):
break
clean_lines.append(line)
return "\n".join(clean_lines).rstrip()
def parse_address_list(header: str) -> list[str]:
"""Parse a comma-separated email header into individual addresses."""
if not header.strip():
return []
decoded = _decode_header_str(header)
return [
f"{name} <{addr}>" if name else addr
for name, addr in getaddresses([decoded])
if addr
]
def peer_id_from_email(email: str) -> str:
"""Convert email to a valid Honcho peer ID."""
peer_id = re.sub(r"[^A-Za-z0-9_-]+", "-", email).strip("-").lower()
peer_id = re.sub(r"-{2,}", "-", peer_id)
if not peer_id:
peer_id = "unknown-peer"
if not PEER_ID_PATTERN.fullmatch(peer_id):
raise ValueError(f"Generated peer ID is invalid: {peer_id!r}")
return peer_id
def fetch_thread_messages(service, thread_id: str) -> list[dict]:
"""Fetch all messages in a Gmail thread with full content."""
data = get_thread(service, thread_id)
messages = []
for msg in data.get("messages", []):
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
body = strip_quoted_replies(decode_body(msg.get("payload", {})))
ts = int(msg.get("internalDate", "0")) / 1000
messages.append({
"id": msg["id"],
"thread_id": msg["threadId"],
"from": headers.get("From", ""),
"to": headers.get("To", ""),
"cc": headers.get("Cc", ""),
"bcc": headers.get("Bcc", ""),
"subject": headers.get("Subject", ""),
"date": headers.get("Date", ""),
"timestamp": datetime.fromtimestamp(ts, tz=timezone.utc),
"body": body.strip(),
"labels": msg.get("labelIds", []),
"snippet": msg.get("snippet", ""),
})
return messages
def main():
parser = argparse.ArgumentParser(description="Load Gmail messages into Honcho")
parser.add_argument("--workspace", "-w", default="gmail", help="Honcho workspace ID (default: gmail)")
parser.add_argument("--query", "-q", default=None, help="Gmail search query (e.g. 'from:alice@example.com')")
parser.add_argument("--label", "-l", default=None, help="Gmail label to filter by (e.g. INBOX)")
parser.add_argument("--max-threads", "-n", type=int, default=10, help="Max threads to fetch (default: 10)")
parser.add_argument("--dry-run", action="store_true", help="Print what would be loaded without writing to Honcho")
parser.add_argument("--credentials", "-c", default=None, help="Path to OAuth credentials JSON (auto-detects client_secret*.json)")
parser.add_argument("--token", "-t", default="token.json", help="Path to store/load access token")
args = parser.parse_args()
# Authenticate
print("Authenticating with Gmail API...")
service = get_gmail_service(args.credentials, args.token)
print(" Authenticated successfully!")
label_ids = [args.label] if args.label else None
# List threads
print(f"\nFetching up to {args.max_threads} threads from Gmail...")
threads = list_threads(service, query=args.query, label_ids=label_ids, max_results=args.max_threads)
print(f" Found {len(threads)} threads")
if not threads:
print("No threads found. Try adjusting --query or --label.")
return
# Fetch full messages for each thread
all_thread_messages = {}
seen_peers = {}
def register_peer(addr: str):
email = extract_email(addr)
if email and email not in seen_peers:
name = extract_name(addr)
if name.lower().strip() == email or "@" in name:
name = email.split("@")[0].replace(".", " ").title()
seen_peers[email] = {
"name": name,
"peer_id": peer_id_from_email(email),
"email": email,
}
for i, t in enumerate(threads):
tid = t["id"]
print(f" Fetching thread {i+1}/{len(threads)}: {tid}")
msgs = fetch_thread_messages(service, tid)
all_thread_messages[tid] = msgs
for m in msgs:
register_peer(m["from"])
for addr in parse_address_list(m["to"]):
register_peer(addr)
for addr in parse_address_list(m["cc"]):
register_peer(addr)
for addr in parse_address_list(m["bcc"]):
register_peer(addr)
# Summary
total_msgs = sum(len(v) for v in all_thread_messages.values())
print("\nSummary:")
print(f" Threads: {len(all_thread_messages)}")
print(f" Messages: {total_msgs}")
print(f" Unique participants: {len(seen_peers)}")
for email, info in seen_peers.items():
print(f" {info['peer_id']} ({info['name']} <{email}>)")
if args.dry_run:
print("\n[DRY RUN] Would create the above in Honcho. Showing first message per thread:")
for tid, msgs in all_thread_messages.items():
m = msgs[0]
body_preview = m["body"][:120].replace("\n", " ") if m["body"] else m["snippet"][:120]
print(f" Thread {tid}: {m['subject']}")
print(f" {m['from']} @ {m['date']}")
print(f" {body_preview}...")
return
# Load into Honcho
from honcho import Honcho
print(f"\nLoading into Honcho workspace '{args.workspace}'...")
honcho = Honcho(workspace_id=args.workspace)
# Create peers
peers = {}
for i, (email, info) in enumerate(seen_peers.items()):
if i > 0 and i % 4 == 0:
time.sleep(1)
peers[email] = honcho.peer(info["peer_id"], metadata={
"email": email,
"name": info["name"],
"source": "gmail",
})
print(f" Peer: {info['peer_id']}")
# Create sessions and messages per thread
for tid, msgs in all_thread_messages.items():
subject = msgs[0]["subject"] if msgs else "No subject"
session_id = f"gmail-thread-{tid}"
thread_peer_emails = set()
for m in msgs:
thread_peer_emails.add(extract_email(m["from"]))
for addr in parse_address_list(m["to"]):
thread_peer_emails.add(extract_email(addr))
for addr in parse_address_list(m["cc"]):
thread_peer_emails.add(extract_email(addr))
for addr in parse_address_list(m["bcc"]):
thread_peer_emails.add(extract_email(addr))
thread_peers = [peers[e] for e in thread_peer_emails if e in peers]
session = honcho.session(session_id, metadata={
"gmail_thread_id": tid,
"subject": subject,
"source": "gmail",
"message_count": len(msgs),
})
session.add_peers(thread_peers)
honcho_msgs = []
for m in msgs:
email = extract_email(m["from"])
peer = peers.get(email)
if not peer:
continue
content = m["body"] if m["body"] else m["snippet"]
if not content:
continue
honcho_msgs.append(peer.message(
content,
metadata={
"gmail_id": m["id"],
"subject": m["subject"],
"from": m["from"],
"to": m["to"],
"labels": m["labels"],
},
created_at=m["timestamp"],
))
if honcho_msgs:
session.add_messages(honcho_msgs)
print(f" Session {session_id}: {len(honcho_msgs)} messages — {subject[:60]}")
print(f"\nDone! Loaded {total_msgs} messages into workspace '{args.workspace}'.")
if __name__ == "__main__":
main()
```
</Accordion>
## Next Steps
<CardGroup cols={2}>
<Card title="Design Patterns" icon="cubes" href="/v3/documentation/core-concepts/design-patterns">
See how the Granola integration maps to common Honcho patterns.
</Card>
<Card title="GitHub Repository" icon="github" href="https://github.com/plastic-labs/honcho/tree/main/examples/gmail">
Source code and example script.
</Card>
</CardGroup>

953
docs/v3/guides/granola.mdx Normal file
View File

@ -0,0 +1,953 @@
---
title: "Granola"
icon: 'microphone'
description: "Import meeting notes and transcripts from Granola into Honcho"
sidebarTitle: 'Granola'
---
In this tutorial, we'll walk through how to import your [Granola](https://granola.ai) meeting data into Honcho. By the end, your meeting participants, transcripts, and summaries will be mapped onto Honcho's peer and session model — giving your agents queryable memory of the people you meet with.
This guide includes a ready-to-run Python script that handles everything: Granola OAuth, meeting fetching, participant detection, and interactive import. You can run it as-is or use the full tutorial below to understand each design decision.
<Note>
The full script is available on [GitHub](https://github.com/plastic-labs/honcho/tree/main/examples/granola).
</Note>
## TL;DR
If you just want to get your meetings into Honcho, here's everything you need.
### 1. Install Dependencies
<CodeGroup>
```bash uv
uv pip install honcho-ai httpx
```
```bash pip
pip install honcho-ai httpx
```
</CodeGroup>
### 2. Set Your API Key
```bash
export HONCHO_API_KEY="your-key-from-app.honcho.dev"
```
### 3. Run the Script
<CodeGroup>
```bash uv
uv run python honcho_granola.py
```
```bash python
python honcho_granola.py
```
</CodeGroup>
The script will:
1. Open your browser for Granola OAuth authentication
2. Fetch all meetings and their content
3. Walk you through each meeting interactively — confirm peers, choose import mode, skip meetings you don't want
4. Print a summary of what was transferred
That's it — your meetings are now queryable in Honcho. Read on if you want to understand how the script works and the design decisions behind it.
---
## Full Tutorial
### How Granola Maps to Honcho
The core idea is straightforward: each Granola meeting becomes a Honcho session, and each participant becomes a peer. Here's the full mapping:
| Granola Concept | Honcho Concept | Details |
|-----------------|----------------|---------|
| Your Granola account | Workspace (`granola`) | One workspace for all meetings |
| Meeting participant | Peer | Email as ID for deduplication across meetings |
| Individual meeting | Session (`meeting-{id}`) | One session per meeting |
| Transcript turns | Messages with attribution | Two-person calls get full speaker attribution |
| Meeting summary | Message from note creator | Multi-person calls store the summary |
### Email as Peer ID
The script uses email addresses as the basis for peer IDs, normalized to a URL-safe format (e.g., `alice@example.com` becomes `alice-example-com`). This ensures consistent identification across meetings — if you meet someone in 5 different calls, all conversations accumulate under the same peer.
```python
# These all resolve to the same peer:
honcho.peer("alice-example-com") # From Meeting A
honcho.peer("alice-example-com") # From Meeting B
```
This also means peers are consistent across data sources. If you import both Granola meetings and Gmail threads for the same person, they merge under the same peer ID.
### Auto-Detecting "Me"
Granola marks the note creator in its participant list with `(note creator)`. The script uses this to identify you automatically — no configuration needed.
```
Participants: You (note creator) from Your Company <you@example.com>,
Alice from Acme Corp <alice@example.com>
```
### Two-Person Calls: Full Attribution
When exactly one other participant is present *and* the transcript contains `Them:` turns, the script stores the transcript with speaker-attributed messages. Consecutive same-speaker turns are merged before storing, cleaning up the fragmentation that's common in raw transcripts.
```python
session.add_messages([
me.message("What's your timeline for the launch?"),
them.message("We're targeting Q2, but it depends on the API integration."),
])
```
### Multi-Person Calls: Summary Mode
Granola's transcript uses `Them:` for all non-creator speakers with no disambiguation — in a 4-person call, everyone else is just `Them:`. Rather than guess incorrectly, the script stores Granola's summary as your record of the meeting, with participants in metadata.
```python
session.add_messages([
me.message(
f"Meeting: Product Planning\n"
f"Date: Mar 5, 2026 2:00 PM\n"
f"Participants: Alice from Acme Corp, Bob from Widgets Inc\n\n"
f"{meeting_summary}",
metadata={
"participants": "Alice from Acme Corp, Bob from Widgets Inc",
"mode": "summary",
"granola_meeting_id": meeting_id,
}
)
])
```
The summary is attributed to you because it's *your* record of what happened. Granola captured your notes from a meeting where those people were present.
### Interactive Confirmation
For each meeting, you choose the import mode: two-person (full attribution), summary, or skip. For multi-person calls that are actually 1:1s (extra participants listed but didn't speak), you can override the detection and select the actual speaker.
### Noisy Transcripts Preserved
Granola's raw transcripts are often fragmented (`Me: Yeah. Them: Yeah. Me: And.`). The script merges consecutive same-speaker turns but otherwise preserves the raw content. Honcho's reasoning extracts signal from noisy data.
### Querying After Import
Once your meetings are in Honcho, you can query any peer:
```python
import os
from honcho import Honcho
honcho = Honcho(workspace_id="granola", api_key=os.environ["HONCHO_API_KEY"])
# Peer IDs are normalized from emails: alice@example.com -> alice-example-com
alice = honcho.peer("alice-example-com")
print(alice.chat("What is Alice working on?"))
print(alice.chat("What concerns has Alice raised?"))
me = honcho.peer("you-example-com")
print(me.chat("What topics do I discuss most frequently?"))
```
### Combining with Other Sources
Because meetings live in a standard Honcho workspace, you can enrich peer representations with data from other channels:
```python
# Same workspace, same peer — data accumulates
alice = honcho.peer("alice-example-com")
me = honcho.peer("you-example-com")
discord_session = honcho.session("discord-general-2024-03")
discord_session.add_messages([
alice.message("Just shipped the new API version!"),
me.message("Congrats! How's the migration guide coming?"),
])
# Queries now draw from both meeting transcripts AND Discord history
alice.chat("What has Alice shipped recently?")
```
---
## Troubleshooting
| Issue | Fix |
|-------|-----|
| Granola OAuth fails | Ensure you have a paid Granola plan (MCP requires Pro+). Clear cached token and retry. |
| Missing transcripts | Free tier has no transcript access. The script falls back to summary content. |
| 500 errors from Honcho | Check for null bytes or control characters in transcript content. The script sanitizes these automatically. |
| Rate limiting with many meetings | The script processes sequentially with delays. Honcho ingestion is async — don't poll for immediate results. |
## Full Script
<Accordion title="honcho_granola.py">
```python
#!/usr/bin/env python3
"""Load Granola meeting notes into Honcho.
Uses the Granola MCP server (with OAuth) to fetch meetings and the Honcho Python SDK
to store them. Each meeting becomes a Honcho session. Two-person meetings get full
speaker attribution; multi-person meetings are stored as summaries.
Prerequisites:
pip install honcho-ai httpx
Environment Variables:
HONCHO_API_KEY - Your Honcho API key (get from app.honcho.dev/api-keys)
Usage:
python honcho_granola.py
"""
import asyncio
import base64
import hashlib
import json
import os
import re
import secrets
import sys
import threading
import traceback
import webbrowser
from dataclasses import dataclass, field
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
@dataclass
class Participant:
name: str
email: str | None = None
org: str | None = None
@dataclass
class ParsedParticipants:
note_creator: Participant | None = None
others: list[Participant] = field(default_factory=list)
@dataclass
class TranscriptTurn:
speaker: str
text: str
# Granola MCP + OAuth endpoints
GRANOLA_MCP_URL = "https://mcp.granola.ai/mcp"
AUTH_BASE = "https://mcp-auth.granola.ai"
OAUTH_REDIRECT_PORT = 8765
OAUTH_REDIRECT_URI = f"http://localhost:{OAUTH_REDIRECT_PORT}/callback"
# Honcho message size limit (25000 max, leave headroom)
MAX_MESSAGE_LEN = 24000
# ---------------------------------------------------------------------------
# OAuth callback handler (must be a class for BaseHTTPRequestHandler)
# ---------------------------------------------------------------------------
class _OAuthCallback(BaseHTTPRequestHandler):
auth_result: dict[str, str | None] = {"code": None, "error": None}
def do_GET(self):
params = parse_qs(urlparse(self.path).query)
if "code" in params:
_OAuthCallback.auth_result["code"] = params["code"][0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"<h1>Authenticated! You can close this window.</h1>")
elif "error" in params:
_OAuthCallback.auth_result["error"] = params.get("error_description", params["error"])[0]
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(f"<h1>Error: {_OAuthCallback.auth_result['error']}</h1>".encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, fmt, *args):
pass
# ---------------------------------------------------------------------------
# Granola OAuth + MCP
# ---------------------------------------------------------------------------
async def authenticate(http_client: httpx.AsyncClient) -> str:
"""Perform OAuth (DCR + PKCE) with Granola. Returns access token."""
_OAuthCallback.auth_result = {"code": None, "error": None}
print("\nAuthenticating with Granola...")
# Register client (DCR)
resp = await http_client.post(
f"{AUTH_BASE}/oauth2/register",
json={
"client_name": "Granola to Honcho Transfer",
"redirect_uris": [OAUTH_REDIRECT_URI],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
},
)
if resp.status_code not in (200, 201):
raise RuntimeError(f"Client registration failed: {resp.status_code}")
client_id = resp.json().get("client_id")
# PKCE
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
# Browser auth
auth_url = f"{AUTH_BASE}/oauth2/authorize?" + urlencode({
"client_id": client_id,
"redirect_uri": OAUTH_REDIRECT_URI,
"response_type": "code",
"state": "granola-honcho-transfer",
"code_challenge": challenge,
"code_challenge_method": "S256",
})
server = HTTPServer(("localhost", OAUTH_REDIRECT_PORT), _OAuthCallback)
thread = threading.Thread(target=server.handle_request)
thread.start()
print(" Opening browser for authentication...")
webbrowser.open(auth_url)
thread.join(timeout=120)
server.server_close()
auth_result = _OAuthCallback.auth_result
if auth_result["error"]:
raise RuntimeError(f"Authentication failed: {auth_result['error']}")
if not auth_result["code"]:
raise RuntimeError("Authentication timed out")
# Exchange code for token
resp = await http_client.post(
f"{AUTH_BASE}/oauth2/token",
data={
"grant_type": "authorization_code",
"code": auth_result["code"],
"redirect_uri": OAUTH_REDIRECT_URI,
"client_id": client_id,
"code_verifier": verifier,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if resp.status_code != 200:
raise RuntimeError(f"Token exchange failed: {resp.status_code}")
print(" Authenticated successfully!")
return resp.json()["access_token"]
async def call_mcp_tool(
http_client: httpx.AsyncClient,
access_token: str,
tool_name: str,
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Call a Granola MCP tool, handling both JSON and SSE responses."""
resp = await http_client.post(
GRANOLA_MCP_URL,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": tool_name, "arguments": arguments or {}},
},
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
)
if resp.status_code != 200:
raise RuntimeError(f"MCP call failed: {resp.status_code} - {resp.text}")
# SSE response
if "text/event-stream" in resp.headers.get("content-type", ""):
result = None
for line in resp.text.split("\n"):
if line.strip().startswith("data: "):
try:
parsed = json.loads(line.strip()[6:])
if "result" in parsed:
result = parsed
elif "error" in parsed:
raise RuntimeError(f"MCP error: {parsed['error']}")
except json.JSONDecodeError:
continue
if result:
final = result.get("result", {})
return final if isinstance(final, dict) else {"result": final}
raise RuntimeError("No result in SSE response")
# JSON response
result = resp.json()
if "error" in result:
raise RuntimeError(f"MCP error: {result['error']}")
return result.get("result", {})
def extract_mcp_text(result: dict[str, Any]) -> str:
"""Extract text from the first content block of an MCP result.
Raises ValueError if the response structure is unexpected.
"""
content = result.get("content", [])
if not isinstance(content, list) or not content:
raise ValueError(f"MCP response missing content array: {list(result.keys())}")
first = content[0]
if not isinstance(first, dict) or "text" not in first:
raise ValueError(f"MCP content block missing 'text' field: {first}")
return str(first["text"])
# ---------------------------------------------------------------------------
# Granola data fetching
# ---------------------------------------------------------------------------
async def list_meetings(
http_client: httpx.AsyncClient, access_token: str, limit: int = 100,
) -> list[dict[str, Any]]:
"""List meetings from Granola MCP. Parses Granola's XML-like response format."""
result = await call_mcp_tool(http_client, access_token, "list_meetings", {"limit": limit})
text = extract_mcp_text(result)
meetings: list[dict[str, Any]] = []
for match in re.finditer(r'<meeting\s+id="([^"]+)"\s+title="([^"]+)"\s+date="([^"]+)"', text):
mid, title, date = match.groups()
block_end = text.find("</meeting>", match.end())
block = text[match.end():block_end] if block_end != -1 else ""
p_match = re.search(r"<known_participants>\s*(.*?)\s*</known_participants>", block, re.DOTALL)
meetings.append({
"id": mid,
"title": title,
"date": date,
"participants": p_match.group(1).strip() if p_match else "",
})
return meetings
async def get_meeting_details(
http_client: httpx.AsyncClient, access_token: str, meeting_id: str,
) -> dict[str, Any]:
"""Get full meeting details including notes."""
result = await call_mcp_tool(http_client, access_token, "get_meetings", {"meeting_ids": [meeting_id]})
text = extract_mcp_text(result)
return {"id": meeting_id, "raw_content": text}
async def get_meeting_transcript(
http_client: httpx.AsyncClient, access_token: str, meeting_id: str,
max_retries: int = 3,
) -> str | None:
"""Get transcript for a meeting (paid tiers only).
Retries on rate limit responses with exponential backoff.
"""
for attempt in range(max_retries):
try:
result = await call_mcp_tool(http_client, access_token, "get_meeting_transcript", {"meeting_id": meeting_id})
text = extract_mcp_text(result)
except Exception as e:
print(f" Transcript unavailable: {e}")
return None
if not text or "no transcript" in text.lower():
return None
# Granola returns rate limit errors as content text, not HTTP errors
if "rate limit" in text.lower():
wait = 2 ** attempt * 3 # 3s, 6s, 12s
print(f" ⚠ Granola rate limit hit (attempt {attempt + 1}/{max_retries}), waiting {wait}s...")
await asyncio.sleep(wait)
continue
return text
print(f" ⚠ Transcript skipped after {max_retries} rate limit retries")
return None
async def fetch_all_meetings(
http_client: httpx.AsyncClient, access_token: str,
) -> list[dict[str, Any]]:
"""Fetch meeting list and enrich each with transcript and details."""
print("\nFetching meetings from Granola...")
meetings = await list_meetings(http_client, access_token, limit=500)
if not meetings:
print("No meetings found.")
return []
print(f" Found {len(meetings)} meetings. Fetching content...\n")
for i, m in enumerate(meetings, 1):
mid = m.get("id")
if not mid:
continue
transcript = await get_meeting_transcript(http_client, access_token, mid)
if transcript:
m["transcript"] = transcript
try:
m.update(await get_meeting_details(http_client, access_token, mid))
except Exception as exc:
print(f" Failed to fetch details for {mid}: {exc}")
has_t = "transcript" in m
has_s = bool(extract_summary(m))
label = "transcript+summary" if has_t and has_s else "transcript only" if has_t else "summary only" if has_s else "basic only"
print(f" [{i}/{len(meetings)}] {label}: {m.get('title', 'Untitled')[:45]}")
await asyncio.sleep(1.5) # rate limit
return meetings
# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------
def parse_participants(participants_str: str) -> ParsedParticipants:
"""Parse Granola's participant string into structured participants.
Warns on unparsable entries instead of silently dropping them.
"""
result = ParsedParticipants()
if not participants_str:
return result
# Split on commas, but not inside angle brackets
entries, current, depth = [], [], 0
for ch in participants_str:
if ch == "<":
depth += 1
elif ch == ">":
depth = max(depth - 1, 0)
elif ch == "," and depth == 0:
entries.append("".join(current))
current = []
continue
current.append(ch)
if current:
entries.append("".join(current))
for entry in entries:
entry = entry.strip()
if not entry:
continue
is_creator = "(note creator)" in entry
clean = entry.replace("(note creator)", "").strip()
email_match = re.search(r"<([^>]+)>", clean)
email = email_match.group(1) if email_match else None
name = re.sub(r"\s*<[^>]+>", "", clean).strip()
if not name:
print(f" Warning: could not parse participant entry: {entry!r}")
continue
org = None
org_match = re.match(r"(.+?)\s+from\s+(.+)", name)
if org_match:
name, org = org_match.group(1).strip(), org_match.group(2).strip()
person = Participant(name=name, email=email, org=org)
if is_creator:
result.note_creator = person
else:
result.others.append(person)
return result
def parse_transcript_turns(raw: str) -> list[TranscriptTurn]:
"""Split a Granola transcript into speaker turns."""
# Unwrap JSON wrapper if present
try:
parsed = json.loads(raw)
if isinstance(parsed, dict) and "transcript" in parsed:
raw = str(parsed["transcript"])
except (json.JSONDecodeError, TypeError):
pass
parts = re.split(r"(?:^|\s{2,})(Me|Them):\s*", raw)
turns: list[TranscriptTurn] = []
i = 1
while i < len(parts) - 1:
text = parts[i + 1].strip()
if text:
turns.append(TranscriptTurn(speaker=parts[i], text=text))
i += 2
return turns
def extract_summary(meeting: dict[str, Any]) -> str:
"""Extract best available summary text from meeting data."""
candidates = []
for key in ("summary", "notes", "note", "meeting_notes", "description"):
val = meeting.get(key)
if isinstance(val, str) and val.strip():
candidates.append(val.strip())
raw = meeting.get("raw_content")
if isinstance(raw, str) and raw.strip():
candidates.append(raw.strip())
for c in candidates:
for tag in ("summary", "notes"):
m = re.search(rf"<{tag}>\s*(.*?)\s*</{tag}>", c, re.DOTALL)
if m:
return m.group(1).strip()
return candidates[0] if candidates else ""
def peer_id_from(value: str) -> str:
"""Normalize a name or email into a Honcho-safe peer ID."""
norm = re.sub(r"[^a-z0-9_-]+", "-", value.strip().lower())
norm = re.sub(r"-{2,}", "-", norm).strip("-_")
return (norm or "peer")[:100]
def sanitize(text: str) -> str:
"""Remove null bytes and control characters."""
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
def parse_date(date_str: str) -> datetime:
"""Parse Granola's date format into a timezone-aware datetime.
Raises ValueError if the date string doesn't match any known format.
"""
for fmt in ["%b %d, %Y %I:%M %p", "%b %d, %Y %I:%M:%S %p", "%B %d, %Y %I:%M %p"]:
try:
return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
raise ValueError(f"Unrecognized date format: {date_str!r}")
# ---------------------------------------------------------------------------
# Honcho import helpers
# ---------------------------------------------------------------------------
def build_messages(
peer: Any,
content: str,
metadata: dict[str, object] | None,
created_at: datetime,
) -> list[Any]:
"""Build chunked messages for a single peer, attaching metadata to the first chunk."""
messages = []
content = sanitize(content)
for start in range(0, len(content), MAX_MESSAGE_LEN):
chunk = content[start:start + MAX_MESSAGE_LEN]
msg_meta = metadata if start == 0 else None
messages.append(peer.message(chunk, metadata=msg_meta, created_at=created_at))
return messages
def send_messages(session: Any, messages: list[Any]) -> None:
"""Send messages to a session in batches of 100."""
for batch_start in range(0, len(messages), 100):
session.add_messages(messages[batch_start:batch_start + 100])
def import_two_person(
honcho: Any,
session: Any,
me_peer_id: str,
them_peer_id: str,
turns: list[TranscriptTurn],
metadata: dict[str, object],
created_at: datetime,
) -> None:
"""Import a two-person meeting with speaker attribution."""
me_peer = honcho.peer(me_peer_id)
them_peer = honcho.peer(them_peer_id)
# Merge consecutive same-speaker turns
merged: list[TranscriptTurn] = []
for t in turns:
if merged and merged[-1].speaker == t.speaker:
merged[-1].text += " " + t.text
else:
merged.append(TranscriptTurn(speaker=t.speaker, text=t.text))
messages: list[Any] = []
for i, t in enumerate(merged):
peer = me_peer if t.speaker == "Me" else them_peer
msg_meta = metadata if i == 0 else None
messages.extend(build_messages(peer, t.text, msg_meta, created_at))
send_messages(session, messages)
print(f" -> Imported as 2-person ({me_peer_id} + {them_peer_id})")
def import_summary(
honcho: Any,
session: Any,
me_peer_id: str,
meeting: dict[str, Any],
metadata: dict[str, object],
created_at: datetime,
) -> None:
"""Import a meeting as a summary message."""
me_peer = honcho.peer(me_peer_id)
summary = extract_summary(meeting)
if not summary:
raw_t = meeting.get("transcript", "")
try:
parsed = json.loads(raw_t)
summary = str(parsed.get("transcript", "")) if isinstance(parsed, dict) else raw_t
except (json.JSONDecodeError, TypeError):
summary = raw_t
summary = summary or "No content available"
title = meeting.get("title", "Untitled")
date = meeting.get("date", "")
header = f"Meeting: {title}\nDate: {date}\nParticipants: {meeting.get('participants', '')}\n\n"
messages = build_messages(me_peer, header + summary, metadata, created_at)
send_messages(session, messages)
print(" -> Imported as summary")
def resolve_them_participant(others: list[Participant]) -> Participant | None:
"""Ask user to pick which participant is 'Them' from a multi-person meeting."""
for j, p in enumerate(others, 1):
email_str = f" <{p.email}>" if p.email else ""
print(f" {j}. {p.name}{email_str}")
idx_str = input(f" Who is 'Them'? [1-{len(others)}]: ").strip()
try:
return others[int(idx_str) - 1]
except (ValueError, IndexError):
print(" Invalid selection.")
return None
def review_meeting(
index: int,
total: int,
meeting: dict[str, Any],
participants: ParsedParticipants,
turns: list[TranscriptTurn],
) -> tuple[str, Participant | None]:
"""Display meeting info and get user's import choice.
Returns (mode, them_participant) where mode is one of:
- "two_person": import with speaker attribution using them_participant
- "summary": import as a single summary message
- "skip": skip this meeting
"""
title = meeting.get("title", "Untitled")
date = meeting.get("date", "")
creator = participants.note_creator
others = participants.others
me_turns = sum(1 for t in turns if t.speaker == "Me")
them_turns = len(turns) - me_turns
total_words = sum(len(t.text.split()) for t in turns)
print(f"\n{'─' * 60}")
print(f" [{index}/{total}] {title}")
print(f" Date: {date}")
if creator:
print(f" You: {creator.name} <{creator.email}>")
for j, p in enumerate(others, 1):
email_str = f" <{p.email}>" if p.email else ""
org_str = f" ({p.org})" if p.org else ""
print(f" {j}. {p.name}{email_str}{org_str}")
has_transcript = bool(meeting.get("transcript"))
if turns:
print(f" Transcript: {me_turns} Me, {them_turns} Them, ~{total_words} words")
if them_turns == 0:
print(" ** No 'Them' turns — nobody else spoke **")
if total_words < 30:
print(" ** Very short — might be empty **")
elif has_transcript:
raw = meeting["transcript"]
print(f" Transcript: present ({len(raw)} chars) but could not parse speaker turns")
print(f" Preview: {raw[:200]!r}")
else:
print(f" Content: {'summary available' if extract_summary(meeting) else 'metadata only'}")
# Two-person default: exactly one other participant with transcript
if len(others) == 1 and them_turns > 0:
them_label = others[0].name + (f" <{others[0].email}>" if others[0].email else "")
print(f"\n Detected: 2-person call (you + {them_label})")
choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower()
while choice not in ("", "s", "k"):
choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower()
if choice == "k":
return ("skip", None)
if choice == "s":
return ("summary", None)
return ("two_person", others[0])
# Multi-person with transcript
if len(others) > 1 and them_turns > 0:
print(f"\n {len(others)} participants")
choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower()
while choice not in ("", "2", "k"):
choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower()
if choice == "k":
return ("skip", None)
if choice == "2":
them = resolve_them_participant(others)
if them is None:
return ("summary", None)
return ("two_person", them)
return ("summary", None)
# No transcript or no other speakers
choice = input(" [Enter] summary / [k] skip: ").strip().lower()
while choice not in ("", "k"):
choice = input(" [Enter] summary / [k] skip: ").strip().lower()
if choice == "k":
return ("skip", None)
return ("summary", None)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
async def main():
print("=" * 60)
print(" Granola -> Honcho Meeting Notes Transfer")
print("=" * 60)
if not os.environ.get("HONCHO_API_KEY"):
print("\nError: HONCHO_API_KEY not set.")
print(" Get your key at: https://app.honcho.dev/api-keys")
sys.exit(1)
async with httpx.AsyncClient(timeout=60.0) as http_client:
try:
access_token = await authenticate(http_client)
meetings = await fetch_all_meetings(http_client, access_token)
if not meetings:
sys.exit(0)
from honcho import Honcho
honcho = Honcho(workspace_id="granola")
seen_peers: set[str] = set()
results = {"imported": 0, "skipped": 0, "failed": 0}
print("\n" + "=" * 60)
print(" Review each meeting")
print("=" * 60)
for i, m in enumerate(meetings, 1):
mid = m.get("id")
if not mid:
continue
participants = parse_participants(m.get("participants", ""))
turns = parse_transcript_turns(m["transcript"]) if m.get("transcript") else []
mode, them = review_meeting(i, len(meetings), m, participants, turns)
if mode == "skip":
print(" -> Skipped")
results["skipped"] += 1
continue
# Resolve creator peer
creator = participants.note_creator
me_source = (creator.email or creator.name) if creator else None
if not me_source:
print(" -> Skipped (no creator identifier)")
results["skipped"] += 1
continue
me_peer_id = peer_id_from(me_source)
if me_peer_id not in seen_peers:
print(f" New peer: {me_source} ({me_peer_id})")
seen_peers.add(me_peer_id)
try:
created_at = parse_date(m.get("date", ""))
session = honcho.session(f"meeting-{mid}")
metadata: dict[str, object] = {
"title": m.get("title", "Untitled"),
"date": m.get("date", ""),
"granola_meeting_id": mid,
"mode": mode,
}
if mode == "two_person" and them is not None:
them_source = them.email or them.name
them_peer_id = peer_id_from(them_source)
if them_peer_id not in seen_peers:
print(f" New peer: {them_source} ({them_peer_id})")
seen_peers.add(them_peer_id)
import_two_person(honcho, session, me_peer_id, them_peer_id, turns, metadata, created_at)
else:
import_summary(honcho, session, me_peer_id, m, metadata, created_at)
results["imported"] += 1
except ValueError as e:
print(f" -> FAILED: {e}")
results["failed"] += 1
except Exception as e:
print(f" -> FAILED: {e}")
traceback.print_exc()
results["failed"] += 1
# Done
print("\n" + "=" * 60)
print(" Transfer Complete!")
print("=" * 60)
print(f"\n Imported: {results['imported']}")
print(f" Skipped: {results['skipped']}")
print(f" Failed: {results['failed']}")
print(" Workspace: granola")
print(f" Peers: {sorted(seen_peers)}")
except KeyboardInterrupt:
print("\n\nAborted.")
sys.exit(0)
except Exception as e:
print(f"\nTransfer failed: {e}")
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
```
</Accordion>
## Next Steps
<CardGroup cols={2}>
<Card title="Design Patterns" icon="cubes" href="/v3/documentation/core-concepts/design-patterns">
See how the Granola integration maps to common Honcho patterns.
</Card>
<Card title="GitHub Repository" icon="github" href="https://github.com/plastic-labs/honcho/tree/main/examples/granola">
Source code and example script.
</Card>
</CardGroup>

View File

@ -0,0 +1,139 @@
---
title: "Hermes Agent + Honcho"
sidebarTitle: "Hermes Agent"
description: "How Hermes Agent uses Honcho for persistent cross-session memory and user modeling"
icon: "message-bot"
---
[Hermes Agent](https://github.com/NousResearch/hermes-agent) is an open-source AI agent from [Nous Research](https://nousresearch.com) with tool-calling, terminal access, a skills system, and multi-platform deployment (Telegram, Discord, Slack, WhatsApp). Honcho gives Hermes persistent cross-session memory and user modeling.
For setup, configuration, and CLI commands, see the [Hermes Agent Honcho docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/honcho).
## What Honcho provides
Honcho acts as a long-term memory and user-model layer alongside Hermes' built-in memory files (`MEMORY.md` and `USER.md`).
It gives Hermes three capabilities:
1. **Prompt-time context injection** -- durable context about a user loaded into the prompt before generating a response.
2. **Cross-session continuity** -- recall of stable preferences, project history, and working context across conversations.
3. **Durable writeback** -- stable facts learned during a conversation stored back for future turns.
These sit alongside Hermes' local session history. Session history remembers the current conversation. Honcho remembers what should still matter later.
## Dual-peer architecture
Both the user and the AI agent have peer representations in Honcho:
- **User peer**: observed from user messages. Learns preferences, goals, communication style.
- **AI peer**: observed from assistant messages. Builds the agent's knowledge representation.
Both representations are injected into the system prompt, giving Hermes awareness of both who it's talking to and what it knows.
## Available tools
Hermes exposes four Honcho tools to the agent:
| Tool | What it does |
|---|---|
| `honcho_profile` | Fast peer card retrieval (no LLM). Returns curated key facts about the user. |
| `honcho_search` | Semantic search over memory. Returns raw excerpts ranked by relevance. |
| `honcho_context` | Dialectic Q&A powered by Honcho's LLM. Synthesizes answers from conversation history. |
| `honcho_conclude` | Writes durable facts to Honcho when the user states preferences, corrections, or important context. |
## Running Honcho locally with Hermes
Follow the [Self-Hosting Guide](/v3/contributing/self-hosting) to get Honcho running locally. Once it's up, point Hermes at your instance:
```bash
hermes memory setup # select "honcho", enter http://localhost:8000 as the base URL
```
Or manually create/edit the config file (checked in order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`):
```json
{
"baseUrl": "http://localhost:8000",
"hosts": {
"hermes": {
"enabled": true,
"aiPeer": "hermes",
"peerName": "your-name",
"workspace": "hermes"
}
}
}
```
For the full list of config fields (`recallMode`, `writeFrequency`, `sessionStrategy`, `dialecticReasoningLevel`, etc.), see the [Hermes memory provider docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory-providers#honcho).
<Info>
**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers and Hermes Agent integration.
</Info>
## Verifying the integration
### 1. Check status
```bash
hermes memory status
```
This should show Honcho as the active memory provider with your base URL.
### 2. Store a fact and recall it across sessions
In one conversation, tell Hermes something specific:
```text
My favorite programming language is Rust and I always use dark mode.
```
Start a **new session** (different thread, new CLI invocation, or a different platform). Ask:
```text
What do you know about my preferences?
```
If Hermes mentions Rust and dark mode without being told again, cross-session memory is working. The deriver processed your messages, extracted observations, and the dialectic recalled them.
### 3. Test tool calling directly
Ask Hermes to use a specific Honcho tool:
```text
Use your honcho_search tool to find anything you know about me.
```
If Hermes calls the tool and returns results, the full tool pipeline (API connection, vector search, embedding) is functional.
## Configuration options
| Field | Default | Description |
|---|---|---|
| `recallMode` | `hybrid` | `hybrid` (auto-inject + tools), `context` (inject only), `tools` (tools only) |
| `writeFrequency` | `async` | `async`, `turn`, `session`, or integer N |
| `sessionStrategy` | `per-directory` | `per-directory`, `per-repo`, `per-session`, `global` |
| `dialecticReasoningLevel` | `low` | `minimal`, `low`, `medium`, `high`, `max` |
| `dialecticDynamic` | `true` | Auto-bump reasoning level by query complexity |
| `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) |
## Next steps
<CardGroup cols={2}>
<Card title="Hermes Agent Honcho Docs" icon="book" href="https://hermes-agent.nousresearch.com/docs/user-guide/features/honcho">
Setup, configuration, CLI commands, and all config options.
</Card>
<Card title="Hermes Agent Source" icon="github" href="https://github.com/NousResearch/hermes-agent">
Source code, installation, and full documentation.
</Card>
<Card title="Honcho Architecture" icon="sitemap" href="/v3/documentation/core-concepts/architecture">
Peers, sessions, and how reasoning works.
</Card>
<Card title="Self-Hosting Guide" icon="server" href="/v3/contributing/self-hosting">
Full local environment setup, provider configuration, and troubleshooting.
</Card>
</CardGroup>

View File

@ -1,19 +1,33 @@
---
title: "Model Context Protocol (MCP)"
icon: 'star-of-life'
description: "Use Honcho in Claude Desktop"
description: "Give any AI tool persistent memory with the Honcho MCP server"
sidebarTitle: 'MCP'
---
You can let Claude use Honcho to manage its own memory in the native desktop app by using the Honcho MCP integration! Follow these steps:
The Honcho MCP server gives any MCP-compatible AI tool persistent memory and personalization. Connect it once and your AI assistant learns who you are, remembers your preferences, and gets better over time — across every conversation.
1. Go to https://app.honcho.dev and get an API key. Then go to Claude Desktop and navigate to custom MCP servers.
**Server URL:** `https://mcp.honcho.dev`
<Note>
If you don't have node installed you will need to do that. Claude Desktop or Claude Code can help!
You'll need an API key from [app.honcho.dev](https://app.honcho.dev) to use the hosted MCP server.
</Note>
2. Add Honcho to your Claude desktop config. You must provide a username for Honcho to refer to you as -- preferably what you want Claude to actually call you.
## Client Setup
Pick your client below and add the config. After adding, **restart the client fully** for changes to take effect.
### Claude Desktop
<Tabs>
<Tab title="macOS">
Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
</Tab>
<Tab title="Windows">
Edit `%APPDATA%\Claude\claude_desktop_config.json`:
</Tab>
</Tabs>
```json
{
"mcpServers": {
@ -28,15 +42,202 @@ If you don't have node installed you will need to do that. Claude Desktop or Cla
"X-Honcho-User-Name:${USER_NAME}"
],
"env": {
"AUTH_HEADER": "Bearer <your-honcho-key>",
"USER_NAME": "<your-name>"
"AUTH_HEADER": "Bearer hch-your-key-here",
"USER_NAME": "YourName"
}
}
}
}
```
You may customize your assistant name and/or workspace ID. Both are optional.
<Tip>
After saving, fully quit and relaunch Claude Desktop. The Honcho tools should appear in the tool picker.
</Tip>
For best results, create a project and paste these [instructions](https://raw.githubusercontent.com/plastic-labs/honcho/refs/heads/main/mcp/instructions.md) into the "Project Instructions" field so Claude knows how to use the memory tools.
### Claude Code
```bash
claude mcp add honcho \
--transport http \
--url "https://mcp.honcho.dev" \
--header "Authorization: Bearer hch-your-key-here" \
--header "X-Honcho-User-Name: YourName"
```
Or if you prefer the [Claude Code Honcho plugin](/v3/guides/integrations/claudecode) for a deeper integration with persistent memory, git awareness, and agent skills:
```bash
/plugin marketplace add plastic-labs/claude-honcho
```
### Codex
Add to `~/.codex/config.toml`:
```toml
[mcp_servers.honcho]
command = "npx"
args = [
"mcp-remote",
"https://mcp.honcho.dev",
"--header",
"Authorization:Bearer hch-your-key-here",
"--header",
"X-Honcho-User-Name:YourName"
]
```
<Note>
Codex only supports stdio transport, so it uses `mcp-remote` as a bridge. Restart both the Codex CLI and VS Code extension after editing.
</Note>
### Cursor
Cursor supports MCP servers natively via HTTP. Add to your global config at `~/.cursor/mcp.json` or per-project at `.cursor/mcp.json`:
```json
{
"mcpServers": {
"honcho": {
"url": "https://mcp.honcho.dev",
"headers": {
"Authorization": "Bearer hch-your-key-here",
"X-Honcho-User-Name": "YourName"
}
}
}
}
```
Alternatively, go to **Cursor Settings → MCP** and add a new HTTP server with the URL and headers above.
### Windsurf
Add to `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"honcho": {
"serverUrl": "https://mcp.honcho.dev",
"headers": {
"Authorization": "Bearer hch-your-key-here",
"X-Honcho-User-Name": "YourName"
}
}
}
}
```
<Note>
Windsurf uses `serverUrl` instead of `url`.
</Note>
### VS Code (Copilot Chat)
Add to your workspace `.vscode/mcp.json`:
```json
{
"servers": {
"honcho": {
"type": "http",
"url": "https://mcp.honcho.dev",
"headers": {
"Authorization": "Bearer hch-your-key-here",
"X-Honcho-User-Name": "YourName"
}
}
}
}
```
Or add to your User Settings JSON (`Cmd+Shift+P` → "Preferences: Open User Settings (JSON)"):
```json
{
"mcp": {
"servers": {
"honcho": {
"type": "http",
"url": "https://mcp.honcho.dev",
"headers": {
"Authorization": "Bearer hch-your-key-here",
"X-Honcho-User-Name": "YourName"
}
}
}
}
}
```
### Cline
Cline supports remote MCP servers natively. Open Cline's MCP settings at:
<Tabs>
<Tab title="macOS">
`~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`
</Tab>
<Tab title="Windows">
`%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json`
</Tab>
</Tabs>
```json
{
"mcpServers": {
"honcho": {
"url": "https://mcp.honcho.dev",
"headers": {
"Authorization": "Bearer hch-your-key-here",
"X-Honcho-User-Name": "YourName"
}
}
}
}
```
Or add it via the Cline sidebar: click the MCP Servers icon → **Configure** → **Remote Servers**.
### Zed
Add to `~/.config/zed/settings.json`:
```json
{
"context_servers": {
"honcho": {
"url": "https://mcp.honcho.dev",
"headers": {
"Authorization": "Bearer hch-your-key-here",
"X-Honcho-User-Name": "YourName"
}
}
}
}
```
<Note>
Zed uses `context_servers` instead of `mcpServers`. Native HTTP support requires Zed v0.214.5 or later.
</Note>
---
## Optional Configuration
You can customize the assistant name and workspace ID by adding extra headers. Both are optional.
| Header | Default | Description |
|--------|---------|-------------|
| `Authorization` | *required* | `Bearer hch-your-key-here` |
| `X-Honcho-User-Name` | *required* | What the AI should call you |
| `X-Honcho-Assistant-Name` | `"Assistant"` | Name for the AI peer |
| `X-Honcho-Workspace-ID` | `"default"` | Isolate memory per project |
Example with all headers (Claude Desktop format):
```json
{
@ -56,18 +257,52 @@ You may customize your assistant name and/or workspace ID. Both are optional.
"X-Honcho-Workspace-ID:${WORKSPACE_ID}"
],
"env": {
"AUTH_HEADER": "Bearer <your-honcho-key>",
"USER_NAME": "<your-name>",
"ASSISTANT_NAME": "<your-assistant-name>",
"WORKSPACE_ID": "<your-custom-workspace-id>"
"AUTH_HEADER": "Bearer hch-your-key-here",
"USER_NAME": "YourName",
"ASSISTANT_NAME": "Claude",
"WORKSPACE_ID": "my-project"
}
}
}
}
```
3. Restart the Claude Desktop app. Upon relaunch, it should start Honcho and the tools should be available!
---
4. Finally, Claude needs instructions on how to use Honcho. The Desktop app doesn't allow you to add system prompts directly, but you can create a project and paste these [instructions](https://raw.githubusercontent.com/plastic-labs/honcho/refs/heads/main/mcp/instructions.md) into the "Project Instructions" field.
## Available Tools
Claude should then query for insights before responding and write your messages to storage! If you come up with more creative ways to get Claude to manage its own memory with Honcho, feel free to [let us know](https://discord.gg/plasticlabs) or make a PR on this [repo](https://github.com/plastic-labs/honcho/tree/main/mcp)!
The recommended flow for a standard conversation uses `create_session` + `add_messages_to_session` + `chat`. See the [full instructions](https://raw.githubusercontent.com/plastic-labs/honcho/refs/heads/main/mcp/instructions.md) for a complete walkthrough.
**Workspace** — `inspect_workspace`, `list_workspaces`, `search`, `get_metadata`, `set_metadata`
**Peers** — `create_peer`, `list_peers`, `chat`, `get_peer_card`, `set_peer_card`, `get_peer_context`, `get_representation`
**Sessions** — `create_session`, `list_sessions`, `delete_session`, `clone_session`, `add_peers_to_session`, `remove_peers_from_session`, `get_session_peers`, `inspect_session`, `add_messages_to_session`, `get_session_messages`, `get_session_message`, `get_session_context`
**Conclusions** — `list_conclusions`, `query_conclusions`, `create_conclusions`, `delete_conclusion`
**System** — `schedule_dream`, `get_queue_status`
---
## Verify It Works
After setup, try asking your AI assistant:
> "What do you know about me?"
On the first conversation there won't be much — but after a few exchanges, Honcho's background reasoning will start building a representation of you. Ask again after a couple of conversations and you'll see the difference.
---
## Troubleshooting
| Problem | Fix |
|---------|-----|
| Tools don't show up | Make sure you fully restarted the client after adding the config. |
| Authorization errors | Check your API key at [app.honcho.dev](https://app.honcho.dev). It should start with `hch-`. |
| `npx` not found | Install Node.js — your AI assistant can help with this. |
| "No personalization insights found" | Normal for new users. Honcho needs a few conversations to build context. |
| Connection timeouts | Check that `https://mcp.honcho.dev` is accessible from your network. |
Need help? Join us on [Discord](https://discord.gg/honcho) or open an issue on [GitHub](https://github.com/plastic-labs/honcho/tree/main/mcp).

View File

@ -11,6 +11,7 @@ sidebarTitle: 'OpenClaw'
Honcho can run entirely locally with OpenClaw — no external API required. Keep your data on your machine while getting full memory capabilities across all channels. See the [self-hosting guide](/v3/contributing/self-hosting) to get started.
</Note>
For OpenClaw's own documentation on Honcho, see the [Honcho Memory guide](https://docs.openclaw.ai/concepts/memory-honcho).
## Install the Plugin
@ -67,7 +68,7 @@ Files are uploaded via `session.uploadFile()`. User/owner files go to the owner
Once installed, the plugin runs automatically:
* **Message Observation** — After every AI turn, the conversation is persisted to Honcho. Both user and agent messages are observed, allowing Honcho to build and refine its models.
* **Tool-Based Context Access** — The AI can query Honcho mid-conversation using tools like `honcho_recall`, `honcho_search`, and `honcho_analyze` to retrieve relevant context. Context is injected during OpenClaw's `before_prompt_build` phase, ensuring accurate turn boundaries.
* **Tool-Based Context Access** — The AI can query Honcho mid-conversation using tools like `honcho_context`, `honcho_search_conclusions`, `honcho_search_messages`, and `honcho_ask` to retrieve relevant context. Context is injected during OpenClaw's `before_prompt_build` phase, ensuring accurate turn boundaries.
* **Dual Peer Model** — Honcho maintains separate representations: one for the user (preferences, facts, communication style) and one for the agent (personality, learned behaviors). Each OpenClaw agent gets its own Honcho peer (`agent-{id}`), so multi-agent workspaces maintain isolated memory.
* **Clean Persistence** — Platform metadata (conversation info, sender headers, thread context, forwarded messages) is stripped before saving to Honcho, ensuring only meaningful content is persisted.
@ -84,17 +85,16 @@ OpenClaw uses a multi-agent architecture where a primary agent can spawn **subag
| Tool | Description |
| ---- | ----------- |
| `honcho_session` | Conversation history and summaries from the current session. |
| `honcho_profile` | User's peer card — key facts (name, preferences, role). |
| `honcho_search` | Semantic search over stored observations. |
| `honcho_context` | Full user representation across all sessions. |
| `honcho_context` | User knowledge across all sessions. `detail='card'` for key facts, `'full'` for broad representation. |
| `honcho_search_conclusions` | Semantic vector search over stored conclusions ranked by relevance. |
| `honcho_search_messages` | Find specific messages across all sessions. Filter by sender, date, or metadata. |
| `honcho_session` | Current session history and summary. Supports semantic search within the session. |
### Q&A (LLM-powered)
| Tool | Description |
| ---- | ----------- |
| `honcho_recall` | Simple factual question — minimal reasoning. |
| `honcho_analyze` | Complex question requiring synthesis — medium reasoning. |
| `honcho_ask` | Ask Honcho a question about the user. `depth='quick'` for facts, `'thorough'` for synthesis. |
## CLI Commands
@ -126,32 +126,27 @@ openclaw honcho setup
## Local File Search (QMD Integration)
The plugin automatically exposes OpenClaw's `memory_search` and `memory_get` tools when a memory backend is configured, allowing both Honcho cloud memory and local file search together.
The plugin automatically exposes OpenClaw's `memory_search` and `memory_get` tools when a [memory backend](https://docs.openclaw.ai/concepts/memory) is configured, allowing both Honcho memory and local file search together.
### Setup
1. Install [QMD](https://github.com/tobi/qmd) on your server
2. Configure OpenClaw in `~/.openclaw/openclaw.json`:
2. Configure OpenClaw to use QMD as the memory backend in `~/.openclaw/openclaw.json`:
```json
{
"memory": {
"backend": "qmd",
"qmd": {
"limits": {
"timeoutMs": 120000
}
}
"backend": "qmd"
}
}
```
3. Set up QMD collections and restart:
OpenClaw manages QMD collections automatically from your workspace memory files and any extra paths in `memory.qmd.paths`. See the [QMD Memory Engine docs](https://docs.openclaw.ai/concepts/memory-qmd) for full setup.
3. Restart the gateway:
```bash
qmd collection add ~/Documents/notes --name notes
qmd update
openclaw gateway restart
```
@ -172,6 +167,10 @@ When QMD is configured, you get both Honcho and local file tools:
Source code, issues, and README.
</Card>
<Card title="OpenClaw Memory Docs" icon="book" href="https://docs.openclaw.ai/concepts/memory">
Memory backends, search, and configuration in the OpenClaw docs.
</Card>
<Card title="Honcho Architecture" icon="sitemap" href="/v3/documentation/core-concepts/architecture">
Learn about peers, sessions, and dialectic reasoning.
</Card>

View File

@ -0,0 +1,196 @@
---
title: "OpenCode"
icon: 'code'
description: "Add AI-native memory to OpenCode"
sidebarTitle: 'OpenCode'
---
Give OpenCode long-term memory that survives context wipes, session restarts, and fresh chats. OpenCode remembers what you're working on, your durable preferences, and prior context across every project you touch.
## Quick Start
### Step 1: Get Your Honcho API Key
1. Go to **[app.honcho.dev](https://app.honcho.dev)**
2. Sign up or log in
3. Copy your API key (starts with `hch-`)
### Step 2: Install the Plugin
<Note>
This plugin requires [Bun](https://bun.sh) and the [OpenCode CLI](https://opencode.ai). If `opencode` isn't on your `PATH`, install it first, then restart your shell.
</Note>
Run the installer:
```bash
bunx @honcho-ai/opencode-honcho install
```
The installer:
- registers `@honcho-ai/opencode-honcho` with OpenCode
- enables both the native server and TUI plugin targets
- writes the Honcho command templates into your global OpenCode config
- activates the plugin globally for every OpenCode project
### Step 3: Run Setup in OpenCode
1. Start OpenCode
2. Run `/honcho:setup`
3. Keep the default **Honcho Cloud** option unless you want a self-hosted or local endpoint
4. Paste your Honcho API key
5. Run `/honcho:status` to verify the runtime
### Step 4: (Optional) Kickstart with an Interview
```
/honcho:interview
```
OpenCode will interview you about stable preferences and project context, then persist what it learns to Honcho so every future session can draw on it.
## What You Get
- **Persistent Memory** — OpenCode retains durable context across sessions
- **Cloud or Local Deployments** — Point at Honcho Cloud or a self-hosted / local instance
- **Workspace Mapping** — OpenCode projects map cleanly to Honcho workspaces
- **Flexible Session Mapping** — Scope sessions per directory, repo, branch, chat instance, or globally
- **Durable Writes** — Save stable conclusions and retain session context across OpenCode runs
- **Memory Retrieval** — Search session messages, query Honcho's reasoning, and inject relevant context into prompts
- **Agent Tools** — First-class tools for search, chat, and conclusion-writing inside OpenCode
## Configuration
Configuration lives in a single shared file at `~/.honcho/config.json`, shared with other Honcho hosts (Claude Code, Cursor, etc.). OpenCode reads and writes this file directly, and OpenCode-specific defaults live under `hosts.opencode`. Edit the file direct or use `/honcho:config` to change it via OpenCode's chat, or call the `honcho_set_config` tool for other settings.
```jsonc
{
"apiKey": "hch-...",
"peerName": "alice",
"baseUrl": "https://api.honcho.dev",
"hosts": {
"opencode": {
"workspace": "opencode",
"aiPeer": "opencode",
"recallMode": "hybrid",
"observationMode": "directional",
"sessionStrategy": "per-directory"
}
}
}
```
Top-level shared fields are `apiKey`, `peerName`, and `baseUrl`. OpenCode's host-scoped settings live under `hosts.opencode`: `workspace`, `aiPeer`, `recallMode`, `observationMode`, and `sessionStrategy`.
### Cloud vs Local
For **Honcho Cloud**:
- `apiKey` is required
- `baseUrl` should stay at `https://api.honcho.dev`
For **self-hosted or local Honcho**:
- `baseUrl` should point to your deployment (e.g. `http://127.0.0.1:8000`)
- `apiKey` is only required if the deployment is authenticated
<Warning>
If OpenCode is running inside Docker or another remote environment, `localhost` won't refer to your host machine. The `baseUrl` must be reachable from the OpenCode runtime.
</Warning>
### Recall Modes
| Mode | Behavior | Best for |
| --- | --- | --- |
| `hybrid` (default) | Context injection **and** tool access | Most users — balanced memory coverage |
| `context` | Only inject memory into system prompts | Predictable prompts, no tool calls |
| `tools` | Only expose memory as tools | Explicit, on-demand retrieval |
### Session Strategies
| Strategy | Behavior | Best for |
| --- | --- | --- |
| `per-directory` (default) | One session per working directory | Most projects |
| `per-repo` | One session per repository | Repos with multiple entry directories |
| `git-branch` | Session follows the current git branch | Branch-specific workflows |
| `per-session` | New session per OpenCode session id | Short-lived isolated work |
| `chat-instance` | Session tied to the current chat instance | Highly ephemeral usage |
| `global` | One session for everything | Shared memory across all work |
## Operator Commands
| Command | Description |
| --- | --- |
| `/honcho:setup` | First-time setup for cloud or local Honcho |
| `/honcho:status` | Show effective Honcho status for the current OpenCode project |
| `/honcho:settings` | Show effective config values and config paths |
| `/honcho:config` | Change `recallMode` |
| `/honcho:interview` | Capture durable preferences or project context into memory |
## Agent Tools
The plugin exposes these tools inside OpenCode:
| Tool | Description |
| --- | --- |
| `honcho_setup` | Validate setup and persist shared credentials or endpoint settings |
| `honcho_status` | Show effective runtime status |
| `honcho_get_config` | Read effective and persisted settings |
| `honcho_set_config` | Update a persisted shared setting |
| `honcho_search` | Search Honcho session messages |
| `honcho_chat` | Query Honcho for reasoning-backed context |
| `honcho_create_conclusion` | Save a durable memory conclusion |
## Plugin Surfaces
The plugin hooks into these OpenCode plugin capabilities:
- `event`
- `chat.message`
- `tool.execute.after`
- `command.execute.before`
- `experimental.chat.system.transform`
- `experimental.session.compacting`
- `shell.env`
- `tool`
## Building with Teammates
Because `~/.honcho/config.json` is shared across Honcho hosts, teammates can collaborate by pointing at the same workspace while keeping their own identities. Sessions are automatically prefixed by `peerName` to avoid collisions.
**Alice** (`~/.honcho/config.json`):
```json
{
"apiKey": "hch-team-key...",
"peerName": "alice",
"hosts": {
"opencode": { "workspace": "team-acme", "aiPeer": "opencode" }
}
}
```
**Bob** (`~/.honcho/config.json`):
```json
{
"apiKey": "hch-team-key...",
"peerName": "bob",
"hosts": {
"opencode": { "workspace": "team-acme", "aiPeer": "opencode" }
}
}
```
Both write to `team-acme`; Honcho's dialectic reasoning draws on context from both peers.
## Next Steps
<CardGroup cols={2}>
<Card title="GitHub Repository" icon="github" href="https://github.com/plastic-labs/opencode-honcho">
Source code, issues, and README.
</Card>
<Card title="Honcho Architecture" icon="sitemap" href="../../documentation/core-concepts/architecture">
Learn about peers, sessions, and dialectic reasoning.
</Card>
</CardGroup>

View File

@ -0,0 +1,136 @@
---
title: "Paperclip"
icon: "paperclip"
description: "Add Honcho memory to Paperclip"
sidebarTitle: "Paperclip"
---
Honcho for [Paperclip](https://paperclip.ing) adds persistent Honcho memory to Paperclip while keeping Paperclip as the system of record.
<Note>
This page covers the current public-host-compatible Paperclip plugin. It supports tools, sync, migration import, and manual prompt previews. It does not depend on automatic prompt-context injection hooks, run transcript import, or legacy workspace file import.
</Note>
## Install the Plugin
1. In Paperclip, open `Instance Settings` -> `Plugins`.
2. Click `Install Plugin`.
3. Enter `@honcho-ai/paperclip-honcho`.
4. Complete the install from the Paperclip UI.
- Plugin download does not currently work on Windows because of a Paperclip host-side issue.
## Quick Setup
### Minimal Path
1. Create a Paperclip secret containing the Honcho API key.
- For a local Honcho, use whatever credential your local startup expects & `honchoApiKey` is not needed.
2. Open the Honcho plugin settings page in Paperclip.
3. If you are using Honcho Cloud, leave the deployment on the default cloud setting.
4. If you are using a local Honcho instance, switch the deployment to `Self-hosted / local` and set `honchoApiBaseUrl`.
5. Set `honchoApiKey`.
6. Save the settings.
7. Run `Initialize Honcho memory`.
`honchoApiKey` is the only field required for the standard setup path. The other settings already have defaults.
<Note>
If you use a local Honcho deployment, `honchoApiBaseUrl` must be reachable from the Paperclip host runtime. If Paperclip is running in Docker, `localhost` may not point at your machine.
</Note>
## Multi-Agent Hierarchy
### What Maps Where
Paperclip memory is organized around company, issue, and agent boundaries:
- **Company -> workspace**: each Paperclip company maps to one Honcho workspace.
- **Issue -> session**: each Paperclip issue maps to one Honcho session inside that workspace.
- **Humans and agents -> peers**: human actors and Paperclip agents map to Honcho peers.
This gives the plugin a natural hierarchy: company-level memory lives at the workspace level, issue-level memory lives at the session level, and people or agents are modeled as peers that participate across those scopes.
### How Agent Observation Works
The current plugin gives agent peers explicit observation settings:
- `observe_me` defaults to `true`
- `observe_others` defaults to `true`
In practice, that means agent peers can both be observed by Honcho and form representations of other peers they interact with.
## How It Works
### Identity And Scope
The integration breaks down into four parts:
- **Identity and scope** - each Paperclip company maps to a Honcho workspace, agents and human actors map to peers, and issues map to sessions.
- **What gets copied into Honcho** - issue comments and document revisions sync into Honcho, with document content sectioned and normalized message content capped before ingestion.
- **What operators get** - operators get a plugin settings page, migration preview/status data, including a per-issue migration mapping preview, repair tools, and an issue-level `Memory` tab.
- **What agents get** - agents get Honcho retrieval and peer-chat tools inside Paperclip.
## Operator Actions
The settings page exposes the main operator workflow directly:
| Action | What it does |
| --- | --- |
| `Validate config` | Validates the current plugin configuration before any sync or import work runs. |
| `Test connection` | Resolves the API key secret, checks the Honcho connection, and returns the mapped workspace ID. |
| `Initialize memory for this company` | Connects Honcho, creates core mappings, imports baseline issue memory, and verifies manual prompt previews. |
| `Rescan migration sources` | Scans issue comments and issue documents and writes a fresh import preview. |
| `Import history` | Imports the approved historical preview into Honcho with idempotent ledger checks. |
| `Preview prompt context` | Builds a manual prompt-context preview for a company or issue without relying on automatic host hooks. |
| `Repair mappings` | Recreates missing workspace, peer, and session mappings for the current company. |
| `Resync this issue` | Replays sync for the current issue from the issue Memory tab. |
## Configuration Defaults And Overrides
### Default Behavior
| Setting | Default | Use when |
| --- | --- | --- |
| `honchoApiKey` | — | Required. Points the plugin at the Paperclip secret containing your Honcho API key. |
| `honchoApiBaseUrl` | `https://api.honcho.dev` | Override this for self-hosted or non-default Honcho deployments. |
| `workspacePrefix` | `paperclip` | Change this if you want a different workspace namespace. |
| `syncIssueComments` | `true` | Turn this off if you do not want comment history imported into Honcho. |
| `syncIssueDocuments` | `true` | Turn this off if you do not want issue document revisions imported. |
| `enablePeerChat` | `true` | Required for the peer chat tool surface. |
| `enablePromptContext` | `false` | Keep this off on the public-host-compatible path and use manual prompt previews instead. |
| `observe_me` | `true` | Controls whether agent peers are observed by Honcho. |
| `observe_others` | `true` | Controls whether agent peers form representations of other peers they interact with. |
The plugin also accepts additional advanced fields in the settings page, including noise-pattern and metadata-strip controls. Most setups can ignore those and start with the defaults above.
## Agent Tools
The plugin registers the following Honcho tools for Paperclip agents:
| Tool | Description |
| --- | --- |
| `honcho_get_issue_context` | Retrieve compact Honcho context for the current issue session. |
| `honcho_search_memory` | Search Honcho memory within the current workspace, narrowing to the current issue by default. |
| `honcho_search_messages` | Search raw Honcho messages. |
| `honcho_search_conclusions` | Search high-signal summarized Honcho memory. |
| `honcho_get_workspace_context` | Retrieve broad workspace recall from Honcho. |
| `honcho_get_session` | Retrieve issue session context from Honcho. |
| `honcho_get_agent_context` | Retrieve peer context for a specific agent. |
| `honcho_get_hierarchy_context` | Retrieve delegated-work context when the host provides lineage metadata. |
| `honcho_ask_peer` | Query Honcho peer chat for a target peer. Requires peer chat to be enabled in plugin config. |
## Next Steps
<CardGroup cols={2}>
<Card title="Paperclip-Honcho Repository" icon="github" href="https://github.com/plastic-labs/paperclip-honcho">
Open the repository for source and setup details.
</Card>
<Card title="Honcho Architecture" icon="sitemap" href="../../documentation/core-concepts/architecture">
Review how workspaces, peers, and sessions fit together.
</Card>
<Card title="Representation Scopes" icon="messages" href="../../documentation/features/advanced/representation-scopes">
Review how `observe_me` and `observe_others` change what peers can model.
</Card>
</CardGroup>

View File

@ -149,7 +149,7 @@ uv run python main.py
<Card title="Get Context" icon="database" href="/v3/documentation/features/get-context">
Retrieve formatted conversation history
</Card>
<Card title="Github code" icon="robot" href="https://github.com/plastic-labs/reachy-mini-honcho">
<Card title="GitHub code" icon="robot" href="https://github.com/plastic-labs/reachy-mini-honcho">
Dig into the code
</Card>
</CardGroup>

View File

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

View File

@ -2,30 +2,37 @@
title: "Guides, Cookbooks, and Integrations"
sidebarTitle: 'Overview'
description: 'Helpful guides and design patterns for building with Honcho'
icon: 'hat-wizard'
icon: 'puzzle-piece'
---
<Note> Before you start a guide, follow [Quickstart](/v3/documentation/introduction/quickstart) to get up and running with Honcho in your language of choice. </Note>
Honcho plugs into whatever you're already building. Add memory to an AI assistant, connect an external data source, wire Honcho into your agent framework, or migrate from another provider.
These guides provide concrete examples and implementation patterns for building with Honcho. Whether you're integrating Honcho into existing platforms, exploring advanced features, or getting up and running quickly, you'll find working code you can adapt to your needs.
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:
## AI Assistants
Add persistent memory to AI assistants and agents:
<CardGroup cols={2}>
<Card title="MCP Integration" icon="link" href="/v3/guides/integrations/mcp">
Get Honcho running with a single prompt in Claude Code
<Card title="Claude Code" icon="terminal" href="/v3/guides/integrations/claude-code">
Long-term memory that survives context wipes, session restarts, and project switches
</Card>
<Card title="LangGraph" icon="diagram-project" href="/v3/guides/integrations/langgraph">
Add persistent memory and theory of mind to your LangGraph agents
<Card title="OpenCode" icon="code" href="/v3/guides/integrations/opencode">
Persistent memory for OpenCode sessions, with per-directory, per-repo, or branch-scoped session mapping
</Card>
<Card title="MCP Server" icon="star-of-life" href="/v3/guides/integrations/mcp">
Add Honcho memory to Claude Desktop, Cursor, Windsurf, Cline, and any MCP client
</Card>
<Card title="Hermes Agent" icon="bolt" href="/v3/guides/community/hermes">
Cross-session memory for Nous Research's Hermes agent
</Card>
<Card title="OpenClaw" icon="lobster" href="/v3/guides/integrations/openclaw">
Memory across every channel — WhatsApp, Telegram, Discord, Slack, and more
</Card>
<Card title="Agent Zero" icon="triangle" href="/v3/guides/community/agent0">
Persistent memory plugin for the Agent Zero framework
</Card>
</CardGroup>
## Showcase
Real-world examples of what you can build with Honcho:
## Platform Connectors
Connect external platforms to Honcho:
<CardGroup cols={2}>
<Card title="Discord Bot" icon="discord" href="/v3/guides/discord">
@ -34,7 +41,43 @@ Real-world examples of what you can build with Honcho:
<Card title="Telegram Bot" icon="telegram" href="/v3/guides/telegram">
Create a Telegram bot with persistent user understanding
</Card>
<Card title="Gmail" icon="envelope" href="/v3/guides/gmail">
Import email threads into Honcho — peers, sessions, and messages from your inbox
</Card>
<Card title="Granola" icon="calendar" href="/v3/guides/granola">
Ingest meeting transcripts with speaker turns and participant data
</Card>
<Card title="Paperclip" icon="paperclip" href="/v3/guides/integrations/paperclip">
Add Honcho memory to Paperclip companies, agents, issues, and documents
</Card>
<Card title="Reachy Mini" icon="robot" href="/v3/guides/integrations/reachy-mini">
Build an embodied voice robot that remembers users across sessions
Build an embodied voice robot with long-term memory
</Card>
</CardGroup>
## Agent Frameworks
Use Honcho as a memory layer in your agent orchestration stack:
<CardGroup cols={2}>
<Card title="LangGraph" icon="diagram-project" href="/v3/guides/integrations/langgraph">
Add persistent memory and theory of mind to your LangGraph agents
</Card>
<Card title="CrewAI" icon="users-gear" href="/v3/guides/integrations/crewai">
Give CrewAI agents memory that persists across sessions
</Card>
<Card title="Zo Computer" icon="bolt" href="/v3/guides/integrations/zo-computer">
Persistent memory skill for Zo Computer AI workflows
</Card>
<Card title="n8n" icon="share-nodes" href="/v3/guides/integrations/n8n">
Build intelligent automation workflows with persistent memory
</Card>
</CardGroup>
## Migrations
Coming from another memory provider?
<CardGroup cols={2}>
<Card title="Migrate from Mem0" icon="arrow-right-arrow-left" href="/v3/guides/migrations/mem0">
Transfer your data and update your integration code
</Card>
</CardGroup>

View File

@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""Load Gmail messages into Honcho.
Uses the Gmail API directly (with OAuth) to fetch emails and the Honcho Python SDK to store them.
Each Gmail thread becomes a Honcho session, each sender becomes a peer.
Prerequisites:
1. Create a Google Cloud project and enable the Gmail API
2. Create OAuth 2.0 credentials (Desktop app type)
3. Download the credentials JSON (client_secret_*.json) into this directory
4. Install dependencies:
pip install google-api-python-client google-auth-oauthlib honcho-ai
On first run, a browser window will open for OAuth consent. After authorizing,
a 'token.json' file will be created to store your credentials for future runs.
"""
import argparse
import base64
import glob
import os
import re
import time
from datetime import datetime, timezone
from email.header import decode_header, make_header
from email.utils import getaddresses, parseaddr
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
PEER_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
def find_credentials() -> str:
"""Find a Google OAuth credentials file in the current directory."""
matches = glob.glob("client_secret*.json")
if matches:
return matches[0]
raise FileNotFoundError(
"No client_secret*.json file found.\n"
"Download OAuth credentials from Google Cloud Console:\n"
"1. Go to console.cloud.google.com\n"
"2. Create/select a project and enable Gmail API\n"
"3. Create OAuth 2.0 credentials (Desktop app)\n"
"4. Download the JSON into this directory"
)
def get_gmail_service(credentials_file: str | None = None, token_file: str = "token.json"):
"""Authenticate and return a Gmail API service instance."""
creds = None
if os.path.exists(token_file):
creds = Credentials.from_authorized_user_file(token_file, SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
print("Refreshing expired credentials...")
creds.refresh(Request())
else:
if credentials_file is None:
credentials_file = find_credentials()
print(f"Using credentials: {credentials_file}")
print("Opening browser for OAuth consent...")
flow = InstalledAppFlow.from_client_secrets_file(credentials_file, SCOPES)
creds = flow.run_local_server(port=0)
with open(token_file, "w") as token:
token.write(creds.to_json())
print(f"Credentials saved to {token_file}")
return build("gmail", "v1", credentials=creds)
def list_threads(service, query: str = None, label_ids: list = None, max_results: int = 10) -> list[dict]:
"""List Gmail threads with pagination support."""
all_threads = []
page_token = None
while len(all_threads) < max_results:
try:
params = {
"userId": "me",
"maxResults": min(100, max_results - len(all_threads)),
}
if query:
params["q"] = query
if label_ids:
params["labelIds"] = label_ids
if page_token:
params["pageToken"] = page_token
response = service.users().threads().list(**params).execute()
threads = response.get("threads", [])
all_threads.extend(threads)
page_token = response.get("nextPageToken")
if not page_token:
break
except HttpError as e:
print(f"Error listing threads: {e}")
break
return all_threads[:max_results]
def get_thread(service, thread_id: str) -> dict:
"""Fetch a complete Gmail thread with all messages."""
try:
return service.users().threads().get(
userId="me",
id=thread_id,
format="full"
).execute()
except HttpError as e:
print(f"Error fetching thread {thread_id}: {e}")
return {}
def _decode_header_str(header: str) -> str:
"""Decode an RFC 2047 encoded header string to plain Unicode."""
return str(make_header(decode_header(header)))
def extract_email(from_header: str) -> str:
"""Extract bare email from an RFC 5322 header value."""
_, addr = parseaddr(_decode_header_str(from_header))
return addr.lower().strip()
def extract_name(from_header: str) -> str:
"""Extract display name from an RFC 5322 header value."""
name, _ = parseaddr(_decode_header_str(from_header))
return name.strip() or from_header.strip()
def decode_body(payload: dict) -> str:
"""Recursively extract plain text from a Gmail message payload."""
if payload.get("mimeType") == "text/plain":
data = payload.get("body", {}).get("data", "")
if data:
return base64.urlsafe_b64decode(data).decode("utf-8", errors="replace")
parts = payload.get("parts", [])
for part in parts:
text = decode_body(part)
if text:
return text
return ""
def strip_quoted_replies(text: str) -> str:
"""Strip quoted reply text from an email body, keeping only the new content."""
lines = text.split("\n")
clean_lines = []
for line in lines:
stripped = line.strip()
if re.match(r"^On .+wrote:\s*$", stripped):
break
if stripped.startswith("---------- Forwarded message"):
break
if stripped.startswith(">"):
break
if re.match(r"^[-_]{10,}$", stripped):
break
clean_lines.append(line)
return "\n".join(clean_lines).rstrip()
def parse_address_list(header: str) -> list[str]:
"""Parse a comma-separated email header into individual addresses."""
if not header.strip():
return []
decoded = _decode_header_str(header)
return [
f"{name} <{addr}>" if name else addr
for name, addr in getaddresses([decoded])
if addr
]
def peer_id_from_email(email: str) -> str:
"""Convert email to a valid Honcho peer ID."""
peer_id = re.sub(r"[^A-Za-z0-9_-]+", "-", email).strip("-").lower()
peer_id = re.sub(r"-{2,}", "-", peer_id)
if not peer_id:
peer_id = "unknown-peer"
if not PEER_ID_PATTERN.fullmatch(peer_id):
raise ValueError(f"Generated peer ID is invalid: {peer_id!r}")
return peer_id
def fetch_thread_messages(service, thread_id: str) -> list[dict]:
"""Fetch all messages in a Gmail thread with full content."""
data = get_thread(service, thread_id)
messages = []
for msg in data.get("messages", []):
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
body = strip_quoted_replies(decode_body(msg.get("payload", {})))
ts = int(msg.get("internalDate", "0")) / 1000
messages.append({
"id": msg["id"],
"thread_id": msg["threadId"],
"from": headers.get("From", ""),
"to": headers.get("To", ""),
"cc": headers.get("Cc", ""),
"bcc": headers.get("Bcc", ""),
"subject": headers.get("Subject", ""),
"date": headers.get("Date", ""),
"timestamp": datetime.fromtimestamp(ts, tz=timezone.utc),
"body": body.strip(),
"labels": msg.get("labelIds", []),
"snippet": msg.get("snippet", ""),
})
return messages
def main():
parser = argparse.ArgumentParser(description="Load Gmail messages into Honcho")
parser.add_argument("--workspace", "-w", default="gmail", help="Honcho workspace ID (default: gmail)")
parser.add_argument("--query", "-q", default=None, help="Gmail search query (e.g. 'from:alice@example.com')")
parser.add_argument("--label", "-l", default=None, help="Gmail label to filter by (e.g. INBOX)")
parser.add_argument("--max-threads", "-n", type=int, default=10, help="Max threads to fetch (default: 10)")
parser.add_argument("--dry-run", action="store_true", help="Print what would be loaded without writing to Honcho")
parser.add_argument("--credentials", "-c", default=None, help="Path to OAuth credentials JSON (auto-detects client_secret*.json)")
parser.add_argument("--token", "-t", default="token.json", help="Path to store/load access token")
args = parser.parse_args()
# Authenticate
print("Authenticating with Gmail API...")
service = get_gmail_service(args.credentials, args.token)
print(" Authenticated successfully!")
label_ids = [args.label] if args.label else None
# List threads
print(f"\nFetching up to {args.max_threads} threads from Gmail...")
threads = list_threads(service, query=args.query, label_ids=label_ids, max_results=args.max_threads)
print(f" Found {len(threads)} threads")
if not threads:
print("No threads found. Try adjusting --query or --label.")
return
# Fetch full messages for each thread
all_thread_messages = {}
seen_peers = {}
def register_peer(addr: str):
email = extract_email(addr)
if email and email not in seen_peers:
name = extract_name(addr)
if name.lower().strip() == email or "@" in name:
name = email.split("@")[0].replace(".", " ").title()
seen_peers[email] = {
"name": name,
"peer_id": peer_id_from_email(email),
"email": email,
}
for i, t in enumerate(threads):
tid = t["id"]
print(f" Fetching thread {i+1}/{len(threads)}: {tid}")
msgs = fetch_thread_messages(service, tid)
all_thread_messages[tid] = msgs
for m in msgs:
register_peer(m["from"])
for addr in parse_address_list(m["to"]):
register_peer(addr)
for addr in parse_address_list(m["cc"]):
register_peer(addr)
for addr in parse_address_list(m["bcc"]):
register_peer(addr)
# Summary
total_msgs = sum(len(v) for v in all_thread_messages.values())
print("\nSummary:")
print(f" Threads: {len(all_thread_messages)}")
print(f" Messages: {total_msgs}")
print(f" Unique participants: {len(seen_peers)}")
for email, info in seen_peers.items():
print(f" {info['peer_id']} ({info['name']} <{email}>)")
if args.dry_run:
print("\n[DRY RUN] Would create the above in Honcho. Showing first message per thread:")
for tid, msgs in all_thread_messages.items():
m = msgs[0]
body_preview = m["body"][:120].replace("\n", " ") if m["body"] else m["snippet"][:120]
print(f" Thread {tid}: {m['subject']}")
print(f" {m['from']} @ {m['date']}")
print(f" {body_preview}...")
return
# Load into Honcho
from honcho import Honcho
print(f"\nLoading into Honcho workspace '{args.workspace}'...")
honcho = Honcho(workspace_id=args.workspace)
# Create peers
peers = {}
for i, (email, info) in enumerate(seen_peers.items()):
if i > 0 and i % 4 == 0:
time.sleep(1)
peers[email] = honcho.peer(info["peer_id"], metadata={
"email": email,
"name": info["name"],
"source": "gmail",
})
print(f" Peer: {info['peer_id']}")
# Create sessions and messages per thread
for tid, msgs in all_thread_messages.items():
subject = msgs[0]["subject"] if msgs else "No subject"
session_id = f"gmail-thread-{tid}"
thread_peer_emails = set()
for m in msgs:
thread_peer_emails.add(extract_email(m["from"]))
for addr in parse_address_list(m["to"]):
thread_peer_emails.add(extract_email(addr))
for addr in parse_address_list(m["cc"]):
thread_peer_emails.add(extract_email(addr))
for addr in parse_address_list(m["bcc"]):
thread_peer_emails.add(extract_email(addr))
thread_peers = [peers[e] for e in thread_peer_emails if e in peers]
session = honcho.session(session_id, metadata={
"gmail_thread_id": tid,
"subject": subject,
"source": "gmail",
"message_count": len(msgs),
})
session.add_peers(thread_peers)
honcho_msgs = []
for m in msgs:
email = extract_email(m["from"])
peer = peers.get(email)
if not peer:
continue
content = m["body"] if m["body"] else m["snippet"]
if not content:
continue
honcho_msgs.append(peer.message(
content,
metadata={
"gmail_id": m["id"],
"subject": m["subject"],
"from": m["from"],
"to": m["to"],
"labels": m["labels"],
},
created_at=m["timestamp"],
))
if honcho_msgs:
session.add_messages(honcho_msgs)
print(f" Session {session_id}: {len(honcho_msgs)} messages — {subject[:60]}")
print(f"\nDone! Loaded {total_msgs} messages into workspace '{args.workspace}'.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,751 @@
#!/usr/bin/env python3
"""Load Granola meeting notes into Honcho.
Uses the Granola MCP server (with OAuth) to fetch meetings and the Honcho Python SDK
to store them. Each meeting becomes a Honcho session. Two-person meetings get full
speaker attribution; multi-person meetings are stored as summaries.
Prerequisites:
pip install honcho-ai httpx
Environment Variables:
HONCHO_API_KEY - Your Honcho API key (get from app.honcho.dev/api-keys)
Usage:
python honcho_granola.py
"""
import asyncio
import base64
import hashlib
import json
import os
import re
import secrets
import sys
import threading
import traceback
import webbrowser
from dataclasses import dataclass, field
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
@dataclass
class Participant:
name: str
email: str | None = None
org: str | None = None
@dataclass
class ParsedParticipants:
note_creator: Participant | None = None
others: list[Participant] = field(default_factory=list)
@dataclass
class TranscriptTurn:
speaker: str
text: str
# Granola MCP + OAuth endpoints
GRANOLA_MCP_URL = "https://mcp.granola.ai/mcp"
AUTH_BASE = "https://mcp-auth.granola.ai"
OAUTH_REDIRECT_PORT = 8765
OAUTH_REDIRECT_URI = f"http://localhost:{OAUTH_REDIRECT_PORT}/callback"
# Honcho message size limit (25000 max, leave headroom)
MAX_MESSAGE_LEN = 24000
# ---------------------------------------------------------------------------
# OAuth callback handler (must be a class for BaseHTTPRequestHandler)
# ---------------------------------------------------------------------------
class _OAuthCallback(BaseHTTPRequestHandler):
auth_result: dict[str, str | None] = {"code": None, "error": None}
def do_GET(self):
params = parse_qs(urlparse(self.path).query)
if "code" in params:
_OAuthCallback.auth_result["code"] = params["code"][0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"<h1>Authenticated! You can close this window.</h1>")
elif "error" in params:
_OAuthCallback.auth_result["error"] = params.get("error_description", params["error"])[0]
self.send_response(400)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(f"<h1>Error: {_OAuthCallback.auth_result['error']}</h1>".encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, fmt, *args):
pass
# ---------------------------------------------------------------------------
# Granola OAuth + MCP
# ---------------------------------------------------------------------------
async def authenticate(http_client: httpx.AsyncClient) -> str:
"""Perform OAuth (DCR + PKCE) with Granola. Returns access token."""
_OAuthCallback.auth_result = {"code": None, "error": None}
print("\nAuthenticating with Granola...")
# Register client (DCR)
resp = await http_client.post(
f"{AUTH_BASE}/oauth2/register",
json={
"client_name": "Granola to Honcho Transfer",
"redirect_uris": [OAUTH_REDIRECT_URI],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
},
)
if resp.status_code not in (200, 201):
raise RuntimeError(f"Client registration failed: {resp.status_code}")
client_id = resp.json().get("client_id")
# PKCE
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
# Browser auth
auth_url = f"{AUTH_BASE}/oauth2/authorize?" + urlencode({
"client_id": client_id,
"redirect_uri": OAUTH_REDIRECT_URI,
"response_type": "code",
"state": "granola-honcho-transfer",
"code_challenge": challenge,
"code_challenge_method": "S256",
})
server = HTTPServer(("localhost", OAUTH_REDIRECT_PORT), _OAuthCallback)
thread = threading.Thread(target=server.handle_request)
thread.start()
print(" Opening browser for authentication...")
webbrowser.open(auth_url)
thread.join(timeout=120)
server.server_close()
auth_result = _OAuthCallback.auth_result
if auth_result["error"]:
raise RuntimeError(f"Authentication failed: {auth_result['error']}")
if not auth_result["code"]:
raise RuntimeError("Authentication timed out")
# Exchange code for token
resp = await http_client.post(
f"{AUTH_BASE}/oauth2/token",
data={
"grant_type": "authorization_code",
"code": auth_result["code"],
"redirect_uri": OAUTH_REDIRECT_URI,
"client_id": client_id,
"code_verifier": verifier,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if resp.status_code != 200:
raise RuntimeError(f"Token exchange failed: {resp.status_code}")
print(" Authenticated successfully!")
return resp.json()["access_token"]
async def call_mcp_tool(
http_client: httpx.AsyncClient,
access_token: str,
tool_name: str,
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Call a Granola MCP tool, handling both JSON and SSE responses."""
resp = await http_client.post(
GRANOLA_MCP_URL,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": tool_name, "arguments": arguments or {}},
},
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
)
if resp.status_code != 200:
raise RuntimeError(f"MCP call failed: {resp.status_code} - {resp.text}")
# SSE response
if "text/event-stream" in resp.headers.get("content-type", ""):
result = None
for line in resp.text.split("\n"):
if line.strip().startswith("data: "):
try:
parsed = json.loads(line.strip()[6:])
if "result" in parsed:
result = parsed
elif "error" in parsed:
raise RuntimeError(f"MCP error: {parsed['error']}")
except json.JSONDecodeError:
continue
if result:
final = result.get("result", {})
return final if isinstance(final, dict) else {"result": final}
raise RuntimeError("No result in SSE response")
# JSON response
result = resp.json()
if "error" in result:
raise RuntimeError(f"MCP error: {result['error']}")
return result.get("result", {})
def extract_mcp_text(result: dict[str, Any]) -> str:
"""Extract text from the first content block of an MCP result.
Raises ValueError if the response structure is unexpected.
"""
content = result.get("content", [])
if not isinstance(content, list) or not content:
raise ValueError(f"MCP response missing content array: {list(result.keys())}")
first = content[0]
if not isinstance(first, dict) or "text" not in first:
raise ValueError(f"MCP content block missing 'text' field: {first}")
return str(first["text"])
# ---------------------------------------------------------------------------
# Granola data fetching
# ---------------------------------------------------------------------------
async def list_meetings(
http_client: httpx.AsyncClient, access_token: str, limit: int = 100,
) -> list[dict[str, Any]]:
"""List meetings from Granola MCP. Parses Granola's XML-like response format."""
result = await call_mcp_tool(http_client, access_token, "list_meetings", {"limit": limit})
text = extract_mcp_text(result)
meetings: list[dict[str, Any]] = []
for match in re.finditer(r'<meeting\s+id="([^"]+)"\s+title="([^"]+)"\s+date="([^"]+)"', text):
mid, title, date = match.groups()
block_end = text.find("</meeting>", match.end())
block = text[match.end():block_end] if block_end != -1 else ""
p_match = re.search(r"<known_participants>\s*(.*?)\s*</known_participants>", block, re.DOTALL)
meetings.append({
"id": mid,
"title": title,
"date": date,
"participants": p_match.group(1).strip() if p_match else "",
})
return meetings
async def get_meeting_details(
http_client: httpx.AsyncClient, access_token: str, meeting_id: str,
) -> dict[str, Any]:
"""Get full meeting details including notes."""
result = await call_mcp_tool(http_client, access_token, "get_meetings", {"meeting_ids": [meeting_id]})
text = extract_mcp_text(result)
return {"id": meeting_id, "raw_content": text}
async def get_meeting_transcript(
http_client: httpx.AsyncClient, access_token: str, meeting_id: str,
max_retries: int = 3,
) -> str | None:
"""Get transcript for a meeting (paid tiers only).
Retries on rate limit responses with exponential backoff.
"""
for attempt in range(max_retries):
try:
result = await call_mcp_tool(http_client, access_token, "get_meeting_transcript", {"meeting_id": meeting_id})
text = extract_mcp_text(result)
except Exception as e:
print(f" Transcript unavailable: {e}")
return None
if not text or "no transcript" in text.lower():
return None
# Granola returns rate limit errors as content text, not HTTP errors
if "rate limit" in text.lower():
wait = 2 ** attempt * 3 # 3s, 6s, 12s
print(f" ⚠ Granola rate limit hit (attempt {attempt + 1}/{max_retries}), waiting {wait}s...")
await asyncio.sleep(wait)
continue
return text
print(f" ⚠ Transcript skipped after {max_retries} rate limit retries")
return None
async def fetch_all_meetings(
http_client: httpx.AsyncClient, access_token: str,
) -> list[dict[str, Any]]:
"""Fetch meeting list and enrich each with transcript and details."""
print("\nFetching meetings from Granola...")
meetings = await list_meetings(http_client, access_token, limit=500)
if not meetings:
print("No meetings found.")
return []
print(f" Found {len(meetings)} meetings. Fetching content...\n")
for i, m in enumerate(meetings, 1):
mid = m.get("id")
if not mid:
continue
transcript = await get_meeting_transcript(http_client, access_token, mid)
if transcript:
m["transcript"] = transcript
try:
m.update(await get_meeting_details(http_client, access_token, mid))
except Exception as exc:
print(f" Failed to fetch details for {mid}: {exc}")
has_t = "transcript" in m
has_s = bool(extract_summary(m))
label = "transcript+summary" if has_t and has_s else "transcript only" if has_t else "summary only" if has_s else "basic only"
print(f" [{i}/{len(meetings)}] {label}: {m.get('title', 'Untitled')[:45]}")
await asyncio.sleep(1.5) # rate limit
return meetings
# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------
def parse_participants(participants_str: str) -> ParsedParticipants:
"""Parse Granola's participant string into structured participants.
Warns on unparsable entries instead of silently dropping them.
"""
result = ParsedParticipants()
if not participants_str:
return result
# Split on commas, but not inside angle brackets
entries, current, depth = [], [], 0
for ch in participants_str:
if ch == "<":
depth += 1
elif ch == ">":
depth = max(depth - 1, 0)
elif ch == "," and depth == 0:
entries.append("".join(current))
current = []
continue
current.append(ch)
if current:
entries.append("".join(current))
for entry in entries:
entry = entry.strip()
if not entry:
continue
is_creator = "(note creator)" in entry
clean = entry.replace("(note creator)", "").strip()
email_match = re.search(r"<([^>]+)>", clean)
email = email_match.group(1) if email_match else None
name = re.sub(r"\s*<[^>]+>", "", clean).strip()
if not name:
print(f" Warning: could not parse participant entry: {entry!r}")
continue
org = None
org_match = re.match(r"(.+?)\s+from\s+(.+)", name)
if org_match:
name, org = org_match.group(1).strip(), org_match.group(2).strip()
person = Participant(name=name, email=email, org=org)
if is_creator:
result.note_creator = person
else:
result.others.append(person)
return result
def parse_transcript_turns(raw: str) -> list[TranscriptTurn]:
"""Split a Granola transcript into speaker turns."""
# Unwrap JSON wrapper if present
try:
parsed = json.loads(raw)
if isinstance(parsed, dict) and "transcript" in parsed:
raw = str(parsed["transcript"])
except (json.JSONDecodeError, TypeError):
pass
parts = re.split(r"(?:^|\s{2,})(Me|Them):\s*", raw)
turns: list[TranscriptTurn] = []
i = 1
while i < len(parts) - 1:
text = parts[i + 1].strip()
if text:
turns.append(TranscriptTurn(speaker=parts[i], text=text))
i += 2
return turns
def extract_summary(meeting: dict[str, Any]) -> str:
"""Extract best available summary text from meeting data."""
candidates = []
for key in ("summary", "notes", "note", "meeting_notes", "description"):
val = meeting.get(key)
if isinstance(val, str) and val.strip():
candidates.append(val.strip())
raw = meeting.get("raw_content")
if isinstance(raw, str) and raw.strip():
candidates.append(raw.strip())
for c in candidates:
for tag in ("summary", "notes"):
m = re.search(rf"<{tag}>\s*(.*?)\s*</{tag}>", c, re.DOTALL)
if m:
return m.group(1).strip()
return candidates[0] if candidates else ""
def peer_id_from(value: str) -> str:
"""Normalize a name or email into a Honcho-safe peer ID."""
norm = re.sub(r"[^a-z0-9_-]+", "-", value.strip().lower())
norm = re.sub(r"-{2,}", "-", norm).strip("-_")
return (norm or "peer")[:100]
def sanitize(text: str) -> str:
"""Remove null bytes and control characters."""
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
def parse_date(date_str: str) -> datetime:
"""Parse Granola's date format into a timezone-aware datetime.
Raises ValueError if the date string doesn't match any known format.
"""
for fmt in ["%b %d, %Y %I:%M %p", "%b %d, %Y %I:%M:%S %p", "%B %d, %Y %I:%M %p"]:
try:
return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
raise ValueError(f"Unrecognized date format: {date_str!r}")
# ---------------------------------------------------------------------------
# Honcho import helpers
# ---------------------------------------------------------------------------
def build_messages(
peer: Any,
content: str,
metadata: dict[str, object] | None,
created_at: datetime,
) -> list[Any]:
"""Build chunked messages for a single peer, attaching metadata to the first chunk."""
messages = []
content = sanitize(content)
for start in range(0, len(content), MAX_MESSAGE_LEN):
chunk = content[start:start + MAX_MESSAGE_LEN]
msg_meta = metadata if start == 0 else None
messages.append(peer.message(chunk, metadata=msg_meta, created_at=created_at))
return messages
def send_messages(session: Any, messages: list[Any]) -> None:
"""Send messages to a session in batches of 100."""
for batch_start in range(0, len(messages), 100):
session.add_messages(messages[batch_start:batch_start + 100])
def import_two_person(
honcho: Any,
session: Any,
me_peer_id: str,
them_peer_id: str,
turns: list[TranscriptTurn],
metadata: dict[str, object],
created_at: datetime,
) -> None:
"""Import a two-person meeting with speaker attribution."""
me_peer = honcho.peer(me_peer_id)
them_peer = honcho.peer(them_peer_id)
# Merge consecutive same-speaker turns
merged: list[TranscriptTurn] = []
for t in turns:
if merged and merged[-1].speaker == t.speaker:
merged[-1].text += " " + t.text
else:
merged.append(TranscriptTurn(speaker=t.speaker, text=t.text))
messages: list[Any] = []
for i, t in enumerate(merged):
peer = me_peer if t.speaker == "Me" else them_peer
msg_meta = metadata if i == 0 else None
messages.extend(build_messages(peer, t.text, msg_meta, created_at))
send_messages(session, messages)
print(f" -> Imported as 2-person ({me_peer_id} + {them_peer_id})")
def import_summary(
honcho: Any,
session: Any,
me_peer_id: str,
meeting: dict[str, Any],
metadata: dict[str, object],
created_at: datetime,
) -> None:
"""Import a meeting as a summary message."""
me_peer = honcho.peer(me_peer_id)
summary = extract_summary(meeting)
if not summary:
raw_t = meeting.get("transcript", "")
try:
parsed = json.loads(raw_t)
summary = str(parsed.get("transcript", "")) if isinstance(parsed, dict) else raw_t
except (json.JSONDecodeError, TypeError):
summary = raw_t
summary = summary or "No content available"
title = meeting.get("title", "Untitled")
date = meeting.get("date", "")
header = f"Meeting: {title}\nDate: {date}\nParticipants: {meeting.get('participants', '')}\n\n"
messages = build_messages(me_peer, header + summary, metadata, created_at)
send_messages(session, messages)
print(" -> Imported as summary")
def resolve_them_participant(others: list[Participant]) -> Participant | None:
"""Ask user to pick which participant is 'Them' from a multi-person meeting."""
for j, p in enumerate(others, 1):
email_str = f" <{p.email}>" if p.email else ""
print(f" {j}. {p.name}{email_str}")
idx_str = input(f" Who is 'Them'? [1-{len(others)}]: ").strip()
try:
return others[int(idx_str) - 1]
except (ValueError, IndexError):
print(" Invalid selection.")
return None
def review_meeting(
index: int,
total: int,
meeting: dict[str, Any],
participants: ParsedParticipants,
turns: list[TranscriptTurn],
) -> tuple[str, Participant | None]:
"""Display meeting info and get user's import choice.
Returns (mode, them_participant) where mode is one of:
- "two_person": import with speaker attribution using them_participant
- "summary": import as a single summary message
- "skip": skip this meeting
"""
title = meeting.get("title", "Untitled")
date = meeting.get("date", "")
creator = participants.note_creator
others = participants.others
me_turns = sum(1 for t in turns if t.speaker == "Me")
them_turns = len(turns) - me_turns
total_words = sum(len(t.text.split()) for t in turns)
print(f"\n{'' * 60}")
print(f" [{index}/{total}] {title}")
print(f" Date: {date}")
if creator:
print(f" You: {creator.name} <{creator.email}>")
for j, p in enumerate(others, 1):
email_str = f" <{p.email}>" if p.email else ""
org_str = f" ({p.org})" if p.org else ""
print(f" {j}. {p.name}{email_str}{org_str}")
has_transcript = bool(meeting.get("transcript"))
if turns:
print(f" Transcript: {me_turns} Me, {them_turns} Them, ~{total_words} words")
if them_turns == 0:
print(" ** No 'Them' turns — nobody else spoke **")
if total_words < 30:
print(" ** Very short — might be empty **")
elif has_transcript:
raw = meeting["transcript"]
print(f" Transcript: present ({len(raw)} chars) but could not parse speaker turns")
print(f" Preview: {raw[:200]!r}")
else:
print(f" Content: {'summary available' if extract_summary(meeting) else 'metadata only'}")
# Two-person default: exactly one other participant with transcript
if len(others) == 1 and them_turns > 0:
them_label = others[0].name + (f" <{others[0].email}>" if others[0].email else "")
print(f"\n Detected: 2-person call (you + {them_label})")
choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower()
while choice not in ("", "s", "k"):
choice = input(" [Enter] 2-person / [s]ummary / [k] skip: ").strip().lower()
if choice == "k":
return ("skip", None)
if choice == "s":
return ("summary", None)
return ("two_person", others[0])
# Multi-person with transcript
if len(others) > 1 and them_turns > 0:
print(f"\n {len(others)} participants")
choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower()
while choice not in ("", "2", "k"):
choice = input(" [Enter] summary / [2] 2-person / [k] skip: ").strip().lower()
if choice == "k":
return ("skip", None)
if choice == "2":
them = resolve_them_participant(others)
if them is None:
return ("summary", None)
return ("two_person", them)
return ("summary", None)
# No transcript or no other speakers
choice = input(" [Enter] summary / [k] skip: ").strip().lower()
while choice not in ("", "k"):
choice = input(" [Enter] summary / [k] skip: ").strip().lower()
if choice == "k":
return ("skip", None)
return ("summary", None)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
async def main():
print("=" * 60)
print(" Granola -> Honcho Meeting Notes Transfer")
print("=" * 60)
if not os.environ.get("HONCHO_API_KEY"):
print("\nError: HONCHO_API_KEY not set.")
print(" Get your key at: https://app.honcho.dev/api-keys")
sys.exit(1)
async with httpx.AsyncClient(timeout=60.0) as http_client:
try:
access_token = await authenticate(http_client)
meetings = await fetch_all_meetings(http_client, access_token)
if not meetings:
sys.exit(0)
from honcho import Honcho
honcho = Honcho(workspace_id="granola_test")
seen_peers: set[str] = set()
results = {"imported": 0, "skipped": 0, "failed": 0}
print("\n" + "=" * 60)
print(" Review each meeting")
print("=" * 60)
for i, m in enumerate(meetings, 1):
mid = m.get("id")
if not mid:
continue
participants = parse_participants(m.get("participants", ""))
turns = parse_transcript_turns(m["transcript"]) if m.get("transcript") else []
mode, them = review_meeting(i, len(meetings), m, participants, turns)
if mode == "skip":
print(" -> Skipped")
results["skipped"] += 1
continue
# Resolve creator peer
creator = participants.note_creator
me_source = (creator.email or creator.name) if creator else None
if not me_source:
print(" -> Skipped (no creator identifier)")
results["skipped"] += 1
continue
me_peer_id = peer_id_from(me_source)
if me_peer_id not in seen_peers:
print(f" New peer: {me_source} ({me_peer_id})")
seen_peers.add(me_peer_id)
try:
created_at = parse_date(m.get("date", ""))
session = honcho.session(f"meeting-{mid}")
metadata: dict[str, object] = {
"title": m.get("title", "Untitled"),
"date": m.get("date", ""),
"granola_meeting_id": mid,
"mode": mode,
}
if mode == "two_person" and them is not None:
them_source = them.email or them.name
them_peer_id = peer_id_from(them_source)
if them_peer_id not in seen_peers:
print(f" New peer: {them_source} ({them_peer_id})")
seen_peers.add(them_peer_id)
import_two_person(honcho, session, me_peer_id, them_peer_id, turns, metadata, created_at)
else:
import_summary(honcho, session, me_peer_id, m, metadata, created_at)
results["imported"] += 1
except ValueError as e:
print(f" -> FAILED: {e}")
results["failed"] += 1
except Exception as e:
print(f" -> FAILED: {e}")
traceback.print_exc()
results["failed"] += 1
# Done
print("\n" + "=" * 60)
print(" Transfer Complete!")
print("=" * 60)
print(f"\n Imported: {results['imported']}")
print(f" Skipped: {results['skipped']}")
print(f" Failed: {results['failed']}")
print(" Workspace: granola")
print(f" Peers: {sorted(seen_peers)}")
except KeyboardInterrupt:
print("\n\nAborted.")
sys.exit(0)
except Exception as e:
print(f"\nTransfer failed: {e}")
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

211
honcho-cli/README.md Normal file
View File

@ -0,0 +1,211 @@
```
██╗ ██╗ ██████╗ ███╗ ██╗ ██████╗██╗ ██╗ ██████╗
██║ ██║██╔═══██╗████╗ ██║██╔════╝██║ ██║██╔═══██╗
███████║██║ ██║██╔██╗ ██║██║ ███████║██║ ██║
██╔══██║██║ ██║██║╚██╗██║██║ ██╔══██║██║ ██║
██║ ██║╚██████╔╝██║ ╚████║╚██████╗██║ ██║╚██████╔╝
╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝
```
# honcho-cli
A terminal for [Honcho](https://honcho.dev) — memory that reasons.
## Install
As a standalone tool (recommended):
```bash
uv tool install honcho-cli
```
## Quick Start
```bash
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
honcho doctor # verify your config + connectivity
honcho # show banner + command list
```
`honcho init` reads `apiKey` and `environmentUrl` from the top-level of `~/.honcho/config.json` (the same file other Honcho tools — plugins, host integrations — share). If both are present, it confirms them with you; if either is missing (or you decline), it prompts for the missing value(s) and writes them back. Host-specific entries under `hosts` are left untouched.
Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars — not persisted as CLI defaults.
## Commands
### Onboarding
| Command | Description |
|---------|-------------|
| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json` |
| `honcho doctor` | Health check: config, connectivity, workspace, peer, queue |
### Workspaces
| Command | Description |
|---------|-------------|
| `honcho workspace list` | List accessible workspaces |
| `honcho workspace create <id>` | Create or get a workspace |
| `honcho workspace inspect` | Peers, sessions, config for a workspace |
| `honcho workspace search <query>` | Search messages across workspace |
| `honcho workspace queue-status` | Deriver queue status (filter with `--observer` / `--sender`) |
| `honcho workspace delete <id>` | Delete a workspace. Use `--dry-run` to preview, `--cascade` to also delete sessions, `--yes` to skip the confirm prompt |
### Peers
| Command | Description |
|---------|-------------|
| `honcho peer list` | List peers in the workspace |
| `honcho peer create <id>` | Create or get a peer |
| `honcho peer inspect <id>` | Card, session count, recent conclusions |
| `honcho peer card <id>` | Raw peer card content |
| `honcho peer chat <query>` | Query the dialectic about a peer (peer via `-p` / `HONCHO_PEER_ID`) |
| `honcho peer representation <id>` | Formatted representation |
| `honcho peer search <query>` | Search a peer's messages (peer via `-p` / `HONCHO_PEER_ID`) |
| `honcho peer get-metadata <id>` / `set-metadata` | Metadata operations |
### Sessions
| Command | Description |
|---------|-------------|
| `honcho session list` | List sessions in the workspace (filter with `--peer/-p`) |
| `honcho session create <id>` | Create or get a session (optionally `--peers` to add peers, `--metadata`) |
| `honcho session inspect <id>` | Peers, message count, summaries, config |
| `honcho session context <id>` | What an agent would see |
| `honcho session summaries <id>` | Short + long summaries |
| `honcho session peers <id>` / `add-peers` / `remove-peers` | Peer management |
| `honcho session search <id> <query>` | Search messages in a session |
| `honcho session representation <id>` | Peer representation in a session |
| `honcho session get-metadata <id>` / `set-metadata` | Metadata operations |
| `honcho session delete <id>` | Destructive; requires `--yes` |
### Messages
| Command | Description |
|---------|-------------|
| `honcho message list` | List messages in a session (session via `-s` / `HONCHO_SESSION_ID`) |
| `honcho message create <content>` | Create a message (requires `--peer/-p`, session via `-s`) |
| `honcho message get <id>` | Get a single message (session via `-s` / `HONCHO_SESSION_ID`) |
### Conclusions (observations)
| Command | Description |
|---------|-------------|
| `honcho conclusion list` | List conclusions (filter with `--observer` / `--observed`) |
| `honcho conclusion search <query>` | Semantic search (filter with `--observer` / `--observed`) |
| `honcho conclusion create` | Create a conclusion |
| `honcho conclusion delete <id>` | Delete a conclusion |
### Config
| Command | Description |
|---------|-------------|
| `honcho config` | Show current config (API key redacted) |
## Agent Usage
All commands output JSON when stdout isn't a TTY, or when `--json` is forced.
Collection commands emit JSON arrays, and single-resource commands emit JSON objects:
```bash
honcho peer list --json
honcho workspace inspect --json | jq '.peers'
honcho doctor --json # machine-parseable health checklist
```
Errors are structured:
```json
{
"error": {
"code": "PEER_NOT_FOUND",
"message": "Peer 'abc' not found in workspace 'my-ws'",
"details": {"workspace_id": "my-ws", "peer_id": "abc"}
}
}
```
Non-interactive onboarding:
```bash
# Pre-seed via flags / env vars; init still prompts for anything missing
HONCHO_API_KEY=hch-v3-xxx honcho init --base-url https://api.honcho.dev
```
## Agent skill
`honcho-cli` ships with a skill that teaches agents the right commands and conventions for inspecting and debugging a Honcho deployment. Install it anywhere skills are accepted (Claude Code, other skill-aware agents):
```bash
npx skills add plastic-labs/honcho
```
The picker lists every skill for Honcho — select `honcho-cli` .
## Environment Variables
All `HONCHO_*` env vars work at runtime — no config file required.
Precedence (highest first): **flag → env var → config file → default**.
| Variable | Flag | Description |
|----------|------|-------------|
| `HONCHO_API_KEY` | `--api-key` (init) | Admin JWT |
| `HONCHO_BASE_URL` | `--base-url` (init) | API URL |
| `HONCHO_WORKSPACE_ID` | `-w` / `--workspace` | Workspace scope |
| `HONCHO_PEER_ID` | `-p` / `--peer` | Peer scope |
| `HONCHO_SESSION_ID` | `-s` / `--session` | Session scope |
| `HONCHO_JSON` | `--json` | Force JSON output (`1` / `true`) |
```bash
# Per-command flags
honcho peer card -w prod -p user
# Or export once per shell
export HONCHO_WORKSPACE_ID=prod
export HONCHO_PEER_ID=user
honcho peer card
# One-off against a different server
HONCHO_BASE_URL=http://localhost:8000 honcho workspace list
# CI/CD — env vars only, no config file needed
export HONCHO_API_KEY=hch-v3-xxx
export HONCHO_BASE_URL=https://api.honcho.dev
honcho workspace list
```
## Configuration
The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns two
top-level keys: `apiKey` and `environmentUrl` (the full Honcho API URL, e.g.
`https://api.honcho.dev` or `http://localhost:8000`). Everything else at the
top level — `hosts`, `sessions`, `saveMessages`, `sessionStrategy`, etc. —
is left untouched.
```json
{
"apiKey": "hch-v3-...",
"environmentUrl": "https://api.honcho.dev",
"hosts": { "claude_code": { "...": "..." } }
}
```
`workspace_id` / `peer_id` / `session_id` are per-command only — never
persisted to the config file.
## Development
Install from source in editable mode so changes are picked up live:
```bash
git clone https://github.com/plastic-labs/honcho
cd honcho
uv tool install --force --editable --from ./honcho-cli honcho-cli
```
Re-run any time — changes to `honcho-cli/src/` are reflected immediately without reinstalling.
## License
MIT

53
honcho-cli/pyproject.toml Normal file
View File

@ -0,0 +1,53 @@
[project]
name = "honcho-cli"
version = "0.1.0"
description = "A terminal for Honcho — memory that reasons."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [
{ name = "Plastic Labs", email = "hello@plasticlabs.ai" },
]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries",
]
dependencies = [
"typer>=0.15.0",
"honcho-ai>=2.0.0",
"rich>=13.0.0",
"httpx>=0.27.0",
]
[project.urls]
Homepage = "https://github.com/plastic-labs/honcho"
Repository = "https://github.com/plastic-labs/honcho"
[project.scripts]
honcho = "honcho_cli.main:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/honcho_cli"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-mock>=3.14.0",
]
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true

View File

@ -0,0 +1,261 @@
"""Generate ``docs/snippets/cli-commands.mdx`` from the Typer app.
Walks the ``honcho`` Typer app and emits a Mintlify snippet using native
Mintlify components: ``<AccordionGroup>`` / ``<Accordion>`` for subcommand
grouping and ``<ParamField>`` for each argument and option. The output is a
single snippet included by ``docs/v3/documentation/reference/cli.mdx``.
Usage::
uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py
# Or as a drift check (non-zero exit if the committed snippet is stale):
uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py --check
"""
from __future__ import annotations
import sys
from argparse import ArgumentParser
from pathlib import Path
import click
import typer.main
from honcho_cli.main import app
REPO_ROOT = Path(__file__).resolve().parents[2]
OUTPUT = REPO_ROOT / "docs" / "snippets" / "cli-commands.mdx"
HEADER = """{/*
GENERATED by honcho-cli/scripts/generate_cli_docs.py do not edit.
Re-generate with: uv run --package honcho-cli python honcho-cli/scripts/generate_cli_docs.py
Source of truth: honcho-cli/src/honcho_cli/commands/
*/}
"""
# Documented once in cli.mdx's Configuration table. Skip at the per-command
# level so each Accordion only shows options specific to that subcommand.
GLOBAL_OPTIONS: set[tuple[str, str]] = {
("--workspace", "Override workspace ID"),
("--peer", "Override peer ID"),
("--session", "Override session ID"),
("--json", "Force JSON output"),
}
def _escape_mdx(text: str) -> str:
"""Escape MDX-sensitive characters in prose so Mintlify's parser doesn't
mistake ``{...}`` for a JSX expression or ``<x>`` for a JSX tag."""
return (
text.replace("\\", "\\\\")
.replace("{", "\\{")
.replace("}", "\\}")
.replace("<", "\\<")
)
def _attr(value: str) -> str:
"""Escape a string for use inside a JSX double-quoted attribute value."""
return value.replace("\\", "\\\\").replace('"', "'")
def _long_opt(param: click.Option) -> str | None:
return next((o for o in param.opts if o.startswith("--")), None)
def _short_opt(param: click.Option) -> str | None:
return next(
(o for o in param.opts if o.startswith("-") and not o.startswith("--")),
None,
)
def _is_global(param: click.Parameter) -> bool:
if not isinstance(param, click.Option) or not param.help:
return False
return (_long_opt(param), param.help) in GLOBAL_OPTIONS
def _param_type(param: click.Parameter) -> str:
if isinstance(param, click.Option) and param.is_flag:
return "boolean"
if isinstance(param.type, click.Choice):
return "string"
name = getattr(param.type, "name", "")
if name in ("integer", "int"):
return "number"
if name in ("float", "decimal"):
return "number"
if name == "boolean":
return "boolean"
return "string"
def _param_path(param: click.Parameter) -> str:
if isinstance(param, click.Argument):
return param.name or ""
return _long_opt(param) or (param.opts[0] if param.opts else "")
def _param_required(param: click.Parameter) -> bool:
if isinstance(param, click.Argument):
return param.required
if isinstance(param, click.Option):
return bool(param.required)
return False
def _default_attr(param: click.Parameter) -> str | None:
default = param.default
if default is None or default is False or callable(default):
return None
if isinstance(default, (list, tuple)) and not default:
return None
if default is True:
return "true"
return _attr(str(default))
def _ensure_period(text: str) -> str:
return text if text.endswith((".", "?", "!", ":")) else text + "."
def _param_body(param: click.Parameter) -> str:
parts: list[str] = []
if isinstance(param, click.Option):
if param.help:
parts.append(_ensure_period(_escape_mdx(param.help.strip())))
short = _short_opt(param)
if short:
parts.append(f"Short alias: `{short}`.")
if param.secondary_opts:
neg = " / ".join(f"`{o}`" for o in param.secondary_opts)
parts.append(f"Negate with {neg}.")
if isinstance(param.type, click.Choice):
choices = ", ".join(f"`{c}`" for c in param.type.choices)
parts.append(f"One of: {choices}.")
return " ".join(parts)
def _render_param(param: click.Parameter) -> list[str]:
props = [
f'path="{_attr(_param_path(param))}"',
f'type="{_param_type(param)}"',
]
if _param_required(param):
props.append("required")
default_attr = _default_attr(param)
if default_attr is not None:
props.append(f'default="{default_attr}"')
body = _param_body(param).strip()
open_tag = f"<ParamField {' '.join(props)}>"
if body:
return [open_tag, f" {body}", "</ParamField>"]
return [open_tag.replace(">", " />")]
def _params_of(
cmd: click.Command, *, strip_globals: bool
) -> list[click.Parameter]:
args = [p for p in cmd.params if isinstance(p, click.Argument)]
opts = [
p
for p in cmd.params
if isinstance(p, click.Option)
and not p.hidden
and not (strip_globals and _is_global(p))
]
return args + opts
def _invocation_line(cmd: click.Command, path: list[str]) -> str:
args = [p for p in cmd.params if isinstance(p, click.Argument)]
parts = [" ".join(path)]
for a in args:
placeholder = f"<{a.name}>"
if not a.required:
placeholder = f"[{placeholder}]"
parts.append(placeholder)
return " ".join(parts)
def _render_accordion(cmd: click.Command, path: list[str]) -> list[str]:
lines = [f'<Accordion title="{_attr(path[-1])}">']
if cmd.help:
lines.append(_escape_mdx(cmd.help.strip()))
lines.append("")
lines.append("```bash")
lines.append(_invocation_line(cmd, path))
lines.append("```")
lines.append("")
for p in _params_of(cmd, strip_globals=True):
lines.extend(_render_param(p))
lines.append("</Accordion>")
return lines
def _render_top(cmd: click.Command, path: list[str]) -> list[str]:
lines = [f"## {' '.join(path)}", ""]
if cmd.help:
lines.append(_escape_mdx(cmd.help.strip()))
lines.append("")
if isinstance(cmd, click.Group) and cmd.commands:
lines.append("<AccordionGroup>")
for sub_name in sorted(cmd.commands):
lines.extend(
_render_accordion(cmd.commands[sub_name], path + [sub_name])
)
lines.append("</AccordionGroup>")
lines.append("")
return lines
lines.append("```bash")
lines.append(_invocation_line(cmd, path))
lines.append("```")
lines.append("")
for p in _params_of(cmd, strip_globals=True):
lines.extend(_render_param(p))
lines.append("")
return lines
def build() -> str:
root: click.Command = typer.main.get_command(app)
if not isinstance(root, click.Group):
raise SystemExit("Expected root command to be a Group")
body: list[str] = []
for name in sorted(root.commands):
body.extend(_render_top(root.commands[name], ["honcho", name]))
return HEADER + "\n".join(body) + "\n"
def main() -> int:
parser = ArgumentParser()
parser.add_argument(
"--check",
action="store_true",
help="Exit non-zero if the committed snippet differs from generated output.",
)
ns = parser.parse_args()
generated = build()
if ns.check:
current = OUTPUT.read_text() if OUTPUT.exists() else ""
if current != generated:
print(
f"::error::{OUTPUT} is stale. Re-run without --check to regenerate.",
file=sys.stderr,
)
return 1
return 0
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(generated)
print(f"Wrote {OUTPUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,3 @@
"""Honcho CLI — a terminal for Honcho."""
__version__ = "0.1.0"

View File

@ -0,0 +1,130 @@
"""Themed help rendering for honcho CLI.
Single source of truth for:
- Rich-utils theme constants (dim borders, brand color)
- HonchoTyperGroup: subclass applied via ``cls=`` at every Typer app in
this package replaces Click's terse ``Usage: …`` line with
pattern/example rows and prints a curated welcome at the top-level.
Lives in its own module so every ``commands/*.py`` can import it without
pulling in ``main.py`` (which would create an import cycle).
"""
from __future__ import annotations
import click
import typer.rich_utils as ru
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from typer.core import TyperGroup
from honcho_cli import __version__
from honcho_cli.branding import BANNER, BRAND
from honcho_cli.output import use_json
# Theme Typer's rich help renderer. Module-level side effect limited to
# styling — no behavior changes that could surprise other Typer users.
ru.STYLE_COMMANDS_PANEL_BORDER = "dim"
ru.STYLE_OPTIONS_PANEL_BORDER = "dim"
ru.STYLE_ERRORS_PANEL_BORDER = "dim"
ru.STYLE_OPTION = f"bold {BRAND}"
ru.STYLE_SWITCH = f"bold {BRAND}"
ru.STYLE_USAGE = "dim"
ru.STYLE_USAGE_COMMAND = f"bold {BRAND}"
def _cmd_table(rows: list[tuple[str, str]]) -> Table:
t = Table(show_header=False, box=None, padding=(0, 2, 0, 0), expand=False)
t.add_column("cmd", style=f"bold {BRAND}", no_wrap=True)
t.add_column("desc", style="default")
for cmd, desc in rows:
t.add_row(cmd, desc)
return t
def _welcome_panel(title: str, rows: list[tuple[str, str]]) -> Panel:
return Panel(
_cmd_table(rows),
title=f"[dim]{title}[/dim]",
title_align="left",
border_style="dim",
box=box.ROUNDED,
padding=(0, 1),
expand=False,
)
def print_welcome(console: Console) -> None:
"""Render the curated 3-panel welcome (banner + getting started / memory / commands)."""
if use_json():
return
console.print(f"[bold {BRAND}]{BANNER}[/bold {BRAND}]")
console.print(f" [dim]v{__version__}[/dim]\n", highlight=False)
start_rows = [
("honcho init", "configure API key and server URL"),
("honcho doctor", "verify connection and workspace health"),
]
cmd_rows = [
("[dim]pattern[/dim]", r"[dim]honcho <command> \[args] \[-w workspace] \[-p peer] \[-s session][/dim]"),
("[dim]example[/dim]", "[dim]honcho peer chat \"what does alice prefer?\" -p alice -w agents[/dim]"),
("", ""),
("workspace", "list · create · search · delete · inspect · queue-status"),
("peer", "list · create · search · inspect · card · chat"),
("", "get-metadata · set-metadata · representation"),
("session", "list · create · search · delete · inspect · add-peers"),
("", "context · get-metadata · set-metadata · peers"),
("", "remove-peers · representation · summaries"),
("message", "list · create · get"),
("conclusion", "list · create · search · delete"),
("config", "inspect current configuration"),
]
memory_rows = [
("honcho peer chat \"...\" -p <peer> -w <workspace>","query the Dialectic about a peer"),
("honcho peer inspect -p <peer> -w <workspace>","dashboard: peer card + recent conclusions + configuration"),
("honcho peer representation -p <peer> -w <workspace>", "global peer representation"),
("honcho peer representation -p <peer> -w <workspace> -s <session>", "session-scoped peer representation"),
("honcho peer card -p <peer> -w <workspace>", "synthesized identity: traits, preferences, instructions"),
("honcho conclusion list -p <peer> -w <workspace>", "browse peer conclusions"),
]
option_rows = [
("-w / --workspace", "scope to a workspace"),
("-p / --peer", "scope to a peer"),
("-s / --session", "scope to a session"),
("--json", "force JSON output for scripts and agents"),
("--help", "show help for any command (e.g. honcho peer --help)"),
]
console.print(_welcome_panel("getting started", start_rows))
console.print(_welcome_panel("commands", cmd_rows))
console.print(_welcome_panel("memory", memory_rows))
console.print(_welcome_panel("options", option_rows))
console.print()
class HonchoTyperGroup(TyperGroup):
"""Typer group with pattern/example usage and top-level welcome.
Applied via ``cls=`` on every ``typer.Typer(...)`` in this package,
so no class-level monkey-patching is needed.
"""
def get_usage(self, ctx):
"""Replace Click's 'Usage: …' with pattern/example rows."""
original = click.Command.get_usage(self, ctx)
pattern = original.replace("Usage: ", "", 1) if original.startswith("Usage: ") else original
subs = self.list_commands(ctx)
example = f"{ctx.command_path} {subs[0]}" if subs else f"{ctx.command_path} --help"
return f"pattern: {pattern}\nexample: {example}"
def format_help(self, ctx, formatter):
"""Top-level --help renders the welcome; sub-groups fall through to Typer."""
if ctx.parent is None:
print_welcome(Console())
return
super().format_help(ctx, formatter)

View File

@ -0,0 +1,17 @@
"""Honcho CLI brand constants — colours, icons, and the ASCII banner.
"""
BRAND = "#B6DAFD"
BANNER = """
""".strip("\n")
ICON_OK = "[green]✓[/green]"
ICON_FAIL = "[red]✗[/red]"
ICON_RUN = f"[{BRAND}]→[/{BRAND}]"

View File

@ -0,0 +1,219 @@
"""Conclusion commands: list, search, create, delete."""
from __future__ import annotations
import json
from typing import Optional
import typer
from honcho_cli.commands.workspace import _handle_error
from honcho_cli.output import print_error, print_result, status, use_json
from honcho_cli.validation import validate_resource_id
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
app = typer.Typer(cls=HonchoTyperGroup, help="List, search, create, and delete peer conclusions (Honcho's memory atoms).")
add_common_options(app)
def _require_observer(observer: str | None) -> str:
"""Resolve observer peer ID; emit combined error if peer+workspace both missing."""
config = get_resolved_config()
obs = observer or config.peer_id
if not obs:
if not config.workspace_id:
print_error(
"NO_SCOPE",
"No peer or workspace scoped. Pass --peer/-p and --workspace/-w, or set HONCHO_PEER_ID and HONCHO_WORKSPACE_ID.",
)
else:
print_error("NO_PEER", "Peer required. Pass --peer/-p: honcho conclusion <cmd> -p <peer>")
raise typer.Exit(1)
return obs
@app.command("list")
def list_conclusions(
observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
limit: int = typer.Option(10, "--limit", help="Max results"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List conclusions."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
observer = _require_observer(observer)
client, config = get_client()
p = client.peer(observer)
try:
if observed:
scope = p.conclusions_of(observed)
else:
scope = p.conclusions
conclusions = scope.list(size=limit).items
items = [
{
"id": c.id,
"content": c.content if use_json() else c.content[:200],
"workspace_id": config.workspace_id,
"observer_id": c.observer_id,
"observed_id": c.observed_id,
"session_id": c.session_id,
"created_at": str(c.created_at),
}
for c in conclusions
]
print_result(items, columns=["id", "content", "workspace_id", "observer_id", "observed_id", "session_id", "created_at"], title="Conclusions")
except Exception as e:
_handle_error(e, "conclusion", "list")
@app.command()
def search(
query: str = typer.Argument(help="Search query"),
observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
top_k: int = typer.Option(10, help="Max results"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Semantic search over conclusions."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
observer = _require_observer(observer)
client, config = get_client()
p = client.peer(observer)
try:
if observed:
scope = p.conclusions_of(observed)
else:
scope = p.conclusions
results = scope.query(query, top_k=top_k)
items = [
{
"id": c.id,
"content": c.content if use_json() else c.content[:200],
"workspace_id": config.workspace_id,
"observer_id": c.observer_id,
"observed_id": c.observed_id,
"session_id": c.session_id,
"created_at": str(c.created_at),
}
for c in results
]
print_result(items, columns=["id", "content", "workspace_id", "session_id", "created_at"], title=f"Conclusion search: {query}")
except Exception as e:
_handle_error(e, "conclusion", "search")
@app.command()
def create(
content: str = typer.Argument(help="Conclusion content or JSON payload"),
observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session context"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Create a conclusion."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session_id)
observer = _require_observer(observer)
client, config = get_client()
# If content looks like JSON, try to parse it
try:
payload = json.loads(content)
if isinstance(payload, dict):
content = payload.get("content", content)
except json.JSONDecodeError:
pass
p = client.peer(observer)
try:
if observed:
scope = p.conclusions_of(observed)
else:
scope = p.conclusions
params: dict[str, object] = {"content": content}
if config.session_id:
params["session_id"] = config.session_id
results = scope.create([params])
result = results[0] if results else None
if result is None:
print_error("CREATE_FAILED", "Conclusion create returned no results")
raise typer.Exit(1)
print_result({
"id": result.id,
"content": result.content,
"workspace_id": config.workspace_id,
"observer_id": result.observer_id,
"observed_id": result.observed_id,
"session_id": result.session_id,
"created_at": str(result.created_at),
})
except Exception as e:
_handle_error(e, "conclusion", "create")
@app.command()
def delete(
conclusion_id: str = typer.Argument(help="Conclusion ID to delete"),
observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Delete a conclusion."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
validate_resource_id(conclusion_id, "conclusion")
client, config = get_client()
if not observer:
observer = config.peer_id
if not observer:
print_error("NO_PEER", "Peer required. Pass --peer/-p: honcho conclusion <cmd> -p <peer>")
raise typer.Exit(1)
p = client.peer(observer)
if not yes:
# SDK doesn't expose a get-by-id on ConclusionScope, so we can't
# preview content cheaply — don't paginate the list just to
# decorate the prompt. Show identifying fields only.
if not use_json():
typer.echo(
f" id: {conclusion_id}\n"
f" observer: {observer}\n"
f" observed: {observed or '(self)'}"
)
typer.confirm(f"Delete conclusion '{conclusion_id}'?", abort=True)
try:
if observed:
scope = p.conclusions_of(observed)
else:
scope = p.conclusions
scope.delete(conclusion_id)
status(f"Conclusion '{conclusion_id}' deleted")
print_result({"deleted": conclusion_id})
except Exception as e:
_handle_error(e, "conclusion", conclusion_id)

View File

@ -0,0 +1,31 @@
"""Config inspection command: ``honcho config``.
Writing to ``~/.honcho/config.json`` is done only via ``honcho init``, which
manages the two CLI-owned keys (``apiKey`` + ``environmentUrl``).
Workspace / peer / session scoping is per-command via flags / env vars, not
persisted defaults.
"""
from __future__ import annotations
import typer
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import handle_cmd_flags
from honcho_cli.config import CLIConfig
from honcho_cli.output import print_result
app = typer.Typer(cls=HonchoTyperGroup, help="Inspect CLI configuration.", invoke_without_command=True)
@app.callback(invoke_without_command=True)
def config(
ctx: typer.Context,
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Show current config (api key redacted)."""
if ctx.invoked_subcommand is not None:
return
handle_cmd_flags(json_output=json_output)
cfg = CLIConfig.load()
print_result(cfg.redacted())

View File

@ -0,0 +1,161 @@
"""Message commands: list, get, create."""
from __future__ import annotations
import hashlib
import json
from typing import Optional
import typer
from honcho.api_types import MessageCreateParams
from honcho_cli.commands.session import _get_session_id
from honcho_cli.commands.workspace import _handle_error
from honcho_cli.output import print_error, print_result, status
from honcho_cli.validation import validate_resource_id
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import add_common_options, get_client, handle_cmd_flags
app = typer.Typer(cls=HonchoTyperGroup, help="List, create, and get messages within a session.")
add_common_options(app)
@app.command("list")
def list_messages(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
last: int = typer.Option(20, "--last", help="Number of recent messages"),
reverse: bool = typer.Option(False, "--reverse", help="Show oldest first (default is newest first)"),
brief: bool = typer.Option(False, "--brief", help="Show only IDs, peer, token count, and created_at (no content)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Filter by peer ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List messages in a session. Scoped to a peer with -p."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
filters = {"peer_id": config.peer_id} if config.peer_id else None
# Fetch newest-first so [:last] always gives the most recent N messages,
# then flip to oldest-at-top / newest-at-bottom for readable display.
# --reverse keeps the raw server order (oldest first, descending in table).
msgs = sess.messages(filters=filters, reverse=True).items[:last]
if not reverse:
msgs = list(reversed(msgs))
# Detect duplicate content
content_hashes: dict[str, list[str]] = {}
for m in msgs:
h = hashlib.md5(m.content.encode()).hexdigest()
content_hashes.setdefault(h, []).append(m.id)
dupes = {h: ids for h, ids in content_hashes.items() if len(ids) > 1}
if dupes:
dupe_count = sum(len(ids) - 1 for ids in dupes.values())
status(f"Warning: {dupe_count} duplicate message(s) detected (identical content, different IDs)")
if brief:
items = [
{
"id": m.id,
"peer_id": m.peer_id,
"token_count": m.token_count,
"created_at": str(m.created_at),
}
for m in msgs
]
print_result(items, columns=["id", "peer_id", "token_count", "created_at"], title="Messages")
else:
items = [
{
"id": m.id,
"peer_id": m.peer_id,
"content": m.content,
"token_count": m.token_count,
"metadata": m.metadata,
"created_at": str(m.created_at),
}
for m in msgs
]
print_result(items, columns=["id", "peer_id", "content", "created_at"], title="Messages")
except Exception as e:
_handle_error(e, "message", "list")
@app.command("create")
def create_message(
content: str = typer.Argument(help="Message content"),
peer_id: str = typer.Option(..., "--peer", "-p", help="Peer ID of the message sender"),
metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the message"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Create a message in a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session_id)
sid = _get_session_id(None)
validate_resource_id(peer_id, "peer")
client, config = get_client()
sess = client.session(sid)
parsed_metadata = None
if metadata:
try:
parsed_metadata = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
try:
msgs = sess.add_messages(MessageCreateParams(
peer_id=peer_id,
content=content,
metadata=parsed_metadata,
))
msg = msgs[0]
print_result({
"id": msg.id,
"peer_id": msg.peer_id,
"content": msg.content,
"token_count": msg.token_count,
"created_at": str(msg.created_at),
})
except Exception as e:
_handle_error(e, "message", "create")
@app.command("get")
def get_message(
message_id: str = typer.Argument(help="Message ID"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get a single message by ID."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
validate_resource_id(message_id, "message")
sid = _get_session_id(session_id)
client, config = get_client()
try:
sess = client.session(sid)
msg = sess.get_message(message_id)
print_result({
"id": msg.id,
"peer_id": msg.peer_id,
"content": msg.content,
"token_count": msg.token_count,
"metadata": msg.metadata,
"created_at": str(msg.created_at),
})
except SystemExit:
raise
except Exception as e:
_handle_error(e, "message", message_id)

View File

@ -0,0 +1,308 @@
"""Peer commands: list, inspect, card, chat, search, create, metadata, representation."""
from __future__ import annotations
import json
from typing import Optional
import typer
from honcho.api_types import PeerConfig
from honcho_cli.commands.workspace import _config_to_dict, _handle_error, _raw_list
from honcho_cli.output import print_error, print_result, use_json
from honcho_cli.validation import validate_resource_id
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
app = typer.Typer(cls=HonchoTyperGroup, help="List, create, chat with, search, and manage peers and their representations.")
add_common_options(app)
def _get_peer_id(peer_id: str | None) -> str:
config = get_resolved_config()
pid = peer_id or config.peer_id
if not pid:
if not config.workspace_id:
print_error(
"NO_SCOPE",
"No peer or workspace scoped. Pass --peer/-p and --workspace/-w, or set HONCHO_PEER_ID and HONCHO_WORKSPACE_ID.",
)
else:
print_error("NO_PEER", "No peer ID provided. Pass --peer/-p or set HONCHO_PEER_ID.")
raise typer.Exit(1)
return validate_resource_id(pid, "peer")
@app.command("list")
def list_peers(
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List all peers in the workspace."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
client, config = get_client()
try:
raw_peers = _raw_list(client.peers())
items = [
{
"id": p.id,
"metadata": p.metadata,
"configuration": _config_to_dict(p.configuration) if p.configuration else None,
"created_at": str(p.created_at),
}
for p in raw_peers
]
print_result(items, columns=["id", "metadata", "created_at"], title="Peers")
except Exception as e:
_handle_error(e, "peer", "list")
@app.command()
def inspect(
peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Inspect a peer: card, session count, recent conclusions."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
pid = _get_peer_id(peer_id)
client, config = get_client()
p = client.peer(pid)
try:
card = p.get_card()
peer_config = p.get_configuration()
# First page only; SyncPage.total (when the server supplies it) is
# authoritative for counts without walking every page.
session_page = p.sessions()
conclusion_page = p.conclusions.list(size=10)
session_items = session_page.items
conclusion_items = conclusion_page.items
result = {
"id": pid,
"card": card,
"configuration": _config_to_dict(peer_config) if peer_config else None,
"session_count": session_page.total,
"conclusion_count": conclusion_page.total,
"recent_conclusions": [
{"id": c.id, "content": c.content if use_json() else c.content[:200], "created_at": str(c.created_at)}
for c in conclusion_items
],
"sessions": [{"id": s.id} for s in session_items[:10]],
}
print_result(result)
except Exception as e:
_handle_error(e, "peer", pid)
@app.command()
def card(
peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"),
target: Optional[str] = typer.Option(None, help="Target peer for relationship card"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get raw peer card content."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
pid = _get_peer_id(peer_id)
client, config = get_client()
p = client.peer(pid)
try:
result = p.get_card(target=target)
print_result({"peer_id": pid, "target": target, "card": result})
except Exception as e:
_handle_error(e, "peer", pid)
@app.command()
def chat(
query: str = typer.Argument(help="Question to ask about the peer"),
target: Optional[str] = typer.Option(None, help="Target peer for perspective"),
reasoning: Optional[str] = typer.Option(None, "--reasoning", "-r", help="Reasoning level: minimal, low, medium, high, max"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Query the dialectic about a peer."""
_REASONING_LEVELS = ("minimal", "low", "medium", "high", "max")
if reasoning and reasoning not in _REASONING_LEVELS:
from honcho_cli.output import print_error
print_error("INVALID_REASONING", f"--reasoning must be one of: {', '.join(_REASONING_LEVELS)}")
raise typer.Exit(1)
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session)
pid = _get_peer_id(None)
client, config = get_client()
p = client.peer(pid)
try:
response = p.chat(
query,
target=target,
session=config.session_id or None,
reasoning_level=reasoning or None,
)
print_result({"peer_id": pid, "query": query, "response": response})
except Exception as e:
_handle_error(e, "peer", pid)
@app.command()
def search(
query: str = typer.Argument(help="Search query"),
limit: int = typer.Option(10, help="Max results"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Search a peer's messages."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
pid = _get_peer_id(None)
client, config = get_client()
p = client.peer(pid)
try:
results = p.search(query, limit=limit)
items = [
{
"id": m.id,
"content": m.content if use_json() else m.content[:200],
"session_id": m.session_id,
"created_at": str(m.created_at),
}
for m in results
]
print_result(items, columns=["id", "session_id", "content", "created_at"], title=f"Peer search: {query}")
except Exception as e:
_handle_error(e, "peer", pid)
@app.command("create")
def create_peer(
peer_id: str = typer.Argument(help="Peer ID to create or get"),
observe_me: Optional[bool] = typer.Option(None, "--observe-me/--no-observe-me", help="Whether Honcho will form a representation of this peer"),
metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the peer"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Create or get a peer."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
pid = validate_resource_id(peer_id, "peer")
client, config = get_client()
parsed_metadata = None
if metadata:
try:
parsed_metadata = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
peer_config = PeerConfig(observe_me=observe_me) if observe_me is not None else None
try:
p = client.peer(pid, configuration=peer_config, metadata=parsed_metadata)
# Only round-trip to the server when the caller passed config or
# metadata — in that case get-or-create may have returned a
# pre-existing peer and the echoed output would lie. When no input
# was passed, skip the two extra API calls entirely.
result: dict[str, object] = {"peer_id": p.id}
if peer_config is not None or parsed_metadata is not None:
result["metadata"] = p.get_metadata()
result["configuration"] = _config_to_dict(p.get_configuration())
print_result(result)
except Exception as e:
_handle_error(e, "peer", pid)
@app.command("get-metadata")
def get_metadata(
peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get metadata for a peer."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
pid = _get_peer_id(peer_id)
client, config = get_client()
p = client.peer(pid)
try:
result = p.get_metadata()
print_result({"peer_id": pid, "metadata": result})
except Exception as e:
_handle_error(e, "peer", pid)
@app.command("set-metadata")
def set_metadata(
metadata: str = typer.Argument(help="JSON metadata to set (e.g. '{\"key\": \"value\"}')"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Peer ID (uses default if omitted)"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Set metadata for a peer."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
pid = _get_peer_id(None)
client, config = get_client()
try:
parsed = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
p = client.peer(pid)
try:
p.set_metadata(parsed)
print_result({"peer_id": pid, "metadata": parsed})
except Exception as e:
_handle_error(e, "peer", pid)
@app.command()
def representation(
peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"),
target: Optional[str] = typer.Option(None, help="Target peer to get representation about"),
search_query: Optional[str] = typer.Option(None, help="Semantic search query to filter conclusions"),
max_conclusions: Optional[int] = typer.Option(None, help="Maximum number of conclusions to include"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get the formatted representation for a peer."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session)
pid = _get_peer_id(peer_id)
client, config = get_client()
p = client.peer(pid)
try:
result = p.representation(
target=target,
session=config.session_id or None,
search_query=search_query,
max_conclusions=max_conclusions,
)
print_result({"peer_id": pid, "target": target, "representation": result})
except Exception as e:
_handle_error(e, "peer", pid)

View File

@ -0,0 +1,404 @@
"""Session commands: list, inspect, context, summaries, peers, search, representation, metadata."""
from __future__ import annotations
import json
from typing import List, Optional
import typer
from honcho import HonchoError
from honcho_cli.commands.workspace import _config_to_dict, _handle_error, _raw_list
from honcho_cli.output import print_error, print_result, status, use_json
from honcho_cli.validation import validate_resource_id
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
app = typer.Typer(cls=HonchoTyperGroup, help="List, inspect, create, delete, and manage conversation sessions and their peers.")
add_common_options(app)
def _get_session_id(session_id: str | None) -> str:
config = get_resolved_config()
sid = session_id or config.session_id
if not sid:
print_error("NO_SESSION", "No session ID provided. Pass --session/-s or set HONCHO_SESSION_ID.")
raise typer.Exit(1)
return validate_resource_id(sid, "session")
@app.command("list")
def list_sessions(
peer_id: Optional[str] = typer.Option(None, "--peer", "-p", help="Filter by peer"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List sessions in the workspace."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
client, config = get_client()
try:
if peer_id:
peer = client.peer(peer_id)
raw_sessions = _raw_list(peer.sessions())
else:
raw_sessions = _raw_list(client.sessions())
items = [
{
"id": s.id,
"is_active": s.is_active,
"metadata": s.metadata,
"created_at": str(s.created_at),
}
for s in raw_sessions
]
print_result(items, columns=["id", "is_active", "metadata", "created_at"], title="Sessions")
except Exception as e:
_handle_error(e, "session", "list")
@app.command("create")
def create_session(
session_id: str = typer.Argument(help="Session ID to create or get"),
peers: Optional[str] = typer.Option(None, "--peers", help="Comma-separated peer IDs to add to the session"),
metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the session"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Create or get a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
sid = validate_resource_id(session_id, "session")
client, config = get_client()
parsed_metadata = None
if metadata:
try:
parsed_metadata = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
peer_ids = [p.strip() for p in peers.split(",") if p.strip()] if peers else []
for pid in peer_ids:
validate_resource_id(pid, "peer")
try:
sess = client.session(sid, metadata=parsed_metadata)
if peer_ids:
sess.add_peers(peer_ids)
result: dict[str, object] = {"session_id": sess.id}
if parsed_metadata is not None:
result["metadata"] = parsed_metadata
if peer_ids:
result["peers"] = peer_ids
print_result(result)
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def inspect(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Inspect a session: peers, message count, summaries, config."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
peers = sess.peers()
msg_page = sess.messages()
summaries = sess.summaries()
sess_config = sess.get_configuration()
result = {
"session_id": sid,
"peers": [{"id": p.id} for p in peers],
"message_count": msg_page.total,
"summaries": {
"short": summaries.short_summary if hasattr(summaries, "short_summary") else None,
"long": summaries.long_summary if hasattr(summaries, "long_summary") else None,
},
"configuration": _config_to_dict(sess_config) if sess_config else None,
}
print_result(result)
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def context(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
tokens: Optional[int] = typer.Option(None, help="Token budget"),
summary: bool = typer.Option(True, help="Include summary"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get session context (what an agent would see)."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
ctx = sess.context(tokens=tokens, summary=summary)
result = ctx.__dict__ if hasattr(ctx, "__dict__") else ctx
print_result(result)
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def summaries(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get session summaries (short + long)."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
s = sess.summaries()
result = {
"session_id": sid,
"short_summary": s.short_summary if hasattr(s, "short_summary") else None,
"long_summary": s.long_summary if hasattr(s, "long_summary") else None,
}
print_result(result)
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def delete(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Delete a session and all its data. Destructive — requires --yes or interactive confirm."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
if not yes:
# Show a short preview so the user knows what's about to disappear.
# Only in interactive/TTY mode — scripted (--json) callers already
# know what they're deleting, and they still need to pass --yes.
# Narrow the except to HonchoError so auth/network failures surface
# before the user types 'y' on a destructive op.
if not use_json():
try:
peers = sess.peers()
msg_page = sess.messages()
peer_ids = [p.id for p in peers]
typer.echo(
f" session: {sid}\n"
f" peers: {', '.join(peer_ids) if peer_ids else '(none)'}\n"
f" messages: {msg_page.total}"
)
except HonchoError as preview_err:
status(f"preview unavailable: {preview_err}")
typer.confirm(f"Delete session '{sid}' and all its messages, conclusions, and queue items?", abort=True)
try:
sess.delete()
status(f"Session '{sid}' deleted")
print_result({"deleted": sid})
except Exception as e:
_handle_error(e, "session", sid)
@app.command("peers")
def session_peers(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List peers in a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
peers = sess.peers()
items = [{"id": p.id} for p in peers]
print_result(items, columns=["id"], title=f"Session peers ({sid})")
except Exception as e:
_handle_error(e, "session", sid)
@app.command("add-peers")
def add_peers(
session_id: str = typer.Argument(help="Session ID"),
peer_ids: List[str] = typer.Argument(help="Peer IDs to add to the session"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Add peers to a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
sess.add_peers(peer_ids)
print_result({"session_id": sid, "added_peers": peer_ids})
except Exception as e:
_handle_error(e, "session", sid)
@app.command("remove-peers")
def remove_peers(
session_id: str = typer.Argument(help="Session ID"),
peer_ids: List[str] = typer.Argument(help="Peer IDs to remove from the session"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Remove peers from a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
sess.remove_peers(peer_ids)
print_result({"session_id": sid, "removed_peers": peer_ids})
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def search(
query: str = typer.Argument(help="Search query"),
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
limit: int = typer.Option(10, help="Max results"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Search messages in a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
results = sess.search(query, limit=limit)
items = [
{
"id": m.id,
"peer_id": m.peer_id,
"content": m.content if use_json() else m.content[:200],
"created_at": str(m.created_at),
}
for m in results
]
print_result(items, columns=["id", "peer_id", "content", "created_at"], title=f"Session search: {query}")
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def representation(
peer_id: str = typer.Argument(help="Peer ID to get representation for"),
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
target: Optional[str] = typer.Option(None, help="Target peer (what peer_id knows about target)"),
search_query: Optional[str] = typer.Option(None, help="Semantic search query to filter conclusions"),
max_conclusions: Optional[int] = typer.Option(None, help="Maximum number of conclusions to include"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get the representation of a peer within a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
result = sess.representation(
peer_id,
target=target,
search_query=search_query,
max_conclusions=max_conclusions,
)
print_result({"session_id": sid, "peer_id": peer_id, "target": target, "representation": result})
except Exception as e:
_handle_error(e, "session", sid)
@app.command("get-metadata")
def get_metadata(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get metadata for a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
result = sess.get_metadata()
print_result({"session_id": sid, "metadata": result})
except Exception as e:
_handle_error(e, "session", sid)
@app.command("set-metadata")
def set_metadata(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
metadata: str = typer.Option(..., "--data", "-d", help="JSON metadata to set (e.g. '{\"key\": \"value\"}')"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Set metadata for a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
try:
parsed = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
sess = client.session(sid)
try:
sess.set_metadata(parsed)
print_result({"session_id": sid, "metadata": parsed})
except Exception as e:
_handle_error(e, "session", sid)

View File

@ -0,0 +1,287 @@
"""Top-level onboarding and health-check commands.
`honcho init` confirm or set apiKey + Honcho URL in ~/.honcho/config.json
`honcho doctor` verify connectivity, config validity, queue health
"""
from __future__ import annotations
import json
import typer
from honcho import (
APIError,
AuthenticationError,
ConnectionError as HonchoConnectionError,
Honcho,
TimeoutError as HonchoTimeoutError,
)
from rich.console import Console
from rich.panel import Panel
from honcho_cli import __version__
from honcho_cli.branding import BANNER, BRAND, ICON_FAIL, ICON_OK, ICON_RUN
from honcho_cli.common import get_resolved_config
from honcho_cli.config import (
CONFIG_FILE,
DEFAULT_BASE_URL,
CLIConfig,
)
from honcho_cli.output import print_error, print_result, set_json_mode, use_json
_console = Console(stderr=True)
# --------------------------------------------------------------------------- #
# shared helpers
def _redact(api_key: str) -> str:
"""Show ``***<last4>`` — enough to compare keys without leaking the body."""
if not api_key:
return ""
if len(api_key) <= 4:
return "***"
return "***" + api_key[-4:]
def _read_file_values() -> tuple[str, str]:
"""Return (apiKey, environmentUrl) persisted on disk (or empty strings)."""
if not CONFIG_FILE.exists():
return "", ""
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return "", ""
if not isinstance(data, dict):
return "", ""
key = data.get("apiKey") if isinstance(data.get("apiKey"), str) else ""
url = data.get("environmentUrl") if isinstance(data.get("environmentUrl"), str) else ""
return key, url
def _test_connection(base_url: str, api_key: str) -> tuple[bool, str]:
"""Probe the Honcho API by listing workspaces. Returns (ok, detail).
Dispatches on the SDK's typed exception hierarchy instead of matching
substrings of error messages robust to SDK message changes and locale.
"""
try:
list(Honcho(base_url=base_url, api_key=api_key).workspaces())
return True, "OK"
except AuthenticationError:
return False, "Unauthorized — check your API key"
except HonchoConnectionError:
return False, "Connection refused — is the server running?"
except HonchoTimeoutError:
return False, "Request timed out"
except APIError as e:
return False, f"API error ({e.status}): {e}"
except Exception as e:
return False, str(e)
def _pick(flag_val: str | None, file_val: str) -> str:
"""Return best available value. Flag/env wins over file."""
return flag_val or file_val or ""
# --------------------------------------------------------------------------- #
# honcho init
def init(
api_key: str | None = typer.Option(None, "--api-key", envvar="HONCHO_API_KEY", help="API key (admin JWT)"),
base_url: str | None = typer.Option(None, "--base-url", envvar="HONCHO_BASE_URL", help="Honcho API URL (e.g. https://api.honcho.dev, http://localhost:8000)"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Set API key and server URL in ~/.honcho/config.json.
Press Enter to keep the current value or type a replacement.
Workspace / peer / session scoping is per-command via -w / -p / -s
or HONCHO_* env vars never persisted.
"""
if json_output:
set_json_mode(True)
file_key, file_url = _read_file_values()
key_val = _pick(api_key, file_key)
url_val = _pick(base_url, file_url).strip()
if not use_json():
_console.print()
_console.print(Panel(
f"[bold {BRAND}]{BANNER}[/bold {BRAND}]\n\n Memory that reasons",
expand=False, subtitle=f"Honcho CLI · v{__version__}",
))
_console.print()
_console.print()
final_key = _prompt_api_key(key_val)
final_url = _prompt_url(url_val)
# Persist if anything changed or if the value came from env/flag.
if final_key != file_key or final_url != file_url:
CLIConfig(base_url=final_url, api_key=final_key).save()
if not use_json():
_console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]")
_check_connection(final_url, final_key)
if use_json():
print_result({"apiKey": _redact(final_key), "baseUrl": final_url})
def _prompt_api_key(value: str) -> str:
"""Prompt for API key.
When a key already exists (from env var or config file), the user picks
between keeping it or entering a replacement. When no key exists, the
user can paste one or press Enter to skip (local dev with auth disabled
doesn't need a key).
"""
if use_json():
return value
if value:
redacted = _redact(value)
_console.print(f" [dim]Current API key: {redacted}[/dim]")
_console.print(" [dim](1)[/dim] Keep current key")
_console.print(" [dim](2)[/dim] Enter a new key")
choice = typer.prompt(" Choice", default="1", show_default=True, prompt_suffix=": ").strip()
if choice == "2":
raw = typer.prompt(" API key", default="", show_default=False, prompt_suffix=": ").strip()
return raw
return value
else:
_console.print(" [dim]Not needed for local dev — press Enter to skip[/dim]")
raw = typer.prompt(" API key", default="", show_default=False, prompt_suffix=": ").strip()
return raw
def _normalize_url(url: str) -> str:
"""Strip whitespace from the URL."""
return url.strip()
def _prompt_url(value: str) -> str:
"""Prompt for Honcho URL. Shows current value as the default; Enter keeps it.
First run defaults to DEFAULT_BASE_URL. After that, whatever is saved
in config becomes the default so the user isn't fighting back to their
custom URL every time.
"""
if use_json():
if value:
return _normalize_url(value)
print_error("MISSING_VALUE", "Honcho URL is required", {})
raise typer.Exit(1)
default = _normalize_url(value) if value else DEFAULT_BASE_URL
_console.print(" [dim]Use https://api.honcho.dev for the hosted Honcho instance[/dim]")
while True:
raw = typer.prompt(" Honcho URL", default=default, show_default=True, prompt_suffix=": ").strip()
url = _normalize_url(raw)
if url.startswith(("http://", "https://")):
return url
_console.print(" [red]URL must start with http:// or https://[/red]")
def _check_connection(base_url: str, api_key: str) -> None:
if not use_json():
_console.print(f"\n {ICON_RUN} [dim]Testing connection to {base_url}...[/dim]", end=" ")
ok, detail = _test_connection(base_url, api_key)
if not ok:
if use_json():
print_error("CONNECTION_FAILED", detail, {"base_url": base_url})
else:
_console.print(f"{ICON_FAIL} [red]Failed[/red]: {detail}")
raise typer.Exit(1)
if not use_json():
_console.print(f"{ICON_OK} [green]Connected[/green]")
# --------------------------------------------------------------------------- #
# honcho doctor
def doctor(
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Verify config and connectivity. Scope with -w / -p to check workspace, peer, and queue health."""
if json_output:
set_json_mode(True)
checks: list[dict] = []
def _add(name: str, ok: bool, detail: str = "") -> None:
checks.append({"check": name, "ok": ok, "detail": detail})
if not use_json():
icon = ICON_OK if ok else ICON_FAIL
line = f" {icon} {name:<22}"
if detail:
line += f" [dim]{detail}[/dim]"
_console.print(line)
if not use_json():
_console.print(f"\n[bold {BRAND}]Honcho Doctor[/bold {BRAND}]\n")
config = get_resolved_config()
_add("Config file", CONFIG_FILE.exists(),
str(CONFIG_FILE) if CONFIG_FILE.exists() else f"{CONFIG_FILE} not found")
_add("API key configured", bool(config.api_key),
"set" if config.api_key else "missing — run `honcho init`")
if config.base_url and config.api_key:
_add("API connectivity", *_test_connection(config.base_url, config.api_key))
else:
_add("API connectivity", False, "skipped — no base_url or api_key")
# Workspace / peer / queue run only when scoped via -w / -p.
ws_ok, client = False, None
if config.workspace_id and config.api_key:
try:
client = Honcho(base_url=config.base_url, api_key=config.api_key, workspace_id=config.workspace_id)
client.get_configuration()
ws_ok = True
_add("Workspace reachable", True, config.workspace_id)
except Exception as e:
_add("Workspace reachable", False, f"{config.workspace_id}: {e}")
if ws_ok:
try:
q = client.queue_status()
_add("Queue health", True, f"{q.completed_work_units}/{q.total_work_units} completed, {q.pending_work_units} pending")
except Exception:
_add("Queue health", True, "endpoint not available (non-critical)")
if config.peer_id:
if ws_ok and client is not None:
try:
client.peer(config.peer_id).get_card()
_add("Peer exists", True, config.peer_id)
except Exception as e:
_add("Peer exists", False, f"{config.peer_id}: {e}")
else:
_add("Peer exists", False, "skipped — workspace not reachable")
passed = sum(1 for c in checks if c["ok"])
total = len(checks)
if use_json():
print_result({"checks": checks, "passed": passed, "total": total})
else:
color = BRAND if passed == total else ("yellow" if passed > total // 2 else "red")
hint = "" if config.workspace_id else " [dim](pass -w / -p to include workspace, peer, queue checks)[/dim]"
_console.print(f"\n [{color}]{passed}/{total}[/{color}] checks passed{hint}\n")
# Config file + API connectivity are hard requirements.
critical = {"Config file", "API key configured", "API connectivity"}
if config.workspace_id:
critical.add("Workspace reachable")
if any(not c["ok"] for c in checks if c["check"] in critical):
raise typer.Exit(1)

View File

@ -0,0 +1,322 @@
"""Workspace commands: list, inspect, create, delete, search, queue-status."""
from __future__ import annotations
import json
from typing import Optional
import typer
from honcho import (
APIError,
AuthenticationError,
Honcho,
NotFoundError,
PermissionDeniedError,
ServerError,
)
from honcho_cli.output import print_error, print_result, status, use_json
from honcho_cli.validation import validate_resource_id
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
app = typer.Typer(cls=HonchoTyperGroup, help="List, create, inspect, delete, and search workspaces.")
add_common_options(app)
def _get_workspace_id(workspace_id: str | None) -> str:
config = get_resolved_config()
wid = workspace_id or config.workspace_id
if not wid:
print_error("NO_WORKSPACE", "No workspace ID provided. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.")
raise typer.Exit(1)
return validate_resource_id(wid, "workspace")
def _raw_list(page) -> list:
"""Collect all raw API response items across all pages of a SyncPage."""
items = list(page._raw_items)
while page.has_next_page():
page = page.get_next_page()
if page is None:
break
items.extend(page._raw_items)
return items
@app.command("list")
def list_workspaces(
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List all accessible workspaces."""
handle_cmd_flags(json_output=json_output)
client, config = get_client(require_workspace=False)
try:
workspaces = list(client.workspaces())
items = [{"id": w} for w in workspaces]
print_result(items, columns=["id"], title="Workspaces")
except Exception as e:
_handle_error(e, "workspace", "list")
@app.command("create")
def create_workspace(
workspace_id: str = typer.Argument(help="Workspace ID to create or get"),
metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the workspace"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Create or get a workspace."""
handle_cmd_flags(json_output=json_output)
wid = validate_resource_id(workspace_id, "workspace")
client, config = get_client(require_workspace=False)
ws_client = _with_workspace(client, wid)
parsed_metadata = None
if metadata:
try:
parsed_metadata = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
try:
# Trigger get-or-create via the workspace ensure mechanism
ws_client.get_configuration()
result: dict[str, object] = {"workspace_id": wid}
if parsed_metadata is not None:
ws_client.set_metadata(parsed_metadata)
result["metadata"] = parsed_metadata
print_result(result)
except Exception as e:
_handle_error(e, "workspace", wid)
@app.command()
def inspect(
workspace_id: Optional[str] = typer.Argument(None, help="Workspace ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Inspect a workspace: peers, sessions, config."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
wid = _get_workspace_id(workspace_id)
client, config = get_client(require_workspace=False)
# Override workspace if positional arg given
if workspace_id:
client = _with_workspace(client, workspace_id)
try:
ws_config = client.get_configuration()
ws_metadata = client.get_metadata()
peer_page = client.peers()
session_page = client.sessions()
raw_peers = peer_page._raw_items
raw_sessions = session_page._raw_items
result = {
"workspace_id": wid,
"metadata": ws_metadata,
"configuration": _config_to_dict(ws_config) if ws_config else None,
"peer_count": peer_page.total,
"session_count": session_page.total,
"peers": [
{"id": p.id, "metadata": p.metadata, "created_at": str(p.created_at)}
for p in raw_peers[:20]
],
"sessions": [
{"id": s.id, "is_active": s.is_active, "metadata": s.metadata, "created_at": str(s.created_at)}
for s in raw_sessions[:20]
],
}
print_result(result)
except Exception as e:
_handle_error(e, "workspace", wid)
@app.command()
def delete(
workspace_id: str = typer.Argument(help="Workspace ID to delete"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt (for scripted/agent use)"),
cascade: bool = typer.Option(False, "--cascade", help="Delete all sessions before deleting the workspace"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Delete a workspace. Use --dry-run first to see what will be deleted.
Requires --yes to skip confirmation, or will prompt interactively.
If sessions exist, requires --cascade to delete them first.
"""
handle_cmd_flags(json_output=json_output)
validate_resource_id(workspace_id, "workspace")
# workspace_id is a required positional and we rebuild the client with it
# immediately, so the default-workspace guard isn't needed here.
client, config = get_client(require_workspace=False)
ws_client = _with_workspace(client, workspace_id)
# Verify workspace exists before prompting for confirmation
try:
ws_client.get_metadata()
except Exception as e:
_handle_error(e, "workspace", workspace_id)
return
# Always fetch sessions for dry-run or cascade
raw_sessions = _raw_list(ws_client.sessions()) if (dry_run or cascade) else []
if dry_run:
print_result({
"dry_run": True,
"workspace_id": workspace_id,
"sessions_to_delete": len(raw_sessions),
"session_ids": [s.id for s in raw_sessions],
"warning": "This action cannot be undone.",
})
return
if not yes:
if cascade and raw_sessions:
typer.confirm(
f"Delete workspace '{workspace_id}' and {len(raw_sessions)} session(s)? This cannot be undone.",
abort=True,
)
else:
typer.confirm(f"Delete workspace '{workspace_id}'? This cannot be undone.", abort=True)
try:
deleted_sessions = []
if cascade and raw_sessions:
for s in raw_sessions:
ws_client.session(s.id).delete()
deleted_sessions.append(s.id)
status(f"Deleted session '{s.id}'")
ws_client.delete_workspace(workspace_id)
status(f"Workspace '{workspace_id}' deletion accepted (processing in background)")
result = {"deleted_workspace": workspace_id, "status": "accepted"}
if cascade:
result["deleted_sessions"] = deleted_sessions
print_result(result)
except Exception as e:
_handle_error(e, "workspace", workspace_id)
@app.command()
def search(
query: str = typer.Argument(help="Search query"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
limit: int = typer.Option(10, help="Max results"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Search messages across workspace."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
wid = _get_workspace_id(None)
client, config = get_client()
try:
results = client.search(query, limit=limit)
items = [
{
"id": m.id,
"content": m.content if use_json() else m.content[:200],
"peer_id": m.peer_id,
"session_id": m.session_id,
"created_at": str(m.created_at),
}
for m in results
]
print_result(items, columns=["id", "peer_id", "session_id", "content"], title=f"Search: {query}")
except Exception as e:
_handle_error(e, "workspace", wid)
@app.command("queue-status")
def queue_status(
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
observer: Optional[str] = typer.Option(None, help="Filter by observer peer"),
sender: Optional[str] = typer.Option(None, help="Filter by sender peer"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get queue processing status."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
_get_workspace_id(None)
client, config = get_client()
try:
result = client.queue_status(observer=observer, sender=sender, session=config.session_id or None)
print_result(result.__dict__ if hasattr(result, "__dict__") else result)
except Exception as e:
_handle_error(e, "queue", "status")
def _with_workspace(client, workspace_id: str):
"""Return a new client pointed at a different workspace."""
return Honcho(
base_url=str(client.base_url),
api_key=client._http.api_key if hasattr(client._http, "api_key") else None,
workspace_id=workspace_id,
)
def _config_to_dict(config) -> dict:
"""Convert a config object to a dict, handling nested objects."""
if hasattr(config, "__dict__"):
result = {}
for k, v in config.__dict__.items():
if k.startswith("_"):
continue
result[k] = _config_to_dict(v) if hasattr(v, "__dict__") and not isinstance(v, str) else v
return result
return config
def _handle_error(e: Exception, resource: str, resource_id: str) -> None:
"""Handle SDK exceptions with structured error output.
Dispatches on the SDK's typed exception hierarchy
(``honcho.http.exceptions``) and falls back to its ``status`` field for
any APIError subclass we don't enumerate. Substring matching on the
message is used only as a last-ditch fallback for non-SDK exceptions.
"""
if isinstance(e, NotFoundError):
print_error(
f"{resource.upper()}_NOT_FOUND",
f"{resource.title()} '{resource_id}' not found",
{resource: resource_id},
)
raise typer.Exit(1)
if isinstance(e, AuthenticationError):
print_error("AUTH_ERROR", f"Authentication failed: {e}", {})
raise typer.Exit(3)
if isinstance(e, PermissionDeniedError):
print_error("PERMISSION_ERROR", f"Permission denied: {e}", {})
raise typer.Exit(3)
if isinstance(e, ServerError):
print_error("SERVER_ERROR", f"Server error: {e}", {resource: resource_id})
raise typer.Exit(2)
if isinstance(e, APIError):
# Catch-all for typed API errors we haven't special-cased
# (BadRequest, Conflict, UnprocessableEntity, RateLimit, ...).
print_error(
"API_ERROR",
f"API error ({e.status}): {e}",
{resource: resource_id, "status": e.status},
)
raise typer.Exit(1)
print_error("UNKNOWN_ERROR", str(e), {resource: resource_id})
raise typer.Exit(1)

View File

@ -0,0 +1,112 @@
"""Shared runtime state, client factory, and command-level flag helpers.
Flags --json, -w, -p, -s are documented at **command-level** (the canonical
form demonstrated in the welcome panel, README, and skill files):
honcho workspace list -w granola --json
They also parse at group-level and top-level for flexibility. All three
positions resolve identically and are idempotent command-level is a
no-op if the same flag was already set at an outer level.
"""
from __future__ import annotations
from typing import Optional
import typer
from honcho import Honcho
from honcho_cli.config import CLIConfig, get_client_kwargs
from honcho_cli.output import print_error, set_json_mode
from honcho_cli.validation import validate_resource_id
# Global overrides from flags (commands read these)
_global_overrides: dict[str, str | None] = {
"workspace": None,
"peer": None,
"session": None,
}
def get_resolved_config():
"""Get config with global flag overrides applied.
Overrides flow through ``validate_resource_id`` so that a malformed
``-w``/``-p``/``-s`` value fails fast with a structured error rather than
reaching the API and surfacing as an opaque ``UNKNOWN_ERROR``.
"""
config = CLIConfig.load()
if _global_overrides["workspace"]:
config.workspace_id = validate_resource_id(_global_overrides["workspace"], "workspace")
if _global_overrides["peer"]:
config.peer_id = validate_resource_id(_global_overrides["peer"], "peer")
if _global_overrides["session"]:
config.session_id = validate_resource_id(_global_overrides["session"], "session")
return config
def get_client(*, require_workspace: bool = True):
"""Create a Honcho client from resolved config.
By default, refuses to build a client when no workspace is scoped the
SDK's get-or-create semantics would otherwise silently operate on an empty
workspace. Commands that legitimately run without a workspace (e.g.
``workspace list``) pass ``require_workspace=False``.
"""
config = get_resolved_config()
if require_workspace and not config.workspace_id:
print_error(
"NO_WORKSPACE",
"No workspace scoped. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.",
)
raise typer.Exit(1)
return Honcho(**get_client_kwargs(config)), config
def handle_cmd_flags(
json_output: bool = False,
workspace: str | None = None,
peer: str | None = None,
session: str | None = None,
**_kwargs,
) -> None:
"""Apply command-level flags. Idempotent if already set by group callback."""
if json_output:
set_json_mode(True)
if workspace:
_global_overrides["workspace"] = workspace
if peer:
_global_overrides["peer"] = peer
if session:
_global_overrides["session"] = session
def add_common_options(app: typer.Typer) -> None:
"""Add a callback to a sub-app that accepts --json, -w, -p, -s."""
@app.callback(invoke_without_command=True)
def _callback(
ctx: typer.Context,
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
) -> None:
if json_output:
set_json_mode(True)
if workspace:
_global_overrides["workspace"] = workspace
if peer:
_global_overrides["peer"] = peer
if session:
_global_overrides["session"] = session
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())

View File

@ -0,0 +1,150 @@
"""Configuration management for Honcho CLI.
Config stored at ``~/.honcho/config.json`` with env var overrides.
The CLI owns exactly two top-level keys in that file:
apiKey -- Honcho admin JWT
environmentUrl -- Honcho API URL (full URL, e.g. https://api.honcho.dev)
All other top-level keys (``hosts``, ``sessions``, ``saveMessages``,
``sessionStrategy``, ) are written by sibling Honcho tools and are
preserved untouched on save.
Workspace / peer / session scoping is intentionally *not* persisted here
pass ``-w`` / ``-p`` / ``-s`` flags or set ``HONCHO_WORKSPACE_ID`` /
``HONCHO_PEER_ID`` / ``HONCHO_SESSION_ID`` per command instead.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, fields
from pathlib import Path
CONFIG_DIR = Path.home() / ".honcho"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_BASE_URL = "https://api.honcho.dev"
# Env var mapping for runtime overrides.
#
# Resolution order: flag > env var > config file > default.
ENV_MAP: dict[str, str] = {
"api_key": "HONCHO_API_KEY",
"base_url": "HONCHO_BASE_URL",
"workspace_id": "HONCHO_WORKSPACE_ID",
"peer_id": "HONCHO_PEER_ID",
"session_id": "HONCHO_SESSION_ID",
}
@dataclass
class CLIConfig:
"""CLI configuration with layered resolution: flag > env > file > default.
``workspace_id`` / ``peer_id`` / ``session_id`` exist on this dataclass so
flag/env overrides flow through ``get_client_kwargs()``, but they are
never read from or written to the config file they're per-command.
"""
base_url: str = DEFAULT_BASE_URL
api_key: str = ""
workspace_id: str = ""
peer_id: str = ""
session_id: str = ""
@classmethod
def load(cls) -> CLIConfig:
"""Load config from file, then overlay env vars."""
config = cls()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
data = {}
if isinstance(data, dict):
url = data.get("environmentUrl")
if isinstance(url, str) and url:
config.base_url = url
key = data.get("apiKey")
if isinstance(key, str):
config.api_key = key
for fld_name, env_var in ENV_MAP.items():
val = os.environ.get(env_var)
if val:
setattr(config, fld_name, val)
elif val == "":
# SDK reads these env vars directly and crashes on empty
# strings with a Pydantic ValidationError. Drop them so the
# SDK falls back to kwargs / defaults.
os.environ.pop(env_var, None)
return config
def save(self) -> None:
"""Write ``apiKey`` + ``environmentUrl`` to config.json.
Preserves unrelated top-level keys (``hosts``, ``sessions``,
``saveMessages``, ``sessionStrategy``, ) that other tools write.
"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
data: dict = {}
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
data = loaded
except (json.JSONDecodeError, OSError):
data = {}
data["environmentUrl"] = self.base_url
if self.api_key:
data["apiKey"] = self.api_key
else:
data.pop("apiKey", None)
CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
# API key in plaintext — restrict to the owner on multi-user hosts.
try:
os.chmod(CONFIG_FILE, 0o600)
except OSError:
pass
def redacted(self) -> dict[str, str]:
"""Return config dict with api_key redacted.
Only includes fields that have a value set per-command fields
(workspace_id, peer_id, session_id) are omitted when empty.
"""
d: dict[str, str] = {}
for fld in fields(self):
val = getattr(self, fld.name)
if not val:
continue
if fld.name == "api_key":
# Show ``***<last4>`` only — enough to compare keys without
# leaking the header or body of the JWT.
d[fld.name] = "***" + val[-4:] if len(val) > 4 else "***"
else:
d[fld.name] = val
return d
def get_client_kwargs(config: CLIConfig) -> dict:
"""Build kwargs for Honcho client from config."""
kwargs: dict = {}
if config.base_url:
kwargs["base_url"] = config.base_url
if config.api_key:
kwargs["api_key"] = config.api_key
if config.workspace_id:
kwargs["workspace_id"] = config.workspace_id
return kwargs

View File

@ -0,0 +1,96 @@
"""Honcho CLI — a terminal for Honcho.
Entry point and top-level command group.
"""
from __future__ import annotations
import os
import sys
import typer
from rich.console import Console
from honcho_cli import __version__
from honcho_cli._help import HonchoTyperGroup, print_welcome
from honcho_cli.branding import BANNER
from honcho_cli.output import set_json_mode
app = typer.Typer(
name="honcho",
cls=HonchoTyperGroup,
help="A terminal for Honcho — memory that reasons.",
invoke_without_command=True,
pretty_exceptions_enable=False,
add_completion=False,
)
def _json_requested_early() -> bool:
"""Best-effort JSON detection before Typer parses flags.
version_callback is eager and fires before set_json_mode() runs, so we
can't call use_json() here. Mirror its logic against argv/env/TTY.
"""
return (
"--json" in sys.argv
or os.environ.get("HONCHO_JSON", "").lower() in ("1", "true")
or not sys.stdout.isatty()
)
def version_callback(value: bool) -> None:
if value:
if not _json_requested_early():
print(BANNER)
print(f" honcho-cli {__version__}")
raise typer.Exit()
@app.callback()
def main(
ctx: typer.Context,
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
version: bool = typer.Option(False, "--version", "-V", callback=version_callback, is_eager=True, help="Show version"),
) -> None:
"""Honcho CLI — admin & debugging tool for Honcho workspaces."""
set_json_mode(json_output)
if ctx.invoked_subcommand is None:
print_welcome(Console())
raise typer.Exit()
# Register top-level commands
from honcho_cli.commands.setup import doctor, init
app.command()(init)
app.command()(doctor)
@app.command("help", hidden=True)
def help_cmd(ctx: typer.Context) -> None:
"""Show help message."""
Console().print(ctx.parent.get_help() if ctx.parent else "")
raise typer.Exit()
# Register command groups
from honcho_cli.commands.config_cmd import app as config_app
from honcho_cli.commands.conclusion import app as conclusion_app
from honcho_cli.commands.message import app as message_app
from honcho_cli.commands.peer import app as peer_app
from honcho_cli.commands.session import app as session_app
from honcho_cli.commands.workspace import app as workspace_app
app.add_typer(peer_app, name="peer")
app.add_typer(session_app, name="session")
app.add_typer(message_app, name="message")
app.add_typer(conclusion_app, name="conclusion")
app.add_typer(workspace_app, name="workspace")
app.add_typer(config_app, name="config")
if __name__ == "__main__":
app()

View File

@ -0,0 +1,104 @@
"""Output formatting: JSON, tables, and structured errors.
Detects TTY to auto-switch between human-readable and machine-parseable output.
"""
from __future__ import annotations
import json
import os
import sys
from typing import Any
from rich.console import Console
from rich.table import Table
console = Console(stderr=True)
stdout_console = Console()
def is_tty() -> bool:
"""Check if stdout is a TTY."""
return sys.stdout.isatty()
# Global state for --json flag
_force_json = False
def set_json_mode(enabled: bool) -> None:
global _force_json
_force_json = enabled
def use_json() -> bool:
"""Should we output JSON?"""
return _force_json or os.environ.get("HONCHO_JSON", "").lower() in ("1", "true") or not is_tty()
def print_json(data: Any) -> None:
"""Print a single JSON value to stdout."""
print(json.dumps(data, indent=2, default=str))
def print_table(columns: list[str], rows: list[list[str]], title: str | None = None) -> None:
"""Print a rich table to stdout."""
table = Table(title=title, show_header=True, header_style="bold")
for col in columns:
table.add_column(col)
for row in rows:
table.add_row(*row)
stdout_console.print(table)
def print_result(data: Any, columns: list[str] | None = None, title: str | None = None) -> None:
"""Print data as JSON or table depending on mode.
For lists, uses JSON arrays in JSON mode or tables in TTY mode.
For dicts, uses JSON or key-value display.
"""
if use_json():
print_json(data)
else:
if isinstance(data, list) and columns:
rows = []
for item in data:
row = [str(item.get(col, "")) if isinstance(item, dict) else str(item) for col in columns]
rows.append(row)
print_table(columns, rows, title=title)
elif isinstance(data, dict):
table = Table(show_header=False)
table.add_column("Field", style="bold")
table.add_column("Value")
for k, v in data.items():
val = json.dumps(v, default=str) if isinstance(v, (dict, list)) else str(v)
table.add_row(k, val)
stdout_console.print(table)
else:
stdout_console.print(data)
def print_error(code: str, message: str, details: dict | None = None) -> None:
"""Print structured error."""
err = {
"error": {
"code": code,
"message": message,
}
}
if details:
err["error"]["details"] = details
if use_json():
print(json.dumps(err, default=str), file=sys.stderr)
else:
console.print(f"[red]Error[/red] ({code}): {message}")
if details:
for k, v in details.items():
console.print(f" {k}: {v}")
def status(msg: str) -> None:
"""Print a status message to stderr."""
console.print(f"[dim]{msg}[/dim]")

View File

@ -0,0 +1,44 @@
"""Input hardening: validate resource IDs and workspace names.
Agents hallucinate bad IDs. Catch them early with clear errors.
"""
from __future__ import annotations
import re
from honcho_cli.output import print_error
UNSAFE_CHARS = re.compile(r'[?#%\x00-\x1f\x7f/\\]')
def validate_resource_id(value: str, resource_type: str = "resource") -> str:
"""Validate a resource ID. Returns the value if valid, raises SystemExit on invalid."""
if not value:
_fail(
"EMPTY_ID",
f"Empty {resource_type} ID provided",
{resource_type: ""},
)
if UNSAFE_CHARS.search(value):
_fail(
"INVALID_ID",
f"Invalid {resource_type} ID: contains unsafe characters (?, #, %, control chars, path separators)",
{resource_type: value},
)
if ".." in value:
_fail(
"INVALID_ID",
f"Invalid {resource_type} ID: contains path traversal",
{resource_type: value},
)
return value
def _fail(code: str, message: str, details: dict) -> None:
"""Print structured error and exit."""
print_error(code, message, details)
raise SystemExit(1)

View File

View File

@ -0,0 +1,195 @@
"""Command-level tests: init flow, destructive confirms, JSON output contract, exit codes.
Uses Typer's CliRunner against the real `app`. stdout is not a TTY under
CliRunner, so `use_json()` returns True and the CLI emits JSON
which is exactly what scripts and agents consume.
"""
from __future__ import annotations
import json
import os
from unittest.mock import MagicMock, patch
import pytest
from typer.testing import CliRunner
from honcho_cli.main import app
@pytest.fixture
def cfg(tmp_path, monkeypatch):
"""Isolated config file + clean HONCHO_* env."""
f = tmp_path / "config.json"
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
monkeypatch.setattr("honcho_cli.commands.setup.CONFIG_FILE", f)
for k in [k for k in os.environ if k.startswith("HONCHO_")]:
monkeypatch.delenv(k)
return f
@pytest.fixture
def runner():
return CliRunner()
# --------------------------------------------------------------------------- #
# 1. `honcho init` end-to-end
class TestInit:
def test_first_run_writes_exact_shape(self, cfg, runner):
"""First run with --api-key + --base-url writes apiKey + environmentUrl only."""
with patch("honcho_cli.commands.setup._test_connection", return_value=(True, "OK")):
result = runner.invoke(
app,
["init", "--api-key", "test-key-123", "--base-url", "http://localhost:8000"],
)
assert result.exit_code == 0, result.stderr
assert json.loads(cfg.read_text()) == {
"environmentUrl": "http://localhost:8000",
"apiKey": "test-key-123",
}
def test_preserves_foreign_keys(self, cfg, runner):
"""Second run must not clobber sibling-tool keys (`hosts`, `sessions`, ...)."""
cfg.write_text(json.dumps({
"apiKey": "old",
"environmentUrl": "http://old.example",
"hosts": {"claude_code": {"peerName": "user"}},
"sessions": {"/Users/user": "home-chat"},
"sessionStrategy": "chat-instance",
}))
with patch("honcho_cli.commands.setup._test_connection", return_value=(True, "OK")):
result = runner.invoke(
app,
["init", "--api-key", "new-key", "--base-url", "https://api.honcho.dev"],
)
assert result.exit_code == 0, result.stderr
on_disk = json.loads(cfg.read_text())
assert on_disk["apiKey"] == "new-key"
assert on_disk["environmentUrl"] == "https://api.honcho.dev"
assert on_disk["hosts"] == {"claude_code": {"peerName": "user"}}
assert on_disk["sessions"] == {"/Users/user": "home-chat"}
assert on_disk["sessionStrategy"] == "chat-instance"
# --------------------------------------------------------------------------- #
# 2. Destructive-confirm guards
class TestDestructiveConfirm:
def test_workspace_delete_aborts_on_no(self, cfg, runner):
"""`workspace delete` without --yes: 'n' at prompt → no API call, non-zero exit."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
fake = MagicMock()
fake.sessions.return_value = MagicMock(has_next_page=lambda: False, _raw_items=[])
with patch("honcho_cli.commands.workspace.get_client", return_value=(fake, MagicMock())), \
patch("honcho_cli.commands.workspace._with_workspace", return_value=fake):
result = runner.invoke(app, ["workspace", "delete", "ws1"], input="n\n")
assert result.exit_code != 0
fake.delete_workspace.assert_not_called()
def test_session_delete_aborts_on_no(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
session = MagicMock()
client = MagicMock()
client.session.return_value = session
config = MagicMock(session_id="s1", workspace_id="ws1")
with patch("honcho_cli.commands.workspace.get_client", return_value=(client, config)):
result = runner.invoke(app, ["session", "delete", "s1"], input="n\n")
assert result.exit_code != 0
session.delete.assert_not_called()
# --------------------------------------------------------------------------- #
# 3. JSON output contract — scripts pipe these
class TestJsonContract:
def test_workspace_list_json_array_shape(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
client = MagicMock()
client.workspaces.return_value = ["ws-a", "ws-b"]
with patch("honcho_cli.commands.workspace.get_client", return_value=(client, MagicMock())):
result = runner.invoke(app, ["workspace", "list"])
assert result.exit_code == 0, result.stderr
assert json.loads(result.stdout) == [{"id": "ws-a"}, {"id": "ws-b"}]
def test_workspace_search_preserves_full_content_in_json_mode(self, cfg, runner):
cfg.write_text(json.dumps({
"apiKey": "k",
"environmentUrl": "http://localhost:8000",
"workspace_id": "ws1",
}))
message = MagicMock(
id="msg1",
content="x" * 250,
peer_id="peer1",
session_id="sess1",
created_at="2026-01-01T00:00:00Z",
)
client = MagicMock()
client.search.return_value = [message]
config = MagicMock(workspace_id="ws1")
with patch("honcho_cli.commands.workspace.get_client", return_value=(client, config)):
result = runner.invoke(app, ["workspace", "search", "topic", "-w", "ws1"])
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
assert payload == [{
"id": "msg1",
"content": "x" * 250,
"peer_id": "peer1",
"session_id": "sess1",
"created_at": "2026-01-01T00:00:00Z",
}]
def test_message_get_returns_single_json_object(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
msg = MagicMock(
id="msg1",
peer_id="peer1",
content="hello",
token_count=7,
metadata={"kind": "demo"},
created_at="2026-01-01T00:00:00Z",
)
session = MagicMock()
session.get_message.return_value = msg
client = MagicMock()
client.session.return_value = session
config = MagicMock(session_id="sess1", workspace_id="ws1")
with patch("honcho_cli.commands.message.get_client", return_value=(client, config)):
result = runner.invoke(app, ["message", "get", "msg1", "-s", "sess1", "-w", "ws1"])
assert result.exit_code == 0, result.stderr
assert json.loads(result.stdout) == {
"id": "msg1",
"peer_id": "peer1",
"content": "hello",
"token_count": 7,
"metadata": {"kind": "demo"},
"created_at": "2026-01-01T00:00:00Z",
}
# --------------------------------------------------------------------------- #
# 4. Exit codes on error
class TestExitCodes:
def test_no_workspace_scoped_exits_nonzero_with_code(self, cfg, runner):
"""Running a workspace-scoped command with no workspace → NO_WORKSPACE on stderr, exit 1."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
result = runner.invoke(app, ["peer", "list"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "NO_WORKSPACE"
def test_not_found_exits_nonzero_with_code(self, cfg, runner):
"""SDK NotFoundError → structured error, exit 1."""
from honcho import NotFoundError
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
client = MagicMock()
client.peer.return_value.get_card.side_effect = NotFoundError("not found")
config = MagicMock(peer_id="missing", session_id="", workspace_id="ws1")
with patch("honcho_cli.commands.peer.get_client", return_value=(client, config)):
result = runner.invoke(app, ["peer", "inspect", "missing", "-w", "ws1"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "PEER_NOT_FOUND"

View File

@ -0,0 +1,113 @@
"""Tests for config management."""
import json
import os
import pytest
from honcho_cli.config import CLIConfig
@pytest.fixture
def cfg_path(tmp_path, monkeypatch):
"""Redirect CONFIG_FILE to tmp_path and clear HONCHO_* env vars."""
f = tmp_path / "config.json"
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
for key in [k for k in os.environ if k.startswith("HONCHO_")]:
monkeypatch.delenv(key)
return f
class TestLoad:
def test_defaults_when_no_file(self, cfg_path):
loaded = CLIConfig.load()
assert loaded.base_url == "https://api.honcho.dev"
assert loaded.api_key == ""
assert loaded.workspace_id == ""
def test_malformed_file_uses_defaults(self, cfg_path):
cfg_path.write_text("not-json{{{")
assert CLIConfig.load().api_key == ""
def test_reads_environment_url(self, cfg_path):
cfg_path.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
loaded = CLIConfig.load()
assert loaded.base_url == "http://localhost:8000"
assert loaded.api_key == "k"
def test_api_key_and_base_url_from_env(self, cfg_path, monkeypatch):
"""HONCHO_API_KEY and HONCHO_BASE_URL override config file at runtime."""
cfg_path.write_text(json.dumps({"environmentUrl": "https://api.honcho.dev"}))
monkeypatch.setenv("HONCHO_API_KEY", "env-key")
monkeypatch.setenv("HONCHO_BASE_URL", "http://localhost:8000")
loaded = CLIConfig.load()
assert loaded.api_key == "env-key"
assert loaded.base_url == "http://localhost:8000"
class TestSave:
def test_writes_only_cli_owned_keys(self, cfg_path):
"""apiKey + environmentUrl are written; workspace/peer/session are not."""
CLIConfig(
base_url="http://localhost:8000",
api_key="test-key-123",
workspace_id="my-ws", # must NOT be persisted
peer_id="user",
session_id="s1",
).save()
assert json.loads(cfg_path.read_text()) == {
"environmentUrl": "http://localhost:8000",
"apiKey": "test-key-123",
}
def test_preserves_foreign_keys(self, cfg_path):
"""Other tools' top-level keys (hosts, sessions, ...) are untouched."""
seed = {
"apiKey": "old-key",
"environmentUrl": "https://api.honcho.dev",
"saveMessages": True,
"sessions": {"/Users/user": "home-chat"},
"hosts": {"claude_code": {"peerName": "user", "workspace": "agents"}},
"sessionStrategy": "chat-instance",
}
cfg_path.write_text(json.dumps(seed))
cfg = CLIConfig.load()
cfg.api_key = "new-key"
cfg.save()
on_disk = json.loads(cfg_path.read_text())
assert on_disk["apiKey"] == "new-key"
assert on_disk["environmentUrl"] == "https://api.honcho.dev"
for k in ("saveMessages", "sessions", "hosts", "sessionStrategy"):
assert on_disk[k] == seed[k]
@pytest.mark.parametrize(
"api_key, expected",
[
# Long JWT: only last 4 chars visible, masked prefix.
("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abcdef", "***cdef"),
# Short value > 4 chars: still only last 4.
("abcdef", "***cdef"),
# 4 or fewer chars: fully masked — don't leak the whole key.
("abcd", "***"),
("x", "***"),
],
)
def test_api_key_redaction_shows_last4_only(api_key, expected):
"""Redacted api_key must show ``***<last4>`` at most, never the header/body."""
assert CLIConfig(api_key=api_key).redacted()["api_key"] == expected
def test_api_key_redaction_empty_omitted():
"""Empty api_key is omitted from redacted output entirely."""
assert "api_key" not in CLIConfig(api_key="").redacted()
def test_save_sets_600_permissions(cfg_path):
"""Config with plaintext API key must be owner-readable only on POSIX."""
import stat
CLIConfig(base_url="http://localhost:8000", api_key="sekret").save()
mode = stat.S_IMODE(os.stat(cfg_path).st_mode)
# chmod(0o600) → rw- --- ---
assert mode == 0o600, f"expected 0o600, got {oct(mode)}"

View File

@ -0,0 +1,57 @@
"""Tests for resource-ID validation.
Agents hallucinate IDs; ``validate_resource_id`` is the defense in depth
between that and the API. These tests pin the accept/reject rules.
"""
from __future__ import annotations
import pytest
from honcho_cli.validation import validate_resource_id
class TestAccepts:
@pytest.mark.parametrize(
"value",
[
"eri",
"my-peer-01",
"workspace_name",
"UPPER",
"with.dots",
"123abc",
"a",
"long-id-with-many-parts_v2",
],
)
def test_safe_id_round_trips(self, value):
assert validate_resource_id(value, "peer") == value
class TestRejects:
def test_empty_string(self):
with pytest.raises(SystemExit):
validate_resource_id("", "peer")
@pytest.mark.parametrize(
"value",
[
"bad/slash",
"bad\\backslash",
"bad?query",
"bad#hash",
"bad%encoded",
"with\x00null",
"with\x1fctrl",
"with\x7fdel",
],
)
def test_unsafe_chars(self, value):
with pytest.raises(SystemExit):
validate_resource_id(value, "peer")
@pytest.mark.parametrize("value", ["..", "../etc", "foo/..", "a..b"])
def test_path_traversal(self, value):
with pytest.raises(SystemExit):
validate_resource_id(value, "peer")

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