Merge branch 'main' into eri/dev-1430
This commit is contained in:
commit
9e0f24f387
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
197
.env.template
197
.env.template
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
|
||||
|
|
|
|||
87
CHANGELOG.md
87
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -8,10 +8,10 @@
|
|||
|
||||
---
|
||||
|
||||

|
||||

|
||||
[](https://pypi.org/project/honcho-ai/)
|
||||
[](https://npmjs.org/package/@honcho-ai/sdk)
|
||||
[](https://discord.gg/plasticlabs)
|
||||
[](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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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+ |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}`))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
|
@ -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 = ["."]
|
||||
|
|
@ -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", "")
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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."
|
||||
|
|
@ -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}'."
|
||||
|
|
@ -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" },
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""Honcho CLI — a terminal for Honcho."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}]"
|
||||
|
|
@ -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)
|
||||
|
|
@ -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())
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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]")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)}"
|
||||
|
|
@ -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
Loading…
Reference in New Issue