diff --git a/CHANGELOG.md b/CHANGELOG.md index 0814ebff..90336129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,23 @@ 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.2.0] - 2025-08-07 +## [2.3.0] — 2025-08-14 + +### Added + +- `getSummaries` endpoint to get all available summaries for a session directly + +### Changed + +- Session Peer limit to be based on observers instead, renamed config value to + `SESSION_OBSERVERS_LIMIT` +- Deriver uses `get_context` internally to prevent context window limit errors +- `Messages` can take a custom timestamp for the `created_at` field, defaulting + to the current time +- `get_context` endpoint returns detailed `Summary` object rather than just + summary content + +## [2.2.0] — 2025-08-07 ### Added @@ -28,14 +44,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Re-introduced Sentry tracing to deriver - Better integrity logic in get_or_create API methods -## [2.1.2] - 2025-07-30 +## [2.1.2] — 2025-07-30 ### Fixed - Summarizer module to ignore empty summaries and pass appropriate one to get_context - Structured Outputs calls with OpenAI provider to pass strict=True to Pydantic Schema -## [2.1.1] - 2025-07-23 +## [2.1.1] — 2025-07-23 ### Added @@ -58,7 +74,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Refactor summarization for performance and code clarity - Refactor queue payloads for clarity -## [2.1.0] - 2025-07-17 +## [2.1.0] — 2025-07-17 ### Added diff --git a/README.md b/README.md index 3d82a0f7..4f6797f2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🫡 Honcho -![Static Badge](https://img.shields.io/badge/Version-2.2.0-blue) +![Static Badge](https://img.shields.io/badge/Version-2.3.0-blue) [![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/plasticlabs) [![arXiv](https://img.shields.io/badge/arXiv-2310.06983-b31b1b.svg)](https://arxiv.org/abs/2310.06983) ![GitHub License](https://img.shields.io/github/license/plastic-labs/honcho) diff --git a/docs/changelog/compatibility-guide.mdx b/docs/changelog/compatibility-guide.mdx index 6f897c57..d3ed4788 100644 --- a/docs/changelog/compatibility-guide.mdx +++ b/docs/changelog/compatibility-guide.mdx @@ -8,23 +8,23 @@ This guide helps you understand which versions of Honcho's API are compatible wi ## Version Compatibility -### Honcho API v2.2.0 (Current) +### Honcho API v2.3.0 (Current) - **Compatible Version:** v1.3.0 + **Compatible Version:** v1.4.0 Install with: ```bash - npm install @honcho-ai/sdk@1.3.0 + npm install @honcho-ai/sdk@1.4.0 ``` - **Compatible Version:** v1.3.0 + **Compatible Version:** v1.4.0 Install with: ```bash - pip install honcho-ai==1.3.0 + pip install honcho-ai==1.4.0 ``` @@ -33,20 +33,20 @@ This guide helps you understand which versions of Honcho's API are compatible wi - **Compatible Version:** v1.2.1 + **Compatible Version:** v1.4.0 Install with: ```bash - npm install @honcho-ai/sdk@1.2.1 + npm install @honcho-ai/sdk@1.4.0 ``` - **Compatible Version:** v1.2.2 + **Compatible Version:** v1.4.0 Install with: ```bash - pip install honcho-ai==1.2.2 + pip install honcho-ai==1.4.0 ``` @@ -56,7 +56,8 @@ This guide helps you understand which versions of Honcho's API are compatible wi | Honcho API Version | TypeScript SDK | Python SDK | |-------------------|---------------|------------| -| v2.2.0 (Current) | v1.3.0 | v1.3.0 | +| v2.3.0 (Current) | v1.4.0 | v1.4.0 | +| v2.2.0 | v1.3.0 | v1.3.0 | | v2.1.2 | v1.2.1 | v1.2.2 | | v2.1.1 | v1.2.1 | v1.2.2 | | v2.1.0 | v1.2.1 | v1.2.2 | diff --git a/docs/changelog/introduction.mdx b/docs/changelog/introduction.mdx index 225563a0..bd9e6836 100644 --- a/docs/changelog/introduction.mdx +++ b/docs/changelog/introduction.mdx @@ -27,7 +27,23 @@ Welcome to the Honcho changelog! This section documents all notable changes to t ### Honcho API and SDK Changelogs - + + ### Added + + - `getSummaries` endpoint to get all available summaries for a session directly + + ### Changed + + - Session Peer limit to be based on observers instead, renamed config value to + `SESSION_OBSERVERS_LIMIT` + - Deriver uses `get_context` internally to prevent context window limit errors + - `Messages` can take a custom timestamp for the `created_at` field, defaulting + to the current time + - `get_context` endpoint returns detailed `Summary` object rather than just + summary content + + + ### Added - Arbitrary filters now available on all search endpoints @@ -239,6 +255,16 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [Python SDK](https://pypi.org/project/honcho-ai/) + + ### Added + + - getSummaries API returning structured summaries + + ### Changed + + - Messages can take an optional `created_at` value, defaulting to the current + time (UTC ISO 8601) + ### Added @@ -275,6 +301,16 @@ Welcome to the Honcho changelog! This section documents all notable changes to t [TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk) + + ### Added + + - getSummaries API returning structured summaries + + ### Changed + + - Messages can take an optional `created_at` value, defaulting to the current + time (UTC ISO 8601) + ### Added diff --git a/docs/docs.json b/docs/docs.json index 118d6877..e1fa6b51 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -14,7 +14,7 @@ "navigation": { "versions": [ { - "version": "v2.2.0", + "version": "v2.3.0", "api": { "openapi": ["openapi.documented.yml"] }, @@ -131,6 +131,7 @@ "v2/api-reference/endpoint/sessions/get-peer-config", "v2/api-reference/endpoint/sessions/set-peer-config", "v2/api-reference/endpoint/sessions/get-session-context", + "v2/api-reference/endpoint/sessions/get-session-summaries", "v2/api-reference/endpoint/sessions/search-session" ] }, @@ -141,12 +142,21 @@ "v2/api-reference/endpoint/messages/get-messages", "v2/api-reference/endpoint/messages/get-message", "v2/api-reference/endpoint/messages/update-message", - "v2/api-reference/endpoint/messages/upload-file" + "v2/api-reference/endpoint/messages/create-messages-with-file" ] }, { "group": "keys", "pages": ["v2/api-reference/endpoint/keys/create-key"] + }, + { + "group": "webhooks", + "pages": [ + "v2/api-reference/endpoint/webhooks/list-webhook-endpoints", + "v2/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint", + "v2/api-reference/endpoint/webhooks/delete-webhook-endpoint", + "v2/api-reference/endpoint/webhooks/test-emit" + ] } ] }, diff --git a/docs/package.json b/docs/package.json index c029ccfa..a9f1757c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -5,7 +5,7 @@ "main": ".pnp.js", "scripts": { "dev": "mint dev", - "openapi": "npx @mintlify/scraping openapi-file openapi.json -o api-reference/endpoint", + "openapi": "npx @mintlify/scraping openapi-file openapi.documented.yml -o api-reference/endpoint", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", diff --git a/docs/v2/api-reference/endpoint/messages/upload-file.mdx b/docs/v2/api-reference/endpoint/messages/create-messages-with-file.mdx similarity index 100% rename from docs/v2/api-reference/endpoint/messages/upload-file.mdx rename to docs/v2/api-reference/endpoint/messages/create-messages-with-file.mdx diff --git a/docs/v2/api-reference/endpoint/sessions/get-session-summaries.mdx b/docs/v2/api-reference/endpoint/sessions/get-session-summaries.mdx new file mode 100644 index 00000000..9ae150bc --- /dev/null +++ b/docs/v2/api-reference/endpoint/sessions/get-session-summaries.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v2/workspaces/{workspace_id}/sessions/{session_id}/summaries +--- diff --git a/docs/v2/api-reference/endpoint/webhooks/delete-webhook-endpoint.mdx b/docs/v2/api-reference/endpoint/webhooks/delete-webhook-endpoint.mdx new file mode 100644 index 00000000..03663d39 --- /dev/null +++ b/docs/v2/api-reference/endpoint/webhooks/delete-webhook-endpoint.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v2/workspaces/{workspace_id}/webhooks/{endpoint_id} +--- diff --git a/docs/v2/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint.mdx b/docs/v2/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint.mdx new file mode 100644 index 00000000..3eca8afb --- /dev/null +++ b/docs/v2/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v2/workspaces/{workspace_id}/webhooks +--- diff --git a/docs/v2/api-reference/endpoint/webhooks/list-webhook-endpoints.mdx b/docs/v2/api-reference/endpoint/webhooks/list-webhook-endpoints.mdx new file mode 100644 index 00000000..a205df80 --- /dev/null +++ b/docs/v2/api-reference/endpoint/webhooks/list-webhook-endpoints.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v2/workspaces/{workspace_id}/webhooks +--- diff --git a/docs/v2/api-reference/endpoint/webhooks/test-emit.mdx b/docs/v2/api-reference/endpoint/webhooks/test-emit.mdx new file mode 100644 index 00000000..8c2cbddd --- /dev/null +++ b/docs/v2/api-reference/endpoint/webhooks/test-emit.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v2/workspaces/{workspace_id}/webhooks/test +--- diff --git a/docs/v2/documentation/reference/sdk.mdx b/docs/v2/documentation/reference/sdk.mdx index 135cfd1c..45d67e9b 100644 --- a/docs/v2/documentation/reference/sdk.mdx +++ b/docs/v2/documentation/reference/sdk.mdx @@ -555,6 +555,33 @@ const response = await openai.chat.completions.create({ ``` +### Custom Message Timestamps + +When creating messages, you can optionally specify a custom `created_at` timestamp instead of using the server's current time: + +```bash +curl -X POST "https://api.honcho.dev/v2/workspaces/{workspace_id}/sessions/{session_id}/messages" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + { + "peer_id": "user123", + "content": "This message happened yesterday", + "created_at": "2024-01-01T12:00:00Z", + "metadata": {"source": "historical_data"} + } + ] + }' +``` + +This is useful for: +- Importing historical conversation data +- Backfilling messages from other systems +- Maintaining accurate timeline ordering when processing batch data + +If `created_at` is not provided, messages will use the server's current timestamp. + ### Metadata and Filtering See [Using Filters](/v2/guides/using-filters) for more examples on how to use filters. diff --git a/docs/v2/openapi.documented.yml b/docs/v2/openapi.documented.yml index ebc1df20..c6567be9 100644 --- a/docs/v2/openapi.documented.yml +++ b/docs/v2/openapi.documented.yml @@ -7,7 +7,7 @@ info: name: Plastic Labs url: https://honcho.dev/ email: hello@plasticlabs.ai - version: 2.1.1 + version: 2.3.0 servers: - url: http://localhost:8000 description: Local Development Server @@ -231,42 +231,23 @@ paths: description: ID of the workspace to search title: Workspace Id description: ID of the workspace to search - - name: page - in: query - required: false - schema: - type: integer - minimum: 1 - description: Page number - default: 1 - title: Page - description: Page number - - name: size - in: query - required: false - schema: - type: integer - maximum: 100 - minimum: 1 - description: Page size - default: 50 - title: Size - description: Page size requestBody: required: true content: application/json: schema: - type: string - description: Search query - title: Query + $ref: '#/components/schemas/MessageSearchOptions' + description: 'Message search parameters ' responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/Page_Message_' + type: array + items: + $ref: '#/components/schemas/Message' + title: Response Search Workspace V2 Workspaces Workspace Id Search Post '422': description: Validation Error content: @@ -282,10 +263,9 @@ paths: apiKey: 'My API Key', }); - // Automatically fetches more pages as needed. - for await (const message of client.workspaces.search('workspace_id', { body: 'body' })) { - console.log(message.id); - } + const messages = await client.workspaces.search('workspace_id', { query: 'query' }); + + console.log(messages); - lang: Python source: |- from honcho_core import Honcho @@ -293,12 +273,11 @@ paths: client = Honcho( api_key="My API Key", ) - page = client.workspaces.search( + messages = client.workspaces.search( workspace_id="workspace_id", - body="body", + query="query", ) - page = page.items[0] - print(page.id) + print(messages) /v2/workspaces/{workspace_id}/deriver/status: get: tags: @@ -891,27 +870,6 @@ paths: description: ID of the peer title: Peer Id description: ID of the peer - - name: page - in: query - required: false - schema: - type: integer - minimum: 1 - description: Page number - default: 1 - title: Page - description: Page number - - name: size - in: query - required: false - schema: - type: integer - maximum: 100 - minimum: 1 - description: Page size - default: 50 - title: Size - description: Page size requestBody: required: true content: @@ -925,7 +883,10 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Page_Message_' + type: array + items: + $ref: '#/components/schemas/Message' + title: Response Search Peer V2 Workspaces Workspace Id Peers Peer Id Search Post '422': description: Validation Error content: @@ -943,12 +904,11 @@ paths: }); - // Automatically fetches more pages as needed. + const messages = await client.workspaces.peers.search('workspace_id', 'peer_id', { query: 'query' + }); - for await (const message of client.workspaces.peers.search('workspace_id', 'peer_id', { query: - 'query' })) { - console.log(message.id); - } + + console.log(messages); - lang: Python source: |- from honcho_core import Honcho @@ -956,13 +916,12 @@ paths: client = Honcho( api_key="My API Key", ) - page = client.workspaces.peers.search( + messages = client.workspaces.peers.search( peer_id="peer_id", workspace_id="workspace_id", query="query", ) - page = page.items[0] - print(page.id) + print(messages) /v2/workspaces/{workspace_id}/sessions: post: tags: @@ -1699,20 +1658,20 @@ paths: $ref: '#/components/schemas/HTTPValidationError' x-codeSamples: - lang: JavaScript - source: >- + source: |- import Honcho from '@honcho-ai/core'; - const client = new Honcho({ apiKey: 'My API Key', }); + const sessionPeerConfig = await client.workspaces.sessions.peers.getConfig( + 'workspace_id', + 'session_id', + 'peer_id', + ); - const response = await client.workspaces.sessions.peers.getConfig('workspace_id', 'session_id', - 'peer_id'); - - - console.log(response.observe_me); + console.log(sessionPeerConfig.observe_me); - lang: Python source: |- from honcho_core import Honcho @@ -1720,12 +1679,12 @@ paths: client = Honcho( api_key="My API Key", ) - response = client.workspaces.sessions.peers.get_config( + session_peer_config = client.workspaces.sessions.peers.get_config( peer_id="peer_id", workspace_id="workspace_id", session_id="session_id", ) - print(response.observe_me) + print(session_peer_config.observe_me) post: tags: - sessions @@ -1814,15 +1773,16 @@ paths: - sessions summary: Get Session Context description: >- - Produce a context object from the session. The caller provides a token limit which the entire context - must fit into. + Produce a context object from the session. The caller provides an optional token limit which the + entire context must fit into. - To do this, we allocate 40% of the token limit to the summary, and 60% to recent messages -- as many - as can fit. + If not provided, the context will be exhaustive (within configured max tokens). To do this, we + allocate 40% of the token limit - If the caller does not want a summary, we allocate all the tokens to recent messages. + to the summary, and 60% to recent messages -- as many as can fit. Note that the summary will usually + take up less space than - The default token limit if not provided is 2048. (TODO: make this configurable) + this. If the caller does not want a summary, we allocate all the tokens to recent messages. operationId: get_session_context_v2_workspaces__workspace_id__sessions__session_id__context_get security: - HTTPBearer: [] @@ -1850,19 +1810,24 @@ paths: schema: anyOf: - type: integer + maximum: 100000 - type: 'null' - description: Number of tokens to use for the context. Includes summary if set to true + description: >- + Number of tokens to use for the context. Includes summary if set to true. If not provided, the + context will be exhaustive (within 100000 tokens) title: Tokens - description: Number of tokens to use for the context. Includes summary if set to true + description: >- + Number of tokens to use for the context. Includes summary if set to true. If not provided, the + context will be exhaustive (within 100000 tokens) - name: summary in: query required: false schema: type: boolean - description: Whether to summarize the session history prior to the cutoff message - default: false + description: Whether or not to include a summary *if* one is available for the session + default: true title: Summary - description: Whether to summarize the session history prior to the cutoff message + description: Whether or not to include a summary *if* one is available for the session responses: '200': description: Successful Response @@ -1900,6 +1865,74 @@ paths: workspace_id="workspace_id", ) print(response.id) + /v2/workspaces/{workspace_id}/sessions/{session_id}/summaries: + get: + tags: + - sessions + summary: Get Session Summaries + description: |- + Get available summaries for a session. + + Returns both short and long summaries if available, including metadata like + the message ID they cover up to, creation timestamp, and token count. + operationId: get_session_summaries_v2_workspaces__workspace_id__sessions__session_id__summaries_get + security: + - HTTPBearer: [] + - {} + parameters: + - name: workspace_id + in: path + required: true + schema: + type: string + description: ID of the workspace + title: Workspace Id + description: ID of the workspace + - name: session_id + in: path + required: true + schema: + type: string + description: ID of the session + title: Session Id + description: ID of the session + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/SessionSummaries' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + x-codeSamples: + - lang: JavaScript + source: |- + import Honcho from '@honcho-ai/core'; + + const client = new Honcho({ + apiKey: 'My API Key', + }); + + const response = await client.workspaces.sessions.summaries('workspace_id', 'session_id'); + + console.log(response.id); + - lang: Python + source: |- + from honcho_core import Honcho + + client = Honcho( + api_key="My API Key", + ) + response = client.workspaces.sessions.summaries( + session_id="session_id", + workspace_id="workspace_id", + ) + print(response.id) /v2/workspaces/{workspace_id}/sessions/{session_id}/search: post: tags: @@ -1927,41 +1960,23 @@ paths: description: ID of the session title: Session Id description: ID of the session - - name: page - in: query - required: false - schema: - type: integer - minimum: 1 - description: Page number - default: 1 - title: Page - description: Page number - - name: size - in: query - required: false - schema: - type: integer - maximum: 100 - minimum: 1 - description: Page size - default: 50 - title: Size - description: Page size requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/MessageSearchOptions' - description: 'Message search parameters ' + description: Message search parameters responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/Page_Message_' + type: array + items: + $ref: '#/components/schemas/Message' + title: Response Search Session V2 Workspaces Workspace Id Sessions Session Id Search Post '422': description: Validation Error content: @@ -1970,19 +1985,20 @@ paths: $ref: '#/components/schemas/HTTPValidationError' x-codeSamples: - lang: JavaScript - source: |- + source: >- import Honcho from '@honcho-ai/core'; + const client = new Honcho({ apiKey: 'My API Key', }); - // Automatically fetches more pages as needed. - for await (const message of client.workspaces.sessions.search('workspace_id', 'session_id', { - query: 'query', - })) { - console.log(message.id); - } + + const messages = await client.workspaces.sessions.search('workspace_id', 'session_id', { query: + 'query' }); + + + console.log(messages); - lang: Python source: |- from honcho_core import Honcho @@ -1990,13 +2006,12 @@ paths: client = Honcho( api_key="My API Key", ) - page = client.workspaces.sessions.search( + messages = client.workspaces.sessions.search( session_id="session_id", workspace_id="workspace_id", query="query", ) - page = page.items[0] - print(page.id) + print(messages) /v2/workspaces/{workspace_id}/sessions/{session_id}/messages/: post: tags: @@ -2505,6 +2520,267 @@ paths: ) key = client.keys.create() print(key) + /v2/workspaces/{workspace_id}/webhooks: + post: + tags: + - webhooks + summary: Get Or Create Webhook Endpoint + description: Get or create a webhook endpoint URL. + operationId: get_or_create_webhook_endpoint_v2_workspaces__workspace_id__webhooks_post + security: + - HTTPBearer: [] + - {} + parameters: + - name: workspace_id + in: path + required: true + schema: + type: string + description: Workspace ID + title: Workspace Id + description: Workspace ID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookEndpointCreate' + description: Webhook endpoint parameters + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookEndpoint' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + x-codeSamples: + - lang: JavaScript + source: >- + import Honcho from '@honcho-ai/core'; + + + const client = new Honcho({ + apiKey: 'My API Key', + }); + + + const webhookEndpoint = await client.workspaces.webhooks.getOrCreate('workspace_id', { url: 'url' + }); + + + console.log(webhookEndpoint.id); + - lang: Python + source: |- + from honcho_core import Honcho + + client = Honcho( + api_key="My API Key", + ) + webhook_endpoint = client.workspaces.webhooks.get_or_create( + workspace_id="workspace_id", + url="url", + ) + print(webhook_endpoint.id) + get: + tags: + - webhooks + summary: List Webhook Endpoints + description: List all webhook endpoints, optionally filtered by workspace. + operationId: list_webhook_endpoints_v2_workspaces__workspace_id__webhooks_get + security: + - HTTPBearer: [] + - {} + parameters: + - name: workspace_id + in: path + required: true + schema: + type: string + description: Workspace ID + title: Workspace Id + description: Workspace ID + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page number + default: 1 + title: Page + description: Page number + - name: size + in: query + required: false + schema: + type: integer + maximum: 100 + minimum: 1 + description: Page size + default: 50 + title: Size + description: Page size + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Page_WebhookEndpoint_' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + x-codeSamples: + - lang: JavaScript + source: |- + import Honcho from '@honcho-ai/core'; + + const client = new Honcho({ + apiKey: 'My API Key', + }); + + // Automatically fetches more pages as needed. + for await (const webhookEndpoint of client.workspaces.webhooks.list('workspace_id')) { + console.log(webhookEndpoint.id); + } + - lang: Python + source: |- + from honcho_core import Honcho + + client = Honcho( + api_key="My API Key", + ) + page = client.workspaces.webhooks.list( + workspace_id="workspace_id", + ) + page = page.items[0] + print(page.id) + /v2/workspaces/{workspace_id}/webhooks/{endpoint_id}: + delete: + tags: + - webhooks + summary: Delete Webhook Endpoint + description: Delete a specific webhook endpoint. + operationId: delete_webhook_endpoint_v2_workspaces__workspace_id__webhooks__endpoint_id__delete + security: + - HTTPBearer: [] + - {} + parameters: + - name: workspace_id + in: path + required: true + schema: + type: string + description: Workspace ID + title: Workspace Id + description: Workspace ID + - name: endpoint_id + in: path + required: true + schema: + type: string + description: Webhook endpoint ID + title: Endpoint Id + description: Webhook endpoint ID + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + x-codeSamples: + - lang: JavaScript + source: |- + import Honcho from '@honcho-ai/core'; + + const client = new Honcho({ + apiKey: 'My API Key', + }); + + const webhook = await client.workspaces.webhooks.delete('workspace_id', 'endpoint_id'); + + console.log(webhook); + - lang: Python + source: |- + from honcho_core import Honcho + + client = Honcho( + api_key="My API Key", + ) + webhook = client.workspaces.webhooks.delete( + endpoint_id="endpoint_id", + workspace_id="workspace_id", + ) + print(webhook) + /v2/workspaces/{workspace_id}/webhooks/test: + get: + tags: + - webhooks + summary: Test Emit + description: Test publishing a webhook event. + operationId: test_emit_v2_workspaces__workspace_id__webhooks_test_get + security: + - HTTPBearer: [] + - {} + parameters: + - name: workspace_id + in: path + required: true + schema: + type: string + description: Workspace ID + title: Workspace Id + description: Workspace ID + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + x-codeSamples: + - lang: JavaScript + source: |- + import Honcho from '@honcho-ai/core'; + + const client = new Honcho({ + apiKey: 'My API Key', + }); + + const response = await client.workspaces.webhooks.testEmit('workspace_id'); + + console.log(response); + - lang: Python + source: |- + from honcho_core import Honcho + + client = Honcho( + api_key="My API Key", + ) + response = client.workspaces.webhooks.test_emit( + "workspace_id", + ) + print(response) components: schemas: Body_create_messages_with_file_v2_workspaces__workspace_id__sessions__session_id__messages_upload_post: @@ -2612,9 +2888,7 @@ components: type: string title: Peer Id session_id: - anyOf: - - type: string - - type: 'null' + type: string title: Session Id metadata: additionalProperties: true @@ -2670,6 +2944,12 @@ components: type: object - type: 'null' title: Metadata + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At type: object required: - content @@ -2682,7 +2962,7 @@ components: - additionalProperties: true type: object - type: 'null' - title: Filter + title: Filters type: object title: MessageGet MessageSearchOptions: @@ -2691,12 +2971,20 @@ components: type: string title: Query description: Search query - semantic: + filters: anyOf: - - type: boolean + - additionalProperties: true + type: object - type: 'null' - title: Semantic - description: Whether to explicitly use semantic search to filter the results + title: Filters + description: Filters to scope the search + limit: + type: integer + maximum: 100 + minimum: 1 + title: Limit + description: Number of results to return + default: 10 type: object required: - query @@ -2798,6 +3086,35 @@ components: - page - size title: Page[Session] + Page_WebhookEndpoint_: + properties: + items: + items: + $ref: '#/components/schemas/WebhookEndpoint' + type: array + title: Items + total: + type: integer + minimum: 0 + title: Total + page: + type: integer + minimum: 1 + title: Page + size: + type: integer + minimum: 1 + title: Size + pages: + type: integer + minimum: 0 + title: Pages + type: object + required: + - items + - page + - size + title: Page[WebhookEndpoint] Page_Workspace_: properties: items: @@ -2884,7 +3201,7 @@ components: - additionalProperties: true type: object - type: 'null' - title: Filter + title: Filters type: object title: PeerGet PeerRepresentationGet: @@ -2960,13 +3277,14 @@ components: type: array title: Messages summary: - type: string - title: Summary + anyOf: + - $ref: '#/components/schemas/Summary' + - type: 'null' + description: The summary if available type: object required: - id - messages - - summary title: SessionContext SessionCreate: properties: @@ -3037,7 +3355,7 @@ components: - additionalProperties: true type: object - type: 'null' - title: Filter + title: Filters type: object title: SessionGet SessionPeerConfig: @@ -3059,6 +3377,25 @@ components: representation of this peer type: object title: SessionPeerConfig + SessionSummaries: + properties: + id: + type: string + title: Id + short_summary: + anyOf: + - $ref: '#/components/schemas/Summary' + - type: 'null' + description: The short summary if available + long_summary: + anyOf: + - $ref: '#/components/schemas/Summary' + - type: 'null' + description: The long summary if available + type: object + required: + - id + title: SessionSummaries SessionUpdate: properties: metadata: @@ -3075,6 +3412,36 @@ components: title: Configuration type: object title: SessionUpdate + Summary: + properties: + content: + type: string + title: Content + description: The summary text + message_id: + type: integer + title: Message Id + description: The ID of the message that this summary covers up to + summary_type: + type: string + title: Summary Type + description: The type of summary (short or long) + created_at: + type: string + title: Created At + description: The timestamp of when the summary was created (ISO format) + token_count: + type: integer + title: Token Count + description: The number of tokens in the summary text + type: object + required: + - content + - message_id + - summary_type + - created_at + - token_count + title: Summary ValidationError: properties: loc: @@ -3096,6 +3463,39 @@ components: - msg - type title: ValidationError + WebhookEndpoint: + properties: + id: + type: string + title: Id + workspace_id: + anyOf: + - type: string + - type: 'null' + title: Workspace Id + url: + type: string + title: Url + created_at: + type: string + format: date-time + title: Created At + type: object + required: + - id + - workspace_id + - url + - created_at + title: WebhookEndpoint + WebhookEndpointCreate: + properties: + url: + type: string + title: Url + type: object + required: + - url + title: WebhookEndpointCreate Workspace: properties: id: @@ -3147,7 +3547,7 @@ components: - additionalProperties: true type: object - type: 'null' - title: Filter + title: Filters type: object title: WorkspaceGet WorkspaceUpdate: diff --git a/pyproject.toml b/pyproject.toml index 2766f5fd..ac6f8a0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho" -version = "2.2.0" +version = "2.3.0" description = "Honcho Server" authors = [ {name = "Plastic Labs", email = "hello@plasticlabs.ai"}, @@ -32,8 +32,6 @@ dependencies = [ ] [tool.uv] dev-dependencies = [ - "honcho-core==1.3.0", - "honcho-ai==1.3.0", "pytest>=8.2.2", "sqlalchemy-utils>=0.41.2", "pytest-asyncio>=0.23.7", @@ -44,8 +42,17 @@ dev-dependencies = [ "basedpyright>=1.29.4", "pre-commit>=4.2.0", "pytest-cov>=6.2.1", + "honcho-ai", ] +[tool.uv.workspace] +members = [ + "sdks/python", +] + +[tool.uv.sources] +honcho-ai = { workspace = true } + [tool.ruff.lint] # from https://docs.astral.sh/ruff/linter/#rule-selection example select = [ diff --git a/scripts/update_version.py b/scripts/update_version.py index a07a007e..4d99020b 100755 --- a/scripts/update_version.py +++ b/scripts/update_version.py @@ -608,7 +608,7 @@ TYPESCRIPT_VERSION= ) # Add new row at the top - new_row = f"| v{api_version} (Current) | v{typescript_version} | v{python_version} | Latest release |" + new_row = f"| v{api_version} (Current) | v{typescript_version} | v{python_version} |" # Reconstruct table new_table = header + new_row + "\n" + rows + after_table diff --git a/sdks/python/CHANGELOG.md b/sdks/python/CHANGELOG.md index 6e29a94b..49451499 100644 --- a/sdks/python/CHANGELOG.md +++ b/sdks/python/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [1.4.0] - 2025-08-12 + +### Added + +- getSummaries API returning structured summaries + +### Changed + +- Messages can take an optional `created_at` value, defaulting to the current + time (UTC ISO 8601) + ## [1.3.0] - 2025-08-04 ### Added diff --git a/sdks/python/examples/get_summaries.py b/sdks/python/examples/get_summaries.py new file mode 100644 index 00000000..892016d4 --- /dev/null +++ b/sdks/python/examples/get_summaries.py @@ -0,0 +1,82 @@ +""" +Example of getting session summaries using the Honcho Python SDK. + +This example demonstrates how to retrieve both short and long summaries +for a session, including their metadata like message ID, creation timestamp, +and token count. +""" + +from honcho import AsyncHoncho, Honcho, SessionSummaries +import asyncio +import os + +# Initialize the Honcho client +api_key = os.getenv("HONCHO_API_KEY") +if not api_key: + raise ValueError("Please set HONCHO_API_KEY environment variable") + +client = Honcho(api_key=api_key) + +# Get a session (replace with your actual session ID) +session = client.session("my-conversation-session") + +# Get summaries for the session +summaries: SessionSummaries = session.get_summaries() + +print(f"Session ID: {summaries.id}") +print("-" * 50) + +# Check and display short summary +if summaries.short_summary: + print("SHORT SUMMARY:") + print(f" Content: {summaries.short_summary.content[:200]}...") # First 200 chars + print(f" Covers up to message ID: {summaries.short_summary.message_id}") + print(f" Created at: {summaries.short_summary.created_at}") + print(f" Token count: {summaries.short_summary.token_count}") + print(f" Type: {summaries.short_summary.summary_type}") +else: + print("No short summary available yet") + +print("-" * 50) + +# Check and display long summary +if summaries.long_summary: + print("LONG SUMMARY:") + print(f" Content: {summaries.long_summary.content[:200]}...") # First 200 chars + print(f" Covers up to message ID: {summaries.long_summary.message_id}") + print(f" Created at: {summaries.long_summary.created_at}") + print(f" Token count: {summaries.long_summary.token_count}") + print(f" Type: {summaries.long_summary.summary_type}") +else: + print("No long summary available yet") + +# Example with async client +print("\n" + "=" * 50) +print("ASYNC EXAMPLE:") +print("=" * 50) + + +async def get_summaries_async(): + async_client = AsyncHoncho(api_key=api_key) + + # Get a session + async_session = async_client.session("my-conversation-session") + + # Get summaries asynchronously + summaries = await async_session.get_summaries() + + print(f"Session ID (async): {summaries.id}") + + if summaries.short_summary: + print( + f"Short summary available with {summaries.short_summary.token_count} tokens" + ) + + if summaries.long_summary: + print( + f"Long summary available with {summaries.long_summary.token_count} tokens" + ) + + +# Run the async example +asyncio.run(get_summaries_async()) diff --git a/sdks/python/examples/pydantic_validation_example.py b/sdks/python/examples/pydantic_validation_example.py index 0fe24c4b..b44fe8d1 100644 --- a/sdks/python/examples/pydantic_validation_example.py +++ b/sdks/python/examples/pydantic_validation_example.py @@ -5,11 +5,13 @@ This example shows how the SDK now uses Pydantic to validate inputs at runtime, providing better error messages and type safety. """ +import datetime import logging -from honcho import Honcho from pydantic import ValidationError +from honcho import Honcho + logging.basicConfig(level=logging.INFO) @@ -119,6 +121,24 @@ def demonstrate_validation(): except ValidationError as e: print(f"❌ Validation error: {e}") + # Example 10: Custom created_at timestamps + print("10. Custom created_at timestamps:") + try: + honcho = Honcho(environment="local", workspace_id="test") + peer = honcho.peer("alice") + message = peer.message("Hello, world!", created_at=datetime.datetime.now()) + print(f"✅ Created message: {message}") + message = peer.message( + "Hello, world!", + created_at=datetime.datetime(1999, 1, 1, tzinfo=datetime.timezone.utc), + ) + print(f"✅ Created message: {message}") + session = honcho.session("conversation_1") + session.add_messages([message]) + print(f"✅ Added message to session: {session}") + except ValidationError as e: + print(f"❌ Validation error: {e}") + print("\n" + "=" * 50 + "\n") print("🎉 Pydantic validation examples completed!") diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 2fd3cd1f..73254de3 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-ai" -version = "1.3.0" +version = "1.4.0" description = "Official DX Optimized Python SDK for Honcho" dynamic = ["readme"] license = "Apache-2.0" @@ -8,7 +8,7 @@ authors = [ { name = "Plastic Labs", email = "hello@plasticlabs.ai" }, ] dependencies = [ - "honcho-core==1.3.0", + "honcho-core>=1.4.0", "httpx>=0.28.0, <1", "pydantic>=2.0.0, <3", ] diff --git a/sdks/python/src/honcho/__init__.py b/sdks/python/src/honcho/__init__.py index 16b77130..f3ac73b7 100644 --- a/sdks/python/src/honcho/__init__.py +++ b/sdks/python/src/honcho/__init__.py @@ -44,9 +44,9 @@ from .client import Honcho from .pagination import SyncPage from .peer import Peer from .session import Session -from .session_context import SessionContext +from .session_context import SessionContext, SessionSummaries, Summary -__version__ = "1.0.0" +__version__ = "1.4.0" __author__ = "Plastic Labs" __email__ = "hello@plasticlabs.ai" @@ -59,5 +59,7 @@ __all__ = [ "Peer", "Session", "SessionContext", + "SessionSummaries", + "Summary", "SyncPage", ] diff --git a/sdks/python/src/honcho/async_client/peer.py b/sdks/python/src/honcho/async_client/peer.py index b3c0eeb8..b21968b3 100644 --- a/sdks/python/src/honcho/async_client/peer.py +++ b/sdks/python/src/honcho/async_client/peer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime from typing import TYPE_CHECKING from honcho_core import AsyncHoncho as AsyncHonchoCore @@ -175,6 +176,10 @@ class AsyncPeer(BaseModel): metadata: dict[str, object] | None = Field( None, description="Optional metadata dictionary" ), + created_at: datetime.datetime | str | None = Field( + None, + description="Optional created-at timestamp for the message. Accepts a datetime which will be converted to an ISO 8601 string, or a preformatted string.", + ), ) -> MessageCreateParam: """ Create a MessageCreateParam object attributed to this peer. @@ -189,7 +194,18 @@ class AsyncPeer(BaseModel): Returns: A new MessageCreateParam object with this peer's ID and the provided content """ - return MessageCreateParam(peer_id=self.id, content=content, metadata=metadata) + created_at_str: str | None + if isinstance(created_at, datetime.datetime): + created_at_str = created_at.isoformat() + else: + created_at_str = created_at + + return MessageCreateParam( + peer_id=self.id, + content=content, + metadata=metadata, + created_at=created_at_str, + ) async def get_metadata(self) -> dict[str, object]: """ diff --git a/sdks/python/src/honcho/async_client/session.py b/sdks/python/src/honcho/async_client/session.py index da95859c..64e03ffe 100644 --- a/sdks/python/src/honcho/async_client/session.py +++ b/sdks/python/src/honcho/async_client/session.py @@ -8,7 +8,7 @@ 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 ..session_context import SessionContext +from ..session_context import SessionContext, SessionSummaries, Summary from ..utils import prepare_file_for_upload from .pagination import AsyncPage @@ -440,8 +440,72 @@ class AsyncSession(BaseModel): summary=summary, ) + # Convert the honcho_core summary to our Summary if it exists + session_summary = None + if context.summary: + session_summary = Summary( + content=context.summary.content, + message_id=context.summary.message_id, + summary_type=context.summary.summary_type, + created_at=context.summary.created_at, + token_count=context.summary.token_count, + ) + return SessionContext( - session_id=self.id, messages=context.messages, summary=context.summary + session_id=self.id, messages=context.messages, summary=session_summary + ) + + async def get_summaries(self) -> SessionSummaries: + """ + Get available summaries for this session. + + Makes an async API call to retrieve both short and long summaries for this session, + if they are available. Summaries are created asynchronously by the backend + as messages are added to the session. + + Returns: + A SessionSummaries object containing: + - id: The session ID + - short_summary: The short summary if available, including metadata + - long_summary: The long summary if available, including metadata + + Note: + Summaries may be None if: + - Not enough messages have been added to trigger summary generation + - The summary generation is still in progress + - Summary generation is disabled for this session + """ + # Use the honcho_core client to get summaries + response = await self._client.workspaces.sessions.summaries( + session_id=self.id, + workspace_id=self.workspace_id, + ) + + # Create Summary objects from the response data + short_summary = None + if response.short_summary: + short_summary = Summary( + content=response.short_summary.content, + message_id=response.short_summary.message_id, + summary_type=response.short_summary.summary_type, + created_at=response.short_summary.created_at, + token_count=response.short_summary.token_count, + ) + + long_summary = None + if response.long_summary: + long_summary = Summary( + content=response.long_summary.content, + message_id=response.long_summary.message_id, + summary_type=response.long_summary.summary_type, + created_at=response.long_summary.created_at, + token_count=response.long_summary.token_count, + ) + + return SessionSummaries( + id=response.id or self.id, + short_summary=short_summary, + long_summary=long_summary, ) @validate_call diff --git a/sdks/python/src/honcho/peer.py b/sdks/python/src/honcho/peer.py index 58f40dea..920163bf 100644 --- a/sdks/python/src/honcho/peer.py +++ b/sdks/python/src/honcho/peer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime from typing import TYPE_CHECKING from honcho_core import Honcho as HonchoCore @@ -158,6 +159,10 @@ class Peer(BaseModel): metadata: dict[str, object] | None = Field( None, description="Optional metadata dictionary" ), + created_at: datetime.datetime | str | None = Field( + None, + description="Optional created-at timestamp for the message. Accepts a datetime which will be converted to an ISO 8601 string, or a preformatted string.", + ), ) -> MessageCreateParam: """ Create a MessageCreateParam object attributed to this peer. @@ -172,7 +177,18 @@ class Peer(BaseModel): Returns: A new MessageCreateParam object with this peer's ID and the provided content """ - return MessageCreateParam(peer_id=self.id, content=content, metadata=metadata) + created_at_str: str | None + if isinstance(created_at, datetime.datetime): + created_at_str = created_at.isoformat() + else: + created_at_str = created_at + + return MessageCreateParam( + peer_id=self.id, + content=content, + metadata=metadata, + created_at=created_at_str, + ) def get_metadata(self) -> dict[str, object]: """ diff --git a/sdks/python/src/honcho/session.py b/sdks/python/src/honcho/session.py index 29216ce7..b3962ea3 100644 --- a/sdks/python/src/honcho/session.py +++ b/sdks/python/src/honcho/session.py @@ -9,7 +9,7 @@ from honcho_core.types.workspaces.sessions.message import Message from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call from .pagination import SyncPage -from .session_context import SessionContext +from .session_context import SessionContext, SessionSummaries, Summary from .utils import prepare_file_for_upload if TYPE_CHECKING: @@ -421,8 +421,72 @@ class Session(BaseModel): summary=summary, ) + # Convert the honcho_core summary to our Summary if it exists + session_summary = None + if context.summary: + session_summary = Summary( + content=context.summary.content, + message_id=context.summary.message_id, + summary_type=context.summary.summary_type, + created_at=context.summary.created_at, + token_count=context.summary.token_count, + ) + return SessionContext( - session_id=self.id, messages=context.messages, summary=context.summary + session_id=self.id, messages=context.messages, summary=session_summary + ) + + def get_summaries(self) -> SessionSummaries: + """ + Get available summaries for this session. + + Makes an API call to retrieve both short and long summaries for this session, + if they are available. Summaries are created asynchronously by the backend + as messages are added to the session. + + Returns: + A SessionSummaries object containing: + - id: The session ID + - short_summary: The short summary if available, including metadata + - long_summary: The long summary if available, including metadata + + Note: + Summaries may be None if: + - Not enough messages have been added to trigger summary generation + - The summary generation is still in progress + - Summary generation is disabled for this session + """ + # Use the honcho_core client to get summaries + response = self._client.workspaces.sessions.summaries( + session_id=self.id, + workspace_id=self.workspace_id, + ) + + # Create Summary objects from the response data + short_summary = None + if response.short_summary: + short_summary = Summary( + content=response.short_summary.content, + message_id=response.short_summary.message_id, + summary_type=response.short_summary.summary_type, + created_at=response.short_summary.created_at, + token_count=response.short_summary.token_count, + ) + + long_summary = None + if response.long_summary: + long_summary = Summary( + content=response.long_summary.content, + message_id=response.long_summary.message_id, + summary_type=response.long_summary.summary_type, + created_at=response.long_summary.created_at, + token_count=response.long_summary.token_count, + ) + + return SessionSummaries( + id=response.id or self.id, + short_summary=short_summary, + long_summary=long_summary, ) @validate_call diff --git a/sdks/python/src/honcho/session_context.py b/sdks/python/src/honcho/session_context.py index 87480802..1cd9e7f5 100644 --- a/sdks/python/src/honcho/session_context.py +++ b/sdks/python/src/honcho/session_context.py @@ -9,6 +9,34 @@ if TYPE_CHECKING: from .peer import Peer +class Summary(BaseModel): + """Represents a summary of a session's conversation.""" + + content: str = Field(..., description="The summary text") + message_id: int = Field( + ..., description="The ID of the message that this summary covers up to" + ) + summary_type: str = Field(..., description="The type of summary (short or long)") + created_at: str = Field( + ..., description="The timestamp of when the summary was created (ISO format)" + ) + token_count: int = Field( + ..., description="The number of tokens in the summary text" + ) + + +class SessionSummaries(BaseModel): + """Contains both short and long summaries for a session.""" + + id: str = Field(..., description="The session ID") + short_summary: Summary | None = Field( + None, description="The short summary if available" + ) + long_summary: Summary | None = Field( + None, description="The long summary if available" + ) + + class SessionContext(BaseModel): """ Represents the context of a session containing a curated list of messages. @@ -27,8 +55,8 @@ class SessionContext(BaseModel): messages: list[Message] = Field( ..., description="List of Message objects to include in the context" ) - summary: str = Field( - ..., description="Summary of the session history prior to the message cutoff" + summary: Summary | None = Field( + None, description="Summary of the session history prior to the message cutoff" ) @validate_call @@ -40,8 +68,8 @@ class SessionContext(BaseModel): messages: list[Message] = Field( ..., description="List of Message objects to include in the context" ), - summary: str = Field( - ..., + summary: Summary | None = Field( + None, description="Summary of the session history prior to the message cutoff", ), ) -> None: @@ -50,6 +78,7 @@ class SessionContext(BaseModel): Args: messages: List of Message objects to include in the context + summary: Optional Summary object containing summary information """ super().__init__( session_id=session_id, @@ -80,10 +109,6 @@ class SessionContext(BaseModel): """ assistant_id = assistant if isinstance(assistant, str) else assistant.id - summary_message = { - "role": "system", - "content": f"{self.summary}", - } messages = [ { "role": "assistant" if message.peer_id == assistant_id else "user", @@ -92,7 +117,14 @@ class SessionContext(BaseModel): } for message in self.messages ] - return [summary_message, *messages] if self.summary else messages + + if self.summary: + summary_message = { + "role": "system", + "content": f"{self.summary.content}", + } + return [summary_message, *messages] + return messages def to_anthropic( self, @@ -120,10 +152,6 @@ class SessionContext(BaseModel): """ assistant_id = assistant if isinstance(assistant, str) else assistant.id - summary_message = { - "role": "user", - "content": f"{self.summary}", - } messages = [ { "role": "assistant", @@ -136,7 +164,14 @@ class SessionContext(BaseModel): } for message in self.messages ] - return [summary_message, *messages] if self.summary else messages + + if self.summary: + summary_message = { + "role": "user", + "content": f"{self.summary.content}", + } + return [summary_message, *messages] + return messages def __len__(self) -> int: """ @@ -154,4 +189,4 @@ class SessionContext(BaseModel): Returns: A string representation suitable for debugging """ - return f"SessionContext(messages={len(self.messages)}, summary={self.summary})" + return f"SessionContext(messages={len(self.messages)}, summary={'present' if self.summary else 'None'})" diff --git a/sdks/python/uv.lock b/sdks/python/uv.lock index c8f8e68e..21c5b230 100644 --- a/sdks/python/uv.lock +++ b/sdks/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.8" resolution-markers = [ "python_full_version >= '3.9'", @@ -38,7 +38,7 @@ wheels = [ [[package]] name = "anyio" -version = "4.9.0" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.9'", @@ -47,20 +47,20 @@ dependencies = [ { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, { name = "idna", marker = "python_full_version >= '3.9'" }, { name = "sniffio", marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] [[package]] name = "certifi" -version = "2025.4.26" +version = "2025.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] [[package]] @@ -78,7 +78,7 @@ version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -96,13 +96,13 @@ wheels = [ [[package]] name = "honcho-ai" -version = "1.3.0" +version = "1.4.0" source = { editable = "." } dependencies = [ { name = "honcho-core" }, { name = "httpx" }, { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pydantic", version = "2.11.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] [package.dev-dependencies] @@ -112,7 +112,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "honcho-core", specifier = "==1.3.0" }, + { name = "honcho-core", specifier = ">=1.4.0" }, { name = "httpx", specifier = ">=0.28.0,<1" }, { name = "pydantic", specifier = ">=2.0.0,<3" }, ] @@ -122,22 +122,22 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }] [[package]] name = "honcho-core" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "anyio", version = "4.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "anyio", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "distro" }, { name = "httpx" }, { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pydantic", version = "2.11.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "sniffio" }, { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/49/1810de2351be1fdff330ffadcc5eef709889b2f698b0340966cae2c1eef9/honcho_core-1.3.0.tar.gz", hash = "sha256:276a73e8d523f7d22f06746922fda97e4a11fbbef4a51a0b43573bc817089cec", size = 123333, upload-time = "2025-08-06T16:42:10.941Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/c2/4d3737d5c0a75e2f59f324bed7428a9593c8edfcb7500cf402c46ad7378a/honcho_core-1.4.0.tar.gz", hash = "sha256:b30de9247763c01c3c37ed22b5d04d3c611440dd1983168f3d7d93f2cc0ecf7d", size = 126164, upload-time = "2025-08-12T19:06:54.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/3f/3beb750ed65bb1b604c585ae1c51a54f8b34ea3002f4525f93fe86549531/honcho_core-1.3.0-py3-none-any.whl", hash = "sha256:c7c0bf77b61162e6c65a580327fc054785aef794923abc35acd3dbd8287245db", size = 112895, upload-time = "2025-08-06T16:42:08.967Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ac/6eb2e38376736bb1dfd26bbdfd96490bfbb6f0ddcb00b39913e93799e7d8/honcho_core-1.4.0-py3-none-any.whl", hash = "sha256:a84397fd9daf546a04f5458d7233ebd5eeafcb97af75ec60a9327db89abdaf03", size = 117602, upload-time = "2025-08-12T19:06:53.667Z" }, ] [[package]] @@ -159,7 +159,7 @@ version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "anyio", version = "4.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "anyio", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, @@ -197,7 +197,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.5" +version = "2.11.7" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.9'", @@ -205,12 +205,12 @@ resolution-markers = [ dependencies = [ { name = "annotated-types", marker = "python_full_version >= '3.9'" }, { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "typing-inspection", marker = "python_full_version >= '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, ] [[package]] @@ -334,7 +334,7 @@ resolution-markers = [ "python_full_version >= '3.9'", ] dependencies = [ - { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } wheels = [ @@ -440,27 +440,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.11.13" +version = "0.12.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/da/5bd7565be729e86e1442dad2c9a364ceeff82227c2dece7c29697a9795eb/ruff-0.12.8.tar.gz", hash = "sha256:4cb3a45525176e1009b2b64126acf5f9444ea59066262791febf55e40493a033", size = 5242373, upload-time = "2025-08-07T19:05:47.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, - { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, - { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, - { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, - { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, - { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, - { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, - { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, - { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, - { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1e/c843bfa8ad1114fab3eb2b78235dda76acd66384c663a4e0415ecc13aa1e/ruff-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:63cb5a5e933fc913e5823a0dfdc3c99add73f52d139d6cd5cc8639d0e0465513", size = 11675315, upload-time = "2025-08-07T19:05:06.15Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/af6e5c2a8ca3a81676d5480a1025494fd104b8896266502bb4de2a0e8388/ruff-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a9bbe28f9f551accf84a24c366c1aa8774d6748438b47174f8e8565ab9dedbc", size = 12456653, upload-time = "2025-08-07T19:05:09.759Z" }, + { url = "https://files.pythonhosted.org/packages/99/9d/e91f84dfe3866fa648c10512904991ecc326fd0b66578b324ee6ecb8f725/ruff-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2fae54e752a3150f7ee0e09bce2e133caf10ce9d971510a9b925392dc98d2fec", size = 11659690, upload-time = "2025-08-07T19:05:12.551Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ac/a363d25ec53040408ebdd4efcee929d48547665858ede0505d1d8041b2e5/ruff-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0acbcf01206df963d9331b5838fb31f3b44fa979ee7fa368b9b9057d89f4a53", size = 11896923, upload-time = "2025-08-07T19:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/58/9f/ea356cd87c395f6ade9bb81365bd909ff60860975ca1bc39f0e59de3da37/ruff-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae3e7504666ad4c62f9ac8eedb52a93f9ebdeb34742b8b71cd3cccd24912719f", size = 11477612, upload-time = "2025-08-07T19:05:16.712Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/92e8fa3c9dcfd49175225c09053916cb97bb7204f9f899c2f2baca69e450/ruff-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb82efb5d35d07497813a1c5647867390a7d83304562607f3579602fa3d7d46f", size = 13182745, upload-time = "2025-08-07T19:05:18.709Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c4/f2176a310f26e6160deaf661ef60db6c3bb62b7a35e57ae28f27a09a7d63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dbea798fc0065ad0b84a2947b0aff4233f0cb30f226f00a2c5850ca4393de609", size = 14206885, upload-time = "2025-08-07T19:05:21.025Z" }, + { url = "https://files.pythonhosted.org/packages/87/9d/98e162f3eeeb6689acbedbae5050b4b3220754554526c50c292b611d3a63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49ebcaccc2bdad86fd51b7864e3d808aad404aab8df33d469b6e65584656263a", size = 13639381, upload-time = "2025-08-07T19:05:23.423Z" }, + { url = "https://files.pythonhosted.org/packages/81/4e/1b7478b072fcde5161b48f64774d6edd59d6d198e4ba8918d9f4702b8043/ruff-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ac9c570634b98c71c88cb17badd90f13fc076a472ba6ef1d113d8ed3df109fb", size = 12613271, upload-time = "2025-08-07T19:05:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/e8/67/0c3c9179a3ad19791ef1b8f7138aa27d4578c78700551c60d9260b2c660d/ruff-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:560e0cd641e45591a3e42cb50ef61ce07162b9c233786663fdce2d8557d99818", size = 12847783, upload-time = "2025-08-07T19:05:28.14Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2a/0b6ac3dd045acf8aa229b12c9c17bb35508191b71a14904baf99573a21bd/ruff-0.12.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:71c83121512e7743fba5a8848c261dcc454cafb3ef2934a43f1b7a4eb5a447ea", size = 11702672, upload-time = "2025-08-07T19:05:30.413Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ee/f9fdc9f341b0430110de8b39a6ee5fa68c5706dc7c0aa940817947d6937e/ruff-0.12.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:de4429ef2ba091ecddedd300f4c3f24bca875d3d8b23340728c3cb0da81072c3", size = 11440626, upload-time = "2025-08-07T19:05:32.492Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/b3aa2d482d05f44e4d197d1de5e3863feb13067b22c571b9561085c999dc/ruff-0.12.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a2cab5f60d5b65b50fba39a8950c8746df1627d54ba1197f970763917184b161", size = 12462162, upload-time = "2025-08-07T19:05:34.449Z" }, + { url = "https://files.pythonhosted.org/packages/18/9f/5c5d93e1d00d854d5013c96e1a92c33b703a0332707a7cdbd0a4880a84fb/ruff-0.12.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:45c32487e14f60b88aad6be9fd5da5093dbefb0e3e1224131cb1d441d7cb7d46", size = 12913212, upload-time = "2025-08-07T19:05:36.541Z" }, + { url = "https://files.pythonhosted.org/packages/71/13/ab9120add1c0e4604c71bfc2e4ef7d63bebece0cfe617013da289539cef8/ruff-0.12.8-py3-none-win32.whl", hash = "sha256:daf3475060a617fd5bc80638aeaf2f5937f10af3ec44464e280a9d2218e720d3", size = 11694382, upload-time = "2025-08-07T19:05:38.468Z" }, + { url = "https://files.pythonhosted.org/packages/f6/dc/a2873b7c5001c62f46266685863bee2888caf469d1edac84bf3242074be2/ruff-0.12.8-py3-none-win_amd64.whl", hash = "sha256:7209531f1a1fcfbe8e46bcd7ab30e2f43604d8ba1c49029bb420b103d0b5f76e", size = 12740482, upload-time = "2025-08-07T19:05:40.391Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718, upload-time = "2025-08-07T19:05:42.866Z" }, ] [[package]] @@ -486,14 +486,14 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.14.0" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.9'", ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] [[package]] @@ -501,7 +501,7 @@ name = "typing-inspection" version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } wheels = [ diff --git a/sdks/typescript/CHANGELOG.md b/sdks/typescript/CHANGELOG.md index ad57256e..cc29097e 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [1.4.0] - 2025-08-12 + +### Added + +- getSummaries API returning structured summaries + +### Changed + +- Messages can take an optional `created_at` value, defaulting to the current + time (UTC ISO 8601) + ## [1.3.0] - 2025-08-04 ### Added diff --git a/sdks/typescript/__tests__/integration.test.ts b/sdks/typescript/__tests__/integration.test.ts index 1c64a5e4..cc876a8b 100644 --- a/sdks/typescript/__tests__/integration.test.ts +++ b/sdks/typescript/__tests__/integration.test.ts @@ -1,18 +1,18 @@ -import { Honcho } from '../src/client'; -import { Peer } from '../src/peer'; -import { Session } from '../src/session'; -import { SessionContext } from '../src/session_context'; -import { Page } from '../src/pagination'; +import { Honcho } from '../src/client' +import { Peer } from '../src/peer' +import { Session } from '../src/session' +import { SessionContext } from '../src/session_context' +import { Page } from '../src/pagination' // Mock the @honcho-ai/core module -let mockWorkspacesApi: any; +let mockWorkspacesApi: any jest.mock('@honcho-ai/core', () => { - return jest.fn().mockImplementation(() => mockWorkspacesApi); -}); + return jest.fn().mockImplementation(() => mockWorkspacesApi) +}) describe('Honcho SDK Integration Tests', () => { - let honcho: Honcho; + let honcho: Honcho beforeEach(() => { mockWorkspacesApi = { @@ -29,7 +29,12 @@ describe('Honcho SDK Integration Tests', () => { }, sessions: { list: jest.fn(), - peers: { add: jest.fn(), set: jest.fn(), remove: jest.fn(), list: jest.fn() }, + peers: { + add: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + list: jest.fn(), + }, messages: { create: jest.fn(), list: jest.fn() }, getOrCreate: jest.fn(), update: jest.fn(), @@ -41,112 +46,135 @@ describe('Honcho SDK Integration Tests', () => { list: jest.fn(), search: jest.fn(), }, - }; + } - jest.clearAllMocks(); + jest.clearAllMocks() honcho = new Honcho({ workspaceId: 'integration-test-workspace', apiKey: 'test-api-key', environment: 'local', - }); - }); + }) + }) describe('Complete Workflow Integration', () => { it('should handle complete chat session workflow', async () => { // Setup mock responses - const mockPeerData = { id: 'assistant', metadata: { role: 'ai' } }; - const mockSessionData = { id: 'chat-session', metadata: { topic: 'general' } }; + const mockPeerData = { id: 'assistant', metadata: { role: 'ai' } } + const mockSessionData = { + id: 'chat-session', + metadata: { topic: 'general' }, + } const mockMessages = [ { id: 'msg1', content: 'Hello', peer_id: 'user' }, { id: 'msg2', content: 'Hi there!', peer_id: 'assistant' }, - ]; - const mockContextData = { messages: mockMessages, summary: 'Friendly greeting' }; + ] + const mockContextData = { + messages: mockMessages, + summary: { + content: 'Friendly greeting', + message_id: 5, + summary_type: 'short', + created_at: '2024-01-01T00:00:00Z', + token_count: 50, + }, + } - mockWorkspacesApi.workspaces.peers.getOrCreate.mockResolvedValue(mockPeerData); - mockWorkspacesApi.workspaces.sessions.getOrCreate.mockResolvedValue(mockSessionData); - mockWorkspacesApi.workspaces.sessions.peers.add.mockResolvedValue({}); - mockWorkspacesApi.workspaces.sessions.messages.create.mockResolvedValue({}); - mockWorkspacesApi.workspaces.sessions.getContext.mockResolvedValue(mockContextData); - mockWorkspacesApi.workspaces.peers.chat.mockResolvedValue({ content: 'AI response' }); + mockWorkspacesApi.workspaces.peers.getOrCreate.mockResolvedValue( + mockPeerData + ) + mockWorkspacesApi.workspaces.sessions.getOrCreate.mockResolvedValue( + mockSessionData + ) + mockWorkspacesApi.workspaces.sessions.peers.add.mockResolvedValue({}) + mockWorkspacesApi.workspaces.sessions.messages.create.mockResolvedValue( + {} + ) + mockWorkspacesApi.workspaces.sessions.getContext.mockResolvedValue( + mockContextData + ) + mockWorkspacesApi.workspaces.peers.chat.mockResolvedValue({ + content: 'AI response', + }) // Step 1: Create peers - const user = await honcho.peer('user'); - const assistant = await honcho.peer('assistant'); + const user = await honcho.peer('user') + const assistant = await honcho.peer('assistant') - expect(user).toBeInstanceOf(Peer); - expect(assistant).toBeInstanceOf(Peer); - expect(user.id).toBe('user'); - expect(assistant.id).toBe('assistant'); + expect(user).toBeInstanceOf(Peer) + expect(assistant).toBeInstanceOf(Peer) + expect(user.id).toBe('user') + expect(assistant.id).toBe('assistant') // Step 2: Create session - const session = await honcho.session('chat-session'); - expect(session).toBeInstanceOf(Session); - expect(session.id).toBe('chat-session'); + const session = await honcho.session('chat-session') + expect(session).toBeInstanceOf(Session) + expect(session.id).toBe('chat-session') // Step 3: Add peers to session - await session.addPeers([user, assistant]); - expect(mockWorkspacesApi.workspaces.sessions.peers.add).toHaveBeenCalledWith( - 'integration-test-workspace', - 'chat-session', - { - 'user': {}, - 'assistant': {} - } - ); + await session.addPeers([user, assistant]) + expect( + mockWorkspacesApi.workspaces.sessions.peers.add + ).toHaveBeenCalledWith('integration-test-workspace', 'chat-session', { + user: {}, + assistant: {}, + }) // Step 4: Add messages to session - const userMessage = user.message('Hello'); - const assistantMessage = assistant.message('Hi there!'); + const userMessage = user.message('Hello') + const assistantMessage = assistant.message('Hi there!') - await session.addMessages([userMessage, assistantMessage]); - expect(mockWorkspacesApi.workspaces.sessions.messages.create).toHaveBeenCalledWith( - 'integration-test-workspace', - 'chat-session', - { - messages: [ - { peer_id: 'user', content: 'Hello', metadata: undefined }, - { peer_id: 'assistant', content: 'Hi there!', metadata: undefined }, - ] - } - ); + await session.addMessages([userMessage, assistantMessage]) + expect( + mockWorkspacesApi.workspaces.sessions.messages.create + ).toHaveBeenCalledWith('integration-test-workspace', 'chat-session', { + messages: [ + { peer_id: 'user', content: 'Hello', metadata: undefined }, + { peer_id: 'assistant', content: 'Hi there!', metadata: undefined }, + ], + }) // Step 5: Get session context - const context = await session.getContext(); - expect(context).toBeInstanceOf(SessionContext); - expect(context.sessionId).toBe('chat-session'); - expect(context.messages).toEqual(mockMessages); - expect(context.summary).toBe('Friendly greeting'); + const context = await session.getContext() + expect(context).toBeInstanceOf(SessionContext) + expect(context.sessionId).toBe('chat-session') + expect(context.messages).toEqual(mockMessages) + expect(context.summary?.content).toBe('Friendly greeting') // Step 6: Convert context to different formats - const openAIFormat = context.toOpenAI('assistant'); - const anthropicFormat = context.toAnthropic('assistant'); + const openAIFormat = context.toOpenAI('assistant') + const anthropicFormat = context.toAnthropic('assistant') expect(openAIFormat).toEqual([ { role: 'system', content: 'Friendly greeting' }, { role: 'user', content: 'Hello', name: 'user' }, { role: 'assistant', content: 'Hi there!', name: 'assistant' }, - ]); + ]) expect(anthropicFormat).toEqual([ { role: 'user', content: 'Friendly greeting' }, { role: 'user', content: 'user: Hello' }, { role: 'assistant', content: 'Hi there!' }, - ]); + ]) // Step 7: Query assistant - const response = await assistant.chat('How are you?'); - expect(response).toBe('AI response'); + const response = await assistant.chat('How are you?') + expect(response).toBe('AI response') expect(mockWorkspacesApi.workspaces.peers.chat).toHaveBeenCalledWith( 'integration-test-workspace', 'assistant', - { query: 'How are you?', stream: undefined, target: undefined, session_id: undefined } - ); - }); + { + query: 'How are you?', + stream: undefined, + target: undefined, + session_id: undefined, + } + ) + }) it('should handle workspace and peer management workflow', async () => { // Setup mock responses - const mockWorkspaceMetadata = { name: 'Test Workspace', version: '1.0' }; + const mockWorkspaceMetadata = { name: 'Test Workspace', version: '1.0' } const mockPeersList = { items: [ { id: 'peer1', metadata: { role: 'user' } }, @@ -155,105 +183,115 @@ describe('Honcho SDK Integration Tests', () => { total: 2, size: 2, hasNextPage: false, - }; + } mockWorkspacesApi.workspaces.getOrCreate.mockResolvedValue({ id: 'integration-test-workspace', metadata: mockWorkspaceMetadata, - }); - mockWorkspacesApi.workspaces.update.mockResolvedValue({}); - mockWorkspacesApi.workspaces.peers.list.mockResolvedValue(mockPeersList); + }) + mockWorkspacesApi.workspaces.update.mockResolvedValue({}) + mockWorkspacesApi.workspaces.peers.list.mockResolvedValue(mockPeersList) // Step 1: Get workspace metadata - const metadata = await honcho.getMetadata(); - expect(metadata).toEqual(mockWorkspaceMetadata); + const metadata = await honcho.getMetadata() + expect(metadata).toEqual(mockWorkspaceMetadata) // Step 2: Update workspace metadata - const newMetadata = { ...mockWorkspaceMetadata, updated: true }; - await honcho.setMetadata(newMetadata); + const newMetadata = { ...mockWorkspaceMetadata, updated: true } + await honcho.setMetadata(newMetadata) expect(mockWorkspacesApi.workspaces.update).toHaveBeenCalledWith( 'integration-test-workspace', { metadata: newMetadata } - ); + ) // Step 3: Get all peers - const peersPage = await honcho.getPeers(); - expect(peersPage).toBeInstanceOf(Page); + const peersPage = await honcho.getPeers() + expect(peersPage).toBeInstanceOf(Page) // Step 4: Iterate through peers - const peersList: Peer[] = []; + const peersList: Peer[] = [] for await (const peer of peersPage) { - peersList.push(peer); + peersList.push(peer) } - expect(peersList).toHaveLength(2); - expect(peersList[0]).toBeInstanceOf(Peer); - expect(peersList[1]).toBeInstanceOf(Peer); - expect(peersList[0].id).toBe('peer1'); - expect(peersList[1].id).toBe('peer2'); - }); + expect(peersList).toHaveLength(2) + expect(peersList[0]).toBeInstanceOf(Peer) + expect(peersList[1]).toBeInstanceOf(Peer) + expect(peersList[0].id).toBe('peer1') + expect(peersList[1].id).toBe('peer2') + }) it('should handle search functionality across different scopes', async () => { // Setup mock responses const mockWorkspaceSearchResults = [ { id: 'msg1', content: 'workspace message', peer_id: 'peer1' }, - ]; + ] const mockPeerSearchResults = [ { id: 'msg2', content: 'peer message', peer_id: 'peer1' }, - ]; + ] const mockSessionSearchResults = [ { id: 'msg3', content: 'session message', peer_id: 'peer1' }, - ]; + ] - mockWorkspacesApi.workspaces.search.mockResolvedValue(mockWorkspaceSearchResults); - mockWorkspacesApi.workspaces.peers.search.mockResolvedValue(mockPeerSearchResults); - mockWorkspacesApi.workspaces.sessions.search.mockResolvedValue(mockSessionSearchResults); + mockWorkspacesApi.workspaces.search.mockResolvedValue( + mockWorkspaceSearchResults + ) + mockWorkspacesApi.workspaces.peers.search.mockResolvedValue( + mockPeerSearchResults + ) + mockWorkspacesApi.workspaces.sessions.search.mockResolvedValue( + mockSessionSearchResults + ) // Step 1: Search workspace - const workspaceResults = await honcho.search('test query'); - expect(Array.isArray(workspaceResults)).toBe(true); + const workspaceResults = await honcho.search('test query') + expect(Array.isArray(workspaceResults)).toBe(true) expect(mockWorkspacesApi.workspaces.search).toHaveBeenCalledWith( 'integration-test-workspace', { query: 'test query', limit: undefined } - ); + ) // Step 2: Search peer - const peer = await honcho.peer('test-peer'); - const peerResults = await peer.search('peer query'); - expect(Array.isArray(peerResults)).toBe(true); + const peer = await honcho.peer('test-peer') + const peerResults = await peer.search('peer query') + expect(Array.isArray(peerResults)).toBe(true) expect(mockWorkspacesApi.workspaces.peers.search).toHaveBeenCalledWith( 'integration-test-workspace', 'test-peer', { query: 'peer query', limit: undefined } - ); + ) // Step 3: Search session - const session = await honcho.session('test-session'); - const sessionResults = await session.search('session query'); - expect(Array.isArray(sessionResults)).toBe(true); + const session = await honcho.session('test-session') + const sessionResults = await session.search('session query') + expect(Array.isArray(sessionResults)).toBe(true) expect(mockWorkspacesApi.workspaces.sessions.search).toHaveBeenCalledWith( 'integration-test-workspace', 'test-session', { query: 'session query', limit: undefined } - ); - }); + ) + }) it('should handle error scenarios gracefully', async () => { // Setup error scenarios - mockWorkspacesApi.workspaces.peers.chat.mockRejectedValue(new Error('Chat API failed')); - mockWorkspacesApi.workspaces.sessions.getContext.mockRejectedValue(new Error('Context API failed')); + mockWorkspacesApi.workspaces.peers.chat.mockRejectedValue( + new Error('Chat API failed') + ) + mockWorkspacesApi.workspaces.sessions.getContext.mockRejectedValue( + new Error('Context API failed') + ) - const assistant = await honcho.peer('assistant'); - const session = await honcho.session('error-session'); + const assistant = await honcho.peer('assistant') + const session = await honcho.session('error-session') // Test error handling in chat - await expect(assistant.chat('Hello')).rejects.toThrow(); + await expect(assistant.chat('Hello')).rejects.toThrow() // Test error handling in context - await expect(session.getContext()).rejects.toThrow(); - }); + await expect(session.getContext()).rejects.toThrow() + }) it('should handle pagination correctly', async () => { // Setup paginated response @@ -266,7 +304,7 @@ describe('Honcho SDK Integration Tests', () => { size: 2, hasNextPage: true, nextPage: jest.fn(), - }; + } const secondPageData = { items: [ @@ -277,34 +315,34 @@ describe('Honcho SDK Integration Tests', () => { size: 2, hasNextPage: false, nextPage: jest.fn().mockResolvedValue(null), - }; + } - firstPageData.nextPage.mockResolvedValue(secondPageData); - mockWorkspacesApi.workspaces.peers.list.mockResolvedValue(firstPageData); + firstPageData.nextPage.mockResolvedValue(secondPageData) + mockWorkspacesApi.workspaces.peers.list.mockResolvedValue(firstPageData) // Step 1: Get first page - const firstPage = await honcho.getPeers(); - expect(firstPage.total).toBe(4); - expect(firstPage.size).toBe(2); - expect(firstPage.hasNextPage).toBe(true); + const firstPage = await honcho.getPeers() + expect(firstPage.total).toBe(4) + expect(firstPage.size).toBe(2) + expect(firstPage.hasNextPage).toBe(true) // Step 2: Get data from first page - const firstPageData_ = await firstPage.data(); - expect(firstPageData_).toHaveLength(2); + const firstPageData_ = await firstPage.data() + expect(firstPageData_).toHaveLength(2) // Step 3: Get next page - const secondPage = await firstPage.nextPage(); - expect(secondPage).not.toBeNull(); - expect(secondPage!.hasNextPage).toBe(false); + const secondPage = await firstPage.nextPage() + expect(secondPage).not.toBeNull() + expect(secondPage!.hasNextPage).toBe(false) // Step 4: Get data from second page - const secondPageData_ = await secondPage!.data(); - expect(secondPageData_).toHaveLength(2); + const secondPageData_ = await secondPage!.data() + expect(secondPageData_).toHaveLength(2) // Step 5: Verify no more pages - const thirdPage = await secondPage!.nextPage(); - expect(thirdPage).toBeNull(); - }); + const thirdPage = await secondPage!.nextPage() + expect(thirdPage).toBeNull() + }) it('should handle working representation queries', async () => { const mockWorkingRep = { @@ -312,94 +350,115 @@ describe('Honcho SDK Integration Tests', () => { knowledge: 'Alice likes coffee and works as a developer', relationships: ['bob', 'charlie'], context: 'session-specific context', - }; + } - mockWorkspacesApi.workspaces.peers.workingRepresentation.mockResolvedValue(mockWorkingRep); + mockWorkspacesApi.workspaces.peers.workingRepresentation.mockResolvedValue( + mockWorkingRep + ) - const session = await honcho.session('working-rep-session'); - const alice = await honcho.peer('alice'); - const bob = await honcho.peer('bob'); + const session = await honcho.session('working-rep-session') + const alice = await honcho.peer('alice') + const bob = await honcho.peer('bob') // Test working representation without target - const globalRep = await session.workingRep('alice'); - expect(globalRep).toEqual(mockWorkingRep); - expect(mockWorkspacesApi.workspaces.peers.workingRepresentation).toHaveBeenCalledWith( - 'integration-test-workspace', - 'alice', - { session_id: 'working-rep-session', target: undefined } - ); + const globalRep = await session.workingRep('alice') + expect(globalRep).toEqual(mockWorkingRep) + expect( + mockWorkspacesApi.workspaces.peers.workingRepresentation + ).toHaveBeenCalledWith('integration-test-workspace', 'alice', { + session_id: 'working-rep-session', + target: undefined, + }) // Test working representation with target - await session.workingRep(alice, bob); - expect(mockWorkspacesApi.workspaces.peers.workingRepresentation).toHaveBeenCalledWith( - 'integration-test-workspace', - 'alice', - { session_id: 'working-rep-session', target: 'bob' } - ); - }); - }); + await session.workingRep(alice, bob) + expect( + mockWorkspacesApi.workspaces.peers.workingRepresentation + ).toHaveBeenCalledWith('integration-test-workspace', 'alice', { + session_id: 'working-rep-session', + target: 'bob', + }) + }) + }) describe('Edge Cases and Error Handling Integration', () => { it('should handle empty and null responses gracefully', async () => { // Setup empty/null responses - mockWorkspacesApi.workspaces.peers.chat.mockResolvedValue({ content: null }); - mockWorkspacesApi.workspaces.peers.list.mockResolvedValue({ items: [], total: 0, hasNextPage: false }); - mockWorkspacesApi.workspaces.sessions.getContext.mockResolvedValue({ messages: [] }); + mockWorkspacesApi.workspaces.peers.chat.mockResolvedValue({ + content: null, + }) + mockWorkspacesApi.workspaces.peers.list.mockResolvedValue({ + items: [], + total: 0, + hasNextPage: false, + }) + mockWorkspacesApi.workspaces.sessions.getContext.mockResolvedValue({ + messages: [], + }) - const peer = await honcho.peer('empty-peer'); - const session = await honcho.session('empty-session'); + const peer = await honcho.peer('empty-peer') + const session = await honcho.session('empty-session') // Test null chat response - const chatResult = await peer.chat('Hello'); - expect(chatResult).toBeNull(); + const chatResult = await peer.chat('Hello') + expect(chatResult).toBeNull() // Test empty peers list - const peersPage = await honcho.getPeers(); - const peersList = await peersPage.data(); - expect(peersList).toEqual([]); + const peersPage = await honcho.getPeers() + const peersList = await peersPage.data() + expect(peersList).toEqual([]) // Test empty context - const context = await session.getContext(); - expect(context.messages).toEqual([]); - expect(context.length).toBe(0); - }); + const context = await session.getContext() + expect(context.messages).toEqual([]) + expect(context.length).toBe(0) + }) it('should maintain type safety throughout the workflow', async () => { // This test verifies TypeScript types are maintained correctly - const peer: Peer = await honcho.peer('typed-peer'); - const session: Session = await honcho.session('typed-session'); + const peer: Peer = await honcho.peer('typed-peer') + const session: Session = await honcho.session('typed-session') - expect(typeof peer.id).toBe('string'); - expect(typeof session.id).toBe('string'); + expect(typeof peer.id).toBe('string') + expect(typeof session.id).toBe('string') - const message = peer.message('typed message', { metadata: { type: 'test' } }); - expect(typeof message.peer_id).toBe('string'); - expect(typeof message.content).toBe('string'); - expect(typeof message.metadata).toBe('object'); + const message = peer.message('typed message', { + metadata: { type: 'test' }, + }) + expect(typeof message.peer_id).toBe('string') + expect(typeof message.content).toBe('string') + expect(typeof message.metadata).toBe('object') // Mock successful operations mockWorkspacesApi.workspaces.sessions.getContext.mockResolvedValue({ messages: [{ id: 'msg1', content: 'Hello', peer_id: 'typed-peer' }], - summary: 'Test summary', - }); + summary: { + content: 'Test summary', + message_id: 1, + summary_type: 'short', + created_at: '2024-01-01T00:00:00Z', + token_count: 20, + }, + }) - const context: SessionContext = await session.getContext(); - expect(typeof context.sessionId).toBe('string'); - expect(Array.isArray(context.messages)).toBe(true); - expect(typeof context.summary).toBe('string'); - expect(typeof context.length).toBe('number'); - expect(typeof context.toString()).toBe('string'); + const context: SessionContext = await session.getContext() + expect(typeof context.sessionId).toBe('string') + expect(Array.isArray(context.messages)).toBe(true) + expect(context.summary).not.toBeNull() + expect(typeof context.summary?.content).toBe('string') + expect(typeof context.length).toBe('number') + expect(typeof context.toString()).toBe('string') - const openAI = context.toOpenAI(peer); - const anthropic = context.toAnthropic('assistant'); + const openAI = context.toOpenAI(peer) + const anthropic = context.toAnthropic('assistant') - expect(Array.isArray(openAI)).toBe(true); - expect(Array.isArray(anthropic)).toBe(true); + expect(Array.isArray(openAI)).toBe(true) + expect(Array.isArray(anthropic)).toBe(true) if (openAI.length > 0) { - expect(typeof openAI[0].role).toBe('string'); - expect(typeof openAI[0].content).toBe('string'); + expect(typeof openAI[0].role).toBe('string') + expect(typeof openAI[0].content).toBe('string') } - }); - }); -}); + }) + }) +}) diff --git a/sdks/typescript/__tests__/session.test.ts b/sdks/typescript/__tests__/session.test.ts index c95f4445..1d0d2d9e 100644 --- a/sdks/typescript/__tests__/session.test.ts +++ b/sdks/typescript/__tests__/session.test.ts @@ -455,6 +455,107 @@ describe('Session', () => { await expect(session.addMessages({ peer_id: 'peer1', content: 'test' })).rejects.toThrow(); }); + + it('should add message with custom timestamp', async () => { + const message = { + peer_id: 'peer1', + content: 'Message with timestamp', + created_at: '2023-01-01T12:00:00Z', + metadata: { test: 'timestamp' }, + }; + mockClient.workspaces.sessions.messages.create.mockResolvedValue({}); + + await session.addMessages(message); + + expect(mockClient.workspaces.sessions.messages.create).toHaveBeenCalledWith( + 'test-workspace', + 'test-session', + { + messages: [{ + peer_id: 'peer1', + content: 'Message with timestamp', + created_at: '2023-01-01T12:00:00Z', + metadata: { test: 'timestamp' } + }] + } + ); + }); + + it('should add message with null timestamp', async () => { + const message = { + peer_id: 'peer1', + content: 'Message without timestamp', + created_at: null, + metadata: { test: 'no_timestamp' }, + }; + mockClient.workspaces.sessions.messages.create.mockResolvedValue({}); + + await session.addMessages(message); + + expect(mockClient.workspaces.sessions.messages.create).toHaveBeenCalledWith( + 'test-workspace', + 'test-session', + { + messages: [{ + peer_id: 'peer1', + content: 'Message without timestamp', + created_at: null, + metadata: { test: 'no_timestamp' } + }] + } + ); + }); + + it('should add mixed messages with and without timestamps', async () => { + const messages = [ + { + peer_id: 'peer1', + content: 'Message with timestamp', + created_at: '2023-01-01T12:00:00Z', + metadata: { type: 'historical' } + }, + { + peer_id: 'peer2', + content: 'Message without timestamp', + metadata: { type: 'current' } + }, + { + peer_id: 'peer3', + content: 'Message with null timestamp', + created_at: null, + metadata: { type: 'default' } + } + ]; + mockClient.workspaces.sessions.messages.create.mockResolvedValue({}); + + await session.addMessages(messages); + + expect(mockClient.workspaces.sessions.messages.create).toHaveBeenCalledWith( + 'test-workspace', + 'test-session', + { + messages: [ + { + peer_id: 'peer1', + content: 'Message with timestamp', + created_at: '2023-01-01T12:00:00Z', + metadata: { type: 'historical' } + }, + { + peer_id: 'peer2', + content: 'Message without timestamp', + metadata: { type: 'current' } + }, + { + peer_id: 'peer3', + content: 'Message with null timestamp', + created_at: null, + metadata: { type: 'default' } + } + ] + } + ); + }); }); describe('getMessages', () => { @@ -582,7 +683,13 @@ describe('Session', () => { { id: 'msg1', content: 'Hello', peer_id: 'peer1' }, { id: 'msg2', content: 'Hi there', peer_id: 'peer2' }, ], - summary: 'Conversation summary', + summary: { + content: 'Conversation summary', + message_id: 10, + summary_type: 'short', + created_at: '2024-01-01T00:00:00Z', + token_count: 100 + }, }; mockClient.workspaces.sessions.getContext.mockResolvedValue(mockContext); @@ -591,7 +698,7 @@ describe('Session', () => { expect(context).toBeInstanceOf(SessionContext); expect(context.sessionId).toBe('test-session'); expect(context.messages).toEqual(mockContext.messages); - expect(context.summary).toBe('Conversation summary'); + expect(context.summary?.content).toBe('Conversation summary'); expect(mockClient.workspaces.sessions.getContext).toHaveBeenCalledWith( 'test-workspace', 'test-session', @@ -602,7 +709,13 @@ describe('Session', () => { it('should get session context with options', async () => { const mockContext = { messages: [{ id: 'msg1', content: 'Hello', peer_id: 'peer1' }], - summary: 'Brief summary', + summary: { + content: 'Brief summary', + message_id: 5, + summary_type: 'short', + created_at: '2024-01-01T00:00:00Z', + token_count: 50 + }, }; mockClient.workspaces.sessions.getContext.mockResolvedValue(mockContext); @@ -624,7 +737,7 @@ describe('Session', () => { const context = await session.getContext(); - expect(context.summary).toBe(''); + expect(context.summary).toBeNull(); }); it('should handle API errors', async () => { diff --git a/sdks/typescript/__tests__/session_context.test.ts b/sdks/typescript/__tests__/session_context.test.ts index 2af28b4d..9c95dbce 100644 --- a/sdks/typescript/__tests__/session_context.test.ts +++ b/sdks/typescript/__tests__/session_context.test.ts @@ -1,4 +1,4 @@ -import { SessionContext } from '../src/session_context'; +import { SessionContext, Summary } from '../src/session_context'; import { Peer } from '../src/peer'; /** @@ -17,6 +17,19 @@ function createTestMessage(id: string, content: string, peer_id: string, additio }; } +/** + * Helper function to create a test Summary object + */ +function createTestSummary(content: string): Summary { + return new Summary({ + content, + message_id: 1, + summary_type: 'short', + created_at: new Date().toISOString(), + token_count: content.length + }); +} + describe('SessionContext', () => { let sessionContext: SessionContext; let mockMessages: any[]; @@ -29,38 +42,39 @@ describe('SessionContext', () => { createTestMessage('msg4', 'I am doing well, thank you!', 'assistant'), ]; - sessionContext = new SessionContext('test-session', mockMessages, ''); + sessionContext = new SessionContext('test-session', mockMessages, null); }); describe('constructor', () => { it('should initialize with all properties', () => { expect(sessionContext.sessionId).toBe('test-session'); expect(sessionContext.messages).toEqual(mockMessages); - expect(sessionContext.summary).toBe(''); + expect(sessionContext.summary).toBe(null); }); - it('should initialize with empty summary when not provided', () => { + it('should initialize with null summary when not provided', () => { const context = new SessionContext('session-id', mockMessages); expect(context.sessionId).toBe('session-id'); expect(context.messages).toEqual(mockMessages); - expect(context.summary).toBe(''); + expect(context.summary).toBe(null); }); it('should handle empty messages array', () => { - const context = new SessionContext('session-id', [], 'No messages'); + const summary = createTestSummary('No messages'); + const context = new SessionContext('session-id', [], summary); expect(context.sessionId).toBe('session-id'); expect(context.messages).toEqual([]); - expect(context.summary).toBe('No messages'); + expect(context.summary).toBe(summary); }); it('should handle null/undefined summary', () => { const context1 = new SessionContext('session-id', mockMessages, undefined as any); - const context2 = new SessionContext('session-id', mockMessages, null as any); + const context2 = new SessionContext('session-id', mockMessages, null); - expect(context1.summary).toBe(''); - expect(context2.summary).toBe(''); + expect(context1.summary).toBe(null); + expect(context2.summary).toBe(null); }); }); @@ -109,7 +123,8 @@ describe('SessionContext', () => { }); it('should include summary message when summary exists', () => { - const contextWithSummary = new SessionContext('test-session', mockMessages, 'This is a summary'); + const summary = createTestSummary('This is a summary'); + const contextWithSummary = new SessionContext('test-session', mockMessages, summary); const openAIMessages = contextWithSummary.toOpenAI('assistant'); expect(openAIMessages).toEqual([ @@ -201,7 +216,8 @@ describe('SessionContext', () => { }); it('should include summary message when summary exists', () => { - const contextWithSummary = new SessionContext('test-session', mockMessages, 'This is a summary'); + const summary = createTestSummary('This is a summary'); + const contextWithSummary = new SessionContext('test-session', mockMessages, summary); const anthropicMessages = contextWithSummary.toAnthropic('assistant'); expect(anthropicMessages).toEqual([ @@ -250,13 +266,13 @@ describe('SessionContext', () => { describe('toString', () => { it('should return correct string representation', () => { const result = sessionContext.toString(); - expect(result).toBe('SessionContext(messages=4, summary=)'); + expect(result).toBe('SessionContext(messages=4, summary=none)'); }); it('should handle empty messages', () => { const emptyContext = new SessionContext('session-id', []); const result = emptyContext.toString(); - expect(result).toBe('SessionContext(messages=0, summary=)'); + expect(result).toBe('SessionContext(messages=0, summary=none)'); }); it('should handle large number of messages', () => { @@ -266,7 +282,7 @@ describe('SessionContext', () => { const context = new SessionContext('session-id', manyMessages); const result = context.toString(); - expect(result).toBe('SessionContext(messages=1000, summary=)'); + expect(result).toBe('SessionContext(messages=1000, summary=none)'); }); }); @@ -305,11 +321,13 @@ describe('SessionContext', () => { it('should handle very long session IDs and summaries', () => { const longSessionId = 'x'.repeat(1000); - const longSummary = 'Very long summary that goes on and on...'.repeat(100); + const longSummaryText = 'Very long summary that goes on and on...'.repeat(100); + const longSummary = createTestSummary(longSummaryText); const context = new SessionContext(longSessionId, mockMessages, longSummary); expect(context.sessionId).toBe(longSessionId); expect(context.summary).toBe(longSummary); + expect(context.summary?.content).toBe(longSummaryText); expect(context.length).toBe(5); // 4 messages + 1 summary }); diff --git a/sdks/typescript/bun.lock b/sdks/typescript/bun.lock index cccce038..c6ea15db 100644 --- a/sdks/typescript/bun.lock +++ b/sdks/typescript/bun.lock @@ -4,7 +4,7 @@ "": { "name": "@honcho-ai/sdk", "dependencies": { - "@honcho-ai/core": "1.3.0", + "@honcho-ai/core": "^1.4.0", "@types/node": "^24.0.1", "zod": "4.0.0", }, @@ -108,7 +108,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-dxy599q6lgp8ANPpR8sDMscwdp9oOumEsVXuVCVT9N2vAho8uYXlCz53JhxX6LtJOXaE73qzgkGQ7QqvFlMC0g=="], - "@honcho-ai/core": ["@honcho-ai/core@1.3.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-yhxpZ00sQhO24PrHoTlj1cwR1q7/8FKSFZoCImqsFFmdERiXtE74YXhhfqzseyuShu1WfM7IwD8T2oCOB3hu7A=="], + "@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=="], "@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=="], @@ -644,7 +644,7 @@ "@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.121", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-bHOrbyztmyYIi4f1R0s17QsPs1uyyYnGcXeZoGEd227oZjry0q6XQBQxd82X1I57zEfwO8h9Xo+Kl5gX1d9MwQ=="], + "@honcho-ai/core/@types/node": ["@types/node@18.19.122", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-yzegtT82dwTNEe/9y+CM8cgb42WrUfMMCg2QqSddzO1J6uPmBD7qKCZ7dOHZP2Yrpm/kb0eqdNMn2MUyEiqBmA=="], "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], diff --git a/sdks/typescript/examples/get_summaries.ts b/sdks/typescript/examples/get_summaries.ts new file mode 100644 index 00000000..15bcd4a9 --- /dev/null +++ b/sdks/typescript/examples/get_summaries.ts @@ -0,0 +1,104 @@ +/** + * Example of getting session summaries using the Honcho TypeScript SDK. + * + * This example demonstrates how to retrieve both short and long summaries + * for a session, including their metadata like message ID, creation timestamp, + * and token count. + */ + +import { Honcho, type SessionSummaries, type Summary } from "../src"; + +async function main() { + // Initialize the Honcho client + const apiKey = process.env.HONCHO_API_KEY; + if (!apiKey) { + throw new Error("Please set HONCHO_API_KEY environment variable"); + } + + const client = new Honcho({ apiKey }); + + // Get a session (replace with your actual session ID) + const session = await client.session("my-conversation-session"); + + // Get summaries for the session + const summaries: SessionSummaries = await session.getSummaries(); + + console.log(`Session ID: ${summaries.id}`); + console.log("-".repeat(50)); + + // Check and display short summary + if (summaries.shortSummary) { + console.log("SHORT SUMMARY:"); + console.log( + ` Content: ${summaries.shortSummary.content.slice(0, 200)}...`, + ); // First 200 chars + console.log( + ` Covers up to message ID: ${summaries.shortSummary.messageId}`, + ); + console.log(` Created at: ${summaries.shortSummary.createdAt}`); + console.log(` Token count: ${summaries.shortSummary.tokenCount}`); + console.log(` Type: ${summaries.shortSummary.summaryType}`); + } else { + console.log("No short summary available yet"); + } + + console.log("-".repeat(50)); + + // Check and display long summary + if (summaries.longSummary) { + console.log("LONG SUMMARY:"); + console.log(` Content: ${summaries.longSummary.content.slice(0, 200)}...`); // First 200 chars + console.log( + ` Covers up to message ID: ${summaries.longSummary.messageId}`, + ); + console.log(` Created at: ${summaries.longSummary.createdAt}`); + console.log(` Token count: ${summaries.longSummary.tokenCount}`); + console.log(` Type: ${summaries.longSummary.summaryType}`); + } else { + console.log("No long summary available yet"); + } + + // Example showing how to use summaries in application logic + console.log("\n" + "=".repeat(50)); + console.log("USAGE EXAMPLE:"); + console.log("=".repeat(50)); + + // Check if we have any summaries + if (summaries.shortSummary || summaries.longSummary) { + // Prefer long summary if available and under token limit + const MAX_TOKENS = 2000; + let selectedSummary: Summary | null = null; + + if ( + summaries.longSummary && + summaries.longSummary.tokenCount <= MAX_TOKENS + ) { + selectedSummary = summaries.longSummary; + console.log("Using long summary (within token limit)"); + } else if (summaries.shortSummary) { + selectedSummary = summaries.shortSummary; + console.log("Using short summary"); + } + + if (selectedSummary) { + console.log( + `Selected summary covers up to message ${selectedSummary.messageId}`, + ); + console.log(`Token count: ${selectedSummary.tokenCount}`); + + // You could use this summary as context for an LLM call + // For example, with OpenAI: + // const systemPrompt = `Previous conversation summary: ${selectedSummary.content}` + } + } else { + console.log( + "No summaries available - would need to use full message history", + ); + } +} + +// Run the example +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 032c227a..016d8bc6 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@honcho-ai/sdk", - "version": "1.3.0", + "version": "1.4.0", "description": "Official DX Optimized TypeScript SDK for Honcho", "author": "Plastic Labs ", "license": "Apache-2.0", @@ -20,7 +20,7 @@ "test:coverage": "jest --coverage" }, "dependencies": { - "@honcho-ai/core": "1.3.0", + "@honcho-ai/core": "^1.4.0", "@types/node": "^24.0.1", "zod": "4.0.0" }, diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index f4f1da9d..3aed433b 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -6,7 +6,12 @@ export { Honcho } from './client' export { Page } from './pagination' export { Peer } from './peer' export { Session, SessionPeerConfig } from './session' -export { SessionContext } from './session_context' +export { + SessionContext, + SessionSummaries, + Summary, + SummaryData, +} from './session_context' // Export validation types for advanced usage export type { diff --git a/sdks/typescript/src/peer.ts b/sdks/typescript/src/peer.ts index 4769dbb5..360a98fa 100644 --- a/sdks/typescript/src/peer.ts +++ b/sdks/typescript/src/peer.ts @@ -131,21 +131,31 @@ export class Peer { * * @param content - The text content for the message * @param metadata - Optional metadata to associate with the message + * @param created_at - Optional ISO 8601 timestamp for the message * @returns A new message object with this peer's ID and the provided content */ message( content: string, - options?: { metadata?: Record } + options?: { + metadata?: Record + created_at?: string | Date + } ): ValidatedMessageCreate { const validatedContent = MessageContentSchema.parse(content) const validatedMetadata = options?.metadata ? MessageMetadataSchema.parse(options.metadata) : undefined + const createdAt = + options?.created_at instanceof Date + ? options.created_at.toISOString() + : options?.created_at + return { peer_id: this.id, content: validatedContent, metadata: validatedMetadata, + created_at: createdAt, } } diff --git a/sdks/typescript/src/session.ts b/sdks/typescript/src/session.ts index 7d7c8262..7f39e09e 100644 --- a/sdks/typescript/src/session.ts +++ b/sdks/typescript/src/session.ts @@ -3,7 +3,7 @@ import type { Message } from '@honcho-ai/core/src/resources/workspaces/sessions/ import type { Uploadable } from '@honcho-ai/core/src/uploads' import { Page } from './pagination' import { Peer } from './peer' -import { SessionContext } from './session_context' +import { SessionContext, SessionSummaries, Summary } from './session_context' import { ContextParamsSchema, FileUploadSchema, @@ -350,6 +350,12 @@ export class Session { * { peer_id: 'user1', content: 'Hello!' }, * { peer_id: 'assistant', content: 'Hi there!' } * ]) + * // Add message with custom ISO 8601 timestamp + * await session.addMessages({ + * peer_id: 'user123', + * content: 'Hello world!', + * created_at: '2021-01-01T00:00:00.000Z' + * }) * ``` */ async addMessages(messages: MessageAddition): Promise { @@ -457,7 +463,37 @@ export class Session { summary: contextParams.summary, } ) - return new SessionContext(this.id, context.messages, context.summary) + // Convert the summary response to Summary object if present + const summary = context.summary ? new Summary(context.summary) : null + return new SessionContext(this.id, context.messages, summary) + } + + /** + * Get available summaries for this session. + * + * Makes an API call to retrieve both short and long summaries for this session, + * if they are available. Summaries are created asynchronously by the backend + * as messages are added to the session. + * + * @returns Promise resolving to a SessionSummaries object containing: + * - id: The session ID + * - shortSummary: The short summary if available, including metadata + * - longSummary: The long summary if available, including metadata + * + * @note Summaries may be null if: + * - Not enough messages have been added to trigger summary generation + * - The summary generation is still in progress + * - Summary generation is disabled for this session + */ + async getSummaries(): Promise { + // Use the core SDK's summaries method + const data = await this._client.workspaces.sessions.summaries( + this.workspaceId, + this.id + ) + + // Return a SessionSummaries instance + return new SessionSummaries(data) } /** diff --git a/sdks/typescript/src/session_context.ts b/sdks/typescript/src/session_context.ts index 958c538c..80df6539 100644 --- a/sdks/typescript/src/session_context.ts +++ b/sdks/typescript/src/session_context.ts @@ -1,6 +1,84 @@ import type { Message } from '@honcho-ai/core/src/resources/workspaces/sessions/messages' import type { Peer } from './peer' +export interface SummaryData { + content: string + message_id: number + summary_type: string + created_at: string + token_count: number +} + +/** + * Represents a summary of a session's conversation. + */ +export class Summary { + /** + * The summary text. + */ + readonly content: string + + /** + * The ID of the message that this summary covers up to. + */ + readonly messageId: number + + /** + * The type of summary (short or long). + */ + readonly summaryType: string + + /** + * The timestamp of when the summary was created (ISO format). + */ + readonly createdAt: string + + /** + * The number of tokens in the summary text. + */ + readonly tokenCount: number + + constructor(data: SummaryData) { + this.content = data.content + this.messageId = data.message_id + this.summaryType = data.summary_type + this.createdAt = data.created_at + this.tokenCount = data.token_count + } +} + +/** + * Contains both short and long summaries for a session. + */ +export class SessionSummaries { + /** + * The session ID. + */ + readonly id: string + + /** + * The short summary if available. + */ + readonly shortSummary: Summary | null + + /** + * The long summary if available. + */ + readonly longSummary: Summary | null + + constructor(data: { + id: string + short_summary?: SummaryData | null + long_summary?: SummaryData | null + }) { + this.id = data.id + this.shortSummary = data.short_summary + ? new Summary(data.short_summary) + : null + this.longSummary = data.long_summary ? new Summary(data.long_summary) : null + } +} + /** * Represents the context of a session containing a curated list of messages. * @@ -22,7 +100,7 @@ export class SessionContext { /** * Summary of the session history prior to the message cutoff. */ - readonly summary: string + readonly summary: Summary | null /** * Initialize a new SessionContext. @@ -31,10 +109,14 @@ export class SessionContext { * @param messages List of Message objects to include in the context * @param summary Summary of the session history prior to the message cutoff */ - constructor(sessionId: string, messages: Message[], summary: string = '') { + constructor( + sessionId: string, + messages: Message[], + summary: Summary | null = null + ) { this.sessionId = sessionId this.messages = messages - this.summary = summary || '' + this.summary = summary } /** @@ -54,16 +136,18 @@ export class SessionContext { assistant: string | Peer ): Array<{ role: string; content: string; name?: string }> { const assistantId = typeof assistant === 'string' ? assistant : assistant.id - const summaryMessage = { - role: 'system', - content: `${this.summary}`, - } + const summaryMessage = this.summary + ? { + role: 'system', + content: `${this.summary.content}`, + } + : null const messages = this.messages.map((message) => ({ role: message.peer_id === assistantId ? 'assistant' : 'user', name: message.peer_id, content: message.content, })) - return this.summary ? [summaryMessage, ...messages] : messages + return summaryMessage ? [summaryMessage, ...messages] : messages } /** @@ -86,10 +170,12 @@ export class SessionContext { assistant: string | Peer ): Array<{ role: string; content: string }> { const assistantId = typeof assistant === 'string' ? assistant : assistant.id - const summaryMessage = { - role: 'user', - content: `${this.summary}`, - } + const summaryMessage = this.summary + ? { + role: 'user', + content: `${this.summary.content}`, + } + : null const messages = this.messages.map((message) => message.peer_id === assistantId ? { @@ -101,20 +187,20 @@ export class SessionContext { content: `${message.peer_id}: ${message.content}`, } ) - return this.summary ? [summaryMessage, ...messages] : messages + return summaryMessage ? [summaryMessage, ...messages] : messages } /** * Return the number of messages in the context. */ get length(): number { - return this.messages.length + (this.summary.length > 0 ? 1 : 0) + return this.messages.length + (this.summary ? 1 : 0) } /** * Return a string representation of the SessionContext. */ toString(): string { - return `SessionContext(messages=${this.messages.length}, summary=${this.summary})` + return `SessionContext(messages=${this.messages.length}, summary=${this.summary ? 'present' : 'none'})` } } diff --git a/sdks/typescript/src/validation.ts b/sdks/typescript/src/validation.ts index c9e40535..f8fae934 100644 --- a/sdks/typescript/src/validation.ts +++ b/sdks/typescript/src/validation.ts @@ -94,6 +94,7 @@ export const MessageCreateSchema = z.object({ peer_id: PeerIdSchema, content: MessageContentSchema, metadata: MessageMetadataSchema, + created_at: z.string().nullable().optional(), }) /** diff --git a/src/crud/message.py b/src/crud/message.py index 4b37972b..707d6edb 100644 --- a/src/crud/message.py +++ b/src/crud/message.py @@ -89,6 +89,7 @@ async def create_messages( workspace_name=workspace_name, public_id=generate_nanoid(), token_count=len(message.encoded_message), + created_at=message.created_at, # Use provided created_at if available ) message_objects.append(message_obj) diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index 73597457..c850da52 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -19,6 +19,7 @@ from src.utils.formatting import ( find_new_observations, format_context_for_prompt, format_new_turn_with_timestamp, + utc_now_iso, ) from src.utils.logging import ( accumulate_metric, @@ -630,7 +631,7 @@ async def save_working_representation_to_peer( working_rep_data = { "final_observations": final_obs_dict, "message_id": message_id, - "created_at": datetime.datetime.now().isoformat(), + "created_at": utc_now_iso(), } # if session_name is supplied, save working representation to session peer diff --git a/src/dialectic/chat.py b/src/dialectic/chat.py index f0880856..c03450cf 100644 --- a/src/dialectic/chat.py +++ b/src/dialectic/chat.py @@ -18,7 +18,7 @@ from mirascope.llm import Stream from src import crud from src.config import settings from src.dependencies import tracked_db -from src.routers.sessions import get_session_context +from src.utils import summarizer from src.utils.clients import honcho_llm_call from src.utils.embedding_store import EmbeddingStore from src.utils.logging import ( @@ -262,25 +262,16 @@ async def chat( # If query is session-scoped, get recent conversation history from that session if session_name: async with tracked_db("chat.get_session_context") as db: - session_context = await get_session_context( - workspace_id=workspace_name, - session_id=session_name, - tokens=context_window_size, - summary=True, - db=db, + recent_conversation_history = ( + await summarizer.get_session_context_formatted( + db, + workspace_name=workspace_name, + session_name=session_name, + token_limit=context_window_size, + include_summary=True, + ) ) - logger.info( - "Retrieved recent conversation history with %s messages", - len(session_context.messages), - ) - recent_conversation_history = f""" - - {session_context.summary} - - - {session_context.messages} - - """ + logger.info("Retrieved recent conversation history") else: recent_conversation_history = None logger.info("Query is not session-scoped, skipping recent conversation history") diff --git a/src/dialectic/utils.py b/src/dialectic/utils.py index f190a5c3..8f9cdceb 100644 --- a/src/dialectic/utils.py +++ b/src/dialectic/utils.py @@ -11,6 +11,7 @@ from src.utils.clients import honcho_llm_call from src.utils.embedding_store import EmbeddingStore from src.utils.formatting import ( format_premises_for_display, + parse_datetime_iso, ) from src.utils.logging import conditional_observe from src.utils.shared_models import SemanticQueries @@ -190,11 +191,9 @@ def _format_observations( if last_accessed: # Format the last_accessed datetime for display try: - from datetime import datetime - if isinstance(last_accessed, str): # Parse ISO format datetime string - dt = datetime.fromisoformat(last_accessed.replace("Z", "+00:00")) + dt = parse_datetime_iso(last_accessed) formatted_last_accessed: str = dt.strftime("%Y-%m-%d %H:%M") access_parts.append(f"last accessed {formatted_last_accessed}") except (ValueError, AttributeError): diff --git a/src/main.py b/src/main.py index 44b2fa39..0d3d5ab0 100644 --- a/src/main.py +++ b/src/main.py @@ -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.2.0", + version="2.3.0", contact={ "name": "Plastic Labs", "url": "https://honcho.dev", diff --git a/src/routers/keys.py b/src/routers/keys.py index becd4355..f4a50375 100644 --- a/src/routers/keys.py +++ b/src/routers/keys.py @@ -10,6 +10,7 @@ from src.security import ( create_jwt, require_auth, ) +from src.utils.formatting import format_datetime_utc logger = logging.getLogger(__name__) @@ -43,7 +44,7 @@ async def create_key( key_str = create_jwt( JWTParams( - exp=expires_at.isoformat() if expires_at else None, + exp=format_datetime_utc(expires_at) if expires_at else None, w=workspace_id, p=peer_id, s=session_id, diff --git a/src/routers/sessions.py b/src/routers/sessions.py index e8fbbf29..b86a8481 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -374,9 +374,11 @@ async def get_session_context( le=config.settings.GET_CONTEXT_MAX_TOKENS, description=f"Number of tokens to use for the context. Includes summary if set to true. If not provided, the context will be exhaustive (within {config.settings.GET_CONTEXT_MAX_TOKENS} tokens)", ), - summary: bool = Query( - True, + *, + include_summary: bool = Query( + default=True, description="Whether or not to include a summary *if* one is available for the session", + alias="summary", ), db: AsyncSession = db, ): @@ -389,18 +391,59 @@ async def get_session_context( token_limit = tokens or config.settings.GET_CONTEXT_MAX_TOKENS # Use the shared get_session_context function from summarizer - summary_content, messages = await summarizer.get_session_context( + summary_obj, messages = await summarizer.get_session_context( db, workspace_name=workspace_id, session_name=session_id, token_limit=token_limit, - include_summary=summary, + include_summary=include_summary, ) return schemas.SessionContext( name=session_id, messages=messages, # pyright: ignore -- db message type and schema message type are different, but excess gets removed by schema - summary=summary_content, + summary=summary_obj, + ) + + +@router.get( + "/{session_id}/summaries", + response_model=schemas.SessionSummaries, + dependencies=[ + Depends(require_auth(workspace_name="workspace_id", session_name="session_id")) + ], +) +async def get_session_summaries( + workspace_id: str = Path(..., description="ID of the workspace"), + session_id: str = Path(..., description="ID of the session"), + db: AsyncSession = db, +) -> schemas.SessionSummaries: + """ + Get available summaries for a session. + + Returns both short and long summaries if available, including metadata like + the message ID they cover up to, creation timestamp, and token count. + """ + # Reuse the logic from get_context to get both summaries + short_summary, long_summary = await summarizer.get_both_summaries( + db, + workspace_name=workspace_id, + session_name=session_id, + ) + + # Convert the internal Summary TypedDict to our Pydantic schema + short_summary_schema = None + if short_summary: + short_summary_schema = summarizer.to_schema_summary(short_summary) + + long_summary_schema = None + if long_summary: + long_summary_schema = summarizer.to_schema_summary(long_summary) + + return schemas.SessionSummaries( + name=session_id, + short_summary=short_summary_schema, + long_summary=long_summary_schema, ) diff --git a/src/schemas.py b/src/schemas.py index 60a5a5bd..b93c3552 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -117,6 +117,7 @@ class MessageCreate(MessageBase): content: Annotated[str, Field(min_length=0, max_length=50000)] peer_name: str = Field(alias="peer_id") metadata: dict[str, Any] | None = None + created_at: datetime.datetime | None = None _encoded_message: list[int] = PrivateAttr(default=[]) @@ -223,10 +224,38 @@ class Session(SessionBase): ) +class Summary(BaseModel): + content: str = Field(description="The summary text") + message_id: int = Field( + description="The ID of the message that this summary covers up to" + ) + summary_type: str = Field(description="The type of summary (short or long)") + created_at: str = Field( + description="The timestamp of when the summary was created (ISO format)" + ) + token_count: int = Field(description="The number of tokens in the summary text") + + class SessionContext(SessionBase): name: str = Field(serialization_alias="id") messages: list[Message] - summary: str + summary: Summary | None = Field( + default=None, description="The summary if available" + ) + + model_config = ConfigDict( # pyright: ignore + from_attributes=True, populate_by_name=True + ) + + +class SessionSummaries(SessionBase): + name: str = Field(serialization_alias="id") + short_summary: Summary | None = Field( + default=None, description="The short summary if available" + ) + long_summary: Summary | None = Field( + default=None, description="The long summary if available" + ) model_config = ConfigDict( # pyright: ignore from_attributes=True, populate_by_name=True diff --git a/src/security.py b/src/security.py index bc91e39b..a0e1a0bc 100644 --- a/src/security.py +++ b/src/security.py @@ -8,6 +8,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel from src.config import settings +from src.utils.formatting import utc_now_iso from .exceptions import AuthenticationException @@ -54,7 +55,7 @@ class JWTParams(BaseModel): `s`: (string) session name """ - t: str = datetime.datetime.now().isoformat() + t: str = utc_now_iso() exp: str | None = None ad: bool | None = None w: str | None = None diff --git a/src/utils/embedding_store.py b/src/utils/embedding_store.py index 84d41759..9bc303ce 100644 --- a/src/utils/embedding_store.py +++ b/src/utils/embedding_store.py @@ -12,6 +12,7 @@ from src import crud, models from src.config import settings from src.dependencies import tracked_db from src.embedding_client import embedding_client +from src.utils.formatting import format_datetime_utc from src.utils.logging import conditional_observe from src.utils.shared_models import ( Observation, @@ -115,7 +116,7 @@ class EmbeddingStore: "message_id": message_id, "session_name": session_name, "premises": obs.premises, # Store premises in metadata - "created_at": message_created_at.isoformat() + "created_at": format_datetime_utc(message_created_at) if message_created_at else None, } diff --git a/src/utils/filter.py b/src/utils/filter.py index f7e4cb84..50867da1 100644 --- a/src/utils/filter.py +++ b/src/utils/filter.py @@ -7,6 +7,7 @@ from sqlalchemy import ColumnElement, Select, and_, case, cast, literal, not_, o from sqlalchemy.types import Numeric from ..exceptions import FilterError +from .formatting import parse_datetime_iso logger = getLogger(__name__) @@ -561,9 +562,9 @@ def _validate_datetime_string(value: str) -> datetime.datetime | None: except ValueError: continue - # Try fromisoformat as a fallback (Python 3.7+) + # Try fromisoformat as a fallback (Python 3.7+) with Z format support try: - return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + return parse_datetime_iso(value) except ValueError: pass diff --git a/src/utils/formatting.py b/src/utils/formatting.py index a9a75add..6b479e3b 100644 --- a/src/utils/formatting.py +++ b/src/utils/formatting.py @@ -5,13 +5,83 @@ This module contains helper functions for processing observations, formatting co and handling temporal metadata for the reasoning system. """ -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Protocol, cast, runtime_checkable from src.utils.logging import conditional_observe from src.utils.shared_models import ReasoningResponse +def format_datetime_utc(dt: datetime) -> str: + """ + Format datetime to ISO 8601 string with Z suffix for UTC timezone. + + This ensures consistent datetime formatting across the entire backend, + using the Z format which is the ISO 8601 standard for UTC and matches + Pydantic's JSON serialization behavior. + + Args: + dt: datetime object (should be timezone-aware) + + Returns: + ISO 8601 formatted string with Z suffix for UTC + + Example: + >>> dt = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + >>> format_datetime_utc(dt) + '2023-01-01T12:00:00Z' + """ + if dt.tzinfo is None: + # If no timezone info, assume UTC + dt = dt.replace(tzinfo=timezone.utc) + + # Convert to UTC if not already + if dt.tzinfo != timezone.utc: + dt = dt.astimezone(timezone.utc) + + # Format and replace +00:00 with Z + return dt.isoformat().replace("+00:00", "Z") + + +def utc_now_iso() -> str: + """ + Get current UTC time as ISO 8601 string with Z suffix. + + Returns: + Current UTC time in ISO 8601 format with Z suffix + + Example: + >>> utc_now_iso() + '2023-01-01T12:34:56.789123Z' + """ + return format_datetime_utc(datetime.now(timezone.utc)) + + +def parse_datetime_iso(iso_string: str) -> datetime: + """ + Parse ISO 8601 datetime string, handling both Z and +00:00 UTC formats. + + This function handles the fact that Python's fromisoformat() doesn't + directly support the 'Z' suffix, which is the standard ISO 8601 way + to represent UTC timezone. + + Args: + iso_string: ISO 8601 formatted datetime string + + Returns: + datetime object with timezone information + + Example: + >>> parse_datetime_iso('2023-01-01T12:00:00Z') + datetime.datetime(2023, 1, 1, 12, 0, tzinfo=datetime.timezone.utc) + >>> parse_datetime_iso('2023-01-01T12:00:00+00:00') + datetime.datetime(2023, 1, 1, 12, 0, tzinfo=datetime.timezone.utc) + """ + # Convert Z format to +00:00 format for Python's fromisoformat + normalized_string = iso_string.replace("Z", "+00:00") + return datetime.fromisoformat(normalized_string) + + @runtime_checkable class StructuredObservation(Protocol): """Protocol for observations that have conclusion and premises attributes.""" diff --git a/src/utils/summarizer.py b/src/utils/summarizer.py index 352dec23..c3391e63 100644 --- a/src/utils/summarizer.py +++ b/src/utils/summarizer.py @@ -1,5 +1,4 @@ import asyncio -import datetime import logging import time from enum import Enum @@ -9,10 +8,12 @@ from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from typing_extensions import TypedDict +from src import schemas from src.config import settings from src.dependencies import tracked_db from src.exceptions import ResourceNotFoundException from src.utils.clients import honcho_llm_call +from src.utils.formatting import utc_now_iso from src.utils.logging import accumulate_metric from .. import crud, models @@ -40,6 +41,16 @@ class Summary(TypedDict): token_count: int +def to_schema_summary(s: Summary) -> schemas.Summary: + return schemas.Summary( + content=s["content"], + message_id=s["message_id"], + summary_type=s["summary_type"], + created_at=s["created_at"], + token_count=s["token_count"], + ) + + # Export the public functions __all__ = [ "get_summary", @@ -49,6 +60,7 @@ __all__ = [ "get_session_context_formatted", "SummaryType", "Summary", + "to_schema_summary", ] @@ -388,7 +400,7 @@ async def _create_summary( content=summary_text, message_id=messages[-1].id if messages else 0, summary_type=summary_type.value, - created_at=datetime.datetime.now(datetime.timezone.utc).isoformat(), + created_at=utc_now_iso(), token_count=summary_tokens, ) @@ -564,7 +576,7 @@ async def get_session_context( *, cutoff: int | None = None, include_summary: bool = True, -) -> tuple[str, list[models.Message]]: +) -> tuple[schemas.Summary | None, list[models.Message]]: """ Get session context similar to the API endpoint but for internal use. @@ -577,10 +589,10 @@ async def get_session_context( include_summary: Whether to include summary if available Returns: - Tuple of (summary_content, messages) where summary_content is the summary text (or empty string) + Tuple of (summary, messages) where summary is a Summary pydantic model (or None) and messages is the list of message objects """ - summary_content = "" + summary = None messages_tokens = token_limit messages_start_id = 0 @@ -601,13 +613,25 @@ async def get_session_context( and long_len <= summary_tokens_limit and long_len > short_len ): - summary_content = latest_long_summary["content"] + summary = schemas.Summary( + content=latest_long_summary["content"], + message_id=latest_long_summary["message_id"], + summary_type=latest_long_summary["summary_type"], + created_at=latest_long_summary["created_at"], + token_count=latest_long_summary["token_count"], + ) messages_tokens = token_limit - latest_long_summary["token_count"] messages_start_id = latest_long_summary["message_id"] elif ( latest_short_summary and short_len <= summary_tokens_limit and short_len > 0 ): - summary_content = latest_short_summary["content"] + summary = schemas.Summary( + content=latest_short_summary["content"], + message_id=latest_short_summary["message_id"], + summary_type=latest_short_summary["summary_type"], + created_at=latest_short_summary["created_at"], + token_count=latest_short_summary["token_count"], + ) messages_tokens = token_limit - latest_short_summary["token_count"] messages_start_id = latest_short_summary["message_id"] else: @@ -628,7 +652,7 @@ async def get_session_context( token_limit=messages_tokens, ) - return summary_content, messages + return summary, messages async def get_session_context_formatted( @@ -646,7 +670,7 @@ async def get_session_context_formatted( This is a convenience wrapper around get_session_context that formats the output as a string. """ - summary_content, messages = await get_session_context( + summary, messages = await get_session_context( db, workspace_name, session_name, @@ -657,6 +681,7 @@ async def get_session_context_formatted( # Format the messages messages_text = _format_messages(messages) + summary_content = summary.content if summary else "" if summary_content and messages_text: return f""" diff --git a/src/webhooks/webhook_delivery.py b/src/webhooks/webhook_delivery.py index c9a1261d..96f9311a 100644 --- a/src/webhooks/webhook_delivery.py +++ b/src/webhooks/webhook_delivery.py @@ -3,7 +3,6 @@ import hashlib import hmac import json import logging -from datetime import datetime, timezone import httpx from sqlalchemy.ext.asyncio import AsyncSession @@ -11,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.config import settings from src.crud.webhook import list_webhook_endpoints from src.deriver.queue_payload import WebhookPayload +from src.utils.formatting import utc_now_iso logger = logging.getLogger(__name__) @@ -31,7 +31,7 @@ async def deliver_webhook(db: AsyncSession, payload: WebhookPayload) -> None: event_payload = { "type": payload.event_type, "data": payload.data, - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": utc_now_iso(), } event_json = json.dumps( event_payload, separators=(",", ":"), sort_keys=True diff --git a/tests/bench/run_tests.py b/tests/bench/run_tests.py index c7510e61..72f7d7b8 100644 --- a/tests/bench/run_tests.py +++ b/tests/bench/run_tests.py @@ -509,8 +509,12 @@ Evaluate whether the actual response contains the core correct information from summary=summary, tokens=max_tokens ) + summary_content = "" + if session_context.summary: + summary_content = session_context.summary.content + tokenizer = tiktoken.get_encoding("cl100k_base") - summary_tokens = len(tokenizer.encode(session_context.summary)) + summary_tokens = len(tokenizer.encode(summary_content)) print(f" summary: {session_context.summary}") got_tokens = summary_tokens diff --git a/tests/routes/test_messages.py b/tests/routes/test_messages.py index fd8e840e..2f222339 100644 --- a/tests/routes/test_messages.py +++ b/tests/routes/test_messages.py @@ -1,3 +1,4 @@ +import datetime from unittest.mock import patch import pytest @@ -921,3 +922,219 @@ async def test_create_messages_with_file_too_large( # Should raise FileTooLargeError which gets converted to 413 assert response.status_code == 413 + + +@pytest.mark.asyncio +async def test_create_message_with_timestamp( + client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] +): + """Test creating a message with custom timestamp""" + test_workspace, test_peer = sample_data + + # Create a test session + test_session = models.Session( + workspace_name=test_workspace.name, name=str(generate_nanoid()) + ) + db_session.add(test_session) + await db_session.commit() + + # Use a specific timestamp for testing + custom_timestamp = datetime.datetime( + 2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc + ) + + response = client.post( + f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + json={ + "messages": [ + { + "content": "Test message with timestamp", + "peer_id": test_peer.name, + "created_at": custom_timestamp.isoformat(), + "metadata": {"test": "with_timestamp"}, + } + ] + }, + ) + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + message = data[0] + assert message["content"] == "Test message with timestamp" + assert message["peer_id"] == test_peer.name + assert message["metadata"] == {"test": "with_timestamp"} + + # Verify the created_at field matches our custom timestamp + # Pydantic serializes UTC timezone as 'Z' format (ISO 8601 standard) + expected_timestamp = "2023-01-01T12:00:00Z" + assert message["created_at"] == expected_timestamp + + +@pytest.mark.asyncio +async def test_create_message_without_timestamp_uses_default( + client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] +): + """Test creating a message without timestamp uses default timestamp""" + test_workspace, test_peer = sample_data + + # Create a test session + test_session = models.Session( + workspace_name=test_workspace.name, name=str(generate_nanoid()) + ) + db_session.add(test_session) + await db_session.commit() + + # Record time before request + before_request = datetime.datetime.now(datetime.timezone.utc) + + response = client.post( + f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + json={ + "messages": [ + { + "content": "Test message without timestamp", + "peer_id": test_peer.name, + "metadata": {"test": "no_timestamp"}, + } + ] + }, + ) + + # Record time after request + after_request = datetime.datetime.now(datetime.timezone.utc) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + message = data[0] + assert message["content"] == "Test message without timestamp" + assert message["peer_id"] == test_peer.name + assert message["metadata"] == {"test": "no_timestamp"} + + # Verify the created_at field is between our before/after times + message_created_at = datetime.datetime.fromisoformat( + message["created_at"].replace("Z", "+00:00") + ) + assert before_request <= message_created_at <= after_request + + +@pytest.mark.asyncio +async def test_create_batch_messages_with_mixed_timestamps( + client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] +): + """Test creating batch messages with some having custom timestamps and others using default""" + test_workspace, test_peer = sample_data + + # Create a test session + test_session = models.Session( + workspace_name=test_workspace.name, name=str(generate_nanoid()) + ) + db_session.add(test_session) + await db_session.commit() + + # Use specific timestamps for testing + timestamp1 = datetime.datetime(2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + timestamp2 = datetime.datetime(2023, 1, 2, 12, 0, 0, tzinfo=datetime.timezone.utc) + + # Record time before request for default timestamp + before_request = datetime.datetime.now(datetime.timezone.utc) + + response = client.post( + f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + json={ + "messages": [ + { + "content": "Message 1 with timestamp", + "peer_id": test_peer.name, + "created_at": timestamp1.isoformat(), + "metadata": {"type": "custom_timestamp"}, + }, + { + "content": "Message 2 without timestamp", + "peer_id": test_peer.name, + "metadata": {"type": "default_timestamp"}, + }, + { + "content": "Message 3 with timestamp", + "peer_id": test_peer.name, + "created_at": timestamp2.isoformat(), + "metadata": {"type": "custom_timestamp"}, + }, + ] + }, + ) + + after_request = datetime.datetime.now(datetime.timezone.utc) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 3 + + # Check first message with custom timestamp + assert data[0]["content"] == "Message 1 with timestamp" + assert ( + data[0]["created_at"] == "2023-01-01T12:00:00Z" + ) # Pydantic converts to Z format + assert data[0]["metadata"] == {"type": "custom_timestamp"} + + # Check second message with default timestamp + assert data[1]["content"] == "Message 2 without timestamp" + message2_created_at = datetime.datetime.fromisoformat( + data[1]["created_at"].replace("Z", "+00:00") + ) + assert before_request <= message2_created_at <= after_request + assert data[1]["metadata"] == {"type": "default_timestamp"} + + # Check third message with custom timestamp + assert data[2]["content"] == "Message 3 with timestamp" + assert ( + data[2]["created_at"] == "2023-01-02T12:00:00Z" + ) # Pydantic converts to Z format + assert data[2]["metadata"] == {"type": "custom_timestamp"} + + +@pytest.mark.asyncio +async def test_create_message_with_null_timestamp( + client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer] +): + """Test creating a message with null timestamp uses default""" + test_workspace, test_peer = sample_data + + # Create a test session + test_session = models.Session( + workspace_name=test_workspace.name, name=str(generate_nanoid()) + ) + db_session.add(test_session) + await db_session.commit() + + # Record time before request + before_request = datetime.datetime.now(datetime.timezone.utc) + + response = client.post( + f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages", + json={ + "messages": [ + { + "content": "Test message with null timestamp", + "peer_id": test_peer.name, + "created_at": None, + "metadata": {"test": "null_timestamp"}, + } + ] + }, + ) + + after_request = datetime.datetime.now(datetime.timezone.utc) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + message = data[0] + assert message["content"] == "Test message with null timestamp" + assert message["metadata"] == {"test": "null_timestamp"} + + # Verify the created_at field uses default (current time) + message_created_at = datetime.datetime.fromisoformat( + message["created_at"].replace("Z", "+00:00") + ) + assert before_request <= message_created_at <= after_request diff --git a/tests/routes/test_sessions.py b/tests/routes/test_sessions.py index 5c12c10a..e2017f40 100644 --- a/tests/routes/test_sessions.py +++ b/tests/routes/test_sessions.py @@ -784,7 +784,7 @@ def test_get_session_context(client: TestClient, sample_data: tuple[Workspace, P assert "summary" in data assert data["id"] == session_id assert isinstance(data["messages"], list) - assert data["summary"] == "" # Default is empty when summary=False + assert data["summary"] is None # No summary available def test_get_session_context_with_summary( @@ -855,6 +855,55 @@ def test_get_session_context_with_all_params( assert "summary" in data +def test_get_session_summaries( + client: TestClient, sample_data: tuple[Workspace, Peer] +) -> None: + """Test getting summaries for a valid session""" + test_workspace, test_peer = sample_data + session_id = str(generate_nanoid()) + + # Create session + client.post( + f"/v2/workspaces/{test_workspace.name}/sessions", + json={"id": session_id, "peers": {test_peer.name: {}}}, + ) + + # Get summaries + response = client.get( + f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/summaries", + ) + assert response.status_code == 200 + data = response.json() + + # Validate response structure + assert "id" in data + assert data["id"] == session_id + assert "short_summary" in data + assert "long_summary" in data + # Summaries will be None since they're created asynchronously + assert data["short_summary"] is None + assert data["long_summary"] is None + + +def test_get_session_summaries_nonexistent_session( + client: TestClient, sample_data: tuple[Workspace, Peer] +) -> None: + """Test getting summaries for a non-existent session""" + test_workspace, _ = sample_data + nonexistent_session_id = str(generate_nanoid()) + + # Try to get summaries for non-existent session + # Should still return 200 with null summaries + response = client.get( + f"/v2/workspaces/{test_workspace.name}/sessions/{nonexistent_session_id}/summaries", + ) + assert response.status_code == 200 + data = response.json() + assert data["id"] == nonexistent_session_id + assert data["short_summary"] is None + assert data["long_summary"] is None + + def test_search_session(client: TestClient, sample_data: tuple[Workspace, Peer]): """Test the session search functionality""" test_workspace, test_peer = sample_data diff --git a/uv.lock b/uv.lock index fb42a7a8..635733ac 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13'", @@ -7,6 +7,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[manifest] +members = [ + "honcho", + "honcho-ai", +] + [[package]] name = "alembic" version = "1.16.4" @@ -822,7 +828,7 @@ wheels = [ [[package]] name = "honcho" -version = "2.2.0" +version = "2.3.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, @@ -853,7 +859,6 @@ dev = [ { name = "basedpyright" }, { name = "coverage" }, { name = "honcho-ai" }, - { name = "honcho-core" }, { name = "interrogate" }, { name = "pre-commit" }, { name = "py-spy" }, @@ -893,8 +898,7 @@ requires-dist = [ dev = [ { name = "basedpyright", specifier = ">=1.29.4" }, { name = "coverage", specifier = ">=7.6.0" }, - { name = "honcho-ai", specifier = "==1.3.0" }, - { name = "honcho-core", specifier = "==1.3.0" }, + { name = "honcho-ai", editable = "sdks/python" }, { name = "interrogate", specifier = ">=1.7.0" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "py-spy", specifier = ">=0.3.14" }, @@ -907,21 +911,32 @@ dev = [ [[package]] name = "honcho-ai" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } +version = "1.4.0" +source = { editable = "sdks/python" } dependencies = [ { name = "honcho-core" }, { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/d3/c882f95637e30124c57a3496ac8221865c2449323f5bd6823009658ce8f7/honcho_ai-1.3.0.tar.gz", hash = "sha256:674440071ae66f3382c7a2355e1c9e8a2c9f0f9482ca0633151d30d42b20ea2c", size = 25042, upload-time = "2025-08-06T21:49:15.655Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/f9/0d3c71f4a400da433119eabfb03837a00f1badd31994d578e4fdc9f95c1f/honcho_ai-1.3.0-py3-none-any.whl", hash = "sha256:cb610384956752a8a33f7dee1a01f10b7732c76417b72c87f0d91dea2acd1174", size = 32599, upload-time = "2025-08-06T21:49:14.418Z" }, + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, ] +[package.metadata] +requires-dist = [ + { name = "honcho-core", specifier = ">=1.4.0" }, + { name = "httpx", specifier = ">=0.28.0,<1" }, + { name = "pydantic", specifier = ">=2.0.0,<3" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.11.13" }] + [[package]] name = "honcho-core" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -931,9 +946,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/49/1810de2351be1fdff330ffadcc5eef709889b2f698b0340966cae2c1eef9/honcho_core-1.3.0.tar.gz", hash = "sha256:276a73e8d523f7d22f06746922fda97e4a11fbbef4a51a0b43573bc817089cec", size = 123333, upload-time = "2025-08-06T16:42:10.941Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/c2/4d3737d5c0a75e2f59f324bed7428a9593c8edfcb7500cf402c46ad7378a/honcho_core-1.4.0.tar.gz", hash = "sha256:b30de9247763c01c3c37ed22b5d04d3c611440dd1983168f3d7d93f2cc0ecf7d", size = 126164, upload-time = "2025-08-12T19:06:54.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/3f/3beb750ed65bb1b604c585ae1c51a54f8b34ea3002f4525f93fe86549531/honcho_core-1.3.0-py3-none-any.whl", hash = "sha256:c7c0bf77b61162e6c65a580327fc054785aef794923abc35acd3dbd8287245db", size = 112895, upload-time = "2025-08-06T16:42:08.967Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ac/6eb2e38376736bb1dfd26bbdfd96490bfbb6f0ddcb00b39913e93799e7d8/honcho_core-1.4.0-py3-none-any.whl", hash = "sha256:a84397fd9daf546a04f5458d7233ebd5eeafcb97af75ec60a9327db89abdaf03", size = 117602, upload-time = "2025-08-12T19:06:53.667Z" }, ] [[package]]