SDK Changes (#223)

* fix: Add top level transactions for tracing in deriver

* fix: Add Delete methods to sdks for sessions

* feat: Add session deletion and session level deriver status methods

* fix: Streaming support to python sdk

* fix: Checkpoint Streaming working in SDKs with workarounds for stainless

* feat: fix pagination, add message metadata update, and get peer card methods

* feat: parity in typescript and python sdks

* fix: Address Coderabbit

* fix: Address Coderabbit

* chore: add basedpyright rules to sdk

* fix: Code Rabbit & Changelog

* fix: updated docs with api-reference
This commit is contained in:
Vineeth Voruganti 2025-10-01 16:25:30 -04:00 committed by GitHub
parent 3df044e1b7
commit 73c5eb1f5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 3610 additions and 1338 deletions

1
.gitignore vendored
View File

@ -185,3 +185,4 @@ config.toml
CRUSH.md
.crush/
AGENTS.md

View File

@ -5,6 +5,13 @@ 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.3.3] - 2025-10-01
### Fixed
- Dialectic Streaming to follow SSE conventions
- Sentry tracing in the deriver
## [2.3.2] - 2025-09-25
### Added

View File

@ -8,7 +8,7 @@
---
![Static Badge](https://img.shields.io/badge/Version-2.3.2-blue)
![Static Badge](https://img.shields.io/badge/Version-2.3.3-blue)
[![PyPI version](https://img.shields.io/pypi/v/honcho-ai.svg)](https://pypi.org/project/honcho-ai/)
[![NPM version](https://img.shields.io/npm/v/@honcho-ai/sdk.svg)](https://npmjs.org/package/@honcho-ai/sdk)
[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/plasticlabs)

View File

@ -8,23 +8,23 @@ This guide helps you understand which versions of Honcho's API are compatible wi
## Version Compatibility
### Honcho API v2.3.2 (Current)
### Honcho API v2.3.3 (Current)
<CardGroup cols={2}>
<Card title="TypeScript SDK" icon="js">
**Compatible Version:** v1.4.0
**Compatible Version:** v1.4.1
Install with:
```bash
npm install @honcho-ai/sdk@1.4.0
npm install @honcho-ai/sdk@1.4.1
```
</Card>
<Card title="Python SDK" icon="python">
**Compatible Version:** v1.4.0
**Compatible Version:** v1.4.1
Install with:
```bash
pip install honcho-ai==1.4.0
pip install honcho-ai==1.4.1
```
</Card>
</CardGroup>
@ -35,7 +35,9 @@ This guide helps you understand which versions of Honcho's API are compatible wi
| Honcho API Version | TypeScript SDK | Python SDK |
|-------------------|---------------|------------|
| v2.3.2 (Current) | v1.4.0 | v1.4.0 |
| v2.3.3 (Current) | v1.4.1 | v1.4.1 |
| v2.3.2 | v1.4.0 | v1.4.0 |
| v2.3.1 | v1.4.0 | v1.4.0 |
| v2.3.0 | v1.4.0 | v1.4.0 |
| v2.2.0 | v1.3.0 | v1.3.0 |
| v2.1.1 | v1.2.1 | v1.2.2 |

View File

@ -27,7 +27,14 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
### Honcho API and SDK Changelogs
<Tabs>
<Tab title="Honcho API">
<Update label="v2.3.2 (Current)">
<Update label="v2.3.3 (Current)">
### Fixed
- Dialectic Streaming to follow SSE conventions
- Sentry tracing in the deriver
</Update>
<Update label="v2.3.2">
### Added
- Get peer cards endpoint (`GET /v2/peers/{peer_id}/peer-card`) for retrieving targeted peer context information

View File

@ -9,21 +9,14 @@
},
"favicon": "/favicon.svg",
"contextual": {
"options": [
"copy",
"view",
"chatgpt",
"claude"
]
"options": ["copy", "view", "chatgpt", "claude"]
},
"navigation": {
"versions": [
{
"version": "v2.3.2",
"version": "v2.3.3",
"api": {
"openapi": [
"openapi.documented.yml"
]
"openapi": ["openapi.documented.yml"]
},
"tabs": [
{
@ -61,17 +54,11 @@
"groups": [
{
"group": "Getting Started",
"pages": [
"v2/guides/overview",
"v2/guides/mcp"
]
"pages": ["v2/guides/overview", "v2/guides/mcp"]
},
{
"group": "Application Interfaces",
"pages": [
"v2/guides/discord",
"v2/guides/telegram"
]
"pages": ["v2/guides/discord", "v2/guides/telegram"]
},
{
"group": "Design Patterns",
@ -106,9 +93,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v2/api-reference/introduction"
]
"pages": ["v2/api-reference/introduction"]
},
{
"group": "workspaces",
@ -129,7 +114,8 @@
"v2/api-reference/endpoint/peers/get-sessions-for-peer",
"v2/api-reference/endpoint/peers/chat",
"v2/api-reference/endpoint/peers/get-working-representation",
"v2/api-reference/endpoint/peers/search-peer"
"v2/api-reference/endpoint/peers/search-peer",
"v2/api-reference/endpoint/peers/get-peer-card"
]
},
{
@ -163,9 +149,7 @@
},
{
"group": "keys",
"pages": [
"v2/api-reference/endpoint/keys/create-key"
]
"pages": ["v2/api-reference/endpoint/keys/create-key"]
},
{
"group": "webhooks",
@ -195,9 +179,7 @@
{
"version": "v1.1.0",
"api": {
"openapi": [
"openapi.json"
]
"openapi": ["openapi.json"]
},
"tabs": [
{
@ -227,23 +209,15 @@
"groups": [
{
"group": "Getting Started",
"pages": [
"v1/guides/overview",
"v1/guides/streaming-response"
]
"pages": ["v1/guides/overview", "v1/guides/streaming-response"]
},
{
"group": "Application Interfaces",
"pages": [
"v1/guides/discord",
"v1/guides/honcho-mcp"
]
"pages": ["v1/guides/discord", "v1/guides/honcho-mcp"]
},
{
"group": "Personal Memory",
"pages": [
"v1/guides/dialectic-endpoint"
]
"pages": ["v1/guides/dialectic-endpoint"]
}
]
},
@ -252,9 +226,7 @@
"groups": [
{
"group": "API Documentation",
"pages": [
"v1/api-reference/introduction"
]
"pages": ["v1/api-reference/introduction"]
},
{
"group": "apps",
@ -302,9 +274,7 @@
},
{
"group": "keys",
"pages": [
"v1/api-reference/endpoint/keys/create-key"
]
"pages": ["v1/api-reference/endpoint/keys/create-key"]
},
{
"group": "metamessages",

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[project]
name = "honcho"
version = "2.3.2"
version = "2.3.3"
description = "Honcho Server"
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},
@ -79,7 +79,7 @@ extend-immutable-calls = ["fastapi.Depends"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
addopts = "--strict-markers --cov=src/ --cov-report=term-missing"
addopts = "--strict-markers --cov=src/ --cov=sdks/python/src/honcho --cov-report=term-missing"
testpaths = ["tests"]
pythonpath = ["src"]

View File

@ -1,6 +1,6 @@
[project]
name = "honcho-ai"
version = "1.4.0"
version = "1.4.1"
description = "Official DX Optimized Python SDK for Honcho"
dynamic = ["readme"]
license = "Apache-2.0"
@ -8,9 +8,10 @@ authors = [
{ name = "Plastic Labs", email = "hello@plasticlabs.ai" },
]
dependencies = [
"honcho-core>=1.4.0",
"honcho-core>=1.4.1-alpha.0",
"httpx>=0.28.0, <1",
"pydantic>=2.0.0, <3",
"typing-extensions>=4.12.0; python_version < \"3.12\"",
]
requires-python = ">= 3.8"
classifiers = [
@ -48,6 +49,16 @@ readme = {file = ["README.md"], content-type = "text/markdown"}
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.basedpyright]
# BasedPyright currently seems like the best type checker option, much faster
# https://docs.basedpyright.com/latest/configuration/config-files/#sample-pyprojecttoml-file
include = ["src"]
reportUnusedCallResult = false
reportCallInDefaultInitializer = false
reportAny = false
reportExplicitAny = false
reportImportCycles = false
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"

View File

@ -45,6 +45,7 @@ from .pagination import SyncPage
from .peer import Peer
from .session import Session
from .session_context import SessionContext, SessionSummaries, Summary
from .types import DialecticStreamResponse
__version__ = "1.4.0"
__author__ = "Plastic Labs"
@ -62,4 +63,5 @@ __all__ = [
"SessionSummaries",
"Summary",
"SyncPage",
"DialecticStreamResponse",
]

View File

@ -9,6 +9,8 @@ import httpx
from honcho_core import AsyncHoncho as AsyncHonchoCore
from honcho_core import Honcho as HonchoCore
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces.peer import Peer as PeerCore
from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
@ -201,7 +203,7 @@ class AsyncHoncho(BaseModel):
async def get_peers(
self, filters: dict[str, object] | None = None
) -> AsyncPage[AsyncPeer]:
) -> AsyncPage[PeerCore, AsyncPeer]:
"""
Get all peers in the current workspace.
@ -265,7 +267,7 @@ class AsyncHoncho(BaseModel):
async def get_sessions(
self, filters: dict[str, object] | None = None
) -> AsyncPage[AsyncSession]:
) -> AsyncPage[SessionCore, AsyncSession]:
"""
Get all sessions in the current workspace.
@ -466,6 +468,53 @@ class AsyncHoncho(BaseModel):
await asyncio.sleep(sleep_time)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def update_message(
self,
message: Message | str = Field(
..., description="The Message object or message ID to update"
),
metadata: dict[str, object] = Field(
..., description="The metadata to update for the message"
),
session_id: str | None = Field(
None,
min_length=1,
description="The ID of the session (required if message is a string ID)",
),
) -> Message:
"""
Update the metadata of a message.
Makes an API call to update the metadata of a specific message within a session.
Args:
message: Either a Message object or a message ID string
metadata: The metadata to update for the message
session_id: The ID of the session (required if message is a string ID, ignored if message is a Message object)
Returns:
The updated Message object
Raises:
ValidationError: If message is a string ID but session_id is not provided
"""
if isinstance(message, Message):
message_id = message.id
resolved_session_id = message.session_id
else:
message_id = message
if not session_id:
raise ValueError("session_id is required when message is a string ID")
resolved_session_id = session_id
return await self._client.workspaces.sessions.messages.update(
message_id=message_id,
workspace_id=self.workspace_id,
session_id=resolved_session_id,
metadata=metadata,
)
def __repr__(self) -> str:
"""
Return a string representation of the AsyncHoncho client.

View File

@ -1,14 +1,13 @@
from collections.abc import AsyncIterator, Callable
from typing import TypeVar
from honcho_core.pagination import AsyncPage as AsyncPageCore
from pydantic import Field, validate_call
from typing_extensions import Generic, TypeVar
T = TypeVar("T")
U = TypeVar("U")
U = TypeVar("U", default=T)
class AsyncPage(AsyncPageCore[U]):
class AsyncPage(Generic[T, U]):
"""
Async paginated result wrapper that transforms objects from type T to type U.
@ -16,16 +15,13 @@ class AsyncPage(AsyncPageCore[U]):
pagination functionality from the underlying core AsyncPage.
"""
@validate_call
_original_page: AsyncPageCore[T]
_transform_func: Callable[[T], U] | None
def __init__(
self,
original_page: AsyncPageCore[T] = Field(
..., description="The original AsyncPage to wrap"
),
transform_func: Callable[[T], U] | None = Field(
None,
description="Optional function to transform objects from type T to type U",
),
original_page: AsyncPageCore[T],
transform_func: Callable[[T], U] | None = None,
) -> None:
"""
Initialize the transformed async page.
@ -35,57 +31,73 @@ class AsyncPage(AsyncPageCore[U]):
transform_func: Optional function to transform objects from type T to type U.
If None, objects are passed through unchanged.
"""
super().__init__(items=original_page.items) # pyright: ignore
self._original_page = original_page # pyright: ignore
self._transform_func = transform_func # pyright: ignore
self._original_page = original_page
self._transform_func = transform_func
@property
def items(self) -> list[U]: # pyright: ignore
"""Get all optionally transformed items as a list."""
if self._transform_func is not None:
return [self._transform_func(item) for item in self._original_page.items]
return self._original_page.items # pyright: ignore
async def __aiter__(self) -> AsyncIterator[U]:
"""Async iterate over optionally transformed objects."""
async def __aiter__(self) -> AsyncIterator[U] | AsyncIterator[T]:
"""Async iterate over all transformed items across all pages."""
async for item in self._original_page:
if self._transform_func is not None:
yield self._transform_func(item)
else:
yield item # type: ignore # pyright: ignore
yield item
async def __agetitem__(self, index: int) -> U:
"""Get an optionally transformed object by index."""
item = await self._original_page.__agetitem__(index) # type: ignore # pyright: ignore
def __getitem__(self, index: int) -> U | T:
"""Get a transformed item by index on the current page."""
items = self._original_page.items or []
item = items[index]
if self._transform_func is not None:
return self._transform_func(item) # pyright: ignore
return item # type: ignore # pyright: ignore
return self._transform_func(item)
return item
def __len__(self) -> int:
"""Get the length of the page."""
return len(self._original_page) # type: ignore # pyright: ignore
"""Get the number of items on the current page."""
items = self._original_page.items or []
return len(items)
@property
async def data(self) -> list[U]:
"""Get all optionally transformed data as a list."""
data = await self._original_page.data # type: ignore # pyright: ignore
def items(self) -> list[U] | list[T]:
"""Get all transformed items on the current page."""
items = self._original_page.items or []
if self._transform_func is not None:
return [self._transform_func(item) for item in data] # pyright: ignore
return data # type: ignore # pyright: ignore
return [self._transform_func(item) for item in items]
return items
@property
def object(self) -> str:
"""Get the object type."""
return self._original_page.object # type: ignore # pyright: ignore
def total(self) -> int | None:
"""Get the total number of items across all pages."""
return self._original_page.total
@property
def has_next_page(self) -> bool: # pyright: ignore
def page(self) -> int | None:
"""Get the current page number."""
return self._original_page.page
@property
def size(self) -> int | None:
"""Get the page size."""
return self._original_page.size
@property
def pages(self) -> int | None:
"""Get the total number of pages."""
return self._original_page.pages
def has_next_page(self) -> bool:
"""Check if there's a next page."""
return self._original_page.has_next_page # type: ignore # pyright: ignore
return self._original_page.has_next_page()
async def next_page(self) -> "AsyncPage[U] | None":
"""Get the next page with optional transformation applied."""
next_page = await self._original_page.next_page() # type: ignore # pyright: ignore
if next_page is None:
async def get_next_page(self) -> "AsyncPage[T, U] | None":
"""
Fetch the next page of results.
Returns None if there are no more pages.
"""
if not hasattr(self._original_page, "get_next_page"):
return None
return AsyncPage(next_page, self._transform_func) # pyright: ignore
next_original_page = await self._original_page.get_next_page()
if not next_original_page:
return None
return AsyncPage(next_original_page, self._transform_func)

View File

@ -2,13 +2,17 @@ from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from collections.abc import AsyncGenerator
from honcho_core import AsyncHoncho as AsyncHonchoCore
from honcho_core._types import NOT_GIVEN
from honcho_core._types import omit
from honcho_core.types.workspaces import PeerCardResponse
from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from ..types import DialecticStreamResponse
from .pagination import AsyncPage
if TYPE_CHECKING:
@ -90,12 +94,12 @@ class AsyncPeer(BaseModel):
"""
peer = cls(peer_id, workspace_id, client)
if config or metadata:
if config is not None or metadata is not None:
await client.workspaces.peers.get_or_create(
workspace_id=workspace_id,
id=peer_id,
configuration=config if config is not None else NOT_GIVEN,
metadata=metadata if metadata is not None else NOT_GIVEN,
configuration=config if config is not None else omit,
metadata=metadata if metadata is not None else omit,
)
return peer
@ -107,7 +111,7 @@ class AsyncPeer(BaseModel):
stream: bool = False,
target: str | AsyncPeer | None = None,
session_id: str | None = None,
) -> str | None:
) -> str | DialecticStreamResponse | None:
"""
Query the peer's representation with a natural language question.
@ -125,9 +129,40 @@ class AsyncPeer(BaseModel):
If provided, only information from that session is considered
Returns:
Response string containing the answer to the query, or None if no
relevant information is available
For non-streaming: Response string containing the answer, or None if no relevant information
For streaming: DialecticStreamResponse object that can be iterated over and provides final response
"""
if stream:
async def stream_response() -> AsyncGenerator[str]:
import json
# Use core SDK with_streaming_response
async with self._client.workspaces.peers.with_streaming_response.chat(
peer_id=self.id,
workspace_id=self.workspace_id,
query=query,
stream=True,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
session_id=session_id,
) as response:
response.http_response.raise_for_status()
async for line in response.iter_lines():
if line.startswith("data: "):
json_str = line[6:] # Remove "data: " prefix
try:
chunk_data = json.loads(json_str)
if chunk_data.get("done"):
break
delta_obj = chunk_data.get("delta", {})
content = delta_obj.get("content")
if content:
yield content
except json.JSONDecodeError:
continue
return DialecticStreamResponse(stream_response())
response = await self._client.workspaces.peers.chat(
peer_id=self.id,
workspace_id=self.workspace_id,
@ -143,7 +178,7 @@ class AsyncPeer(BaseModel):
async def get_sessions(
self, filters: dict[str, object] | None = None
) -> AsyncPage[AsyncSession]:
) -> AsyncPage[SessionCore, AsyncSession]:
"""
Get all sessions this peer is a member of.
@ -322,6 +357,40 @@ class AsyncPeer(BaseModel):
limit=limit,
)
async def card(
self,
target: str | AsyncPeer | None = None,
) -> str:
"""
Get the peer card for this peer.
Makes an API call to retrieve the peer card, which contains a representation
of what this peer knows. If a target is provided, returns this peer's local
representation of the target peer.
Args:
target: Optional target peer for local card. If provided, returns this
peer's card of the target peer. Can be an AsyncPeer object or peer ID string.
Returns:
A string containing the peer card joined with newlines, or an empty string if none is available
"""
# Validate target parameter
if isinstance(target, str) and len(target.strip()) == 0:
raise ValueError("target string cannot be empty")
response: PeerCardResponse = await self._client.workspaces.peers.card(
peer_id=self.id,
workspace_id=self.workspace_id,
target=str(target.id) if isinstance(target, AsyncPeer) else target,
)
if response.peer_card is None:
return ""
items: list[str] = response.peer_card
return "\n".join(items)
def __repr__(self) -> str:
"""
Return a string representation of the AsyncPeer.

View File

@ -1,9 +1,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import asyncio
import time
import logging
from honcho_core import AsyncHoncho as AsyncHonchoCore
from honcho_core._types import NOT_GIVEN
from honcho_core._types import omit
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
@ -15,6 +19,8 @@ from .pagination import AsyncPage
if TYPE_CHECKING:
from .peer import AsyncPeer
logger = logging.getLogger(__name__)
class SessionPeerConfig(BaseModel):
observe_others: bool | None = Field(
@ -105,12 +111,12 @@ class AsyncSession(BaseModel):
"""
session = cls(session_id, workspace_id, client)
if config or metadata:
if config is not None or metadata is not None:
await client.workspaces.sessions.get_or_create(
workspace_id=workspace_id,
id=session_id,
configuration=config if config is not None else NOT_GIVEN,
metadata=metadata if metadata is not None else NOT_GIVEN,
configuration=config if config is not None else omit,
metadata=metadata if metadata is not None else omit,
)
return session
@ -296,10 +302,10 @@ class AsyncSession(BaseModel):
peer_id=str(peer.id) if isinstance(peer, AsyncPeer) else peer,
workspace_id=self.workspace_id,
session_id=self.id,
observe_others=NOT_GIVEN
observe_others=omit
if config.observe_others is None
else config.observe_others,
observe_me=NOT_GIVEN if config.observe_me is None else config.observe_me,
observe_me=omit if config.observe_me is None else config.observe_me,
)
@validate_call
@ -362,6 +368,17 @@ class AsyncSession(BaseModel):
)
return AsyncPage(messages_page)
async def delete(self) -> None:
"""
Delete this session.
Makes an async API call to delete this session.
"""
await self._client.workspaces.sessions.delete(
session_id=self.id,
workspace_id=self.workspace_id,
)
async def get_metadata(self) -> dict[str, object]:
"""
Get metadata for this session.
@ -436,7 +453,7 @@ class AsyncSession(BaseModel):
context = await self._client.workspaces.sessions.get_context(
session_id=self.id,
workspace_id=self.workspace_id,
tokens=tokens if tokens is not None else NOT_GIVEN,
tokens=tokens if tokens is not None else omit,
summary=summary,
)
@ -615,6 +632,100 @@ class AsyncSession(BaseModel):
target=str(target.id) if isinstance(target, AsyncPeer) else target,
)
@validate_call
async def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
"""
return await self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
session_id=self.id,
)
@validate_call
async def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
timeout: float = Field(
300.0,
gt=0,
description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).",
),
) -> DeriverStatus:
"""
Poll get_deriver_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the deriver for
use with the dialectic endpoint.
The polling estimates sleep time by assuming each work unit takes 1 second.
Args:
observer_id: Optional observer ID to scope the status check
sender_id: Optional sender ID to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
DeriverStatus when all work units are complete
Raises:
TimeoutError: If timeout is exceeded before work units complete
Exception: If get_deriver_status fails repeatedly
"""
start_time = time.time()
while True:
try:
status = await self.get_deriver_status(observer_id, sender_id)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying
await asyncio.sleep(1)
# Check timeout after error
elapsed_time = time.time() - start_time
if elapsed_time >= timeout:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Error during status check: {e}"
) from e
continue
if status.pending_work_units == 0 and status.in_progress_work_units == 0:
return status
# Check timeout before sleeping
elapsed_time = time.time() - start_time
if elapsed_time >= timeout:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Current status: {status.pending_work_units} pending, "
+ f"{status.in_progress_work_units} in progress work units."
)
# Sleep for the expected time to complete all current work units
# Assuming each pending and in-progress work unit takes 1 second
total_work_units = status.pending_work_units + status.in_progress_work_units
sleep_time = max(1, total_work_units)
# Don't sleep past the timeout
remaining_time = timeout - elapsed_time
sleep_time = min(sleep_time, remaining_time)
if sleep_time <= 0:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Current status: {status.pending_work_units} pending, "
+ f"{status.in_progress_work_units} in progress work units."
)
await asyncio.sleep(sleep_time)
def __repr__(self) -> str:
"""
Return a string representation of the AsyncSession.

View File

@ -7,6 +7,8 @@ from typing import Any, Literal
import httpx
from honcho_core import Honcho as HonchoCore
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces.peer import Peer as PeerCore
from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
@ -185,7 +187,9 @@ class Honcho(BaseModel):
id, self.workspace_id, self._client, config=config, metadata=metadata
)
def get_peers(self, filters: dict[str, object] | None = None) -> SyncPage[Peer]:
def get_peers(
self, filters: dict[str, object] | None = None
) -> SyncPage[PeerCore, Peer]:
"""
Get all peers in the current workspace.
@ -247,7 +251,7 @@ class Honcho(BaseModel):
def get_sessions(
self, filters: dict[str, object] | None = None
) -> SyncPage[Session]:
) -> SyncPage[SessionCore, Session]:
"""
Get all sessions in the current workspace.
@ -438,6 +442,53 @@ class Honcho(BaseModel):
time.sleep(sleep_time)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def update_message(
self,
message: Message | str = Field(
..., description="The Message object or message ID to update"
),
metadata: dict[str, object] = Field(
..., description="The metadata to update for the message"
),
session_id: str | None = Field(
None,
min_length=1,
description="The ID of the session (required if message is a string ID)",
),
) -> Message:
"""
Update the metadata of a message.
Makes an API call to update the metadata of a specific message within a session.
Args:
message: Either a Message object or a message ID string
metadata: The metadata to update for the message
session_id: The ID of the session (required if message is a string ID, ignored if message is a Message object)
Returns:
The updated Message object
Raises:
ValidationError: If message is a string ID but session_id is not provided
"""
if isinstance(message, Message):
message_id = message.id
resolved_session_id = message.session_id
else:
message_id = message
if not session_id:
raise ValueError("session_id is required when message is a string ID")
resolved_session_id = session_id
return self._client.workspaces.sessions.messages.update(
message_id=message_id,
workspace_id=self.workspace_id,
session_id=resolved_session_id,
metadata=metadata,
)
def __repr__(self) -> str:
"""
Return a string representation of the Honcho client.

View File

@ -1,14 +1,14 @@
from collections.abc import Callable, Iterator
from typing import Optional, TypeVar # pyright: ignore
from typing import Generic
from honcho_core.pagination import SyncPage as SyncPageCore
from pydantic import Field, validate_call
from typing_extensions import TypeVar
T = TypeVar("T")
U = TypeVar("U")
U = TypeVar("U", default=T)
class SyncPage(SyncPageCore[U]):
class SyncPage(Generic[T, U]):
"""
Paginated result wrapper that transforms objects from type T to type U.
@ -16,16 +16,13 @@ class SyncPage(SyncPageCore[U]):
pagination functionality from the underlying core SyncPage.
"""
@validate_call
def __init__( # pyright: ignore
_original_page: SyncPageCore[T]
_transform_func: Callable[[T], U] | None
def __init__(
self,
original_page: SyncPageCore[T] = Field(
..., description="The original SyncPage to wrap"
),
transform_func: Callable[[T], U] | None = Field(
None,
description="Optional function to transform objects from type T to type U",
),
original_page: SyncPageCore[T],
transform_func: Callable[[T], U] | None = None,
) -> None:
"""
Initialize the transformed page.
@ -35,48 +32,73 @@ class SyncPage(SyncPageCore[U]):
transform_func: Optional function to transform objects from type T to type U.
If None, objects are passed through unchanged.
"""
self._original_page = original_page # pyright: ignore
self._transform_func = transform_func # pyright: ignore
self._original_page = original_page
self._transform_func = transform_func
def __iter__(self) -> Iterator[U]:
"""Iterate over optionally transformed objects."""
def __iter__(self) -> Iterator[U] | Iterator[T]:
"""Iterate over all transformed items across all pages."""
for item in self._original_page:
if self._transform_func is not None:
yield self._transform_func(item)
else:
yield item # pyright: ignore
yield item
def __getitem__(self, index: int) -> U:
"""Get an optionally transformed object by index."""
item = self._original_page[index] # pyright: ignore
def __getitem__(self, index: int) -> U | T:
"""Get a transformed item by index on the current page."""
items = self._original_page.items or []
item = items[index]
if self._transform_func is not None:
return self._transform_func(item) # pyright: ignore
return item # pyright: ignore
return self._transform_func(item)
return item
def __len__(self) -> int:
"""Get the length of the page."""
return len(self._original_page) # pyright: ignore
"""Get the number of items on the current page."""
items = self._original_page.items or []
return len(items)
@property
def data(self) -> list[U]:
"""Get all optionally transformed data as a list."""
def items(self) -> list[U] | list[T]:
"""Get all transformed items on the current page."""
items = self._original_page.items or []
if self._transform_func is not None:
return [self._transform_func(item) for item in self._original_page.data] # pyright: ignore
return self._original_page.data # pyright: ignore
return [self._transform_func(item) for item in items]
return items
@property
def object(self) -> str:
"""Get the object type."""
return self._original_page.object # pyright: ignore
def total(self) -> int | None:
"""Get the total number of items across all pages."""
return self._original_page.total
@property
def has_next_page(self) -> bool: # pyright: ignore
def page(self) -> int | None:
"""Get the current page number."""
return self._original_page.page
@property
def size(self) -> int | None:
"""Get the page size."""
return self._original_page.size
@property
def pages(self) -> int | None:
"""Get the total number of pages."""
return self._original_page.pages
def has_next_page(self) -> bool:
"""Check if there's a next page."""
return self._original_page.has_next_page # pyright: ignore
return self._original_page.has_next_page()
def next_page(self) -> Optional["SyncPage[U]"]: # pyright: ignore
"""Get the next page with optional transformation applied."""
next_page = self._original_page.next_page() # pyright: ignore
if next_page is None:
def get_next_page(self) -> "SyncPage[T, U] | None":
"""
Fetch the next page of results.
Returns None if there are no more pages.
"""
if not hasattr(self._original_page, "get_next_page"):
return None
return SyncPage(next_page, self._transform_func) # pyright: ignore
next_original_page = self._original_page.get_next_page()
if not next_original_page:
return None
return SyncPage(next_original_page, self._transform_func)

View File

@ -2,13 +2,17 @@ from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from collections.abc import Generator
from honcho_core import Honcho as HonchoCore
from honcho_core._types import NOT_GIVEN
from honcho_core._types import omit
from honcho_core.types.workspaces import PeerCardResponse
from honcho_core.types.workspaces.session import Session as SessionCore
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
from .types import DialecticStreamResponse
from .pagination import SyncPage
if TYPE_CHECKING:
@ -76,12 +80,12 @@ class Peer(BaseModel):
super().__init__(id=peer_id, workspace_id=workspace_id)
self._client = client
if config or metadata:
if config is not None or metadata is not None:
self._client.workspaces.peers.get_or_create(
workspace_id=workspace_id,
id=peer_id,
configuration=config if config is not None else NOT_GIVEN,
metadata=metadata if metadata is not None else NOT_GIVEN,
configuration=config if config is not None else omit,
metadata=metadata if metadata is not None else omit,
)
def chat(
@ -91,7 +95,7 @@ class Peer(BaseModel):
stream: bool = False,
target: str | Peer | None = None,
session_id: str | None = None,
) -> str | None:
) -> str | DialecticStreamResponse | None:
"""
Query the peer's representation with a natural language question.
@ -109,9 +113,40 @@ class Peer(BaseModel):
If provided, only information from that session is considered
Returns:
Response string containing the answer to the query, or None if no
relevant information is available
For non-streaming: Response string containing the answer, or None if no relevant information
For streaming: DialecticStreamResponse object that can be iterated over and provides final response
"""
if stream:
def stream_response() -> Generator[str, None, None]:
import json
# Use core SDK with_streaming_response
with self._client.workspaces.peers.with_streaming_response.chat(
peer_id=self.id,
workspace_id=self.workspace_id,
query=query,
stream=True,
target=str(target.id) if isinstance(target, Peer) else target,
session_id=session_id,
) as response:
response.http_response.raise_for_status()
for line in response.iter_lines():
if line.startswith("data: "):
json_str = line[6:] # Remove "data: " prefix
try:
chunk_data = json.loads(json_str)
if chunk_data.get("done"):
break
delta_obj = chunk_data.get("delta", {})
content = delta_obj.get("content")
if content:
yield content
except json.JSONDecodeError:
continue
return DialecticStreamResponse(stream_response())
response = self._client.workspaces.peers.chat(
peer_id=self.id,
workspace_id=self.workspace_id,
@ -126,7 +161,7 @@ class Peer(BaseModel):
def get_sessions(
self, filters: dict[str, object] | None = None
) -> SyncPage[Session]:
) -> SyncPage[SessionCore, Session]:
"""
Get all sessions this peer is a member of.
@ -305,6 +340,40 @@ class Peer(BaseModel):
limit=limit,
)
def card(
self,
target: str | Peer | None = None,
) -> str:
"""
Get the peer card for this peer.
Makes an API call to retrieve the peer card, which contains a representation
of what this peer knows. If a target is provided, returns this peer's local
representation of the target peer.
Args:
target: Optional target peer for local card. If provided, returns this
peer's card of the target peer. Can be a Peer object or peer ID string.
Returns:
A string containing the peer card joined with newlines, or an empty string if none is available
"""
# Validate target parameter
if isinstance(target, str) and len(target.strip()) == 0:
raise ValueError("target string cannot be empty")
response: PeerCardResponse = self._client.workspaces.peers.card(
peer_id=self.id,
workspace_id=self.workspace_id,
target=str(target.id) if isinstance(target, Peer) else target,
)
if response.peer_card is None:
return ""
items: list[str] = response.peer_card
return "\n".join(items)
def __repr__(self) -> str:
"""
Return a string representation of the Peer.

View File

@ -1,9 +1,12 @@
from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING, Any
from honcho_core import Honcho as HonchoCore
from honcho_core._types import NOT_GIVEN
from honcho_core._types import omit
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces.sessions import MessageCreateParam
from honcho_core.types.workspaces.sessions.message import Message
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
@ -15,6 +18,8 @@ from .utils import prepare_file_for_upload
if TYPE_CHECKING:
from .peer import Peer
logger = logging.getLogger(__name__)
class SessionPeerConfig(BaseModel):
observe_others: bool | None = Field(
@ -91,12 +96,12 @@ class Session(BaseModel):
)
self._client = client
if config or metadata:
if config is not None or metadata is not None:
self._client.workspaces.sessions.get_or_create(
workspace_id=workspace_id,
id=session_id,
configuration=config if config is not None else NOT_GIVEN,
metadata=metadata if metadata is not None else NOT_GIVEN,
configuration=config if config is not None else omit,
metadata=metadata if metadata is not None else omit,
)
def add_peers(
@ -275,10 +280,10 @@ class Session(BaseModel):
peer_id=str(peer.id) if isinstance(peer, Peer) else peer,
workspace_id=self.workspace_id,
session_id=self.id,
observe_others=NOT_GIVEN
observe_others=omit
if config.observe_others is None
else config.observe_others,
observe_me=NOT_GIVEN if config.observe_me is None else config.observe_me,
observe_me=omit if config.observe_me is None else config.observe_me,
)
@validate_call
@ -360,6 +365,17 @@ class Session(BaseModel):
or {}
)
def delete(self) -> None:
"""
Delete this session
Makes an API call to mark this session as inactive.
"""
self._client.workspaces.sessions.delete(
session_id=self.id,
workspace_id=self.workspace_id,
)
@validate_call
def set_metadata(
self,
@ -417,7 +433,7 @@ class Session(BaseModel):
context = self._client.workspaces.sessions.get_context(
session_id=self.id,
workspace_id=self.workspace_id,
tokens=tokens if tokens is not None else NOT_GIVEN,
tokens=tokens if tokens is not None else omit,
summary=summary,
)
@ -596,6 +612,100 @@ class Session(BaseModel):
target=str(target.id) if isinstance(target, Peer) else target,
)
@validate_call
def get_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
) -> DeriverStatus:
"""
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
"""
return self._client.workspaces.deriver_status(
workspace_id=self.workspace_id,
observer_id=observer_id,
sender_id=sender_id,
session_id=self.id,
)
@validate_call
def poll_deriver_status(
self,
observer_id: str | None = None,
sender_id: str | None = None,
timeout: float = Field(
300.0,
gt=0,
description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).",
),
) -> DeriverStatus:
"""
Poll get_deriver_status until pending_work_units and in_progress_work_units are both 0.
This allows you to guarantee that all messages have been processed by the deriver for
use with the dialectic endpoint.
The polling estimates sleep time by assuming each work unit takes 1 second.
Args:
observer_id: Optional observer ID to scope the status check
sender_id: Optional sender ID to scope the status check
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
Returns:
DeriverStatus when all work units are complete
Raises:
TimeoutError: If timeout is exceeded before work units complete
Exception: If get_deriver_status fails repeatedly
"""
start_time = time.time()
while True:
try:
status = self.get_deriver_status(observer_id, sender_id)
except Exception as e:
logger.warning(f"Failed to get deriver status: {e}")
# Sleep briefly before retrying
time.sleep(1)
# Check timeout after error
elapsed_time = time.time() - start_time
if elapsed_time >= timeout:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Error during status check: {e}"
) from e
continue
if status.pending_work_units == 0 and status.in_progress_work_units == 0:
return status
# Check timeout before sleeping
elapsed_time = time.time() - start_time
if elapsed_time >= timeout:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Current status: {status.pending_work_units} pending, "
+ f"{status.in_progress_work_units} in progress work units."
)
# Sleep for the expected time to complete all current work units
# Assuming each pending and in-progress work unit takes 1 second
total_work_units = status.pending_work_units + status.in_progress_work_units
sleep_time = max(1, total_work_units)
# Don't sleep past the timeout
remaining_time = timeout - elapsed_time
sleep_time = min(sleep_time, remaining_time)
if sleep_time <= 0:
raise TimeoutError(
f"Polling timeout exceeded after {timeout}s. "
+ f"Current status: {status.pending_work_units} pending, "
+ f"{status.in_progress_work_units} in progress work units."
)
time.sleep(sleep_time)
def __repr__(self) -> str:
"""
Return a string representation of the Session.

View File

@ -0,0 +1,106 @@
"""Shared types for the Honcho SDK."""
from __future__ import annotations
from collections.abc import Iterator, AsyncIterator
class DialecticStreamResponse:
"""
Iterator for streaming dialectic responses with utilities for accessing the final response.
Similar to OpenAI and Anthropic streaming patterns, this allows you to:
- Iterate over chunks as they arrive
- Access the final accumulated response after streaming completes
Works with both sync and async iterators.
Example (sync):
```python
stream = peer.chat("Hello", stream=True)
# Stream chunks
for chunk in stream:
print(chunk, end="", flush=True)
# Get final response object
final = stream.get_final_response()
print(f"\\nFull content: {final['content']}")
```
Example (async):
```python
stream = await peer.chat("Hello", stream=True)
# Stream chunks
async for chunk in stream:
print(chunk, end="", flush=True)
# Get final response object
final = stream.get_final_response()
print(f"\\nFull content: {final['content']}")
```
"""
_iterator: Iterator[str] | AsyncIterator[str]
_accumulated_content: list[str]
_is_complete: bool
def __init__(self, iterator: Iterator[str] | AsyncIterator[str]):
self._iterator = iterator
self._accumulated_content = []
self._is_complete = False
# Sync iterator protocol
def __iter__(self):
if isinstance(self._iterator, Iterator):
return self
else:
raise TypeError("iterator must be an sync iterator, got async iterator")
def __next__(self) -> str:
try:
if not isinstance(self._iterator, Iterator):
raise TypeError("iterator must be an sync iterator, got async iterator")
chunk = next(self._iterator) # type: ignore
self._accumulated_content.append(chunk)
return chunk
except StopIteration:
self._is_complete = True
raise
# Async iterator protocol
def __aiter__(self):
if isinstance(self._iterator, AsyncIterator):
return self
else:
raise TypeError("iterator must be an async iterator, got sync iterator")
async def __anext__(self) -> str:
try:
if not isinstance(self._iterator, AsyncIterator):
raise TypeError("iterator must be an async iterator, got sync iterator")
chunk = await self._iterator.__anext__() # type: ignore
self._accumulated_content.append(chunk)
return chunk
except StopAsyncIteration:
self._is_complete = True
raise
def get_final_response(self) -> dict[str, str]:
"""
Get the final accumulated response after streaming completes.
Returns:
A dictionary with the full content: {"content": "full accumulated text"}
Note:
This should be called after the stream has been fully consumed.
If called before completion, it returns the content accumulated so far.
"""
return {"content": "".join(self._accumulated_content)}
@property
def is_complete(self) -> bool:
"""Check if the stream has finished."""
return self._is_complete

View File

@ -2,6 +2,7 @@ import { Honcho } from '../src/client';
import { Peer } from '../src/peer';
import { Session } from '../src/session';
import { Page } from '../src/pagination';
import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages';
// Mock the @honcho-ai/core module
jest.mock('@honcho-ai/core', () => {
@ -481,4 +482,93 @@ describe('Honcho Client', () => {
await expect(honcho.pollDeriverStatus({ timeoutMs: 100 })).rejects.toThrow();
});
});
describe('updateMessage', () => {
beforeEach(() => {
mockClient.workspaces.sessions = {
messages: {
update: jest.fn(),
},
};
});
it('should update message metadata using Message object', async () => {
const mockMessage: Message = {
id: 'msg-123',
session_id: 'session-456',
content: 'Test message',
peer_id: 'peer-789',
created_at: '2024-01-01T00:00:00Z',
token_count: 10,
workspace_id: 'test-workspace',
};
const metadata = { updated: true, importance: 'high' };
const mockUpdatedMessage = { ...mockMessage, metadata };
mockClient.workspaces.sessions.messages.update.mockResolvedValue(mockUpdatedMessage);
const result = await honcho.updateMessage(mockMessage, metadata);
expect(result).toEqual(mockUpdatedMessage);
expect(mockClient.workspaces.sessions.messages.update).toHaveBeenCalledWith(
'test-workspace',
'session-456',
'msg-123',
{ metadata }
);
});
it('should update message metadata using message ID and session ID', async () => {
const messageId = 'msg-123';
const sessionId = 'session-456';
const metadata = { updated: true, importance: 'high' };
const mockUpdatedMessage = {
id: messageId,
session_id: sessionId,
content: 'Test message',
peer_id: 'peer-789',
metadata,
};
mockClient.workspaces.sessions.messages.update.mockResolvedValue(mockUpdatedMessage);
const result = await honcho.updateMessage(messageId, metadata, sessionId);
expect(result).toEqual(mockUpdatedMessage);
expect(mockClient.workspaces.sessions.messages.update).toHaveBeenCalledWith(
'test-workspace',
sessionId,
messageId,
{ metadata }
);
});
it('should throw error when message is string ID but session ID is not provided', async () => {
const messageId = 'msg-123';
const metadata = { updated: true };
await expect(honcho.updateMessage(messageId, metadata)).rejects.toThrow(
'sessionId is required when message is a string ID'
);
});
it('should handle API errors', async () => {
const mockMessage: Message = {
id: 'msg-123',
session_id: 'session-456',
content: 'Test message',
peer_id: 'peer-789',
created_at: '2024-01-01T00:00:00Z',
token_count: 10,
workspace_id: 'test-workspace',
};
const metadata = { updated: true };
mockClient.workspaces.sessions.messages.update.mockRejectedValue(
new Error('Update failed')
);
await expect(honcho.updateMessage(mockMessage, metadata)).rejects.toThrow('Update failed');
});
});
});

View File

@ -165,7 +165,7 @@ describe('Honcho SDK Integration Tests', () => {
'assistant',
{
query: 'How are you?',
stream: undefined,
stream: false,
target: undefined,
session_id: undefined,
}
@ -183,6 +183,11 @@ describe('Honcho SDK Integration Tests', () => {
total: 2,
size: 2,
hasNextPage: false,
[Symbol.asyncIterator]: async function* () {
for (const item of this.items) {
yield item
}
},
}
mockWorkspacesApi.workspaces.getOrCreate.mockResolvedValue({
@ -302,8 +307,15 @@ describe('Honcho SDK Integration Tests', () => {
],
total: 4,
size: 2,
page: 1,
pages: 2,
hasNextPage: true,
nextPage: jest.fn(),
getNextPage: jest.fn(),
[Symbol.asyncIterator]: async function* () {
for (const item of this.items) {
yield item
}
},
}
const secondPageData = {
@ -313,11 +325,18 @@ describe('Honcho SDK Integration Tests', () => {
],
total: 4,
size: 2,
page: 2,
pages: 2,
hasNextPage: false,
nextPage: jest.fn().mockResolvedValue(null),
getNextPage: jest.fn().mockResolvedValue(null),
[Symbol.asyncIterator]: async function* () {
for (const item of this.items) {
yield item
}
},
}
firstPageData.nextPage.mockResolvedValue(secondPageData)
firstPageData.getNextPage.mockResolvedValue(secondPageData)
mockWorkspacesApi.workspaces.peers.list.mockResolvedValue(firstPageData)
// Step 1: Get first page
@ -327,20 +346,25 @@ describe('Honcho SDK Integration Tests', () => {
expect(firstPage.hasNextPage).toBe(true)
// Step 2: Get data from first page
const firstPageData_ = await firstPage.data()
const firstPageData_ = firstPage.items
expect(firstPageData_).toHaveLength(2)
expect(firstPageData_[0]).toBeInstanceOf(Peer)
expect(firstPageData_[0].id).toBe('peer1')
// Step 3: Get next page
const secondPage = await firstPage.nextPage()
const secondPage = await firstPage.getNextPage()
expect(secondPage).not.toBeNull()
expect(secondPage!.hasNextPage).toBe(false)
expect(secondPage!.page).toBe(2)
// Step 4: Get data from second page
const secondPageData_ = await secondPage!.data()
const secondPageData_ = secondPage!.items
expect(secondPageData_).toHaveLength(2)
expect(secondPageData_[0]).toBeInstanceOf(Peer)
expect(secondPageData_[0].id).toBe('peer3')
// Step 5: Verify no more pages
const thirdPage = await secondPage!.nextPage()
const thirdPage = await secondPage!.getNextPage()
expect(thirdPage).toBeNull()
})
@ -405,7 +429,7 @@ describe('Honcho SDK Integration Tests', () => {
// Test empty peers list
const peersPage = await honcho.getPeers()
const peersList = await peersPage.data()
const peersList = peersPage.items
expect(peersList).toEqual([])
// Test empty context

View File

@ -1,453 +1,330 @@
import { Page } from '../src/pagination';
import { Page } from '../src/pagination'
describe('Page', () => {
let mockOriginalPage: any;
let mockItems: any[];
let mockOriginalPage: any
let mockItems: any[]
beforeEach(() => {
mockItems = [
{ id: 'item1', name: 'Item 1' },
{ id: 'item2', name: 'Item 2' },
{ id: 'item3', name: 'Item 3' },
];
]
mockOriginalPage = {
items: mockItems,
data: mockItems,
size: 3,
total: 10,
page: 1,
pages: 4,
hasNextPage: true,
get: jest.fn(),
nextPage: jest.fn(),
};
});
[Symbol.asyncIterator]: async function*() {
for (const item of mockItems) {
yield item
}
},
} as any
})
describe('constructor', () => {
it('should initialize with original page', () => {
const page = new Page(mockOriginalPage);
const page = new Page(mockOriginalPage)
expect(page['_originalPage']).toBe(mockOriginalPage);
expect(page['_transformFunc']).toBeUndefined();
});
expect(page['_originalPage']).toBe(mockOriginalPage)
expect(page['_transformFunc']).toBeUndefined()
})
it('should initialize with transform function', () => {
const transformFunc = (item: any) => ({ ...item, transformed: true });
const page = new Page(mockOriginalPage, transformFunc);
const transformFunc = (item: any) => ({ ...item, transformed: true })
const page = new Page(mockOriginalPage, transformFunc)
expect(page['_originalPage']).toBe(mockOriginalPage);
expect(page['_transformFunc']).toBe(transformFunc);
});
});
expect(page['_originalPage']).toBe(mockOriginalPage)
expect(page['_transformFunc']).toBe(transformFunc)
})
})
describe('Symbol.asyncIterator', () => {
it('should iterate through items without transform', async () => {
const page = new Page(mockOriginalPage);
const items: any[] = [];
const page = new Page(mockOriginalPage)
const items: any[] = []
for await (const item of page) {
items.push(item);
items.push(item)
}
expect(items).toEqual(mockItems);
});
expect(items).toEqual(mockItems)
})
it('should iterate through items with transform', async () => {
const transformFunc = (item: any) => ({ ...item, transformed: true });
const page = new Page(mockOriginalPage, transformFunc);
const items: any[] = [];
const transformFunc = (item: any) => ({ ...item, transformed: true })
const page = new Page(mockOriginalPage, transformFunc)
const items: any[] = []
for await (const item of page) {
items.push(item);
items.push(item)
}
expect(items).toEqual([
{ id: 'item1', name: 'Item 1', transformed: true },
{ id: 'item2', name: 'Item 2', transformed: true },
{ id: 'item3', name: 'Item 3', transformed: true },
]);
});
])
})
it('should handle empty items array', async () => {
const emptyPage = { items: [], data: [], size: 0, total: 0, hasNextPage: false };
const page = new Page(emptyPage);
const items: any[] = [];
it('should handle transform function that throws error', async () => {
const errorTransform = () => {
throw new Error('Transform error')
}
const page = new Page(mockOriginalPage, errorTransform)
for await (const item of page) {
items.push(item);
const iterate = async () => {
for await (const item of page) {
// This should throw
}
}
expect(items).toEqual([]);
});
it('should handle page with data field instead of items', async () => {
const pageWithData = {
data: mockItems,
size: 3,
total: 10,
hasNextPage: true,
};
const page = new Page(pageWithData);
const items: any[] = [];
for await (const item of page) {
items.push(item);
}
expect(items).toEqual(mockItems);
});
it('should handle page with neither items nor data', async () => {
const pageWithoutItems = {
size: 0,
total: 0,
hasNextPage: false,
};
const page = new Page(pageWithoutItems);
const items: any[] = [];
for await (const item of page) {
items.push(item);
}
expect(items).toEqual([]);
});
});
await expect(iterate()).rejects.toThrow('Transform error')
})
})
describe('get', () => {
it('should get item by index without transform', async () => {
mockOriginalPage.get.mockResolvedValue(mockItems[1]);
const page = new Page(mockOriginalPage);
it('should get item by index without transform', () => {
const page = new Page(mockOriginalPage)
const item = await page.get(1);
const item = page.get(1)
expect(item).toEqual(mockItems[1]);
expect(mockOriginalPage.get).toHaveBeenCalledWith(1);
});
expect(item).toEqual(mockItems[1])
})
it('should get item by index with transform', async () => {
const transformFunc = (item: any) => ({ ...item, transformed: true });
mockOriginalPage.get.mockResolvedValue(mockItems[1]);
const page = new Page(mockOriginalPage, transformFunc);
it('should get item by index with transform', () => {
const transformFunc = (item: any) => ({ ...item, transformed: true })
const page = new Page(mockOriginalPage, transformFunc)
const item = await page.get(1);
const item = page.get(1)
expect(item).toEqual({ ...mockItems[1], transformed: true });
expect(mockOriginalPage.get).toHaveBeenCalledWith(1);
});
expect(item).toEqual({ ...mockItems[1], transformed: true })
})
it('should handle out of bounds index', async () => {
mockOriginalPage.get.mockResolvedValue(undefined);
const page = new Page(mockOriginalPage);
it('should handle out of bounds index', () => {
const page = new Page(mockOriginalPage)
const item = await page.get(999);
expect(() => page.get(999)).toThrow(
'Index 999 is out of bounds for page with 3 items'
)
})
})
expect(item).toBeUndefined();
expect(mockOriginalPage.get).toHaveBeenCalledWith(999);
});
describe('length getter', () => {
it('should return length of items array', () => {
const page = new Page(mockOriginalPage)
it('should handle negative index', async () => {
mockOriginalPage.get.mockResolvedValue(undefined);
const page = new Page(mockOriginalPage);
expect(page.length).toBe(3)
})
const item = await page.get(-1);
it('should handle empty items array', () => {
const emptyPage = { ...mockOriginalPage, items: [] }
const page = new Page(emptyPage)
expect(item).toBeUndefined();
expect(mockOriginalPage.get).toHaveBeenCalledWith(-1);
});
});
expect(page.length).toBe(0)
})
describe('size getter', () => {
it('should return size from original page', () => {
const page = new Page(mockOriginalPage);
it('should handle undefined items', () => {
const noItemsPage = { ...mockOriginalPage, items: undefined }
const page = new Page(noItemsPage)
expect(page.size).toBe(3);
});
expect(page.length).toBe(0)
})
})
it('should handle missing size', () => {
const pageWithoutSize = { items: mockItems };
const page = new Page(pageWithoutSize);
describe('items getter', () => {
it('should return items array without transform', () => {
const page = new Page(mockOriginalPage)
expect(page.size).toBeUndefined();
});
});
const data = page.items
describe('total getter', () => {
it('should return total from original page', () => {
const page = new Page(mockOriginalPage);
expect(data).toEqual(mockItems)
})
expect(page.total).toBe(10);
});
it('should return items array with transform', () => {
const transformFunc = (item: any) => ({ ...item, transformed: true })
const page = new Page(mockOriginalPage, transformFunc)
it('should handle missing total', () => {
const pageWithoutTotal = { items: mockItems };
const page = new Page(pageWithoutTotal);
expect(page.total).toBeUndefined();
});
});
describe('data', () => {
it('should return data array without transform', async () => {
const page = new Page(mockOriginalPage);
const data = await page.data();
expect(data).toEqual(mockItems);
});
it('should return data array with transform', async () => {
const transformFunc = (item: any) => ({ ...item, transformed: true });
const page = new Page(mockOriginalPage, transformFunc);
const data = await page.data();
const data = page.items
expect(data).toEqual([
{ id: 'item1', name: 'Item 1', transformed: true },
{ id: 'item2', name: 'Item 2', transformed: true },
{ id: 'item3', name: 'Item 3', transformed: true },
]);
});
])
})
it('should handle data as function', async () => {
const mockDataFunction = jest.fn().mockResolvedValue(mockItems);
const pageWithDataFunction = {
data: mockDataFunction,
size: 3,
total: 10,
hasNextPage: true,
};
const page = new Page(pageWithDataFunction);
it('should handle transform function returning null', () => {
const nullTransform = () => null
const page = new Page(mockOriginalPage, nullTransform)
const data = await page.data();
const data = page.items
expect(data).toEqual(mockItems);
expect(mockDataFunction).toHaveBeenCalled();
});
expect(data).toEqual([null, null, null])
})
})
it('should handle items as function', async () => {
const mockItemsFunction = jest.fn().mockResolvedValue(mockItems);
const pageWithItemsFunction = {
items: mockItemsFunction,
size: 3,
total: 10,
hasNextPage: true,
};
const page = new Page(pageWithItemsFunction);
describe('pagination metadata getters', () => {
it('should return total from original page', () => {
const page = new Page(mockOriginalPage)
const data = await page.data();
expect(page.total).toBe(10)
})
expect(data).toEqual(mockItems);
expect(mockItemsFunction).toHaveBeenCalled();
});
it('should return page number from original page', () => {
const page = new Page(mockOriginalPage)
it('should handle empty data', async () => {
const emptyPage = { items: [], size: 0, total: 0, hasNextPage: false };
const page = new Page(emptyPage);
expect(page.page).toBe(1)
})
const data = await page.data();
it('should return size from original page', () => {
const page = new Page(mockOriginalPage)
expect(data).toEqual([]);
});
expect(page.size).toBe(3)
})
it('should prioritize items over data field', async () => {
const pageWithBoth = {
items: [{ id: 'from-items' }],
data: [{ id: 'from-data' }],
size: 1,
total: 1,
hasNextPage: false,
};
const page = new Page(pageWithBoth);
it('should return pages from original page', () => {
const page = new Page(mockOriginalPage)
const data = await page.data();
expect(page.pages).toBe(4)
})
expect(data).toEqual([{ id: 'from-items' }]);
});
});
it('should handle undefined metadata', () => {
const minimalPage = { items: mockItems }
const page = new Page(minimalPage as any)
expect(page.total).toBeUndefined()
expect(page.page).toBeUndefined()
expect(page.size).toBeUndefined()
expect(page.pages).toBeUndefined()
})
})
describe('hasNextPage getter', () => {
it('should return hasNextPage from original page', () => {
const page = new Page(mockOriginalPage);
it('should return true when hasNextPage is true', () => {
const page = new Page(mockOriginalPage)
expect(page.hasNextPage).toBe(true);
});
expect(page.hasNextPage).toBe(true)
})
it('should handle missing hasNextPage', () => {
const pageWithoutHasNextPage = { items: mockItems };
const page = new Page(pageWithoutHasNextPage);
it('should return false when hasNextPage is false', () => {
const lastPage = { ...mockOriginalPage, hasNextPage: false }
const page = new Page(lastPage)
expect(page.hasNextPage).toBeUndefined();
});
expect(page.hasNextPage).toBe(false)
})
it('should handle false hasNextPage', () => {
const lastPage = { ...mockOriginalPage, hasNextPage: false };
const page = new Page(lastPage);
it('should call hasNextPage function if it is a function', () => {
const hasNextPageFn = jest.fn(() => true)
const pageWithFn = { ...mockOriginalPage, hasNextPage: hasNextPageFn }
const page = new Page(pageWithFn)
expect(page.hasNextPage).toBe(false);
});
});
const result = page.hasNextPage
describe('nextPage', () => {
expect(result).toBe(true)
expect(hasNextPageFn).toHaveBeenCalled()
})
it('should return false when hasNextPage is undefined', () => {
const noNextPage = { ...mockOriginalPage }
delete noNextPage.hasNextPage
const page = new Page(noNextPage)
expect(page.hasNextPage).toBe(false)
})
})
describe('getNextPage', () => {
it('should return next page with same transform function', async () => {
const nextPageData = {
items: [{ id: 'item4', name: 'Item 4' }],
size: 1,
total: 10,
page: 2,
pages: 4,
hasNextPage: false,
};
const transformFunc = (item: any) => ({ ...item, transformed: true });
mockOriginalPage.nextPage.mockResolvedValue(nextPageData);
const page = new Page(mockOriginalPage, transformFunc);
[Symbol.asyncIterator]: async function*() {
for (const item of this.items) {
yield item
}
},
}
const transformFunc = (item: any) => ({ ...item, transformed: true })
mockOriginalPage.getNextPage = jest.fn().mockResolvedValue(nextPageData)
const page = new Page(mockOriginalPage, transformFunc)
const nextPage = await page.nextPage();
const nextPage = await page.getNextPage()
expect(nextPage).toBeInstanceOf(Page);
expect(nextPage!['_transformFunc']).toBe(transformFunc);
expect(mockOriginalPage.nextPage).toHaveBeenCalled();
});
expect(nextPage).toBeInstanceOf(Page)
expect(nextPage!['_transformFunc']).toBe(transformFunc)
expect(mockOriginalPage.getNextPage).toHaveBeenCalled()
})
it('should return null when no next page', async () => {
mockOriginalPage.nextPage.mockResolvedValue(null);
const page = new Page(mockOriginalPage);
mockOriginalPage.getNextPage = jest.fn().mockResolvedValue(null)
const page = new Page(mockOriginalPage)
const nextPage = await page.nextPage();
const nextPage = await page.getNextPage()
expect(nextPage).toBeNull();
});
expect(nextPage).toBeNull()
})
it('should handle next page returning undefined', async () => {
mockOriginalPage.nextPage.mockResolvedValue(undefined);
const page = new Page(mockOriginalPage);
it('should return null when getNextPage returns undefined', async () => {
mockOriginalPage.getNextPage = jest.fn().mockResolvedValue(undefined)
const page = new Page(mockOriginalPage)
const nextPage = await page.nextPage();
const nextPage = await page.getNextPage()
expect(nextPage).toBeNull();
});
expect(nextPage).toBeNull()
})
it('should handle error from original page nextPage', async () => {
mockOriginalPage.nextPage.mockRejectedValue(new Error('Failed to get next page'));
const page = new Page(mockOriginalPage);
it('should return null when getNextPage method does not exist', async () => {
const pageWithoutNext = { ...mockOriginalPage }
delete pageWithoutNext.getNextPage
const page = new Page(pageWithoutNext)
await expect(page.nextPage()).rejects.toThrow();
});
});
const nextPage = await page.getNextPage()
describe('edge cases and error handling', () => {
it('should handle null original page', () => {
const page = new Page(null);
expect(nextPage).toBeNull()
})
expect(page['_originalPage']).toBeNull();
expect(page.size).toBeUndefined();
expect(page.total).toBeUndefined();
expect(page.hasNextPage).toBeUndefined();
});
it('should handle transform function that throws error', async () => {
const errorTransform = () => {
throw new Error('Transform error');
};
const page = new Page(mockOriginalPage, errorTransform);
await expect(async () => {
for await (const item of page) {
// This should throw
}
}).rejects.toThrow();
});
it('should handle transform function returning null', async () => {
const nullTransform = () => null;
const page = new Page(mockOriginalPage, nullTransform);
const items: any[] = [];
for await (const item of page) {
items.push(item);
}
expect(items).toEqual([null, null, null]);
});
it('should handle very large datasets', async () => {
const largeItems = Array.from({ length: 10000 }, (_, i) => ({ id: i }));
const largePage = {
items: largeItems,
size: 10000,
total: 10000,
hasNextPage: false,
};
const page = new Page(largePage);
let count = 0;
for await (const item of page) {
count++;
if (count > 10) break; // Don't actually iterate through all 10k items
}
expect(count).toBe(11);
});
it('should handle circular reference in items', async () => {
const circularItem: any = { id: 'circular' };
circularItem.self = circularItem;
const pageWithCircular = {
items: [circularItem],
size: 1,
total: 1,
hasNextPage: false,
};
const page = new Page(pageWithCircular);
const items: any[] = [];
for await (const item of page) {
items.push(item);
}
expect(items).toHaveLength(1);
expect(items[0].id).toBe('circular');
expect(items[0].self).toBe(items[0]);
});
it('should handle items with complex nested structures', async () => {
const complexItems = [
{
id: 'complex1',
nested: {
deep: {
value: 'deeply nested',
array: [1, 2, { nested: 'array object' }],
},
},
metadata: new Map([['key', 'value']]),
it('should propagate transform function to next page', async () => {
const nextPageData = {
items: [{ id: 'item4', name: 'Item 4' }],
[Symbol.asyncIterator]: async function*() {
for (const item of this.items) {
yield item
}
},
];
const complexPage = {
items: complexItems,
size: 1,
total: 1,
hasNextPage: false,
};
const page = new Page(complexPage);
const items: any[] = [];
for await (const item of page) {
items.push(item);
}
const transformFunc = (item: any) => ({ ...item, count: 999 })
mockOriginalPage.getNextPage = jest.fn().mockResolvedValue(nextPageData)
const page = new Page(mockOriginalPage, transformFunc)
expect(items).toEqual(complexItems);
expect(items[0].nested.deep.array[2].nested).toBe('array object');
});
const nextPage = await page.getNextPage()
it('should handle async transform function', async () => {
const asyncTransform = async (item: any) => {
await new Promise(resolve => setTimeout(resolve, 1));
return { ...item, asyncTransformed: true };
};
mockOriginalPage.get.mockResolvedValue(mockItems[0]);
const page = new Page(mockOriginalPage, asyncTransform);
expect(nextPage).not.toBeNull()
const transformedItem = nextPage!.get(0)
expect(transformedItem).toEqual({
id: 'item4',
name: 'Item 4',
count: 999,
})
})
const item = await page.get(0);
it('should handle error from getNextPage', async () => {
mockOriginalPage.getNextPage = jest
.fn()
.mockRejectedValue(new Error('Failed to get next page'))
const page = new Page(mockOriginalPage)
expect(item).toEqual({ ...mockItems[0], asyncTransformed: true });
});
});
});
await expect(page.getNextPage()).rejects.toThrow(
'Failed to get next page'
)
})
})
})

View File

@ -67,7 +67,7 @@ describe('Peer', () => {
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ query: 'Hello', stream: undefined, target: undefined, session_id: undefined }
{ query: 'Hello', stream: false, target: undefined, session_id: undefined }
);
});
@ -89,17 +89,9 @@ describe('Peer', () => {
expect(result).toBeNull();
});
it('should handle chat with streaming option', async () => {
const mockResponse = { content: 'Streamed response' };
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
await peer.chat('Hello', { stream: true });
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ query: 'Hello', stream: true, target: undefined, session_id: undefined }
);
it.skip('should handle chat with streaming option', async () => {
// Skipped: streaming now uses fetch API directly, not the mocked client
// TODO: Add proper streaming tests with fetch mocking when needed
});
it('should handle chat with target peer', async () => {
@ -112,7 +104,7 @@ describe('Peer', () => {
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ query: 'Hello', stream: undefined, target: 'target-peer', session_id: undefined }
{ query: 'Hello', stream: false, target: 'target-peer', session_id: undefined }
);
});
@ -125,7 +117,7 @@ describe('Peer', () => {
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ query: 'Hello', stream: undefined, target: 'string-target', session_id: undefined }
{ query: 'Hello', stream: false, target: 'string-target', session_id: undefined }
);
});
@ -138,23 +130,24 @@ describe('Peer', () => {
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ query: 'Hello', stream: undefined, target: undefined, session_id: 'session-123' }
{ query: 'Hello', stream: false, target: undefined, session_id: 'session-123' }
);
});
it('should handle all options together', async () => {
const targetPeer = new Peer('target-peer', 'test-workspace', mockClient);
const mockResponse = { content: 'Full options response' };
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
// TODO: Re-enable after regenerating Stainless SDK with streaming support
// it('should handle all options together', async () => {
// const targetPeer = new Peer('target-peer', 'test-workspace', mockClient);
// const mockResponse = { content: 'Full options response' };
// mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
await peer.chat('Hello', { stream: true, target: targetPeer, sessionId: 'session-456' });
// await peer.chat('Hello', { stream: true, target: targetPeer, sessionId: 'session-456' });
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ query: 'Hello', stream: true, target: 'target-peer', session_id: 'session-456' }
);
});
// expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
// 'test-workspace',
// 'test-peer',
// { query: 'Hello', stream: true, target: 'target-peer', session_id: 'session-456' }
// );
// });
it('should handle API errors', async () => {
mockClient.workspaces.peers.chat.mockRejectedValue(new Error('Chat failed'));
@ -429,4 +422,96 @@ describe('Peer', () => {
await expect(peer.search('test')).rejects.toThrow();
});
});
describe('card', () => {
beforeEach(() => {
mockClient.workspaces.peers.card = jest.fn();
});
it('should get peer card without target', async () => {
const mockCardResponse = {
peer_card: ['Fact 1 about peer', 'Fact 2 about peer', 'Fact 3 about peer'],
};
mockClient.workspaces.peers.card.mockResolvedValue(mockCardResponse);
const result = await peer.card();
expect(result).toBe('Fact 1 about peer\nFact 2 about peer\nFact 3 about peer');
expect(mockClient.workspaces.peers.card).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ target: undefined }
);
});
it('should get peer card with target as string', async () => {
const mockCardResponse = {
peer_card: ['What peer knows about target'],
};
mockClient.workspaces.peers.card.mockResolvedValue(mockCardResponse);
const result = await peer.card('target-peer');
expect(result).toBe('What peer knows about target');
expect(mockClient.workspaces.peers.card).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ target: 'target-peer' }
);
});
it('should get peer card with target as Peer object', async () => {
const targetPeer = new Peer('target-peer', 'test-workspace', mockClient);
const mockCardResponse = {
peer_card: ['What peer knows about target peer'],
};
mockClient.workspaces.peers.card.mockResolvedValue(mockCardResponse);
const result = await peer.card(targetPeer);
expect(result).toBe('What peer knows about target peer');
expect(mockClient.workspaces.peers.card).toHaveBeenCalledWith(
'test-workspace',
'test-peer',
{ target: 'target-peer' }
);
});
it('should return empty string when peer_card is null', async () => {
const mockCardResponse = {
peer_card: null,
};
mockClient.workspaces.peers.card.mockResolvedValue(mockCardResponse);
const result = await peer.card();
expect(result).toBe('');
});
it('should return empty string when peer_card is undefined', async () => {
const mockCardResponse = {};
mockClient.workspaces.peers.card.mockResolvedValue(mockCardResponse);
const result = await peer.card();
expect(result).toBe('');
});
it('should throw error for empty string target', async () => {
await expect(peer.card('')).rejects.toThrow('target string cannot be empty');
await expect(peer.card(' ')).rejects.toThrow('target string cannot be empty');
});
it('should throw error for invalid target type', async () => {
await expect(peer.card(123 as any)).rejects.toThrow('target must be string, Peer, or undefined');
await expect(peer.card(null as any)).rejects.toThrow('target must be string, Peer, or undefined');
await expect(peer.card({} as any)).rejects.toThrow('target must be string, Peer, or undefined');
});
it('should handle API errors', async () => {
mockClient.workspaces.peers.card.mockRejectedValue(new Error('Card fetch failed'));
await expect(peer.card()).rejects.toThrow('Card fetch failed');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
"": {
"name": "@honcho-ai/sdk",
"dependencies": {
"@honcho-ai/core": "^1.4.0",
"@honcho-ai/core": "^1.4.1-alpha.0",
"@types/node": "^24.0.1",
"zod": "4.0.0",
},
@ -18,15 +18,13 @@
},
},
"packages": {
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="],
"@babel/compat-data": ["@babel/compat-data@7.28.4", "", {}, "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw=="],
"@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="],
"@babel/generator": ["@babel/generator@7.28.0", "", { "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="],
"@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
@ -34,7 +32,7 @@
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.27.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
@ -44,9 +42,9 @@
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.2", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.2" } }, "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="],
"@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
"@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="],
@ -84,31 +82,33 @@
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="],
"@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
"@biomejs/biome": ["@biomejs/biome@2.1.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.1.3", "@biomejs/cli-darwin-x64": "2.1.3", "@biomejs/cli-linux-arm64": "2.1.3", "@biomejs/cli-linux-arm64-musl": "2.1.3", "@biomejs/cli-linux-x64": "2.1.3", "@biomejs/cli-linux-x64-musl": "2.1.3", "@biomejs/cli-win32-arm64": "2.1.3", "@biomejs/cli-win32-x64": "2.1.3" }, "bin": { "biome": "bin/biome" } }, "sha512-KE/tegvJIxTkl7gJbGWSgun7G6X/n2M6C35COT6ctYrAy7SiPyNvi6JtoQERVK/VRbttZfgGq96j2bFmhmnH4w=="],
"@biomejs/biome": ["@biomejs/biome@2.2.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-x64": "2.2.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LFLkSWRoSGS1wVUD/BE6Nlt2dSn0ulH3XImzg2O/36BoToJHKXjSxzPEMAqT9QvwVtk7/9AQhZpTneERU9qaXA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Q/4OTw8P9No9QeowyxswcWdm0n2MsdCwWcc5NcKQQvzwPjwuPdf8dpPPf4r+x0RWKBtl1FLiAUtJvBlri6DnYw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-2hS6LgylRqMFmAZCOFwYrf77QMdUwJp49oe8PX/O8+P2yKZMSpyQTf3Eo5ewnsMFUEmYbPOskafdV1ds1MZMJA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-KXouFSBnoxAWZYDQrnNRzZBbt5s9UJkIm40hdvSL9mBxSSoxRFQJbtg1hP3aa8A2SnXyQHxQfpiVeJlczZt76w=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-NxlSCBhLvQtWGagEztfAZ4WcE1AkMTntZV65ZvR+J9jp06+EtOYEBPQndA70ZGhHbEDG57bR6uNvqkd1WrEYVA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-KaLAxnROouzIWtl6a0Y88r/4hW5oDUJTIqQorOTVQITaKQsKjZX4XCUmHIhdEk8zMnaiLZzRTAwk1yIAl+mIew=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-V9CUZCtWH4u0YwyCYbQ3W5F4ZGPWp2C2TYcsiWFNNyRfmOW1j/TY/jAurl33SaRjgZPO5UUhGyr9m6BN9t84NQ=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-dxy599q6lgp8ANPpR8sDMscwdp9oOumEsVXuVCVT9N2vAho8uYXlCz53JhxX6LtJOXaE73qzgkGQ7QqvFlMC0g=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="],
"@honcho-ai/core": ["@honcho-ai/core@1.4.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-pHyUgkiIlf+pMfZJx7NmJ1UH8yOKWZ+vmPRummg3olgstMXApcBrW0bhJTPjHWoGGA+OFYcOPxJBoZKY6VCtMw=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@honcho-ai/core": ["@honcho-ai/core@1.4.1-alpha.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-cRp3ug8cBe9E7NZDgCXkI0EHIgm8x5Jq/nQB0xA5629GuniS99yq6M786I//FjOZhB2YEZoOxd4Zkg412uk5Yg=="],
"@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="],
@ -142,13 +142,15 @@
"@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
@ -156,6 +158,40 @@
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
"@swc/core": ["@swc/core@1.13.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.24" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.5", "@swc/core-darwin-x64": "1.13.5", "@swc/core-linux-arm-gnueabihf": "1.13.5", "@swc/core-linux-arm64-gnu": "1.13.5", "@swc/core-linux-arm64-musl": "1.13.5", "@swc/core-linux-x64-gnu": "1.13.5", "@swc/core-linux-x64-musl": "1.13.5", "@swc/core-win32-arm64-msvc": "1.13.5", "@swc/core-win32-ia32-msvc": "1.13.5", "@swc/core-win32-x64-msvc": "1.13.5" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.5", "", { "os": "win32", "cpu": "x64" }, "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
"@tsconfig/node10": ["@tsconfig/node10@1.0.11", "", {}, "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw=="],
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
"@tsconfig/node14": ["@tsconfig/node14@1.0.3", "", {}, "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="],
"@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@ -174,7 +210,7 @@
"@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="],
"@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="],
"@types/node": ["@types/node@24.6.1", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
@ -186,6 +222,10 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
@ -196,6 +236,8 @@
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
@ -212,11 +254,13 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="],
"browserslist": ["browserslist@4.26.2", "", { "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", "electron-to-chromium": "^1.5.218", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A=="],
"bs-logger": ["bs-logger@0.2.6", "", { "dependencies": { "fast-json-stable-stringify": "2.x" } }, "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog=="],
@ -230,7 +274,7 @@
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001731", "", {}, "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001746", "", {}, "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@ -258,11 +302,13 @@
"create-jest": ["create-jest@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", "exit": "^0.1.2", "graceful-fs": "^4.2.9", "jest-config": "^29.7.0", "jest-util": "^29.7.0", "prompts": "^2.0.1" }, "bin": { "create-jest": "bin/create-jest.js" } }, "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q=="],
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"dedent": ["dedent@1.6.0", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA=="],
"dedent": ["dedent@1.7.0", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
@ -270,17 +316,19 @@
"detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="],
"diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.197", "", {}, "sha512-m1xWB3g7vJ6asIFz+2pBUbq3uGmfmln1M9SSvBe4QIFWYrRHylP73zL/3nMjDmwz8V+1xAXQDfBd6+HPW0WvDQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.228", "", {}, "sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA=="],
"emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="],
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@ -388,7 +436,7 @@
"istanbul-lib-source-maps": ["istanbul-lib-source-maps@4.0.1", "", { "dependencies": { "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0", "source-map": "^0.6.1" } }, "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw=="],
"istanbul-reports": ["istanbul-reports@3.1.7", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"jest": ["jest@29.7.0", "", { "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", "import-local": "^3.0.2", "jest-cli": "^29.7.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "bin/jest.js" } }, "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw=="],
@ -498,7 +546,7 @@
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"node-releases": ["node-releases@2.0.21", "", {}, "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@ -594,20 +642,24 @@
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"ts-jest": ["ts-jest@29.4.1", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.7.2", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "optionalPeers": ["@babel/core", "@jest/transform", "@jest/types", "babel-jest", "jest-util"], "bin": { "ts-jest": "cli.js" } }, "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw=="],
"ts-jest": ["ts-jest@29.4.4", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.7.2", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "optionalPeers": ["@babel/core", "@jest/transform", "@jest/types", "babel-jest", "jest-util"], "bin": { "ts-jest": "cli.js" } }, "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw=="],
"ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="],
"type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
"v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="],
"walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="],
@ -636,6 +688,8 @@
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.0.0", "", {}, "sha512-9diLdTPc/L7w/5jI4C3gHYNiGHDV9IZYxo1e5LSD8cabi65WVTWWb+g2BGPEpUUCOxR4D+6O5B0AzyMdUAXwrw=="],
@ -644,7 +698,9 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@honcho-ai/core/@types/node": ["@types/node@18.19.122", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-yzegtT82dwTNEe/9y+CM8cgb42WrUfMMCg2QqSddzO1J6uPmBD7qKCZ7dOHZP2Yrpm/kb0eqdNMn2MUyEiqBmA=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@honcho-ai/core/@types/node": ["@types/node@18.19.129", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A=="],
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],

View File

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

View File

@ -1,10 +1,10 @@
import HonchoCore from '@honcho-ai/core'
import type { DefaultQuery } from '@honcho-ai/core/src/core'
import type { Message } from '@honcho-ai/core/src/resources/workspaces/sessions/messages'
import type { DefaultQuery } from '@honcho-ai/core/core'
import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
import type {
DeriverStatus,
WorkspaceDeriverStatusParams,
} from '@honcho-ai/core/src/resources/workspaces/workspaces'
} from '@honcho-ai/core/resources/workspaces/workspaces'
import { Page } from './pagination'
import { Peer } from './peer'
import { Session } from './session'
@ -16,6 +16,7 @@ import {
type HonchoConfig,
HonchoConfigSchema,
LimitSchema,
MessageMetadataSchema,
type PeerConfig,
PeerConfigSchema,
PeerIdSchema,
@ -449,6 +450,47 @@ export class Honcho {
}
}
/**
* Update the metadata of a message.
*
* Makes an API call to update the metadata of a specific message within a session.
*
* @param message - Either a Message object or a message ID string
* @param metadata - The metadata to update for the message
* @param sessionId - The ID of the session (required if message is a string ID, ignored if message is a Message object)
* @returns Promise resolving to the updated Message object
* @throws Error if message is a string ID but sessionId is not provided
*/
async updateMessage(
message: Message | string,
metadata: Record<string, unknown>,
sessionId?: string
): Promise<Message> {
const validatedMetadata = MessageMetadataSchema.parse(metadata)
let messageId: string
let resolvedSessionId: string
if (typeof message === 'string') {
messageId = message
if (!sessionId) {
throw new Error('sessionId is required when message is a string ID')
}
resolvedSessionId = sessionId
} else {
messageId = message.id
resolvedSessionId = message.session_id
}
return await this._client.workspaces.sessions.messages.update(
this.workspaceId,
resolvedSessionId,
messageId,
{
metadata: validatedMetadata,
}
)
}
/**
* Return a string representation of the Honcho client.
*

View File

@ -1,7 +1,7 @@
// Main entry point for the Honcho TypeScript SDK
// Exports all main classes and types
export { Message } from '@honcho-ai/core/src/resources/workspaces/sessions/messages'
export type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
export { Honcho } from './client'
export { Page } from './pagination'
export { Peer } from './peer'
@ -12,6 +12,11 @@ export {
Summary,
SummaryData,
} from './session_context'
export {
type DialecticStreamChunk,
type DialecticStreamDelta,
DialecticStreamResponse,
} from './types'
// Export validation types for advanced usage
export type {

View File

@ -1,80 +1,127 @@
import type { Page as CorePage } from '@honcho-ai/core/pagination'
/**
* Generic paginated result wrapper for Honcho SDK.
* Provides async iteration and transformation capabilities.
* Provides async iteration and transformation capabilities while preserving
* pagination functionality from the underlying core Page.
*/
export class Page<T> implements AsyncIterable<T> {
private _originalPage: any
private _transformFunc?: (item: any) => T
export class Page<T, TOriginal = any> implements AsyncIterable<T> {
private _originalPage: CorePage<TOriginal>
private _transformFunc?: (item: TOriginal) => T
/**
* Initialize a new Page.
*
* @param originalPage - The original Page to wrap
* @param transformFunc - Optional function to transform objects from the original type to type T.
* If not provided, objects are passed through unchanged.
*/
constructor(originalPage: any, transformFunc?: (item: any) => T) {
constructor(
originalPage: CorePage<TOriginal>,
transformFunc?: (item: TOriginal) => T
) {
this._originalPage = originalPage
this._transformFunc = transformFunc
}
/**
* Async iterator for the page's items.
* Async iterator for all transformed items across all pages.
*/
async *[Symbol.asyncIterator](): AsyncIterator<T> {
// Handle different page structure formats
const items = this._originalPage.items || this._originalPage.data || []
for (const item of items) {
yield this._transformFunc ? this._transformFunc(item) : item
for await (const item of this._originalPage) {
yield this._transformFunc
? this._transformFunc(item)
: (item as unknown as T)
}
}
/**
* Get an item by index.
* Get a transformed item by index on the current page.
*/
async get(index: number): Promise<T> {
if (
!this._originalPage?.get ||
typeof this._originalPage.get !== 'function'
) {
throw new Error('Original page does not support indexed access')
get(index: number): T {
const items = this._originalPage.items || []
if (index < 0 || index >= items.length) {
throw new RangeError(
`Index ${index} is out of bounds for page with ${items.length} items`
)
}
const item = await this._originalPage.get(index)
return this._transformFunc ? this._transformFunc(item) : item
const item = items[index]
return this._transformFunc
? this._transformFunc(item)
: (item as unknown as T)
}
/**
* Get the size of the page.
* Get the number of items on the current page.
*/
get size(): number {
return this._originalPage?.size
get length(): number {
const items = this._originalPage.items || []
return items.length
}
/**
* Get the total number of items.
* Get all transformed items on the current page.
*/
get total(): number {
get items(): T[] {
const items = this._originalPage.items || []
return this._transformFunc
? items.map(this._transformFunc)
: (items as unknown as T[])
}
/**
* Get the total number of items across all pages.
*/
get total(): number | undefined {
return this._originalPage?.total
}
/**
* Get all data as a list.
* Get the current page number.
*/
async data(): Promise<T[]> {
const items = this._originalPage.items || this._originalPage.data || []
const data = typeof items === 'function' ? await items() : items
return this._transformFunc ? data.map(this._transformFunc) : data
get page(): number | undefined {
return this._originalPage?.page
}
/**
* Get the page size.
*/
get size(): number | undefined {
return this._originalPage?.size
}
/**
* Get the total number of pages.
*/
get pages(): number | undefined {
return this._originalPage?.pages
}
/**
* Check if there's a next page.
*/
get hasNextPage(): boolean {
return this._originalPage?.hasNextPage
const hasNext = this._originalPage.hasNextPage
if (typeof hasNext === 'function') {
return hasNext()
}
return hasNext || false
}
/**
* Get the next page.
* Fetch the next page of results.
* Returns null if there are no more pages.
*/
async nextPage(): Promise<Page<T> | null> {
const nextPage = await this._originalPage.nextPage()
if (!nextPage) return null
return new Page(nextPage, this._transformFunc)
async getNextPage(): Promise<Page<T, TOriginal> | null> {
if (!this._originalPage.getNextPage) {
return null
}
const nextOriginalPage = await this._originalPage.getNextPage()
if (!nextOriginalPage) {
return null
}
return new Page(nextOriginalPage, this._transformFunc)
}
}

View File

@ -1,7 +1,8 @@
import type HonchoCore from '@honcho-ai/core'
import type { Message } from '@honcho-ai/core/src/resources/workspaces/sessions/messages'
import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
import { Page } from './pagination'
import { Session } from './session'
import { type DialecticStreamChunk, DialecticStreamResponse } from './types'
import {
ChatQuerySchema,
FilterSchema,
@ -61,8 +62,9 @@ export class Peer {
* querying the peer's global representation
* @param sessionId - Optional session ID to scope the query to a specific session.
* If provided, only information from that session is considered
* @returns Promise resolving to response string containing the answer to the query,
* or null if no relevant information is available
* @returns Promise resolving to:
* - For non-streaming: response string or null if no relevant information
* - For streaming: DialecticStreamResponse that can be iterated over
*/
async chat(
query: string,
@ -71,19 +73,98 @@ export class Peer {
target?: string | Peer
sessionId?: string
}
): Promise<string | null> {
): Promise<string | DialecticStreamResponse | null> {
const chatParams = ChatQuerySchema.parse({
query,
stream: options?.stream,
target: options?.target,
sessionId: options?.sessionId,
})
if (chatParams.stream) {
const body = {
query: chatParams.query,
stream: true,
target: chatParams.target
? typeof chatParams.target === 'string'
? chatParams.target
: chatParams.target.id
: undefined,
session_id: chatParams.sessionId,
}
const url = `${this._client.baseURL}/v2/workspaces/${this.workspaceId}/peers/${this.id}/chat`
const apiKey = this._client.apiKey
async function* streamResponse(): AsyncGenerator<
string,
void,
undefined
> {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
// Include auth headers if present
...(apiKey && {
Authorization: `Bearer ${apiKey}`,
}),
},
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
if (!response.body) {
throw new Error('Response body is null')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6) // Remove "data: " prefix
try {
const chunkData: DialecticStreamChunk = JSON.parse(jsonStr)
if (chunkData.done) {
return
}
const content = chunkData.delta.content
if (content) {
yield content
}
} catch {}
}
}
}
} finally {
reader.releaseLock()
}
}
return new DialecticStreamResponse(streamResponse())
}
const response = await this._client.workspaces.peers.chat(
this.workspaceId,
this.id,
{
query: chatParams.query,
stream: chatParams.stream,
stream: false,
target: chatParams.target
? typeof chatParams.target === 'string'
? chatParams.target
@ -258,6 +339,50 @@ export class Peer {
)
}
/**
* Get the peer card for this peer.
*
* Makes an API call to retrieve the peer card, which contains a representation
* of what this peer knows. If a target is provided, returns this peer's local
* representation of the target peer.
*
* @param target - Optional target peer for local card. If provided, returns this
* peer's card of the target peer. Can be a Peer object or peer ID string.
* @returns Promise resolving to a string containing the peer card
*/
async card(target?: string | Peer): Promise<string> {
// Validate target parameter
if (
target !== undefined &&
typeof target !== 'string' &&
!(target instanceof Peer)
) {
throw new TypeError(
`target must be string, Peer, or undefined, got ${typeof target}`
)
}
if (typeof target === 'string' && target.trim().length === 0) {
throw new Error('target string cannot be empty')
}
const response = await this._client.workspaces.peers.card(
this.workspaceId,
this.id,
{
target: target instanceof Peer ? target.id : target,
}
)
if (!response.peer_card) {
return ''
}
const items: string[] = response.peer_card
return items.join('\n')
}
/**
* Return a string representation of the Peer.
*

View File

@ -1,11 +1,17 @@
import type HonchoCore from '@honcho-ai/core'
import type { Message } from '@honcho-ai/core/src/resources/workspaces/sessions/messages'
import type { Uploadable } from '@honcho-ai/core/src/uploads'
import type {
DeriverStatus,
WorkspaceDeriverStatusParams,
} from '@honcho-ai/core/resources/index'
import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
import type { Uploadable } from '@honcho-ai/core/uploads'
import { Page } from './pagination'
import { Peer } from './peer'
import { SessionContext, SessionSummaries, Summary } from './session_context'
import {
ContextParamsSchema,
type DeriverStatusOptions,
DeriverStatusOptionsSchema,
FileUploadSchema,
FilterSchema,
type Filters,
@ -427,6 +433,15 @@ export class Session {
})
}
/**
* Delete this session.
*
* Makes an API call to mark this session as inactive.
*/
async delete(): Promise<void> {
await this._client.workspaces.sessions.delete(this.workspaceId, this.id)
}
/**
* Get optimized context for this session within a token limit.
*
@ -532,6 +547,112 @@ export class Session {
)
}
/**
* Get the deriver processing status for this session, optionally scoped to an observer or sender.
*
* Makes an API call to retrieve the current status of the deriver processing queue.
* The deriver is responsible for processing messages and updating peer representations.
* This method automatically scopes the status to this session.
*
* @param options - Configuration options for the status request
* @param options.observerId - Optional observer ID to scope the status to
* @param options.senderId - Optional sender ID to scope the status to
* @returns Promise resolving to the deriver status information including work unit counts
*/
async getDeriverStatus(
options?: Omit<DeriverStatusOptions, 'sessionId'>
): Promise<{
totalWorkUnits: number
completedWorkUnits: number
inProgressWorkUnits: number
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
}> {
const validatedOptions = options
? DeriverStatusOptionsSchema.parse(options)
: undefined
const queryParams: WorkspaceDeriverStatusParams = {
session_id: this.id, // Always use this session's ID
}
if (validatedOptions?.observerId)
queryParams.observer_id = validatedOptions.observerId
if (validatedOptions?.senderId)
queryParams.sender_id = validatedOptions.senderId
const status = await this._client.workspaces.deriverStatus(
this.workspaceId,
queryParams
)
return {
totalWorkUnits: status.total_work_units,
completedWorkUnits: status.completed_work_units,
inProgressWorkUnits: status.in_progress_work_units,
pendingWorkUnits: status.pending_work_units,
sessions: status.sessions || undefined,
}
}
/**
* Poll getDeriverStatus until pending_work_units and in_progress_work_units are both 0.
* This allows you to guarantee that all messages have been processed by the deriver for
* use with the dialectic endpoint.
*
* The polling estimates sleep time by assuming each work unit takes 1 second.
*
* @param options - Configuration options for the status request
* @param options.observerId - Optional observer ID to scope the status to
* @param options.senderId - Optional sender ID to scope the status to
* @param options.timeoutMs - Optional timeout in milliseconds (default: 300000 - 5 minutes)
* @returns Promise resolving to the final deriver status when processing is complete
* @throws Error if timeout is exceeded before processing completes
*/
async pollDeriverStatus(
options?: Omit<DeriverStatusOptions, 'sessionId'>
): Promise<{
totalWorkUnits: number
completedWorkUnits: number
inProgressWorkUnits: number
pendingWorkUnits: number
sessions?: Record<string, DeriverStatus.Sessions>
}> {
const validatedOptions = options
? DeriverStatusOptionsSchema.parse(options)
: undefined
const timeoutMs = validatedOptions?.timeoutMs ?? 300000 // Default to 5 minutes
const startTime = Date.now()
while (true) {
const status = await this.getDeriverStatus(validatedOptions)
if (status.pendingWorkUnits === 0 && status.inProgressWorkUnits === 0) {
return status
}
// Check if timeout has been exceeded
const elapsedTime = Date.now() - startTime
if (elapsedTime >= timeoutMs) {
throw new Error(
`Polling timeout exceeded after ${timeoutMs}ms. ` +
`Current status: ${status.pendingWorkUnits} pending, ${status.inProgressWorkUnits} in progress work units.`
)
}
// Sleep for the expected time to complete all current work units
// Assuming each pending and in-progress work unit takes 1 second
const totalWorkUnits =
status.pendingWorkUnits + status.inProgressWorkUnits
const sleepMs = Math.max(1000, totalWorkUnits * 1000) // Sleep at least 1 second
// Ensure we don't sleep past the timeout
const remainingTime = timeoutMs - elapsedTime
const actualSleepMs = Math.min(sleepMs, remainingTime)
if (actualSleepMs > 0) {
await new Promise((resolve) => setTimeout(resolve, actualSleepMs))
}
}
}
/**
* Upload a file to create messages in this session.
*

View File

@ -1,4 +1,4 @@
import type { Message } from '@honcho-ai/core/src/resources/workspaces/sessions/messages'
import type { Message } from '@honcho-ai/core/resources/workspaces/sessions/messages'
import type { Peer } from './peer'
export interface SummaryData {

View File

@ -0,0 +1,86 @@
/**
* Shared types for the Honcho TypeScript SDK.
*/
/**
* Delta object for streaming dialectic responses.
*/
export interface DialecticStreamDelta {
content?: string
// Future fields can be added here:
// premises?: string[]
// tokens?: number
// analytics?: Record<string, unknown>
}
/**
* Chunk in a streaming dialectic response.
*/
export interface DialecticStreamChunk {
delta: DialecticStreamDelta
done: boolean
}
/**
* Iterator for streaming dialectic responses with utilities for accessing the final response.
*
* Similar to OpenAI and Anthropic streaming patterns, this allows you to:
* - Iterate over chunks as they arrive
* - Access the final accumulated response after streaming completes
*
* @example
* ```typescript
* const stream = await peer.chat("Hello", { stream: true })
*
* // Stream chunks
* for await (const chunk of stream) {
* process.stdout.write(chunk)
* }
*
* // Get final response object
* const final = stream.getFinalResponse()
* console.log(`\nFull content: ${final.content}`)
* ```
*/
export class DialecticStreamResponse implements AsyncIterable<string> {
private iterator: AsyncIterator<string>
private accumulatedContent: string[] = []
private _isComplete = false
constructor(iterator: AsyncIterator<string>) {
this.iterator = iterator
}
[Symbol.asyncIterator](): AsyncIterator<string> {
return {
next: async () => {
const result = await this.iterator.next()
if (result.done) {
this._isComplete = true
return { done: true, value: undefined }
}
this.accumulatedContent.push(result.value)
return { done: false, value: result.value }
},
}
}
/**
* Get the final accumulated response after streaming completes.
*
* @returns An object with the full content
*
* @note This should be called after the stream has been fully consumed.
* If called before completion, it returns the content accumulated so far.
*/
getFinalResponse(): { content: string } {
return { content: this.accumulatedContent.join('') }
}
/**
* Check if the stream has finished.
*/
get isComplete(): boolean {
return this._isComplete
}
}

View File

@ -118,7 +118,7 @@ export const FilterSchema = z.record(z.string(), z.unknown()).optional()
*/
export const ChatQuerySchema = z.object({
query: SearchQuerySchema,
stream: z.boolean().optional(),
stream: z.boolean().optional().default(false),
target: z.union([z.string(), z.object({ id: z.string() })]).optional(),
sessionId: z.string().optional(),
})

View File

@ -188,7 +188,7 @@ class DeriverSettings(HonchoSettings):
STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = 5
PROVIDER: SupportedProviders = "google"
MODEL: str = "gemini-2.5-flash"
MODEL: str = "gemini-2.5-flash-lite"
MAX_OUTPUT_TOKENS: Annotated[int, Field(default=2500, gt=0, le=100_000)] = 2500
# Thinking budget tokens are only applied when using Anthropic as provider

View File

@ -1,6 +1,7 @@
import logging
from typing import Any
import sentry_sdk
from langfuse import get_client
from pydantic import ValidationError
from rich.console import Console
@ -51,19 +52,29 @@ async def process_item(task_type: str, queue_payload: dict[str, Any]) -> None:
queue_payload,
)
raise ValueError(f"Invalid payload structure: {str(e)}") from e
if settings.LANGFUSE_PUBLIC_KEY:
with lf.start_as_current_span(
name="summary_processing",
input={
"workspace_name": validated.workspace_name,
"session_name": validated.session_name,
"message_id": validated.message_id,
},
metadata={
"summary_model": settings.SUMMARY.MODEL,
},
):
with sentry_sdk.start_transaction(name="process_summary_task", op="deriver"):
if settings.LANGFUSE_PUBLIC_KEY:
with lf.start_as_current_span(
name="summary_processing",
input={
"workspace_name": validated.workspace_name,
"session_name": validated.session_name,
"message_id": validated.message_id,
},
metadata={
"summary_model": settings.SUMMARY.MODEL,
},
):
await summarizer.summarize_if_needed(
validated.workspace_name,
validated.session_name,
validated.message_id,
validated.message_seq_in_session,
)
log_performance_metrics(
f"summary_{validated.workspace_name}_{validated.message_id}"
)
else:
await summarizer.summarize_if_needed(
validated.workspace_name,
validated.session_name,
@ -73,16 +84,6 @@ async def process_item(task_type: str, queue_payload: dict[str, Any]) -> None:
log_performance_metrics(
f"summary_{validated.workspace_name}_{validated.message_id}"
)
else:
await summarizer.summarize_if_needed(
validated.workspace_name,
validated.session_name,
validated.message_id,
validated.message_seq_in_session,
)
log_performance_metrics(
f"summary_{validated.workspace_name}_{validated.message_id}"
)
else:
raise ValueError(f"Invalid task type: {task_type}")

View File

@ -40,6 +40,7 @@ from src.utils.shared_models import (
ReasoningResponseWithThinking,
UnifiedObservation,
)
from src.utils.tracing import with_sentry_transaction
from .prompts import (
critical_analysis_prompt,
@ -116,7 +117,7 @@ async def peer_card_call(
return response.content
@sentry_sdk.trace
@with_sentry_transaction("process_representation_tasks_batch", op="deriver")
async def process_representation_tasks_batch(
sender_name: str,
target_name: str,

View File

@ -288,8 +288,6 @@ class QueueManager:
######################
# Queue Worker Logic #
######################
@sentry_sdk.trace
async def process_work_unit(self, work_unit_key: str, worker_id: str) -> None:
"""Process all messages for a specific work unit by routing to the correct handler."""
logger.debug(

View File

@ -117,7 +117,7 @@ app = FastAPI(
title="Honcho API",
summary="The Identity Layer for the Agentic World",
description="""Honcho is a platform for giving agents user-centric memory and social cognition""",
version="2.3.2",
version="2.3.3",
contact={
"name": "Plastic Labs",
"url": "https://honcho.dev",

View File

@ -137,11 +137,15 @@ async def get_sessions_for_peer(
@router.post(
"/{peer_id}/chat",
response_model=schemas.DialecticResponse,
responses={
200: {
"description": "Response to a question informed by Honcho's User Representation",
"content": {"text/event-stream": {}},
"content": {
"application/json": {
"schema": schemas.DialecticResponse.model_json_schema()
},
"text/event-stream": {},
},
},
},
dependencies=[
@ -186,7 +190,17 @@ async def chat(
)
if isinstance(stream, AsyncIterator):
async for chunk in stream:
yield chunk.content
if chunk.content:
# Send each chunk as JSON with nested delta object
stream_chunk = schemas.DialecticStreamChunk(
delta=schemas.DialecticStreamDelta(content=chunk.content)
)
yield f"data: {stream_chunk.model_dump_json()}\n\n"
# Send final done message
final_chunk = schemas.DialecticStreamChunk(
delta=schemas.DialecticStreamDelta(), done=True
)
yield f"data: {final_chunk.model_dump_json()}\n\n"
else:
raise HTTPException(status_code=500, detail="Invalid stream type")
except Exception as e:

View File

@ -315,6 +315,23 @@ class DialecticResponse(BaseModel):
content: str
class DialecticStreamDelta(BaseModel):
"""Delta object for streaming dialectic responses."""
content: str | None = None
# Future fields can be added here:
# premises: str | None = None
# tokens: int | None = None
# analytics: dict[str, Any] | None = None
class DialecticStreamChunk(BaseModel):
"""Chunk in a streaming dialectic response."""
delta: DialecticStreamDelta
done: bool = False
class SessionCounts(BaseModel):
"""Counts for a specific session in queue processing."""

48
src/utils/tracing.py Normal file
View File

@ -0,0 +1,48 @@
import asyncio
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar, cast
import sentry_sdk
P = ParamSpec("P")
T = TypeVar("T")
def with_sentry_transaction(
name: str, op: str
) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""Decorator to wrap a function in a Sentry transaction.
Args:
name: The name of the transaction.
op: The operation type (e.g., "deriver", "http.server").
Returns:
A decorator that wraps the target function in a Sentry transaction.
Example:
@with_sentry_transaction("process_task", op="deriver")
def process_task(data):
...
"""
def decorator(func: Callable[P, T]) -> Callable[P, T]:
if asyncio.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
with sentry_sdk.start_transaction(name=name, op=op):
return await func(*args, **kwargs)
return cast(Callable[P, T], async_wrapper)
else:
@wraps(func)
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
with sentry_sdk.start_transaction(name=name, op=op):
return func(*args, **kwargs)
return sync_wrapper
return decorator

