ts SDK fix: adding strict checking (#421)

* fix: adding strict checking and updating readme

* chore: changelog and version

* fix: Add strict validation to all Python and TypeScript classes

* fix: Address Code Rabit Comments

* fix: duplicate searchQuery param in typescript session.context()

* feat: add created_at, is_active fields, and get_message method

* feat: Add pagintion params to sdk

* fix: Remove lazy initalization behavior from sdks

* fix: Address File Upload Validation, add compatibility shims, address review comments

* chore: Docs updates

* fix: Convert session config from API format in Peer.sessions()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Pass all args to Session constructor in Peer.sessions()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Preserve createdAt in Peer.refresh(), pass all data in session.peers()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: changelog for ts

* fix: Review Comments

* fix: Add createAt and to peers call

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ajspig 2026-03-30 10:57:12 -04:00 committed by GitHub
parent e9ee9d9ddb
commit a5423b52e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 2312 additions and 534 deletions

View File

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

View File

@ -524,7 +524,30 @@ 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.0 (Current)">
### 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
@ -638,7 +661,54 @@ 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.0 (Current)">
### 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

View File

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

View File

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

View File

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

View File

@ -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,
@ -811,12 +833,12 @@ The SessionContext object has the following structure:
| `tokens` | `int` | Maximum tokens to include |
| `peer_target` | `str` | Peer ID to get representation for |
| `peer_perspective` | `str` | Peer ID for perspective (requires peer_target) |
| `search_query` | `str` or `Message` | Query string or Message object for semantic search |
| `limit_to_session` | `bool` | Limit representation to session only |
| `search_top_k` | `int` | Number of semantic search results (1-100) |
| `search_max_distance` | `float` | Max semantic distance (0.0-1.0) |
| `include_most_frequent` | `bool` | Include most frequent conclusions |
| `max_conclusions` | `int` | Max conclusions to include (1-100) |
| `representationOptions.searchQuery` | `str` or `Message` | Query string or Message object for semantic search |
| `representationOptions.searchTopK` | `int` | Number of semantic search results (1-100) |
| `representationOptions.searchMaxDistance` | `float` | Max semantic distance (0.0-1.0) |
| `representationOptions.includeMostFrequent` | `bool` | Include most frequent conclusions |
| `representationOptions.maxConclusions` | `int` | Max conclusions to include (1-100) |
## Advanced Usage
@ -988,23 +1010,46 @@ const actionItems = await session.messages({
### Pagination
All list methods support `page`, `size`, and `reverse` parameters:
<CodeGroup>
```python Python
# Iterate through all sessions
# Default pagination (page 1, size 50)
for session in honcho.sessions():
print(f"Session: {session.id}")
# Iterate through session messages
for message in session.messages():
print(f" {message.peer_id}: {message.content}")
# Custom page size
for message in session.messages(size=100):
print(f" {message.peer_id}: {message.content}")
# Start at a specific page
page3 = session.messages(page=3, size=25)
# Reverse ordering
recent_first = session.messages(reverse=True)
# Combine with filters
filtered = session.messages(filters={"peer_id": "alice"}, size=10)
```
```typescript TypeScript
// Get paginated results
// Default pagination (page 1, size 50)
const peersPage = await honcho.peers();
// Iterate through all items
for await (const peer of peersPage) {
// Custom page size and filtering
const filtered = await honcho.peers({
filters: { metadata: { role: "user" } },
size: 25
});
// Start at a specific page
const page3 = await session.messages({ page: 3, size: 25 });
// Reverse ordering
const recent = await session.messages({ reverse: true });
// Iterate through all items (auto-paginates)
for await (const peer of await honcho.peers()) {
console.log(`Peer: ${peer.id}`);
}
@ -1048,8 +1093,8 @@ const supportAgent = await honcho.peer(`agent-${agentId}`);
<CodeGroup>
```python Python
# Lazy creation - no API calls until needed
peers = [honcho.peer(f"user-{i}") for i in range(100)] # Fast
# Create peers (each makes a get-or-create call)
peers = [honcho.peer(f"user-{i}") for i in range(100)]
# Batch operations when possible
session.add_messages([peer.message(f"Message {i}") for i, peer in enumerate(peers)])
@ -1059,7 +1104,7 @@ context = session.context(tokens=1500) # Limit context size
```
```typescript TypeScript
// Lazy creation - no API calls until needed
// Create peers (each makes a get-or-create call)
const peers = await Promise.all(
Array.from({ length: 100 }, (_, i) => honcho.peer(`user-${i}`))
);

View File

@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [2.1.0] - 2026-03-25
### 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: `peers()`, `sessions()`, `messages()`, and `conclusions.list()`
### Changed
- **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. All Peer/Session objects now have `created_at` populated immediately.
- Response configuration models (`WorkspaceConfigurationResponse`, `SessionConfigurationResponse`) 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 `pyproject.toml` fallback in source checkouts, so it stays aligned with SDK releases.
## [2.0.2] - 2026-03-10
### Changed
- All input models now reject unknown fields via `extra="forbid"` Pydantic validation. Previously, misspelled or extraneous fields were silently ignored. Now a `ValidationError` is raised with the unrecognized field name.
## [2.0.1] - 2026-02-09
### Added

View File

@ -1,6 +1,6 @@
[project]
name = "honcho-ai"
version = "2.0.1"
version = "2.1.0"
description = "Official DX Optimized Python SDK for Honcho"
dynamic = ["readme"]
license = "Apache-2.0"

View File

@ -37,6 +37,10 @@ Usage:
print(p.id)
"""
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
import re
from .aio import ConclusionScopeAio, HonchoAio, PeerAio, SessionAio
from .api_types import MessageCreateParams
from .base import PeerBase, SessionBase
@ -66,7 +70,23 @@ from .types import (
DialecticStreamResponse,
)
__version__ = "2.0.1"
def _detect_version() -> str:
try:
return version("honcho-ai")
except PackageNotFoundError:
try:
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
pyproject_text = pyproject_path.read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', pyproject_text, re.MULTILINE)
if match:
return match.group(1)
except OSError:
pass
return "0.0.0"
__version__ = _detect_version()
__author__ = "Plastic Labs"
__email__ = "hello@plasticlabs.ai"

View File

@ -167,42 +167,52 @@ class HonchoAio(AsyncMetadataConfigMixin):
configuration: Optional configuration to set for this peer.
Returns:
A Peer object
A Peer object with cached values from the API response.
"""
if configuration is not None or metadata is not None:
await self._honcho._ensure_workspace_async()
body: dict[str, Any] = {"id": id}
if metadata is not None:
body["metadata"] = metadata
if configuration is not None:
body["configuration"] = configuration.model_dump(exclude_none=True)
await self._honcho._ensure_workspace_async()
body: dict[str, Any] = {"id": id}
if metadata is not None:
body["metadata"] = metadata
if configuration is not None:
body["configuration"] = configuration.model_dump(exclude_none=True)
data = await self._honcho._async_http_client.post(
routes.peers(self._honcho.workspace_id), body=body
)
peer_data = PeerResponse.model_validate(data)
return Peer(
id,
self._honcho,
metadata=peer_data.metadata,
configuration=peer_data.configuration,
)
return Peer(id, self._honcho, metadata=metadata, configuration=configuration)
data = await self._honcho._async_http_client.post(
routes.peers(self._honcho.workspace_id), body=body
)
peer_data = PeerResponse.model_validate(data)
return Peer(
id,
self._honcho,
metadata=peer_data.metadata,
configuration=peer_data.configuration,
created_at=peer_data.created_at,
)
async def peers(
self, filters: dict[str, object] | None = None
self,
filters: dict[str, object] | None = None,
*,
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> AsyncPage[PeerResponse, Peer]:
"""
Get all peers in the current workspace asynchronously.
Returns:
An AsyncPage of Peer objects
Args:
filters: Optional filter criteria.
page: Page number (1-indexed). Default: 1.
size: Number of items per page. Default: 50.
reverse: If True, reverses the default ordering. Default: False.
"""
await self._honcho._ensure_workspace_async()
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
query["reverse"] = "true"
data = await self._honcho._async_http_client.post(
routes.peers_list(self._honcho.workspace_id),
body={"filters": filters} if filters else None,
query=query,
)
def transform(peer: PeerResponse) -> Peer:
@ -211,13 +221,17 @@ class HonchoAio(AsyncMetadataConfigMixin):
self._honcho,
metadata=peer.metadata,
configuration=peer.configuration,
created_at=peer.created_at,
)
async def fetch_next(page: int) -> AsyncPage[PeerResponse, Peer]:
async def fetch_next(next_page: int) -> AsyncPage[PeerResponse, Peer]:
next_query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
next_query["reverse"] = "true"
next_data = await self._honcho._async_http_client.post(
routes.peers_list(self._honcho.workspace_id),
body={"filters": filters} if filters else None,
query={"page": page},
query=next_query,
)
return AsyncPage(next_data, PeerResponse, transform, fetch_next)
@ -239,42 +253,55 @@ class HonchoAio(AsyncMetadataConfigMixin):
configuration: Optional configuration to set for this session.
Returns:
A Session object
A Session object with cached values from the API response.
"""
if configuration is not None or metadata is not None:
await self._honcho._ensure_workspace_async()
body: dict[str, Any] = {"id": id}
if metadata is not None:
body["metadata"] = metadata
if configuration is not None:
body["configuration"] = configuration.model_dump(exclude_none=True)
await self._honcho._ensure_workspace_async()
body: dict[str, Any] = {"id": id}
if metadata is not None:
body["metadata"] = metadata
if configuration is not None:
body["configuration"] = configuration.model_dump(exclude_none=True)
data = await self._honcho._async_http_client.post(
routes.sessions(self._honcho.workspace_id), body=body
)
session_data = SessionResponse.model_validate(data)
return Session(
id,
self._honcho,
metadata=session_data.metadata,
configuration=session_data.configuration,
)
return Session(id, self._honcho, metadata=metadata, configuration=configuration)
data = await self._honcho._async_http_client.post(
routes.sessions(self._honcho.workspace_id), body=body
)
session_data = SessionResponse.model_validate(data)
return Session(
id,
self._honcho,
metadata=session_data.metadata,
configuration=SessionConfiguration.model_validate(
session_data.configuration.model_dump()
),
created_at=session_data.created_at,
is_active=session_data.is_active,
)
async def sessions(
self, filters: dict[str, object] | None = None
self,
filters: dict[str, object] | None = None,
*,
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> AsyncPage[SessionResponse, Session]:
"""
Get all sessions in the current workspace asynchronously.
Returns:
An AsyncPage of Session objects
Args:
filters: Optional filter criteria.
page: Page number (1-indexed). Default: 1.
size: Number of items per page. Default: 50.
reverse: If True, reverses the default ordering. Default: False.
"""
await self._honcho._ensure_workspace_async()
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
query["reverse"] = "true"
data = await self._honcho._async_http_client.post(
routes.sessions_list(self._honcho.workspace_id),
body={"filters": filters} if filters else None,
query=query,
)
def transform(session: SessionResponse) -> Session:
@ -283,13 +310,18 @@ class HonchoAio(AsyncMetadataConfigMixin):
self._honcho,
metadata=session.metadata,
configuration=session.configuration,
created_at=session.created_at,
is_active=session.is_active,
)
async def fetch_next(page: int) -> AsyncPage[SessionResponse, Session]:
async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]:
next_query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
next_query["reverse"] = "true"
next_data = await self._honcho._async_http_client.post(
routes.sessions_list(self._honcho.workspace_id),
body={"filters": filters} if filters else None,
query={"page": page},
query=next_query,
)
return AsyncPage(next_data, SessionResponse, transform, fetch_next)
@ -457,6 +489,21 @@ class PeerAio(AsyncMetadataConfigMixin):
return {}
return self._peer._configuration.model_dump(exclude_none=True)
def _apply_peer_response(self, peer: PeerResponse) -> None:
self._peer._metadata = peer.metadata or {}
self._peer._configuration = peer.configuration
self._peer._created_at = peer.created_at
async def get_metadata(self) -> dict[str, object]:
"""Get metadata from the server asynchronously."""
await self._peer._honcho._ensure_workspace_async()
data = await self._get_async_http_client().post(
self._get_fetch_route(), body=self._get_fetch_body()
)
peer = PeerResponse.model_validate(data)
self._apply_peer_response(peer)
return self._peer._metadata or {}
async def get_configuration(self) -> PeerConfig: # pyright: ignore[reportIncompatibleMethodOverride]
"""Get configuration from the server asynchronously."""
await self._peer._honcho._ensure_workspace_async()
@ -464,9 +511,17 @@ class PeerAio(AsyncMetadataConfigMixin):
self._get_fetch_route(), body=self._get_fetch_body()
)
peer = PeerResponse.model_validate(data)
self._peer._metadata = peer.metadata or {}
self._peer._configuration = peer.configuration
return self._peer._configuration
self._apply_peer_response(peer)
return self._peer._configuration or PeerConfig()
async def refresh(self) -> None:
"""Refresh cached metadata, configuration, and created_at asynchronously."""
await self._peer._honcho._ensure_workspace_async()
data = await self._get_async_http_client().post(
self._get_fetch_route(), body=self._get_fetch_body()
)
peer = PeerResponse.model_validate(data)
self._apply_peer_response(peer)
async def set_configuration(self, configuration: PeerConfig) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
"""Set configuration on the server asynchronously."""
@ -544,23 +599,44 @@ class PeerAio(AsyncMetadataConfigMixin):
return AsyncDialecticStreamResponse(stream_response())
async def sessions(
self, filters: dict[str, object] | None = None
self,
filters: dict[str, object] | None = None,
*,
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> AsyncPage[SessionResponse, Session]:
"""Get all sessions this peer is a member of asynchronously."""
await self._peer._honcho._ensure_workspace_async()
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
query["reverse"] = "true"
data = await self._peer._honcho._async_http_client.post(
routes.peer_sessions_list(self._peer.workspace_id, self._peer.id),
body={"filters": filters} if filters else None,
query=query,
)
def transform(session: SessionResponse) -> Session:
return Session(session.id, self._peer._honcho)
return Session(
session.id,
self._peer._honcho,
metadata=session.metadata,
configuration=SessionConfiguration.model_validate(
session.configuration.model_dump()
),
created_at=session.created_at,
is_active=session.is_active,
)
async def fetch_next(page: int) -> AsyncPage[SessionResponse, Session]:
async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]:
next_query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
next_query["reverse"] = "true"
next_data = await self._peer._honcho._async_http_client.post(
routes.peer_sessions_list(self._peer.workspace_id, self._peer.id),
body={"filters": filters} if filters else None,
query={"page": page},
query=next_query,
)
return AsyncPage(next_data, SessionResponse, transform, fetch_next)
@ -777,6 +853,24 @@ class SessionAio(AsyncMetadataConfigMixin):
return {}
return self._session._configuration.model_dump(exclude_none=True)
def _apply_session_response(self, session: SessionResponse) -> None:
self._session._metadata = session.metadata or {}
self._session._configuration = SessionConfiguration.model_validate(
session.configuration.model_dump()
)
self._session._created_at = session.created_at
self._session._is_active = session.is_active
async def get_metadata(self) -> dict[str, object]:
"""Get metadata from the server asynchronously."""
await self._session._honcho._ensure_workspace_async()
data = await self._get_async_http_client().post(
self._get_fetch_route(), body=self._get_fetch_body()
)
session = SessionResponse.model_validate(data)
self._apply_session_response(session)
return self._get_metadata()
async def get_configuration(self) -> SessionConfiguration: # pyright: ignore[reportIncompatibleMethodOverride]
"""Get configuration from the server asynchronously."""
await self._session._honcho._ensure_workspace_async()
@ -784,9 +878,17 @@ class SessionAio(AsyncMetadataConfigMixin):
self._get_fetch_route(), body=self._get_fetch_body()
)
session = SessionResponse.model_validate(data)
self._session._metadata = session.metadata or {}
self._session._configuration = session.configuration
return self._session._configuration
self._apply_session_response(session)
return self._session._configuration or SessionConfiguration()
async def refresh(self) -> None:
"""Refresh cached metadata, configuration, and session status asynchronously."""
await self._session._honcho._ensure_workspace_async()
data = await self._get_async_http_client().post(
self._get_fetch_route(), body=self._get_fetch_body()
)
session = SessionResponse.model_validate(data)
self._apply_session_response(session)
async def set_configuration(self, configuration: SessionConfiguration) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
"""Set configuration on the server asynchronously."""
@ -921,22 +1023,32 @@ class SessionAio(AsyncMetadataConfigMixin):
self,
*,
filters: dict[str, object] | None = None,
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> AsyncPage[MessageResponse, Message]:
"""Get messages from this session asynchronously."""
await self._session._honcho._ensure_workspace_async()
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
query["reverse"] = "true"
data = await self._session._honcho._async_http_client.post(
routes.messages_list(self._session.workspace_id, self._session.id),
body={"filters": filters} if filters else None,
query=query,
)
def transform(response: MessageResponse) -> Message:
return Message.from_api_response(response)
async def fetch_next(page: int) -> AsyncPage[MessageResponse, Message]:
async def fetch_next(next_page: int) -> AsyncPage[MessageResponse, Message]:
next_query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
next_query["reverse"] = "true"
next_data = await self._session._honcho._async_http_client.post(
routes.messages_list(self._session.workspace_id, self._session.id),
body={"filters": filters} if filters else None,
query={"page": page},
query=next_query,
)
return AsyncPage(next_data, MessageResponse, transform, fetch_next)
@ -966,6 +1078,8 @@ class SessionAio(AsyncMetadataConfigMixin):
self._session._honcho,
metadata=cloned.metadata,
configuration=cloned.configuration,
created_at=cloned.created_at,
is_active=cloned.is_active,
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
@ -1248,6 +1362,21 @@ class SessionAio(AsyncMetadataConfigMixin):
)
return QueueStatusResponse.model_validate(data)
async def get_message(self, message_id: str) -> Message:
"""Get a single message by ID from this session asynchronously.
Args:
message_id: The ID of the message to retrieve
Returns:
The Message object
"""
await self._session._honcho._ensure_workspace_async()
data = await self._session._honcho._async_http_client.get(
routes.message(self._session.workspace_id, self._session.id, message_id)
)
return Message.from_api_response(MessageResponse.model_validate(data))
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def update_message(
self,
@ -1288,6 +1417,8 @@ class ConclusionScopeAio:
page: int = 1,
size: int = 50,
session: str | SessionBase | None = None,
*,
reverse: bool = False,
) -> AsyncPage[ConclusionResponse, Conclusion]:
"""List conclusions in this scope asynchronously."""
await self._scope._honcho._ensure_workspace_async()
@ -1299,22 +1430,28 @@ class ConclusionScopeAio:
if resolved_session_id:
filters["session_id"] = resolved_session_id
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
query["reverse"] = "true"
data = await self._scope._honcho._async_http_client.post(
routes.conclusions_list(self._scope.workspace_id),
body={"filters": filters},
query={"page": page, "size": size},
query=query,
)
def transform(response: ConclusionResponse) -> Conclusion:
return Conclusion.from_api_response(response)
async def fetch_next(
page: int,
next_page: int,
) -> AsyncPage[ConclusionResponse, Conclusion]:
next_query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
next_query["reverse"] = "true"
next_data = await self._scope._honcho._async_http_client.post(
routes.conclusions_list(self._scope.workspace_id),
body={"filters": filters},
query={"page": page, "size": size},
query=next_query,
)
return AsyncPage(next_data, ConclusionResponse, transform, fetch_next)

View File

@ -18,6 +18,8 @@ from pydantic import BaseModel, ConfigDict, Field
class ReasoningConfiguration(BaseModel):
"""Configuration for reasoning functionality."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
enabled: bool | None = None
custom_instructions: str | None = None
@ -25,6 +27,8 @@ class ReasoningConfiguration(BaseModel):
class PeerCardConfiguration(BaseModel):
"""Configuration for peer card functionality."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
use: bool | None = None
create: bool | None = None
@ -32,6 +36,8 @@ class PeerCardConfiguration(BaseModel):
class SummaryConfiguration(BaseModel):
"""Configuration for summary functionality."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
enabled: bool | None = None
messages_per_short_summary: int | None = None
messages_per_long_summary: int | None = None
@ -40,13 +46,15 @@ class SummaryConfiguration(BaseModel):
class DreamConfiguration(BaseModel):
"""Configuration for dream functionality."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
enabled: bool | None = None
class WorkspaceConfiguration(BaseModel):
"""Workspace-level configuration options."""
model_config = ConfigDict(extra="allow") # pyright: ignore[reportUnannotatedClassAttribute]
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
reasoning: ReasoningConfiguration | None = None
peer_card: PeerCardConfiguration | None = None
@ -60,9 +68,23 @@ class SessionConfiguration(WorkspaceConfiguration):
pass
class WorkspaceConfigurationResponse(WorkspaceConfiguration):
"""Workspace configuration for response parsing — tolerates unknown fields from newer servers."""
model_config = ConfigDict(extra="ignore") # pyright: ignore[reportUnannotatedClassAttribute]
class SessionConfigurationResponse(SessionConfiguration):
"""Session configuration for response parsing — tolerates unknown fields from newer servers."""
model_config = ConfigDict(extra="ignore") # pyright: ignore[reportUnannotatedClassAttribute]
class MessageConfiguration(BaseModel):
"""Message-level configuration options."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
reasoning: ReasoningConfiguration | None = None
@ -74,6 +96,8 @@ class MessageConfiguration(BaseModel):
class PeerConfig(BaseModel):
"""Configuration for peer-level settings."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
observe_me: bool | None = None
"""Whether Honcho will use reasoning to form a representation of this peer."""
@ -81,6 +105,8 @@ class PeerConfig(BaseModel):
class SessionPeerConfig(BaseModel):
"""Configuration for a peer within a session."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
observe_others: bool | None = Field(
None,
description="Whether this peer should form a session-level theory-of-mind representation of other peers in the session",
@ -103,8 +129,8 @@ class WorkspaceResponse(BaseModel):
id: str
metadata: dict[str, Any] = Field(default_factory=dict)
configuration: WorkspaceConfiguration = Field(
default_factory=WorkspaceConfiguration
configuration: WorkspaceConfigurationResponse = Field(
default_factory=WorkspaceConfigurationResponse
)
created_at: datetime.datetime
@ -112,6 +138,8 @@ class WorkspaceResponse(BaseModel):
class WorkspaceCreateParams(BaseModel):
"""Parameters for creating a workspace."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
id: str = Field(min_length=1, max_length=100)
metadata: dict[str, Any] = Field(default_factory=dict)
configuration: WorkspaceConfiguration = Field(
@ -122,6 +150,8 @@ class WorkspaceCreateParams(BaseModel):
class WorkspaceUpdateParams(BaseModel):
"""Parameters for updating a workspace."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
metadata: dict[str, Any] | None = None
configuration: WorkspaceConfiguration | None = None
@ -129,6 +159,8 @@ class WorkspaceUpdateParams(BaseModel):
class WorkspaceListParams(BaseModel):
"""Parameters for listing workspaces."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
filters: dict[str, Any] | None = None
@ -152,6 +184,8 @@ class PeerResponse(BaseModel):
class PeerCreateParams(BaseModel):
"""Parameters for creating a peer."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
id: str = Field(min_length=1, max_length=100)
metadata: dict[str, Any] | None = None
configuration: PeerConfig | None = None
@ -160,6 +194,8 @@ class PeerCreateParams(BaseModel):
class PeerUpdateParams(BaseModel):
"""Parameters for updating a peer."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
metadata: dict[str, Any] | None = None
configuration: PeerConfig | None = None
@ -167,12 +203,16 @@ class PeerUpdateParams(BaseModel):
class PeerListParams(BaseModel):
"""Parameters for listing peers."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
filters: dict[str, Any] | None = None
class PeerRepresentationParams(BaseModel):
"""Parameters for getting peer representation."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
session_id: str | None = None
target: str | None = None
search_query: str | None = None
@ -217,13 +257,17 @@ class SessionResponse(BaseModel):
is_active: bool
workspace_id: str
metadata: dict[str, Any] = Field(default_factory=dict)
configuration: SessionConfiguration = Field(default_factory=SessionConfiguration)
configuration: SessionConfigurationResponse = Field(
default_factory=SessionConfigurationResponse
)
created_at: datetime.datetime
class SessionCreateParams(BaseModel):
"""Parameters for creating a session."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
id: str = Field(min_length=1, max_length=100)
metadata: dict[str, Any] | None = None
peers: dict[str, SessionPeerConfig] | None = None
@ -233,6 +277,8 @@ class SessionCreateParams(BaseModel):
class SessionUpdateParams(BaseModel):
"""Parameters for updating a session."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
metadata: dict[str, Any] | None = None
configuration: SessionConfiguration | None = None
@ -240,6 +286,8 @@ class SessionUpdateParams(BaseModel):
class SessionListParams(BaseModel):
"""Parameters for listing sessions."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
filters: dict[str, Any] | None = None
@ -308,6 +356,8 @@ class MessageResponse(BaseModel):
class MessageCreateParams(BaseModel):
"""Parameters for creating a message."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
content: str
peer_id: str
metadata: dict[str, Any] | None = None
@ -318,24 +368,32 @@ class MessageCreateParams(BaseModel):
class MessageBatchCreateParams(BaseModel):
"""Parameters for batch message creation."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
messages: list[MessageCreateParams] = Field(min_length=1, max_length=100)
class MessageUpdateParams(BaseModel):
"""Parameters for updating a message."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
metadata: dict[str, Any] | None = None
class MessageListParams(BaseModel):
"""Parameters for listing messages."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
filters: dict[str, Any] | None = None
class MessageSearchParams(BaseModel):
"""Parameters for searching messages."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
query: str
filters: dict[str, Any] | None = None
limit: int = Field(default=10, ge=1, le=100)
@ -362,6 +420,8 @@ class ConclusionResponse(BaseModel):
class ConclusionCreateParams(BaseModel):
"""Parameters for creating a conclusion."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
content: str = Field(min_length=1, max_length=65535)
observer_id: str
observed_id: str
@ -371,18 +431,24 @@ class ConclusionCreateParams(BaseModel):
class ConclusionBatchCreateParams(BaseModel):
"""Parameters for batch conclusion creation."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
conclusions: list[ConclusionCreateParams] = Field(min_length=1, max_length=100)
class ConclusionListParams(BaseModel):
"""Parameters for listing conclusions."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
filters: dict[str, Any] | None = None
class ConclusionQueryParams(BaseModel):
"""Parameters for querying conclusions."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
query: str
top_k: int = Field(default=10, ge=1, le=100)
distance: float | None = Field(default=None, ge=0.0, le=1.0)
@ -425,6 +491,8 @@ ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"]
class DialecticParams(BaseModel):
"""Parameters for dialectic chat."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
session_id: str | None = None
target: str | None = None
query: str = Field(min_length=1, max_length=10000)
@ -474,6 +542,8 @@ class PageResponse(BaseModel):
class MessageUploadParams(BaseModel):
"""Parameters for file upload message creation."""
model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute]
peer_id: str
metadata: dict[str, Any] | None = None
configuration: MessageConfiguration | None = None

View File

@ -55,7 +55,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
recently fetched. Call get_configuration() for fresh data.
"""
model_config = ConfigDict(extra="allow") # pyright: ignore
model_config = ConfigDict(extra="forbid") # pyright: ignore
workspace_id: str = Field(
...,
@ -305,44 +305,62 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
"""
Get or create a peer with the given ID.
Creates a Peer object that can be used to interact with the specified peer.
This method does not make an API call unless `configuration` or `metadata` is
provided.
Makes an API call to get or create the peer, then returns a Peer object
with cached values from the response.
Args:
id: Unique identifier for the peer within the workspace. Should be a
stable identifier that can be used consistently across sessions.
id: Unique identifier for the peer within the workspace.
metadata: Optional metadata dictionary to associate with this peer.
If set, will get/create peer immediately with metadata.
configuration: Optional configuration to set for this peer.
If set, will get/create peer immediately with flags.
Returns:
A Peer object that can be used to send messages, join sessions, and
query the peer's knowledge representations
Raises:
ValidationError: If the peer ID is empty or invalid
A Peer object with cached metadata, configuration, and created_at.
"""
return Peer(id, self, configuration=configuration, metadata=metadata)
self._ensure_workspace()
body: dict[str, Any] = {"id": id}
if metadata is not None:
body["metadata"] = metadata
if configuration is not None:
body["configuration"] = configuration.model_dump(exclude_none=True)
data = self._http.post(routes.peers(self.workspace_id), body=body)
peer_data = PeerResponse.model_validate(data)
return Peer(
id,
self,
metadata=peer_data.metadata,
configuration=peer_data.configuration,
created_at=peer_data.created_at,
)
def peers(
self, filters: dict[str, object] | None = None
self,
filters: dict[str, object] | None = None,
*,
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> SyncPage[PeerResponse, Peer]:
"""
Get all peers in the current workspace.
Makes an API call to retrieve all peers that have been created or used
within the current workspace. Returns a paginated result that transforms
inner client Peer objects to SDK Peer objects as they are consumed.
Args:
filters: Optional filter criteria.
page: Page number (1-indexed). Default: 1.
size: Number of items per page. Default: 50.
reverse: If True, reverses the default ordering. Default: False.
Returns:
A SyncPage of Peer objects representing all peers in the workspace
"""
self._ensure_workspace()
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
query["reverse"] = "true"
data = self._http.post(
routes.peers_list(self.workspace_id),
body={"filters": filters} if filters else None,
query=query,
)
def transform(peer: PeerResponse) -> Peer:
@ -351,13 +369,17 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
self,
metadata=peer.metadata,
configuration=peer.configuration,
created_at=peer.created_at,
)
def fetch_next(page: int) -> SyncPage[PeerResponse, Peer]:
def fetch_next(next_page: int) -> SyncPage[PeerResponse, Peer]:
next_query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
next_query["reverse"] = "true"
next_data = self._http.post(
routes.peers_list(self.workspace_id),
body={"filters": filters} if filters else None,
query={"page": page},
query=next_query,
)
return SyncPage(next_data, PeerResponse, transform, fetch_next)
@ -382,45 +404,65 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
"""
Get or create a session with the given ID.
Creates a Session object that can be used to manage conversations between
multiple peers. This method does not make an API call unless `configuration` or
`metadata` is provided.
Makes an API call to get or create the session, then returns a Session object
with cached values from the response.
Args:
id: Unique identifier for the session within the workspace. Should be a
stable identifier that can be used consistently to reference the
same conversation
id: Unique identifier for the session within the workspace.
metadata: Optional metadata dictionary to associate with this session.
If set, will get/create session immediately with metadata.
configuration: Optional configuration to set for this session.
If set, will get/create session immediately with flags.
Returns:
A Session object that can be used to add peers, send messages, and
manage conversation context
Raises:
ValidationError: If the session ID is empty or invalid
A Session object with cached metadata, configuration, created_at, and is_active.
"""
return Session(id, self, configuration=configuration, metadata=metadata)
self._ensure_workspace()
body: dict[str, Any] = {"id": id}
if metadata is not None:
body["metadata"] = metadata
if configuration is not None:
body["configuration"] = configuration.model_dump(exclude_none=True)
data = self._http.post(routes.sessions(self.workspace_id), body=body)
session_data = SessionResponse.model_validate(data)
return Session(
id,
self,
metadata=session_data.metadata,
configuration=SessionConfiguration.model_validate(
session_data.configuration.model_dump()
),
created_at=session_data.created_at,
is_active=session_data.is_active,
)
def sessions(
self, filters: dict[str, object] | None = None
self,
filters: dict[str, object] | None = None,
*,
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> SyncPage[SessionResponse, Session]:
"""
Get all sessions in the current workspace.
Makes an API call to retrieve all sessions that have been created within
the current workspace.
Args:
filters: Optional filter criteria.
page: Page number (1-indexed). Default: 1.
size: Number of items per page. Default: 50.
reverse: If True, reverses the default ordering. Default: False.
Returns:
A SyncPage of Session objects representing all sessions in the workspace.
Returns an empty page if no sessions exist
"""
self._ensure_workspace()
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
query["reverse"] = "true"
data = self._http.post(
routes.sessions_list(self.workspace_id),
body={"filters": filters} if filters else None,
query=query,
)
def transform(session: SessionResponse) -> Session:
@ -429,13 +471,18 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
self,
metadata=session.metadata,
configuration=session.configuration,
created_at=session.created_at,
is_active=session.is_active,
)
def fetch_next(page: int) -> SyncPage[SessionResponse, Session]:
def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]:
next_query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
next_query["reverse"] = "true"
next_data = self._http.post(
routes.sessions_list(self.workspace_id),
body={"filters": filters} if filters else None,
query={"page": page},
query=next_query,
)
return SyncPage(next_data, SessionResponse, transform, fetch_next)

View File

@ -168,6 +168,8 @@ class ConclusionScope:
page: int = 1,
size: int = 50,
session: str | SessionBase | None = None,
*,
reverse: bool = False,
) -> SyncPage[ConclusionResponse, Conclusion]:
"""
List conclusions in this scope.
@ -176,6 +178,7 @@ class ConclusionScope:
page: Page number (1-indexed)
size: Number of results per page
session: Optional session (ID string or Session object) to filter by
reverse: If True, reverses the default ordering. Default: False.
Returns:
Paginated response containing Conclusion objects
@ -189,22 +192,28 @@ class ConclusionScope:
if resolved_session_id:
filters["session_id"] = resolved_session_id
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
query["reverse"] = "true"
data = self._honcho._http.post(
routes.conclusions_list(self.workspace_id),
body={"filters": filters},
query={"page": page, "size": size},
query=query,
)
def transform(response: ConclusionResponse) -> Conclusion:
return Conclusion.from_api_response(response)
def fetch_next(
page: int,
next_page: int,
) -> SyncPage[ConclusionResponse, Conclusion]:
next_query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
next_query["reverse"] = "true"
next_data = self._honcho._http.post(
routes.conclusions_list(self.workspace_id),
body={"filters": filters},
query={"page": page, "size": size},
query=next_query,
)
return SyncPage(next_data, ConclusionResponse, transform, fetch_next)

View File

@ -19,6 +19,7 @@ from .api_types import (
PeerContextResponse,
PeerResponse,
RepresentationResponse,
SessionConfiguration,
SessionResponse,
)
from .base import PeerBase, SessionBase
@ -57,6 +58,7 @@ class Peer(PeerBase, MetadataConfigMixin):
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: PeerConfig | None = PrivateAttr(default=None)
_created_at: datetime.datetime | None = PrivateAttr(default=None)
_honcho: "Honcho" = PrivateAttr()
@property
@ -69,6 +71,11 @@ class Peer(PeerBase, MetadataConfigMixin):
"""Cached configuration for this peer. May be stale. Use get_configuration() for fresh data."""
return self._configuration
@property
def created_at(self) -> datetime.datetime | None:
"""Timestamp when this peer was created. Only available if fetched from the API."""
return self._created_at
# MetadataConfigMixin implementation
def _get_http_client(self):
self._honcho._ensure_workspace()
@ -90,6 +97,21 @@ class Peer(PeerBase, MetadataConfigMixin):
# Return configuration as dict for mixin compatibility
return peer.metadata or {}, peer.configuration.model_dump(exclude_none=True)
def _apply_peer_response(self, peer: PeerResponse) -> None:
self._metadata = peer.metadata or {}
self._configuration = peer.configuration
self._created_at = peer.created_at
def get_metadata(self) -> dict[str, object]:
"""Get metadata from the server and update the cache."""
self._honcho._ensure_workspace()
data = self._get_http_client().post(
self._get_fetch_route(), body=self._get_fetch_body()
)
peer = PeerResponse.model_validate(data)
self._apply_peer_response(peer)
return self._metadata or {}
def get_configuration(self) -> PeerConfig: # pyright: ignore[reportIncompatibleMethodOverride]
"""
Get configuration from the server and update the cache.
@ -102,9 +124,17 @@ class Peer(PeerBase, MetadataConfigMixin):
self._get_fetch_route(), body=self._get_fetch_body()
)
peer = PeerResponse.model_validate(data)
self._metadata = peer.metadata or {}
self._configuration = peer.configuration
return self._configuration
self._apply_peer_response(peer)
return self._configuration or PeerConfig()
def refresh(self) -> None:
"""Refresh cached metadata, configuration, and created_at from the server."""
self._honcho._ensure_workspace()
data = self._get_http_client().post(
self._get_fetch_route(), body=self._get_fetch_body()
)
peer = PeerResponse.model_validate(data)
self._apply_peer_response(peer)
@validate_call
def set_configuration( # pyright: ignore[reportIncompatibleMethodOverride]
@ -163,6 +193,10 @@ class Peer(PeerBase, MetadataConfigMixin):
None,
description="Optional configuration to set for this peer. If set, will get/create peer immediately with flags.",
),
created_at: datetime.datetime | None = Field(
None,
description="Timestamp when this peer was created.",
),
) -> None:
"""
Initialize a new Peer.
@ -184,21 +218,8 @@ class Peer(PeerBase, MetadataConfigMixin):
)
self._honcho = honcho
self._metadata = metadata
self._configuration = configuration
if configuration is not None or metadata is not None:
self._honcho._ensure_workspace()
body: dict[str, Any] = {"id": peer_id}
if metadata is not None:
body["metadata"] = metadata
if configuration is not None:
body["configuration"] = configuration.model_dump(exclude_none=True)
data = honcho._http.post(routes.peers(honcho.workspace_id), body=body)
peer_data = PeerResponse.model_validate(data)
# Update cached values with API response
self._metadata = peer_data.metadata
self._configuration = peer_data.configuration # pyright: ignore[reportIncompatibleVariableOverride]
self._configuration = configuration # pyright: ignore[reportIncompatibleVariableOverride]
self._created_at = created_at
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def chat(
@ -309,35 +330,58 @@ class Peer(PeerBase, MetadataConfigMixin):
return DialecticStreamResponse(stream_response())
def sessions(
self, filters: dict[str, object] | None = None
self,
filters: dict[str, object] | None = None,
*,
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> SyncPage[SessionResponse, "Session"]:
"""
Get all sessions this peer is a member of.
Makes an API call to retrieve all sessions where this peer is an active participant.
Sessions are created when peers are added to them or send messages to them.
Args:
filters: Optional filter criteria.
page: Page number (1-indexed). Default: 1.
size: Number of items per page. Default: 50.
reverse: If True, reverses the default ordering. Default: False.
Returns:
A paginated list of Session objects this peer belongs to. Returns an empty
list if the peer is not a member of any sessions
A paginated list of Session objects this peer belongs to.
"""
self._honcho._ensure_workspace()
# Import here to avoid circular import (session.py imports Peer)
from .session import Session
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
query["reverse"] = "true"
data = self._honcho._http.post(
routes.peer_sessions_list(self.workspace_id, self.id),
body={"filters": filters} if filters else None,
query=query,
)
def transform(session: SessionResponse) -> Session:
return Session(session.id, self._honcho)
return Session(
session.id,
self._honcho,
metadata=session.metadata,
configuration=SessionConfiguration.model_validate(
session.configuration.model_dump()
),
created_at=session.created_at,
is_active=session.is_active,
)
def fetch_next(page: int) -> SyncPage[SessionResponse, Session]:
def fetch_next(next_page: int) -> SyncPage[SessionResponse, Session]:
next_query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
next_query["reverse"] = "true"
next_data = self._honcho._http.post(
routes.peer_sessions_list(self.workspace_id, self.id),
body={"filters": filters} if filters else None,
query={"page": page},
query=next_query,
)
return SyncPage(next_data, SessionResponse, transform, fetch_next)

View File

@ -62,6 +62,8 @@ class Session(SessionBase, MetadataConfigMixin):
_metadata: dict[str, object] | None = PrivateAttr(default=None)
_configuration: SessionConfiguration | None = PrivateAttr(default=None)
_created_at: datetime | None = PrivateAttr(default=None)
_is_active: bool | None = PrivateAttr(default=None)
_honcho: "Honcho" = PrivateAttr()
@property
@ -74,6 +76,16 @@ class Session(SessionBase, MetadataConfigMixin):
"""Cached configuration for this session. May be stale. Use get_configuration() for fresh data."""
return self._configuration
@property
def created_at(self) -> datetime | None:
"""Timestamp when this session was created. Only available if fetched from the API."""
return self._created_at
@property
def is_active(self) -> bool | None:
"""Whether this session is active. Only available if fetched from the API."""
return self._is_active
# MetadataConfigMixin implementation
def _get_http_client(self):
self._honcho._ensure_workspace()
@ -97,6 +109,30 @@ class Session(SessionBase, MetadataConfigMixin):
exclude_none=True
)
def _apply_session_response(self, session: SessionResponse) -> None:
self._metadata = session.metadata or {}
self._configuration = SessionConfiguration.model_validate(
session.configuration.model_dump()
)
self._created_at = session.created_at
self._is_active = session.is_active
def get_metadata(self) -> dict[str, object]:
"""
Get metadata from the server and update the cache.
Returns:
A dictionary containing the metadata. Returns an empty dictionary
if no metadata is set.
"""
self._honcho._ensure_workspace()
data = self._get_http_client().post(
self._get_fetch_route(), body=self._get_fetch_body()
)
session = SessionResponse.model_validate(data)
self._apply_session_response(session)
return self._metadata or {}
def get_configuration(self) -> SessionConfiguration: # pyright: ignore[reportIncompatibleMethodOverride]
"""
Get configuration from the server and update the cache.
@ -109,9 +145,19 @@ class Session(SessionBase, MetadataConfigMixin):
self._get_fetch_route(), body=self._get_fetch_body()
)
session = SessionResponse.model_validate(data)
self._metadata = session.metadata or {}
self._configuration = session.configuration
return self._configuration
self._apply_session_response(session)
return self._configuration or SessionConfiguration()
def refresh(self) -> None:
"""
Refresh cached metadata, configuration, and session status from the server.
"""
self._honcho._ensure_workspace()
data = self._get_http_client().post(
self._get_fetch_route(), body=self._get_fetch_body()
)
session = SessionResponse.model_validate(data)
self._apply_session_response(session)
@validate_call
def set_configuration( # pyright: ignore[reportIncompatibleMethodOverride]
@ -171,6 +217,14 @@ class Session(SessionBase, MetadataConfigMixin):
None,
description="Optional configuration to set for this session. If set, will get/create session immediately with flags.",
),
created_at: datetime | None = Field(
None,
description="Timestamp when this session was created.",
),
is_active: bool | None = Field(
None,
description="Whether this session is active.",
),
) -> None:
"""
Initialize a new Session.
@ -192,21 +246,9 @@ class Session(SessionBase, MetadataConfigMixin):
)
self._honcho = honcho
self._metadata = metadata
self._configuration = configuration
if configuration is not None or metadata is not None:
self._honcho._ensure_workspace()
body: dict[str, Any] = {"id": session_id}
if metadata is not None:
body["metadata"] = metadata
if configuration is not None:
body["configuration"] = configuration.model_dump(exclude_none=True)
data = honcho._http.post(routes.sessions(honcho.workspace_id), body=body)
session_data = SessionResponse.model_validate(data)
# Update cached values with API response
self._metadata = session_data.metadata
self._configuration = session_data.configuration # pyright: ignore[reportIncompatibleVariableOverride]
self._configuration = configuration # pyright: ignore[reportIncompatibleVariableOverride]
self._created_at = created_at
self._is_active = is_active
def add_peers(
self,
@ -405,38 +447,43 @@ class Session(SessionBase, MetadataConfigMixin):
filters: dict[str, object] | None = Field(
None, description="Dictionary of filter criteria"
),
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> SyncPage[MessageResponse, Message]:
"""
Get messages from this session with optional filtering.
Makes an API call to retrieve messages from this session. Results can be
filtered based on various criteria.
Args:
filters: Dictionary of filter criteria. Supported filters include:
- peer_id: Filter messages by the peer who created them
- metadata: Filter messages by metadata key-value pairs
- timestamp_start: Filter messages after a specific timestamp
- timestamp_end: Filter messages before a specific timestamp
filters: Dictionary of filter criteria.
page: Page number (1-indexed). Default: 1.
size: Number of items per page. Default: 50.
reverse: If True, returns messages in reverse chronological order. Default: False.
Returns:
A list of Message objects matching the specified criteria, ordered by
creation time (most recent first)
A paginated result of Message objects.
"""
self._honcho._ensure_workspace()
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
query["reverse"] = "true"
data = self._honcho._http.post(
routes.messages_list(self.workspace_id, self.id),
body={"filters": filters} if filters else None,
query=query,
)
def transform(response: MessageResponse) -> Message:
return Message.from_api_response(response)
def fetch_next(page: int) -> SyncPage[MessageResponse, Message]:
def fetch_next(next_page: int) -> SyncPage[MessageResponse, Message]:
next_query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
next_query["reverse"] = "true"
next_data = self._honcho._http.post(
routes.messages_list(self.workspace_id, self.id),
body={"filters": filters} if filters else None,
query={"page": page},
query=next_query,
)
return SyncPage(next_data, MessageResponse, transform, fetch_next)
@ -503,6 +550,8 @@ class Session(SessionBase, MetadataConfigMixin):
self._honcho,
metadata=cloned.metadata,
configuration=cloned.configuration,
created_at=cloned.created_at,
is_active=cloned.is_active,
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
@ -919,6 +968,21 @@ class Session(SessionBase, MetadataConfigMixin):
)
return QueueStatusResponse.model_validate(data)
def get_message(self, message_id: str) -> Message:
"""Get a single message by ID from this session.
Args:
message_id: The ID of the message to retrieve
Returns:
The Message object
"""
self._honcho._ensure_workspace()
data = self._honcho._http.get(
routes.message(self.workspace_id, self.id, message_id)
)
return Message.from_api_response(MessageResponse.model_validate(data))
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def update_message(
self,

View File

@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [2.1.0] - 2026-03-25
### 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` (extracts `.content` automatically)
- `page`, `size`, and `reverse` pagination controls on all list methods: `peers()`, `sessions()`, `messages()`, `workspaces()`, and `conclusions.list()`
### Changed
- **Breaking**: `searchQuery` removed from top-level `context()` options. Use `representationOptions.searchQuery` instead — this eliminates the duplicate parameter and is consistent with the API structure.
- List methods (`peers()`, `sessions()`, `messages()`, `workspaces()`) support both the new options object and the legacy raw-filter form: `peers({ filters, page, size, reverse })` and `peers(filters)`.
- 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. Previously, calling without metadata/configuration returned a lazy object with no API call. All Peer/Session objects now have `createdAt` populated immediately.
- Response configuration models (`WorkspaceConfigurationResponse`, `SessionConfigurationResponse`) now tolerate unknown fields from newer servers for forward compatibility
- Reusable `PeerIdObjectSchema` and `SessionIdObjectSchema` helpers for union validation
- 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.
## [2.0.2] - 2026-03-10
### Changed
- **Breaking**: 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.
## [2.0.1] - 2026-02-09
### Added

View File

@ -15,7 +15,7 @@ import { Honcho } from "@honcho-ai/sdk";
const honcho = new Honcho({
apiKey: process.env.HONCHO_API_KEY,
baseUrl: "http://localhost:8000",
baseURL: "http://localhost:8000",
workspaceId: "test",
});

View File

@ -143,6 +143,27 @@ describe('Honcho Client', () => {
await testClient.deleteWorkspace(testClient.workspaceId)
}
})
test('workspaces with options.filters narrows results', async () => {
const uniqueValue = `filter-options-${Date.now()}`
const testClient = new Honcho({
baseURL: TEST_CONFIG.baseURL,
apiKey: TEST_CONFIG.apiKey,
workspaceId: generateWorkspaceId('filter-options'),
})
try {
await testClient.setMetadata({ filterKey: uniqueValue })
const page = await client.workspaces({
filters: { metadata: { filterKey: uniqueValue } },
})
expect(page.items).toContain(testClient.workspaceId)
} finally {
await testClient.deleteWorkspace(testClient.workspaceId)
}
})
})
// ===========================================================================
@ -172,11 +193,12 @@ describe('Honcho Client', () => {
// ===========================================================================
describe('Peer access', () => {
test('peer() returns Peer instance without API call', async () => {
test('peer() returns Peer instance with cached data', async () => {
const peer = await client.peer('lazy-peer')
expect(peer.id).toBe('lazy-peer')
expect(peer.workspaceId).toBe(client.workspaceId)
expect(peer.createdAt).toBeDefined()
})
test('peer() with metadata makes API call', async () => {
@ -205,11 +227,13 @@ describe('Honcho Client', () => {
})
describe('Session access', () => {
test('session() returns Session instance without API call', async () => {
test('session() returns Session instance with cached data', async () => {
const session = await client.session('lazy-session', { metadata: {} })
expect(session.id).toBe('lazy-session')
expect(session.workspaceId).toBe(client.workspaceId)
expect(session.createdAt).toBeDefined()
expect(session.isActive).toBe(true)
})
test('session() with metadata makes API call', async () => {

View File

@ -14,6 +14,11 @@ import type {
PageResponse,
} from '../src/types/api'
function expectValidDateString(value: string): void {
const parsedDate = new Date(value)
expect(Number.isNaN(parsedDate.getTime())).toBe(false)
}
// =============================================================================
// Response Shape Assertions
// =============================================================================
@ -28,8 +33,7 @@ export function assertWorkspaceShape(workspace: WorkspaceResponse): void {
expect(typeof workspace.metadata).toBe('object')
expect(typeof workspace.configuration).toBe('object')
expect(typeof workspace.created_at).toBe('string')
// Validate ISO 8601 date format
expect(() => new Date(workspace.created_at)).not.toThrow()
expectValidDateString(workspace.created_at)
}
/**
@ -43,7 +47,7 @@ export function assertPeerShape(peer: PeerResponse): void {
expect(typeof peer.metadata).toBe('object')
expect(typeof peer.configuration).toBe('object')
expect(typeof peer.created_at).toBe('string')
expect(() => new Date(peer.created_at)).not.toThrow()
expectValidDateString(peer.created_at)
}
/**
@ -58,7 +62,7 @@ export function assertSessionShape(session: SessionResponse): void {
expect(typeof session.metadata).toBe('object')
expect(typeof session.configuration).toBe('object')
expect(typeof session.created_at).toBe('string')
expect(() => new Date(session.created_at)).not.toThrow()
expectValidDateString(session.created_at)
}
/**
@ -76,7 +80,7 @@ export function assertMessageShape(message: Message): void {
expect(typeof message.createdAt).toBe('string')
expect(typeof message.tokenCount).toBe('number')
expect(message.tokenCount).toBeGreaterThanOrEqual(0)
expect(() => new Date(message.createdAt)).not.toThrow()
expectValidDateString(message.createdAt)
}
/**
@ -91,7 +95,7 @@ export function assertConclusionShape(conclusion: ConclusionResponse): void {
expect(typeof conclusion.observed_id).toBe('string')
expect(typeof conclusion.session_id).toBe('string')
expect(typeof conclusion.created_at).toBe('string')
expect(() => new Date(conclusion.created_at)).not.toThrow()
expectValidDateString(conclusion.created_at)
}
/**

View File

@ -223,7 +223,7 @@ describe('Messages', () => {
bob.message('From Bob'),
])
const page = await session.messages({ peer_id: alice.id })
const page = await session.messages({ filters: { peer_id: alice.id } })
expect(page.items.length).toBe(1)
expect(page.items[0].peerId).toBe(alice.id)
@ -238,11 +238,27 @@ describe('Messages', () => {
peer.message('Untagged'),
])
const page = await session.messages({ metadata: { category: 'important' } })
const page = await session.messages({ filters: { metadata: { category: 'important' } } })
expect(page.items.length).toBe(1)
expect(page.items[0].metadata.category).toBe('important')
})
test('accepts legacy raw filter objects', async () => {
const session = await client.session('legacy-filter-msg-session', { metadata: {} })
const alice = await client.peer('legacy-filter-alice')
const bob = await client.peer('legacy-filter-bob')
await session.addPeers([alice.id, bob.id])
await session.addMessages([
alice.message('Legacy Alice'),
bob.message('Legacy Bob'),
])
const page = await session.messages({ peer_id: alice.id })
expect(page.items.length).toBe(1)
expect(page.items[0].peerId).toBe(alice.id)
})
})
// ===========================================================================
@ -382,8 +398,8 @@ describe('Messages', () => {
expect(typeof message.tokenCount).toBe('number')
// Validate date format
expect(() => new Date(message.createdAt)).not.toThrow()
const date = new Date(message.createdAt)
expect(Number.isNaN(date.getTime())).toBe(false)
expect(date.getTime()).toBeGreaterThan(0)
})
@ -403,4 +419,109 @@ describe('Messages', () => {
expect(uniqueIds.size).toBe(ids.length)
})
})
// ===========================================================================
// Get Single Message (GET /messages/:id)
// ===========================================================================
describe('GET /messages/:id (getMessage)', () => {
test('getMessage returns message by ID', async () => {
const session = await client.session('get-msg-session', { metadata: {} })
const peer = await client.peer('get-msg-peer')
await session.addPeers([peer.id])
const [created] = await session.addMessages(peer.message('Retrievable'))
const fetched = await session.getMessage(created.id)
expect(fetched.id).toBe(created.id)
expect(fetched.content).toBe('Retrievable')
expect(fetched.peerId).toBe(peer.id)
expect(fetched.sessionId).toBe(session.id)
})
test('getMessage preserves metadata', async () => {
const session = await client.session('get-msg-meta-session', {
metadata: {},
})
const peer = await client.peer('get-msg-meta-peer')
await session.addPeers([peer.id])
const [created] = await session.addMessages(
peer.message('With metadata', { metadata: { key: 'value' } })
)
const fetched = await session.getMessage(created.id)
expect(fetched.metadata).toEqual({ key: 'value' })
})
})
// ===========================================================================
// Pagination Controls
// ===========================================================================
describe('Pagination controls', () => {
test('messages with custom page size', async () => {
const session = await client.session('page-size-session', { metadata: {} })
const peer = await client.peer('page-size-peer')
await session.addPeers([peer.id])
// Create 3 messages
await session.addMessages([
peer.message('One'),
peer.message('Two'),
peer.message('Three'),
])
// Fetch with size=2
const page = await session.messages({ size: 2 })
const items = await collectAll(page)
// Should get all 3 via auto-pagination, but first page had only 2
expect(items.length).toBe(3)
})
test('messages with explicit page number', async () => {
const session = await client.session('page-num-session', { metadata: {} })
const peer = await client.peer('page-num-peer')
await session.addPeers([peer.id])
await session.addMessages([
peer.message('A'),
peer.message('B'),
peer.message('C'),
])
// Fetch page 1 with size 2
const page1 = await session.messages({ page: 1, size: 2 })
expect(page1.items.length).toBe(2)
// Fetch page 2
const page2 = await session.messages({ page: 2, size: 2 })
expect(page2.items.length).toBe(1)
})
test('messages with reverse ordering', async () => {
const session = await client.session('reverse-session', { metadata: {} })
const peer = await client.peer('reverse-peer')
await session.addPeers([peer.id])
await session.addMessages([
peer.message('First'),
peer.message('Second'),
peer.message('Third'),
])
const normal = await session.messages()
const reversed = await session.messages({ reverse: true })
const normalItems = await collectAll(normal)
const reversedItems = await collectAll(reversed)
expect(normalItems.length).toBe(reversedItems.length)
// Reversed order should have different first element
expect(normalItems[0].content).not.toBe(
reversedItems[0].content
)
})
})
})

View File

@ -52,6 +52,18 @@ describe('Peer', () => {
expect(peer).toBeInstanceOf(Peer)
expect(peer.id).toBe('simple-peer')
expect(peer.workspaceId).toBe(client.workspaceId)
expect(peer.createdAt).toBeDefined()
})
test('peer created with metadata exposes createdAt', async () => {
const peer = await client.peer('peer-created-at', {
metadata: { test: true },
})
expect(peer.createdAt).toBeDefined()
expect(typeof peer.createdAt).toBe('string')
const createdAt = new Date(peer.createdAt!)
expect(Number.isNaN(createdAt.getTime())).toBe(false)
})
test('creates peer with metadata', async () => {
@ -116,12 +128,24 @@ describe('Peer', () => {
metadata: { uniqueTag },
})
const page = await client.peers({ metadata: { uniqueTag } })
const page = await client.peers({ filters: { metadata: { uniqueTag } } })
expect(page.items.length).toBe(1)
expect(page.items[0].id).toBe('filtered-peer')
})
test('peers accept legacy raw filter objects', async () => {
const uniqueTag = `legacy-tag-${Date.now()}`
await client.peer(`legacy-filtered-peer-${Date.now()}`, {
metadata: { uniqueTag },
})
const page = await client.peers({ metadata: { uniqueTag } })
expect(page.items.length).toBe(1)
expect(page.items[0].metadata.uniqueTag).toBe(uniqueTag)
})
test('Page is async iterable', async () => {
await client.peer('iter-peer-1', { metadata: {} })
await client.peer('iter-peer-2', { metadata: {} })
@ -213,10 +237,24 @@ describe('Peer', () => {
})
await session.addPeers([peer.id])
const sessions = await peer.sessions({ metadata: { category: 'special' } })
const sessions = await peer.sessions({ filters: { metadata: { category: 'special' } } })
expect(sessions.items.length).toBeGreaterThanOrEqual(1)
})
test('sessions accept legacy raw filter objects', async () => {
const category = `legacy-category-${Date.now()}`
const peer = await client.peer(`legacy-filter-sessions-peer-${Date.now()}`)
const session = await client.session(`legacy-filterable-session-${Date.now()}`, {
metadata: { category },
})
await session.addPeers([peer.id])
const sessions = await peer.sessions({ metadata: { category } })
expect(sessions.items.length).toBeGreaterThanOrEqual(1)
expect(sessions.items[0].metadata.category).toBe(category)
})
})
// ===========================================================================
@ -637,4 +675,32 @@ describe('Peer', () => {
expect(str).toBe("Peer(id='tostring-peer')")
})
})
// ===========================================================================
// Pagination Controls
// ===========================================================================
describe('Pagination controls', () => {
test('peers with custom page size', async () => {
// Create peers with unique metadata
await client.peer('page-peer-1', { metadata: { group: 'page-test' } })
await client.peer('page-peer-2', { metadata: { group: 'page-test' } })
await client.peer('page-peer-3', { metadata: { group: 'page-test' } })
const page = await client.peers({
filters: { metadata: { group: 'page-test' } },
size: 2,
})
expect(page.items.length).toBe(2)
})
test('peers with explicit page number', async () => {
const page2 = await client.peers({
filters: { metadata: { group: 'page-test' } },
page: 2,
size: 2,
})
expect(page2.items.length).toBe(1)
})
})
})

View File

@ -54,6 +54,20 @@ describe('Session', () => {
expect(session).toBeInstanceOf(Session)
expect(session.id).toBe('simple-session')
expect(session.workspaceId).toBe(client.workspaceId)
expect(session.createdAt).toBeDefined()
expect(session.isActive).toBe(true)
})
test('session created with metadata exposes createdAt and isActive', async () => {
const session = await client.session('session-timestamps', {
metadata: { test: true },
})
expect(session.createdAt).toBeDefined()
expect(typeof session.createdAt).toBe('string')
const createdAt = new Date(session.createdAt!)
expect(Number.isNaN(createdAt.getTime())).toBe(false)
expect(session.isActive).toBe(true)
})
test('creates session with metadata', async () => {
@ -104,11 +118,23 @@ describe('Session', () => {
const tag = `tag-${Date.now()}`
await client.session('filtered-session', { metadata: { tag } })
const page = await client.sessions({ metadata: { tag } })
const page = await client.sessions({ filters: { metadata: { tag } } })
expect(page.items.length).toBe(1)
expect(page.items[0].id).toBe('filtered-session')
})
test('sessions accept legacy raw filter objects', async () => {
const tag = `legacy-tag-${Date.now()}`
await client.session(`legacy-filtered-session-${Date.now()}`, {
metadata: { tag },
})
const page = await client.sessions({ metadata: { tag } })
expect(page.items.length).toBe(1)
expect(page.items[0].metadata.tag).toBe(tag)
})
})
// ===========================================================================
@ -446,7 +472,7 @@ describe('Session', () => {
peer.message('Not this one'),
])
const page = await session.messages({ metadata: { tag: 'special' } })
const page = await session.messages({ filters: { metadata: { tag: 'special' } } })
expect(page.items.length).toBe(1)
expect(page.items[0].metadata.tag).toBe('special')
@ -610,4 +636,34 @@ describe('Session', () => {
expect(str).toBe("Session(id='tostring-session')")
})
})
// ===========================================================================
// Pagination Controls
// ===========================================================================
describe('Pagination controls', () => {
test('sessions with custom page size', async () => {
// Create 3 sessions
await client.session('page-test-1', { metadata: { group: 'page-test' } })
await client.session('page-test-2', { metadata: { group: 'page-test' } })
await client.session('page-test-3', { metadata: { group: 'page-test' } })
const page = await client.sessions({
filters: { metadata: { group: 'page-test' } },
size: 2,
})
// First page should have 2 items
expect(page.items.length).toBe(2)
})
test('sessions with explicit page number', async () => {
const page2 = await client.sessions({
filters: { metadata: { group: 'page-test' } },
page: 2,
size: 2,
})
// Page 2 should have the remaining item
expect(page2.items.length).toBe(1)
})
})
})

View File

@ -0,0 +1,150 @@
import { describe, expect, test } from 'bun:test'
import { ZodError } from 'zod'
import type { HonchoHTTPClient } from '../src/http/client'
import { Session } from '../src/session'
import type { SessionContextResponse, SessionResponse } from '../src/types/api'
function createSessionResponse(
overrides: Partial<SessionResponse> = {}
): SessionResponse {
return {
id: 'session-1',
workspace_id: 'workspace-1',
is_active: true,
metadata: {},
configuration: {},
created_at: '2024-01-01T00:00:00Z',
...overrides,
}
}
function createSessionContextResponse(): SessionContextResponse {
return {
id: 'session-1',
messages: [],
summary: null,
peer_representation: null,
peer_card: null,
}
}
describe('Session unit behavior', () => {
test('getMetadata populates createdAt and isActive from fetched session data', async () => {
const http = {
post: async () =>
createSessionResponse({
metadata: { topic: 'testing' },
created_at: '2024-02-01T12:00:00Z',
is_active: true,
}),
} as unknown as HonchoHTTPClient
const session = new Session('session-1', 'workspace-1', http)
expect(session.createdAt).toBeUndefined()
expect(session.isActive).toBeUndefined()
const metadata = await session.getMetadata()
expect(metadata).toEqual({ topic: 'testing' })
expect(session.createdAt).toBe('2024-02-01T12:00:00Z')
expect(session.isActive).toBe(true)
})
test('refresh updates cached createdAt and isActive from the latest fetch', async () => {
const responses = [
createSessionResponse({
metadata: { version: 1 },
configuration: { reasoning: { enabled: true } },
created_at: '2024-01-01T00:00:00Z',
is_active: true,
}),
createSessionResponse({
metadata: { version: 2 },
configuration: { reasoning: { enabled: false } },
created_at: '2024-01-02T00:00:00Z',
is_active: false,
}),
]
const http = {
post: async () => responses.shift() ?? createSessionResponse(),
} as unknown as HonchoHTTPClient
const session = new Session('session-1', 'workspace-1', http)
await session.getMetadata()
await session.refresh()
expect(session.metadata).toEqual({ version: 2 })
expect(session.configuration).toMatchObject({
reasoning: { enabled: false },
})
expect(session.createdAt).toBe('2024-01-02T00:00:00Z')
expect(session.isActive).toBe(false)
})
test('uploadFile converts configuration to API field names', async () => {
let capturedFormData: FormData | undefined
const http = {
upload: async (_path: string, formData: FormData) => {
capturedFormData = formData
return []
},
} as unknown as HonchoHTTPClient
const session = new Session('session-1', 'workspace-1', http)
await session.uploadFile(new Blob(['hello'], { type: 'text/plain' }), 'peer-1', {
configuration: {
reasoning: {
enabled: true,
customInstructions: 'Keep headings intact',
},
},
})
expect(capturedFormData).toBeDefined()
const rawConfiguration = capturedFormData?.get('configuration')
expect(typeof rawConfiguration).toBe('string')
expect(JSON.parse(rawConfiguration as string)).toEqual({
reasoning: {
enabled: true,
custom_instructions: 'Keep headings intact',
},
})
})
test('context rejects invalid content-like searchQuery inputs', async () => {
const http = {
get: async () => createSessionContextResponse(),
} as unknown as HonchoHTTPClient
const session = new Session('session-1', 'workspace-1', http)
await expect(
session.context({
peerTarget: 'peer-1',
representationOptions: {
searchQuery: { foo: 'bar' } as never,
},
})
).rejects.toBeInstanceOf(ZodError)
})
test('representation rejects whitespace-only content-like searchQuery inputs', async () => {
const http = {
post: async () => ({ representation: 'ok' }),
} as unknown as HonchoHTTPClient
const session = new Session('session-1', 'workspace-1', http)
await expect(
session.representation('peer-1', {
searchQuery: { content: ' ' } as never,
})
).rejects.toBeInstanceOf(ZodError)
})
})

View File

@ -0,0 +1,406 @@
/**
* Validation Schema Tests
*
* Unit tests for Zod validation schemas. No server required.
*/
import { describe, test, expect } from 'bun:test'
import { ZodError } from 'zod'
import {
ChatQuerySchema,
ContextParamsSchema,
HonchoConfigSchema,
MessageInputSchema,
FileUploadSchema,
RepresentationOptionsSchema,
} from '../src/validation'
// =============================================================================
// ChatQuerySchema
// =============================================================================
describe('ChatQuerySchema', () => {
// --- Valid inputs ---
test('minimal valid query', () => {
const result = ChatQuerySchema.parse({ query: 'hello' })
expect(result.query).toBe('hello')
expect(result.target).toBeUndefined()
expect(result.session).toBeUndefined()
expect(result.reasoningLevel).toBeUndefined()
})
test('query with all optional fields', () => {
const result = ChatQuerySchema.parse({
query: 'hello',
target: 'peer-1',
session: 'session-1',
reasoningLevel: 'high',
})
expect(result.query).toBe('hello')
expect(result.target).toBe('peer-1')
expect(result.session).toBe('session-1')
expect(result.reasoningLevel).toBe('high')
})
test('target as object with id is transformed to string', () => {
const result = ChatQuerySchema.parse({
query: 'hello',
target: { id: 'peer-1' },
})
expect(result.target).toBe('peer-1')
})
test('session as object with id is transformed to string', () => {
const result = ChatQuerySchema.parse({
query: 'hello',
session: { id: 'session-1' },
})
expect(result.session).toBe('session-1')
})
test.each(['minimal', 'low', 'medium', 'high', 'max'] as const)(
'reasoning level "%s" is valid',
(level) => {
const result = ChatQuerySchema.parse({ query: 'hello', reasoningLevel: level })
expect(result.reasoningLevel).toBe(level)
}
)
// --- Missing required fields ---
test('missing query throws', () => {
expect(() => ChatQuerySchema.parse({})).toThrow(ZodError)
})
test('empty query throws', () => {
expect(() => ChatQuerySchema.parse({ query: '' })).toThrow(ZodError)
})
test('whitespace-only query throws', () => {
expect(() => ChatQuerySchema.parse({ query: ' ' })).toThrow(ZodError)
})
// --- Invalid field values ---
test('invalid reasoning level throws', () => {
expect(() =>
ChatQuerySchema.parse({ query: 'hello', reasoningLevel: 'ultra' })
).toThrow(ZodError)
})
test('empty target string throws', () => {
expect(() =>
ChatQuerySchema.parse({ query: 'hello', target: '' })
).toThrow(ZodError)
})
test('empty session string throws', () => {
expect(() =>
ChatQuerySchema.parse({ query: 'hello', session: '' })
).toThrow(ZodError)
})
test('target with special characters throws', () => {
expect(() =>
ChatQuerySchema.parse({ query: 'hello', target: 'peer with spaces' })
).toThrow(ZodError)
})
test('session with special characters throws', () => {
expect(() =>
ChatQuerySchema.parse({ query: 'hello', session: 'session/bad' })
).toThrow(ZodError)
})
// --- Strict mode: unknown fields ---
test('unknown field throws (strict)', () => {
expect(() =>
ChatQuerySchema.parse({ query: 'hello', typo: 'oops' })
).toThrow(ZodError)
})
test('misspelled field throws (strict)', () => {
expect(() =>
ChatQuerySchema.parse({ query: 'hello', reasoning_level: 'high' })
).toThrow(ZodError)
})
test('extra field alongside valid fields throws (strict)', () => {
expect(() =>
ChatQuerySchema.parse({
query: 'hello',
target: 'peer-1',
session: 'session-1',
reasoningLevel: 'low',
extra: true,
})
).toThrow(ZodError)
})
})
// =============================================================================
// HonchoConfigSchema (strict validation)
// =============================================================================
describe('HonchoConfigSchema (strict)', () => {
test('baseUrl (wrong casing) throws', () => {
expect(() =>
HonchoConfigSchema.parse({
baseUrl: 'http://localhost:8000',
workspaceId: 'test',
})
).toThrow(ZodError)
})
test('baseURL (correct casing) passes', () => {
const result = HonchoConfigSchema.parse({
baseURL: 'http://localhost:8000',
workspaceId: 'test',
})
expect(result.baseURL).toBe('http://localhost:8000')
})
test('unknown option throws', () => {
expect(() =>
HonchoConfigSchema.parse({
workspaceId: 'test',
retries: 3,
})
).toThrow(ZodError)
})
})
// =============================================================================
// MessageInputSchema (strict validation)
// =============================================================================
describe('MessageInputSchema (strict)', () => {
test('valid message input', () => {
const result = MessageInputSchema.parse({
peerId: 'peer-1',
content: 'hello',
metadata: { key: 'value' },
})
expect(result.peerId).toBe('peer-1')
expect(result.content).toBe('hello')
})
test('snake_case peerId alias throws (strict)', () => {
expect(() =>
MessageInputSchema.parse({
peer_id: 'peer-1',
content: 'hello',
})
).toThrow(ZodError)
})
test('unknown field throws (strict)', () => {
expect(() =>
MessageInputSchema.parse({
peerId: 'peer-1',
content: 'hello',
role: 'user',
})
).toThrow(ZodError)
})
})
// =============================================================================
// ContextParamsSchema (searchQuery in representationOptions)
// =============================================================================
describe('ContextParamsSchema', () => {
test('top-level searchQuery throws (strict)', () => {
expect(() =>
ContextParamsSchema.parse({
peerTarget: 'peer-1',
searchQuery: 'hello',
})
).toThrow(ZodError)
})
test('searchQuery inside representationOptions passes', () => {
const result = ContextParamsSchema.parse({
peerTarget: 'peer-1',
representationOptions: { searchQuery: 'hello' },
})
expect(result.representationOptions?.searchQuery).toBe('hello')
})
test('representationOptions.searchQuery requires peerTarget', () => {
expect(() =>
ContextParamsSchema.parse({
representationOptions: { searchQuery: 'hello' },
})
).toThrow(ZodError)
})
test('minimal valid context params', () => {
const result = ContextParamsSchema.parse({})
expect(result.summary).toBeUndefined()
expect(result.peerTarget).toBeUndefined()
})
test('all valid options', () => {
const result = ContextParamsSchema.parse({
summary: true,
tokens: 1000,
peerTarget: 'peer-1',
peerPerspective: 'peer-2',
limitToSession: true,
representationOptions: {
searchQuery: 'preferences',
searchTopK: 10,
searchMaxDistance: 0.5,
includeMostFrequent: true,
maxConclusions: 25,
},
})
expect(result.summary).toBe(true)
expect(result.peerTarget).toBe('peer-1')
expect(result.representationOptions?.searchQuery).toBe('preferences')
expect(result.representationOptions?.searchTopK).toBe(10)
})
test('peerPerspective without peerTarget throws', () => {
expect(() =>
ContextParamsSchema.parse({ peerPerspective: 'peer-2' })
).toThrow(ZodError)
})
})
// =============================================================================
// RepresentationOptionsSchema
// =============================================================================
describe('RepresentationOptionsSchema', () => {
test('string searchQuery passes', () => {
const result = RepresentationOptionsSchema.parse({ searchQuery: 'hello' })
expect(result.searchQuery).toBe('hello')
})
test('content-like object searchQuery passes', () => {
const result = RepresentationOptionsSchema.parse({
searchQuery: {
id: 'msg-1',
content: 'hello world',
created_at: '2024-01-01T00:00:00Z',
peer_id: 'peer-1',
session_id: 'session-1',
token_count: 2,
workspace_id: 'ws-1',
metadata: {},
},
})
expect(result.searchQuery).toBeDefined()
})
test('whitespace-only string searchQuery throws', () => {
expect(() =>
RepresentationOptionsSchema.parse({ searchQuery: ' ' })
).toThrow(ZodError)
})
test('whitespace-only object content searchQuery throws', () => {
expect(() =>
RepresentationOptionsSchema.parse({
searchQuery: { content: ' ' },
})
).toThrow(ZodError)
})
test('invalid object searchQuery throws', () => {
expect(() =>
RepresentationOptionsSchema.parse({
searchQuery: { foo: 'bar' },
})
).toThrow(ZodError)
})
test('numeric searchQuery throws', () => {
expect(() =>
RepresentationOptionsSchema.parse({
searchQuery: 42,
})
).toThrow(ZodError)
})
test('unknown field throws (strict)', () => {
expect(() =>
RepresentationOptionsSchema.parse({
searchQuery: 'hello',
typo: true,
})
).toThrow(ZodError)
})
})
// =============================================================================
// FileUploadSchema
// =============================================================================
describe('FileUploadSchema', () => {
test('Blob upload passes', () => {
const result = FileUploadSchema.parse({
file: new Blob(['hello'], { type: 'text/plain' }),
peer: 'peer-1',
})
expect(result.file).toBeInstanceOf(Blob)
})
test('custom upload object passes', () => {
const result = FileUploadSchema.parse({
file: {
filename: 'test.txt',
content: new TextEncoder().encode('hello'),
content_type: 'text/plain',
},
peer: 'peer-1',
})
expect(result.file).toBeDefined()
})
test('custom upload object accepts Buffer content', () => {
const result = FileUploadSchema.parse({
file: {
filename: 'test.txt',
content: Buffer.from('hello'),
content_type: 'text/plain',
},
peer: 'peer-1',
})
expect(result.file).toBeDefined()
})
test('top-level Uint8Array throws', () => {
expect(() =>
FileUploadSchema.parse({
file: new Uint8Array([1, 2, 3]),
peer: 'peer-1',
})
).toThrow(ZodError)
})
test('top-level Buffer throws', () => {
expect(() =>
FileUploadSchema.parse({
file: Buffer.from('hello'),
peer: 'peer-1',
})
).toThrow(ZodError)
})
test('arbitrary object throws', () => {
expect(() =>
FileUploadSchema.parse({
file: { filename: 'test.txt' },
peer: 'peer-1',
})
).toThrow(ZodError)
})
})

View File

@ -5,12 +5,12 @@
"": {
"name": "@honcho-ai/sdk",
"dependencies": {
"@types/node": "^24.0.1",
"zod": "4.0.0",
},
"devDependencies": {
"@biomejs/biome": "^2.1.2",
"@types/bun": "latest",
"@types/node": "^24.0.1",
"typescript": "^5.0.0",
},
},

View File

@ -1,6 +1,6 @@
{
"name": "@honcho-ai/sdk",
"version": "2.0.1",
"version": "2.1.0",
"description": "Official DX Optimized TypeScript SDK for Honcho",
"author": "Plastic Labs <hello@plasticlabs.ai>",
"license": "Apache-2.0",
@ -18,12 +18,12 @@
"test": "echo 'Error: Tests must be run from the monorepo root via pytest. See tests/README.md' && exit 1"
},
"dependencies": {
"@types/node": "^24.0.1",
"zod": "4.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.1.2",
"@types/bun": "latest",
"@types/node": "^24.0.1",
"typescript": "^5.0.0"
}
}

View File

@ -21,6 +21,7 @@ import {
type HonchoConfig,
HonchoConfigSchema,
LimitSchema,
normalizeListOptions,
type PeerConfig,
PeerConfigSchema,
PeerIdSchema,
@ -279,13 +280,18 @@ export class Honcho {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}
): Promise<PageResponse<PeerResponse>> {
return this._http.post<PageResponse<PeerResponse>>(
`/${API_VERSION}/workspaces/${workspaceId}/peers/list`,
{
body: { filters: params?.filters },
query: { page: params?.page, size: params?.size },
query: {
page: params?.page,
size: params?.size,
reverse: params?.reverse ? 'true' : undefined,
},
}
)
}
@ -310,13 +316,18 @@ export class Honcho {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}
): Promise<PageResponse<SessionResponse>> {
return this._http.post<PageResponse<SessionResponse>>(
`/${API_VERSION}/workspaces/${workspaceId}/sessions/list`,
{
body: { filters: params?.filters },
query: { page: params?.page, size: params?.size },
query: {
page: params?.page,
size: params?.size,
reverse: params?.reverse ? 'true' : undefined,
},
}
)
}
@ -381,29 +392,19 @@ export class Honcho {
? PeerConfigSchema.parse(options.configuration)
: undefined
if (validatedConfiguration || validatedMetadata) {
const peerData = await this._getOrCreatePeer(this.workspaceId, {
id: validatedId,
configuration: peerConfigToApi(validatedConfiguration),
metadata: validatedMetadata,
})
return new Peer(
validatedId,
this.workspaceId,
this._http,
peerData.metadata ?? undefined,
peerConfigFromApi(peerData.configuration) ?? undefined,
() => this._ensureWorkspace()
)
}
const peerData = await this._getOrCreatePeer(this.workspaceId, {
id: validatedId,
configuration: peerConfigToApi(validatedConfiguration),
metadata: validatedMetadata,
})
return new Peer(
validatedId,
this.workspaceId,
this._http,
undefined,
undefined,
() => this._ensureWorkspace()
peerData.metadata ?? undefined,
peerConfigFromApi(peerData.configuration) ?? undefined,
() => this._ensureWorkspace(),
peerData.created_at
)
}
@ -413,14 +414,37 @@ export class Honcho {
* Makes an API call to retrieve all peers that have been created or used
* within the current workspace. Returns a paginated result.
*
* @param filters - Optional filter criteria for peers. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* @param options - Either a legacy raw filter object or an options object with
* `filters`, `page`, `size`, and `reverse`. See
* [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* @returns Promise resolving to a Page of Peer objects representing all peers in the workspace
*/
async peers(filters?: Filters): Promise<Page<Peer, PeerResponse>> {
async peers(
options?:
| Filters
| {
filters?: Filters
page?: number
size?: number
reverse?: boolean
}
): Promise<Page<Peer, PeerResponse>> {
await this._ensureWorkspace()
const validatedFilter = filters ? FilterSchema.parse(filters) : undefined
const normalizedOptions = normalizeListOptions(options, [
'filters',
'page',
'size',
'reverse',
])
const validatedFilter = normalizedOptions.filters
? FilterSchema.parse(normalizedOptions.filters)
: undefined
const reverse = normalizedOptions.reverse
const peersPage = await this._listPeers(this.workspaceId, {
filters: validatedFilter,
page: normalizedOptions.page,
size: normalizedOptions.size,
reverse,
})
const fetchNextPage = async (
@ -431,6 +455,7 @@ export class Honcho {
filters: validatedFilter,
page,
size,
reverse,
})
}
@ -443,7 +468,8 @@ export class Honcho {
this._http,
peer.metadata ?? undefined,
peerConfigFromApi(peer.configuration) ?? undefined,
() => this._ensureWorkspace()
() => this._ensureWorkspace(),
peer.created_at
),
fetchNextPage
)
@ -486,29 +512,20 @@ export class Honcho {
? SessionConfigSchema.parse(options.configuration)
: undefined
if (validatedConfiguration || validatedMetadata) {
const sessionData = await this._getOrCreateSession(this.workspaceId, {
id: validatedId,
configuration: validatedConfiguration,
metadata: validatedMetadata,
})
return new Session(
validatedId,
this.workspaceId,
this._http,
sessionData.metadata ?? undefined,
sessionConfigFromApi(sessionData.configuration) ?? undefined,
() => this._ensureWorkspace()
)
}
const sessionData = await this._getOrCreateSession(this.workspaceId, {
id: validatedId,
configuration: validatedConfiguration,
metadata: validatedMetadata,
})
return new Session(
validatedId,
this.workspaceId,
this._http,
undefined,
undefined,
() => this._ensureWorkspace()
sessionData.metadata ?? undefined,
sessionConfigFromApi(sessionData.configuration) ?? undefined,
() => this._ensureWorkspace(),
sessionData.created_at,
sessionData.is_active
)
}
@ -518,15 +535,38 @@ export class Honcho {
* Makes an API call to retrieve all sessions that have been created within
* the current workspace.
*
* @param filters - Optional filter criteria for sessions. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* @param options - Either a legacy raw filter object or an options object with
* `filters`, `page`, `size`, and `reverse`. See
* [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* @returns Promise resolving to a Page of Session objects representing all sessions
* in the workspace. Returns an empty page if no sessions exist
*/
async sessions(filters?: Filters): Promise<Page<Session, SessionResponse>> {
async sessions(
options?:
| Filters
| {
filters?: Filters
page?: number
size?: number
reverse?: boolean
}
): Promise<Page<Session, SessionResponse>> {
await this._ensureWorkspace()
const validatedFilter = filters ? FilterSchema.parse(filters) : undefined
const normalizedOptions = normalizeListOptions(options, [
'filters',
'page',
'size',
'reverse',
])
const validatedFilter = normalizedOptions.filters
? FilterSchema.parse(normalizedOptions.filters)
: undefined
const reverse = normalizedOptions.reverse
const sessionsPage = await this._listSessions(this.workspaceId, {
filters: validatedFilter,
page: normalizedOptions.page,
size: normalizedOptions.size,
reverse,
})
const fetchNextPage = async (
@ -537,6 +577,7 @@ export class Honcho {
filters: validatedFilter,
page,
size,
reverse,
})
}
@ -549,7 +590,9 @@ export class Honcho {
this._http,
session.metadata ?? undefined,
sessionConfigFromApi(session.configuration) ?? undefined,
() => this._ensureWorkspace()
() => this._ensureWorkspace(),
session.created_at,
session.is_active
),
fetchNextPage
)
@ -647,16 +690,33 @@ export class Honcho {
* Makes an API call to retrieve all workspace IDs that the authenticated
* user has access to.
*
* @param filters - Optional filter criteria for workspaces. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* @param options - Either a legacy raw filter object or an options object with
* `filters`, `page`, and `size`. See
* [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* @returns Promise resolving to a Page of workspace ID strings. Returns an empty
* page if no workspaces are accessible or none exist
*/
async workspaces(
filters?: Filters
options?:
| Filters
| {
filters?: Filters
page?: number
size?: number
}
): Promise<Page<string, WorkspaceResponse>> {
const validatedFilter = filters ? FilterSchema.parse(filters) : undefined
const normalizedOptions = normalizeListOptions(options, [
'filters',
'page',
'size',
])
const validatedFilter = normalizedOptions.filters
? FilterSchema.parse(normalizedOptions.filters)
: undefined
const workspacesPage = await this._listWorkspaces({
filters: validatedFilter,
page: normalizedOptions.page,
size: normalizedOptions.size,
})
const fetchNextPage = async (

View File

@ -8,6 +8,7 @@ import type {
RepresentationOptions,
RepresentationResponse,
} from './types/api'
import { normalizeSearchQuery, RepresentationOptionsSchema } from './validation'
/**
* Parameters for creating a conclusion.
@ -101,13 +102,18 @@ export class ConclusionScope {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}): Promise<PageResponse<ConclusionResponse>> {
await this._ensureWorkspace()
return this._http.post<PageResponse<ConclusionResponse>>(
`/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/list`,
{
body: { filters: params.filters },
query: { page: params.page, size: params.size },
query: {
page: params.page,
size: params.size,
reverse: params.reverse ? 'true' : undefined,
},
}
)
}
@ -182,6 +188,7 @@ export class ConclusionScope {
page?: number
size?: number
session?: string | Session
reverse?: boolean
}): Promise<Page<Conclusion, ConclusionResponse>> {
const resolvedSessionId = options?.session
? typeof options.session === 'string'
@ -195,18 +202,20 @@ export class ConclusionScope {
if (resolvedSessionId) {
filters.session_id = resolvedSessionId
}
const reverse = options?.reverse
const response = await this._list({
filters,
page: options?.page ?? 1,
size: options?.size ?? 50,
reverse,
})
const fetchNextPage = async (
page: number,
size: number
): Promise<PageResponse<ConclusionResponse>> => {
return this._list({ filters, page, size })
return this._list({ filters, page, size, reverse })
}
return new Page(
@ -277,13 +286,22 @@ export class ConclusionScope {
* Get the computed representation for this scope.
*/
async representation(options?: RepresentationOptions): Promise<string> {
const searchQuery = normalizeSearchQuery(options?.searchQuery)
const validatedOptions = RepresentationOptionsSchema.parse({
searchQuery,
searchTopK: options?.searchTopK,
searchMaxDistance: options?.searchMaxDistance,
includeMostFrequent: options?.includeMostFrequent,
maxConclusions: options?.maxConclusions,
})
const response = await this._getRepresentation(this.observer, {
target: this.observed,
search_query: options?.searchQuery,
search_top_k: options?.searchTopK,
search_max_distance: options?.searchMaxDistance,
include_most_frequent: options?.includeMostFrequent,
max_conclusions: options?.maxConclusions,
search_query: searchQuery,
search_top_k: validatedOptions.searchTopK,
search_max_distance: validatedOptions.searchMaxDistance,
include_most_frequent: validatedOptions.includeMostFrequent,
max_conclusions: validatedOptions.maxConclusions,
})
return response.representation
}

View File

@ -28,6 +28,8 @@ import {
MessageConfigurationSchema,
MessageContentSchema,
MessageMetadataSchema,
normalizeListOptions,
normalizeSearchQuery,
PeerCardContentSchema,
type PeerConfig,
PeerConfigSchema,
@ -35,7 +37,9 @@ import {
PeerMetadataSchema,
peerConfigFromApi,
peerConfigToApi,
RepresentationOptionsSchema,
SearchQuerySchema,
sessionConfigFromApi,
} from './validation'
/**
@ -120,6 +124,7 @@ export class Peer {
* Private cached configuration for this peer.
*/
private _configuration?: PeerConfig
private _createdAt?: string
/**
* Cached metadata for this peer. May be stale if the peer
@ -143,6 +148,13 @@ export class Peer {
return this._configuration
}
/**
* Timestamp when this peer was created. Only available if fetched from the API.
*/
get createdAt(): string | undefined {
return this._createdAt
}
/**
* Initialize a new Peer. **Do not call this directly, use the client.peer() method instead.**
*
@ -158,7 +170,8 @@ export class Peer {
http: HonchoHTTPClient,
metadata?: Record<string, unknown>,
configuration?: PeerConfig,
ensureWorkspace: () => Promise<void> = async () => undefined
ensureWorkspace: () => Promise<void> = async () => undefined,
createdAt?: string
) {
this.id = id
this.workspaceId = workspaceId
@ -166,6 +179,7 @@ export class Peer {
this._metadata = metadata
this._configuration = configuration
this._ensureWorkspace = ensureWorkspace
this._createdAt = createdAt
}
// ===========================================================================
@ -199,13 +213,18 @@ export class Peer {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}): Promise<PageResponse<SessionResponse>> {
await this._ensureWorkspace()
return this._http.post<PageResponse<SessionResponse>>(
`/${API_VERSION}/workspaces/${this.workspaceId}/peers/${this.id}/sessions`,
{
body: { filters: params?.filters },
query: { page: params?.page, size: params?.size },
query: {
page: params?.page,
size: params?.size,
reverse: params?.reverse ? 'true' : undefined,
},
}
)
}
@ -459,19 +478,49 @@ export class Peer {
* Makes an API call to retrieve all sessions where this peer is an active participant.
* Sessions are created when peers are added to them or send messages to them.
*
* @param filters - Optional filter criteria for sessions. See [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* @param options - Either a legacy raw filter object or an options object with
* `filters`, `page`, `size`, and `reverse`. See
* [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* @returns Promise resolving to a paginated list of Session objects this peer belongs to.
* Returns an empty list if the peer is not a member of any sessions
*/
async sessions(filters?: Filters): Promise<Page<Session, SessionResponse>> {
const validatedFilter = filters ? FilterSchema.parse(filters) : undefined
const sessionsPage = await this._listSessions({ filters: validatedFilter })
async sessions(
options?:
| Filters
| {
filters?: Filters
page?: number
size?: number
reverse?: boolean
}
): Promise<Page<Session, SessionResponse>> {
const normalizedOptions = normalizeListOptions(options, [
'filters',
'page',
'size',
'reverse',
])
const validatedFilter = normalizedOptions.filters
? FilterSchema.parse(normalizedOptions.filters)
: undefined
const reverse = normalizedOptions.reverse
const sessionsPage = await this._listSessions({
filters: validatedFilter,
page: normalizedOptions.page,
size: normalizedOptions.size,
reverse,
})
const fetchNextPage = async (
page: number,
size: number
): Promise<PageResponse<SessionResponse>> => {
return this._listSessions({ filters: validatedFilter, page, size })
return this._listSessions({
filters: validatedFilter,
page,
size,
reverse,
})
}
return new Page(
@ -482,7 +531,10 @@ export class Peer {
this.workspaceId,
this._http,
session.metadata ?? undefined,
session.configuration ?? undefined
sessionConfigFromApi(session.configuration) ?? undefined,
() => this._ensureWorkspace(),
session.created_at,
session.is_active
),
fetchNextPage
)
@ -559,6 +611,7 @@ export class Peer {
async getMetadata(): Promise<Record<string, unknown>> {
const peer = await this._getOrCreate({ id: this.id })
this._metadata = peer.metadata || {}
this._createdAt = peer.created_at
return this._metadata
}
@ -590,6 +643,7 @@ export class Peer {
async getConfiguration(): Promise<PeerConfig> {
const peer = await this._getOrCreate({ id: this.id })
this._configuration = peerConfigFromApi(peer.configuration) || {}
this._createdAt = peer.created_at
return this._configuration
}
@ -621,6 +675,7 @@ export class Peer {
const peer = await this._getOrCreate({ id: this.id })
this._metadata = peer.metadata || {}
this._configuration = peerConfigFromApi(peer.configuration) || {}
this._createdAt = peer.created_at
}
/**
@ -741,17 +796,18 @@ export class Peer {
async representation(options?: {
session?: string | Session
target?: string | Peer
searchQuery?: string
searchQuery?: string | Message
searchTopK?: number
searchMaxDistance?: number
includeMostFrequent?: boolean
maxConclusions?: number
}): Promise<string> {
const searchQuery = normalizeSearchQuery(options?.searchQuery)
const getRepresentationParams = PeerGetRepresentationParamsSchema.parse({
session: options?.session,
target: options?.target,
options: {
searchQuery: options?.searchQuery,
searchQuery,
searchTopK: options?.searchTopK,
searchMaxDistance: options?.searchMaxDistance,
includeMostFrequent: options?.includeMostFrequent,
@ -772,7 +828,7 @@ export class Peer {
const response = await this._getRepresentation({
session_id: sessionId,
target: targetId,
search_query: getRepresentationParams.options?.searchQuery,
search_query: searchQuery,
search_top_k: getRepresentationParams.options?.searchTopK,
search_max_distance: getRepresentationParams.options?.searchMaxDistance,
include_most_frequent:
@ -827,14 +883,25 @@ export class Peer {
? options.target
: options.target.id
: undefined
const searchQuery =
options?.searchQuery === undefined
? undefined
: SearchQuerySchema.parse(options.searchQuery)
const validatedOptions = RepresentationOptionsSchema.parse({
searchQuery,
searchTopK: options?.searchTopK,
searchMaxDistance: options?.searchMaxDistance,
includeMostFrequent: options?.includeMostFrequent,
maxConclusions: options?.maxConclusions,
})
const response = await this._getContext({
target: targetId,
search_query: options?.searchQuery,
search_top_k: options?.searchTopK,
search_max_distance: options?.searchMaxDistance,
include_most_frequent: options?.includeMostFrequent,
max_conclusions: options?.maxConclusions,
search_query: searchQuery,
search_top_k: validatedOptions.searchTopK,
search_max_distance: validatedOptions.searchMaxDistance,
include_most_frequent: validatedOptions.includeMostFrequent,
max_conclusions: validatedOptions.maxConclusions,
})
return PeerContext.fromApiResponse(response)

View File

@ -28,10 +28,14 @@ import {
type MessageAddition,
MessageAdditionToApiSchema,
MessageMetadataSchema,
messageConfigToApi,
normalizeListOptions,
normalizeSearchQuery,
type PeerAddition,
PeerAdditionToApiSchema,
type PeerRemoval,
PeerRemovalSchema,
peerConfigFromApi,
type QueueStatusOptions,
SearchQuerySchema,
type SessionConfig,
@ -79,6 +83,8 @@ export class Session {
private _http: HonchoHTTPClient
private _metadata?: Record<string, unknown>
private _configuration?: SessionConfig
private _createdAt?: string
private _isActive?: boolean
private _ensureWorkspace: () => Promise<void>
/**
@ -103,6 +109,20 @@ export class Session {
return this._configuration
}
/**
* Timestamp when this session was created. Only available if fetched from the API.
*/
get createdAt(): string | undefined {
return this._createdAt
}
/**
* Whether this session is active. Only available if fetched from the API.
*/
get isActive(): boolean | undefined {
return this._isActive
}
/**
* Initialize a new Session. **Do not call this directly, use the client.session() method instead.**
*
@ -118,7 +138,9 @@ export class Session {
http: HonchoHTTPClient,
metadata?: Record<string, unknown>,
configuration?: SessionConfig,
ensureWorkspace: () => Promise<void> = async () => undefined
ensureWorkspace: () => Promise<void> = async () => undefined,
createdAt?: string,
isActive?: boolean
) {
this.id = id
this.workspaceId = workspaceId
@ -126,6 +148,15 @@ export class Session {
this._metadata = metadata
this._configuration = configuration
this._ensureWorkspace = ensureWorkspace
this._createdAt = createdAt
this._isActive = isActive
}
private _applySessionResponse(session: SessionResponse): void {
this._metadata = session.metadata || {}
this._configuration = sessionConfigFromApi(session.configuration) || {}
this._createdAt = session.created_at
this._isActive = session.is_active
}
// ===========================================================================
@ -305,13 +336,18 @@ export class Session {
filters?: Record<string, unknown>
page?: number
size?: number
reverse?: boolean
}): Promise<PageResponse<MessageResponse>> {
await this._ensureWorkspace()
return this._http.post<PageResponse<MessageResponse>>(
`/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/messages/list`,
{
body: { filters: params?.filters },
query: { page: params?.page, size: params?.size },
query: {
page: params?.page,
size: params?.size,
reverse: params?.reverse ? 'true' : undefined,
},
}
)
}
@ -358,6 +394,13 @@ export class Session {
)
}
private async _getMessage(messageId: string): Promise<MessageResponse> {
await this._ensureWorkspace()
return this._http.get<MessageResponse>(
`/${API_VERSION}/workspaces/${this.workspaceId}/sessions/${this.id}/messages/${messageId}`
)
}
private async _updateMessage(
messageId: string,
params: { metadata: Record<string, unknown> }
@ -450,9 +493,10 @@ export class Session {
peer.id,
this.workspaceId,
this._http,
undefined,
undefined,
() => this._ensureWorkspace()
peer.metadata ?? undefined,
peerConfigFromApi(peer.configuration) ?? undefined,
() => this._ensureWorkspace(),
peer.created_at
)
)
}
@ -539,19 +583,48 @@ export class Session {
*
* Makes an API call to retrieve messages in the session, with optional filtering.
*
* @param filters - Optional filter criteria for messages. See
* @param options - Either a legacy raw filter object or an options object with
* `filters`, `page`, `size`, and `reverse`. See
* [search filters documentation](https://docs.honcho.dev/v3/documentation/core-concepts/features/using-filters).
* @returns Promise resolving to a paginated Page of Message objects
*/
async messages(filters?: Filters): Promise<Page<Message, MessageResponse>> {
const validatedFilter = filters ? FilterSchema.parse(filters) : undefined
const messagesPage = await this._listMessages({ filters: validatedFilter })
async messages(
options?:
| Filters
| {
filters?: Filters
page?: number
size?: number
reverse?: boolean
}
): Promise<Page<Message, MessageResponse>> {
const normalizedOptions = normalizeListOptions(options, [
'filters',
'page',
'size',
'reverse',
])
const validatedFilter = normalizedOptions.filters
? FilterSchema.parse(normalizedOptions.filters)
: undefined
const reverse = normalizedOptions.reverse
const messagesPage = await this._listMessages({
filters: validatedFilter,
page: normalizedOptions.page,
size: normalizedOptions.size,
reverse,
})
const fetchNextPage = async (
page: number,
size: number
): Promise<PageResponse<MessageResponse>> => {
return this._listMessages({ filters: validatedFilter, page, size })
return this._listMessages({
filters: validatedFilter,
page,
size,
reverse,
})
}
return new Page(messagesPage, Message.fromApiResponse, fetchNextPage)
@ -568,8 +641,8 @@ export class Session {
*/
async getMetadata(): Promise<Record<string, unknown>> {
const session = await this._getOrCreate({ id: this.id })
this._metadata = session.metadata || {}
return this._metadata
this._applySessionResponse(session)
return this._metadata ?? {}
}
/**
@ -599,8 +672,8 @@ export class Session {
*/
async getConfiguration(): Promise<SessionConfig> {
const session = await this._getOrCreate({ id: this.id })
this._configuration = sessionConfigFromApi(session.configuration) || {}
return this._configuration
this._applySessionResponse(session)
return this._configuration ?? {}
}
/**
@ -627,8 +700,7 @@ export class Session {
*/
async refresh(): Promise<void> {
const session = await this._getOrCreate({ id: this.id })
this._metadata = session.metadata || {}
this._configuration = sessionConfigFromApi(session.configuration) || {}
this._applySessionResponse(session)
}
/**
@ -662,7 +734,9 @@ export class Session {
this._http,
clonedSessionData.metadata ?? undefined,
sessionConfigFromApi(clonedSessionData.configuration) ?? undefined,
() => this._ensureWorkspace()
() => this._ensureWorkspace(),
clonedSessionData.created_at,
clonedSessionData.is_active
)
}
@ -677,10 +751,9 @@ export class Session {
* @param options.summary - Whether to include a summary of earlier messages
* @param options.tokens - Target token count for the context window
* @param options.peerTarget - The peer to get representation for
* @param options.lastUserMessage - Message text (string) or Message object whose content will be used for semantic search
* @param options.peerPerspective - The peer whose perspective to use for representation
* @param options.limitToSession - Whether to limit representation to this session only
* @param options.representationOptions - Options for representation retrieval
* @param options.representationOptions - Options for representation retrieval (searchQuery, searchTopK, etc.)
* @returns Promise resolving to a SessionContext with messages, summary, and representation
*
* @example
@ -699,7 +772,6 @@ export class Session {
summary?: boolean
tokens?: number
peerTarget?: string | Peer
searchQuery?: string | Message
peerPerspective?: string | Peer
limitToSession?: boolean
representationOptions?: RepresentationOptions
@ -713,30 +785,29 @@ export class Session {
typeof opts.peerPerspective === 'object'
? opts.peerPerspective.id
: opts.peerPerspective
const searchQueryText =
typeof opts.searchQuery === 'string'
? opts.searchQuery
: opts.searchQuery?.content
const searchQuery = normalizeSearchQuery(
opts.representationOptions?.searchQuery
)
const contextParams = ContextParamsSchema.parse({
summary: opts.summary,
tokens: opts.tokens,
peerTarget: peerTargetId,
searchQuery: searchQueryText,
peerPerspective: peerPerspectiveId,
limitToSession: opts.limitToSession,
representationOptions: opts.representationOptions,
representationOptions: opts.representationOptions
? {
...opts.representationOptions,
searchQuery,
}
: undefined,
})
const searchQueryParsed =
typeof contextParams.searchQuery === 'string'
? contextParams.searchQuery
: contextParams.searchQuery?.content
const context = await this._getContext({
tokens: contextParams.tokens,
summary: contextParams.summary,
search_query: searchQueryParsed,
search_query: searchQuery,
peer_target: contextParams.peerTarget,
peer_perspective: contextParams.peerPerspective,
limit_to_session: contextParams.limitToSession,
@ -895,14 +966,15 @@ export class Session {
})
const formData = new FormData()
const uploadFile = uploadParams.file
if (file instanceof File || file instanceof Blob) {
formData.append('file', file)
if (uploadFile instanceof Blob) {
formData.append('file', uploadFile)
} else {
// Convert to Uint8Array for Blob compatibility
const content = new Uint8Array(file.content)
const blob = new Blob([content], { type: file.content_type })
formData.append('file', blob, file.filename)
const content = new Uint8Array(uploadFile.content)
const blob = new Blob([content], { type: uploadFile.content_type })
formData.append('file', blob, uploadFile.filename)
}
formData.append('peer_id', resolvedPeerId)
@ -913,10 +985,8 @@ export class Session {
uploadParams.configuration !== undefined &&
uploadParams.configuration !== null
) {
formData.append(
'configuration',
JSON.stringify(uploadParams.configuration)
)
const apiConfiguration = messageConfigToApi(uploadParams.configuration)
formData.append('configuration', JSON.stringify(apiConfiguration))
}
if (
uploadParams.createdAt !== undefined &&
@ -949,18 +1019,19 @@ export class Session {
peer: string | Peer,
options?: {
target?: string | Peer
searchQuery?: string
searchQuery?: string | Message
searchTopK?: number
searchMaxDistance?: number
includeMostFrequent?: boolean
maxConclusions?: number
}
): Promise<string> {
const searchQuery = normalizeSearchQuery(options?.searchQuery)
const getRepresentationParams = GetRepresentationParamsSchema.parse({
peer,
target: options?.target,
options: {
searchQuery: options?.searchQuery,
searchQuery,
searchTopK: options?.searchTopK,
searchMaxDistance: options?.searchMaxDistance,
includeMostFrequent: options?.includeMostFrequent,
@ -980,7 +1051,7 @@ export class Session {
const response = await this._getRepresentation(peerId, {
session_id: this.id,
target: targetId,
search_query: getRepresentationParams.options?.searchQuery,
search_query: searchQuery,
search_top_k: getRepresentationParams.options?.searchTopK,
search_max_distance: getRepresentationParams.options?.searchMaxDistance,
include_most_frequent:
@ -990,6 +1061,17 @@ export class Session {
return response.representation
}
/**
* Get a single message by ID from this session.
*
* @param messageId - The ID of the message to retrieve
* @returns Promise resolving to the Message object
*/
async getMessage(messageId: string): Promise<Message> {
const response = await this._getMessage(messageId)
return Message.fromApiResponse(response)
}
/**
* Update the metadata of a message in this session.
*

View File

@ -285,8 +285,9 @@ export interface RepresentationResponse {
export interface RepresentationOptions {
/**
* Semantic search query to filter relevant conclusions.
* Accepts a string or any object with a `content` property (e.g., a Message).
*/
searchQuery?: string
searchQuery?: string | { content: string }
/**
* Number of semantically relevant conclusions to return.

View File

@ -1,5 +1,4 @@
import { z } from 'zod'
import type { MessageResponse } from './types/api'
/**
* Validation schemas for the Honcho TypeScript SDK.
@ -23,23 +22,28 @@ export const WorkspaceIdSchema = z
/**
* Schema for Honcho client configuration options.
*/
export const HonchoConfigSchema = z.object({
apiKey: z.string().optional(),
environment: z.enum(['local', 'production']).optional(),
baseURL: z.url('Base URL must be a valid URL').optional(),
workspaceId: WorkspaceIdSchema.optional(),
timeout: z.number().positive('Timeout must be a positive number').optional(),
maxRetries: z
.number()
.int()
.min(0, 'Max retries must be a non-negative integer')
.max(3, 'Max retries must be at most 3')
.optional(),
defaultHeaders: z.record(z.string(), z.string()).optional(),
defaultQuery: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
.optional(),
})
export const HonchoConfigSchema = z
.object({
apiKey: z.string().optional(),
environment: z.enum(['local', 'production']).optional(),
baseURL: z.url('Base URL must be a valid URL').optional(),
workspaceId: WorkspaceIdSchema.optional(),
timeout: z
.number()
.positive('Timeout must be a positive number')
.optional(),
maxRetries: z
.number()
.int()
.min(0, 'Max retries must be a non-negative integer')
.max(3, 'Max retries must be at most 3')
.optional(),
defaultHeaders: z.record(z.string(), z.string()).optional(),
defaultQuery: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
.optional(),
})
.strict()
/**
* Schema for peer metadata.
@ -49,9 +53,11 @@ export const PeerMetadataSchema = z.record(z.string(), z.unknown())
/**
* Schema for peer configuration.
*/
export const PeerConfigSchema = z.object({
observeMe: z.boolean().nullable().optional(),
})
export const PeerConfigSchema = z
.object({
observeMe: z.boolean().nullable().optional(),
})
.strict()
/**
* Schema for peer ID validation.
@ -65,6 +71,11 @@ export const PeerIdSchema = z
)
.max(100, 'Peer ID can be at most 100 characters')
/**
* Strict helper: peer ID as object.
*/
const PeerIdObjectSchema = z.object({ id: PeerIdSchema })
/**
* Schema for session metadata.
*/
@ -78,48 +89,58 @@ export const SessionMetadataSchema = z.record(z.string(), z.unknown())
* Schema for reasoning configuration.
* Used in workspace, session, and message configuration.
*/
export const ReasoningConfigSchema = z.object({
enabled: z.boolean().nullable().optional(),
customInstructions: z.string().nullable().optional(),
})
export const ReasoningConfigSchema = z
.object({
enabled: z.boolean().nullable().optional(),
customInstructions: z.string().nullable().optional(),
})
.strict()
/**
* Schema for peer card configuration.
* Used in workspace and session configuration.
*/
export const PeerCardConfigSchema = z.object({
use: z.boolean().nullable().optional(),
create: z.boolean().nullable().optional(),
})
export const PeerCardConfigSchema = z
.object({
use: z.boolean().nullable().optional(),
create: z.boolean().nullable().optional(),
})
.strict()
/**
* Schema for summary configuration.
* Used in workspace and session configuration.
*/
export const SummaryConfigSchema = z.object({
enabled: z.boolean().nullable().optional(),
messagesPerShortSummary: z.number().int().min(10).nullable().optional(),
messagesPerLongSummary: z.number().int().min(20).nullable().optional(),
})
export const SummaryConfigSchema = z
.object({
enabled: z.boolean().nullable().optional(),
messagesPerShortSummary: z.number().int().min(10).nullable().optional(),
messagesPerLongSummary: z.number().int().min(20).nullable().optional(),
})
.strict()
/**
* Schema for dream configuration.
* Used in workspace and session configuration.
*/
export const DreamConfigSchema = z.object({
enabled: z.boolean().nullable().optional(),
})
export const DreamConfigSchema = z
.object({
enabled: z.boolean().nullable().optional(),
})
.strict()
/**
* Schema for session configuration.
* Includes reasoning, peer card, summary, and dream settings.
*/
export const SessionConfigSchema = z.object({
reasoning: ReasoningConfigSchema.nullable().optional(),
peerCard: PeerCardConfigSchema.nullable().optional(),
summary: SummaryConfigSchema.nullable().optional(),
dream: DreamConfigSchema.nullable().optional(),
})
export const SessionConfigSchema = z
.object({
reasoning: ReasoningConfigSchema.nullable().optional(),
peerCard: PeerCardConfigSchema.nullable().optional(),
summary: SummaryConfigSchema.nullable().optional(),
dream: DreamConfigSchema.nullable().optional(),
})
.strict()
/**
* Schema for session ID validation.
@ -133,13 +154,20 @@ export const SessionIdSchema = z
)
.max(100, 'Session ID can be at most 100 characters')
/**
* Strict helper: session ID as object.
*/
const SessionIdObjectSchema = z.object({ id: SessionIdSchema })
/**
* Schema for session peer configuration.
*/
export const SessionPeerConfigSchema = z.object({
observeMe: z.boolean().nullable().optional(),
observeOthers: z.boolean().nullable().optional(),
})
export const SessionPeerConfigSchema = z
.object({
observeMe: z.boolean().nullable().optional(),
observeOthers: z.boolean().nullable().optional(),
})
.strict()
/**
* Schema for message content.
@ -166,19 +194,22 @@ export const MessageConfigurationSchema = z
.object({
reasoning: ReasoningConfigSchema.nullable().optional(),
})
.strict()
.nullable()
.optional()
/**
* Schema for message input.
*/
export const MessageInputSchema = z.object({
peerId: PeerIdSchema,
content: MessageContentSchema,
metadata: MessageMetadataSchema,
configuration: MessageConfigurationSchema,
createdAt: z.string().nullable().optional(),
})
export const MessageInputSchema = z
.object({
peerId: PeerIdSchema,
content: MessageContentSchema,
metadata: MessageMetadataSchema,
configuration: MessageConfigurationSchema,
createdAt: z.string().nullable().optional(),
})
.strict()
/**
* Schema for search query validation.
@ -191,78 +222,125 @@ export const SearchQuerySchema = z
'Search query cannot be only whitespace'
)
/**
* Schema for content-like search query objects.
* Accepts SDK Message instances and other objects with a valid content field.
*/
export const SearchQueryObjectSchema = z
.object({
content: SearchQuerySchema,
})
.passthrough()
/**
* Schema for search query inputs that can be normalized to a string.
*/
export const SearchQueryLikeSchema = z.union([
SearchQuerySchema,
SearchQueryObjectSchema,
])
/**
* Normalize a supported search query input to plain text.
*/
export function normalizeSearchQuery(searchQuery: unknown): string | undefined {
if (searchQuery === undefined) {
return undefined
}
const validatedSearchQuery = SearchQueryLikeSchema.parse(searchQuery)
return typeof validatedSearchQuery === 'string'
? validatedSearchQuery
: validatedSearchQuery.content
}
/**
* Schema for filter objects.
*/
export const FilterSchema = z.record(z.string(), z.unknown()).optional()
/**
* Schema for chat query parameters.
* Normalize list-method input so legacy raw filters and the new options object
* shape are both accepted.
*
* Discriminates on the `filters` key: if the input has a `filters` property or
* any of the pagination-only keys (`page`, `size`, `reverse`) it is treated as
* the new options object. Otherwise it is treated as a legacy raw filter.
*/
export const ChatQuerySchema = z.object({
query: SearchQuerySchema,
target: z
.union([PeerIdSchema, z.object({ id: PeerIdSchema })])
.optional()
.transform((val) =>
val ? (typeof val === 'string' ? val : val.id) : undefined
),
session: z
.union([SessionIdSchema, z.object({ id: SessionIdSchema })])
.optional()
.transform((val) =>
val ? (typeof val === 'string' ? val : val.id) : undefined
),
reasoningLevel: z
.enum(['minimal', 'low', 'medium', 'high', 'max'])
.optional(),
})
export function normalizeListOptions<T extends { filters?: Filters }>(
input: Filters | T | undefined,
optionKeys: string[]
): T {
if (input === undefined) {
return {} as T
}
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
return { filters: input as Filters } as T
}
// Pagination-only keys can never appear in a raw filter object
const paginationKeys = optionKeys.filter((k) => k !== 'filters')
const hasFiltersKey = 'filters' in input
const hasPaginationKey = paginationKeys.some((key) => key in input)
if (hasFiltersKey || hasPaginationKey) {
return input as T
}
return { filters: input as Filters } as T
}
/**
* Schema for validating Message API responses (snake_case).
* Schema for chat query parameters.
*/
const MessageResponseSchema: z.ZodType<MessageResponse> = z.object({
id: z.string(),
content: z.string(),
created_at: z.string(),
peer_id: PeerIdSchema,
session_id: SessionIdSchema,
token_count: z.number(),
workspace_id: WorkspaceIdSchema,
metadata: z.record(z.string(), z.unknown()),
}) as z.ZodType<MessageResponse>
export const ChatQuerySchema = z
.object({
query: SearchQuerySchema,
target: z
.union([PeerIdSchema, PeerIdObjectSchema])
.optional()
.transform((val) =>
val ? (typeof val === 'string' ? val : val.id) : undefined
),
session: z
.union([SessionIdSchema, SessionIdObjectSchema])
.optional()
.transform((val) =>
val ? (typeof val === 'string' ? val : val.id) : undefined
),
reasoningLevel: z
.enum(['minimal', 'low', 'medium', 'high', 'max'])
.optional(),
})
.strict()
/**
* Schema for representation options.
*/
export const RepresentationOptionsSchema = z.object({
searchQuery: z
.string()
.min(1, 'searchQuery must be a non-empty string')
.refine(
(query: string) => query.trim().length > 0,
'searchQuery cannot be only whitespace'
)
.optional(),
searchTopK: z
.number()
.int()
.min(1, 'searchTopK must be at least 1')
.max(100, 'searchTopK must be at most 100')
.optional(),
searchMaxDistance: z
.number()
.min(0.0, 'searchMaxDistance must be at least 0.0')
.max(1.0, 'searchMaxDistance must be at most 1.0')
.optional(),
includeMostFrequent: z.boolean().optional(),
maxConclusions: z
.number()
.int()
.min(1, 'maxConclusions must be at least 1')
.max(100, 'maxConclusions must be at most 100')
.optional(),
})
export const RepresentationOptionsSchema = z
.object({
searchQuery: SearchQueryLikeSchema.optional(),
searchTopK: z
.number()
.int()
.min(1, 'searchTopK must be at least 1')
.max(100, 'searchTopK must be at most 100')
.optional(),
searchMaxDistance: z
.number()
.min(0.0, 'searchMaxDistance must be at least 0.0')
.max(1.0, 'searchMaxDistance must be at most 1.0')
.optional(),
includeMostFrequent: z.boolean().optional(),
maxConclusions: z
.number()
.int()
.min(1, 'maxConclusions must be at least 1')
.max(100, 'maxConclusions must be at most 100')
.optional(),
})
.strict()
/**
* Schema for context retrieval parameters.
@ -271,23 +349,18 @@ export const ContextParamsSchema = z
.object({
summary: z.boolean().optional(),
tokens: z.int('Token limit must be an integer').optional(),
searchQuery: z
.union([
z.string().min(1, 'Search query must be a non-empty string'),
MessageResponseSchema,
])
.optional(),
peerTarget: PeerIdSchema.optional(),
peerPerspective: PeerIdSchema.optional(),
limitToSession: z.boolean().optional(),
representationOptions: RepresentationOptionsSchema.optional(),
})
.strict()
.superRefine((data, ctx) => {
if (data.searchQuery && !data.peerTarget) {
if (data.representationOptions?.searchQuery && !data.peerTarget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'peerTarget is required when searchQuery is provided',
path: ['searchQuery'],
path: ['representationOptions', 'searchQuery'],
})
}
@ -303,74 +376,72 @@ export const ContextParamsSchema = z
/**
* Schema for deriver status options.
*/
export const QueueStatusOptionsSchema = z.object({
observer: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(),
sender: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(),
session: z
.union([SessionIdSchema, z.object({ id: SessionIdSchema })])
.optional(),
timeout: z.number().positive('Timeout must be a positive number').optional(),
})
export const QueueStatusOptionsSchema = z
.object({
observer: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(),
sender: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(),
session: z.union([SessionIdSchema, SessionIdObjectSchema]).optional(),
timeout: z
.number()
.positive('Timeout must be a positive number')
.optional(),
})
.strict()
/**
* Schema for file upload parameters.
* Supports File objects (browser), Buffer, Uint8Array, and custom uploadable objects.
* Supports Blob/File objects and custom uploadable objects with binary content.
*/
export const FileUploadSchema = z.object({
file: z.union([
// Browser File object
z.instanceof(File),
// Node.js Buffer
z.instanceof(Buffer),
// Uint8Array
z.instanceof(Uint8Array),
// Custom uploadable object with filename, content, and content_type
z.object({
filename: z.string().min(1, 'Filename must be a non-empty string'),
content: z.union([z.instanceof(Buffer), z.instanceof(Uint8Array)]),
content_type: z
.string()
.min(1, 'Content type must be a non-empty string'),
}),
// Fallback for any other uploadable type
z
.any()
.refine(
(val) => val !== null && val !== undefined,
'File must not be null or undefined'
),
]),
peer: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]),
metadata: MessageMetadataSchema,
configuration: z.record(z.string(), z.unknown()).optional(),
createdAt: z.string().nullable().optional(),
})
export const FileUploadSchema = z
.object({
file: z.union([
// Browser/File API objects
z.instanceof(Blob),
// Custom uploadable object with filename, content, and content_type
z
.object({
filename: z.string().min(1, 'Filename must be a non-empty string'),
content: z.instanceof(Uint8Array),
content_type: z
.string()
.min(1, 'Content type must be a non-empty string'),
})
.strict(),
]),
peer: z.union([PeerIdSchema, PeerIdObjectSchema]),
metadata: MessageMetadataSchema,
configuration: MessageConfigurationSchema,
createdAt: z.string().nullable().optional(),
})
.strict()
/**
* Schema for get representation parameters.
*/
export const GetRepresentationParamsSchema = z.object({
peer: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]),
target: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(),
options: RepresentationOptionsSchema.optional(),
})
export const GetRepresentationParamsSchema = z
.object({
peer: z.union([PeerIdSchema, PeerIdObjectSchema]),
target: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(),
options: RepresentationOptionsSchema.optional(),
})
.strict()
/**
* Schema for peer get representation parameters.
*/
export const PeerGetRepresentationParamsSchema = z.object({
session: z
.union([SessionIdSchema, z.object({ id: SessionIdSchema })])
.optional(),
target: z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]).optional(),
options: RepresentationOptionsSchema.optional(),
})
export const PeerGetRepresentationParamsSchema = z
.object({
session: z.union([SessionIdSchema, SessionIdObjectSchema]).optional(),
target: z.union([PeerIdSchema, PeerIdObjectSchema]).optional(),
options: RepresentationOptionsSchema.optional(),
})
.strict()
/**
* Schema for peer card target parameter.
*/
export const CardTargetSchema = z
.union([PeerIdSchema, z.object({ id: PeerIdSchema })])
.union([PeerIdSchema, PeerIdObjectSchema])
.optional()
.transform((val) =>
val ? (typeof val === 'string' ? val : val.id) : undefined
@ -386,19 +457,19 @@ export const PeerCardContentSchema = z.array(z.string())
*/
export const PeerAdditionSchema = z.union([
PeerIdSchema,
z.object({ id: PeerIdSchema }),
PeerIdObjectSchema,
z.array(
z.union([
PeerIdSchema,
z.object({ id: PeerIdSchema }),
PeerIdObjectSchema,
z.tuple([
z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]),
z.union([PeerIdSchema, PeerIdObjectSchema]),
SessionPeerConfigSchema,
]),
])
),
z.tuple([
z.union([PeerIdSchema, z.object({ id: PeerIdSchema })]),
z.union([PeerIdSchema, PeerIdObjectSchema]),
SessionPeerConfigSchema,
]),
])
@ -798,8 +869,8 @@ export const PeerAdditionToApiSchema = PeerAdditionSchema.transform(
*/
export const PeerRemovalSchema = z.union([
PeerIdSchema,
z.object({ id: PeerIdSchema }),
z.array(z.union([PeerIdSchema, z.object({ id: PeerIdSchema })])),
PeerIdObjectSchema,
z.array(z.union([PeerIdSchema, PeerIdObjectSchema])),
])
/**
@ -835,12 +906,14 @@ export const WorkspaceMetadataSchema = z.record(z.string(), z.unknown())
* Schema for workspace configuration.
* Includes reasoning, peer card, summary, and dream settings.
*/
export const WorkspaceConfigSchema = z.object({
reasoning: ReasoningConfigSchema.nullable().optional(),
peerCard: PeerCardConfigSchema.nullable().optional(),
summary: SummaryConfigSchema.nullable().optional(),
dream: DreamConfigSchema.nullable().optional(),
})
export const WorkspaceConfigSchema = z
.object({
reasoning: ReasoningConfigSchema.nullable().optional(),
peerCard: PeerCardConfigSchema.nullable().optional(),
summary: SummaryConfigSchema.nullable().optional(),
dream: DreamConfigSchema.nullable().optional(),
})
.strict()
/**
* Schema for limit.
@ -854,21 +927,23 @@ export const LimitSchema = z
/**
* Schema for conclusion query parameters.
*/
export const ConclusionQueryParamsSchema = z.object({
query: SearchQuerySchema,
top_k: z
.number()
.int()
.min(1, 'top_k must be at least 1')
.max(100, 'top_k must be at most 100')
.optional(),
distance: z
.number()
.min(0.0, 'distance must be at least 0.0')
.max(1.0, 'distance must be at most 1.0')
.optional(),
filters: FilterSchema,
})
export const ConclusionQueryParamsSchema = z
.object({
query: SearchQuerySchema,
top_k: z
.number()
.int()
.min(1, 'top_k must be at least 1')
.max(100, 'top_k must be at most 100')
.optional(),
distance: z
.number()
.min(0.0, 'distance must be at least 0.0')
.max(1.0, 'distance must be at most 1.0')
.optional(),
filters: FilterSchema,
})
.strict()
/**
* Type exports for use throughout the SDK.
@ -883,6 +958,7 @@ export type MessageInput = z.infer<typeof MessageInputSchema>
export type Filters = z.infer<typeof FilterSchema>
export type ChatQuery = z.infer<typeof ChatQuerySchema>
export type ContextParams = z.infer<typeof ContextParamsSchema>
export type SearchQueryLike = z.infer<typeof SearchQueryLikeSchema>
export type QueueStatusOptions = z.infer<typeof QueueStatusOptionsSchema>
export type FileUpload = z.infer<typeof FileUploadSchema>
export type GetRepresentationParams = z.infer<

View File

@ -36,6 +36,62 @@ async def test_session_metadata(client_fixture: tuple[Honcho, str]):
assert metadata == {"foo": "bar"}
@pytest.mark.asyncio
async def test_session_fetch_methods_refresh_cached_status_fields(
client_fixture: tuple[Honcho, str],
):
"""
Tests that fetch-style session methods populate created_at and is_active.
"""
honcho_client, client_type = client_fixture
session_id = "test-session-refresh-cache"
if client_type == "async":
await honcho_client.aio.session(id=session_id)
session = Session(session_id, honcho_client)
assert session.created_at is None
assert session.is_active is None
metadata = await session.aio.get_metadata()
assert metadata == {}
assert session.created_at is not None
assert session.is_active is True
session = Session(session_id, honcho_client)
await session.aio.refresh()
assert session.created_at is not None
assert session.is_active is True
session = Session(session_id, honcho_client)
configuration = await session.aio.get_configuration()
assert configuration is not None
assert session.created_at is not None
assert session.is_active is True
else:
honcho_client.session(id=session_id)
session = Session(session_id, honcho_client)
assert session.created_at is None
assert session.is_active is None
metadata = session.get_metadata()
assert metadata == {}
assert session.created_at is not None
assert session.is_active is True
session = Session(session_id, honcho_client)
session.refresh()
assert session.created_at is not None
assert session.is_active is True
session = Session(session_id, honcho_client)
configuration = session.get_configuration()
assert configuration is not None
assert session.created_at is not None
assert session.is_active is True
@pytest.mark.asyncio
async def test_session_peer_management(
client_fixture: tuple[Honcho, str],

View File

@ -1397,7 +1397,7 @@ dev = [
[[package]]
name = "honcho-ai"
version = "2.0.1"
version = "2.1.0"
source = { editable = "sdks/python" }
dependencies = [
{ name = "httpx" },