View File

@ -563,8 +563,10 @@ except Exception as e:
# Create an empty .env file in temp directory to satisfy docker-compose
# (even though the database service doesn't actually use it)
if self.temp_dir:
if self.temp_dir and self.temp_dir.exists():
(self.temp_dir / ".env").touch()
else:
raise Exception("Temporary directory does not exist")
# Start database
self.start_database()

View File

@ -3,6 +3,7 @@ from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from honcho_core.types import DeriverStatus
from honcho_core.types.workspaces.sessions.message import Message
from sdks.python.src.honcho.async_client.client import AsyncHoncho
from sdks.python.src.honcho.async_client.pagination import AsyncPage
@ -318,3 +319,118 @@ async def test_poll_deriver_status(client_fixture: tuple[Honcho | AsyncHoncho, s
observer_id=peer.id, sender_id=peer.id
)
assert isinstance(status, DeriverStatus)
@pytest.mark.asyncio
async def test_update_message_with_message_object(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests updating message metadata using a Message object.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
session = await honcho_client.session(id="test-update-msg-session")
peer = await honcho_client.peer(id="test-update-msg-peer")
# Create a message
await session.add_messages([peer.message("test message")])
messages = await session.get_messages()
assert len(messages) >= 1
message = messages[0]
assert isinstance(message, Message)
# Update using Message object
updated = await honcho_client.update_message(message, {"key": "value"})
assert isinstance(updated, Message)
assert updated.metadata == {"key": "value"}
assert updated.id == message.id
else:
assert isinstance(honcho_client, Honcho)
session = honcho_client.session(id="test-update-msg-session")
peer = honcho_client.peer(id="test-update-msg-peer")
# Create a message
session.add_messages([peer.message("test message")])
messages = session.get_messages()
assert len(messages) >= 1
message = messages[0]
assert isinstance(message, Message)
# Update using Message object
updated = honcho_client.update_message(message, {"key": "value"})
assert isinstance(updated, Message)
assert updated.metadata == {"key": "value"}
assert updated.id == message.id
@pytest.mark.asyncio
async def test_update_message_with_message_id(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests updating message metadata using message_id string.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
session = await honcho_client.session(id="test-update-msg-id-session")
peer = await honcho_client.peer(id="test-update-msg-id-peer")
# Create a message
await session.add_messages([peer.message("test message")])
messages = await session.get_messages()
assert len(messages) >= 1
message = messages[0]
assert message.metadata == {}
# Update using message_id string
updated = await honcho_client.update_message(
message.id, {"updated": True}, session_id=session.id
)
assert isinstance(updated, Message)
assert updated.metadata == {"updated": True}
assert updated.id == message.id
else:
assert isinstance(honcho_client, Honcho)
session = honcho_client.session(id="test-update-msg-id-session")
peer = honcho_client.peer(id="test-update-msg-id-peer")
# Create a message
messages = session.add_messages([peer.message("test message")])
messages = session.get_messages()
assert len(messages) >= 1
message = messages[0]
assert message.metadata == {}
# Update using message_id string
updated = honcho_client.update_message(
message.id, {"updated": True}, session_id=session.id
)
assert isinstance(updated, Message)
assert updated.metadata == {"updated": True}
assert updated.id == message.id
@pytest.mark.asyncio
async def test_update_message_validation(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that update_message raises ValueError when message_id is provided without session_id.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
with pytest.raises(ValueError, match="session_id is required"):
await honcho_client.update_message("msg_123", {"key": "value"})
else:
assert isinstance(honcho_client, Honcho)
with pytest.raises(ValueError, match="session_id is required"):
honcho_client.update_message("msg_123", {"key": "value"})

View File

@ -0,0 +1,284 @@
import pytest
from sdks.python.src.honcho.async_client.client import AsyncHoncho
from sdks.python.src.honcho.async_client.pagination import AsyncPage
from sdks.python.src.honcho.async_client.peer import AsyncPeer
from sdks.python.src.honcho.client import Honcho
from sdks.python.src.honcho.pagination import SyncPage
from sdks.python.src.honcho.peer import Peer
@pytest.mark.asyncio
async def test_sync_page_get_next_page(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that SyncPage.get_next_page() works correctly for sync clients.
"""
honcho_client, client_type = client_fixture
if client_type != "sync":
pytest.skip("Test only applicable to sync client")
assert isinstance(honcho_client, Honcho)
# Create multiple peers to test pagination
for i in range(15):
peer = honcho_client.peer(id=f"pagination-test-peer-{i}")
peer.get_metadata() # Create the peer
# Get first page - SDK doesn't expose size parameter yet, so we get default pagination
first_page = honcho_client.get_peers()
assert isinstance(first_page, SyncPage)
assert len(first_page.items) > 0
# If there's a next page, test get_next_page()
if first_page.has_next_page():
second_page = first_page.get_next_page()
assert second_page is not None
assert isinstance(second_page, SyncPage)
assert second_page.page == 2
@pytest.mark.asyncio
async def test_async_page_get_next_page(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that AsyncPage.get_next_page() works correctly for async clients.
"""
honcho_client, client_type = client_fixture
if client_type != "async":
pytest.skip("Test only applicable to async client")
assert isinstance(honcho_client, AsyncHoncho)
# Create multiple peers to test pagination
for i in range(15):
peer = await honcho_client.peer(id=f"pagination-test-peer-async-{i}")
await peer.get_metadata() # Create the peer
# Get first page - SDK doesn't expose size parameter yet, so we get default pagination
first_page = await honcho_client.get_peers()
assert isinstance(first_page, AsyncPage)
assert len(first_page.items) > 0
# If there's a next page, test get_next_page()
if first_page.has_next_page():
second_page = await first_page.get_next_page()
assert second_page is not None
assert isinstance(second_page, AsyncPage)
assert second_page.page == 2
@pytest.mark.asyncio
async def test_sync_page_transform_preserved_across_pages(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that transformation function is preserved when getting next page (sync).
"""
honcho_client, client_type = client_fixture
if client_type != "sync":
pytest.skip("Test only applicable to sync client")
assert isinstance(honcho_client, Honcho)
# Create multiple peers to ensure pagination
for i in range(25):
peer = honcho_client.peer(id=f"transform-test-peer-{i}")
peer.get_metadata()
# Get first page
first_page = honcho_client.get_peers()
assert isinstance(first_page, SyncPage)
# Verify items are Peer instances (transformed)
for item in first_page.items:
assert isinstance(item, Peer)
# Get next page and verify transformation is preserved
if first_page.has_next_page():
second_page = first_page.get_next_page()
assert second_page is not None
for item in second_page.items:
assert isinstance(item, Peer)
@pytest.mark.asyncio
async def test_async_page_transform_preserved_across_pages(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that transformation function is preserved when getting next page (async).
"""
honcho_client, client_type = client_fixture
if client_type != "async":
pytest.skip("Test only applicable to async client")
assert isinstance(honcho_client, AsyncHoncho)
# Create multiple peers to ensure pagination
for i in range(25):
peer = await honcho_client.peer(id=f"transform-test-peer-async-{i}")
await peer.get_metadata()
# Get first page
first_page = await honcho_client.get_peers()
assert isinstance(first_page, AsyncPage)
# Verify items are AsyncPeer instances (transformed)
for item in first_page.items:
assert isinstance(item, AsyncPeer)
# Get next page and verify transformation is preserved
if first_page.has_next_page():
second_page = await first_page.get_next_page()
assert second_page is not None
for item in second_page.items:
assert isinstance(item, AsyncPeer)
@pytest.mark.asyncio
async def test_sync_page_get_next_page_throws_exception_on_last_page(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that get_next_page() returns None when on the last page (sync).
"""
honcho_client, client_type = client_fixture
if client_type != "sync":
pytest.skip("Test only applicable to sync client")
assert isinstance(honcho_client, Honcho)
# Create just a few peers to ensure we're on the last page
for i in range(3):
peer = honcho_client.peer(id=f"last-page-test-peer-{i}")
peer.get_metadata()
# Get first page
first_page = honcho_client.get_peers()
assert isinstance(first_page, SyncPage)
# Should be on last page (or only page)
if not first_page.has_next_page():
# get_next_page should return None
try:
first_page.get_next_page()
except Exception as e:
assert isinstance(e, RuntimeError)
@pytest.mark.asyncio
async def test_async_page_get_next_page_throws_exception_on_last_page(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests that get_next_page() returns None when on the last page (async).
"""
honcho_client, client_type = client_fixture
if client_type != "async":
pytest.skip("Test only applicable to async client")
assert isinstance(honcho_client, AsyncHoncho)
# Create just a few peers to ensure we're on the last page
for i in range(3):
peer = await honcho_client.peer(id=f"last-page-test-peer-async-{i}")
await peer.get_metadata()
# Get first page
first_page = await honcho_client.get_peers()
assert isinstance(first_page, AsyncPage)
# Should be on last page (or only page)
if not first_page.has_next_page():
# get_next_page should throw runtime error
try:
await first_page.get_next_page()
except Exception as e:
assert isinstance(e, RuntimeError)
@pytest.mark.asyncio
async def test_sync_page_manual_pagination(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests manual pagination with get_next_page (sync).
"""
honcho_client, client_type = client_fixture
if client_type != "sync":
pytest.skip("Test only applicable to sync client")
assert isinstance(honcho_client, Honcho)
# Create enough peers to ensure multiple pages
for i in range(25):
peer = honcho_client.peer(id=f"manual-pagination-test-peer-{i}")
peer.get_metadata()
# Collect items via manual pagination
manual_items = []
manual_page = honcho_client.get_peers()
page_count = 0
while manual_page is not None:
page_count += 1
manual_items.extend(manual_page.items) # pyright: ignore
if not manual_page.has_next_page():
break
manual_page = manual_page.get_next_page()
# Should have collected all items
assert len(manual_items) >= 25 # pyright: ignore
# All items should be Peer instances
assert all(isinstance(item, Peer) for item in manual_items) # pyright: ignore
@pytest.mark.asyncio
async def test_async_page_manual_pagination(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests manual pagination with get_next_page (async).
"""
honcho_client, client_type = client_fixture
if client_type != "async":
pytest.skip("Test only applicable to async client")
assert isinstance(honcho_client, AsyncHoncho)
# Create enough peers to ensure multiple pages
for i in range(25):
peer = await honcho_client.peer(id=f"manual-pagination-test-peer-async-{i}")
await peer.get_metadata()
# Collect items via manual pagination
manual_items = []
manual_page = await honcho_client.get_peers()
page_count = 0
while manual_page is not None:
page_count += 1
manual_items.extend(manual_page.items) # pyright: ignore
if not manual_page.has_next_page():
break
manual_page = await manual_page.get_next_page()
# Should have collected all items
assert len(manual_items) >= 25 # pyright: ignore
# All items should be AsyncPeer instances
assert all(isinstance(item, AsyncPeer) for item in manual_items) # pyright: ignore

View File

@ -1,9 +1,12 @@
from unittest.mock import AsyncMock, Mock, patch
import pytest
from sdks.python.src.honcho.async_client.client import AsyncHoncho
from sdks.python.src.honcho.async_client.peer import AsyncPeer
from sdks.python.src.honcho.client import Honcho
from sdks.python.src.honcho.peer import Peer
from sdks.python.src.honcho.types import DialecticStreamResponse
@pytest.mark.asyncio
@ -74,3 +77,211 @@ async def test_peer_get_sessions(client_fixture: tuple[Honcho | AsyncHoncho, str
session_ids = {s.id for s in sessions}
assert "s1" in session_ids
assert "s2" in session_ids
@pytest.mark.asyncio
async def test_peer_card_global(client_fixture: tuple[Honcho | AsyncHoncho, str]):
"""
Tests getting a global peer card (no target).
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
peer = await honcho_client.peer(id="test-card-global-peer")
session = await honcho_client.session(id="test-card-global-session")
# Add some messages to create context
await session.add_messages([peer.message("I like pizza")])
# Get global peer card
card_response = await peer.card()
assert isinstance(card_response, str)
else:
assert isinstance(honcho_client, Honcho)
peer = honcho_client.peer(id="test-card-global-peer")
session = honcho_client.session(id="test-card-global-session")
# Add some messages to create context
session.add_messages([peer.message("I like pizza")])
# Get global peer card
card_response = peer.card()
assert isinstance(card_response, str)
@pytest.mark.asyncio
async def test_peer_card_local(client_fixture: tuple[Honcho | AsyncHoncho, str]):
"""
Tests getting a local peer card (with target).
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
observer = await honcho_client.peer(id="test-card-local-observer")
target = await honcho_client.peer(id="test-card-local-target")
session = await honcho_client.session(id="test-card-local-session")
# Add messages from both peers
await session.add_messages(
[observer.message("Hello"), target.message("Hi there")]
)
# Get local peer card with target as Peer object
card_response = await observer.card(target=target)
assert isinstance(card_response, str)
# Get local peer card with target as string
card_response = await observer.card(target=target.id)
assert isinstance(card_response, str)
else:
assert isinstance(honcho_client, Honcho)
observer = honcho_client.peer(id="test-card-local-observer")
target = honcho_client.peer(id="test-card-local-target")
session = honcho_client.session(id="test-card-local-session")
# Add messages from both peers
session.add_messages([observer.message("Hello"), target.message("Hi there")])
# Get local peer card with target as Peer object
card_response = observer.card(target=target)
assert isinstance(card_response, str)
# Get local peer card with target as string
card_response = observer.card(target=target.id)
assert isinstance(card_response, str)
@pytest.mark.asyncio
async def test_peer_card_validation(client_fixture: tuple[Honcho | AsyncHoncho, str]):
"""
Tests peer card validation.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
peer = await honcho_client.peer(id="test-card-validation-peer")
# Test with empty string
with pytest.raises(ValueError, match="target string cannot be empty"):
await peer.card(target="")
else:
assert isinstance(honcho_client, Honcho)
peer = honcho_client.peer(id="test-card-validation-peer")
# Test with empty string
with pytest.raises(ValueError, match="target string cannot be empty"):
peer.card(target="")
@pytest.mark.asyncio
async def test_peer_chat_streaming(client_fixture: tuple[Honcho | AsyncHoncho, str]):
"""
Tests streaming chat with mocked response generator.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
peer = await honcho_client.peer(id="test-stream-async-peer")
session = await honcho_client.session(id="test-stream-async-session")
# Add some messages to create context
await session.add_messages([peer.message("I like pizza")])
# Mock the async streaming response
async def mock_aiter_lines():
yield 'data: {"delta": {"content": "Hello"}}'
yield 'data: {"delta": {"content": " async"}}'
yield 'data: {"done": true}'
mock_response = AsyncMock()
mock_response.iter_lines = mock_aiter_lines
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=None)
# Mock the with_streaming_response.chat call
with patch.object(
honcho_client.core.workspaces.peers.with_streaming_response,
"chat",
return_value=mock_response,
):
result = await peer.chat("Tell me something", stream=True)
assert isinstance(result, DialecticStreamResponse)
# Collect chunks
chunks: list[str] = []
async for chunk in result:
assert isinstance(chunk, str)
chunks.append(chunk)
assert chunks == ["Hello", " async"]
assert result.get_final_response()["content"] == "Hello async"
else:
assert isinstance(honcho_client, Honcho)
peer = honcho_client.peer(id="test-stream-peer")
session = honcho_client.session(id="test-stream-session")
# Add some messages to create context
session.add_messages([peer.message("I like pizza")])
# Mock the streaming response
def mock_iter_lines():
yield 'data: {"delta": {"content": "Hello"}}'
yield 'data: {"delta": {"content": " world"}}'
yield 'data: {"done": true}'
mock_response = Mock()
mock_response.iter_lines = mock_iter_lines
mock_response.__enter__ = Mock(return_value=mock_response)
mock_response.__exit__ = Mock(return_value=None)
# Mock the with_streaming_response.chat call
with patch.object(
honcho_client.core.workspaces.peers.with_streaming_response,
"chat",
return_value=mock_response,
):
result = peer.chat("Tell me something", stream=True)
assert isinstance(result, DialecticStreamResponse)
# Collect chunks
chunks = list(result)
assert chunks == ["Hello", " world"]
assert result.get_final_response()["content"] == "Hello world"
@pytest.mark.asyncio
async def test_peer_chat_non_streaming(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests non-streaming chat (already using core SDK).
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
peer = await honcho_client.peer(id="test-non-stream-peer")
session = await honcho_client.session(id="test-non-stream-session")
# Add some messages
await session.add_messages([peer.message("I like pizza")])
# Non-streaming chat
response = await peer.chat("What do I like?", stream=False)
# Response can be None or a string
assert response is None or isinstance(response, str)
else:
assert isinstance(honcho_client, Honcho)
peer = honcho_client.peer(id="test-non-stream-peer")
session = honcho_client.session(id="test-non-stream-session")
# Add some messages
session.add_messages([peer.message("I like pizza")])
# Non-streaming chat
response = peer.chat("What do I like?", stream=False)
# Response can be None or a string
assert response is None or isinstance(response, str)

View File

@ -1,4 +1,7 @@
from unittest.mock import AsyncMock, patch
import pytest
from honcho_core.types import DeriverStatus
from sdks.python.src.honcho.async_client.client import AsyncHoncho
from sdks.python.src.honcho.async_client.peer import AsyncPeer
@ -297,3 +300,161 @@ async def test_session_working_rep(client_fixture: tuple[Honcho | AsyncHoncho, s
assert isinstance(peer, Peer)
session.add_messages([peer.message("test message for working rep")])
_working_rep = session.working_rep(peer)
@pytest.mark.asyncio
async def test_session_delete(client_fixture: tuple[Honcho | AsyncHoncho, str]) -> None:
"""
Tests deleting a session.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
session = await honcho_client.session(id="test-session-delete")
assert isinstance(session, AsyncSession)
# Add a peer to make the session exist
user = await honcho_client.peer(id="user-delete")
await session.add_peers([user])
# Delete should not raise an exception
await session.delete()
all_sessions_page = await honcho_client.get_sessions({"is_active": True})
all_sessions = all_sessions_page.items
all_session_ids = [s.id for s in all_sessions]
assert "test-session-delete" not in all_session_ids
@pytest.mark.asyncio
async def test_session_get_deriver_status(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests getting deriver status with various parameter combinations for sessions.
"""
honcho_client, client_type = client_fixture
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
session = await honcho_client.session(id="test-session-deriver-status")
assert isinstance(session, AsyncSession)
status = await session.get_deriver_status()
assert isinstance(status, DeriverStatus)
assert hasattr(status, "total_work_units")
assert hasattr(status, "completed_work_units")
assert hasattr(status, "in_progress_work_units")
assert hasattr(status, "pending_work_units")
assert status.sessions is None
# Test with observer_id only
peer = await honcho_client.peer(id="test-peer-session-deriver")
await peer.get_metadata() # Create the peer
status = await session.get_deriver_status(observer_id=peer.id)
assert isinstance(status, DeriverStatus)
# Test with sender_id only
status = await session.get_deriver_status(sender_id=peer.id)
assert isinstance(status, DeriverStatus)
# Test with both observer_id and sender_id
status = await session.get_deriver_status(
observer_id=peer.id, sender_id=peer.id
)
assert isinstance(status, DeriverStatus)
else:
assert isinstance(honcho_client, Honcho)
session = honcho_client.session(id="test-session-deriver-status")
assert isinstance(session, Session)
# Test with no parameters
status = session.get_deriver_status()
assert isinstance(status, DeriverStatus)
assert hasattr(status, "total_work_units")
assert hasattr(status, "completed_work_units")
assert hasattr(status, "in_progress_work_units")
assert hasattr(status, "pending_work_units")
assert status.sessions is None
# Test with observer_id only
peer = honcho_client.peer(id="test-peer-session-deriver")
peer.get_metadata() # Create the peer
status = session.get_deriver_status(observer_id=peer.id)
assert isinstance(status, DeriverStatus)
# Test with sender_id only
status = session.get_deriver_status(sender_id=peer.id)
assert isinstance(status, DeriverStatus)
# Test with both observer_id and sender_id
status = session.get_deriver_status(observer_id=peer.id, sender_id=peer.id)
assert isinstance(status, DeriverStatus)
@pytest.mark.asyncio
async def test_session_poll_deriver_status(
client_fixture: tuple[Honcho | AsyncHoncho, str],
):
"""
Tests polling deriver status until completion for sessions.
"""
honcho_client, client_type = client_fixture
# Mock the get_deriver_status method to return a "completed" status
# to avoid infinite polling in tests
completed_status = DeriverStatus(
total_work_units=0,
completed_work_units=0,
in_progress_work_units=0,
pending_work_units=0,
)
if client_type == "async":
assert isinstance(honcho_client, AsyncHoncho)
session = await honcho_client.session(id="test-session-poll-deriver")
assert isinstance(session, AsyncSession)
with patch.object(
session.__class__,
"get_deriver_status",
new=AsyncMock(return_value=completed_status),
):
status = await session.poll_deriver_status()
assert isinstance(status, DeriverStatus)
assert status.pending_work_units == 0
assert status.in_progress_work_units == 0
# Test with parameters
peer = await honcho_client.peer(id="test-peer-session-poll")
with patch.object(
session.__class__,
"get_deriver_status",
new=AsyncMock(return_value=completed_status),
):
status = await session.poll_deriver_status(
observer_id=peer.id, sender_id=peer.id
)
assert isinstance(status, DeriverStatus)
else:
assert isinstance(honcho_client, Honcho)
session = honcho_client.session(id="test-session-poll-deriver")
assert isinstance(session, Session)
with patch.object(
session.__class__, "get_deriver_status", return_value=completed_status
):
status = session.poll_deriver_status()
assert isinstance(status, DeriverStatus)
assert status.pending_work_units == 0
assert status.in_progress_work_units == 0
# Test with parameters
peer = honcho_client.peer(id="test-peer-session-poll")
with patch.object(
session.__class__, "get_deriver_status", return_value=completed_status
):
status = session.poll_deriver_status(observer_id=peer.id, sender_id=peer.id)
assert isinstance(status, DeriverStatus)

16
uv.lock
View File

@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.13'",
@ -673,7 +673,7 @@ wheels = [
[[package]]
name = "honcho"
version = "2.3.2"
version = "2.3.3"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
@ -760,12 +760,13 @@ dev = [
[[package]]
name = "honcho-ai"
version = "1.4.0"
version = "1.4.1"
source = { editable = "sdks/python" }
dependencies = [
{ name = "honcho-core" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
[package.dev-dependencies]
@ -775,9 +776,10 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "honcho-core", specifier = ">=1.4.0" },
{ name = "honcho-core", specifier = ">=1.4.1a0" },
{ name = "httpx", specifier = ">=0.28.0,<1" },
{ name = "pydantic", specifier = ">=2.0.0,<3" },
{ name = "typing-extensions", marker = "python_full_version < '3.12'", specifier = ">=4.12.0" },
]
[package.metadata.requires-dev]
@ -785,7 +787,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }]
[[package]]
name = "honcho-core"
version = "1.4.0"
version = "1.4.1a0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -795,9 +797,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/c2/4d3737d5c0a75e2f59f324bed7428a9593c8edfcb7500cf402c46ad7378a/honcho_core-1.4.0.tar.gz", hash = "sha256:b30de9247763c01c3c37ed22b5d04d3c611440dd1983168f3d7d93f2cc0ecf7d", size = 126164, upload-time = "2025-08-12T19:06:54.828Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/2f/fbf3ca017ffcdaef92e26fe84b77268c1c0b26f95e282d8684f50932d81a/honcho_core-1.4.1a0.tar.gz", hash = "sha256:e0023f46c65500d727e3fb1275781ae1e0b162c226a01352afd45c421c5f350b", size = 130826, upload-time = "2025-10-01T03:23:27.143Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ac/6eb2e38376736bb1dfd26bbdfd96490bfbb6f0ddcb00b39913e93799e7d8/honcho_core-1.4.0-py3-none-any.whl", hash = "sha256:a84397fd9daf546a04f5458d7233ebd5eeafcb97af75ec60a9327db89abdaf03", size = 117602, upload-time = "2025-08-12T19:06:53.667Z" },
{ url = "https://files.pythonhosted.org/packages/83/5f/2aa746b7fd92690ef782dcacc56a5c47a19a999b9729597c04c7cc509e52/honcho_core-1.4.1a0-py3-none-any.whl", hash = "sha256:8b4c86e3ac3964ff56fe6a449b9fa23be49530609c34bdf35c9ecde7cd871bba", size = 121704, upload-time = "2025-10-01T03:23:25.66Z" },
]
[[package]]