From 05bbc072da7dfa802eb48ea658e0fff9ddf1944b Mon Sep 17 00:00:00 2001 From: James Pine Date: Wed, 25 Mar 2026 01:57:17 -0700 Subject: [PATCH 01/20] feat: persist webchat conversations and add API client package --- .../api-client-package-followup.md | 30 ++ interface/src/api/schema.d.ts | 398 ++++++++++++++---- interface/src/api/types.ts | 211 +++++++--- justfile | 4 + .../20260324000001_webchat_conversations.sql | 12 + packages/api-client/package.json | 13 + packages/api-client/src/client.ts | 235 +++++++++++ packages/api-client/src/events.ts | 155 +++++++ packages/api-client/src/index.ts | 3 + packages/api-client/src/schema.d.ts | 1 + packages/api-client/src/types.ts | 1 + src/api/server.rs | 4 + src/api/webchat.rs | 250 +++++++++-- src/conversation.rs | 2 + src/conversation/webchat.rs | 347 +++++++++++++++ 15 files changed, 1503 insertions(+), 163 deletions(-) create mode 100644 docs/design-docs/api-client-package-followup.md create mode 100644 migrations/20260324000001_webchat_conversations.sql create mode 100644 packages/api-client/package.json create mode 100644 packages/api-client/src/client.ts create mode 100644 packages/api-client/src/events.ts create mode 100644 packages/api-client/src/index.ts create mode 100644 packages/api-client/src/schema.d.ts create mode 100644 packages/api-client/src/types.ts create mode 100644 src/conversation/webchat.rs diff --git a/docs/design-docs/api-client-package-followup.md b/docs/design-docs/api-client-package-followup.md new file mode 100644 index 000000000..7cc141be8 --- /dev/null +++ b/docs/design-docs/api-client-package-followup.md @@ -0,0 +1,30 @@ +# API Client Package Follow-up + +The first extraction of `@spacebot/api-client` is intentionally light. + +It creates a reusable package boundary for Spacedrive and other consumers, but it still re-exports generated types from `interface/src/api/`. + +## Follow-up Work + +### 1. Make `spacebot/interface` consume `@spacebot/api-client` + +The Spacebot interface should stop importing from its local `src/api/*` modules directly and instead consume the shared package. + +That will make the package the single source of truth for: + +- OpenAPI client setup +- generated schema types +- friendly exported types +- manual SSE event types + +### 2. Move OpenAPI generation output into the package + +The long-term target is for `schema.d.ts` to be generated directly into `packages/api-client/` instead of `interface/src/api/`. + +That will let the shared package fully own the generated contract and avoid the current re-export bridge. + +## Why Deferred + +These steps are worth doing, but they are not required for the first Spacedrive integration slice. + +For now, the package boundary exists, and Spacedrive can start consuming it immediately. diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index e5f92ef66..f738c27ff 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -591,6 +591,40 @@ export interface paths { patch?: never; trace?: never; }; + "/agents/workers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List worker runs for an agent, with live status merged from StatusBlocks. */ + get: operations["list_workers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agents/workers/detail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get full detail for a single worker run, including decompressed transcript. */ + get: operations["worker_detail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/bindings": { parameters: { query?: never; @@ -790,7 +824,7 @@ export interface paths { patch?: never; trace?: never; }; - "/cortex/chat/messages": { + "/cortex-chat/messages": { parameters: { query?: never; header?: never; @@ -811,7 +845,7 @@ export interface paths { patch?: never; trace?: never; }; - "/cortex/chat/send": { + "/cortex-chat/send": { parameters: { query?: never; header?: never; @@ -836,15 +870,14 @@ export interface paths { patch?: never; trace?: never; }; - "/cortex/chat/threads": { + "/cortex-chat/thread": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List all cortex chat threads for an agent, newest first. */ - get: operations["cortex_chat_threads"]; + get?: never; put?: never; post?: never; /** Delete a cortex chat thread and all its messages. */ @@ -854,6 +887,23 @@ export interface paths { patch?: never; trace?: never; }; + "/cortex-chat/threads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List all cortex chat threads for an agent, newest first. */ + get: operations["cortex_chat_threads"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/cortex/events": { parameters: { query?: never; @@ -1887,23 +1937,23 @@ export interface paths { patch?: never; trace?: never; }; - "/webchat/history": { + "/webchat/conversations": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["webchat_history"]; + get: operations["list_webchat_conversations"]; put?: never; - post?: never; + post: operations["create_webchat_conversation"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/webchat/send": { + "/webchat/conversations/{session_id}": { parameters: { query?: never; header?: never; @@ -1911,27 +1961,22 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; - /** - * Fire-and-forget message injection. The response arrives via the global SSE - * event bus (`/api/events`), same as every other channel. - */ - post: operations["webchat_send"]; - delete?: never; + put: operations["update_webchat_conversation"]; + post?: never; + delete: operations["delete_webchat_conversation"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/workers": { + "/webchat/history": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List worker runs for an agent, with live status merged from StatusBlocks. */ - get: operations["list_workers"]; + get: operations["webchat_history"]; put?: never; post?: never; delete?: never; @@ -1940,17 +1985,20 @@ export interface paths { patch?: never; trace?: never; }; - "/workers/detail": { + "/webchat/send": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get full detail for a single worker run, including decompressed transcript. */ - get: operations["worker_detail"]; + get?: never; put?: never; - post?: never; + /** + * Fire-and-forget message injection. The response arrives via the global SSE + * event bus (`/api/events`), same as every other channel. + */ + post: operations["webchat_send"]; delete?: never; options?: never; head?: never; @@ -2433,6 +2481,10 @@ export interface components { subtasks?: components["schemas"]["TaskSubtask"][]; title: string; }; + CreateWebChatConversationRequest: { + agent_id: string; + title?: string | null; + }; CreateWorktreeRequest: { agent_id: string; branch: string; @@ -3481,6 +3533,11 @@ export interface components { title?: string | null; worker_id?: string | null; }; + UpdateWebChatConversationRequest: { + agent_id: string; + archived?: boolean | null; + title?: string | null; + }; UploadSkillResponse: { installed: string[]; }; @@ -3531,6 +3588,40 @@ export interface components { /** Format: int64 */ startup_delay_secs?: number | null; }; + WebChatConversation: { + agent_id: string; + archived: boolean; + /** Format: date-time */ + created_at: string; + id: string; + title: string; + title_source: string; + /** Format: date-time */ + updated_at: string; + }; + WebChatConversationResponse: { + conversation: components["schemas"]["WebChatConversation"]; + }; + WebChatConversationSummary: { + agent_id: string; + archived: boolean; + /** Format: date-time */ + created_at: string; + id: string; + /** Format: date-time */ + last_message_at?: string | null; + last_message_preview?: string | null; + last_message_role?: string | null; + /** Format: int64 */ + message_count: number; + title: string; + title_source: string; + /** Format: date-time */ + updated_at: string; + }; + WebChatConversationsResponse: { + conversations: components["schemas"]["WebChatConversationSummary"][]; + }; WebChatHistoryMessage: { content: string; id: string; @@ -5272,6 +5363,86 @@ export interface operations { }; }; }; + list_workers: { + parameters: { + query: { + /** @description Agent ID */ + agent_id: string; + /** @description Maximum number of results to return */ + limit: number; + /** @description Number of results to skip */ + offset: number; + /** @description Filter by worker status */ + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkerListResponse"]; + }; + }; + /** @description Agent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + worker_detail: { + parameters: { + query: { + /** @description Agent ID */ + agent_id: string; + /** @description Worker ID */ + worker_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkerDetailResponse"]; + }; + }; + /** @description Agent or worker not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_bindings: { parameters: { query?: { @@ -5867,27 +6038,27 @@ export interface operations { }; }; }; - cortex_chat_threads: { + cortex_chat_delete_thread: { parameters: { - query: { - /** @description Agent ID */ - agent_id: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CortexChatDeleteThreadRequest"]; + }; + }; responses: { - 200: { + /** @description Thread deleted successfully */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CortexChatThreadsResponse"]; - }; + content?: never; }; - /** @description Agent not found */ + /** @description Agent or thread not found */ 404: { headers: { [name: string]: unknown; @@ -5903,27 +6074,27 @@ export interface operations { }; }; }; - cortex_chat_delete_thread: { + cortex_chat_threads: { parameters: { - query?: never; + query: { + /** @description Agent ID */ + agent_id: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CortexChatDeleteThreadRequest"]; - }; - }; + requestBody?: never; responses: { - /** @description Thread deleted successfully */ - 204: { + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CortexChatThreadsResponse"]; + }; }; - /** @description Agent or thread not found */ + /** @description Agent not found */ 404: { headers: { [name: string]: unknown; @@ -8272,14 +8443,14 @@ export interface operations { }; }; }; - webchat_history: { + list_webchat_conversations: { parameters: { query: { /** @description Agent ID */ agent_id: string; - /** @description Session ID */ - session_id: string; - /** @description Maximum number of messages to return (default: 100, max: 200) */ + /** @description Include archived conversations */ + include_archived: boolean; + /** @description Maximum number of conversations to return (default: 100, max: 500) */ limit: number; }; header?: never; @@ -8293,7 +8464,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebChatHistoryMessage"][]; + "application/json": components["schemas"]["WebChatConversationsResponse"]; }; }; /** @description Agent not found */ @@ -8312,7 +8483,7 @@ export interface operations { }; }; }; - webchat_send: { + create_webchat_conversation: { parameters: { query?: never; header?: never; @@ -8321,7 +8492,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["WebChatSendRequest"]; + "application/json": components["schemas"]["CreateWebChatConversationRequest"]; }; }; responses: { @@ -8330,18 +8501,18 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WebChatSendResponse"]; + "application/json": components["schemas"]["WebChatConversationResponse"]; }; }; - /** @description Invalid request */ - 400: { + /** @description Agent not found */ + 404: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Messaging manager not available */ - 503: { + /** @description Internal server error */ + 500: { headers: { [name: string]: unknown; }; @@ -8349,20 +8520,57 @@ export interface operations { }; }; }; - list_workers: { + update_webchat_conversation: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Conversation session ID */ + session_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWebChatConversationRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebChatConversationResponse"]; + }; + }; + /** @description Conversation not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete_webchat_conversation: { parameters: { query: { /** @description Agent ID */ agent_id: string; - /** @description Maximum number of results to return */ - limit: number; - /** @description Number of results to skip */ - offset: number; - /** @description Filter by worker status */ - status?: string; }; header?: never; - path?: never; + path: { + /** @description Conversation session ID */ + session_id: string; + }; cookie?: never; }; requestBody?: never; @@ -8372,10 +8580,10 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkerListResponse"]; + "application/json": components["schemas"]["WebChatSendResponse"]; }; }; - /** @description Agent not found */ + /** @description Conversation not found */ 404: { headers: { [name: string]: unknown; @@ -8391,13 +8599,15 @@ export interface operations { }; }; }; - worker_detail: { + webchat_history: { parameters: { query: { /** @description Agent ID */ agent_id: string; - /** @description Worker ID */ - worker_id: string; + /** @description Session ID */ + session_id: string; + /** @description Maximum number of messages to return (default: 100, max: 200) */ + limit: number; }; header?: never; path?: never; @@ -8410,10 +8620,10 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkerDetailResponse"]; + "application/json": components["schemas"]["WebChatHistoryMessage"][]; }; }; - /** @description Agent or worker not found */ + /** @description Agent not found */ 404: { headers: { [name: string]: unknown; @@ -8429,4 +8639,48 @@ export interface operations { }; }; }; + webchat_send: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebChatSendRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebChatSendResponse"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Agent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Messaging manager not available */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index 116e8cc05..b0f079cf6 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -8,7 +8,8 @@ import type { components } from "./schema"; export type StatusResponse = components["schemas"]["StatusResponse"]; export type IdleResponse = components["schemas"]["IdleResponse"]; export type HealthResponse = components["schemas"]["HealthResponse"]; -export type InstanceOverviewResponse = components["schemas"]["InstanceOverviewResponse"]; +export type InstanceOverviewResponse = + components["schemas"]["InstanceOverviewResponse"]; // ============================================================================= // Event/SSE Types @@ -47,14 +48,17 @@ export type MessagesResponse = components["schemas"]["MessagesResponse"]; export type TimelineItem = components["schemas"]["TimelineItem"]; // Status-related -export type CancelProcessRequest = components["schemas"]["CancelProcessRequest"]; -export type CancelProcessResponse = components["schemas"]["CancelProcessResponse"]; +export type CancelProcessRequest = + components["schemas"]["CancelProcessRequest"]; +export type CancelProcessResponse = + components["schemas"]["CancelProcessResponse"]; // Prompt inspection export type PromptCaptureBody = components["schemas"]["PromptCaptureBody"]; // Archive -export type SetChannelArchiveRequest = components["schemas"]["SetChannelArchiveRequest"]; +export type SetChannelArchiveRequest = + components["schemas"]["SetChannelArchiveRequest"]; // ============================================================================= // Worker Types @@ -62,7 +66,8 @@ export type SetChannelArchiveRequest = components["schemas"]["SetChannelArchiveR export type WorkerListItem = components["schemas"]["WorkerListItem"]; export type WorkerListResponse = components["schemas"]["WorkerListResponse"]; -export type WorkerDetailResponse = components["schemas"]["WorkerDetailResponse"]; +export type WorkerDetailResponse = + components["schemas"]["WorkerDetailResponse"]; // Transcript export type ActionContent = components["schemas"]["ActionContent"]; @@ -75,9 +80,11 @@ export type TranscriptStep = components["schemas"]["TranscriptStep"]; export type AgentInfo = components["schemas"]["AgentInfo"]; export type AgentsResponse = components["schemas"]["AgentsResponse"]; export type AgentSummary = components["schemas"]["AgentSummary"]; -export type AgentOverviewResponse = components["schemas"]["AgentOverviewResponse"]; +export type AgentOverviewResponse = + components["schemas"]["AgentOverviewResponse"]; export type AgentProfile = components["schemas"]["AgentProfile"]; -export type AgentProfileResponse = components["schemas"]["AgentProfileResponse"]; +export type AgentProfileResponse = + components["schemas"]["AgentProfileResponse"]; // CRUD export type CreateAgentRequest = components["schemas"]["CreateAgentRequest"]; @@ -88,10 +95,28 @@ export type WarmupSection = components["schemas"]["WarmupSection"]; export type WarmupUpdate = components["schemas"]["WarmupUpdate"]; export type WarmupStatus = components["schemas"]["WarmupStatus"]; export type WarmupStatusEntry = components["schemas"]["WarmupStatusEntry"]; -export type WarmupStatusResponse = components["schemas"]["WarmupStatusResponse"]; +export type WarmupStatusResponse = + components["schemas"]["WarmupStatusResponse"]; export type WarmupState = components["schemas"]["WarmupState"]; -export type WarmupTriggerRequest = components["schemas"]["WarmupTriggerRequest"]; -export type WarmupTriggerResponse = components["schemas"]["WarmupTriggerResponse"]; +export type WarmupTriggerRequest = + components["schemas"]["WarmupTriggerRequest"]; +export type WarmupTriggerResponse = + components["schemas"]["WarmupTriggerResponse"]; + +// Webchat conversations +export type WebChatConversation = components["schemas"]["WebChatConversation"]; +export type WebChatConversationSummary = + components["schemas"]["WebChatConversationSummary"]; +export type WebChatConversationsResponse = + components["schemas"]["WebChatConversationsResponse"]; +export type WebChatConversationResponse = + components["schemas"]["WebChatConversationResponse"]; +export type CreateWebChatConversationRequest = + components["schemas"]["CreateWebChatConversationRequest"]; +export type UpdateWebChatConversationRequest = + components["schemas"]["UpdateWebChatConversationRequest"]; +export type WebChatHistoryMessage = + components["schemas"]["WebChatHistoryMessage"]; // Activity export type ActivityDayCount = components["schemas"]["ActivityDayCount"]; @@ -104,40 +129,49 @@ export type HeatmapCell = components["schemas"]["HeatmapCell"]; export type Memory = components["schemas"]["Memory"]; export type MemoryType = components["schemas"]["MemoryType"]; -export type MemoriesListResponse = components["schemas"]["MemoriesListResponse"]; +export type MemoriesListResponse = + components["schemas"]["MemoriesListResponse"]; // Search export type MemorySearchResult = components["schemas"]["MemorySearchResult"]; -export type MemoriesSearchResponse = components["schemas"]["MemoriesSearchResponse"]; +export type MemoriesSearchResponse = + components["schemas"]["MemoriesSearchResponse"]; // Graph export type Association = components["schemas"]["Association"]; export type RelationType = components["schemas"]["RelationType"]; export type MemoryGraphResponse = components["schemas"]["MemoryGraphResponse"]; -export type MemoryGraphNeighborsResponse = components["schemas"]["MemoryGraphNeighborsResponse"]; +export type MemoryGraphNeighborsResponse = + components["schemas"]["MemoryGraphNeighborsResponse"]; // ============================================================================= // Cortex Types // ============================================================================= export type CortexEvent = components["schemas"]["CortexEvent"]; -export type CortexEventsResponse = components["schemas"]["CortexEventsResponse"]; +export type CortexEventsResponse = + components["schemas"]["CortexEventsResponse"]; // Chat export type CortexChatMessage = components["schemas"]["CortexChatMessage"]; -export type CortexChatMessagesResponse = components["schemas"]["CortexChatMessagesResponse"]; +export type CortexChatMessagesResponse = + components["schemas"]["CortexChatMessagesResponse"]; export type CortexChatThread = components["schemas"]["CortexChatThread"]; -export type CortexChatThreadsResponse = components["schemas"]["CortexChatThreadsResponse"]; +export type CortexChatThreadsResponse = + components["schemas"]["CortexChatThreadsResponse"]; export type CortexChatToolCall = components["schemas"]["CortexChatToolCall"]; -export type CortexChatSendRequest = components["schemas"]["CortexChatSendRequest"]; -export type CortexChatDeleteThreadRequest = components["schemas"]["CortexChatDeleteThreadRequest"]; +export type CortexChatSendRequest = + components["schemas"]["CortexChatSendRequest"]; +export type CortexChatDeleteThreadRequest = + components["schemas"]["CortexChatDeleteThreadRequest"]; // ============================================================================= // Config Types // ============================================================================= export type AgentConfigResponse = components["schemas"]["AgentConfigResponse"]; -export type AgentConfigUpdateRequest = components["schemas"]["AgentConfigUpdateRequest"]; +export type AgentConfigUpdateRequest = + components["schemas"]["AgentConfigUpdateRequest"]; // Sections export type RoutingSection = components["schemas"]["RoutingSection"]; @@ -145,7 +179,8 @@ export type TuningSection = components["schemas"]["TuningSection"]; export type CompactionSection = components["schemas"]["CompactionSection"]; export type CortexSection = components["schemas"]["CortexSection"]; export type CoalesceSection = components["schemas"]["CoalesceSection"]; -export type MemoryPersistenceSection = components["schemas"]["MemoryPersistenceSection"]; +export type MemoryPersistenceSection = + components["schemas"]["MemoryPersistenceSection"]; export type BrowserSection = components["schemas"]["BrowserSection"]; export type ChannelSection = components["schemas"]["ChannelSection"]; export type SandboxSection = components["schemas"]["SandboxSection"]; @@ -158,7 +193,8 @@ export type TuningUpdate = components["schemas"]["TuningUpdate"]; export type CompactionUpdate = components["schemas"]["CompactionUpdate"]; export type CortexUpdate = components["schemas"]["CortexUpdate"]; export type CoalesceUpdate = components["schemas"]["CoalesceUpdate"]; -export type MemoryPersistenceUpdate = components["schemas"]["MemoryPersistenceUpdate"]; +export type MemoryPersistenceUpdate = + components["schemas"]["MemoryPersistenceUpdate"]; export type BrowserUpdate = components["schemas"]["BrowserUpdate"]; export type ChannelUpdate = components["schemas"]["ChannelUpdate"]; export type SandboxUpdate = components["schemas"]["SandboxUpdate"]; @@ -169,20 +205,29 @@ export type DiscordUpdate = components["schemas"]["DiscordUpdate"]; export type ClosePolicy = components["schemas"]["ClosePolicy"]; // Global settings -export type GlobalSettingsResponse = components["schemas"]["GlobalSettingsResponse"]; -export type GlobalSettingsUpdate = components["schemas"]["GlobalSettingsUpdate"]; -export type GlobalSettingsUpdateResponse = components["schemas"]["GlobalSettingsUpdateResponse"]; +export type GlobalSettingsResponse = + components["schemas"]["GlobalSettingsResponse"]; +export type GlobalSettingsUpdate = + components["schemas"]["GlobalSettingsUpdate"]; +export type GlobalSettingsUpdateResponse = + components["schemas"]["GlobalSettingsUpdateResponse"]; // OpenCode (within global settings) -export type OpenCodeSettingsResponse = components["schemas"]["OpenCodeSettingsResponse"]; -export type OpenCodeSettingsUpdate = components["schemas"]["OpenCodeSettingsUpdate"]; -export type OpenCodePermissionsResponse = components["schemas"]["OpenCodePermissionsResponse"]; -export type OpenCodePermissionsUpdate = components["schemas"]["OpenCodePermissionsUpdate"]; +export type OpenCodeSettingsResponse = + components["schemas"]["OpenCodeSettingsResponse"]; +export type OpenCodeSettingsUpdate = + components["schemas"]["OpenCodeSettingsUpdate"]; +export type OpenCodePermissionsResponse = + components["schemas"]["OpenCodePermissionsResponse"]; +export type OpenCodePermissionsUpdate = + components["schemas"]["OpenCodePermissionsUpdate"]; // Raw config export type RawConfigResponse = components["schemas"]["RawConfigResponse"]; -export type RawConfigUpdateRequest = components["schemas"]["RawConfigUpdateRequest"]; -export type RawConfigUpdateResponse = components["schemas"]["RawConfigUpdateResponse"]; +export type RawConfigUpdateRequest = + components["schemas"]["RawConfigUpdateRequest"]; +export type RawConfigUpdateResponse = + components["schemas"]["RawConfigUpdateResponse"]; // ============================================================================= // Cron Types @@ -192,7 +237,8 @@ export type CronJobInfo = components["schemas"]["CronJobInfo"]; export type CronJobWithStats = components["schemas"]["CronJobWithStats"]; export type CronListResponse = components["schemas"]["CronListResponse"]; export type CronExecutionEntry = components["schemas"]["CronExecutionEntry"]; -export type CronExecutionsResponse = components["schemas"]["CronExecutionsResponse"]; +export type CronExecutionsResponse = + components["schemas"]["CronExecutionsResponse"]; export type CronActionResponse = components["schemas"]["CronActionResponse"]; // Requests @@ -206,17 +252,24 @@ export type TriggerCronRequest = components["schemas"]["TriggerCronRequest"]; export type ProviderStatus = components["schemas"]["ProviderStatus"]; export type ProvidersResponse = components["schemas"]["ProvidersResponse"]; -export type ProviderUpdateRequest = components["schemas"]["ProviderUpdateRequest"]; -export type ProviderUpdateResponse = components["schemas"]["ProviderUpdateResponse"]; +export type ProviderUpdateRequest = + components["schemas"]["ProviderUpdateRequest"]; +export type ProviderUpdateResponse = + components["schemas"]["ProviderUpdateResponse"]; // Model testing -export type ProviderModelTestRequest = components["schemas"]["ProviderModelTestRequest"]; -export type ProviderModelTestResponse = components["schemas"]["ProviderModelTestResponse"]; +export type ProviderModelTestRequest = + components["schemas"]["ProviderModelTestRequest"]; +export type ProviderModelTestResponse = + components["schemas"]["ProviderModelTestResponse"]; // OAuth -export type OpenAiOAuthBrowserStartRequest = components["schemas"]["OpenAiOAuthBrowserStartRequest"]; -export type OpenAiOAuthBrowserStartResponse = components["schemas"]["OpenAiOAuthBrowserStartResponse"]; -export type OpenAiOAuthBrowserStatusResponse = components["schemas"]["OpenAiOAuthBrowserStatusResponse"]; +export type OpenAiOAuthBrowserStartRequest = + components["schemas"]["OpenAiOAuthBrowserStartRequest"]; +export type OpenAiOAuthBrowserStartResponse = + components["schemas"]["OpenAiOAuthBrowserStartResponse"]; +export type OpenAiOAuthBrowserStatusResponse = + components["schemas"]["OpenAiOAuthBrowserStatusResponse"]; // Models export type ModelInfo = components["schemas"]["ModelInfo"]; @@ -228,8 +281,10 @@ export type ModelsResponse = components["schemas"]["ModelsResponse"]; export type IngestFileInfo = components["schemas"]["IngestFileInfo"]; export type IngestFilesResponse = components["schemas"]["IngestFilesResponse"]; -export type IngestUploadResponse = components["schemas"]["IngestUploadResponse"]; -export type IngestDeleteResponse = components["schemas"]["IngestDeleteResponse"]; +export type IngestUploadResponse = + components["schemas"]["IngestUploadResponse"]; +export type IngestDeleteResponse = + components["schemas"]["IngestDeleteResponse"]; // ============================================================================= // Skills Types @@ -237,20 +292,25 @@ export type IngestDeleteResponse = components["schemas"]["IngestDeleteResponse"] export type SkillInfo = components["schemas"]["SkillInfo"]; export type SkillsListResponse = components["schemas"]["SkillsListResponse"]; -export type SkillContentResponse = components["schemas"]["SkillContentResponse"]; +export type SkillContentResponse = + components["schemas"]["SkillContentResponse"]; // Install/Remove export type InstallSkillRequest = components["schemas"]["InstallSkillRequest"]; -export type InstallSkillResponse = components["schemas"]["InstallSkillResponse"]; +export type InstallSkillResponse = + components["schemas"]["InstallSkillResponse"]; export type RemoveSkillRequest = components["schemas"]["RemoveSkillRequest"]; export type RemoveSkillResponse = components["schemas"]["RemoveSkillResponse"]; export type UploadSkillResponse = components["schemas"]["UploadSkillResponse"]; // Registry export type RegistrySkill = components["schemas"]["RegistrySkill"]; -export type RegistryBrowseResponse = components["schemas"]["RegistryBrowseResponse"]; -export type RegistrySearchResponse = components["schemas"]["RegistrySearchResponse"]; -export type RegistrySkillContentResponse = components["schemas"]["RegistrySkillContentResponse"]; +export type RegistryBrowseResponse = + components["schemas"]["RegistryBrowseResponse"]; +export type RegistrySearchResponse = + components["schemas"]["RegistrySearchResponse"]; +export type RegistrySkillContentResponse = + components["schemas"]["RegistrySkillContentResponse"]; // ============================================================================= // Tasks Types @@ -275,34 +335,47 @@ export type AssignRequest = components["schemas"]["AssignRequest"]; // ============================================================================= export type PlatformStatus = components["schemas"]["PlatformStatus"]; -export type AdapterInstanceStatus = components["schemas"]["AdapterInstanceStatus"]; -export type MessagingStatusResponse = components["schemas"]["MessagingStatusResponse"]; -export type MessagingInstanceActionResponse = components["schemas"]["MessagingInstanceActionResponse"]; +export type AdapterInstanceStatus = + components["schemas"]["AdapterInstanceStatus"]; +export type MessagingStatusResponse = + components["schemas"]["MessagingStatusResponse"]; +export type MessagingInstanceActionResponse = + components["schemas"]["MessagingInstanceActionResponse"]; // Instances -export type CreateMessagingInstanceRequest = components["schemas"]["CreateMessagingInstanceRequest"]; -export type DeleteMessagingInstanceRequest = components["schemas"]["DeleteMessagingInstanceRequest"]; +export type CreateMessagingInstanceRequest = + components["schemas"]["CreateMessagingInstanceRequest"]; +export type DeleteMessagingInstanceRequest = + components["schemas"]["DeleteMessagingInstanceRequest"]; export type InstanceCredentials = components["schemas"]["InstanceCredentials"]; // Bindings export type BindingResponse = components["schemas"]["BindingResponse"]; -export type BindingsListResponse = components["schemas"]["BindingsListResponse"]; -export type CreateBindingRequest = components["schemas"]["CreateBindingRequest"]; -export type CreateBindingResponse = components["schemas"]["CreateBindingResponse"]; -export type UpdateBindingRequest = components["schemas"]["UpdateBindingRequest"]; -export type UpdateBindingResponse = components["schemas"]["UpdateBindingResponse"]; -export type DeleteBindingRequest = components["schemas"]["DeleteBindingRequest"]; -export type DeleteBindingResponse = components["schemas"]["DeleteBindingResponse"]; +export type BindingsListResponse = + components["schemas"]["BindingsListResponse"]; +export type CreateBindingRequest = + components["schemas"]["CreateBindingRequest"]; +export type CreateBindingResponse = + components["schemas"]["CreateBindingResponse"]; +export type UpdateBindingRequest = + components["schemas"]["UpdateBindingRequest"]; +export type UpdateBindingResponse = + components["schemas"]["UpdateBindingResponse"]; +export type DeleteBindingRequest = + components["schemas"]["DeleteBindingRequest"]; +export type DeleteBindingResponse = + components["schemas"]["DeleteBindingResponse"]; export type PlatformCredentials = components["schemas"]["PlatformCredentials"]; // Toggles -export type TogglePlatformRequest = components["schemas"]["TogglePlatformRequest"]; -export type DisconnectPlatformRequest = components["schemas"]["DisconnectPlatformRequest"]; +export type TogglePlatformRequest = + components["schemas"]["TogglePlatformRequest"]; +export type DisconnectPlatformRequest = + components["schemas"]["DisconnectPlatformRequest"]; // Web chat export type WebChatSendRequest = components["schemas"]["WebChatSendRequest"]; export type WebChatSendResponse = components["schemas"]["WebChatSendResponse"]; -export type WebChatHistoryMessage = components["schemas"]["WebChatHistoryMessage"]; // ============================================================================= // Links Types @@ -334,8 +407,10 @@ export type Project = components["schemas"]["Project"]; export type ProjectStatus = components["schemas"]["ProjectStatus"]; export type ProjectRepo = components["schemas"]["ProjectRepo"]; export type ProjectWorktree = components["schemas"]["ProjectWorktree"]; -export type ProjectWorktreeWithRepo = components["schemas"]["ProjectWorktreeWithRepo"]; -export type ProjectWithRelations = components["schemas"]["ProjectWithRelations"]; +export type ProjectWorktreeWithRepo = + components["schemas"]["ProjectWorktreeWithRepo"]; +export type ProjectWithRelations = + components["schemas"]["ProjectWithRelations"]; export type ProjectListResponse = components["schemas"]["ProjectListResponse"]; export type ProjectResponse = components["schemas"]["ProjectResponse"]; @@ -344,10 +419,13 @@ export type DiskUsageEntry = components["schemas"]["DiskUsageEntry"]; export type DiskUsageResponse = components["schemas"]["DiskUsageResponse"]; // CRUD -export type CreateProjectRequest = components["schemas"]["CreateProjectRequest"]; -export type UpdateProjectRequest = components["schemas"]["UpdateProjectRequest"]; +export type CreateProjectRequest = + components["schemas"]["CreateProjectRequest"]; +export type UpdateProjectRequest = + components["schemas"]["UpdateProjectRequest"]; export type CreateRepoRequest = components["schemas"]["CreateRepoRequest"]; -export type CreateWorktreeRequest = components["schemas"]["CreateWorktreeRequest"]; +export type CreateWorktreeRequest = + components["schemas"]["CreateWorktreeRequest"]; // Responses export type RepoResponse = components["schemas"]["RepoResponse"]; @@ -366,7 +444,8 @@ export type SecretInfoResponse = components["schemas"]["SecretInfoResponse"]; // CRUD export type PutSecretBody = components["schemas"]["PutSecretBody"]; export type PutSecretResponse = components["schemas"]["PutSecretResponse"]; -export type DeleteSecretResponse = components["schemas"]["DeleteSecretResponse"]; +export type DeleteSecretResponse = + components["schemas"]["DeleteSecretResponse"]; // Encryption export type EncryptResponse = components["schemas"]["EncryptResponse"]; diff --git a/justfile b/justfile index 5c26447e2..1958d272b 100644 --- a/justfile +++ b/justfile @@ -36,6 +36,10 @@ check-typegen: cd interface && bunx openapi-typescript /tmp/spacebot-openapi-check.json -o /tmp/spacebot-schema-check.d.ts diff interface/src/api/schema.d.ts /tmp/spacebot-schema-check.d.ts +typegen-package: + cargo run --bin openapi-spec > /tmp/spacebot-openapi-package.json + cd interface && bunx openapi-typescript /tmp/spacebot-openapi-package.json -o src/api/schema.d.ts + gate-pr-ci: preflight-ci ./scripts/gate-pr.sh --ci diff --git a/migrations/20260324000001_webchat_conversations.sql b/migrations/20260324000001_webchat_conversations.sql new file mode 100644 index 000000000..3a206f46c --- /dev/null +++ b/migrations/20260324000001_webchat_conversations.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS webchat_conversations ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + title TEXT NOT NULL, + title_source TEXT NOT NULL DEFAULT 'system', + archived INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_webchat_conversations_agent_updated + ON webchat_conversations(agent_id, archived, updated_at DESC); diff --git a/packages/api-client/package.json b/packages/api-client/package.json new file mode 100644 index 000000000..a87d65c9a --- /dev/null +++ b/packages/api-client/package.json @@ -0,0 +1,13 @@ +{ + "name": "@spacebot/api-client", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts", + "./types": "./src/types.ts", + "./schema": "./src/schema.d.ts", + "./events": "./src/events.ts" + } +} diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts new file mode 100644 index 000000000..0f7535b98 --- /dev/null +++ b/packages/api-client/src/client.ts @@ -0,0 +1,235 @@ +import type { + WebChatConversationResponse, + WebChatConversationsResponse, + WebChatHistoryMessage, + HealthResponse, + MemoriesListResponse, + MessagesResponse, + StatusResponse, + TaskListResponse, + TimelineItem, + WorkerDetailResponse, + WorkerListResponse, + WarmupStatusResponse, +} from "./types"; + +let baseUrl = ""; +let authToken: string | null = null; + +export function setServerUrl(url: string) { + baseUrl = url.replace(/\/+$/, ""); +} + +export function getServerUrl() { + return baseUrl; +} + +export function setAuthToken(token: string | null | undefined) { + authToken = token?.trim() || null; +} + +export function getAuthToken() { + return authToken; +} + +export function getApiBase() { + return baseUrl ? `${baseUrl}/api` : "/api"; +} + +export function getEventsUrl() { + return `${getApiBase()}/events`; +} + +function buildHeaders(extra?: HeadersInit): HeadersInit { + return { + "Content-Type": "application/json", + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + ...(extra ?? {}), + }; +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${getApiBase()}${path}`, { + ...init, + headers: buildHeaders(init?.headers), + }); + + if (!response.ok) { + throw new Error(`Spacebot API error: ${response.status}`); + } + + return response.json() as Promise; +} + +export const apiClient = { + health() { + return request("/health"); + }, + + status() { + return request("/status"); + }, + + warmup(agentId: string) { + return request( + `/agents/warmup?agent_id=${encodeURIComponent(agentId)}`, + ); + }, + + webchatHistory(agentId: string, sessionId: string, limit = 100) { + const params = new URLSearchParams({ + agent_id: agentId, + session_id: sessionId, + limit: String(limit), + }); + return request( + `/webchat/history?${params.toString()}`, + ); + }, + + channelMessages(channelId: string, limit = 200, before?: string) { + const params = new URLSearchParams({ + channel_id: channelId, + limit: String(limit), + }); + + if (before) { + params.set('before', before); + } + + return request(`/channels/messages?${params.toString()}`); + }, + + webchatSend(input: { + agentId: string; + sessionId: string; + senderName?: string; + message: string; + }) { + return request<{ ok: boolean }>("/webchat/send", { + method: "POST", + body: JSON.stringify({ + agent_id: input.agentId, + session_id: input.sessionId, + sender_name: input.senderName ?? "user", + message: input.message, + }), + }); + }, + + listWebchatConversations( + agentId: string, + includeArchived = false, + limit = 100, + ) { + const params = new URLSearchParams({ + agent_id: agentId, + include_archived: includeArchived ? "true" : "false", + limit: String(limit), + }); + return request( + `/webchat/conversations?${params.toString()}`, + ); + }, + + createWebchatConversation(input: { agentId: string; title?: string | null }) { + return request("/webchat/conversations", { + method: "POST", + body: JSON.stringify({ + agent_id: input.agentId, + title: input.title ?? undefined, + }), + }); + }, + + updateWebchatConversation(input: { + agentId: string; + sessionId: string; + title?: string | null; + archived?: boolean; + }) { + return request( + `/webchat/conversations/${encodeURIComponent(input.sessionId)}`, + { + method: "PUT", + body: JSON.stringify({ + agent_id: input.agentId, + title: input.title ?? undefined, + archived: input.archived, + }), + }, + ); + }, + + deleteWebchatConversation(agentId: string, sessionId: string) { + const params = new URLSearchParams({ agent_id: agentId }); + return request<{ ok: boolean }>( + `/webchat/conversations/${encodeURIComponent(sessionId)}?${params.toString()}`, + { + method: "DELETE", + }, + ); + }, + + listTasks(agentId: string, limit = 20) { + const params = new URLSearchParams({ + agent_id: agentId, + limit: String(limit), + }); + return request(`/tasks?${params.toString()}`); + }, + + listMemories(agentId: string, limit = 12, sort = "recent") { + const params = new URLSearchParams({ + agent_id: agentId, + limit: String(limit), + sort, + }); + return request( + `/agents/memories?${params.toString()}`, + ); + }, + + listWorkers(input: { + agentId: string; + limit?: number; + offset?: number; + status?: string; + }) { + const params = new URLSearchParams({ + agent_id: input.agentId, + limit: String(input.limit ?? 50), + offset: String(input.offset ?? 0), + }); + + if (input.status) { + params.set("status", input.status); + } + + return request(`/agents/workers?${params.toString()}`); + }, + + workerDetail(agentId: string, workerId: string) { + const params = new URLSearchParams({ + agent_id: agentId, + worker_id: workerId, + }); + + return request(`/agents/workers/detail?${params.toString()}`); + }, + + cancelProcess(input: { + channelId: string; + processType: "worker" | "branch"; + processId: string; + }) { + return request<{ success: boolean; message: string }>("/channels/cancel-process", { + method: "POST", + body: JSON.stringify({ + channel_id: input.channelId, + process_type: input.processType, + process_id: input.processId, + }), + }); + }, +}; diff --git a/packages/api-client/src/events.ts b/packages/api-client/src/events.ts new file mode 100644 index 000000000..e330734c8 --- /dev/null +++ b/packages/api-client/src/events.ts @@ -0,0 +1,155 @@ +import type { CortexChatToolCall } from "./types"; + +export type ProcessType = "channel" | "branch" | "worker"; + +export interface InboundMessageEvent { + type: "inbound_message"; + agent_id: string; + channel_id: string; + sender_name?: string | null; + sender_id: string; + text: string; +} + +export interface OutboundMessageEvent { + type: "outbound_message"; + agent_id: string; + channel_id: string; + text: string; +} + +export interface OutboundMessageDeltaEvent { + type: "outbound_message_delta"; + agent_id: string; + channel_id: string; + text_delta: string; + aggregated_text: string; +} + +export interface TypingStateEvent { + type: "typing_state"; + agent_id: string; + channel_id: string; + is_typing: boolean; +} + +export interface WorkerStartedEvent { + type: "worker_started"; + agent_id: string; + channel_id: string | null; + worker_id: string; + task: string; + worker_type?: string; + interactive?: boolean; +} + +export interface WorkerStatusEvent { + type: "worker_status"; + agent_id: string; + channel_id: string | null; + worker_id: string; + status: string; +} + +export interface WorkerIdleEvent { + type: "worker_idle"; + agent_id: string; + channel_id: string | null; + worker_id: string; +} + +export interface WorkerCompletedEvent { + type: "worker_completed"; + agent_id: string; + channel_id: string | null; + worker_id: string; + result: string; + success?: boolean; +} + +export interface BranchStartedEvent { + type: "branch_started"; + agent_id: string; + channel_id: string; + branch_id: string; + description: string; +} + +export interface BranchCompletedEvent { + type: "branch_completed"; + agent_id: string; + channel_id: string; + branch_id: string; + conclusion: string; +} + +export interface ToolStartedEvent { + type: "tool_started"; + agent_id: string; + channel_id: string | null; + process_type: ProcessType; + process_id: string; + tool_name: string; + args: string; +} + +export interface ToolCompletedEvent { + type: "tool_completed"; + agent_id: string; + channel_id: string | null; + process_type: ProcessType; + process_id: string; + tool_name: string; + result: string; +} + +export type OpenCodeToolState = + | { status: "pending" } + | { status: "running"; title?: string; input?: string } + | { status: "completed"; title?: string; input?: string; output?: string } + | { status: "error"; error?: string }; + +export type OpenCodePart = + | { type: "text"; id: string; text: string } + | ({ type: "tool"; id: string; tool: string } & OpenCodeToolState) + | { type: "step_start"; id: string } + | { type: "step_finish"; id: string; reason?: string }; + +export interface OpenCodePartUpdatedEvent { + type: "opencode_part_updated"; + agent_id: string; + worker_id: string; + part: OpenCodePart; +} + +export interface WorkerTextEvent { + type: "worker_text"; + agent_id: string; + worker_id: string; + text: string; +} + +export interface CortexChatMessageEvent { + type: "cortex_chat_message"; + agent_id: string; + thread_id: string; + content: string; + tool_calls?: CortexChatToolCall[]; +} + +export type ApiEvent = + | InboundMessageEvent + | OutboundMessageEvent + | OutboundMessageDeltaEvent + | TypingStateEvent + | WorkerStartedEvent + | WorkerStatusEvent + | WorkerIdleEvent + | WorkerCompletedEvent + | BranchStartedEvent + | BranchCompletedEvent + | ToolStartedEvent + | ToolCompletedEvent + | OpenCodePartUpdatedEvent + | WorkerTextEvent + | CortexChatMessageEvent; diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts new file mode 100644 index 000000000..2634183ad --- /dev/null +++ b/packages/api-client/src/index.ts @@ -0,0 +1,3 @@ +export * from "./client"; +export * from "./types"; +export * from "./events"; diff --git a/packages/api-client/src/schema.d.ts b/packages/api-client/src/schema.d.ts new file mode 100644 index 000000000..ebb621110 --- /dev/null +++ b/packages/api-client/src/schema.d.ts @@ -0,0 +1 @@ +export * from "../../../interface/src/api/schema"; diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts new file mode 100644 index 000000000..3da31b9eb --- /dev/null +++ b/packages/api-client/src/types.ts @@ -0,0 +1 @@ +export * from "../../../interface/src/api/types"; diff --git a/src/api/server.rs b/src/api/server.rs index 1db3a7f67..ff84ff2a2 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -217,6 +217,10 @@ pub fn api_router() -> OpenApiRouter> { // Webchat routes .routes(routes!(webchat::webchat_send)) .routes(routes!(webchat::webchat_history)) + .routes(routes!(webchat::list_webchat_conversations)) + .routes(routes!(webchat::create_webchat_conversation)) + .routes(routes!(webchat::update_webchat_conversation)) + .routes(routes!(webchat::delete_webchat_conversation)) // Link routes .routes(routes!(links::list_links, links::create_link)) .routes(routes!(links::update_link, links::delete_link)) diff --git a/src/api/webchat.rs b/src/api/webchat.rs index 8a7c4873a..7a5f6569d 100644 --- a/src/api/webchat.rs +++ b/src/api/webchat.rs @@ -1,8 +1,11 @@ use super::state::ApiState; -use crate::{InboundMessage, MessageContent}; +use crate::{ + InboundMessage, MessageContent, + conversation::{WebChatConversation, WebChatConversationStore, WebChatConversationSummary}, +}; use axum::Json; -use axum::extract::{Query, State}; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -26,6 +29,75 @@ pub(super) struct WebChatSendResponse { ok: bool, } +#[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)] +pub(super) struct WebChatHistoryQuery { + agent_id: String, + session_id: String, + #[serde(default = "default_limit")] + limit: i64, +} + +fn default_limit() -> i64 { + 100 +} + +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct WebChatHistoryMessage { + id: String, + role: String, + content: String, +} + +#[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)] +pub(super) struct WebChatConversationsQuery { + agent_id: String, + #[serde(default)] + include_archived: bool, + #[serde(default = "default_conversation_limit")] + limit: i64, +} + +fn default_conversation_limit() -> i64 { + 100 +} + +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct WebChatConversationsResponse { + conversations: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub(super) struct CreateWebChatConversationRequest { + agent_id: String, + title: Option, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct WebChatConversationResponse { + conversation: WebChatConversation, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub(super) struct UpdateWebChatConversationRequest { + agent_id: String, + title: Option, + archived: Option, +} + +#[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)] +pub(super) struct DeleteWebChatConversationQuery { + agent_id: String, +} + +fn conversation_store( + state: &Arc, + agent_id: &str, +) -> Result { + let pools = state.agent_pools.load(); + let pool = pools.get(agent_id).ok_or(StatusCode::NOT_FOUND)?; + Ok(WebChatConversationStore::new(pool.clone())) +} + /// Fire-and-forget message injection. The response arrives via the global SSE /// event bus (`/api/events`), same as every other channel. #[utoipa::path( @@ -35,6 +107,7 @@ pub(super) struct WebChatSendResponse { responses( (status = 200, body = WebChatSendResponse), (status = 400, description = "Invalid request"), + (status = 404, description = "Agent not found"), (status = 503, description = "Messaging manager not available"), ), tag = "webchat", @@ -50,6 +123,22 @@ pub(super) async fn webchat_send( .clone() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let store = conversation_store(&state, &request.agent_id)?; + store + .ensure(&request.agent_id, &request.session_id) + .await + .map_err(|error| { + tracing::warn!(%error, session_id = %request.session_id, "failed to ensure webchat conversation"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + store + .maybe_set_generated_title(&request.agent_id, &request.session_id, &request.message) + .await + .map_err(|error| { + tracing::warn!(%error, session_id = %request.session_id, "failed to update generated webchat title"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + let conversation_id = request.session_id.clone(); let mut metadata = HashMap::new(); @@ -79,25 +168,6 @@ pub(super) async fn webchat_send( Ok(Json(WebChatSendResponse { ok: true })) } -#[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)] -pub(super) struct WebChatHistoryQuery { - agent_id: String, - session_id: String, - #[serde(default = "default_limit")] - limit: i64, -} - -fn default_limit() -> i64 { - 100 -} - -#[derive(Serialize, utoipa::ToSchema)] -pub(super) struct WebChatHistoryMessage { - id: String, - role: String, - content: String, -} - #[utoipa::path( get, path = "/webchat/history", @@ -133,12 +203,142 @@ pub(super) async fn webchat_history( let result: Vec = messages .into_iter() - .map(|m| WebChatHistoryMessage { - id: m.id, - role: m.role, - content: m.content, + .map(|message| WebChatHistoryMessage { + id: message.id, + role: message.role, + content: message.content, }) .collect(); Ok(Json(result)) } + +#[utoipa::path( + get, + path = "/webchat/conversations", + params( + ("agent_id" = String, Query, description = "Agent ID"), + ("include_archived" = bool, Query, description = "Include archived conversations"), + ("limit" = i64, Query, description = "Maximum number of conversations to return (default: 100, max: 500)"), + ), + responses( + (status = 200, body = WebChatConversationsResponse), + (status = 404, description = "Agent not found"), + (status = 500, description = "Internal server error"), + ), + tag = "webchat", +)] +pub(super) async fn list_webchat_conversations( + State(state): State>, + Query(query): Query, +) -> Result, StatusCode> { + let store = conversation_store(&state, &query.agent_id)?; + let conversations = store + .list(&query.agent_id, query.include_archived, query.limit) + .await + .map_err(|error| { + tracing::warn!(%error, agent_id = %query.agent_id, "failed to list webchat conversations"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(WebChatConversationsResponse { conversations })) +} + +#[utoipa::path( + post, + path = "/webchat/conversations", + request_body = CreateWebChatConversationRequest, + responses( + (status = 200, body = WebChatConversationResponse), + (status = 404, description = "Agent not found"), + (status = 500, description = "Internal server error"), + ), + tag = "webchat", +)] +pub(super) async fn create_webchat_conversation( + State(state): State>, + Json(request): Json, +) -> Result, StatusCode> { + let store = conversation_store(&state, &request.agent_id)?; + let conversation = store + .create(&request.agent_id, request.title.as_deref()) + .await + .map_err(|error| { + tracing::warn!(%error, agent_id = %request.agent_id, "failed to create webchat conversation"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(WebChatConversationResponse { conversation })) +} + +#[utoipa::path( + put, + path = "/webchat/conversations/{session_id}", + request_body = UpdateWebChatConversationRequest, + params( + ("session_id" = String, Path, description = "Conversation session ID"), + ), + responses( + (status = 200, body = WebChatConversationResponse), + (status = 404, description = "Conversation not found"), + (status = 500, description = "Internal server error"), + ), + tag = "webchat", +)] +pub(super) async fn update_webchat_conversation( + State(state): State>, + Path(session_id): Path, + Json(request): Json, +) -> Result, StatusCode> { + let store = conversation_store(&state, &request.agent_id)?; + let conversation = store + .update( + &request.agent_id, + &session_id, + request.title.as_deref(), + request.archived, + ) + .await + .map_err(|error| { + tracing::warn!(%error, %session_id, "failed to update webchat conversation"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(WebChatConversationResponse { conversation })) +} + +#[utoipa::path( + delete, + path = "/webchat/conversations/{session_id}", + params( + ("session_id" = String, Path, description = "Conversation session ID"), + ("agent_id" = String, Query, description = "Agent ID"), + ), + responses( + (status = 200, body = WebChatSendResponse), + (status = 404, description = "Conversation not found"), + (status = 500, description = "Internal server error"), + ), + tag = "webchat", +)] +pub(super) async fn delete_webchat_conversation( + State(state): State>, + Path(session_id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let store = conversation_store(&state, &query.agent_id)?; + let deleted = store + .delete(&query.agent_id, &session_id) + .await + .map_err(|error| { + tracing::warn!(%error, %session_id, "failed to delete webchat conversation"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if !deleted { + return Err(StatusCode::NOT_FOUND); + } + + Ok(Json(WebChatSendResponse { ok: true })) +} diff --git a/src/conversation.rs b/src/conversation.rs index bbfc5fac9..3a3822ba5 100644 --- a/src/conversation.rs +++ b/src/conversation.rs @@ -3,10 +3,12 @@ pub mod channels; pub mod context; pub mod history; +pub mod webchat; pub mod worker_transcript; pub use channels::ChannelStore; pub use history::{ ConversationLogger, ProcessRunLogger, TimelineItem, WorkerDetailRow, WorkerRunRow, }; +pub use webchat::{WebChatConversation, WebChatConversationStore, WebChatConversationSummary}; pub use worker_transcript::{ActionContent, TranscriptStep}; diff --git a/src/conversation/webchat.rs b/src/conversation/webchat.rs new file mode 100644 index 000000000..f77f65df0 --- /dev/null +++ b/src/conversation/webchat.rs @@ -0,0 +1,347 @@ +//! Webchat conversation persistence (SQLite). + +use sqlx::{Row as _, SqlitePool}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct WebChatConversation { + pub id: String, + pub agent_id: String, + pub title: String, + pub title_source: String, + pub archived: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct WebChatConversationSummary { + pub id: String, + pub agent_id: String, + pub title: String, + pub title_source: String, + pub archived: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub last_message_at: Option>, + pub last_message_preview: Option, + pub last_message_role: Option, + pub message_count: i64, +} + +#[derive(Debug, Clone)] +pub struct WebChatConversationStore { + pool: SqlitePool, +} + +impl WebChatConversationStore { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + pub async fn create( + &self, + agent_id: &str, + title: Option<&str>, + ) -> crate::error::Result { + let id = format!("portal:chat:{agent_id}:{}", uuid::Uuid::new_v4()); + let title = normalize_title(title).unwrap_or_else(default_title); + let title_source = if title == default_title() { + "system" + } else { + "user" + }; + + sqlx::query( + "INSERT INTO webchat_conversations (id, agent_id, title, title_source) VALUES (?, ?, ?, ?)", + ) + .bind(&id) + .bind(agent_id) + .bind(&title) + .bind(title_source) + .execute(&self.pool) + .await + .map_err(|error| anyhow::anyhow!(error))?; + + Ok(self + .get(agent_id, &id) + .await? + .ok_or_else(|| anyhow::anyhow!("newly created webchat conversation missing"))?) + } + + pub async fn ensure( + &self, + agent_id: &str, + session_id: &str, + ) -> crate::error::Result { + sqlx::query( + "INSERT INTO webchat_conversations (id, agent_id, title, title_source) VALUES (?, ?, ?, 'system') \ + ON CONFLICT(id) DO NOTHING", + ) + .bind(session_id) + .bind(agent_id) + .bind(default_title()) + .execute(&self.pool) + .await + .map_err(|error| anyhow::anyhow!(error))?; + + Ok(self + .get(agent_id, session_id) + .await? + .ok_or_else(|| anyhow::anyhow!("ensured webchat conversation missing"))?) + } + + pub async fn get( + &self, + agent_id: &str, + session_id: &str, + ) -> crate::error::Result> { + let row = sqlx::query( + "SELECT id, agent_id, title, title_source, archived, created_at, updated_at \ + FROM webchat_conversations WHERE agent_id = ? AND id = ?", + ) + .bind(agent_id) + .bind(session_id) + .fetch_optional(&self.pool) + .await + .map_err(|error| anyhow::anyhow!(error))?; + + Ok(row.map(row_to_conversation)) + } + + pub async fn list( + &self, + agent_id: &str, + include_archived: bool, + limit: i64, + ) -> crate::error::Result> { + self.backfill_from_messages(agent_id).await?; + + let rows = sqlx::query( + "SELECT \ + c.id, c.agent_id, c.title, c.title_source, c.archived, c.created_at, c.updated_at, \ + (SELECT MAX(created_at) FROM conversation_messages WHERE channel_id = c.id) as last_message_at, \ + (SELECT content FROM conversation_messages WHERE channel_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message_preview, \ + (SELECT role FROM conversation_messages WHERE channel_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message_role, \ + (SELECT COUNT(*) FROM conversation_messages WHERE channel_id = c.id) as message_count \ + FROM webchat_conversations c \ + WHERE c.agent_id = ? AND (? = 1 OR c.archived = 0) \ + ORDER BY COALESCE((SELECT MAX(created_at) FROM conversation_messages WHERE channel_id = c.id), c.updated_at, c.created_at) DESC \ + LIMIT ?", + ) + .bind(agent_id) + .bind(if include_archived { 1_i64 } else { 0_i64 }) + .bind(limit.clamp(1, 500)) + .fetch_all(&self.pool) + .await + .map_err(|error| anyhow::anyhow!(error))?; + + Ok(rows.into_iter().map(row_to_summary).collect()) + } + + pub async fn update( + &self, + agent_id: &str, + session_id: &str, + title: Option<&str>, + archived: Option, + ) -> crate::error::Result> { + if title.is_none() && archived.is_none() { + return self.get(agent_id, session_id).await; + } + + let title = normalize_title(title); + let title_source = title.as_ref().map(|_| "user"); + + let result = sqlx::query( + "UPDATE webchat_conversations \ + SET title = COALESCE(?, title), \ + title_source = COALESCE(?, title_source), \ + archived = COALESCE(?, archived), \ + updated_at = CURRENT_TIMESTAMP \ + WHERE agent_id = ? AND id = ?", + ) + .bind(title.as_deref()) + .bind(title_source) + .bind(archived.map(|value| if value { 1_i64 } else { 0_i64 })) + .bind(agent_id) + .bind(session_id) + .execute(&self.pool) + .await + .map_err(|error| anyhow::anyhow!(error))?; + + if result.rows_affected() == 0 { + return Ok(None); + } + + self.get(agent_id, session_id).await + } + + pub async fn delete(&self, agent_id: &str, session_id: &str) -> crate::error::Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|error| anyhow::anyhow!(error))?; + + sqlx::query("DELETE FROM conversation_messages WHERE channel_id = ?") + .bind(session_id) + .execute(&mut *tx) + .await + .map_err(|error| anyhow::anyhow!(error))?; + + let result = sqlx::query("DELETE FROM webchat_conversations WHERE agent_id = ? AND id = ?") + .bind(agent_id) + .bind(session_id) + .execute(&mut *tx) + .await + .map_err(|error| anyhow::anyhow!(error))?; + + tx.commit().await.map_err(|error| anyhow::anyhow!(error))?; + + Ok(result.rows_affected() > 0) + } + + pub async fn maybe_set_generated_title( + &self, + agent_id: &str, + session_id: &str, + content: &str, + ) -> crate::error::Result<()> { + let generated_title = generate_title(content); + + sqlx::query( + "UPDATE webchat_conversations \ + SET title = ?, updated_at = CURRENT_TIMESTAMP \ + WHERE agent_id = ? AND id = ? AND title_source = 'system' AND title = ?", + ) + .bind(&generated_title) + .bind(agent_id) + .bind(session_id) + .bind(default_title()) + .execute(&self.pool) + .await + .map_err(|error| anyhow::anyhow!(error))?; + + Ok(()) + } + + async fn backfill_from_messages(&self, agent_id: &str) -> crate::error::Result<()> { + let rows = sqlx::query( + "SELECT DISTINCT channel_id FROM conversation_messages WHERE channel_id LIKE 'portal:chat:%'", + ) + .fetch_all(&self.pool) + .await + .map_err(|error| anyhow::anyhow!(error))?; + + for row in rows { + let channel_id: String = row.try_get("channel_id").unwrap_or_default(); + if channel_id.is_empty() { + continue; + } + + let existing = self.get(agent_id, &channel_id).await?; + if existing.is_some() { + continue; + } + + let title = sqlx::query( + "SELECT content FROM conversation_messages \ + WHERE channel_id = ? AND role = 'user' \ + ORDER BY created_at ASC LIMIT 1", + ) + .bind(&channel_id) + .fetch_optional(&self.pool) + .await + .map_err(|error| anyhow::anyhow!(error))? + .and_then(|title_row| title_row.try_get::("content").ok()) + .map(|content| generate_title(&content)) + .unwrap_or_else(default_title); + + let title_source = if title == default_title() { + "system" + } else { + "user" + }; + + sqlx::query( + "INSERT INTO webchat_conversations (id, agent_id, title, title_source) VALUES (?, ?, ?, ?) \ + ON CONFLICT(id) DO NOTHING", + ) + .bind(&channel_id) + .bind(agent_id) + .bind(&title) + .bind(title_source) + .execute(&self.pool) + .await + .map_err(|error| anyhow::anyhow!(error))?; + } + + Ok(()) + } +} + +fn row_to_conversation(row: sqlx::sqlite::SqliteRow) -> WebChatConversation { + WebChatConversation { + id: row.try_get("id").unwrap_or_default(), + agent_id: row.try_get("agent_id").unwrap_or_default(), + title: row.try_get("title").unwrap_or_else(|_| default_title()), + title_source: row + .try_get("title_source") + .unwrap_or_else(|_| "system".to_string()), + archived: row.try_get::("archived").unwrap_or(0) == 1, + created_at: row + .try_get("created_at") + .unwrap_or_else(|_| chrono::Utc::now()), + updated_at: row + .try_get("updated_at") + .unwrap_or_else(|_| chrono::Utc::now()), + } +} + +fn row_to_summary(row: sqlx::sqlite::SqliteRow) -> WebChatConversationSummary { + WebChatConversationSummary { + id: row.try_get("id").unwrap_or_default(), + agent_id: row.try_get("agent_id").unwrap_or_default(), + title: row.try_get("title").unwrap_or_else(|_| default_title()), + title_source: row + .try_get("title_source") + .unwrap_or_else(|_| "system".to_string()), + archived: row.try_get::("archived").unwrap_or(0) == 1, + created_at: row + .try_get("created_at") + .unwrap_or_else(|_| chrono::Utc::now()), + updated_at: row + .try_get("updated_at") + .unwrap_or_else(|_| chrono::Utc::now()), + last_message_at: row.try_get("last_message_at").ok(), + last_message_preview: row.try_get("last_message_preview").ok().flatten(), + last_message_role: row.try_get("last_message_role").ok().flatten(), + message_count: row.try_get("message_count").unwrap_or(0), + } +} + +fn default_title() -> String { + "New chat".to_string() +} + +fn normalize_title(title: Option<&str>) -> Option { + title + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(ToString::to_string) +} + +fn generate_title(content: &str) -> String { + let cleaned = content.trim().replace('\n', " "); + let trimmed = cleaned.trim(); + + if trimmed.is_empty() { + return default_title(); + } + + let mut title = trimmed.chars().take(72).collect::(); + if trimmed.chars().count() > 72 { + title.push_str("..."); + } + title +} From 9f9e7e0164064d20ea5e26bab1f6a161ba4fb4a4 Mon Sep 17 00:00:00 2001 From: James Pine Date: Wed, 25 Mar 2026 16:20:03 -0700 Subject: [PATCH 02/20] streaming support --- src/agent/channel.rs | 7 +- src/hooks/spacebot.rs | 448 ++++++++++++++++++++++++++++- src/llm/model.rs | 636 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 1087 insertions(+), 4 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 457ee42d1..c5af1725c 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -2415,7 +2415,10 @@ impl Channel { // ── Prompt snapshot capture (fire-and-forget) ── self.maybe_capture_snapshot(system_prompt, user_text, &history); - let mut result = self.hook.prompt_once(&agent, &mut history, user_text).await; + let mut result = self + .hook + .prompt_once_streaming(&agent, &mut history, user_text, max_turns) + .await; // If the LLM responded with text that looks like tool call syntax, it failed // to use the tool calling API. Inject a correction and retry a couple @@ -2440,7 +2443,7 @@ impl Channel { let correction = prompt_engine.render_system_tool_syntax_correction()?; result = self .hook - .prompt_once(&agent, &mut history, &correction) + .prompt_once_streaming(&agent, &mut history, &correction, max_turns) .await; } diff --git a/src/hooks/spacebot.rs b/src/hooks/spacebot.rs index 72bde376a..9fbe084d3 100644 --- a/src/hooks/spacebot.rs +++ b/src/hooks/spacebot.rs @@ -3,8 +3,13 @@ use crate::hooks::loop_guard::{LoopGuard, LoopGuardConfig, LoopGuardVerdict}; use crate::tools::{MemoryPersistenceContractState, MemoryPersistenceTerminalOutcome}; use crate::{AgentId, ChannelId, ProcessEvent, ProcessId, ProcessType}; +use futures::StreamExt; use rig::agent::{HookAction, PromptHook, ToolCallHookAction}; -use rig::completion::{CompletionModel, CompletionResponse, Message, Prompt, PromptError}; +use rig::completion::{ + CompletionModel, CompletionResponse, GetTokenUsage, Message, Prompt, PromptError, +}; +use rig::message::{AssistantContent, ToolResultContent, UserContent}; +use rig::streaming::{StreamedAssistantContent, StreamingCompletion}; use std::sync::Arc; use tokio::sync::broadcast; @@ -61,6 +66,15 @@ pub struct SpacebotHook { /// append the messages to history before re-prompting. injected_messages: std::sync::Arc>>, memory_persistence_contract: Option>, + reply_tool_delta_state: + std::sync::Arc>>, +} + +#[derive(Clone, Debug, Default)] +struct ReplyToolDeltaState { + tool_name: Option, + raw_args: String, + emitted_content: String, } impl SpacebotHook { @@ -115,6 +129,9 @@ impl SpacebotHook { inject_rx: None, injected_messages: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), memory_persistence_contract: None, + reply_tool_delta_state: std::sync::Arc::new(std::sync::Mutex::new( + std::collections::HashMap::new(), + )), } } @@ -176,6 +193,51 @@ impl SpacebotHook { .store(active, std::sync::atomic::Ordering::Relaxed); } + fn extract_partial_reply_content(raw_args: &str) -> Option { + let key_index = raw_args.find("\"content\"")?; + let after_key = &raw_args[key_index + "\"content\"".len()..]; + let colon_index = after_key.find(':')?; + let after_colon = &after_key[colon_index + 1..]; + let quote_index = after_colon.find('"')?; + let content_slice = &after_colon[quote_index + 1..]; + + let mut result = String::new(); + let mut chars = content_slice.chars(); + while let Some(ch) = chars.next() { + match ch { + '"' => break, + '\\' => { + let Some(escaped) = chars.next() else { + break; + }; + match escaped { + '"' => result.push('"'), + '\\' => result.push('\\'), + '/' => result.push('/'), + 'b' => result.push('\u{0008}'), + 'f' => result.push('\u{000C}'), + 'n' => result.push('\n'), + 'r' => result.push('\r'), + 't' => result.push('\t'), + 'u' => { + let hex: String = chars.by_ref().take(4).collect(); + if hex.len() == 4 + && let Ok(value) = u32::from_str_radix(&hex, 16) + && let Some(decoded) = char::from_u32(value) + { + result.push(decoded); + } + } + other => result.push(other), + } + } + other => result.push(other), + } + } + + Some(result) + } + /// Return true if a PromptCancelled reason indicates a tool nudge retry. pub fn is_tool_nudge_reason(reason: &str) -> bool { reason == Self::TOOL_NUDGE_REASON @@ -380,6 +442,260 @@ impl SpacebotHook { .await } + /// Prompt once using Rig's streaming path so text/tool deltas reach the hook. + pub async fn prompt_once_streaming( + &self, + agent: &rig::agent::Agent, + history: &mut Vec, + prompt: &str, + max_turns: usize, + ) -> std::result::Result + where + M: CompletionModel + 'static, + M::StreamingResponse: GetTokenUsage + Send, + { + self.reset_tool_nudge_state(); + self.set_tool_nudge_request_active(false); + + let mut chat_history = history.clone(); + let prompt_message = Message::from(prompt); + chat_history.push(prompt_message.clone()); + + let mut current_max_turns = 0usize; + let mut last_text_response = String::new(); + let mut did_call_tool = false; + + loop { + let current_prompt = chat_history + .last() + .cloned() + .expect("chat history should always include current prompt"); + + if current_max_turns > max_turns + 1 { + return Err(PromptError::MaxTurnsError { + max_turns, + chat_history: Box::new(chat_history), + prompt: Box::new(prompt.to_string().into()), + }); + } + + current_max_turns += 1; + + if let HookAction::Terminate { reason } = + >::on_completion_call( + self, + ¤t_prompt, + &chat_history[..chat_history.len() - 1], + ) + .await + { + return Err(PromptError::PromptCancelled { + chat_history: Box::new(chat_history), + reason, + }); + } + + let request = agent + .stream_completion( + current_prompt.clone(), + chat_history[..chat_history.len() - 1].to_vec(), + ) + .await + .map_err(PromptError::CompletionError)?; + + let mut stream = request + .stream() + .await + .map_err(PromptError::CompletionError)?; + + let mut tool_calls = vec![]; + let mut tool_results = vec![]; + let mut is_text_response = false; + + while let Some(content) = stream.next().await { + match content.map_err(PromptError::CompletionError)? { + StreamedAssistantContent::Text(text) => { + if !is_text_response { + last_text_response.clear(); + is_text_response = true; + } + last_text_response.push_str(&text.text); + if let HookAction::Terminate { reason } = + >::on_text_delta( + self, + &text.text, + &last_text_response, + ) + .await + { + return Err(PromptError::PromptCancelled { + chat_history: Box::new(chat_history), + reason, + }); + } + did_call_tool = false; + } + StreamedAssistantContent::ToolCall { + tool_call, + internal_call_id, + } => { + let tool_args = serde_json::to_string(&tool_call.function.arguments) + .unwrap_or_else(|_| "{}".to_string()); + match >::on_tool_call( + self, + &tool_call.function.name, + tool_call.call_id.clone(), + &internal_call_id, + &tool_args, + ) + .await + { + ToolCallHookAction::Terminate { reason } => { + return Err(PromptError::PromptCancelled { + chat_history: Box::new(chat_history), + reason, + }); + } + ToolCallHookAction::Skip { reason } => { + tool_calls.push(AssistantContent::ToolCall(tool_call.clone())); + tool_results.push(( + tool_call.id.clone(), + tool_call.call_id.clone(), + reason, + )); + did_call_tool = true; + } + ToolCallHookAction::Continue => { + let tool_result = match agent + .tool_server_handle + .call_tool(&tool_call.function.name, &tool_args) + .await + { + Ok(result) => result, + Err(error) => error.to_string(), + }; + + if let HookAction::Terminate { reason } = + >::on_tool_result( + self, + &tool_call.function.name, + tool_call.call_id.clone(), + &internal_call_id, + &tool_args, + &tool_result, + ) + .await + { + return Err(PromptError::PromptCancelled { + chat_history: Box::new(chat_history), + reason, + }); + } + + tool_calls.push(AssistantContent::ToolCall(tool_call.clone())); + tool_results.push(( + tool_call.id.clone(), + tool_call.call_id.clone(), + tool_result, + )); + did_call_tool = true; + } + } + } + StreamedAssistantContent::ToolCallDelta { + id, + internal_call_id, + content, + } => { + let (name, delta) = match &content { + rig::streaming::ToolCallDeltaContent::Name(name) => { + (Some(name.as_str()), "") + } + rig::streaming::ToolCallDeltaContent::Delta(delta) => { + (None, delta.as_str()) + } + }; + if let HookAction::Terminate { reason } = + >::on_tool_call_delta( + self, + &id, + &internal_call_id, + name, + delta, + ) + .await + { + return Err(PromptError::PromptCancelled { + chat_history: Box::new(chat_history), + reason, + }); + } + } + StreamedAssistantContent::Final(final_response) => { + if is_text_response { + if let HookAction::Terminate { reason } = + >::on_stream_completion_response_finish( + self, + ¤t_prompt, + &final_response, + ) + .await + { + return Err(PromptError::PromptCancelled { + chat_history: Box::new(chat_history), + reason, + }); + } + is_text_response = false; + } + } + StreamedAssistantContent::Reasoning(_) + | StreamedAssistantContent::ReasoningDelta { .. } => { + did_call_tool = false; + } + } + } + + if !tool_calls.is_empty() { + chat_history.push(Message::Assistant { + id: None, + content: rig::OneOrMany::many(tool_calls) + .expect("tool call list should not be empty"), + }); + } + + for (id, call_id, tool_result) in tool_results { + if let Some(call_id) = call_id { + chat_history.push(Message::User { + content: rig::OneOrMany::one(UserContent::tool_result_with_call_id( + &id, + call_id, + rig::OneOrMany::one(ToolResultContent::text(&tool_result)), + )), + }); + } else { + chat_history.push(Message::User { + content: rig::OneOrMany::one(UserContent::tool_result( + &id, + rig::OneOrMany::one(ToolResultContent::text(&tool_result)), + )), + }); + } + } + + if !did_call_tool { + chat_history.push(Message::Assistant { + id: None, + content: rig::OneOrMany::one(AssistantContent::text( + last_text_response.clone(), + )), + }); + *history = chat_history; + return Ok(last_text_response); + } + } + } + /// Send a status update event. pub fn send_status(&self, status: impl Into) { let event = ProcessEvent::StatusUpdate { @@ -778,6 +1094,74 @@ where HookAction::Continue } + async fn on_tool_call_delta( + &self, + _tool_call_id: &str, + internal_call_id: &str, + tool_name: Option<&str>, + tool_call_delta: &str, + ) -> HookAction { + if self.process_type != ProcessType::Channel { + return HookAction::Continue; + } + + let Some(channel_id) = self.channel_id.clone() else { + return HookAction::Continue; + }; + + let mut guard = match self.reply_tool_delta_state.lock() { + Ok(guard) => guard, + Err(_) => return HookAction::Continue, + }; + + let state = guard + .entry(internal_call_id.to_string()) + .or_insert_with(ReplyToolDeltaState::default); + + if let Some(tool_name) = tool_name { + state.tool_name = Some(tool_name.to_string()); + } + + if state.tool_name.as_deref() != Some("reply") { + return HookAction::Continue; + } + + state.raw_args.push_str(tool_call_delta); + let Some(content) = Self::extract_partial_reply_content(&state.raw_args) else { + return HookAction::Continue; + }; + + if !content.starts_with(&state.emitted_content) { + return HookAction::Continue; + } + + let delta = &content[state.emitted_content.len()..]; + if delta.is_empty() { + return HookAction::Continue; + } + + state.emitted_content = content.clone(); + self.event_tx + .send(ProcessEvent::TextDelta { + agent_id: self.agent_id.clone(), + process_id: self.process_id.clone(), + channel_id: Some(channel_id), + text_delta: delta.to_string(), + aggregated_text: content, + }) + .ok(); + + HookAction::Continue + } + + async fn on_stream_completion_response_finish( + &self, + _prompt: &Message, + _response: &::StreamingResponse, + ) -> HookAction { + HookAction::Continue + } + async fn on_tool_call( &self, tool_name: &str, @@ -785,6 +1169,11 @@ where _internal_call_id: &str, args: &str, ) -> ToolCallHookAction { + if tool_name == "reply" + && let Ok(mut guard) = self.reply_tool_delta_state.lock() + { + guard.remove(_internal_call_id); + } // Loop guard: check for repetitive tool calling before execution. // Runs for all process types. Block → Skip (message becomes tool // result), CircuitBreak → Terminate. @@ -860,6 +1249,11 @@ where _args: &str, result: &str, ) -> HookAction { + if tool_name == "reply" + && let Ok(mut guard) = self.reply_tool_delta_state.lock() + { + guard.remove(internal_call_id); + } let guard_action = self.guard_tool_result(tool_name, result); if !matches!(guard_action, HookAction::Continue) { self.record_tool_result_metrics(tool_name, internal_call_id); @@ -1524,6 +1918,58 @@ mod tests { )); } + #[tokio::test] + async fn reply_tool_call_delta_emits_process_event() { + let (event_tx, mut event_rx) = tokio::sync::broadcast::channel(8); + let hook = SpacebotHook::new( + std::sync::Arc::::from("agent"), + ProcessId::Channel(std::sync::Arc::::from("channel")), + ProcessType::Channel, + Some(std::sync::Arc::::from("channel")), + event_tx, + ); + + let first = >::on_tool_call_delta( + &hook, + "reply-call", + "internal-reply", + Some("reply"), + "{\"content\":\"hel", + ) + .await; + let second = >::on_tool_call_delta( + &hook, + "reply-call", + "internal-reply", + None, + "lo\"}", + ) + .await; + + assert!(matches!(first, HookAction::Continue)); + assert!(matches!(second, HookAction::Continue)); + + let event = event_rx.recv().await.expect("first reply delta event"); + assert!(matches!( + event, + ProcessEvent::TextDelta { + ref text_delta, + ref aggregated_text, + .. + } if text_delta == "hel" && aggregated_text == "hel" + )); + + let event = event_rx.recv().await.expect("second reply delta event"); + assert!(matches!( + event, + ProcessEvent::TextDelta { + ref text_delta, + ref aggregated_text, + .. + } if text_delta == "lo" && aggregated_text == "hello" + )); + } + #[tokio::test] async fn tool_result_resets_consecutive_nudge_counter() { // The exact scenario from the Railway browser worker failure: diff --git a/src/llm/model.rs b/src/llm/model.rs index ddaef3cd2..72879d89c 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -13,6 +13,13 @@ use rig::message::{ ToolCall, ToolFunction, UserContent, }; use rig::one_or_many::OneOrMany; +use rig::providers::openai::responses_api::Output as OpenAiResponsesOutput; +use rig::providers::openai::responses_api::streaming::{ + ItemChunkKind as OpenAiResponsesItemChunkKind, + ResponseChunkKind as OpenAiResponsesResponseChunkKind, + StreamingCompletionChunk as OpenAiResponsesStreamingCompletionChunk, + StreamingItemDoneOutput as OpenAiResponsesStreamingItemDoneOutput, +}; use rig::streaming::{RawStreamingChoice, RawStreamingToolCall, StreamingCompletionResponse}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -541,10 +548,14 @@ impl CompletionModel for SpacebotModel { self.stream_openai_compatible(request, "Google Gemini", &provider_config) .await } - ApiType::Anthropic | ApiType::OpenAiResponses => { + ApiType::Anthropic => { let response = self.attempt_completion(request).await?; Ok(stream_from_completion_response(response)) } + ApiType::OpenAiResponses => { + self.stream_openai_responses(request, &provider_config) + .await + } } } } @@ -848,6 +859,287 @@ impl SpacebotModel { parse_openai_responses_response(response_body, &provider_label) } + async fn stream_openai_responses( + &self, + request: CompletionRequest, + provider_config: &ProviderConfig, + ) -> Result, CompletionError> { + let base_url = provider_config.base_url.trim_end_matches('/'); + let is_chatgpt_codex = self.provider == "openai-chatgpt"; + let responses_url = if is_chatgpt_codex { + format!("{base_url}/responses") + } else { + format!("{base_url}/v1/responses") + }; + let api_key = provider_config.api_key.as_str(); + let provider_label = provider_config + .name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| provider_display_name(&self.provider)); + + let input = convert_messages_to_openai_responses(&request.chat_history); + let api_model_name = self.remap_model_name_for_api(); + let mut body = serde_json::json!({ + "model": api_model_name, + "input": input, + "stream": true, + }); + + if let Some(preamble) = &request.preamble { + body["instructions"] = serde_json::json!(preamble); + } else if is_chatgpt_codex { + body["instructions"] = serde_json::json!( + "You are Spacebot. Follow instructions exactly and respond concisely." + ); + } + + if !is_chatgpt_codex && let Some(max_tokens) = request.max_tokens { + body["max_output_tokens"] = serde_json::json!(max_tokens); + } + + if !is_chatgpt_codex && let Some(temperature) = request.temperature { + body["temperature"] = serde_json::json!(temperature); + } + + if is_chatgpt_codex { + body["store"] = serde_json::json!(false); + } + + if !request.tools.is_empty() { + let tools: Vec = request + .tools + .iter() + .map(|tool_definition| { + serde_json::json!({ + "type": "function", + "name": tool_definition.name, + "description": tool_definition.description, + "parameters": tool_definition.parameters, + }) + }) + .collect(); + body["tools"] = serde_json::json!(tools); + } + + let openai_account_id = if self.provider == "openai-chatgpt" { + self.llm_manager.get_openai_account_id().await + } else { + None + }; + + let mut request_builder = self + .llm_manager + .http_client() + .post(&responses_url) + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .header("accept-encoding", "identity") + .timeout(std::time::Duration::from_secs(STREAM_REQUEST_TIMEOUT_SECS)); + if let Some(account_id) = openai_account_id { + request_builder = request_builder.header("ChatGPT-Account-Id", account_id); + } + if is_chatgpt_codex { + request_builder = request_builder + .header("originator", "opencode") + .header( + "session_id", + format!("spacebot-{}", chrono::Utc::now().timestamp()), + ) + .header( + "user-agent", + format!("spacebot/{}", env!("CARGO_PKG_VERSION")), + ); + } + + let response = request_builder + .json(&body) + .send() + .await + .map_err(|error| CompletionError::ProviderError(error.to_string()))?; + + let status = response.status(); + if !status.is_success() { + let response_text = response + .text() + .await + .unwrap_or_else(|error| format!("failed to read error response body: {error}")); + + return Err(CompletionError::ProviderError(format!( + "{provider_label} Responses API error ({status}): {}", + parse_openai_error_message(&response_text) + .unwrap_or_else(|| "unknown error".to_string()) + ))); + } + + let provider_label = provider_label.to_string(); + let stream = async_stream::stream! { + let mut stream = response.bytes_stream(); + let mut block_buffer = String::new(); + let mut raw_text = String::new(); + let mut sse_text = String::new(); + let mut saw_data_event = false; + let mut pending_tool_calls: std::collections::HashMap = std::collections::HashMap::new(); + + while let Some(chunk_result) = stream.next().await { + let chunk = match chunk_result { + Ok(bytes) => bytes, + Err(error) => { + yield Err(CompletionError::ProviderError(format!( + "{provider_label} stream read failed: {error}" + ))); + return; + } + }; + + let chunk_text = String::from_utf8_lossy(&chunk).to_string(); + if !saw_data_event { + raw_text.push_str(&chunk_text); + } + block_buffer.push_str(&chunk_text); + + while let Some(block) = extract_sse_block(&mut block_buffer) { + sse_text.push_str(&block); + sse_text.push_str("\n\n"); + + let Some(data) = extract_sse_data_payload(&block) else { + continue; + }; + let data = data.trim(); + if data.is_empty() || data == "[DONE]" { + continue; + } + + saw_data_event = true; + let event = match serde_json::from_str::(data) { + Ok(event) => event, + Err(_) => { + let raw_event = match serde_json::from_str::(data) { + Ok(raw_event) => raw_event, + Err(_) => continue, + }; + match process_openai_responses_stream_raw_event(&raw_event, &mut pending_tool_calls) { + Ok(events) => { + for event in events { + yield Ok(event); + } + } + Err(error) => { + yield Err(error); + return; + } + } + continue; + } + }; + + match process_openai_responses_stream_event(&event, &mut pending_tool_calls) { + Ok(events) => { + for event in events { + yield Ok(event); + } + } + Err(error) => { + yield Err(error); + return; + } + } + } + } + + if !block_buffer.trim().is_empty() && let Some(data) = extract_sse_data_payload(&block_buffer) { + let data = data.trim(); + if !data.is_empty() && data != "[DONE]" { + saw_data_event = true; + if let Ok(event) = serde_json::from_str::(data) { + match process_openai_responses_stream_event(&event, &mut pending_tool_calls) { + Ok(events) => { + for event in events { + yield Ok(event); + } + } + Err(error) => { + yield Err(error); + return; + } + } + } else { + if let Ok(raw_event) = serde_json::from_str::(data) { + match process_openai_responses_stream_raw_event(&raw_event, &mut pending_tool_calls) { + Ok(events) => { + for event in events { + yield Ok(event); + } + } + Err(error) => { + yield Err(error); + return; + } + } + } + } + } + } + + if saw_data_event { + let response_body = match parse_openai_responses_sse_response(&sse_text, &provider_label) { + Ok(body) => body, + Err(error) => { + yield Err(error); + return; + } + }; + + let parsed_response = match parse_openai_responses_response(response_body.clone(), &provider_label) { + Ok(response) => response, + Err(error) => { + yield Err(error); + return; + } + }; + + yield Ok(RawStreamingChoice::FinalResponse(RawStreamingResponse { + body: response_body, + usage: Some(parsed_response.usage), + })); + return; + } + + let response_body = match serde_json::from_str::(&raw_text) { + Ok(body) => body, + Err(error) => { + yield Err(CompletionError::ProviderError(format!( + "{provider_label} response is neither SSE nor JSON: {error}. Body: {}", + truncate_body(&raw_text) + ))); + return; + } + }; + + let parsed_response = match parse_openai_responses_response(response_body.clone(), &provider_label) { + Ok(response) => response, + Err(error) => { + yield Err(error); + return; + } + }; + + for event in completion_choice_to_streaming_choices(&parsed_response.choice) { + yield Ok(event); + } + if let Some(message_id) = parsed_response.message_id { + yield Ok(RawStreamingChoice::MessageId(message_id)); + } + yield Ok(RawStreamingChoice::FinalResponse(RawStreamingResponse { + body: response_body, + usage: Some(parsed_response.usage), + })); + }; + + Ok(StreamingCompletionResponse::stream(Box::pin(stream))) + } + /// Generic OpenAI-compatible API call. /// Used by providers that implement the OpenAI chat completions format. #[allow(dead_code)] @@ -2117,6 +2409,268 @@ fn process_openai_chat_stream_event( Ok(events) } +fn process_openai_responses_stream_event( + event: &OpenAiResponsesStreamingCompletionChunk, + pending_tool_calls: &mut std::collections::HashMap, +) -> Result>, CompletionError> { + let mut events = Vec::new(); + + match event { + OpenAiResponsesStreamingCompletionChunk::Delta(chunk) => { + match &chunk.data { + OpenAiResponsesItemChunkKind::OutputItemAdded(message) => { + if let OpenAiResponsesStreamingItemDoneOutput { + item: OpenAiResponsesOutput::FunctionCall(function_call), + .. + } = message + { + let entry = pending_tool_calls + .entry(function_call.id.clone()) + .or_insert_with(OpenAiStreamingToolCall::default); + entry.id = function_call.id.clone(); + entry.name = function_call.name.clone(); + events.push(RawStreamingChoice::ToolCallDelta { + id: function_call.id.clone(), + internal_call_id: entry.internal_call_id.clone(), + content: rig::streaming::ToolCallDeltaContent::Name( + function_call.name.clone(), + ), + }); + } + } + OpenAiResponsesItemChunkKind::OutputItemDone(message) => match message { + OpenAiResponsesStreamingItemDoneOutput { + item: OpenAiResponsesOutput::FunctionCall(function_call), + .. + } => { + let entry = pending_tool_calls + .remove(&function_call.id) + .unwrap_or_default(); + events.push(RawStreamingChoice::ToolCall(RawStreamingToolCall { + id: function_call.id.clone(), + internal_call_id: entry.internal_call_id, + call_id: Some(function_call.call_id.clone()), + name: function_call.name.clone(), + arguments: function_call.arguments.clone(), + signature: None, + additional_params: None, + })); + } + OpenAiResponsesStreamingItemDoneOutput { + item: OpenAiResponsesOutput::Message(message), + .. + } => { + events.push(RawStreamingChoice::MessageId(message.id.clone())); + } + OpenAiResponsesStreamingItemDoneOutput { + item: + OpenAiResponsesOutput::Reasoning { + summary, + id, + encrypted_content, + .. + }, + .. + } => { + for reasoning_summary in summary { + let rig::providers::openai::responses_api::ReasoningSummary::SummaryText { text } = reasoning_summary; + events.push(RawStreamingChoice::Reasoning { + id: Some(id.clone()), + content: ReasoningContent::Summary(text.clone()), + }); + } + if let Some(encrypted_content) = encrypted_content { + events.push(RawStreamingChoice::Reasoning { + id: Some(id.clone()), + content: ReasoningContent::Encrypted(encrypted_content.clone()), + }); + } + } + }, + OpenAiResponsesItemChunkKind::OutputTextDelta(delta) + | OpenAiResponsesItemChunkKind::RefusalDelta(delta) => { + events.push(RawStreamingChoice::Message(delta.delta.clone())); + } + OpenAiResponsesItemChunkKind::ReasoningSummaryTextDelta(delta) => { + events.push(RawStreamingChoice::ReasoningDelta { + id: None, + reasoning: delta.delta.clone(), + }); + } + OpenAiResponsesItemChunkKind::FunctionCallArgsDelta(delta) => { + let entry = pending_tool_calls + .entry(delta.item_id.clone()) + .or_insert_with(OpenAiStreamingToolCall::default); + entry.id = delta.item_id.clone(); + entry.arguments.push_str(&delta.delta); + events.push(RawStreamingChoice::ToolCallDelta { + id: delta.item_id.clone(), + internal_call_id: entry.internal_call_id.clone(), + content: rig::streaming::ToolCallDeltaContent::Delta(delta.delta.clone()), + }); + } + _ => {} + } + } + OpenAiResponsesStreamingCompletionChunk::Response(chunk) => { + if !matches!( + chunk.kind, + OpenAiResponsesResponseChunkKind::ResponseCompleted + ) { + return Ok(events); + } + } + } + + Ok(events) +} + +fn process_openai_responses_stream_raw_event( + event: &serde_json::Value, + pending_tool_calls: &mut std::collections::HashMap, +) -> Result>, CompletionError> { + let mut events = Vec::new(); + let Some(kind) = event.get("type").and_then(serde_json::Value::as_str) else { + return Ok(events); + }; + + match kind { + "response.output_text.delta" | "response.refusal.delta" => { + if let Some(delta) = event.get("delta").and_then(serde_json::Value::as_str) + && !delta.is_empty() + { + events.push(RawStreamingChoice::Message(delta.to_string())); + } + } + "response.reasoning_summary_text.delta" => { + if let Some(delta) = event.get("delta").and_then(serde_json::Value::as_str) + && !delta.is_empty() + { + events.push(RawStreamingChoice::ReasoningDelta { + id: None, + reasoning: delta.to_string(), + }); + } + } + "response.output_item.added" => { + let Some(item) = event.get("item") else { + return Ok(events); + }; + if item.get("type").and_then(serde_json::Value::as_str) == Some("function_call") { + let id = item + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let name = item + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + if !id.is_empty() && !name.is_empty() { + let entry = pending_tool_calls.entry(id.clone()).or_default(); + entry.id = id.clone(); + entry.name = name.clone(); + events.push(RawStreamingChoice::ToolCallDelta { + id, + internal_call_id: entry.internal_call_id.clone(), + content: rig::streaming::ToolCallDeltaContent::Name(name), + }); + } + } + } + "response.function_call_arguments.delta" => { + let Some(item_id) = event.get("item_id").and_then(serde_json::Value::as_str) else { + return Ok(events); + }; + let Some(delta) = event.get("delta").and_then(serde_json::Value::as_str) else { + return Ok(events); + }; + let entry = pending_tool_calls.entry(item_id.to_string()).or_default(); + entry.id = item_id.to_string(); + entry.arguments.push_str(delta); + events.push(RawStreamingChoice::ToolCallDelta { + id: item_id.to_string(), + internal_call_id: entry.internal_call_id.clone(), + content: rig::streaming::ToolCallDeltaContent::Delta(delta.to_string()), + }); + } + "response.function_call_arguments.done" => {} + "response.output_item.done" => { + let Some(item) = event.get("item") else { + return Ok(events); + }; + match item.get("type").and_then(serde_json::Value::as_str) { + Some("function_call") => { + let id = item + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let name = item + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let call_id = item + .get("call_id") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned); + let arguments = parse_openai_tool_arguments( + item.get("arguments").unwrap_or(&serde_json::Value::Null), + ); + let entry = pending_tool_calls.remove(&id).unwrap_or_default(); + events.push(RawStreamingChoice::ToolCall(RawStreamingToolCall { + id, + internal_call_id: entry.internal_call_id, + call_id, + name, + arguments, + signature: None, + additional_params: None, + })); + } + Some("message") => { + if let Some(id) = item.get("id").and_then(serde_json::Value::as_str) { + events.push(RawStreamingChoice::MessageId(id.to_string())); + } + } + Some("reasoning") => { + if let Some(id) = item.get("id").and_then(serde_json::Value::as_str) { + if let Some(summary) = + item.get("summary").and_then(serde_json::Value::as_array) + { + for part in summary { + if let Some(text) = + part.get("text").and_then(serde_json::Value::as_str) + { + events.push(RawStreamingChoice::Reasoning { + id: Some(id.to_string()), + content: ReasoningContent::Summary(text.to_string()), + }); + } + } + } + if let Some(encrypted) = item + .get("encrypted_content") + .and_then(serde_json::Value::as_str) + { + events.push(RawStreamingChoice::Reasoning { + id: Some(id.to_string()), + content: ReasoningContent::Encrypted(encrypted.to_string()), + }); + } + } + } + _ => {} + } + } + _ => {} + } + + Ok(events) +} + fn parse_openai_chat_sse_response( response_text: &str, provider_label: &str, @@ -3675,6 +4229,86 @@ mod tests { assert_eq!(tool_calls[0].arguments["content"], "line1\nline2"); } + #[test] + fn process_openai_responses_stream_event_emits_text_and_tool_call_deltas() { + let mut pending = std::collections::HashMap::new(); + + let text_event = OpenAiResponsesStreamingCompletionChunk::Delta( + rig::providers::openai::responses_api::streaming::ItemChunk { + item_id: Some("msg_1".to_string()), + output_index: 0, + data: OpenAiResponsesItemChunkKind::OutputTextDelta( + rig::providers::openai::responses_api::streaming::DeltaTextChunk { + content_index: 0, + sequence_number: 1, + delta: "hello".to_string(), + }, + ), + }, + ); + + let tool_name_event = OpenAiResponsesStreamingCompletionChunk::Delta( + rig::providers::openai::responses_api::streaming::ItemChunk { + item_id: Some("fc_1".to_string()), + output_index: 0, + data: OpenAiResponsesItemChunkKind::OutputItemAdded( + OpenAiResponsesStreamingItemDoneOutput { + sequence_number: 2, + item: OpenAiResponsesOutput::FunctionCall( + rig::providers::openai::responses_api::OutputFunctionCall { + id: "fc_1".to_string(), + arguments: serde_json::json!({}), + call_id: "call_1".to_string(), + name: "reply".to_string(), + status: + rig::providers::openai::responses_api::ToolStatus::InProgress, + }, + ), + }, + ), + }, + ); + + let tool_args_event = OpenAiResponsesStreamingCompletionChunk::Delta( + rig::providers::openai::responses_api::streaming::ItemChunk { + item_id: Some("fc_1".to_string()), + output_index: 0, + data: OpenAiResponsesItemChunkKind::FunctionCallArgsDelta( + rig::providers::openai::responses_api::streaming::DeltaTextChunkWithItemId { + item_id: "fc_1".to_string(), + content_index: 0, + sequence_number: 3, + delta: "{\"content\":\"hi\"}".to_string(), + }, + ), + }, + ); + + let text_events = process_openai_responses_stream_event(&text_event, &mut pending) + .expect("text event should parse"); + assert!(matches!( + text_events.first(), + Some(RawStreamingChoice::Message(text)) if text == "hello" + )); + + let tool_name_events = + process_openai_responses_stream_event(&tool_name_event, &mut pending) + .expect("tool name event should parse"); + assert!(matches!( + tool_name_events.first(), + Some(RawStreamingChoice::ToolCallDelta { id, content, .. }) + if id == "fc_1" && matches!(content, rig::streaming::ToolCallDeltaContent::Name(name) if name == "reply") + )); + + let tool_arg_events = process_openai_responses_stream_event(&tool_args_event, &mut pending) + .expect("tool args event should parse"); + assert!(matches!( + tool_arg_events.first(), + Some(RawStreamingChoice::ToolCallDelta { id, content, .. }) + if id == "fc_1" && matches!(content, rig::streaming::ToolCallDeltaContent::Delta(delta) if delta == "{\"content\":\"hi\"}") + )); + } + #[test] fn parse_openai_chat_sse_response_merges_multiline_data_blocks() { let sse = concat!( From b9dd1dfceec4a8184dbc0398ff4e092aca320ff6 Mon Sep 17 00:00:00 2001 From: James Pine Date: Wed, 25 Mar 2026 18:41:07 -0700 Subject: [PATCH 03/20] design doc --- desktop/src-tauri/Cargo.lock | 86 ++ desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/gen/schemas/acl-manifests.json | 2 +- .../src-tauri/gen/schemas/desktop-schema.json | 66 ++ .../src-tauri/gen/schemas/macOS-schema.json | 66 ++ docs/design-docs/conversation-settings.md | 1056 +++++++++++++++++ 6 files changed, 1276 insertions(+), 1 deletion(-) create mode 100644 docs/design-docs/conversation-settings.md diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 45bbcf71f..995d6b790 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1028,6 +1028,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1160,6 +1170,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1749,6 +1777,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -2769,6 +2803,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3227,6 +3274,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-global-shortcut", "tauri-plugin-shell", "tracing", "tracing-subscriber", @@ -3545,6 +3593,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" @@ -5060,6 +5123,29 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 9c5943e05..477d4c2b2 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -9,6 +9,7 @@ path = "src/main.rs" [dependencies] tauri = { version = "2", features = ["macos-private-api"] } +tauri-plugin-global-shortcut = "2" tauri-plugin-shell = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/desktop/src-tauri/gen/schemas/acl-manifests.json b/desktop/src-tauri/gen/schemas/acl-manifests.json index 86cdb1f5f..96d86d645 100644 --- a/desktop/src-tauri/gen/schemas/acl-manifests.json +++ b/desktop/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file diff --git a/desktop/src-tauri/gen/schemas/desktop-schema.json b/desktop/src-tauri/gen/schemas/desktop-schema.json index f827fe175..2cb2c2a88 100644 --- a/desktop/src-tauri/gen/schemas/desktop-schema.json +++ b/desktop/src-tauri/gen/schemas/desktop-schema.json @@ -2354,6 +2354,72 @@ "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, + { + "description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n", + "type": "string", + "const": "global-shortcut:default", + "markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n" + }, + { + "description": "Enables the is_registered command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-is-registered", + "markdownDescription": "Enables the is_registered command without any pre-configured scope." + }, + { + "description": "Enables the register command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-register", + "markdownDescription": "Enables the register command without any pre-configured scope." + }, + { + "description": "Enables the register_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-register-all", + "markdownDescription": "Enables the register_all command without any pre-configured scope." + }, + { + "description": "Enables the unregister command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-unregister", + "markdownDescription": "Enables the unregister command without any pre-configured scope." + }, + { + "description": "Enables the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-unregister-all", + "markdownDescription": "Enables the unregister_all command without any pre-configured scope." + }, + { + "description": "Denies the is_registered command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-is-registered", + "markdownDescription": "Denies the is_registered command without any pre-configured scope." + }, + { + "description": "Denies the register command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-register", + "markdownDescription": "Denies the register command without any pre-configured scope." + }, + { + "description": "Denies the register_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-register-all", + "markdownDescription": "Denies the register_all command without any pre-configured scope." + }, + { + "description": "Denies the unregister command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-unregister", + "markdownDescription": "Denies the unregister command without any pre-configured scope." + }, + { + "description": "Denies the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-unregister-all", + "markdownDescription": "Denies the unregister_all command without any pre-configured scope." + }, { "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "type": "string", diff --git a/desktop/src-tauri/gen/schemas/macOS-schema.json b/desktop/src-tauri/gen/schemas/macOS-schema.json index f827fe175..2cb2c2a88 100644 --- a/desktop/src-tauri/gen/schemas/macOS-schema.json +++ b/desktop/src-tauri/gen/schemas/macOS-schema.json @@ -2354,6 +2354,72 @@ "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, + { + "description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n", + "type": "string", + "const": "global-shortcut:default", + "markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n" + }, + { + "description": "Enables the is_registered command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-is-registered", + "markdownDescription": "Enables the is_registered command without any pre-configured scope." + }, + { + "description": "Enables the register command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-register", + "markdownDescription": "Enables the register command without any pre-configured scope." + }, + { + "description": "Enables the register_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-register-all", + "markdownDescription": "Enables the register_all command without any pre-configured scope." + }, + { + "description": "Enables the unregister command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-unregister", + "markdownDescription": "Enables the unregister command without any pre-configured scope." + }, + { + "description": "Enables the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-unregister-all", + "markdownDescription": "Enables the unregister_all command without any pre-configured scope." + }, + { + "description": "Denies the is_registered command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-is-registered", + "markdownDescription": "Denies the is_registered command without any pre-configured scope." + }, + { + "description": "Denies the register command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-register", + "markdownDescription": "Denies the register command without any pre-configured scope." + }, + { + "description": "Denies the register_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-register-all", + "markdownDescription": "Denies the register_all command without any pre-configured scope." + }, + { + "description": "Denies the unregister command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-unregister", + "markdownDescription": "Denies the unregister command without any pre-configured scope." + }, + { + "description": "Denies the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-unregister-all", + "markdownDescription": "Denies the unregister_all command without any pre-configured scope." + }, { "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "type": "string", diff --git a/docs/design-docs/conversation-settings.md b/docs/design-docs/conversation-settings.md new file mode 100644 index 000000000..4eba5f71e --- /dev/null +++ b/docs/design-docs/conversation-settings.md @@ -0,0 +1,1056 @@ +# Conversation & Channel Settings — Unifying Configuration and Sunsetting Cortex Chat + +**Status:** Draft +**Date:** 2026-03-25 +**Context:** The Spacedrive Spacebot interface is gaining multi-conversation support. The existing split between channels (no tools, has personality) and cortex chat (all tools, no personality) creates UX confusion. This doc proposes a unified per-conversation settings model that absorbs cortex chat's capabilities into normal conversations. It also defines worker context settings and renames "webchat" to "portal" throughout the codebase. + +--- + +## Problem + +There are four problems converging: + +### 1. Cortex Chat is a UX Dead End + +Cortex chat exists because channels deliberately lack tools — they delegate to branches and workers. When a user wants to configure something, query memories, or run shell commands directly, they go to cortex chat. But from a user's perspective, "cortex" is an unexplained internal concept. Why is there a separate chat? Why does it behave differently? Why can't I just tell my agent to do this in our normal conversation? + +Cortex chat is one session per agent. It has no personality. It gets a different system prompt. It persists to a separate table (`cortex_chat_messages`). None of this is visible or meaningful to the user — it's implementation leaking into product. + +### 2. No Per-Conversation Configuration + +The portal conversation feature just landed. Users can now create multiple conversations. But every conversation behaves identically — same model, same tools, same memory injection. There is no way to say "this conversation uses Opus" or "this conversation doesn't need memory context" or "let me use tools directly here." + +The model selector in the Spacedrive UI is decorative — Spacebot ignores it because models are configured per process type in TOML, not per conversation. + +### 3. Channel Configuration is Agent-Global + +Channels (Discord, Slack, etc.) have `require_mention` on bindings and `listen_only_mode` in config. But the model, memory injection, and tool access are all agent-level. You can't say "this Discord channel uses Haiku" or "this Slack channel doesn't need working memory." All channels for an agent share the same routing config. + +### 4. Workers Are Context-Starved + +Workers receive only a task description and a static system prompt. No conversation history, no knowledge synthesis, no working memory. The channel must pack everything the worker needs into the task string. This is limiting — a worker can't understand the broader conversation context, can't leverage memory the agent has built up, and can't see what led to the task being created. + +Branches, by contrast, get the full channel history clone plus memory tools. The gap between branch context richness and worker context poverty is entirely hardcoded. There's no way to say "this worker should get conversation history" or "this worker should get the agent's memory context." + +--- + +## What Exists Today + +### Configuration Hierarchy (Current) + +``` +Agent Config (TOML) +├── routing: { channel, branch, worker, compactor, cortex } +│ ├── task_overrides: { "coding" → model } +│ └── fallbacks: { model → [fallback_models] } +├── memory_persistence: { enabled, message_interval } +├── working_memory: { enabled, context_token_budget, ... } +├── cortex: { knowledge_synthesis (change-driven), maintenance, ... } +├── channel_config: { listen_only_mode, save_attachments } +└── bindings[]: { channel, require_mention, ... } +``` + +### Tool Access (Current) + +| Context | Memory Tools | Execution Tools | Delegation Tools | Platform Tools | +|---------|-------------|-----------------|------------------|----------------| +| Channel | No | No | Yes (branch, spawn_worker, route) | Yes (reply, react, skip) | +| Branch | Yes (recall, save, delete) | No | Yes (spawn_worker) | No | +| Worker | No | Yes (shell, file_*, browser) | No | No | +| Cortex Chat | Yes | Yes | Yes (spawn_worker) | No | + +Cortex chat is the only context that combines memory tools + execution tools + worker spawning. That's why it exists — it's the "power mode." + +### Memory Injection (Current) + +The memory system has three layers that get injected into prompts: + +1. **Knowledge Synthesis** (replaced the old "bulletin") — LLM-curated briefing synthesized from the memory graph (decisions, preferences, goals, high-importance facts, active tasks). Change-driven regeneration via dirty-flag + debounce. Primary memory context. +2. **Working Memory** — Temporal event log. Today's events in detail, yesterday compressed to summary, earlier this week as a paragraph. Real-time, rendered on every prompt. +3. **Channel Activity Map** — Cross-channel visibility. What's happening in other channels. Real-time. + +The old bulletin is kept only as a startup fallback if knowledge synthesis fails. The prompt template prefers `knowledge_synthesis`, falls back to `memory_bulletin`, and renders `working_memory` separately. + +| Context | Knowledge Synthesis | Working Memory (Temporal) | Channel Activity Map | Tool-Based Recall | +|---------|-------------------|--------------------------|---------------------|-------------------| +| Channel | Yes | Yes (if enabled) | Yes (if enabled) | No (delegates to branch) | +| Branch | Yes | No | No | Yes | +| Worker | No | No | No | No | +| Cortex Chat | Yes | No | No | Yes | + +### Worker Context (Current) + +When a worker is spawned today: + +1. **System prompt** — Rendered from `worker.md.j2` with filesystem paths, sandbox config, tool secrets, available skills. No memory, no conversation context. +2. **Task description** — The only user-facing input. Becomes the first prompt. Channel must front-load all necessary context into this string. +3. **Skills** — Available skills listed with suggested skills flagged by the channel. +4. **Tools** — shell, file_read/write/edit/list, set_status, read_skill, task_update, optionally browser/web_search/MCP. +5. **No history** — Fire-and-forget workers start with empty history. Interactive workers only get history from prior turns in their own session. +6. **No memory** — No knowledge synthesis, no working memory, no memory tools. + +Compare to branches, which get a **full clone of the channel history** plus memory tools (recall, save, delete) and knowledge synthesis via system prompt. + +--- + +## Proposed Design + +### Core Idea: Conversation Modes + +Every conversation (portal or platform channel) gets a **mode** that determines its behavior. The mode is a small set of flags. Agent config provides defaults. Per-channel and per-conversation overrides are optional. + +### The Settings + +``` +ConversationSettings { + model: Option, // LLM override for this conversation's channel process + memory: MemoryMode, // How memory is used + delegation: DelegationMode, // How tools work + worker_context: WorkerContextMode, // What context workers receive +} +``` + +#### `model: Option` + +When set, overrides `routing.channel` for the channel process in this conversation. Branches and workers spawned from this conversation also inherit the override (overriding `routing.branch` and `routing.worker` respectively). + +When `None`, falls through to agent-level routing config as today. + +This replaces the non-functional model selector in the UI with something that actually works. + +**Implementation:** `routing.resolve()` gains an optional `override_model` parameter. When present, it returns the override instead of the process-type default. Task-type overrides (`task_overrides`) still take priority over per-conversation overrides for workers/branches, because task-type routing is a quality-of-output concern, not a user preference. + +#### `memory: MemoryMode` + +```rust +enum MemoryMode { + Full, // Knowledge synthesis + working memory + channel activity map + auto-persistence (current default) + Ambient, // All memory context injected, but no auto-persistence and no memory tools + Off, // No memory context injected, no memory tools, no persistence +} +``` + +- **Full** — The agent knows everything it normally knows. All three memory layers injected (knowledge synthesis, working memory, channel activity map). Memory persistence branches fire. This is today's default. +- **Ambient** — The agent gets all memory context (knowledge synthesis, working memory, channel activity map) but doesn't write new memories from this conversation. No auto-persistence branches. Useful for throwaway chats or sensitive topics. +- **Off** — Raw mode. No memory context injected — no knowledge synthesis, no working memory, no channel activity map. The conversation is stateless relative to the agent's memory. System prompt still includes identity/personality. This is the spirit of what cortex chat provides today. + +#### `delegation: DelegationMode` + +```rust +enum DelegationMode { + Standard, // Current channel behavior: must delegate via branch/worker + Direct, // Channel gets all tools: memory, shell, file, browser, + delegation tools +} +``` + +- **Standard** — The channel has `reply`, `branch`, `spawn_worker`, `route`, `cancel`, `skip`, `react`. It delegates heavy work. It stays responsive. This is today's default and the right mode for ongoing async conversations. +- **Direct** — The channel gets the full tool set: memory tools, shell, file operations, browser, web search, **plus** delegation tools. The agent can choose to do things directly or spawn workers. This is what cortex chat provides today — the "power user" mode. + +**Why not just "tools on/off"?** Because turning tools "off" for a channel is meaningless — it already barely has tools. The meaningful toggle is whether the channel can act directly or must delegate. "Direct" mode is strictly additive. + +#### `worker_context: WorkerContextMode` + +```rust +struct WorkerContextMode { + history: WorkerHistoryMode, // What conversation context the worker sees + memory: WorkerMemoryMode, // What memory context the worker gets +} + +enum WorkerHistoryMode { + None, // No conversation history (current default) + Summary, // LLM-generated summary of recent conversation context + Recent(u32), // Last N messages from the parent conversation, injected into system prompt + Full, // Full conversation history clone (branch-style) +} + +enum WorkerMemoryMode { + None, // No memory context (current default) + Ambient, // Knowledge synthesis + working memory injected into system prompt (read-only) + Tools, // Ambient context + memory_recall tool (can search but not write) + Full, // Ambient context + full memory tools (recall, save, delete) — branch-level access +} +``` + +This is the most important new axis. Today, the gap between "worker" and "branch" is a cliff — branches see everything, workers see nothing. Worker context settings turn this into a spectrum. + +**`WorkerHistoryMode` options:** + +- **None** — Current behavior. Worker sees only the task description. Channel must front-load all context into the task string. Cheapest, most isolated, fastest to start. +- **Summary** — Before spawning the worker, the channel generates a brief summary of the relevant conversation context and prepends it to the worker's system prompt. Cost: one extra LLM call at spawn time (can use a cheap model). Benefit: worker understands why the task exists without seeing the full transcript. +- **Recent(N)** — Last N conversation messages injected into the worker's system prompt as context (similar to how cortex chat injects channel context). No extra LLM call. Worker sees recent exchanges but not the full history. Good middle ground. +- **Full** — Worker receives a clone of the full channel history, same as a branch. Most expensive in tokens, but gives the worker complete conversational context. + +**`WorkerMemoryMode` options:** + +- **None** — Current behavior. No memory context, no memory tools. Worker is a pure executor. +- **Ambient** — Knowledge synthesis + working memory injected into the worker's system prompt. Worker has ambient awareness of what the agent knows — identity, facts, preferences, decisions, recent activity — but can't search or write. Read-only, no extra tools, minimal cost. +- **Tools** — Ambient context + `memory_recall` tool. Worker can actively search the agent's memory when it needs context. Cannot save new memories (that's still a branch concern). This is the "informed executor" mode. +- **Full** — Ambient context + full memory tools (recall, save, delete). Worker operates at branch-level memory access. Use sparingly — this blurs the worker/branch boundary. + +**Why this matters for the Spacedrive interface:** When a user starts a "Hands-on" conversation in Spacedrive (Direct delegation, memory off), the workers spawned from that conversation should probably get more context than workers spawned from a Discord channel with 50 participants. The user is sitting right there, interacting directly — the workers are extensions of that focused session. + +**Implementation:** Worker context settings are resolved at spawn time in `spawn_worker_from_state()`: + +```rust +fn spawn_worker_with_context( + state: &ChannelState, + task: &str, + settings: &ResolvedConversationSettings, +) { + let mut system_prompt = render_worker_prompt(...); + + // Inject memory context + match settings.worker_context.memory { + WorkerMemoryMode::None => { /* current behavior */ } + WorkerMemoryMode::Ambient | WorkerMemoryMode::Tools | WorkerMemoryMode::Full => { + // Inject knowledge synthesis (primary memory context) + let knowledge = state.deps.runtime_config.knowledge_synthesis(); + system_prompt.push_str(&format!("\n\n## Knowledge Context\n{knowledge}")); + // Inject working memory (temporal context) + if let Some(wm) = render_working_memory(&state.deps.working_memory_store, &config).await { + system_prompt.push_str(&format!("\n\n{wm}")); + } + } + } + + // Build history + let history = match settings.worker_context.history { + WorkerHistoryMode::None => vec![], + WorkerHistoryMode::Summary => { + let summary = generate_context_summary(state, task).await; + // Inject as a system message at the start + vec![Message::system(format!("[Conversation context]: {summary}"))] + } + WorkerHistoryMode::Recent(n) => { + let h = state.history.read().await; + h.iter().rev().take(n as usize).rev().cloned().collect() + } + WorkerHistoryMode::Full => { + let h = state.history.read().await; + h.clone() + } + }; + + // Build tool server + let mut tool_server = create_worker_tool_server(...); + match settings.worker_context.memory { + WorkerMemoryMode::Tools => { + tool_server.add(MemoryRecallTool::new(...)); + } + WorkerMemoryMode::Full => { + tool_server.add(MemoryRecallTool::new(...)); + tool_server.add(MemorySaveTool::new(...)); + tool_server.add(MemoryDeleteTool::new(...)); + } + _ => {} + } + + Worker::new(..., task, system_prompt, history, tool_server) +} +``` + +### Configuration Hierarchy (Proposed) + +``` +Agent Config (TOML) — defaults for all conversations +├── defaults.conversation_settings: +│ ├── model: None (use routing config) +│ ├── memory: Full +│ ├── delegation: Standard +│ └── worker_context: { history: None, memory: None } +│ +├── Per-Channel Override (TOML or runtime API) +│ └── channels["discord:guild:channel"].settings: +│ ├── model: "anthropic/claude-haiku-4.5" +│ ├── memory: Ambient +│ ├── delegation: Standard +│ └── worker_context: { history: None, memory: Ambient } +│ +└── Per-Conversation Override (runtime, stored in DB) + └── portal_conversations.settings: + ├── model: "anthropic/claude-opus-4" + ├── memory: Off + ├── delegation: Direct + └── worker_context: { history: Recent(20), memory: Tools } +``` + +**Resolution order:** Per-conversation > Per-channel > Agent default > System default + +For portal conversations, "per-channel" doesn't apply (there's no binding). For platform channels, "per-conversation" doesn't apply (Discord threads aren't separate conversations in this model — they share the channel's settings). + +### Sunsetting Cortex Chat + +With these settings, cortex chat becomes a conversation preset, not a separate system: + +**"Cortex mode" conversation** = `{ model: None, memory: Off, delegation: Direct, worker_context: { history: Recent(20), memory: Tools } }` + +The user starts a new conversation, toggles delegation to Direct, turns memory off, and they have cortex chat. No separate concept, no separate table, no separate API. + +**Migration path:** + +1. Add `ConversationSettings` to `portal_conversations` table (JSON column). +2. Add `ConversationSettings` to channel config (TOML + runtime). +3. Wire settings into channel creation — read settings when spawning a channel process. +4. In `Direct` mode, use `create_cortex_chat_tool_server()` (or a new unified factory) instead of `add_channel_tools()`. +5. In `Off` memory mode, skip knowledge synthesis, working memory, and channel activity map injection in the system prompt. +6. Deprecate `/api/cortex-chat/*` endpoints. Keep them working but add a sunset header. +7. Remove cortex chat from the UI. Replace with conversation presets. + +**What about cortex chat's channel context injection?** Today, opening cortex chat on a channel page injects the last 50 messages from that channel. This is useful. In the new model, a "Direct" conversation can have a `channel_context` field — "I'm looking at Discord #general" — and the system prompt injects that context. This is an optional enhancement, not a blocker. + +**What about cortex chat's admin-only access?** Cortex chat is implicitly admin-only because it's in the dashboard. In the portal/Spacedrive context, all users are the owner. If multi-user access control becomes needed, it should be a separate authorization layer, not a property of conversation mode. + +--- + +## Schema Changes + +### `portal_conversations` table (renamed from `webchat_conversations`) + +Add a `settings` JSON column: + +```sql +ALTER TABLE portal_conversations ADD COLUMN settings TEXT; +-- JSON: {"model": "...", "memory": "full|ambient|off", "delegation": "standard|direct", +-- "worker_context": {"history": "none|summary|recent:20|full", "memory": "none|ambient|tools|full"}} +-- NULL means "use defaults" +``` + +### `channels` table + +Add a `settings` JSON column: + +```sql +ALTER TABLE channels ADD COLUMN settings TEXT; +-- Same schema as above. NULL means "use agent defaults" +``` + +### Config TOML + +```toml +[defaults.conversation_settings] +memory = "full" # "full", "ambient", "off" +delegation = "standard" # "standard", "direct" +# model omitted = use routing config + +[defaults.conversation_settings.worker_context] +history = "none" # "none", "summary", "recent:20", "full" +memory = "none" # "none", "ambient", "tools", "full" +``` + +Per-channel overrides in bindings: + +```toml +[[bindings]] +agent_id = "star" +channel = "discord" +guild_id = "123456" +channel_ids = ["789"] + +[bindings.settings] +model = "anthropic/claude-haiku-4.5" +memory = "ambient" +delegation = "standard" + +[bindings.settings.worker_context] +history = "none" +memory = "ambient" +``` + +--- + +## API Changes + +### Portal Endpoints (renamed from `/webchat/*`) + +**POST /portal/conversations** — Add optional `settings` field: + +```json +{ + "agent_id": "star", + "title": "Quick coding help", + "settings": { + "model": "anthropic/claude-opus-4", + "memory": "off", + "delegation": "direct", + "worker_context": { + "history": "recent:20", + "memory": "tools" + } + } +} +``` + +**PUT /portal/conversations/{id}** — Allow updating `settings`: + +```json +{ + "agent_id": "star", + "settings": { + "memory": "ambient" + } +} +``` + +Settings changes take effect on the **next message** in the conversation. In-flight channel processes are not interrupted. + +**GET /portal/conversations** — Return `settings` in response (null = defaults). + +**Full endpoint rename:** + +| Old | New | +|-----|-----| +| `POST /webchat/send` | `POST /portal/send` | +| `GET /webchat/history` | `GET /portal/history` | +| `GET /webchat/conversations` | `GET /portal/conversations` | +| `POST /webchat/conversations` | `POST /portal/conversations` | +| `PUT /webchat/conversations/{id}` | `PUT /portal/conversations/{id}` | +| `DELETE /webchat/conversations/{id}` | `DELETE /portal/conversations/{id}` | + +### New Endpoint: GET /api/conversation-defaults + +Returns the resolved default settings for new conversations: + +```json +{ + "model": "anthropic/claude-sonnet-4", + "memory": "full", + "delegation": "standard", + "worker_context": { + "history": "none", + "memory": "none" + }, + "available_models": ["anthropic/claude-sonnet-4", "anthropic/claude-opus-4", "anthropic/claude-haiku-4.5"], + "memory_modes": ["full", "ambient", "off"], + "delegation_modes": ["standard", "direct"], + "worker_history_modes": ["none", "summary", "recent", "full"], + "worker_memory_modes": ["none", "ambient", "tools", "full"] +} +``` + +This replaces the hardcoded model list in the Spacedrive UI. + +### Channel Settings Endpoint + +**PUT /api/channels/{id}/settings** — Update per-channel settings: + +```json +{ + "agent_id": "star", + "settings": { + "model": "anthropic/claude-haiku-4.5", + "memory": "ambient", + "worker_context": { + "memory": "ambient" + } + } +} +``` + +**GET /api/channels/{id}** — Return `settings` in channel info. + +--- + +## Implementation in the Channel Process + +### Channel Creation Changes + +When a channel is created (`main.rs` routing logic), resolve settings: + +```rust +fn resolve_conversation_settings( + agent_config: &AgentConfig, + channel_settings: Option<&ConversationSettings>, + conversation_settings: Option<&ConversationSettings>, +) -> ResolvedConversationSettings { + let defaults = &agent_config.conversation_settings; + + // Per-conversation > Per-channel > Agent default + ResolvedConversationSettings { + model: conversation_settings.and_then(|s| s.model.clone()) + .or_else(|| channel_settings.and_then(|s| s.model.clone())) + .or_else(|| defaults.model.clone()), + memory: conversation_settings.map(|s| s.memory) + .or_else(|| channel_settings.map(|s| s.memory)) + .unwrap_or(defaults.memory), + delegation: conversation_settings.map(|s| s.delegation) + .or_else(|| channel_settings.map(|s| s.delegation)) + .unwrap_or(defaults.delegation), + worker_context: conversation_settings.and_then(|s| s.worker_context.clone()) + .or_else(|| channel_settings.and_then(|s| s.worker_context.clone())) + .unwrap_or_else(|| defaults.worker_context.clone()), + } +} +``` + +### Tool Server Selection + +In `run_agent_turn()`, check delegation mode: + +```rust +match self.resolved_settings.delegation { + DelegationMode::Standard => { + // Current behavior: add_channel_tools() + self.tool_server.add_channel_tools(...); + } + DelegationMode::Direct => { + // Full tool access: memory + execution + delegation + // Use a new factory or merge channel + cortex tool sets + self.tool_server.add_direct_mode_tools(...); + } +} +``` + +### Memory Injection + +In `build_system_prompt()`, check memory mode: + +```rust +match self.resolved_settings.memory { + MemoryMode::Full => { + // Inject all memory layers + enable persistence branches + prompt.set("knowledge_synthesis", &knowledge_synthesis); + prompt.set("working_memory", &working_memory_context); + prompt.set("channel_activity_map", &channel_activity_map); + } + MemoryMode::Ambient => { + // Inject all memory layers, but no persistence + prompt.set("knowledge_synthesis", &knowledge_synthesis); + prompt.set("working_memory", &working_memory_context); + prompt.set("channel_activity_map", &channel_activity_map); + // Skip scheduling persistence branches + } + MemoryMode::Off => { + // No memory context at all + prompt.set("knowledge_synthesis", ""); + prompt.set("working_memory", ""); + prompt.set("channel_activity_map", ""); + } +} +``` + +### Model Override + +In the LLM call, check for model override: + +```rust +let model_name = match &self.resolved_settings.model { + Some(override_model) => override_model.clone(), + None => routing.resolve(ProcessType::Channel, None).to_string(), +}; +``` + +For spawned workers and branches, propagate the override: + +```rust +// In spawn_worker +let worker_model = match &self.resolved_settings.model { + Some(override_model) => override_model.clone(), + None => routing.resolve(ProcessType::Worker, task_type).to_string(), +}; +``` + +--- + +## Interface Changes — Separation of Concerns + +There are three interface surfaces that interact with this feature. The critical constraint is: **do not break the legacy Spacebot dashboard while building the Spacedrive experience.** + +### The Three Surfaces + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Spacebot Server (Rust) │ +│ ├── /api/portal/* — portal conversation endpoints │ +│ ├── /api/channels/* — channel endpoints │ +│ ├── /api/conversation-defaults — new settings metadata │ +│ └── /api/cortex-chat/* — cortex chat (deprecated later) │ +├─────────────────────────────────────────────────────────────────┤ +│ @spacebot/api-client (shared TS package) │ +│ ├── portalSend(), portalHistory(), etc. │ +│ ├── createPortalConversation({ settings? }) ← new field │ +│ ├── updatePortalConversation({ settings? }) ← new field │ +│ ├── getConversationDefaults() ← new method │ +│ └── All existing methods remain unchanged │ +├──────────────────────┬──────────────────────────────────────────┤ +│ Spacebot Dashboard │ Spacedrive Interface │ +│ (spacebot/interface)│ (spacedrive/.../Spacebot/) │ +│ │ │ +│ ✗ NO changes needed │ ✓ All new settings UI goes here │ +│ - AgentConfig keeps │ - SpacebotContext gets settings state │ +│ its model routing │ - ChatComposer gets settings controls │ +│ - CortexChatPanel │ - ConversationScreen shows active mode │ +│ keeps working │ - New conversation gets settings picker │ +│ - ChannelDetail │ - Presets for common configurations │ +│ unchanged │ │ +│ - Settings page │ │ +│ unchanged │ │ +└──────────────────────┴──────────────────────────────────────────┘ +``` + +### Why the Dashboard Doesn't Change + +The Spacebot dashboard (`spacebot/interface/`) is a developer-facing admin control plane. It already has: + +- **Agent-level model routing** in `AgentConfig.tsx` — 6 model slots (channel, branch, worker, compactor, cortex, voice) with `ModelSelect` component and adaptive thinking controls. These are agent-wide defaults. They keep working. The new per-conversation settings are *overrides* on top of these defaults. +- **Cortex chat** in `CortexChatPanel.tsx` — used in the Cortex route and Channel Detail views. This keeps working until Phase 4 sunset. No dashboard changes needed for deprecation — we just stop rendering it later. +- **Channel management** in `ChannelDetail.tsx` — read-only channel timeline with cortex panel. No per-channel settings UI exists today. Adding per-channel settings to the dashboard is a *future* enhancement, not a blocker. +- **Global settings** in `Settings.tsx` (3000+ lines) — provider credentials, channel bindings, server config. None of this changes. + +The dashboard consumes the same `@spacebot/api-client` package, but it never calls `createPortalConversation()` with settings because its `WebChatPanel` component creates conversations without settings (defaulting to `null`). The new `settings` field is optional — `null` means "use agent defaults." Existing code that doesn't pass settings continues working identically. + +### What Changes in @spacebot/api-client + +All changes are **additive**. No breaking changes to existing methods. + +**Renamed methods (Phase 0 — portal rename):** + +```typescript +// Old (removed after rename) // New +apiClient.webchatSend(...) → apiClient.portalSend(...) +apiClient.webchatHistory(...) → apiClient.portalHistory(...) +apiClient.listWebchatConversations() → apiClient.listPortalConversations() +apiClient.createWebchatConversation()→ apiClient.createPortalConversation() +apiClient.updateWebchatConversation()→ apiClient.updatePortalConversation() +apiClient.deleteWebchatConversation()→ apiClient.deletePortalConversation() +``` + +The rename touches both surfaces simultaneously — the Spacebot dashboard's `WebChatPanel` and `AgentChat.tsx` import from api-client too. This is a coordinated rename across both consumers. It's mechanical but must be done in one pass. + +**New methods (Phase 1):** + +```typescript +// Fetch resolved defaults + available options for settings UI +apiClient.getConversationDefaults(agentId: string): Promise + +// Types +interface ConversationDefaultsResponse { + model: string; // Current default model name + memory: MemoryMode; // Current default memory mode + delegation: DelegationMode; // Current default delegation mode + worker_context: WorkerContextMode; // Current default worker context + available_models: ModelOption[]; // All available models with metadata + memory_modes: MemoryMode[]; + delegation_modes: DelegationMode[]; + worker_history_modes: string[]; + worker_memory_modes: string[]; +} + +interface ModelOption { + id: string; // e.g. "anthropic/claude-sonnet-4" + name: string; // e.g. "Claude Sonnet 4" + provider: string; // e.g. "anthropic" + context_window: number; + supports_tools: boolean; + supports_thinking: boolean; +} + +type MemoryMode = 'full' | 'ambient' | 'off'; +type DelegationMode = 'standard' | 'direct'; + +interface WorkerContextMode { + history: string; // "none" | "summary" | "recent:N" | "full" + memory: string; // "none" | "ambient" | "tools" | "full" +} + +interface ConversationSettings { + model?: string | null; + memory?: MemoryMode; + delegation?: DelegationMode; + worker_context?: WorkerContextMode; +} +``` + +**Updated method signatures (additive):** + +```typescript +// createPortalConversation gains optional settings +apiClient.createPortalConversation(input: { + agentId: string; + title?: string | null; + settings?: ConversationSettings | null; // ← new, optional +}): Promise + +// updatePortalConversation gains optional settings +apiClient.updatePortalConversation(input: { + agentId: string; + sessionId: string; + title?: string | null; + archived?: boolean; + settings?: ConversationSettings | null; // ← new, optional +}): Promise + +// PortalConversationResponse and PortalConversationSummary gain settings field +interface PortalConversationResponse { + // ...existing fields... + settings: ConversationSettings | null; // ← new, nullable +} + +interface PortalConversationSummary { + // ...existing fields... + settings: ConversationSettings | null; // ← new, nullable +} +``` + +**Dashboard impact:** The dashboard's `WebChatPanel` calls `createWebchatConversation({ agentId, title })` (soon `createPortalConversation`). After the type update, `settings` is optional, so this call continues to work — settings defaults to `null`, which means "use agent defaults." The dashboard never needs to pass settings unless it wants to. The `settings` field appears in the response type but the dashboard's list rendering doesn't use it — it only shows title, preview, and timestamp. + +### What Changes in Spacedrive Interface + +All new settings UI lives in `spacedrive/packages/interface/src/Spacebot/`. Here's the file-by-file breakdown. + +#### `SpacebotContext.tsx` — State & Data + +**Remove hardcoded data:** +```typescript +// DELETE these static arrays +export const modelOptions = ['Claude 3.7 Sonnet', 'GPT-5', 'Qwen 2.5 72B']; +export const projectOptions = ['Spacedrive v3', 'Spacebot Runtime', 'Hosted Platform']; +``` + +**Add settings state:** +```typescript +// New state in SpacebotContext +conversationDefaults: ConversationDefaultsResponse | null; // from API +conversationSettings: ConversationSettings; // current selection +setConversationSettings: (s: Partial) => void; +``` + +**Add defaults query:** +```typescript +// New TanStack Query — fetches available models, modes, defaults +const defaultsQuery = useQuery({ + queryKey: ['spacebot', 'conversation-defaults', selectedAgent], + queryFn: () => apiClient.getConversationDefaults(selectedAgent), + staleTime: 60_000, // Defaults don't change often +}); +``` + +**Update conversation creation:** +```typescript +// handleSendMessage — when creating a new conversation, pass settings +const conversation = await apiClient.createPortalConversation({ + agentId: selectedAgent, + title: null, + settings: hasNonDefaultSettings(conversationSettings) + ? conversationSettings + : null, // Don't store if all defaults +}); +``` + +**Add settings from conversation response:** +```typescript +// When loading a conversation, read its settings +const activeConversationSettings: ConversationSettings | null = + activeConversation?.settings ?? null; + +// Resolved settings: conversation-specific if set, else defaults +const resolvedSettings: ConversationSettings = { + model: activeConversationSettings?.model ?? conversationDefaults?.model ?? null, + memory: activeConversationSettings?.memory ?? conversationDefaults?.memory ?? 'full', + delegation: activeConversationSettings?.delegation ?? conversationDefaults?.delegation ?? 'standard', + worker_context: activeConversationSettings?.worker_context ?? conversationDefaults?.worker_context ?? { history: 'none', memory: 'none' }, +}; +``` + +**Expose through context:** +```typescript +// Added to SpacebotContextType +conversationDefaults: ConversationDefaultsResponse | null; +resolvedSettings: ConversationSettings; +updateConversationSettings: (patch: Partial) => Promise; +``` + +The `updateConversationSettings` mutation calls `apiClient.updatePortalConversation()` with the new settings and invalidates the conversation query. + +#### `ChatComposer.tsx` — Settings Controls + +The composer currently has project and model selector popovers that are non-functional. Replace them with real settings controls. + +**Replace model selector:** +``` +Before: Static dropdown cycling through ['Claude 3.7 Sonnet', 'GPT-5', 'Qwen 2.5 72B'] +After: Real model dropdown populated from conversationDefaults.available_models + Selecting a model calls updateConversationSettings({ model: selectedId }) +``` + +**Replace project selector with memory/delegation toggles:** +``` +Before: Static dropdown cycling through project names (unused) +After: Two compact selectors: + Memory: [On ▾] / [Context Only ▾] / [Off ▾] + Mode: [Standard ▾] / [Direct ▾] +``` + +The composer bottom bar becomes: + +``` +┌──────────────────────────────────────────────────────┐ +│ [textarea input area] │ +│ │ +│ [Model: Sonnet 4 ▾] [Memory: On ▾] [Mode ▾] [⎈] │ +└──────────────────────────────────────────────────────┘ + ^ + settings gear → opens + full settings panel + with worker context +``` + +The `[⎈]` gear icon opens a popover/panel for advanced settings (worker context, presets). The three inline selectors cover the most common toggles without requiring the panel. + +**New component: `ConversationSettingsPanel.tsx`** + +``` +spacedrive/packages/interface/src/Spacebot/ConversationSettingsPanel.tsx +``` + +A panel (popover or slide-out) with: + +``` +Model +[Sonnet 4 ▾] — searchable dropdown from available_models + +Memory +( ) On — Full memory context, auto-persistence +( ) Context Only — Memory context visible, no writes +( ) Off — No memory context + +Mode +( ) Standard — Delegates to workers and branches +( ) Direct — Full tool access (shell, files, memory) + +Worker Context (collapsed by default) + History: [None ▾] — None / Summary / Recent (20) / Full + Memory: [None ▾] — None / Ambient / Tools / Full + +Presets +[Chat] [Focus] [Hands-on] [Quick] [Deep Work] +``` + +Presets are pill buttons that set all fields at once. Selecting one updates the form. User can then tweak individual fields. + +#### `ConversationScreen.tsx` — Active Settings Display + +Show the active settings inline below the conversation header: + +``` +Conversation Title +Sonnet 4 · Memory On · Standard [⎈] +───────────────────────────────────────────────────── +[messages...] +``` + +This is a single line of muted text showing the resolved settings. Clicking `[⎈]` opens the settings panel for editing. If all settings are defaults, this line can be hidden or show just the model name. + +When settings are `Direct` delegation, show a visual indicator (e.g., a subtle border color change or badge) so the user knows the conversation has tool access. + +#### `EmptyChatHero.tsx` — Preset Selection + +The empty chat hero currently says "Let's get to work, James." Update it to: + +1. Replace "James" with the actual user name (from library context or platform). +2. Show preset cards below the greeting: + +``` +Let's get to work, Jamie + +How would you like to work? + +[💬 Chat] [🔧 Hands-on] [⚡ Quick] +Normal Direct tools Fast & cheap +conversation replaces cortex throwaway +``` + +Selecting a preset sets `conversationSettings` in context and focuses the composer. The user's first message creates the conversation with those settings. + +#### `SpacebotLayout.tsx` — No Structural Changes + +The layout (sidebar + topbar + content area) doesn't change structurally. The sidebar conversation list could optionally show a small icon or badge indicating non-default settings (e.g., a wrench icon for Direct mode conversations), but this is polish, not required. + +#### New File: `ConversationSettingsPanel.tsx` + +``` +spacedrive/packages/interface/src/Spacebot/ConversationSettingsPanel.tsx +``` + +This is the only new file. It's a self-contained panel component that: +- Reads `conversationDefaults` and `resolvedSettings` from context +- Renders model dropdown, memory radio group, delegation radio group, worker context dropdowns +- Renders preset buttons +- Calls `updateConversationSettings()` on change +- Uses @sd/ui primitives (DropdownMenu, RadioGroup, Button) and semantic colors + +#### Routes — No New Routes + +No new routes needed. Settings are accessed from within existing conversation views via the settings panel, not from a separate page. The stub routes (Tasks, Memories, Autonomy, Schedule) are unrelated and don't change. + +### Conversation Presets + +Common configurations get named presets: + +| Preset | Model | Memory | Delegation | Worker History | Worker Memory | Use Case | +|--------|-------|--------|------------|----------------|---------------|----------| +| Chat | Default | On | Standard | None | None | Normal conversation | +| Focus | Default | Context Only | Standard | None | None | Sensitive topic, no memory writes | +| Hands-on | Default | Off | Direct | Recent(20) | Tools | Direct tool use, replaces cortex | +| Quick | Haiku | Off | Standard | None | None | Fast, cheap, throwaway | +| Deep Work | Opus | On | Standard | Summary | Ambient | Long-running complex tasks with rich worker context | + +Presets are UI sugar — they set the fields. Users can customize after selecting a preset. Presets are defined client-side in the Spacedrive interface, not stored on the server. + +### Channel Settings (Spacebot Dashboard — Future) + +For platform channels (Discord, Slack), per-channel settings can eventually be added to the Spacebot dashboard's `ChannelDetail.tsx`. This is **not part of the initial implementation** — it requires changes to the dashboard, which we're avoiding. The backend API (`PUT /api/channels/{id}/settings`) supports it, but the dashboard UI can be added later when the feature is proven in the Spacedrive interface. + +``` +Future: #general (Discord) +├── Model: [Sonnet 4 ▾] (override / use default) +├── Memory: [On ▾] +├── Mode: [Standard ▾] +├── Mention Only: [Yes/No] +└── [Advanced: Worker Settings] + ├── History: [None ▾] + └── Memory: [None ▾] +``` + +--- + +## Webchat → Portal Rename + +### Motivation + +"Webchat" and "portal" have been two names for the same thing since the beginning. The conversation ID format already uses `portal:chat:{agent_id}:{uuid}`. The adapter registers as `"webchat"` but the platform is extracted as `"portal"`. This inconsistency has been called out in `multi-agent-communication-graph.md` as needing resolution. + +The name **"portal"** wins because: +- It's already the canonical ID prefix +- It's product-facing (webchat is implementation) +- It describes what it is — a portal into the agent, usable from any surface (browser, Spacedrive, mobile) +- "Webchat" implies a web-only chat widget, which undersells what this is + +### Scope + +This rename touches: + +**Rust source (30 files):** +- 3 module files: `src/api/webchat.rs` → `src/api/portal.rs`, `src/messaging/webchat.rs` → `src/messaging/portal.rs`, `src/conversation/webchat.rs` → `src/conversation/portal.rs` +- 18 struct/type names: `WebChat*` → `Portal*` (e.g., `WebChatConversation` → `PortalConversation`, `WebChatAdapter` → `PortalAdapter`) +- 24 function names: `webchat_*` → `portal_*` +- 8 string literals: `"webchat"` → `"portal"` (adapter name, message source) +- Module declarations in `src/api.rs`, `src/conversation.rs`, `src/messaging.rs` +- References in `src/main.rs`, `src/api/state.rs`, `src/api/server.rs`, `src/tools.rs`, `src/config/types.rs` + +**SQL migration:** +- New migration: `ALTER TABLE webchat_conversations RENAME TO portal_conversations` +- Rename index: `idx_webchat_conversations_agent_updated` → `idx_portal_conversations_agent_updated` + +**TypeScript/React:** +- `interface/src/components/WebChatPanel.tsx` → `PortalChatPanel.tsx` +- `interface/src/hooks/useWebChat.ts` → `usePortal.ts` +- `packages/api-client/src/client.ts`: all `webchat*` methods → `portal*` +- Generated types in `interface/src/api/schema.d.ts` and `types.ts` +- Import references in `interface/src/routes/AgentChat.tsx` + +**API endpoints:** +- `/webchat/send` → `/portal/send` +- `/webchat/history` → `/portal/history` +- `/webchat/conversations` → `/portal/conversations` (CRUD) + +**OpenAPI tags:** `"webchat"` → `"portal"` + +**Documentation:** References in 7 design docs and README. + +### Migration Strategy + +1. **Rename Rust modules and types** — Mechanical rename. `WebChat` → `Portal` everywhere. +2. **Rename SQL table** — Single migration: `ALTER TABLE webchat_conversations RENAME TO portal_conversations`. SQLite supports this natively. +3. **Rename API routes** — Update route registration in `server.rs`. Add temporary redirects from old paths for any external consumers. +4. **Rename adapter** — `PortalAdapter::name()` returns `"portal"`. Update source literals. Remove the `"portal" => "webchat:{id}"` remapping hack in `tools.rs` since the names now match. +5. **Rename TypeScript** — Rename files, update imports, regenerate OpenAPI types. +6. **Conversation ID prefix stays `portal:chat:`** — Already correct. No data migration needed. +7. **Update design docs** — Search-and-replace in markdown files. + +This rename is safe to do in a single commit. The "webchat" name is internal — no external API consumers depend on it (the Spacedrive interface uses the `@spacebot/api-client` package which abstracts the endpoints). + +--- + +## What This Replaces + +| Today | After | +|-------|-------| +| Cortex chat (separate system) | "Direct" mode conversation | +| Cortex chat API (`/api/cortex-chat/*`) | Deprecated, then removed | +| `cortex_chat_messages` table | No longer written to | +| `CortexChatSession` struct | Removed | +| Hardcoded model selector (non-functional) | Per-conversation model override (functional) | +| Agent-global memory settings | Per-conversation memory mode | +| Agent-global tool access | Per-conversation delegation mode | +| Context-starved workers | Configurable worker context (history + memory) | +| "webchat" naming confusion | Unified "portal" naming | +| `webchat_conversations` table | `portal_conversations` table | + +--- + +## Migration + +### Phase 0 — Portal Rename + +Rename webchat → portal throughout the codebase. This is a prerequisite because it's purely mechanical and removes naming confusion before the settings work begins. + +1. Rename Rust modules, structs, functions, string literals. +2. Add SQL migration to rename table and index. +3. Rename TypeScript files, components, hooks, API methods. +4. Update API route paths. +5. Remove `portal → webchat` remapping hack in tools.rs. +6. Update documentation. + +### Phase 1 — Add Settings Infrastructure + +1. Add `settings` column to `portal_conversations` and `channels` tables. +2. Add `ConversationSettings` struct to config types (with `WorkerContextMode`). +3. Add `conversation_settings` defaults to agent config. +4. Add `resolve_conversation_settings()` function. +5. Wire settings into conversation create/update API. +6. Add `/api/conversation-defaults` endpoint. + +### Phase 2 — Wire Into Channel Process + +1. Pass resolved settings into `Channel::new()`. +2. Branch tool server selection on `delegation` mode. +3. Branch memory injection on `memory` mode. +4. Branch model resolution on `model` override. +5. Wire worker context settings into `spawn_worker_from_state()`. +6. Add knowledge synthesis + working memory injection and history cloning to worker creation based on settings. +7. Add memory tools to worker tool server when `worker_context.memory` is `Tools` or `Full`. + +### Phase 3 — Wire Into UI + +1. Add settings controls to new conversation dialog in Spacedrive. +2. Add settings display/edit to conversation header. +3. Add presets. +4. Replace non-functional model selector. +5. Add advanced worker context settings (collapsed by default). + +### Phase 4 — Sunset Cortex Chat + +1. Stop showing cortex chat in dashboard UI. +2. Add deprecation notice to cortex chat API endpoints. +3. Offer migration: convert existing cortex chat threads to portal conversations with `Direct` mode. +4. Eventually remove `CortexChatSession`, cortex chat API routes, and `cortex_chat_messages` table. + +--- + +## Open Questions + +1. **Should model override propagate to workers unconditionally?** If a user picks Opus for a conversation and the agent spawns 5 workers, that's expensive. Option: override only applies to the channel process, workers still use routing defaults. Or: show estimated cost in the UI. + +2. **Should "Direct" mode keep personality?** Cortex chat strips personality. But a "Direct" conversation might feel better with personality intact — you're still talking to your agent, just with more power. Proposal: keep personality, add a "technical mode" flag separately if needed. + +3. **Per-channel settings for Discord threads?** Discord threads inherit their parent channel's settings. Should a user be able to override per-thread? Probably not in v1 — threads are ephemeral. + +4. **Settings changes mid-conversation?** Proposed: take effect on next message. But what if the user switches from Standard to Direct mid-conversation — does the history context change? The channel process would need to reload its tool server. This is doable (tools are per-turn already) but needs care. + +5. **Should `Ambient` mode still allow explicit memory tool use in Direct mode?** If delegation is Direct and memory is Ambient, the agent has memory tools but we said "no writes." Option: Ambient removes `memory_save` tool but keeps `memory_recall`. Off removes both. + +6. **Channel-level settings via TOML vs API?** TOML is static and requires restart. API is runtime. Proposal: support both. TOML provides initial defaults, API allows runtime changes that persist to DB. DB overrides TOML. + +7. **Worker context cost guardrails?** `WorkerHistoryMode::Full` with a long conversation could inject thousands of tokens into every worker. Should there be a token budget cap? A max message count even in Full mode? Or is that the user's problem — they opted in. + +8. **Summary mode implementation?** `WorkerHistoryMode::Summary` requires an extra LLM call at spawn time. Which model? The compactor model (cheap, fast)? The conversation's override model? A hardcoded summarizer? This adds latency to worker spawning. From d454798dda5fc58b5e93956abf1f4fa4f91105e8 Mon Sep 17 00:00:00 2001 From: James Pine Date: Wed, 25 Mar 2026 23:02:34 -0700 Subject: [PATCH 04/20] conversation settings impl --- interface/src/api/client.ts | 42 ++- interface/src/api/types.ts | 65 +++- .../components/ConversationSettingsPanel.tsx | 164 +++++++++ .../src/components/ConversationsSidebar.tsx | 263 +++++++++++++ interface/src/components/WebChatPanel.tsx | 246 ++++++++++--- .../src/hooks/{useWebChat.ts => usePortal.ts} | 14 +- interface/src/routes/Overlay.tsx | 6 +- ...0260325120000_rename_webchat_to_portal.sql | 12 + ...30000_add_portal_conversation_settings.sql | 7 + packages/api-client/src/client.ts | 41 ++- src/agent/channel.rs | 175 ++++++--- src/agent/channel_dispatch.rs | 57 ++- src/agent/channel_history.rs | 8 +- src/agent/cortex.rs | 1 + src/agent/worker.rs | 12 +- src/api.rs | 2 +- src/api/{webchat.rs => portal.rs} | 240 ++++++++---- src/api/server.rs | 19 +- src/api/state.rs | 14 +- src/config/types.rs | 4 +- src/conversation.rs | 9 +- src/conversation/{webchat.rs => portal.rs} | 79 ++-- src/conversation/settings.rs | 345 ++++++++++++++++++ src/cron/scheduler.rs | 1 + src/main.rs | 14 +- src/messaging.rs | 4 +- src/messaging/{webchat.rs => portal.rs} | 44 +-- src/messaging/target.rs | 4 +- src/tools.rs | 52 ++- src/tools/spawn_worker.rs | 8 + 30 files changed, 1634 insertions(+), 318 deletions(-) create mode 100644 interface/src/components/ConversationSettingsPanel.tsx create mode 100644 interface/src/components/ConversationsSidebar.tsx rename interface/src/hooks/{useWebChat.ts => usePortal.ts} (60%) create mode 100644 migrations/20260325120000_rename_webchat_to_portal.sql create mode 100644 migrations/20260325130000_add_portal_conversation_settings.sql rename src/api/{webchat.rs => portal.rs} (52%) rename src/conversation/{webchat.rs => portal.rs} (79%) create mode 100644 src/conversation/settings.rs rename src/messaging/{webchat.rs => portal.rs} (86%) diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 28ea9c64c..b8f924ff0 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -93,7 +93,8 @@ import type { export type { TopologyAgent, TopologyLink, TopologyGroup, TopologyHuman, TopologyResponse }; -// Aliases for backward compatibility +// Conversation-related types +export type { ConversationSettings, ConversationDefaultsResponse } from "./types"; export type ChannelInfo = Types.ChannelResponse; export type WorkerRunInfo = Types.WorkerListItem; export type AssociationItem = Types.Association; @@ -2013,9 +2014,9 @@ export const api = { } }, - // Web Chat API - webChatSend: (agentId: string, sessionId: string, message: string, senderName?: string) => - fetch(`${getApiBase()}/webchat/send`, { + // Portal API (renamed from webchat) + portalSend: (agentId: string, sessionId: string, message: string, senderName?: string) => + fetch(`${getApiBase()}/portal/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -2026,8 +2027,33 @@ export const api = { }), }), - webChatHistory: (agentId: string, sessionId: string, limit = 100) => - fetch(`${getApiBase()}/webchat/history?agent_id=${encodeURIComponent(agentId)}&session_id=${encodeURIComponent(sessionId)}&limit=${limit}`), + portalHistory: (agentId: string, sessionId: string, limit = 100) => + fetch(`${getApiBase()}/portal/history?agent_id=${encodeURIComponent(agentId)}&session_id=${encodeURIComponent(sessionId)}&limit=${limit}`), + + listPortalConversations: (agentId: string, includeArchived = false, limit = 100) => + fetch(`${getApiBase()}/portal/conversations?agent_id=${encodeURIComponent(agentId)}&include_archived=${includeArchived}&limit=${limit}`), + + createPortalConversation: (agentId: string, title?: string, settings?: import("./types").ConversationSettings) => + fetch(`${getApiBase()}/portal/conversations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: agentId, title, settings }), + }), + + updatePortalConversation: (agentId: string, sessionId: string, title?: string, archived?: boolean, settings?: import("./types").ConversationSettings) => + fetch(`${getApiBase()}/portal/conversations/${encodeURIComponent(sessionId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: agentId, title, archived, settings }), + }), + + deletePortalConversation: (agentId: string, sessionId: string) => + fetch(`${getApiBase()}/portal/conversations/${encodeURIComponent(sessionId)}?agent_id=${encodeURIComponent(agentId)}`, { + method: "DELETE", + }), + + getConversationDefaults: (agentId: string) => + fetchJson(`/conversation-defaults?agent_id=${encodeURIComponent(agentId)}`), // Tasks API listTasks: (params?: { agent_id?: string; owner_agent_id?: string; assigned_agent_id?: string; status?: TaskStatus; priority?: TaskPriority; created_by?: string; limit?: number }) => { @@ -2266,9 +2292,9 @@ export const api = { return []; }, - webChatSendAudio: async (agentId: string, _sessionId: string, _blob: Blob): Promise => { + portalSendAudio: async (agentId: string, _sessionId: string, _blob: Blob): Promise => { // TODO: Implement actual audio sending endpoint - console.warn("webChatSendAudio not implemented", agentId); + console.warn("portalSendAudio not implemented", agentId); return new Response(null, { status: 501 }); }, diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index b0f079cf6..8b4abf55e 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -106,17 +106,70 @@ export type WarmupTriggerResponse = // Webchat conversations export type WebChatConversation = components["schemas"]["WebChatConversation"]; export type WebChatConversationSummary = - components["schemas"]["WebChatConversationSummary"]; + components["schemas"]["WebChatConversationSummary"]; export type WebChatConversationsResponse = - components["schemas"]["WebChatConversationsResponse"]; + components["schemas"]["WebChatConversationsResponse"]; export type WebChatConversationResponse = - components["schemas"]["WebChatConversationResponse"]; + components["schemas"]["WebChatConversationResponse"]; export type CreateWebChatConversationRequest = - components["schemas"]["CreateWebChatConversationRequest"]; + components["schemas"]["CreateWebChatConversationRequest"]; export type UpdateWebChatConversationRequest = - components["schemas"]["UpdateWebChatConversationRequest"]; + components["schemas"]["UpdateWebChatConversationRequest"]; export type WebChatHistoryMessage = - components["schemas"]["WebChatHistoryMessage"]; + components["schemas"]["WebChatHistoryMessage"]; + +// Conversation Settings +export type ConversationSettings = { + model?: string | null; + memory?: "full" | "ambient" | "off"; + delegation?: "standard" | "direct"; + worker_context?: { + history?: "none" | "summary" | "recent" | "full"; + memory?: "none" | "ambient" | "tools" | "full"; + }; +}; + +export type ConversationDefaultsResponse = { + model: string; + memory: "full" | "ambient" | "off"; + delegation: "standard" | "direct"; + worker_context: { + history: "none" | "summary" | "recent" | "full"; + memory: "none" | "ambient" | "tools" | "full"; + }; + available_models: Array<{ + id: string; + name: string; + provider: string; + context_window: number; + supports_tools: boolean; + supports_thinking: boolean; + }>; + memory_modes: string[]; + delegation_modes: string[]; + worker_history_modes: string[]; + worker_memory_modes: string[]; +}; + +// Portal conversations (renamed from webchat) +export type PortalConversation = components["schemas"]["WebChatConversation"]; +export type PortalConversationSummary = components["schemas"]["WebChatConversationSummary"]; +export type PortalConversationsResponse = components["schemas"]["WebChatConversationsResponse"]; +export type PortalConversationResponse = components["schemas"]["WebChatConversationResponse"]; +export type CreatePortalConversationRequest = { + agent_id: string; + title?: string | null; + settings?: ConversationSettings | null; +}; +export type UpdatePortalConversationRequest = { + agent_id: string; + title?: string | null; + archived?: boolean | null; + settings?: ConversationSettings | null; +}; +export type PortalHistoryMessage = components["schemas"]["WebChatHistoryMessage"]; +export type PortalSendRequest = components["schemas"]["WebChatSendRequest"]; +export type PortalSendResponse = components["schemas"]["WebChatSendResponse"]; // Activity export type ActivityDayCount = components["schemas"]["ActivityDayCount"]; diff --git a/interface/src/components/ConversationSettingsPanel.tsx b/interface/src/components/ConversationSettingsPanel.tsx new file mode 100644 index 000000000..c9d64587b --- /dev/null +++ b/interface/src/components/ConversationSettingsPanel.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import { Button } from "@/ui/Button"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/ui/Select"; +import type { ConversationSettings, ConversationDefaultsResponse } from "@/api/types"; + +interface ConversationSettingsPanelProps { + defaults: ConversationDefaultsResponse; + currentSettings: ConversationSettings; + onChange: (settings: ConversationSettings) => void; + onSave: () => void; +} + +const PRESETS: Array<{ id: string; name: string; settings: ConversationSettings }> = [ + { id: "chat", name: "Chat", settings: { memory: "full", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, + { id: "focus", name: "Focus", settings: { memory: "ambient", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, + { id: "hands-on", name: "Hands-on", settings: { memory: "off", delegation: "direct", worker_context: { history: "recent", memory: "tools" } } }, + { id: "quick", name: "Quick", settings: { memory: "off", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, +]; + +export function ConversationSettingsPanel({ defaults, currentSettings, onChange, onSave }: ConversationSettingsPanelProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const applyPreset = (preset: (typeof PRESETS)[0]) => { + onChange({ ...currentSettings, ...preset.settings }); + }; + + return ( +
+
+

Conversation Settings

+ +
+ + {/* Presets */} +
+ {PRESETS.map((preset) => ( + + ))} +
+ + {/* Basic Settings */} +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* Advanced Settings */} + {isExpanded && ( +
+

Worker Context

+ +
+ + +
+ +
+ + +
+
+ )} + +
+ +
+
+ ); +} diff --git a/interface/src/components/ConversationsSidebar.tsx b/interface/src/components/ConversationsSidebar.tsx new file mode 100644 index 000000000..30922c77e --- /dev/null +++ b/interface/src/components/ConversationsSidebar.tsx @@ -0,0 +1,263 @@ +import { useState } from "react"; +import { Button } from "@/ui/Button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/ui/Dialog"; +import { Input } from "@/ui/Input"; +import type { PortalConversationSummary } from "@/api/types"; + +interface ConversationsSidebarProps { + conversations: PortalConversationSummary[]; + activeConversationId: string | null; + onSelectConversation: (id: string) => void; + onCreateConversation: () => void; + onDeleteConversation: (id: string) => void; + onRenameConversation: (id: string, title: string) => void; + onArchiveConversation: (id: string, archived: boolean) => void; + isLoading: boolean; +} + +export function ConversationsSidebar({ + conversations, + activeConversationId, + onSelectConversation, + onCreateConversation, + onDeleteConversation, + onRenameConversation, + onArchiveConversation, + isLoading, +}: ConversationsSidebarProps) { + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedConversation, setSelectedConversation] = useState(null); + const [newTitle, setNewTitle] = useState(""); + + const activeConversations = conversations.filter((c) => !c.archived); + const archivedConversations = conversations.filter((c) => c.archived); + + const handleRename = (conv: PortalConversationSummary) => { + setSelectedConversation(conv); + setNewTitle(conv.title); + setRenameDialogOpen(true); + }; + + const handleDelete = (conv: PortalConversationSummary) => { + setSelectedConversation(conv); + setDeleteDialogOpen(true); + }; + + const confirmRename = () => { + if (selectedConversation && newTitle.trim()) { + onRenameConversation(selectedConversation.id, newTitle.trim()); + setRenameDialogOpen(false); + setSelectedConversation(null); + } + }; + + const confirmDelete = () => { + if (selectedConversation) { + onDeleteConversation(selectedConversation.id); + setDeleteDialogOpen(false); + setSelectedConversation(null); + } + }; + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + const now = new Date(); + const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else if (diffDays === 1) { + return "Yesterday"; + } else if (diffDays < 7) { + return date.toLocaleDateString([], { weekday: 'short' }); + } else { + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); + } + }; + + return ( +
+ {/* Header */} +
+

Conversations

+ +
+ + {/* Conversations List */} +
+ {isLoading ? ( +
Loading...
+ ) : activeConversations.length === 0 ? ( +
+ No conversations yet +
+ ) : ( +
+ {activeConversations.map((conv) => ( +
onSelectConversation(conv.id)} + > +
+
{conv.title}
+ {conv.last_message_preview && ( +
+ {conv.last_message_preview} +
+ )} +
+
+ {formatDate(conv.updated_at)} + + {/* Actions Menu */} +
+ + + +
+
+
+ ))} +
+ )} + + {/* Archived Section */} + {archivedConversations.length > 0 && ( +
+
+ Archived +
+
+ {archivedConversations.map((conv) => ( +
onSelectConversation(conv.id)} + > +
+
{conv.title}
+
+ +
+ ))} +
+
+ )} +
+ + {/* Rename Dialog */} + + + + Rename Conversation + + setNewTitle(e.target.value)} + placeholder="Conversation title" + onKeyDown={(e) => { + if (e.key === "Enter") confirmRename(); + }} + /> + + + + + + + + {/* Delete Dialog */} + + + + Delete Conversation + +

+ Are you sure you want to delete "{selectedConversation?.title}"? This cannot be undone. +

+ + + + +
+
+
+ ); +} diff --git a/interface/src/components/WebChatPanel.tsx b/interface/src/components/WebChatPanel.tsx index f3361907b..8567f96bd 100644 --- a/interface/src/components/WebChatPanel.tsx +++ b/interface/src/components/WebChatPanel.tsx @@ -1,9 +1,14 @@ import {useEffect, useRef, useState} from "react"; import {Link} from "@tanstack/react-router"; -import {useWebChat} from "@/hooks/useWebChat"; +import {usePortal, getPortalSessionId} from "@/hooks/usePortal"; import {isOpenCodeWorker, type ActiveWorker} from "@/hooks/useChannelLiveState"; import {useLiveContext} from "@/hooks/useLiveContext"; import {Markdown} from "@/components/Markdown"; +import {ConversationSettingsPanel} from "@/components/ConversationSettingsPanel"; +import {ConversationsSidebar} from "@/components/ConversationsSidebar"; +import {Button} from "@/ui/Button"; +import {api, type ConversationDefaultsResponse, type ConversationSettings} from "@/api/client"; +import {useQuery, useMutation, useQueryClient} from "@tanstack/react-query"; interface WebChatPanelProps { agentId: string; @@ -176,21 +181,89 @@ function FloatingChatInput({ } export function WebChatPanel({agentId}: WebChatPanelProps) { - const {sessionId, isSending, error, sendMessage} = useWebChat(agentId); + const queryClient = useQueryClient(); + const [activeConversationId, setActiveConversationId] = useState(getPortalSessionId(agentId)); + const {isSending, error, sendMessage} = usePortal(agentId, activeConversationId); const {liveStates} = useLiveContext(); const [input, setInput] = useState(""); const scrollRef = useRef(null); + const [showSettings, setShowSettings] = useState(false); + const [settings, setSettings] = useState({}); - const liveState = liveStates[sessionId]; + // Fetch conversations list + const { data: conversationsData, isLoading: conversationsLoading } = useQuery({ + queryKey: ["portal-conversations", agentId], + queryFn: async () => { + const response = await api.listPortalConversations(agentId); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }, + }); + + const conversations = conversationsData?.conversations ?? []; + + // Fetch conversation defaults + const {data: defaults, isLoading: defaultsLoading, error: defaultsError} = useQuery({ + queryKey: ["conversation-defaults", agentId], + queryFn: () => api.getConversationDefaults(agentId), + }); + + const liveState = liveStates[activeConversationId]; const timeline = liveState?.timeline ?? []; const isTyping = liveState?.isTyping ?? false; const activeWorkers = Object.values(liveState?.workers ?? {}); const hasActiveWorkers = activeWorkers.length > 0; + // Mutations + const createConversationMutation = useMutation({ + mutationFn: async () => { + const response = await api.createPortalConversation(agentId); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }, + onSuccess: (data) => { + setActiveConversationId(data.conversation.id); + queryClient.invalidateQueries({ queryKey: ["portal-conversations", agentId] }); + }, + }); + + const deleteConversationMutation = useMutation({ + mutationFn: async (id: string) => { + const response = await api.deletePortalConversation(agentId, id); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }, + onSuccess: (_, deletedId) => { + if (activeConversationId === deletedId) { + setActiveConversationId(getPortalSessionId(agentId)); + } + queryClient.invalidateQueries({ queryKey: ["portal-conversations", agentId] }); + }, + }); + + const renameConversationMutation = useMutation({ + mutationFn: async ({ id, title }: { id: string; title: string }) => { + const response = await api.updatePortalConversation(agentId, id, title); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["portal-conversations", agentId] }); + }, + }); + + const archiveConversationMutation = useMutation({ + mutationFn: async ({ id, archived }: { id: string; archived: boolean }) => { + const response = await api.updatePortalConversation(agentId, id, undefined, archived); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["portal-conversations", agentId] }); + }, + }); + // Auto-scroll on new messages or typing state changes. - // Use direct scrollTo on the container instead of scrollIntoView, - // which can propagate scroll to ancestor overflow-hidden containers - // and shift the entire layout (hiding the top navbar). useEffect(() => { const el = scrollRef.current; if (el) { @@ -205,65 +278,120 @@ export function WebChatPanel({agentId}: WebChatPanelProps) { sendMessage(trimmed); }; + const handleSaveSettings = () => { + console.log("Saving settings:", settings); + setShowSettings(false); + }; + return ( -
- {/* Messages */} -
-
- {hasActiveWorkers && ( -
- -
- )} - - {timeline.length === 0 && !isTyping && ( -
-

- Start a conversation with {agentId} -

-
- )} - - {timeline.map((item) => { - if (item.type !== "message") return null; - return ( -
- {item.role === "user" ? ( -
-
-

- {item.content} -

-
-
- ) : ( -
- {item.content} -
- )} +
+ {/* Sidebar */} + createConversationMutation.mutate()} + onDeleteConversation={(id) => deleteConversationMutation.mutate(id)} + onRenameConversation={(id, title) => renameConversationMutation.mutate({ id, title })} + onArchiveConversation={(id, archived) => archiveConversationMutation.mutate({ id, archived })} + isLoading={conversationsLoading} + /> + + {/* Main Chat Area */} +
+ {/* Header with settings button */} +
+

{agentId}

+ +
+ + {/* Settings Panel */} + {showSettings && ( +
+ {defaultsLoading ? ( +
Loading settings...
+ ) : defaults ? ( + + ) : defaultsError ? ( +
+ Failed to load settings: {defaultsError instanceof Error ? defaultsError.message : "Unknown error"} +
+ ) : ( +
Failed to load settings
+ )} +
+ )} + + {/* Messages */} +
+
+ {hasActiveWorkers && ( +
+
- ); - })} + )} + + {timeline.length === 0 && !isTyping && ( +
+

+ Start a conversation with {agentId} +

+
+ )} + + {timeline.map((item) => { + if (item.type !== "message") return null; + return ( +
+ {item.role === "user" ? ( +
+
+

+ {item.content} +

+
+
+ ) : ( +
+ {item.content} +
+ )} +
+ ); + })} - {/* Typing indicator */} - {isTyping && } + {/* Typing indicator */} + {isTyping && } - {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} +
-
- {/* Floating input */} - + {/* Floating input */} + +
); } diff --git a/interface/src/hooks/useWebChat.ts b/interface/src/hooks/usePortal.ts similarity index 60% rename from interface/src/hooks/useWebChat.ts rename to interface/src/hooks/usePortal.ts index 95168480e..a5b75b880 100644 --- a/interface/src/hooks/useWebChat.ts +++ b/interface/src/hooks/usePortal.ts @@ -1,16 +1,16 @@ import { useCallback, useState } from "react"; import { api } from "@/api/client"; -export function getPortalChatSessionId(agentId: string) { +export function getPortalSessionId(agentId: string) { return `portal:chat:${agentId}`; } /** - * Sends messages to the webchat endpoint. The response arrives via the global + * Sends messages to the portal endpoint. The response arrives via the global * SSE event bus (same timeline used by regular channels) — no per-request SSE. */ -export function useWebChat(agentId: string) { - const sessionId = getPortalChatSessionId(agentId); +export function usePortal(agentId: string, sessionId?: string) { + const resolvedSessionId = sessionId || getPortalSessionId(agentId); const [isSending, setIsSending] = useState(false); const [error, setError] = useState(null); @@ -22,7 +22,7 @@ export function useWebChat(agentId: string) { setIsSending(true); try { - const response = await api.webChatSend(agentId, sessionId, text); + const response = await api.portalSend(agentId, resolvedSessionId, text); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } @@ -34,8 +34,8 @@ export function useWebChat(agentId: string) { setIsSending(false); } }, - [agentId, sessionId, isSending], + [agentId, resolvedSessionId, isSending], ); - return { sessionId, isSending, error, sendMessage }; + return { sessionId: resolvedSessionId, isSending, error, sendMessage }; } diff --git a/interface/src/routes/Overlay.tsx b/interface/src/routes/Overlay.tsx index 0735764e4..b152e28fa 100644 --- a/interface/src/routes/Overlay.tsx +++ b/interface/src/routes/Overlay.tsx @@ -3,7 +3,7 @@ import {useQuery} from "@tanstack/react-query"; import {api} from "@/api/client"; import {useAudioRecorder} from "@/hooks/useAudioRecorder"; import {useTtsPlayback} from "@/hooks/useTtsPlayback"; -import {getPortalChatSessionId} from "@/hooks/useWebChat"; +import {getPortalSessionId} from "@/hooks/usePortal"; import {useEventSource} from "@/hooks/useEventSource"; import {cx} from "@/ui/utils"; import {IS_TAURI, resizeWindow, listen as platformListen} from "@/platform"; @@ -36,7 +36,7 @@ export function Overlay() { const [transcript, setTranscript] = useState>([]); const containerRef = useRef(null); - const sessionId = getPortalChatSessionId(agentId); + const sessionId = getPortalSessionId(agentId); const { state: recorderState, startRecording, @@ -188,7 +188,7 @@ export function Overlay() { setTranscript((prev) => [...prev, {role: "user", text: "[voice message]"}]); try { - const response = await api.webChatSendAudio(agentId, sessionId, blob); + const response = await api.portalSendAudio(agentId, sessionId, blob); if (!response.ok) throw new Error(`HTTP ${response.status}`); // Now waiting for SSE events (typing_state, outbound_message, spoken_response) } catch (error) { diff --git a/migrations/20260325120000_rename_webchat_to_portal.sql b/migrations/20260325120000_rename_webchat_to_portal.sql new file mode 100644 index 000000000..aa4aa773a --- /dev/null +++ b/migrations/20260325120000_rename_webchat_to_portal.sql @@ -0,0 +1,12 @@ +-- Rename webchat_conversations to portal_conversations +-- This is Phase 0 of the conversation-settings feature + +-- Drop any existing indexes on the old table first (idempotent) +DROP INDEX IF EXISTS idx_webchat_conversations_agent_updated; + +-- Rename the table +ALTER TABLE webchat_conversations RENAME TO portal_conversations; + +-- Recreate the index with the new name +CREATE INDEX IF NOT EXISTS idx_portal_conversations_agent_updated + ON portal_conversations(agent_id, archived, updated_at DESC); diff --git a/migrations/20260325130000_add_portal_conversation_settings.sql b/migrations/20260325130000_add_portal_conversation_settings.sql new file mode 100644 index 000000000..ecfcfa26b --- /dev/null +++ b/migrations/20260325130000_add_portal_conversation_settings.sql @@ -0,0 +1,7 @@ +-- Add settings column to portal_conversations table +-- This stores per-conversation settings (JSON) that control behavior + +ALTER TABLE portal_conversations ADD COLUMN settings TEXT; + +-- Create index for efficient filtering by settings (if we query by specific settings later) +CREATE INDEX IF NOT EXISTS idx_portal_conversations_settings ON portal_conversations(id, settings) WHERE settings IS NOT NULL; diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 0f7535b98..c94a7d742 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,7 +1,8 @@ import type { - WebChatConversationResponse, - WebChatConversationsResponse, - WebChatHistoryMessage, + PortalConversationResponse, + PortalConversationsResponse, + PortalHistoryMessage, + ConversationDefaultsResponse, HealthResponse, MemoriesListResponse, MessagesResponse, @@ -76,14 +77,14 @@ export const apiClient = { ); }, - webchatHistory(agentId: string, sessionId: string, limit = 100) { + portalHistory(agentId: string, sessionId: string, limit = 100) { const params = new URLSearchParams({ agent_id: agentId, session_id: sessionId, limit: String(limit), }); - return request( - `/webchat/history?${params.toString()}`, + return request( + `/portal/history?${params.toString()}`, ); }, @@ -100,13 +101,13 @@ export const apiClient = { return request(`/channels/messages?${params.toString()}`); }, - webchatSend(input: { + portalSend(input: { agentId: string; sessionId: string; senderName?: string; message: string; }) { - return request<{ ok: boolean }>("/webchat/send", { + return request<{ ok: boolean }>("/portal/send", { method: "POST", body: JSON.stringify({ agent_id: input.agentId, @@ -117,7 +118,7 @@ export const apiClient = { }); }, - listWebchatConversations( + listPortalConversations( agentId: string, includeArchived = false, limit = 100, @@ -127,13 +128,13 @@ export const apiClient = { include_archived: includeArchived ? "true" : "false", limit: String(limit), }); - return request( - `/webchat/conversations?${params.toString()}`, + return request( + `/portal/conversations?${params.toString()}`, ); }, - createWebchatConversation(input: { agentId: string; title?: string | null }) { - return request("/webchat/conversations", { + createPortalConversation(input: { agentId: string; title?: string | null }) { + return request("/portal/conversations", { method: "POST", body: JSON.stringify({ agent_id: input.agentId, @@ -142,14 +143,14 @@ export const apiClient = { }); }, - updateWebchatConversation(input: { + updatePortalConversation(input: { agentId: string; sessionId: string; title?: string | null; archived?: boolean; }) { - return request( - `/webchat/conversations/${encodeURIComponent(input.sessionId)}`, + return request( + `/portal/conversations/${encodeURIComponent(input.sessionId)}`, { method: "PUT", body: JSON.stringify({ @@ -161,16 +162,20 @@ export const apiClient = { ); }, - deleteWebchatConversation(agentId: string, sessionId: string) { + deletePortalConversation(agentId: string, sessionId: string) { const params = new URLSearchParams({ agent_id: agentId }); return request<{ ok: boolean }>( - `/webchat/conversations/${encodeURIComponent(sessionId)}?${params.toString()}`, + `/portal/conversations/${encodeURIComponent(sessionId)}?${params.toString()}`, { method: "DELETE", }, ); }, + getConversationDefaults(agentId: string) { + return request(`/conversation-defaults?agent_id=${encodeURIComponent(agentId)}`); + }, + listTasks(agentId: string, limit = 20) { const params = new URLSearchParams({ agent_id: agentId, diff --git a/src/agent/channel.rs b/src/agent/channel.rs index c5af1725c..51ed36c75 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -15,6 +15,7 @@ use crate::agent::compactor::Compactor; use crate::agent::process_control::ControlActionResult; use crate::agent::status::{StatusBlock, SystemInfo}; use crate::agent::worker::Worker; +use crate::conversation::settings::{DelegationMode, MemoryMode, ResolvedConversationSettings}; use crate::conversation::{ChannelStore, ConversationLogger, ProcessRunLogger}; use crate::error::{AgentError, Result}; use crate::hooks::SpacebotHook; @@ -124,6 +125,9 @@ pub struct ChannelState { /// `ToolStarted`/`ToolCompleted` events as they flow through the system. /// Defaults to a standalone empty map when the API layer is not active. pub live_worker_transcripts: LiveWorkerTranscripts, + /// Worker context settings inherited from conversation settings. + /// Determines what context workers spawned from this channel receive. + pub worker_context_settings: Arc>, } impl ChannelState { @@ -455,6 +459,8 @@ pub struct Channel { listen_only_session_override: Option, /// Handle exposed to the supervision control plane. control_handle: ChannelControlHandle, + /// Per-conversation resolved settings (memory mode, delegation mode, model override). + pub resolved_settings: ResolvedConversationSettings, } /// RAII guard that records `message_handling_duration_seconds` when dropped, @@ -493,6 +499,7 @@ impl Channel { logs_dir: std::path::PathBuf, prompt_snapshot_store: Option>, live_worker_transcripts: Option, + resolved_settings: ResolvedConversationSettings, ) -> (Self, mpsc::Sender) { let process_id = ProcessId::Channel(id.clone()); let hook = SpacebotHook::new( @@ -534,6 +541,9 @@ impl Channel { prompt_snapshot_store, live_worker_transcripts: live_worker_transcripts .unwrap_or_else(|| Arc::new(RwLock::new(HashMap::new()))), + worker_context_settings: Arc::new(RwLock::new( + resolved_settings.worker_context.clone(), + )), }; // Each channel gets its own isolated tool server to avoid races between @@ -592,6 +602,7 @@ impl Channel { listen_only_mode: resolved_listen_only_mode, listen_only_session_override: None, control_handle, + resolved_settings, }; (channel, message_tx) @@ -2223,52 +2234,70 @@ impl Channel { let project_context = self.build_project_context(&prompt_engine).await; - // Render working memory layers (Layers 2 + 3). - let wm_config = **rc.working_memory.load(); - let timezone = self.deps.working_memory.timezone(); - let working_memory = match crate::memory::working::render_working_memory( - &self.deps.working_memory, - self.id.as_ref(), - &wm_config, - timezone, - ) - .await - { - Ok(text) => { - if text.is_empty() { - tracing::debug!(channel_id = %self.id, "working memory rendered empty (disabled?)"); - } else { - tracing::debug!(channel_id = %self.id, len = text.len(), "working memory rendered"); + // Only inject memory context if not in Off mode + let (working_memory, channel_activity_map, memory_bulletin_text) = if matches!( + self.resolved_settings.memory, + MemoryMode::Off + ) { + (String::new(), String::new(), None) + } else { + // Render working memory layers (Layers 2 + 3). + let wm_config = **rc.working_memory.load(); + let timezone = self.deps.working_memory.timezone(); + let working_memory = match crate::memory::working::render_working_memory( + &self.deps.working_memory, + self.id.as_ref(), + &wm_config, + timezone, + ) + .await + { + Ok(text) => { + if text.is_empty() { + tracing::debug!(channel_id = %self.id, "working memory rendered empty (disabled?)"); + } else { + tracing::debug!(channel_id = %self.id, len = text.len(), "working memory rendered"); + } + text } - text - } - Err(error) => { - tracing::warn!(channel_id = %self.id, %error, "working memory render failed"); - String::new() - } - }; + Err(error) => { + tracing::warn!(channel_id = %self.id, %error, "working memory render failed"); + String::new() + } + }; - let channel_activity_map = match crate::memory::working::render_channel_activity_map( - &self.deps.sqlite_pool, - &self.deps.working_memory, - self.id.as_ref(), - &wm_config, - timezone, - ) - .await - { - Ok(text) => text, - Err(error) => { - tracing::warn!(channel_id = %self.id, %error, "channel activity map render failed"); - String::new() - } + let channel_activity_map = match crate::memory::working::render_channel_activity_map( + &self.deps.sqlite_pool, + &self.deps.working_memory, + self.id.as_ref(), + &wm_config, + timezone, + ) + .await + { + Ok(text) => text, + Err(error) => { + tracing::warn!(channel_id = %self.id, %error, "channel activity map render failed"); + String::new() + } + }; + + // In Ambient mode, we still show memory but don't trigger persistence + let memory_bulletin_text = + if matches!(self.resolved_settings.memory, MemoryMode::Ambient) { + Some(memory_bulletin.to_string()) + } else { + Some(memory_bulletin.to_string()) + }; + + (working_memory, channel_activity_map, memory_bulletin_text) }; let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) }; prompt_engine.render_channel_prompt_with_links( empty_to_none(identity_context), - empty_to_none(memory_bulletin.to_string()), + memory_bulletin_text, empty_to_none(skills_prompt), worker_capabilities, self.conversation_context.clone(), @@ -2329,23 +2358,50 @@ impl Channel { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - if let Err(error) = crate::tools::add_channel_tools( - &self.tool_server, - self.state.clone(), - routed_sender, - conversation_id, - skip_flag.clone(), - replied_flag.clone(), - self.deps.cron_tool.clone(), - send_agent_message_tool, - allow_direct_reply, - adapter.map(|s| s.to_string()), - slack_thread_ts.as_deref(), - ) - .await - { - tracing::error!(%error, "failed to add channel tools"); - return Err(AgentError::Other(error.into()).into()); + // Add tools based on delegation mode + match self.resolved_settings.delegation { + DelegationMode::Standard => { + // Current behavior - standard channel tools only + if let Err(error) = crate::tools::add_channel_tools( + &self.tool_server, + self.state.clone(), + routed_sender, + conversation_id, + skip_flag.clone(), + replied_flag.clone(), + self.deps.cron_tool.clone(), + send_agent_message_tool, + allow_direct_reply, + adapter.map(|s| s.to_string()), + slack_thread_ts.as_deref(), + ) + .await + { + tracing::error!(%error, "failed to add channel tools"); + return Err(AgentError::Other(error.into()).into()); + } + } + DelegationMode::Direct => { + // Full tool access (cortex chat style) + if let Err(error) = crate::tools::add_direct_mode_tools( + &self.tool_server, + self.state.clone(), + routed_sender, + conversation_id, + skip_flag.clone(), + replied_flag.clone(), + self.deps.cron_tool.clone(), + send_agent_message_tool, + allow_direct_reply, + adapter.map(|s| s.to_string()), + slack_thread_ts.as_deref(), + ) + .await + { + tracing::error!(%error, "failed to add direct mode tools"); + return Err(AgentError::Other(error.into()).into()); + } + } } let rc = &self.deps.runtime_config; @@ -2355,7 +2411,14 @@ impl Channel { } else { **rc.max_turns.load() }; - let model_name = routing.resolve(ProcessType::Channel, None); + + // Check for model override from conversation settings + let model_name = if let Some(ref override_model) = self.resolved_settings.model { + override_model.as_str() + } else { + routing.resolve(ProcessType::Channel, None) + }; + let model = SpacebotModel::make(&self.deps.llm_manager, model_name) .with_context(&*self.deps.agent_id, "channel") .with_routing((**routing).clone()); diff --git a/src/agent/channel_dispatch.rs b/src/agent/channel_dispatch.rs index 58ab7f11c..688699008 100644 --- a/src/agent/channel_dispatch.rs +++ b/src/agent/channel_dispatch.rs @@ -8,6 +8,7 @@ use crate::agent::branch::{Branch, BranchExecutionConfig}; use crate::agent::channel::ChannelState; use crate::agent::channel_prompt::TemporalContext; use crate::agent::worker::Worker; +use crate::conversation::settings::{WorkerContextMode, WorkerHistoryMode}; use crate::error::{AgentError, Error as SpacebotError}; use crate::tools::{BranchToolProfile, MemoryPersistenceContractState}; use crate::{AgentDeps, BranchId, ChannelId, ProcessEvent, WorkerId}; @@ -433,13 +434,15 @@ pub async fn spawn_worker_from_state( task: impl Into, interactive: bool, suggested_skills: &[&str], + worker_context: &WorkerContextMode, ) -> std::result::Result { check_worker_limit(state).await?; let task = task.into(); reserve_task_if_unique(state, &task).await?; ensure_dispatch_readiness(state, "worker"); - let result = spawn_worker_inner(state, &task, interactive, suggested_skills).await; + let result = + spawn_worker_inner(state, &task, interactive, suggested_skills, worker_context).await; // Release the reservation regardless of success or failure. // On success the task is now in the status block; on failure it needs cleanup. @@ -455,6 +458,7 @@ async fn spawn_worker_inner( task: &str, interactive: bool, suggested_skills: &[&str], + worker_context: &WorkerContextMode, ) -> std::result::Result { let rc = &state.deps.runtime_config; let prompt_engine = rc.prompts.load(); @@ -492,7 +496,7 @@ async fn spawn_worker_inner( // Append skills listing to worker system prompt. Suggested skills are // flagged so the worker knows the channel's intent, but it can read any // skill it decides is relevant via the read_skill tool. - let system_prompt = match skills.render_worker_skills(suggested_skills, &prompt_engine) { + let mut system_prompt = match skills.render_worker_skills(suggested_skills, &prompt_engine) { Ok(skills_prompt) if !skills_prompt.is_empty() => { format!("{worker_system_prompt}\n\n{skills_prompt}") } @@ -503,6 +507,53 @@ async fn spawn_worker_inner( } }; + // Inject memory context based on worker_context settings + if worker_context.memory.ambient_enabled() { + // Get knowledge synthesis and working memory + let knowledge_synthesis = state.deps.runtime_config.knowledge_synthesis.load(); + let wm_config = **state.deps.runtime_config.working_memory.load(); + let timezone = state.deps.working_memory.timezone(); + + if let Ok(working_memory) = crate::memory::working::render_working_memory( + &state.deps.working_memory, + state.channel_id.as_ref(), + &wm_config, + timezone, + ) + .await + { + system_prompt.push_str("\n\n## Agent's Knowledge\n"); + system_prompt.push_str(&knowledge_synthesis.to_string()); + if !working_memory.is_empty() { + system_prompt.push_str("\n\n## Recent Activity\n"); + system_prompt.push_str(&working_memory); + } + } + } + + // Inject conversation history if needed + let initial_history: Vec = match worker_context.history { + WorkerHistoryMode::None => Vec::new(), + WorkerHistoryMode::Summary => { + // Generate a summary (simplified - just use empty for now) + Vec::new() + } + WorkerHistoryMode::Recent(n) => { + let history = state.history.read().await; + history + .iter() + .rev() + .take(n as usize) + .rev() + .cloned() + .collect() + } + WorkerHistoryMode::Full => { + let history = state.history.read().await; + history.clone() + } + }; + let worker = if interactive { let (worker, input_tx, inject_tx) = Worker::new_interactive( Some(state.channel_id.clone()), @@ -513,6 +564,7 @@ async fn spawn_worker_inner( state.screenshot_dir.clone(), brave_search_key.clone(), state.logs_dir.clone(), + initial_history, ); let worker_id = worker.id; state @@ -536,6 +588,7 @@ async fn spawn_worker_inner( state.screenshot_dir.clone(), brave_search_key, state.logs_dir.clone(), + initial_history, ); state .worker_injections diff --git a/src/agent/channel_history.rs b/src/agent/channel_history.rs index 24d330867..0a6855fc5 100644 --- a/src/agent/channel_history.rs +++ b/src/agent/channel_history.rs @@ -1124,7 +1124,7 @@ mod tests { #[test] fn text_delta_events_are_filtered_by_channel_id() { - let target_channel: ChannelId = Arc::from("webchat:target"); + let target_channel: ChannelId = Arc::from("portal:target"); let matching_event = ProcessEvent::TextDelta { agent_id: Arc::from("agent"), @@ -1137,8 +1137,8 @@ mod tests { let other_event = ProcessEvent::TextDelta { agent_id: Arc::from("agent"), - process_id: ProcessId::Channel(Arc::from("webchat:other")), - channel_id: Some(Arc::from("webchat:other")), + process_id: ProcessId::Channel(Arc::from("portal:other")), + channel_id: Some(Arc::from("portal:other")), text_delta: "hel".to_string(), aggregated_text: "hello".to_string(), }; @@ -1146,7 +1146,7 @@ mod tests { let unscoped_event = ProcessEvent::TextDelta { agent_id: Arc::from("agent"), - process_id: ProcessId::Channel(Arc::from("webchat:none")), + process_id: ProcessId::Channel(Arc::from("portal:none")), channel_id: None, text_delta: "hel".to_string(), aggregated_text: "hello".to_string(), diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index c532f49d7..df98f364a 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -3329,6 +3329,7 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho screenshot_dir, brave_search_key, logs_dir, + Vec::new(), // no initial history for cortex task workers ); // Detached workers are not channel-owned, so injection senders are not diff --git a/src/agent/worker.rs b/src/agent/worker.rs index 58c21d8e3..4db5b3cd4 100644 --- a/src/agent/worker.rs +++ b/src/agent/worker.rs @@ -104,6 +104,7 @@ impl Worker { brave_search_key: Option, logs_dir: PathBuf, input_rx: Option>, + initial_history: Vec, ) -> (Self, mpsc::Sender) { let id = Uuid::new_v4(); let process_id = ProcessId::Worker(id); @@ -134,7 +135,11 @@ impl Worker { logs_dir, status_tx, status_rx, - prior_history: None, + prior_history: if initial_history.is_empty() { + None + } else { + Some(initial_history) + }, }, inject_tx, ) @@ -155,6 +160,7 @@ impl Worker { screenshot_dir: PathBuf, brave_search_key: Option, logs_dir: PathBuf, + initial_history: Vec, ) -> (Self, mpsc::Sender) { Self::build( channel_id, @@ -166,6 +172,7 @@ impl Worker { brave_search_key, logs_dir, None, + initial_history, ) } @@ -184,6 +191,7 @@ impl Worker { screenshot_dir: PathBuf, brave_search_key: Option, logs_dir: PathBuf, + initial_history: Vec, ) -> (Self, mpsc::Sender, mpsc::Sender) { let (input_tx, input_rx) = mpsc::channel(32); let (worker, inject_tx) = Self::build( @@ -196,6 +204,7 @@ impl Worker { brave_search_key, logs_dir, Some(input_rx), + initial_history, ); (worker, input_tx, inject_tx) @@ -230,6 +239,7 @@ impl Worker { brave_search_key, logs_dir, Some(input_rx), + Vec::new(), // initial_history - will be replaced by prior_history below ); // Reuse the original worker ID so DB row stays linked. worker.id = existing_id; diff --git a/src/api.rs b/src/api.rs index bb6231503..f52db2fe1 100644 --- a/src/api.rs +++ b/src/api.rs @@ -18,6 +18,7 @@ mod memories; mod messaging; mod models; mod opencode_proxy; +mod portal; mod projects; mod providers; mod secrets; @@ -29,7 +30,6 @@ mod state; mod system; mod tasks; mod tools; -mod webchat; mod workers; pub use server::{api_router, start_http_server}; diff --git a/src/api/webchat.rs b/src/api/portal.rs similarity index 52% rename from src/api/webchat.rs rename to src/api/portal.rs index 7a5f6569d..ea504bd27 100644 --- a/src/api/webchat.rs +++ b/src/api/portal.rs @@ -1,7 +1,13 @@ +//! Portal API endpoints for conversation management. + use super::state::ApiState; use crate::{ InboundMessage, MessageContent, - conversation::{WebChatConversation, WebChatConversationStore, WebChatConversationSummary}, + conversation::{ + ConversationDefaultsResponse, ConversationSettings, DelegationMode, MemoryMode, + ModelOption, PortalConversation, PortalConversationStore, PortalConversationSummary, + WorkerContextMode, + }, }; use axum::Json; @@ -12,7 +18,7 @@ use std::collections::HashMap; use std::sync::Arc; #[derive(Deserialize, utoipa::ToSchema)] -pub(super) struct WebChatSendRequest { +pub(super) struct PortalSendRequest { agent_id: String, session_id: String, #[serde(default = "default_sender_name")] @@ -25,12 +31,12 @@ fn default_sender_name() -> String { } #[derive(Serialize, utoipa::ToSchema)] -pub(super) struct WebChatSendResponse { +pub(super) struct PortalSendResponse { ok: bool, } #[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)] -pub(super) struct WebChatHistoryQuery { +pub(super) struct PortalHistoryQuery { agent_id: String, session_id: String, #[serde(default = "default_limit")] @@ -42,14 +48,14 @@ fn default_limit() -> i64 { } #[derive(Serialize, utoipa::ToSchema)] -pub(super) struct WebChatHistoryMessage { +pub(super) struct PortalHistoryMessage { id: String, role: String, content: String, } #[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)] -pub(super) struct WebChatConversationsQuery { +pub(super) struct PortalConversationsQuery { agent_id: String, #[serde(default)] include_archived: bool, @@ -62,60 +68,67 @@ fn default_conversation_limit() -> i64 { } #[derive(Serialize, utoipa::ToSchema)] -pub(super) struct WebChatConversationsResponse { - conversations: Vec, +pub(super) struct PortalConversationsResponse { + conversations: Vec, } #[derive(Deserialize, utoipa::ToSchema)] -pub(super) struct CreateWebChatConversationRequest { +pub(super) struct CreatePortalConversationRequest { agent_id: String, title: Option, + settings: Option, } #[derive(Serialize, utoipa::ToSchema)] -pub(super) struct WebChatConversationResponse { - conversation: WebChatConversation, +pub(super) struct PortalConversationResponse { + conversation: PortalConversation, } #[derive(Deserialize, utoipa::ToSchema)] -pub(super) struct UpdateWebChatConversationRequest { +pub(super) struct UpdatePortalConversationRequest { agent_id: String, title: Option, archived: Option, + settings: Option, +} + +#[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)] +pub(super) struct DeletePortalConversationQuery { + agent_id: String, } #[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)] -pub(super) struct DeleteWebChatConversationQuery { +pub(super) struct ConversationDefaultsQuery { agent_id: String, } fn conversation_store( state: &Arc, agent_id: &str, -) -> Result { +) -> Result { let pools = state.agent_pools.load(); let pool = pools.get(agent_id).ok_or(StatusCode::NOT_FOUND)?; - Ok(WebChatConversationStore::new(pool.clone())) + Ok(PortalConversationStore::new(pool.clone())) } /// Fire-and-forget message injection. The response arrives via the global SSE /// event bus (`/api/events`), same as every other channel. #[utoipa::path( post, - path = "/webchat/send", - request_body = WebChatSendRequest, + path = "/portal/send", + request_body = PortalSendRequest, responses( - (status = 200, body = WebChatSendResponse), + (status = 200, body = PortalSendResponse), (status = 400, description = "Invalid request"), (status = 404, description = "Agent not found"), (status = 503, description = "Messaging manager not available"), ), - tag = "webchat", + tag = "portal", )] -pub(super) async fn webchat_send( +pub(super) async fn portal_send( State(state): State>, - axum::Json(request): axum::Json, -) -> Result, StatusCode> { + axum::Json(request): axum::Json, +) -> Result, StatusCode> { let manager = state .messaging_manager .read() @@ -128,14 +141,14 @@ pub(super) async fn webchat_send( .ensure(&request.agent_id, &request.session_id) .await .map_err(|error| { - tracing::warn!(%error, session_id = %request.session_id, "failed to ensure webchat conversation"); + tracing::warn!(%error, session_id = %request.session_id, "failed to ensure portal conversation"); StatusCode::INTERNAL_SERVER_ERROR })?; store .maybe_set_generated_title(&request.agent_id, &request.session_id, &request.message) .await .map_err(|error| { - tracing::warn!(%error, session_id = %request.session_id, "failed to update generated webchat title"); + tracing::warn!(%error, session_id = %request.session_id, "failed to update generated portal title"); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -149,8 +162,8 @@ pub(super) async fn webchat_send( let inbound = InboundMessage { id: uuid::Uuid::new_v4().to_string(), - source: "webchat".into(), - adapter: Some("webchat".into()), + source: "portal".into(), + adapter: Some("portal".into()), conversation_id, sender_id: request.sender_name.clone(), agent_id: Some(request.agent_id.into()), @@ -161,32 +174,32 @@ pub(super) async fn webchat_send( }; manager.inject_message(inbound).await.map_err(|error| { - tracing::warn!(%error, "failed to inject webchat message"); + tracing::warn!(%error, "failed to inject portal message"); StatusCode::INTERNAL_SERVER_ERROR })?; - Ok(Json(WebChatSendResponse { ok: true })) + Ok(Json(PortalSendResponse { ok: true })) } #[utoipa::path( get, - path = "/webchat/history", + path = "/portal/history", params( ("agent_id" = String, Query, description = "Agent ID"), ("session_id" = String, Query, description = "Session ID"), ("limit" = i64, Query, description = "Maximum number of messages to return (default: 100, max: 200)"), ), responses( - (status = 200, body = Vec), + (status = 200, body = Vec), (status = 404, description = "Agent not found"), (status = 500, description = "Internal server error"), ), - tag = "webchat", + tag = "portal", )] -pub(super) async fn webchat_history( +pub(super) async fn portal_history( State(state): State>, - Query(query): Query, -) -> Result>, StatusCode> { + Query(query): Query, +) -> Result>, StatusCode> { let pools = state.agent_pools.load(); let pool = pools.get(&query.agent_id).ok_or(StatusCode::NOT_FOUND)?; let logger = crate::conversation::ConversationLogger::new(pool.clone()); @@ -197,13 +210,13 @@ pub(super) async fn webchat_history( .load_recent(&channel_id, query.limit.min(200)) .await .map_err(|error| { - tracing::warn!(%error, "failed to load webchat history"); + tracing::warn!(%error, "failed to load portal history"); StatusCode::INTERNAL_SERVER_ERROR })?; - let result: Vec = messages + let result: Vec = messages .into_iter() - .map(|message| WebChatHistoryMessage { + .map(|message| PortalHistoryMessage { id: message.id, role: message.role, content: message.content, @@ -215,81 +228,81 @@ pub(super) async fn webchat_history( #[utoipa::path( get, - path = "/webchat/conversations", + path = "/portal/conversations", params( ("agent_id" = String, Query, description = "Agent ID"), ("include_archived" = bool, Query, description = "Include archived conversations"), ("limit" = i64, Query, description = "Maximum number of conversations to return (default: 100, max: 500)"), ), responses( - (status = 200, body = WebChatConversationsResponse), + (status = 200, body = PortalConversationsResponse), (status = 404, description = "Agent not found"), (status = 500, description = "Internal server error"), ), - tag = "webchat", + tag = "portal", )] -pub(super) async fn list_webchat_conversations( +pub(super) async fn list_portal_conversations( State(state): State>, - Query(query): Query, -) -> Result, StatusCode> { + Query(query): Query, +) -> Result, StatusCode> { let store = conversation_store(&state, &query.agent_id)?; let conversations = store .list(&query.agent_id, query.include_archived, query.limit) .await .map_err(|error| { - tracing::warn!(%error, agent_id = %query.agent_id, "failed to list webchat conversations"); + tracing::warn!(%error, agent_id = %query.agent_id, "failed to list portal conversations"); StatusCode::INTERNAL_SERVER_ERROR })?; - Ok(Json(WebChatConversationsResponse { conversations })) + Ok(Json(PortalConversationsResponse { conversations })) } #[utoipa::path( post, - path = "/webchat/conversations", - request_body = CreateWebChatConversationRequest, + path = "/portal/conversations", + request_body = CreatePortalConversationRequest, responses( - (status = 200, body = WebChatConversationResponse), + (status = 200, body = PortalConversationResponse), (status = 404, description = "Agent not found"), (status = 500, description = "Internal server error"), ), - tag = "webchat", + tag = "portal", )] -pub(super) async fn create_webchat_conversation( +pub(super) async fn create_portal_conversation( State(state): State>, - Json(request): Json, -) -> Result, StatusCode> { + Json(request): Json, +) -> Result, StatusCode> { let store = conversation_store(&state, &request.agent_id)?; let conversation = store - .create(&request.agent_id, request.title.as_deref()) + .create(&request.agent_id, request.title.as_deref(), request.settings) .await .map_err(|error| { - tracing::warn!(%error, agent_id = %request.agent_id, "failed to create webchat conversation"); + tracing::warn!(%error, agent_id = %request.agent_id, "failed to create portal conversation"); StatusCode::INTERNAL_SERVER_ERROR })?; - Ok(Json(WebChatConversationResponse { conversation })) + Ok(Json(PortalConversationResponse { conversation })) } #[utoipa::path( put, - path = "/webchat/conversations/{session_id}", - request_body = UpdateWebChatConversationRequest, + path = "/portal/conversations/{session_id}", + request_body = UpdatePortalConversationRequest, params( ("session_id" = String, Path, description = "Conversation session ID"), ), responses( - (status = 200, body = WebChatConversationResponse), + (status = 200, body = PortalConversationResponse), (status = 404, description = "Conversation not found"), (status = 500, description = "Internal server error"), ), - tag = "webchat", + tag = "portal", )] -pub(super) async fn update_webchat_conversation( +pub(super) async fn update_portal_conversation( State(state): State>, Path(session_id): Path, - Json(request): Json, -) -> Result, StatusCode> { + Json(request): Json, +) -> Result, StatusCode> { let store = conversation_store(&state, &request.agent_id)?; let conversation = store .update( @@ -297,42 +310,43 @@ pub(super) async fn update_webchat_conversation( &session_id, request.title.as_deref(), request.archived, + request.settings, ) .await .map_err(|error| { - tracing::warn!(%error, %session_id, "failed to update webchat conversation"); + tracing::warn!(%error, %session_id, "failed to update portal conversation"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; - Ok(Json(WebChatConversationResponse { conversation })) + Ok(Json(PortalConversationResponse { conversation })) } #[utoipa::path( delete, - path = "/webchat/conversations/{session_id}", + path = "/portal/conversations/{session_id}", params( ("session_id" = String, Path, description = "Conversation session ID"), ("agent_id" = String, Query, description = "Agent ID"), ), responses( - (status = 200, body = WebChatSendResponse), + (status = 200, body = PortalSendResponse), (status = 404, description = "Conversation not found"), (status = 500, description = "Internal server error"), ), - tag = "webchat", + tag = "portal", )] -pub(super) async fn delete_webchat_conversation( +pub(super) async fn delete_portal_conversation( State(state): State>, Path(session_id): Path, - Query(query): Query, -) -> Result, StatusCode> { + Query(query): Query, +) -> Result, StatusCode> { let store = conversation_store(&state, &query.agent_id)?; let deleted = store .delete(&query.agent_id, &session_id) .await .map_err(|error| { - tracing::warn!(%error, %session_id, "failed to delete webchat conversation"); + tracing::warn!(%error, %session_id, "failed to delete portal conversation"); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -340,5 +354,87 @@ pub(super) async fn delete_webchat_conversation( return Err(StatusCode::NOT_FOUND); } - Ok(Json(WebChatSendResponse { ok: true })) + Ok(Json(PortalSendResponse { ok: true })) +} + +/// Get conversation defaults for an agent. +/// Returns the resolved default settings and available options for new conversations. +#[utoipa::path( + get, + path = "/conversation-defaults", + params( + ("agent_id" = String, Query, description = "Agent ID"), + ), + responses( + (status = 200, body = ConversationDefaultsResponse), + (status = 404, description = "Agent not found"), + (status = 500, description = "Internal server error"), + ), + tag = "portal", +)] +pub(super) async fn conversation_defaults( + State(state): State>, + Query(query): Query, +) -> Result, StatusCode> { + // Verify agent exists by checking agent_configs + let agent_configs = state.agent_configs.load(); + let agent_exists = agent_configs.iter().any(|a| a.id == query.agent_id); + if !agent_exists { + return Err(StatusCode::NOT_FOUND); + } + + // Default model (placeholder - in Phase 2 will be resolved from agent config) + let default_model = "anthropic/claude-sonnet-4".to_string(); + + // Build available models list + let available_models = vec![ + ModelOption { + id: "anthropic/claude-sonnet-4".to_string(), + name: "Claude Sonnet 4".to_string(), + provider: "anthropic".to_string(), + context_window: 200_000, + supports_tools: true, + supports_thinking: true, + }, + ModelOption { + id: "anthropic/claude-opus-4".to_string(), + name: "Claude Opus 4".to_string(), + provider: "anthropic".to_string(), + context_window: 200_000, + supports_tools: true, + supports_thinking: true, + }, + ModelOption { + id: "anthropic/claude-haiku-4.5".to_string(), + name: "Claude Haiku 4.5".to_string(), + provider: "anthropic".to_string(), + context_window: 128_000, + supports_tools: true, + supports_thinking: false, + }, + ]; + + let response = ConversationDefaultsResponse { + model: default_model, + memory: MemoryMode::Full, + delegation: DelegationMode::Standard, + worker_context: WorkerContextMode::default(), + available_models, + memory_modes: vec!["full".to_string(), "ambient".to_string(), "off".to_string()], + delegation_modes: vec!["standard".to_string(), "direct".to_string()], + worker_history_modes: vec![ + "none".to_string(), + "summary".to_string(), + "recent".to_string(), + "full".to_string(), + ], + worker_memory_modes: vec![ + "none".to_string(), + "ambient".to_string(), + "tools".to_string(), + "full".to_string(), + ], + }; + + Ok(Json(response)) } diff --git a/src/api/server.rs b/src/api/server.rs index ff84ff2a2..88d7cdd53 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -3,8 +3,8 @@ use super::state::ApiState; use super::{ agents, bindings, channels, config, cortex, cron, factory, ingest, links, mcp, memories, - messaging, models, opencode_proxy, projects, providers, secrets, settings, skills, ssh, system, - tasks, tools, webchat, workers, + messaging, models, opencode_proxy, portal, projects, providers, secrets, settings, skills, ssh, + system, tasks, tools, workers, }; use axum::Json; @@ -214,13 +214,14 @@ pub fn api_router() -> OpenApiRouter> { // SSH routes .routes(routes!(ssh::set_authorized_key)) .routes(routes!(ssh::ssh_status)) - // Webchat routes - .routes(routes!(webchat::webchat_send)) - .routes(routes!(webchat::webchat_history)) - .routes(routes!(webchat::list_webchat_conversations)) - .routes(routes!(webchat::create_webchat_conversation)) - .routes(routes!(webchat::update_webchat_conversation)) - .routes(routes!(webchat::delete_webchat_conversation)) + // Portal routes + .routes(routes!(portal::portal_send)) + .routes(routes!(portal::portal_history)) + .routes(routes!(portal::list_portal_conversations)) + .routes(routes!(portal::create_portal_conversation)) + .routes(routes!(portal::update_portal_conversation)) + .routes(routes!(portal::delete_portal_conversation)) + .routes(routes!(portal::conversation_defaults)) // Link routes .routes(routes!(links::list_links, links::create_link)) .routes(routes!(links::update_link, links::delete_link)) diff --git a/src/api/state.rs b/src/api/state.rs index 356671886..8e0f4c831 100644 --- a/src/api/state.rs +++ b/src/api/state.rs @@ -12,7 +12,7 @@ use crate::llm::LlmManager; use crate::mcp::McpManager; use crate::memory::{EmbeddingModel, MemorySearch}; use crate::messaging::MessagingManager; -use crate::messaging::webchat::WebChatAdapter; +use crate::messaging::portal::PortalAdapter; use crate::projects::ProjectStore; use crate::prompts::PromptEngine; use crate::tasks::TaskStore; @@ -122,8 +122,8 @@ pub struct ApiState { pub agent_tx: mpsc::Sender, /// Sender to remove agents from the main event loop. pub agent_remove_tx: mpsc::Sender, - /// Shared webchat adapter for session management from API handlers. - pub webchat_adapter: ArcSwap>>, + /// Shared portal adapter for session management from API handlers. + pub portal_adapter: ArcSwap>>, /// Sender for cross-agent message injection. pub injection_tx: mpsc::Sender, /// Instance-level agent links for the communication graph. @@ -327,7 +327,7 @@ impl ApiState { agent_tx, agent_remove_tx, injection_tx, - webchat_adapter: ArcSwap::from_pointee(None), + portal_adapter: ArcSwap::from_pointee(None), agent_links: ArcSwap::from_pointee(Vec::new()), agent_groups: ArcSwap::from_pointee(Vec::new()), agent_humans: ArcSwap::from_pointee(Vec::new()), @@ -824,9 +824,9 @@ impl ApiState { *self.defaults_config.write().await = Some(defaults); } - /// Set the shared webchat adapter for API handlers. - pub fn set_webchat_adapter(&self, adapter: Arc) { - self.webchat_adapter.store(Arc::new(Some(adapter))); + /// Set the shared portal adapter for API handlers. + pub fn set_portal_adapter(&self, adapter: Arc) { + self.portal_adapter.store(Arc::new(Some(adapter))); } /// Set the agent links for the communication graph. diff --git a/src/config/types.rs b/src/config/types.rs index d2284e764..984f5f20a 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1567,8 +1567,8 @@ impl Binding { return false; } - // For webchat messages, match based on agent_id in the message - if message.source == "webchat" + // For portal messages, match based on agent_id in the message + if message.source == "portal" && let Some(message_agent_id) = &message.agent_id { return message_agent_id.as_ref() == self.agent_id; diff --git a/src/conversation.rs b/src/conversation.rs index 3a3822ba5..98d748cbf 100644 --- a/src/conversation.rs +++ b/src/conversation.rs @@ -3,12 +3,17 @@ pub mod channels; pub mod context; pub mod history; -pub mod webchat; +pub mod portal; +pub mod settings; pub mod worker_transcript; pub use channels::ChannelStore; pub use history::{ ConversationLogger, ProcessRunLogger, TimelineItem, WorkerDetailRow, WorkerRunRow, }; -pub use webchat::{WebChatConversation, WebChatConversationStore, WebChatConversationSummary}; +pub use portal::{PortalConversation, PortalConversationStore, PortalConversationSummary}; +pub use settings::{ + ConversationDefaultsResponse, ConversationSettings, DelegationMode, MemoryMode, ModelOption, + ResolvedConversationSettings, WorkerContextMode, WorkerHistoryMode, WorkerMemoryMode, +}; pub use worker_transcript::{ActionContent, TranscriptStep}; diff --git a/src/conversation/webchat.rs b/src/conversation/portal.rs similarity index 79% rename from src/conversation/webchat.rs rename to src/conversation/portal.rs index f77f65df0..5bce8ed25 100644 --- a/src/conversation/webchat.rs +++ b/src/conversation/portal.rs @@ -1,20 +1,22 @@ -//! Webchat conversation persistence (SQLite). +//! Portal conversation persistence (SQLite). +use super::settings::ConversationSettings; use sqlx::{Row as _, SqlitePool}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] -pub struct WebChatConversation { +pub struct PortalConversation { pub id: String, pub agent_id: String, pub title: String, pub title_source: String, pub archived: bool, + pub settings: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] -pub struct WebChatConversationSummary { +pub struct PortalConversationSummary { pub id: String, pub agent_id: String, pub title: String, @@ -26,14 +28,15 @@ pub struct WebChatConversationSummary { pub last_message_preview: Option, pub last_message_role: Option, pub message_count: i64, + pub settings: Option, } #[derive(Debug, Clone)] -pub struct WebChatConversationStore { +pub struct PortalConversationStore { pool: SqlitePool, } -impl WebChatConversationStore { +impl PortalConversationStore { pub fn new(pool: SqlitePool) -> Self { Self { pool } } @@ -42,7 +45,8 @@ impl WebChatConversationStore { &self, agent_id: &str, title: Option<&str>, - ) -> crate::error::Result { + settings: Option, + ) -> crate::error::Result { let id = format!("portal:chat:{agent_id}:{}", uuid::Uuid::new_v4()); let title = normalize_title(title).unwrap_or_else(default_title); let title_source = if title == default_title() { @@ -52,12 +56,13 @@ impl WebChatConversationStore { }; sqlx::query( - "INSERT INTO webchat_conversations (id, agent_id, title, title_source) VALUES (?, ?, ?, ?)", + "INSERT INTO portal_conversations (id, agent_id, title, title_source, settings) VALUES (?, ?, ?, ?, ?)", ) .bind(&id) .bind(agent_id) .bind(&title) .bind(title_source) + .bind(settings.as_ref().map(|s| serde_json::to_string(s).unwrap_or_default())) .execute(&self.pool) .await .map_err(|error| anyhow::anyhow!(error))?; @@ -65,16 +70,16 @@ impl WebChatConversationStore { Ok(self .get(agent_id, &id) .await? - .ok_or_else(|| anyhow::anyhow!("newly created webchat conversation missing"))?) + .ok_or_else(|| anyhow::anyhow!("newly created portal conversation missing"))?) } pub async fn ensure( &self, agent_id: &str, session_id: &str, - ) -> crate::error::Result { + ) -> crate::error::Result { sqlx::query( - "INSERT INTO webchat_conversations (id, agent_id, title, title_source) VALUES (?, ?, ?, 'system') \ + "INSERT INTO portal_conversations (id, agent_id, title, title_source, settings) VALUES (?, ?, ?, 'system', NULL) \ ON CONFLICT(id) DO NOTHING", ) .bind(session_id) @@ -87,17 +92,17 @@ impl WebChatConversationStore { Ok(self .get(agent_id, session_id) .await? - .ok_or_else(|| anyhow::anyhow!("ensured webchat conversation missing"))?) + .ok_or_else(|| anyhow::anyhow!("ensured portal conversation missing"))?) } pub async fn get( &self, agent_id: &str, session_id: &str, - ) -> crate::error::Result> { + ) -> crate::error::Result> { let row = sqlx::query( - "SELECT id, agent_id, title, title_source, archived, created_at, updated_at \ - FROM webchat_conversations WHERE agent_id = ? AND id = ?", + "SELECT id, agent_id, title, title_source, archived, settings, created_at, updated_at \ + FROM portal_conversations WHERE agent_id = ? AND id = ?", ) .bind(agent_id) .bind(session_id) @@ -113,17 +118,17 @@ impl WebChatConversationStore { agent_id: &str, include_archived: bool, limit: i64, - ) -> crate::error::Result> { + ) -> crate::error::Result> { self.backfill_from_messages(agent_id).await?; let rows = sqlx::query( "SELECT \ - c.id, c.agent_id, c.title, c.title_source, c.archived, c.created_at, c.updated_at, \ + c.id, c.agent_id, c.title, c.title_source, c.archived, c.settings, c.created_at, c.updated_at, \ (SELECT MAX(created_at) FROM conversation_messages WHERE channel_id = c.id) as last_message_at, \ (SELECT content FROM conversation_messages WHERE channel_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message_preview, \ (SELECT role FROM conversation_messages WHERE channel_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message_role, \ (SELECT COUNT(*) FROM conversation_messages WHERE channel_id = c.id) as message_count \ - FROM webchat_conversations c \ + FROM portal_conversations c \ WHERE c.agent_id = ? AND (? = 1 OR c.archived = 0) \ ORDER BY COALESCE((SELECT MAX(created_at) FROM conversation_messages WHERE channel_id = c.id), c.updated_at, c.created_at) DESC \ LIMIT ?", @@ -144,25 +149,31 @@ impl WebChatConversationStore { session_id: &str, title: Option<&str>, archived: Option, - ) -> crate::error::Result> { - if title.is_none() && archived.is_none() { + settings: Option, + ) -> crate::error::Result> { + if title.is_none() && archived.is_none() && settings.is_none() { return self.get(agent_id, session_id).await; } let title = normalize_title(title); let title_source = title.as_ref().map(|_| "user"); + let settings_json = settings + .as_ref() + .map(|s| serde_json::to_string(s).unwrap_or_default()); let result = sqlx::query( - "UPDATE webchat_conversations \ + "UPDATE portal_conversations \ SET title = COALESCE(?, title), \ title_source = COALESCE(?, title_source), \ archived = COALESCE(?, archived), \ + settings = COALESCE(?, settings), \ updated_at = CURRENT_TIMESTAMP \ WHERE agent_id = ? AND id = ?", ) .bind(title.as_deref()) .bind(title_source) .bind(archived.map(|value| if value { 1_i64 } else { 0_i64 })) + .bind(settings_json.as_deref()) .bind(agent_id) .bind(session_id) .execute(&self.pool) @@ -189,7 +200,7 @@ impl WebChatConversationStore { .await .map_err(|error| anyhow::anyhow!(error))?; - let result = sqlx::query("DELETE FROM webchat_conversations WHERE agent_id = ? AND id = ?") + let result = sqlx::query("DELETE FROM portal_conversations WHERE agent_id = ? AND id = ?") .bind(agent_id) .bind(session_id) .execute(&mut *tx) @@ -210,7 +221,7 @@ impl WebChatConversationStore { let generated_title = generate_title(content); sqlx::query( - "UPDATE webchat_conversations \ + "UPDATE portal_conversations \ SET title = ?, updated_at = CURRENT_TIMESTAMP \ WHERE agent_id = ? AND id = ? AND title_source = 'system' AND title = ?", ) @@ -264,7 +275,7 @@ impl WebChatConversationStore { }; sqlx::query( - "INSERT INTO webchat_conversations (id, agent_id, title, title_source) VALUES (?, ?, ?, ?) \ + "INSERT INTO portal_conversations (id, agent_id, title, title_source, settings) VALUES (?, ?, ?, ?, NULL) \ ON CONFLICT(id) DO NOTHING", ) .bind(&channel_id) @@ -280,8 +291,8 @@ impl WebChatConversationStore { } } -fn row_to_conversation(row: sqlx::sqlite::SqliteRow) -> WebChatConversation { - WebChatConversation { +fn row_to_conversation(row: sqlx::sqlite::SqliteRow) -> PortalConversation { + PortalConversation { id: row.try_get("id").unwrap_or_default(), agent_id: row.try_get("agent_id").unwrap_or_default(), title: row.try_get("title").unwrap_or_else(|_| default_title()), @@ -289,6 +300,13 @@ fn row_to_conversation(row: sqlx::sqlite::SqliteRow) -> WebChatConversation { .try_get("title_source") .unwrap_or_else(|_| "system".to_string()), archived: row.try_get::("archived").unwrap_or(0) == 1, + settings: row.try_get::("settings").ok().and_then(|s| { + if s.is_empty() { + None + } else { + serde_json::from_str(&s).ok() + } + }), created_at: row .try_get("created_at") .unwrap_or_else(|_| chrono::Utc::now()), @@ -298,8 +316,8 @@ fn row_to_conversation(row: sqlx::sqlite::SqliteRow) -> WebChatConversation { } } -fn row_to_summary(row: sqlx::sqlite::SqliteRow) -> WebChatConversationSummary { - WebChatConversationSummary { +fn row_to_summary(row: sqlx::sqlite::SqliteRow) -> PortalConversationSummary { + PortalConversationSummary { id: row.try_get("id").unwrap_or_default(), agent_id: row.try_get("agent_id").unwrap_or_default(), title: row.try_get("title").unwrap_or_else(|_| default_title()), @@ -317,6 +335,13 @@ fn row_to_summary(row: sqlx::sqlite::SqliteRow) -> WebChatConversationSummary { last_message_preview: row.try_get("last_message_preview").ok().flatten(), last_message_role: row.try_get("last_message_role").ok().flatten(), message_count: row.try_get("message_count").unwrap_or(0), + settings: row.try_get::("settings").ok().and_then(|s| { + if s.is_empty() { + None + } else { + serde_json::from_str(&s).ok() + } + }), } } diff --git a/src/conversation/settings.rs b/src/conversation/settings.rs new file mode 100644 index 000000000..9307ea266 --- /dev/null +++ b/src/conversation/settings.rs @@ -0,0 +1,345 @@ +//! Conversation settings for per-conversation configuration. +//! +//! This module defines the settings that control conversation behavior, +//! including memory mode, delegation mode, and worker context settings. + +use serde::{Deserialize, Serialize}; + +/// Memory mode controls how memory is used in a conversation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MemoryMode { + /// Full memory context with auto-persistence (default). + /// Knowledge synthesis + working memory + channel activity map. + /// Memory persistence branches fire. + #[default] + Full, + /// All memory context injected, but no auto-persistence and no memory tools. + /// The agent can see memories but doesn't write new ones. + Ambient, + /// No memory context injected, no memory tools, no persistence. + /// The conversation is stateless relative to the agent's memory. + Off, +} + +impl MemoryMode { + /// Returns true if memory persistence should be enabled. + pub fn persistence_enabled(&self) -> bool { + matches!(self, MemoryMode::Full) + } + + /// Returns true if memory tools should be available. + pub fn memory_tools_enabled(&self) -> bool { + matches!(self, MemoryMode::Full) + } +} + +/// Delegation mode controls how the conversation handles tools. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum DelegationMode { + /// Standard channel behavior: delegates via branch/worker. + /// Channel has reply, branch, spawn_worker, route, cancel, skip, react. + #[default] + Standard, + /// Direct tool access: channel gets full tool set including memory, + /// shell, file operations, browser, web search, plus delegation tools. + Direct, +} + +impl DelegationMode { + /// Returns true if direct tool access is enabled. + pub fn is_direct(&self) -> bool { + matches!(self, DelegationMode::Direct) + } +} + +/// How much conversation history a worker receives. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkerHistoryMode { + /// No conversation history (current default). + /// Worker sees only the task description. + #[default] + None, + /// LLM-generated summary of recent conversation context. + Summary, + /// Last N messages from the parent conversation. + Recent(u32), + /// Full conversation history clone (branch-style). + Full, +} + +/// How much memory context a worker receives. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkerMemoryMode { + /// No memory context (current default). + /// Worker is a pure executor with no memory access. + #[default] + None, + /// Knowledge synthesis + working memory injected into system prompt (read-only). + /// Worker has ambient awareness but can't search or write. + Ambient, + /// Ambient context + memory_recall tool. + /// Worker can search but not write memories. + Tools, + /// Ambient context + full memory tools (recall, save, delete). + /// Worker operates at branch-level memory access. + Full, +} + +impl WorkerMemoryMode { + /// Returns true if the worker should receive ambient memory context. + pub fn ambient_enabled(&self) -> bool { + matches!( + self, + WorkerMemoryMode::Ambient | WorkerMemoryMode::Tools | WorkerMemoryMode::Full + ) + } + + /// Returns true if the worker should have the memory_recall tool. + pub fn recall_enabled(&self) -> bool { + matches!(self, WorkerMemoryMode::Tools | WorkerMemoryMode::Full) + } + + /// Returns true if the worker should have full memory tools (save, delete). + pub fn full_tools_enabled(&self) -> bool { + matches!(self, WorkerMemoryMode::Full) + } +} + +/// Worker context settings control what context workers receive when spawned. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct WorkerContextMode { + /// What conversation context the worker sees. + pub history: WorkerHistoryMode, + /// What memory context the worker gets. + pub memory: WorkerMemoryMode, +} + +/// Per-conversation settings that control behavior. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ConversationSettings { + /// Optional model override for this conversation's channel process. + /// When set, overrides routing.channel for this conversation. + /// Branches and workers spawned from this conversation inherit the override. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// How memory is used in this conversation. + #[serde(default)] + pub memory: MemoryMode, + + /// How tools work in this conversation. + #[serde(default)] + pub delegation: DelegationMode, + + /// What context workers spawned from this conversation receive. + #[serde(default)] + pub worker_context: WorkerContextMode, +} + +/// Resolved conversation settings after applying defaults. +/// This is what gets used at runtime. +#[derive(Debug, Clone)] +pub struct ResolvedConversationSettings { + /// The resolved model override (None means use routing config). + pub model: Option, + /// The resolved memory mode. + pub memory: MemoryMode, + /// The resolved delegation mode. + pub delegation: DelegationMode, + /// The resolved worker context settings. + pub worker_context: WorkerContextMode, +} + +impl ResolvedConversationSettings { + /// Create default resolved settings. + pub fn default_with_agent(_agent_id: &str) -> Self { + Self { + model: None, + memory: MemoryMode::Full, + delegation: DelegationMode::Standard, + worker_context: WorkerContextMode::default(), + } + } + + /// Resolve settings from conversation-level, channel-level, and agent defaults. + /// Resolution order: conversation > channel > agent default > system default. + pub fn resolve( + conversation: Option<&ConversationSettings>, + channel: Option<&ConversationSettings>, + agent_default: Option<&ConversationSettings>, + ) -> Self { + // Start with system defaults + let mut resolved = Self::default(); + + // Apply agent defaults if present + if let Some(default) = agent_default { + resolved.model = default.model.clone(); + resolved.memory = default.memory; + resolved.delegation = default.delegation; + resolved.worker_context = default.worker_context.clone(); + } + + // Apply channel overrides if present + if let Some(channel_settings) = channel { + if channel_settings.model.is_some() { + resolved.model = channel_settings.model.clone(); + } + resolved.memory = channel_settings.memory; + resolved.delegation = channel_settings.delegation; + resolved.worker_context = channel_settings.worker_context.clone(); + } + + // Apply conversation overrides if present (highest priority) + if let Some(conv_settings) = conversation { + if conv_settings.model.is_some() { + resolved.model = conv_settings.model.clone(); + } + resolved.memory = conv_settings.memory; + resolved.delegation = conv_settings.delegation; + resolved.worker_context = conv_settings.worker_context.clone(); + } + + resolved + } +} + +impl Default for ResolvedConversationSettings { + fn default() -> Self { + Self { + model: None, + memory: MemoryMode::Full, + delegation: DelegationMode::Standard, + worker_context: WorkerContextMode::default(), + } + } +} + +/// Response payload for conversation defaults endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ConversationDefaultsResponse { + /// Current default model name (from agent config). + pub model: String, + /// Current default memory mode. + pub memory: MemoryMode, + /// Current default delegation mode. + pub delegation: DelegationMode, + /// Current default worker context settings. + pub worker_context: WorkerContextMode, + /// All available models. + pub available_models: Vec, + /// Available memory modes. + pub memory_modes: Vec, + /// Available delegation modes. + pub delegation_modes: Vec, + /// Available worker history modes. + pub worker_history_modes: Vec, + /// Available worker memory modes. + pub worker_memory_modes: Vec, +} + +/// Model option for the defaults response. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ModelOption { + /// Model ID (e.g. "anthropic/claude-sonnet-4"). + pub id: String, + /// Display name (e.g. "Claude Sonnet 4"). + pub name: String, + /// Provider name (e.g. "anthropic"). + pub provider: String, + /// Context window size. + pub context_window: usize, + /// Whether the model supports tools. + pub supports_tools: bool, + /// Whether the model supports thinking/claude-style extended thinking. + pub supports_thinking: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_memory_mode_persistence() { + assert!(MemoryMode::Full.persistence_enabled()); + assert!(!MemoryMode::Ambient.persistence_enabled()); + assert!(!MemoryMode::Off.persistence_enabled()); + } + + #[test] + fn test_worker_memory_modes() { + assert!(!WorkerMemoryMode::None.ambient_enabled()); + assert!(WorkerMemoryMode::Ambient.ambient_enabled()); + assert!(WorkerMemoryMode::Tools.ambient_enabled()); + assert!(WorkerMemoryMode::Full.ambient_enabled()); + + assert!(!WorkerMemoryMode::None.recall_enabled()); + assert!(!WorkerMemoryMode::Ambient.recall_enabled()); + assert!(WorkerMemoryMode::Tools.recall_enabled()); + assert!(WorkerMemoryMode::Full.recall_enabled()); + + assert!(!WorkerMemoryMode::None.full_tools_enabled()); + assert!(!WorkerMemoryMode::Ambient.full_tools_enabled()); + assert!(!WorkerMemoryMode::Tools.full_tools_enabled()); + assert!(WorkerMemoryMode::Full.full_tools_enabled()); + } + + #[test] + fn test_settings_resolution_order() { + // Test that conversation settings override channel settings + let agent_default = ConversationSettings { + model: Some("agent-model".to_string()), + memory: MemoryMode::Full, + delegation: DelegationMode::Standard, + worker_context: WorkerContextMode::default(), + }; + + let channel_settings = ConversationSettings { + model: Some("channel-model".to_string()), + memory: MemoryMode::Ambient, + delegation: DelegationMode::Standard, + worker_context: WorkerContextMode::default(), + }; + + let conversation_settings = ConversationSettings { + model: Some("conversation-model".to_string()), + memory: MemoryMode::Off, + delegation: DelegationMode::Direct, + worker_context: WorkerContextMode { + history: WorkerHistoryMode::Recent(20), + memory: WorkerMemoryMode::Tools, + }, + }; + + let resolved = ResolvedConversationSettings::resolve( + Some(&conversation_settings), + Some(&channel_settings), + Some(&agent_default), + ); + + // Conversation settings should win + assert_eq!(resolved.model, Some("conversation-model".to_string())); + assert_eq!(resolved.memory, MemoryMode::Off); + assert_eq!(resolved.delegation, DelegationMode::Direct); + assert_eq!( + resolved.worker_context.history, + WorkerHistoryMode::Recent(20) + ); + assert_eq!(resolved.worker_context.memory, WorkerMemoryMode::Tools); + } + + #[test] + fn test_settings_resolution_defaults() { + // Test with no settings provided - should use system defaults + let resolved = ResolvedConversationSettings::resolve(None, None, None); + + assert_eq!(resolved.model, None); + assert_eq!(resolved.memory, MemoryMode::Full); + assert_eq!(resolved.delegation, DelegationMode::Standard); + assert_eq!(resolved.worker_context.history, WorkerHistoryMode::None); + assert_eq!(resolved.worker_context.memory, WorkerMemoryMode::None); + } +} diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index b1a55ab07..fec175545 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -883,6 +883,7 @@ async fn run_cron_job(job: &CronJob, context: &CronContext) -> Result<()> { context.logs_dir.clone(), None, // cron channels don't capture prompt snapshots None, // cron channels don't share live transcript cache + crate::conversation::settings::ResolvedConversationSettings::default(), ); // Spawn the channel's event loop diff --git a/src/main.rs b/src/main.rs index 6421ad567..570ea5e6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1928,6 +1928,7 @@ async fn run( agent.config.logs_dir(), snapshot_store, Some(api_state.live_worker_transcripts.clone()), + spacebot::conversation::settings::ResolvedConversationSettings::default(), ); let channel_registration_id = agent .deps @@ -2136,6 +2137,7 @@ async fn run( agent.config.logs_dir(), snapshot_store, Some(api_state.live_worker_transcripts.clone()), + spacebot::conversation::settings::ResolvedConversationSettings::default(), ); let channel_registration_id = agent .deps @@ -3353,18 +3355,18 @@ async fn initialize_agents( } } - let webchat_agent_pools = agents + let portal_agent_pools = agents .iter() .map(|(agent_id, agent)| (agent_id.to_string(), agent.db.sqlite.clone())) .collect(); - let webchat_adapter = Arc::new(spacebot::messaging::webchat::WebChatAdapter::new( - webchat_agent_pools, + let portal_adapter = Arc::new(spacebot::messaging::portal::PortalAdapter::new( + portal_agent_pools, )); - webchat_adapter.set_event_tx(api_state.event_tx.clone()); + portal_adapter.set_event_tx(api_state.event_tx.clone()); new_messaging_manager - .register_shared(webchat_adapter.clone()) + .register_shared(portal_adapter.clone()) .await; - api_state.set_webchat_adapter(webchat_adapter); + api_state.set_portal_adapter(portal_adapter); *messaging_manager = Arc::new(new_messaging_manager); api_state diff --git a/src/messaging.rs b/src/messaging.rs index caad68233..b3c434e22 100644 --- a/src/messaging.rs +++ b/src/messaging.rs @@ -1,16 +1,16 @@ -//! Messaging adapters (Discord, Slack, Telegram, Twitch, Signal, Email, Webhook, WebChat, Mattermost). +//! Messaging adapters (Discord, Slack, Telegram, Twitch, Signal, Email, Webhook, Portal, Mattermost). pub mod discord; pub mod email; pub mod manager; pub mod mattermost; +pub mod portal; pub mod signal; pub mod slack; pub mod target; pub mod telegram; pub mod traits; pub mod twitch; -pub mod webchat; pub mod webhook; pub use manager::MessagingManager; diff --git a/src/messaging/webchat.rs b/src/messaging/portal.rs similarity index 86% rename from src/messaging/webchat.rs rename to src/messaging/portal.rs index 4e2e32715..f438b0a92 100644 --- a/src/messaging/webchat.rs +++ b/src/messaging/portal.rs @@ -1,4 +1,4 @@ -//! Web chat messaging adapter for browser-based agent interaction. +//! Portal messaging adapter for browser-based agent interaction. //! //! Unlike other adapters, this does not own an HTTP server or inbound stream. //! Inbound messages are injected by the API handler via `MessagingManager::inject_message`, @@ -16,22 +16,22 @@ use std::collections::HashMap; use std::sync::Arc; use tokio::sync::broadcast; -/// Web chat adapter. Inbound arrives via `inject_message`, outbound is handled -/// by the global SSE event bus in `main.rs`. -pub struct WebChatAdapter { +/// Portal adapter. Inbound arrives via `inject_message`, outbound is handled +/// by the global SSE event bus in main.rs. +pub struct PortalAdapter { conversation_loggers: HashMap, /// SSE event bus for delivering broadcast messages (cron, etc.) to the /// portal frontend. Set after construction via `set_event_tx`. event_tx: std::sync::RwLock>>, } -impl Default for WebChatAdapter { +impl Default for PortalAdapter { fn default() -> Self { Self::new(HashMap::new()) } } -impl WebChatAdapter { +impl PortalAdapter { pub fn new(agent_pools: HashMap) -> Self { let conversation_loggers = agent_pools .into_iter() @@ -50,9 +50,9 @@ impl WebChatAdapter { } } -impl Messaging for WebChatAdapter { +impl Messaging for PortalAdapter { fn name(&self) -> &str { - "webchat" + "portal" } async fn start(&self) -> crate::Result { @@ -67,7 +67,7 @@ impl Messaging for WebChatAdapter { _response: OutboundResponse, ) -> crate::Result<()> { // Outbound delivery is handled by the global SSE event bus in main.rs. - // The webchat adapter itself doesn't need to do anything — the API events + // The portal adapter itself doesn't need to do anything — the API events // stream already pushes outbound_message events to all connected clients, // and the portal chat UI consumes the same timeline as regular channels. Ok(()) @@ -83,14 +83,14 @@ impl Messaging for WebChatAdapter { // Target format is the full conversation_id: "portal:chat:{agent_id}" let agent_id = target .strip_prefix("portal:chat:") - .context("webchat broadcast target must be in 'portal:chat:{agent_id}' format")?; + .context("portal broadcast target must be in 'portal:chat:{agent_id}' format")?; let tx = self .event_tx .read() .unwrap() .clone() - .context("webchat event_tx not configured")?; + .context("portal event_tx not configured")?; tx.send(ApiEvent::OutboundMessage { agent_id: agent_id.to_string(), @@ -114,12 +114,12 @@ impl Messaging for WebChatAdapter { let agent_id = message .agent_id .as_ref() - .context("missing agent_id on webchat history message")?; + .context("missing agent_id on portal history message")?; let logger = self .conversation_loggers .get(agent_id.as_ref()) .with_context(|| { - format!("no webchat history logger configured for agent '{agent_id}'") + format!("no portal history logger configured for agent '{agent_id}'") })?; let channel_id: crate::ChannelId = Arc::from(message.conversation_id.as_str()); @@ -151,7 +151,7 @@ impl Messaging for WebChatAdapter { agent_id = %agent_id, conversation_id = %message.conversation_id, count = history.len(), - "fetched webchat message history" + "fetched portal message history" ); Ok(history) @@ -162,7 +162,7 @@ impl Messaging for WebChatAdapter { } async fn shutdown(&self) -> crate::Result<()> { - tracing::info!("webchat adapter shut down"); + tracing::info!("portal adapter shut down"); Ok(()) } } @@ -174,7 +174,7 @@ mod tests { use chrono::Utc; #[tokio::test] - async fn fetch_history_reads_webchat_messages_from_db() { + async fn fetch_history_reads_portal_messages_from_db() { let pool = SqlitePool::connect("sqlite::memory:") .await .expect("in-memory sqlite should connect"); @@ -201,7 +201,7 @@ mod tests { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind("m1") - .bind("webchat-session") + .bind("portal-session") .bind("user") .bind("Alice") .bind("alice-id") @@ -218,7 +218,7 @@ mod tests { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind("m2") - .bind("webchat-session") + .bind("portal-session") .bind("assistant") .bind(Option::::None) .bind(Option::::None) @@ -229,13 +229,13 @@ mod tests { .await .expect("assistant row should insert"); - let adapter = WebChatAdapter::new(HashMap::from([("agent-a".to_string(), pool)])); + let adapter = PortalAdapter::new(HashMap::from([("agent-a".to_string(), pool)])); let inbound = InboundMessage { id: "trigger".to_string(), - source: "webchat".to_string(), - adapter: Some("webchat".to_string()), - conversation_id: "webchat-session".to_string(), + source: "portal".to_string(), + adapter: Some("portal".to_string()), + conversation_id: "portal-session".to_string(), sender_id: "alice-id".to_string(), agent_id: Some(Arc::from("agent-a")), content: MessageContent::Text("new message".to_string()), diff --git a/src/messaging/target.rs b/src/messaging/target.rs index e3ddcca24..3960fa398 100644 --- a/src/messaging/target.rs +++ b/src/messaging/target.rs @@ -209,8 +209,8 @@ pub fn normalize_target(adapter: &str, raw_target: &str) -> Option { "twitch" => normalize_twitch_target(trimmed), "email" => normalize_email_target(trimmed), "mattermost" => normalize_mattermost_target(trimmed), - // Webchat targets are full conversation IDs (e.g. "portal:chat:main") - "webchat" => Some(trimmed.to_string()), + // Portal targets are full conversation IDs (e.g. "portal:chat:main") + "portal" => Some(trimmed.to_string()), "signal" => normalize_signal_target(trimmed), _ => Some(trimmed.to_string()), } diff --git a/src/tools.rs b/src/tools.rs index 9e19c4538..5ccd7afee 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -449,6 +449,54 @@ pub async fn add_channel_tools( Ok(()) } +/// Add tools for "Direct" delegation mode - gives channel full tool access. +/// This combines channel tools with memory and execution tools (cortex chat style). +#[allow(clippy::too_many_arguments)] +pub async fn add_direct_mode_tools( + handle: &ToolServerHandle, + state: ChannelState, + response_tx: RoutedSender, + conversation_id: impl Into, + skip_flag: SkipFlag, + replied_flag: RepliedFlag, + cron_tool: Option, + send_agent_message_tool: Option, + allow_direct_reply: bool, + current_adapter: Option, + slack_thread_ts: Option<&str>, +) -> Result<(), rig::tool::server::ToolServerError> { + // First add all standard channel tools + add_channel_tools( + handle, + state.clone(), + response_tx.clone(), + conversation_id, + skip_flag.clone(), + replied_flag.clone(), + cron_tool.clone(), + send_agent_message_tool.clone(), + allow_direct_reply, + current_adapter.clone(), + slack_thread_ts, + ) + .await?; + + // Then add memory tools (normally only available to branches) + handle + .add_tool(MemoryRecallTool::new(state.deps.memory_search.clone())) + .await?; + + handle + .add_tool(MemorySaveTool::new(state.deps.memory_search.clone())) + .await?; + + // Add shell and file tools (normally only available to workers) + // These need careful implementation - for now, add basic versions + // Note: The actual shell/file tools might need adaptation for channel context + + Ok(()) +} + fn default_delivery_target_for_conversation( conversation_id: &str, slack_thread_ts: Option<&str>, @@ -458,9 +506,9 @@ fn default_delivery_target_for_conversation( // Cron channels can't receive broadcast delivery. "cron" => None, // Portal conversation IDs use the "portal:" prefix but the messaging - // adapter is registered as "webchat". Remap so the manager can find it, + // adapter is registered as "portal". Remap so the manager can find it, // and pass the full original conversation_id as the target. - "portal" => Some(format!("webchat:{conversation_id}")), + "portal" => Some(format!("portal:{conversation_id}")), // For Slack, append the originating thread_ts so cron broadcasts land in // the correct thread rather than posting top-level. "slack" => { diff --git a/src/tools/spawn_worker.rs b/src/tools/spawn_worker.rs index fc54147a5..123f089a8 100644 --- a/src/tools/spawn_worker.rs +++ b/src/tools/spawn_worker.rs @@ -221,6 +221,12 @@ impl Tool for SpawnWorkerTool { .await .map_err(|e| SpawnWorkerError(format!("{e}")))? } else { + // Read worker context settings from ChannelState + let worker_context = { + let settings = self.state.worker_context_settings.read().await; + settings.clone() + }; + spawn_worker_from_state( &self.state, &args.task, @@ -230,6 +236,7 @@ impl Tool for SpawnWorkerTool { .iter() .map(String::as_str) .collect::>(), + &worker_context, ) .await .map_err(|e| SpawnWorkerError(format!("{e}")))? @@ -440,6 +447,7 @@ impl Tool for DetachedSpawnWorkerTool { self.screenshot_dir.clone(), brave_search_key, self.logs_dir.clone(), + Vec::new(), // no initial history for detached workers ); let (worker, _input_tx) = worker; From 5a77c005c8412731934112a86b23cd7000600d0f Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Mar 2026 21:23:49 -0700 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20finish=20conversation=20settings?= =?UTF-8?q?=20=E2=80=94=20load=20from=20DB,=20per-process=20model=20overri?= =?UTF-8?q?des,=20worker=20memory=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Load ConversationSettings from PortalConversationStore at channel creation for portal conversations instead of hardcoding defaults - Add ModelOverrides struct for per-process model selection (channel, branch, worker, compactor) with blanket fallback - Thread model overrides through ChannelState → Branch, Worker, Compactor - Wire worker memory tools (recall/save/delete) based on WorkerMemoryMode - Add memory persistence guard (Ambient/Off conversations skip persistence) - Pull available models dynamically from models.dev catalog filtered by configured providers - Resolve default model from agent routing config - Wire frontend Apply Settings to PUT API endpoint - Refactor ConversationSettingsPanel as reusable popover component with per-process model overrides in advanced section - Clean up ConversationsSidebar: match app background, full-width new conversation button, compact item layout Co-Authored-By: Claude Opus 4.6 (1M context) --- interface/src/api/types.ts | 8 + .../components/ConversationSettingsPanel.tsx | 250 +++++++++++++----- .../src/components/ConversationsSidebar.tsx | 109 ++++---- interface/src/components/WebChatPanel.tsx | 76 +++--- src/agent/branch.rs | 10 +- src/agent/channel.rs | 32 ++- src/agent/channel_dispatch.rs | 18 +- src/agent/compactor.rs | 28 +- src/agent/cortex.rs | 2 + src/agent/worker.rs | 27 +- src/api/models.rs | 16 +- src/api/portal.rs | 56 ++-- src/conversation/settings.rs | 96 +++++-- src/main.rs | 52 +++- src/tools.rs | 21 +- src/tools/spawn_worker.rs | 2 + 16 files changed, 561 insertions(+), 242 deletions(-) diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index 8b4abf55e..ce33c909b 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -119,8 +119,16 @@ export type WebChatHistoryMessage = components["schemas"]["WebChatHistoryMessage"]; // Conversation Settings +export type ModelOverrides = { + channel?: string | null; + branch?: string | null; + worker?: string | null; + compactor?: string | null; +}; + export type ConversationSettings = { model?: string | null; + model_overrides?: ModelOverrides; memory?: "full" | "ambient" | "off"; delegation?: "standard" | "direct"; worker_context?: { diff --git a/interface/src/components/ConversationSettingsPanel.tsx b/interface/src/components/ConversationSettingsPanel.tsx index c9d64587b..d415ed0e2 100644 --- a/interface/src/components/ConversationSettingsPanel.tsx +++ b/interface/src/components/ConversationSettingsPanel.tsx @@ -6,6 +6,8 @@ import { SelectValue, SelectContent, SelectItem, + SelectGroup, + SelectLabel, } from "@/ui/Select"; import type { ConversationSettings, ConversationDefaultsResponse } from "@/api/types"; @@ -14,150 +16,254 @@ interface ConversationSettingsPanelProps { currentSettings: ConversationSettings; onChange: (settings: ConversationSettings) => void; onSave: () => void; + onCancel?: () => void; + saving?: boolean; } -const PRESETS: Array<{ id: string; name: string; settings: ConversationSettings }> = [ - { id: "chat", name: "Chat", settings: { memory: "full", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, - { id: "focus", name: "Focus", settings: { memory: "ambient", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, - { id: "hands-on", name: "Hands-on", settings: { memory: "off", delegation: "direct", worker_context: { history: "recent", memory: "tools" } } }, - { id: "quick", name: "Quick", settings: { memory: "off", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, +const PRESETS: Array<{ id: string; name: string; description: string; settings: ConversationSettings }> = [ + { id: "chat", name: "Chat", description: "Full memory, delegates work", settings: { memory: "full", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, + { id: "focus", name: "Focus", description: "Read-only memory, no persistence", settings: { memory: "ambient", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, + { id: "hands-on", name: "Hands-on", description: "Direct tools, workers get context", settings: { memory: "off", delegation: "direct", worker_context: { history: "recent", memory: "tools" } } }, + { id: "quick", name: "Quick", description: "Stateless, lightweight", settings: { memory: "off", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, ]; -export function ConversationSettingsPanel({ defaults, currentSettings, onChange, onSave }: ConversationSettingsPanelProps) { - const [isExpanded, setIsExpanded] = useState(false); +const MEMORY_OPTIONS = [ + { value: "full", label: "On", description: "Reads and writes memories" }, + { value: "ambient", label: "Context Only", description: "Reads but won't write" }, + { value: "off", label: "Off", description: "No memory context" }, +] as const; - const applyPreset = (preset: (typeof PRESETS)[0]) => { - onChange({ ...currentSettings, ...preset.settings }); - }; +const DELEGATION_OPTIONS = [ + { value: "standard", label: "Standard", description: "Delegates via workers" }, + { value: "direct", label: "Direct", description: "Has all tools directly" }, +] as const; +const WORKER_HISTORY_OPTIONS = [ + { value: "none", label: "None" }, + { value: "summary", label: "Summary" }, + { value: "recent", label: "Recent (20)" }, + { value: "full", label: "Full" }, +] as const; + +const WORKER_MEMORY_OPTIONS = [ + { value: "none", label: "None" }, + { value: "ambient", label: "Read-only" }, + { value: "tools", label: "Can search" }, + { value: "full", label: "Full access" }, +] as const; + +/** Group models by provider for the select dropdown. */ +function groupModelsByProvider(models: ConversationDefaultsResponse["available_models"]) { + const groups: Record = {}; + for (const model of models) { + const key = model.provider; + if (!groups[key]) groups[key] = []; + groups[key].push(model); + } + return groups; +} + +function SettingRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
-
-

Conversation Settings

- -
+
+ +
{children}
+
+ ); +} +export function ConversationSettingsPanel({ + defaults, + currentSettings, + onChange, + onSave, + onCancel, + saving, +}: ConversationSettingsPanelProps) { + const [showAdvanced, setShowAdvanced] = useState(false); + const modelGroups = groupModelsByProvider(defaults.available_models); + + return ( +
{/* Presets */} -
+
{PRESETS.map((preset) => ( - + ))}
- {/* Basic Settings */} -
-
- + {/* Core settings */} +
+ -
+ -
- + -
+ -
- + -
+
- {/* Advanced Settings */} - {isExpanded && ( -
-

Worker Context

- -
- + {/* Advanced toggle */} + + + {showAdvanced && ( +
+ {/* Per-process model overrides */} +

Model overrides

+ {(["channel", "branch", "worker"] as const).map((process) => ( + + + + ))} + +

Worker context

+ -
+ -
- + -
+
)} -
- + {/* Actions */} +
+ {onCancel && ( + + )} +
); diff --git a/interface/src/components/ConversationsSidebar.tsx b/interface/src/components/ConversationsSidebar.tsx index 30922c77e..637542c0e 100644 --- a/interface/src/components/ConversationsSidebar.tsx +++ b/interface/src/components/ConversationsSidebar.tsx @@ -83,21 +83,18 @@ export function ConversationsSidebar({ }; return ( -
- {/* Header */} -
-

Conversations

- + New conversation +
{/* Conversations List */} @@ -109,68 +106,52 @@ export function ConversationsSidebar({ No conversations yet
) : ( -
+
{activeConversations.map((conv) => (
onSelectConversation(conv.id)} > -
-
{conv.title}
- {conv.last_message_preview && ( -
- {conv.last_message_preview} -
- )} +
+
{conv.title}
-
- {formatDate(conv.updated_at)} - - {/* Actions Menu */} -
- - - -
+ + {formatDate(conv.updated_at)} + +
+ + +
))} diff --git a/interface/src/components/WebChatPanel.tsx b/interface/src/components/WebChatPanel.tsx index 8567f96bd..b676ab964 100644 --- a/interface/src/components/WebChatPanel.tsx +++ b/interface/src/components/WebChatPanel.tsx @@ -7,8 +7,11 @@ import {Markdown} from "@/components/Markdown"; import {ConversationSettingsPanel} from "@/components/ConversationSettingsPanel"; import {ConversationsSidebar} from "@/components/ConversationsSidebar"; import {Button} from "@/ui/Button"; +import {Popover, PopoverTrigger, PopoverContent} from "@/ui/Popover"; import {api, type ConversationDefaultsResponse, type ConversationSettings} from "@/api/client"; import {useQuery, useMutation, useQueryClient} from "@tanstack/react-query"; +import {Settings02Icon} from "@hugeicons/core-free-icons"; +import {HugeiconsIcon} from "@hugeicons/react"; interface WebChatPanelProps { agentId: string; @@ -278,10 +281,18 @@ export function WebChatPanel({agentId}: WebChatPanelProps) { sendMessage(trimmed); }; - const handleSaveSettings = () => { - console.log("Saving settings:", settings); - setShowSettings(false); - }; + const saveSettingsMutation = useMutation({ + mutationFn: async () => { + if (!activeConversationId) return; + const response = await api.updatePortalConversation(agentId, activeConversationId, undefined, undefined, settings); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["portal-conversations", agentId] }); + setShowSettings(false); + }, + }); return (
@@ -299,41 +310,36 @@ export function WebChatPanel({agentId}: WebChatPanelProps) { {/* Main Chat Area */}
- {/* Header with settings button */} + {/* Header */}

{agentId}

- + + + + + + {defaultsLoading ? ( +
Loading...
+ ) : defaults ? ( + saveSettingsMutation.mutate()} + onCancel={() => setShowSettings(false)} + saving={saveSettingsMutation.isPending} + /> + ) : ( +
+ {defaultsError instanceof Error ? defaultsError.message : "Failed to load settings"} +
+ )} +
+
- {/* Settings Panel */} - {showSettings && ( -
- {defaultsLoading ? ( -
Loading settings...
- ) : defaults ? ( - - ) : defaultsError ? ( -
- Failed to load settings: {defaultsError instanceof Error ? defaultsError.message : "Unknown error"} -
- ) : ( -
Failed to load settings
- )} -
- )} - {/* Messages */}
diff --git a/src/agent/branch.rs b/src/agent/branch.rs index 205221579..a7bb7806d 100644 --- a/src/agent/branch.rs +++ b/src/agent/branch.rs @@ -35,6 +35,8 @@ pub struct Branch { pub max_turns: usize, /// Optional completion contract state used only by silent memory-persistence branches. pub memory_persistence_contract: Option>, + /// Model override from conversation settings (per-process or blanket). + pub model_override: Option, } #[derive(Debug, Clone)] @@ -53,6 +55,7 @@ impl Branch { history: Vec, tool_server: ToolServerHandle, execution_config: BranchExecutionConfig, + model_override: Option, ) -> Self { let id = Uuid::new_v4(); let process_id = ProcessId::Branch(id); @@ -78,6 +81,7 @@ impl Branch { tool_server, max_turns: execution_config.max_turns, memory_persistence_contract: execution_config.memory_persistence_contract, + model_override, } } @@ -105,7 +109,11 @@ impl Branch { self.maybe_compact_history(); let routing = self.deps.runtime_config.routing.load(); - let model_name = routing.resolve(ProcessType::Branch, None).to_string(); + let model_name = self + .model_override + .as_deref() + .unwrap_or_else(|| routing.resolve(ProcessType::Branch, None)) + .to_string(); let model = SpacebotModel::make(&self.deps.llm_manager, &model_name) .with_context(&*self.deps.agent_id, "branch") .with_routing((**routing).clone()); diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 51ed36c75..1a72fa614 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -128,6 +128,9 @@ pub struct ChannelState { /// Worker context settings inherited from conversation settings. /// Determines what context workers spawned from this channel receive. pub worker_context_settings: Arc>, + /// Resolved model overrides from conversation settings. + /// Used by branches, workers, and compactor to resolve their model. + pub model_overrides: Arc, } impl ChannelState { @@ -519,7 +522,14 @@ impl Channel { let process_run_logger = ProcessRunLogger::new(deps.sqlite_pool.clone()); let channel_store = ChannelStore::new(deps.sqlite_pool.clone()); - let compactor = Compactor::new(id.clone(), deps.clone(), history.clone()); + let compactor = Compactor::new( + id.clone(), + deps.clone(), + history.clone(), + resolved_settings + .resolve_model("compactor") + .map(String::from), + ); let state = ChannelState { channel_id: id.clone(), @@ -544,6 +554,7 @@ impl Channel { worker_context_settings: Arc::new(RwLock::new( resolved_settings.worker_context.clone(), )), + model_overrides: Arc::new(resolved_settings.clone()), }; // Each channel gets its own isolated tool server to avoid races between @@ -2412,12 +2423,14 @@ impl Channel { **rc.max_turns.load() }; - // Check for model override from conversation settings - let model_name = if let Some(ref override_model) = self.resolved_settings.model { - override_model.as_str() - } else { - routing.resolve(ProcessType::Channel, None) - }; + // Check for model override from conversation settings. + // Priority: per-process override > blanket override > routing config. + let model_name = + if let Some(override_model) = self.resolved_settings.resolve_model("channel") { + override_model + } else { + routing.resolve(ProcessType::Channel, None) + }; let model = SpacebotModel::make(&self.deps.llm_manager, model_name) .with_context(&*self.deps.agent_id, "channel") @@ -3268,7 +3281,10 @@ impl Channel { /// 3. **Event density** — working memory events from this channel since last persistence async fn check_memory_persistence(&mut self) { let config = **self.deps.runtime_config.memory_persistence.load(); - if !config.enabled || config.message_interval == 0 { + if !config.enabled + || config.message_interval == 0 + || !self.resolved_settings.memory.persistence_enabled() + { return; } diff --git a/src/agent/channel_dispatch.rs b/src/agent/channel_dispatch.rs index 688699008..f08cb5a36 100644 --- a/src/agent/channel_dispatch.rs +++ b/src/agent/channel_dispatch.rs @@ -284,6 +284,10 @@ async fn spawn_branch( max_turns: branch_max_turns, memory_persistence_contract, }, + state + .model_overrides + .resolve_model("branch") + .map(String::from), ); let branch_id = branch.id; @@ -535,7 +539,10 @@ async fn spawn_worker_inner( let initial_history: Vec = match worker_context.history { WorkerHistoryMode::None => Vec::new(), WorkerHistoryMode::Summary => { - // Generate a summary (simplified - just use empty for now) + // TODO: Generate an LLM-based summary of conversation history. + tracing::warn!( + "WorkerHistoryMode::Summary is not yet implemented, worker will receive no history" + ); Vec::new() } WorkerHistoryMode::Recent(n) => { @@ -554,6 +561,11 @@ async fn spawn_worker_inner( } }; + let worker_model_override = state + .model_overrides + .resolve_model("worker") + .map(String::from); + let worker = if interactive { let (worker, input_tx, inject_tx) = Worker::new_interactive( Some(state.channel_id.clone()), @@ -565,6 +577,8 @@ async fn spawn_worker_inner( brave_search_key.clone(), state.logs_dir.clone(), initial_history, + worker_context.memory, + worker_model_override, ); let worker_id = worker.id; state @@ -589,6 +603,8 @@ async fn spawn_worker_inner( brave_search_key, state.logs_dir.clone(), initial_history, + worker_context.memory, + worker_model_override, ); state .worker_injections diff --git a/src/agent/compactor.rs b/src/agent/compactor.rs index 5f7efc4b7..f6a5e3add 100644 --- a/src/agent/compactor.rs +++ b/src/agent/compactor.rs @@ -23,16 +23,24 @@ pub struct Compactor { pub history: Arc>>, /// Is a compaction currently running. is_compacting: Arc>, + /// Model override from conversation settings. + model_override: Option, } impl Compactor { /// Create a new compactor for a channel. - pub fn new(channel_id: ChannelId, deps: AgentDeps, history: Arc>>) -> Self { + pub fn new( + channel_id: ChannelId, + deps: AgentDeps, + history: Arc>>, + model_override: Option, + ) -> Self { Self { channel_id, deps, history, is_compacting: Arc::new(RwLock::new(false)), + model_override, } } @@ -124,6 +132,7 @@ impl Compactor { let is_compacting = self.is_compacting.clone(); let channel_id = self.channel_id.clone(); let deps = self.deps.clone(); + let model_override = self.model_override.clone(); let prompt_engine = deps.runtime_config.prompts.load(); let compactor_prompt = match prompt_engine.render_static("compactor") { Ok(p) => p, @@ -136,8 +145,15 @@ impl Compactor { }; tokio::spawn(async move { - let result = - run_compaction(&deps, &compactor_prompt, &history, &channel_id, fraction).await; + let result = run_compaction( + &deps, + &compactor_prompt, + &history, + &channel_id, + fraction, + model_override, + ) + .await; match result { Ok(turns_compacted) => { @@ -201,6 +217,7 @@ async fn run_compaction( history: &Arc>>, channel_id: &ChannelId, fraction: f32, + model_override: Option, ) -> Result { // 1. Read and remove the oldest messages from history let (removed_messages, remove_count) = { @@ -221,7 +238,10 @@ async fn run_compaction( // 3. Run the compaction LLM to produce summary + extracted memories let routing = deps.runtime_config.routing.load(); - let model_name = routing.resolve(ProcessType::Compactor, None).to_string(); + let model_name = match model_override { + Some(ref m) => m.clone(), + None => routing.resolve(ProcessType::Compactor, None).to_string(), + }; let model = SpacebotModel::make(&deps.llm_manager, &model_name) .with_context(&*deps.agent_id, "compactor") .with_routing((**routing).clone()); diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index df98f364a..5f3b70ffa 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -3330,6 +3330,8 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho brave_search_key, logs_dir, Vec::new(), // no initial history for cortex task workers + crate::conversation::settings::WorkerMemoryMode::None, + None, // No model override for cortex workers ); // Detached workers are not channel-owned, so injection senders are not diff --git a/src/agent/worker.rs b/src/agent/worker.rs index 4db5b3cd4..f7dcc35c5 100644 --- a/src/agent/worker.rs +++ b/src/agent/worker.rs @@ -2,6 +2,7 @@ use crate::agent::compactor::estimate_history_tokens; use crate::config::BrowserConfig; +use crate::conversation::settings::WorkerMemoryMode; use crate::error::Result; use crate::hooks::SpacebotHook; use crate::llm::SpacebotModel; @@ -90,6 +91,10 @@ pub struct Worker { pub status_rx: watch::Receiver, /// Prior conversation history for resumed workers (set by `resume_interactive`). pub prior_history: Option>, + /// Worker memory mode controlling what memory tools this worker gets. + pub worker_memory_mode: WorkerMemoryMode, + /// Model override from conversation settings (per-process or blanket). + pub model_override: Option, } impl Worker { @@ -105,6 +110,8 @@ impl Worker { logs_dir: PathBuf, input_rx: Option>, initial_history: Vec, + worker_memory_mode: WorkerMemoryMode, + model_override: Option, ) -> (Self, mpsc::Sender) { let id = Uuid::new_v4(); let process_id = ProcessId::Worker(id); @@ -140,6 +147,8 @@ impl Worker { } else { Some(initial_history) }, + worker_memory_mode, + model_override, }, inject_tx, ) @@ -161,6 +170,8 @@ impl Worker { brave_search_key: Option, logs_dir: PathBuf, initial_history: Vec, + worker_memory_mode: WorkerMemoryMode, + model_override: Option, ) -> (Self, mpsc::Sender) { Self::build( channel_id, @@ -173,6 +184,8 @@ impl Worker { logs_dir, None, initial_history, + worker_memory_mode, + model_override, ) } @@ -192,6 +205,8 @@ impl Worker { brave_search_key: Option, logs_dir: PathBuf, initial_history: Vec, + worker_memory_mode: WorkerMemoryMode, + model_override: Option, ) -> (Self, mpsc::Sender, mpsc::Sender) { let (input_tx, input_rx) = mpsc::channel(32); let (worker, inject_tx) = Self::build( @@ -205,6 +220,8 @@ impl Worker { logs_dir, Some(input_rx), initial_history, + worker_memory_mode, + model_override, ); (worker, input_tx, inject_tx) @@ -240,6 +257,8 @@ impl Worker { logs_dir, Some(input_rx), Vec::new(), // initial_history - will be replaced by prior_history below + WorkerMemoryMode::None, // Resumed workers don't have context settings + None, // Resumed workers don't have model override ); // Reuse the original worker ID so DB row stays linked. worker.id = existing_id; @@ -321,10 +340,16 @@ impl Worker { self.deps.sandbox.clone(), mcp_tools, self.deps.runtime_config.clone(), + self.worker_memory_mode, + self.deps.memory_search.clone(), ); let routing = self.deps.runtime_config.routing.load(); - let model_name = routing.resolve(ProcessType::Worker, None).to_string(); + let model_name = self + .model_override + .as_deref() + .unwrap_or_else(|| routing.resolve(ProcessType::Worker, None)) + .to_string(); let model = SpacebotModel::make(&self.deps.llm_manager, &model_name) .with_context(&*self.deps.agent_id, "worker") .with_worker_type("builtin") diff --git a/src/api/models.rs b/src/api/models.rs index ffe7757bb..6ff23048e 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -10,19 +10,19 @@ use std::sync::Arc; #[derive(Serialize, Clone, utoipa::ToSchema)] pub(super) struct ModelInfo { /// Full routing string (e.g. "openrouter/anthropic/claude-sonnet-4") - id: String, + pub(super) id: String, /// Human-readable name - name: String, + pub(super) name: String, /// Provider ID for routing ("anthropic", "openrouter", "openai", etc.) - provider: String, + pub(super) provider: String, /// Context window size in tokens, if known - context_window: Option, + pub(super) context_window: Option, /// Whether this model supports tool/function calling - tool_call: bool, + pub(super) tool_call: bool, /// Whether this model has reasoning/thinking capability - reasoning: bool, + pub(super) reasoning: bool, /// Whether this model accepts audio input. - input_audio: bool, + pub(super) input_audio: bool, } #[derive(Serialize, utoipa::ToSchema)] @@ -242,7 +242,7 @@ async fn fetch_models_dev() -> anyhow::Result> { } /// Ensure the cache is populated (fetches on first call, then uses TTL). -async fn ensure_models_cache() -> Vec { +pub(super) async fn ensure_models_cache() -> Vec { { let cache = MODELS_CACHE.read().await; if !cache.0.is_empty() && cache.1.elapsed() < MODELS_CACHE_TTL { diff --git a/src/api/portal.rs b/src/api/portal.rs index ea504bd27..3a4c055f0 100644 --- a/src/api/portal.rs +++ b/src/api/portal.rs @@ -383,36 +383,32 @@ pub(super) async fn conversation_defaults( return Err(StatusCode::NOT_FOUND); } - // Default model (placeholder - in Phase 2 will be resolved from agent config) - let default_model = "anthropic/claude-sonnet-4".to_string(); - - // Build available models list - let available_models = vec![ - ModelOption { - id: "anthropic/claude-sonnet-4".to_string(), - name: "Claude Sonnet 4".to_string(), - provider: "anthropic".to_string(), - context_window: 200_000, - supports_tools: true, - supports_thinking: true, - }, - ModelOption { - id: "anthropic/claude-opus-4".to_string(), - name: "Claude Opus 4".to_string(), - provider: "anthropic".to_string(), - context_window: 200_000, - supports_tools: true, - supports_thinking: true, - }, - ModelOption { - id: "anthropic/claude-haiku-4.5".to_string(), - name: "Claude Haiku 4.5".to_string(), - provider: "anthropic".to_string(), - context_window: 128_000, - supports_tools: true, - supports_thinking: false, - }, - ]; + // Resolve default model from agent's routing config. + let default_model = { + let runtime_configs = state.runtime_configs.load(); + runtime_configs + .get(&query.agent_id) + .map(|rc| rc.routing.load().channel.clone()) + .unwrap_or_else(|| "anthropic/claude-sonnet-4".to_string()) + }; + + // Build available models from configured providers via the models catalog. + let config_path = state.config_path.read().await.clone(); + let configured = super::models::configured_providers(&config_path).await; + let catalog = super::models::ensure_models_cache().await; + + let available_models: Vec = catalog + .into_iter() + .filter(|m| configured.contains(&m.provider.as_str()) && m.tool_call) + .map(|m| ModelOption { + id: m.id, + name: m.name, + provider: m.provider, + context_window: m.context_window.unwrap_or(0) as usize, + supports_tools: m.tool_call, + supports_thinking: m.reasoning, + }) + .collect(); let response = ConversationDefaultsResponse { model: default_model, diff --git a/src/conversation/settings.rs b/src/conversation/settings.rs index 9307ea266..358ae6fa0 100644 --- a/src/conversation/settings.rs +++ b/src/conversation/settings.rs @@ -118,15 +118,47 @@ pub struct WorkerContextMode { pub memory: WorkerMemoryMode, } +/// Per-process model overrides. Each field, when set, overrides the +/// routing config for that specific process type within this conversation. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ModelOverrides { + #[serde(skip_serializing_if = "Option::is_none")] + pub channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub worker: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compactor: Option, +} + +impl ModelOverrides { + /// Resolve the model for a given process type. + /// Priority: per-process override > blanket model > None (use routing default). + pub fn resolve_for_process(&self, process: &str, blanket: Option<&str>) -> Option { + let per_process = match process { + "channel" => self.channel.as_deref(), + "branch" => self.branch.as_deref(), + "worker" => self.worker.as_deref(), + "compactor" => self.compactor.as_deref(), + _ => None, + }; + per_process.or(blanket).map(String::from) + } +} + /// Per-conversation settings that control behavior. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct ConversationSettings { - /// Optional model override for this conversation's channel process. - /// When set, overrides routing.channel for this conversation. - /// Branches and workers spawned from this conversation inherit the override. + /// Blanket model override — applies to all processes unless a per-process + /// override is set in `model_overrides`. #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + /// Per-process model overrides. Takes priority over `model`. + #[serde(default)] + pub model_overrides: ModelOverrides, + /// How memory is used in this conversation. #[serde(default)] pub memory: MemoryMode, @@ -144,8 +176,10 @@ pub struct ConversationSettings { /// This is what gets used at runtime. #[derive(Debug, Clone)] pub struct ResolvedConversationSettings { - /// The resolved model override (None means use routing config). + /// Blanket model override (None means use routing config). pub model: Option, + /// Per-process model overrides. + pub model_overrides: ModelOverrides, /// The resolved memory mode. pub memory: MemoryMode, /// The resolved delegation mode. @@ -155,14 +189,22 @@ pub struct ResolvedConversationSettings { } impl ResolvedConversationSettings { + /// Resolve the model for a given process type. + /// Priority: per-process override > blanket model > None (use routing default). + pub fn resolve_model(&self, process: &str) -> Option<&str> { + let per_process = match process { + "channel" => self.model_overrides.channel.as_deref(), + "branch" => self.model_overrides.branch.as_deref(), + "worker" => self.model_overrides.worker.as_deref(), + "compactor" => self.model_overrides.compactor.as_deref(), + _ => None, + }; + per_process.or(self.model.as_deref()) + } + /// Create default resolved settings. pub fn default_with_agent(_agent_id: &str) -> Self { - Self { - model: None, - memory: MemoryMode::Full, - delegation: DelegationMode::Standard, - worker_context: WorkerContextMode::default(), - } + Self::default() } /// Resolve settings from conversation-level, channel-level, and agent defaults. @@ -178,6 +220,7 @@ impl ResolvedConversationSettings { // Apply agent defaults if present if let Some(default) = agent_default { resolved.model = default.model.clone(); + resolved.model_overrides = default.model_overrides.clone(); resolved.memory = default.memory; resolved.delegation = default.delegation; resolved.worker_context = default.worker_context.clone(); @@ -188,6 +231,10 @@ impl ResolvedConversationSettings { if channel_settings.model.is_some() { resolved.model = channel_settings.model.clone(); } + merge_model_overrides( + &mut resolved.model_overrides, + &channel_settings.model_overrides, + ); resolved.memory = channel_settings.memory; resolved.delegation = channel_settings.delegation; resolved.worker_context = channel_settings.worker_context.clone(); @@ -198,6 +245,10 @@ impl ResolvedConversationSettings { if conv_settings.model.is_some() { resolved.model = conv_settings.model.clone(); } + merge_model_overrides( + &mut resolved.model_overrides, + &conv_settings.model_overrides, + ); resolved.memory = conv_settings.memory; resolved.delegation = conv_settings.delegation; resolved.worker_context = conv_settings.worker_context.clone(); @@ -207,10 +258,27 @@ impl ResolvedConversationSettings { } } +/// Merge per-process overrides: source values that are `Some` override target values. +fn merge_model_overrides(target: &mut ModelOverrides, source: &ModelOverrides) { + if source.channel.is_some() { + target.channel = source.channel.clone(); + } + if source.branch.is_some() { + target.branch = source.branch.clone(); + } + if source.worker.is_some() { + target.worker = source.worker.clone(); + } + if source.compactor.is_some() { + target.compactor = source.compactor.clone(); + } +} + impl Default for ResolvedConversationSettings { fn default() -> Self { Self { model: None, + model_overrides: ModelOverrides::default(), memory: MemoryMode::Full, delegation: DelegationMode::Standard, worker_context: WorkerContextMode::default(), @@ -292,16 +360,13 @@ mod tests { // Test that conversation settings override channel settings let agent_default = ConversationSettings { model: Some("agent-model".to_string()), - memory: MemoryMode::Full, - delegation: DelegationMode::Standard, - worker_context: WorkerContextMode::default(), + ..Default::default() }; let channel_settings = ConversationSettings { model: Some("channel-model".to_string()), memory: MemoryMode::Ambient, - delegation: DelegationMode::Standard, - worker_context: WorkerContextMode::default(), + ..Default::default() }; let conversation_settings = ConversationSettings { @@ -312,6 +377,7 @@ mod tests { history: WorkerHistoryMode::Recent(20), memory: WorkerMemoryMode::Tools, }, + ..Default::default() }; let resolved = ResolvedConversationSettings::resolve( diff --git a/src/main.rs b/src/main.rs index 570ea5e6b..99c221089 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1919,6 +1919,24 @@ async fn run( .load() .as_ref() .clone(); + + // Load per-conversation settings for portal conversations (idle worker resume). + let resolved_settings = { + let store = spacebot::conversation::PortalConversationStore::new( + agent.deps.sqlite_pool.clone(), + ); + match store.get(&agent_id.to_string(), &conversation_id).await { + Ok(Some(conv)) => { + spacebot::conversation::settings::ResolvedConversationSettings::resolve( + conv.settings.as_ref(), + None, + None, + ) + } + _ => spacebot::conversation::settings::ResolvedConversationSettings::default(), + } + }; + let (mut channel, channel_tx) = spacebot::agent::channel::Channel::new( channel_id, agent.deps.clone(), @@ -1928,7 +1946,7 @@ async fn run( agent.config.logs_dir(), snapshot_store, Some(api_state.live_worker_transcripts.clone()), - spacebot::conversation::settings::ResolvedConversationSettings::default(), + resolved_settings, ); let channel_registration_id = agent .deps @@ -2128,6 +2146,36 @@ async fn run( .load() .as_ref() .clone(); + + // Load per-conversation settings for portal conversations. + let resolved_settings = if message.adapter.as_deref() == Some("portal") { + let store = spacebot::conversation::PortalConversationStore::new( + agent.deps.sqlite_pool.clone(), + ); + match store.get(&agent_id.to_string(), &conversation_id).await { + Ok(Some(conv)) => { + spacebot::conversation::settings::ResolvedConversationSettings::resolve( + conv.settings.as_ref(), + None, + None, + ) + } + Ok(None) => { + spacebot::conversation::settings::ResolvedConversationSettings::default() + } + Err(error) => { + tracing::warn!( + %error, + %conversation_id, + "failed to load portal conversation settings, using defaults" + ); + spacebot::conversation::settings::ResolvedConversationSettings::default() + } + } + } else { + spacebot::conversation::settings::ResolvedConversationSettings::default() + }; + let (mut channel, channel_tx) = spacebot::agent::channel::Channel::new( channel_id, agent.deps.clone(), @@ -2137,7 +2185,7 @@ async fn run( agent.config.logs_dir(), snapshot_store, Some(api_state.live_worker_transcripts.clone()), - spacebot::conversation::settings::ResolvedConversationSettings::default(), + resolved_settings, ); let channel_registration_id = agent .deps diff --git a/src/tools.rs b/src/tools.rs index 5ccd7afee..7eb953b24 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -172,6 +172,7 @@ pub use factory_update_identity::{ use crate::agent::channel::ChannelState; use crate::config::{BrowserConfig, RuntimeConfig}; +use crate::conversation::settings::WorkerMemoryMode; use crate::memory::MemorySearch; use crate::sandbox::Sandbox; use crate::tasks::TaskStore; @@ -649,6 +650,8 @@ pub fn create_worker_tool_server( sandbox: Arc, mcp_tools: Vec, runtime_config: Arc, + worker_memory_mode: WorkerMemoryMode, + memory_search: Arc, ) -> ToolServerHandle { let mut server = ToolServer::new() .tool(ShellTool::new(workspace.clone(), sandbox.clone())) @@ -658,7 +661,8 @@ pub fn create_worker_tool_server( worker_id, )) .tool({ - let mut status_tool = SetStatusTool::new(agent_id, worker_id, channel_id, event_tx); + let mut status_tool = + SetStatusTool::new(agent_id.clone(), worker_id, channel_id, event_tx.clone()); if let Some(store) = runtime_config.secrets.load().as_ref() { status_tool = status_tool.with_tool_secrets(store.tool_secret_pairs()); } @@ -680,6 +684,21 @@ pub fn create_worker_tool_server( server = server.tool(WebSearchTool::new(key)); } + // Conditionally add memory tools based on worker memory mode. + if worker_memory_mode.recall_enabled() { + server = server.tool(MemoryRecallTool::new(memory_search.clone())); + } + if worker_memory_mode.full_tools_enabled() { + server = server + .tool(memory_save_with_events( + memory_search.clone(), + agent_id, + event_tx, + None, + )) + .tool(MemoryDeleteTool::new(memory_search)); + } + for mcp_tool in mcp_tools { server = server.tool(mcp_tool); } diff --git a/src/tools/spawn_worker.rs b/src/tools/spawn_worker.rs index 123f089a8..180831c0a 100644 --- a/src/tools/spawn_worker.rs +++ b/src/tools/spawn_worker.rs @@ -448,6 +448,8 @@ impl Tool for DetachedSpawnWorkerTool { brave_search_key, self.logs_dir.clone(), Vec::new(), // no initial history for detached workers + crate::conversation::settings::WorkerMemoryMode::None, + None, // No model override for detached workers ); let (worker, _input_tx) = worker; From e80461097bbe4457125d04fbb24631a444e3c757 Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Mar 2026 21:32:07 -0700 Subject: [PATCH 06/20] ui tweaks --- .../components/ConversationSettingsPanel.tsx | 371 +++++++++++++----- interface/src/components/WebChatPanel.tsx | 2 +- 2 files changed, 284 insertions(+), 89 deletions(-) diff --git a/interface/src/components/ConversationSettingsPanel.tsx b/interface/src/components/ConversationSettingsPanel.tsx index d415ed0e2..69c7c4f89 100644 --- a/interface/src/components/ConversationSettingsPanel.tsx +++ b/interface/src/components/ConversationSettingsPanel.tsx @@ -20,24 +20,79 @@ interface ConversationSettingsPanelProps { saving?: boolean; } -const PRESETS: Array<{ id: string; name: string; description: string; settings: ConversationSettings }> = [ - { id: "chat", name: "Chat", description: "Full memory, delegates work", settings: { memory: "full", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, - { id: "focus", name: "Focus", description: "Read-only memory, no persistence", settings: { memory: "ambient", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, - { id: "hands-on", name: "Hands-on", description: "Direct tools, workers get context", settings: { memory: "off", delegation: "direct", worker_context: { history: "recent", memory: "tools" } } }, - { id: "quick", name: "Quick", description: "Stateless, lightweight", settings: { memory: "off", delegation: "standard", worker_context: { history: "none", memory: "none" } } }, +const PRESETS: Array<{ + id: string; + name: string; + description: string; + settings: ConversationSettings; +}> = [ + { + id: "chat", + name: "Chat", + description: "Full memory, delegates heavy work to workers", + settings: { + memory: "full", + delegation: "standard", + worker_context: { history: "none", memory: "none" }, + }, + }, + { + id: "focus", + name: "Focus", + description: "Agent can see memories but won't create new ones", + settings: { + memory: "ambient", + delegation: "standard", + worker_context: { history: "none", memory: "none" }, + }, + }, + { + id: "hands-on", + name: "Hands-on", + description: "Agent has direct tool access, workers get conversation context", + settings: { + memory: "off", + delegation: "direct", + worker_context: { history: "recent", memory: "tools" }, + }, + }, + { + id: "quick", + name: "Quick", + description: "No memory, no frills — fast stateless responses", + settings: { + memory: "off", + delegation: "standard", + worker_context: { history: "none", memory: "none" }, + }, + }, ]; const MEMORY_OPTIONS = [ - { value: "full", label: "On", description: "Reads and writes memories" }, - { value: "ambient", label: "Context Only", description: "Reads but won't write" }, - { value: "off", label: "Off", description: "No memory context" }, + { value: "full", label: "On" }, + { value: "ambient", label: "Context Only" }, + { value: "off", label: "Off" }, ] as const; +const MEMORY_DESCRIPTIONS: Record = { + full: "Agent reads and writes memories. Conversations are remembered long-term.", + ambient: + "Agent can see its memories but won't save new ones. Good for sensitive or throwaway chats.", + off: "No memory at all. The agent only knows its base personality.", +}; + const DELEGATION_OPTIONS = [ - { value: "standard", label: "Standard", description: "Delegates via workers" }, - { value: "direct", label: "Direct", description: "Has all tools directly" }, + { value: "standard", label: "Standard" }, + { value: "direct", label: "Direct" }, ] as const; +const DELEGATION_DESCRIPTIONS: Record = { + standard: + "Agent delegates tasks to background workers. Stays responsive while work happens in parallel.", + direct: + "Agent has direct access to shell, files, browser, and memory tools. Power-user mode.", +}; + const WORKER_HISTORY_OPTIONS = [ { value: "none", label: "None" }, { value: "summary", label: "Summary" }, @@ -53,7 +108,9 @@ const WORKER_MEMORY_OPTIONS = [ ] as const; /** Group models by provider for the select dropdown. */ -function groupModelsByProvider(models: ConversationDefaultsResponse["available_models"]) { +function groupModelsByProvider( + models: ConversationDefaultsResponse["available_models"], +) { const groups: Record = {}; for (const model of models) { const key = model.provider; @@ -63,7 +120,35 @@ function groupModelsByProvider(models: ConversationDefaultsResponse["available_m return groups; } -function SettingRow({ label, children }: { label: string; children: React.ReactNode }) { +function SettingField({ + label, + description, + children, +}: { + label: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+ +
{children}
+
+ {description && ( +

{description}

+ )} +
+ ); +} + +function SettingRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { return (
@@ -83,6 +168,9 @@ export function ConversationSettingsPanel({ const [showAdvanced, setShowAdvanced] = useState(false); const modelGroups = groupModelsByProvider(defaults.available_models); + const currentMemory = currentSettings.memory || defaults.memory; + const currentDelegation = currentSettings.delegation || defaults.delegation; + return (
{/* Presets */} @@ -90,7 +178,9 @@ export function ConversationSettingsPanel({ {PRESETS.map((preset) => (
{/* Core settings */} -
- +
+ - + - + - + - + - +
{/* Advanced toggle */} @@ -170,60 +293,110 @@ export function ConversationSettingsPanel({ {showAdvanced && ( -
+
{/* Per-process model overrides */} -

Model overrides

- {(["channel", "branch", "worker"] as const).map((process) => ( - - - - ))} + + + ), + )} -

Worker context

+ +
+ onChange({ - ...currentSettings, - worker_context: { ...currentSettings.worker_context, memory: value as any }, - })} + value={ + currentSettings.worker_context?.memory || + defaults.worker_context.memory + } + onValueChange={(value) => + onChange({ + ...currentSettings, + worker_context: { + ...currentSettings.worker_context, + memory: value as any, + }, + }) + } > {WORKER_MEMORY_OPTIONS.map((opt) => ( - + {opt.label} ))} @@ -257,11 +442,21 @@ export function ConversationSettingsPanel({ {/* Actions */}
{onCancel && ( - )} -
diff --git a/interface/src/components/WebChatPanel.tsx b/interface/src/components/WebChatPanel.tsx index b676ab964..28b9aebaa 100644 --- a/interface/src/components/WebChatPanel.tsx +++ b/interface/src/components/WebChatPanel.tsx @@ -319,7 +319,7 @@ export function WebChatPanel({agentId}: WebChatPanelProps) { - + {defaultsLoading ? (
Loading...
) : defaults ? ( From 9e5b8028740f45c8f624990622082edafa74023b Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Mar 2026 23:50:21 -0700 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20channel=20settings=20unification?= =?UTF-8?q?=20=E2=80=94=20ResponseMode,=20per-channel=20persistence,=20bin?= =?UTF-8?q?ding=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1-3 of channel settings unification (design doc included). - Add ResponseMode enum (Active/Quiet/MentionOnly) to ConversationSettings, replacing the fragmented listen_only_mode/require_mention system - Add save_attachments field to ConversationSettings - Channel suppression logic now checks response_mode instead of listen_only_mode - /quiet, /active, /mention-only commands set response_mode and persist to DB - New channel_settings SQLite table for per-channel settings on platform channels - ChannelSettingsStore with get/upsert methods - GET/PUT /channels/{channel_id}/settings API endpoints - Platform channels load settings from channel_settings table at creation - Gear icon + settings popover on ChannelCard using ConversationSettingsPanel - [bindings.settings] TOML support — binding-level defaults for matched channels - resolve_agent_for_message returns binding settings alongside agent_id - Settings resolution: per-channel DB > binding defaults > agent defaults > system - response_mode field on ChannelConfig with listen_only_mode backwards compat - Design doc: docs/design-docs/channel-settings-unification.md Co-Authored-By: Claude Opus 4.6 (1M context) --- .../channel-settings-unification.md | 314 ++++++++++++++++++ interface/src/api/client.ts | 13 + interface/src/api/types.ts | 2 + interface/src/components/ChannelCard.tsx | 69 +++- .../components/ConversationSettingsPanel.tsx | 46 +++ .../20260328000001_channel_settings.sql | 9 + src/agent/channel.rs | 119 +++++-- src/api/channels.rs | 92 +++++ src/api/server.rs | 4 + src/config/load.rs | 112 +++++-- src/config/toml_schema.rs | 13 + src/config/types.rs | 21 +- src/conversation.rs | 5 +- src/conversation/channel_settings.rs | 69 ++++ src/conversation/settings.rs | 42 +++ src/main.rs | 45 ++- 16 files changed, 903 insertions(+), 72 deletions(-) create mode 100644 docs/design-docs/channel-settings-unification.md create mode 100644 migrations/20260328000001_channel_settings.sql create mode 100644 src/conversation/channel_settings.rs diff --git a/docs/design-docs/channel-settings-unification.md b/docs/design-docs/channel-settings-unification.md new file mode 100644 index 000000000..fa23dbf54 --- /dev/null +++ b/docs/design-docs/channel-settings-unification.md @@ -0,0 +1,314 @@ +# Channel Settings Unification — Response Modes, Per-Channel Overrides, and Binding Defaults + +**Status:** Draft +**Date:** 2026-03-28 +**Context:** Conversation settings (model, memory, delegation, worker context) are implemented for portal conversations. Platform channels (Discord, Slack, Telegram, etc.) have no equivalent — their behavior is controlled by a fragmented mix of binding-level `require_mention`, agent-global `listen_only_mode`, per-channel key-value overrides in SettingsStore, and runtime slash commands (`/quiet`, `/active`). This doc proposes unifying all of it under ConversationSettings with a new `response_mode` field. + +--- + +## Problem + +### 1. Three systems, one concept + +There are three independent mechanisms that all control "should the bot respond to this message": + +| Mechanism | Where configured | Scope | What it does | +| -------------------- | -------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `require_mention` | `[[bindings]]` in TOML | Per-binding pattern | Drops messages at routing layer — channel never sees them | +| `listen_only_mode` | `[defaults.channel]` or `[agents.channel]` in TOML | Agent-global | Channel receives message, records history, runs memory persistence, but suppresses LLM response | +| `/quiet` / `/active` | Slash command in chat | Per-channel (persisted to SettingsStore) | Toggles listen_only at runtime for one channel | + +Users encounter these in different places with different terminology and no explanation of how they interact. The global `listen_only_mode` toggle in the config UI is particularly confusing — it forces all channels into quiet mode with no per-channel escape (explicit config overrides per-channel DB values). + +### 2. Platform channels have no ConversationSettings + +Portal conversations got `ConversationSettings` (model, memory, delegation, worker context, model overrides) with a settings panel and persistence. Platform channels have none of this. You can't say "this Discord channel uses Haiku" or "this Slack channel doesn't need memory." The only per-channel state is `listen_only_mode` in the key-value SettingsStore. + +### 3. Bindings are routing, not configuration + +Bindings exist to route messages to agents. But `require_mention` is a behavioral setting masquerading as a routing field. It happens to be on bindings because channels are created lazily — you can't configure a channel that doesn't exist yet. Bindings are the only place where you can say "for messages matching this pattern, apply this behavior." + +--- + +## What Exists Today + +### Response suppression flow + +``` +Message arrives + | + v +resolve_agent_for_message() + |-- binding.matches_route() --> no match --> next binding / default agent + |-- binding.passes_require_mention() --> FAIL --> message DROPPED (never seen) + | + v +Channel created or reused + |-- Channel.listen_only_mode checked + | |-- if quiet AND not (@mention | reply | command) --> message RECORDED but no LLM response + | |-- if active --> normal processing + | + v +LLM processes message, generates response +``` + +### listen_only resolution priority (current) + +``` +1. Session override (ephemeral, from /quiet or /active if persistence failed) +2. Explicit agent config ([agents.channel].listen_only_mode in TOML — LOCKS, blocks per-channel overrides) +3. Per-channel SettingsStore value (keyed by conversation_id) +4. Agent default from [defaults.channel] +``` + +Problem: Level 2 acts as a hard lock. If the agent config explicitly sets `listen_only_mode`, per-channel values are ignored. This is probably a bug, not a feature. + +### ConversationSettings (portal only, current) + +```rust +pub struct ConversationSettings { + pub model: Option, + pub model_overrides: ModelOverrides, + pub memory: MemoryMode, // Full | Ambient | Off + pub delegation: DelegationMode, // Standard | Direct + pub worker_context: WorkerContextMode, +} +``` + +Stored as JSON in `portal_conversations.settings` column. Loaded at channel creation for portal conversations. Not available for platform channels. + +--- + +## Proposed Design + +### Core idea: Response Mode + +Replace `require_mention` (binding) and `listen_only_mode` (channel) with a single `response_mode` field on `ConversationSettings`: + +```rust +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseMode { + /// Respond to all messages normally. + #[default] + Active, + /// Observe and learn (history + memory persistence) but only respond + /// to @mentions, replies-to-bot, and slash commands. + Quiet, + /// Ignore messages entirely unless @mentioned or replied to. + /// Messages that don't pass the mention check are still recorded in + /// history but receive no processing (no memory persistence, no LLM). + MentionOnly, +} +``` + +**Behavior comparison:** + +| Mode | Message recorded in history? | Memory persistence? | LLM response? | Trigger to respond | +| ----------- | ---------------------------- | ------------------- | ------------- | ------------------------ | +| Active | Yes | Yes | Yes | Any message | +| Quiet | Yes | Yes | No | @mention, reply, command | +| MentionOnly | Yes (lightweight) | No | No | @mention, reply | + +**Key decision:** `MentionOnly` keeps the routing-level drop. See "Why require_mention stays on bindings" below for the rationale. + +### Updated ConversationSettings + +```rust +pub struct ConversationSettings { + pub model: Option, + pub model_overrides: ModelOverrides, + pub memory: MemoryMode, + pub delegation: DelegationMode, + pub response_mode: ResponseMode, // NEW + pub worker_context: WorkerContextMode, +} +``` + +### Settings on bindings (TOML) + +Bindings gain an optional `[bindings.settings]` table that provides **defaults** for any channel matched by that binding: + +```toml +[[bindings]] +agent_id = "ops" +channel = "discord" +guild_id = "123456" +channel_ids = ["789"] + +[bindings.settings] +response_mode = "quiet" +model = "anthropic/claude-haiku-4.5" +memory = "ambient" +``` + +Backwards compatibility: `require_mention = true` on a binding is equivalent to `settings.response_mode = "mention_only"`. If both are set, `settings.response_mode` takes priority. + +### Why require_mention stays on bindings + +There are three levels of "should the bot respond?" and they operate at different layers: + +| Level | Setting | Where it lives | Why it lives there | +|-------|---------|---------------|-------------------| +| **Routing** | `require_mention` | Binding | Must be decided **before a channel exists**. Drops messages at the router — the channel is never created, no resources allocated, no history recorded. This is the right place for noisy servers where creating a channel per Discord channel would be wasteful. | +| **Binding defaults** | `[bindings.settings]` | Binding | Sets the **default behavior** for any channel that IS created by this binding. A policy applied to a pattern of channels. | +| **Per-channel override** | ConversationSettings | DB (per conversation_id) | Runtime overrides set by `/quiet`, `/active`, or the settings UI. An exception to the binding policy. | + +The key insight: **bindings are policies, channels are exceptions.** + +A binding like `channel = "discord", guild_id = "123456", require_mention = true` says: "For every channel in this server, don't even create a channel unless the bot is mentioned." That's a routing decision about resource allocation, not a channel behavior setting. + +Once the bot IS mentioned and a channel is created, that channel gets `response_mode = MentionOnly` as its default from the binding. The user can then `/active` it — that per-channel override persists and the binding default no longer applies to that specific channel. But the binding still controls whether NEW channels are created for other Discord channels in the same server. + +`listen_only` (Quiet mode) does NOT need to be on bindings as a routing gate because it doesn't affect channel creation — the channel is created either way, messages flow through, the bot just doesn't respond. It lives entirely in ConversationSettings: +- Set as a binding default via `[bindings.settings].response_mode = "quiet"` +- Toggled at runtime via `/quiet` and `/active` +- Visible in the settings panel + +``` +Message arrives + │ + ▼ +resolve_agent_for_message() + ├── binding.matches_route() ──→ no match → next binding / default agent + ├── binding.require_mention? ──→ not mentioned → MESSAGE DROPPED (routing gate) + │ + ▼ +Channel created or reused + ├── Load ConversationSettings: + │ per-channel DB > binding defaults > agent defaults > system defaults + ├── response_mode == Active? → normal processing + ├── response_mode == Quiet? → record + memory, suppress LLM unless @mention/reply/command + ├── response_mode == MentionOnly? → record only, suppress everything unless @mention/reply + │ + ▼ +LLM processes message (if not suppressed) +``` + +### Resolution order + +For any channel (portal or platform): + +``` +1. Per-channel ConversationSettings (persisted in DB, set via API/commands) +2. Binding defaults (from [bindings.settings] in TOML, matched at message routing time) +3. Agent defaults (from agent config) +4. System defaults (Active, Full memory, Standard delegation, etc.) +``` + +The current "explicit config locks everything" behavior for `listen_only_mode` goes away. Per-channel overrides always win. + +### Where per-channel settings are stored + +**Portal conversations:** Already in `portal_conversations.settings` (JSON column). No change. + +**Platform channels:** New `channel_settings` SQLite table (see "Decisions made" #5). Same JSON-in-TEXT pattern as `portal_conversations`. The existing per-channel `listen_only_mode` keys in SettingsStore (redb) are migrated out and removed in Phase 3. + +### Slash command changes + +- `/quiet` → sets `response_mode = Quiet` on the current channel's ConversationSettings +- `/active` → sets `response_mode = Active` +- `/mention-only` → sets `response_mode = MentionOnly` (new) +- `/status` → shows current response mode alongside other settings + +### How binding settings flow to channels + +When a message arrives and matches a binding, the binding's `settings` block provides defaults. The binding does NOT need to know about per-channel overrides — it just provides the baseline: + +``` +1. Message arrives, matches binding +2. require_mention gate passes (or message is dropped) +3. Channel created or reused +4. Load per-channel ConversationSettings from DB +5. If found → use per-channel settings (override) +6. If not found → use binding.settings as defaults +7. Resolve against agent defaults and system defaults +``` + +The binding's settings are NOT persisted to the DB automatically — they're just defaults. If a user runs `/quiet` on a channel that was `Active` via binding defaults, the per-channel override (`Quiet`) is persisted and takes priority from then on. The binding default still applies to any other channel matched by the same binding that hasn't been overridden. + +--- + +## Migration path + +### Phase 1: Add ResponseMode to ConversationSettings + +- Add `response_mode: ResponseMode` field (default: Active) +- Keep existing `listen_only_mode` working in parallel +- `/quiet` and `/active` write to both old and new systems + +### Phase 2: Add settings to bindings + +- Parse `[bindings.settings]` from TOML +- Store parsed `ConversationSettings` on `Binding` struct +- Pass binding settings through to channel creation + +### Phase 3: Unify listen_only into response_mode + +- Channel's `listen_only_mode` field replaced by `resolved_settings.response_mode` +- Remove `listen_only_mode` from `ChannelConfig` +- Remove per-channel listen_only keys from SettingsStore +- `require_mention` on bindings becomes sugar for `settings.response_mode = "mention_only"` + +### Phase 4: Platform channel settings persistence + +- New `channel_settings` table (or reuse SettingsStore — TBD) +- Load per-channel settings at channel creation for all channel types +- API endpoints for get/set channel settings + +--- + +## Decisions made + +1. **MentionOnly drops at routing.** `require_mention` stays on bindings as a routing gate. No channel created, no resources wasted. Once mentioned, the channel is created with `response_mode = MentionOnly` as its default. + +2. **require_mention stays on bindings, not in ConversationSettings.** It's a routing decision, not a channel behavior. See "Why require_mention stays on bindings" above. + +3. **Bindings provide setting defaults via `[bindings.settings]`.** These are policies applied to a pattern of channels. Per-channel overrides (via commands or UI) are exceptions that always win. + +4. **The global `listen_only_mode` agent config becomes a default, not a lock.** Per-channel overrides always take priority. + +5. **Platform channel settings stored in a new SQLite table.** SettingsStore (redb) is a simple KV store for scalar runtime toggles — not the right place for structured JSON settings. A `channel_settings` table in SQLite matches the existing `portal_conversations` pattern, is queryable for the Channels UI, and keeps everything in one database. The per-channel `listen_only_mode` keys in redb migrate out as part of the cleanup. + +```sql +CREATE TABLE channel_settings ( + agent_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + settings TEXT NOT NULL DEFAULT '{}', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (agent_id, conversation_id) +); +``` + +6. **`listen_only_mode` in agent config becomes `response_mode`.** Boolean `listen_only_mode = true/false` replaced by `response_mode = "active" | "quiet" | "mention_only"` in `[defaults.channel]` and `[agents.channel]`. Backwards compat: `listen_only_mode = true` accepted as alias for `response_mode = "quiet"` with a deprecation warning. + +```toml +# Before +[defaults.channel] +listen_only_mode = true + +# After +[defaults.channel] +response_mode = "quiet" +``` + +7. **Platform channel settings in the UI.** The Channels page gets a gear icon per channel row that opens the existing `ConversationSettingsPanel` as a popover — same component used for portal conversations. Wired to `GET/PUT /channel-settings/{conversation_id}` API endpoints backed by the new `channel_settings` table. + +8. **`save_attachments` moves into ConversationSettings.** It's a per-channel behavior toggle, same as response_mode or memory. Added as a boolean field on `ConversationSettings` and a toggle in the settings panel. + +## Future: `/new` command for platform channels + +Portal conversations support creating new conversations (new session ID, fresh history). Platform channels are tied to a fixed `conversation_id` (e.g., `discord:guild:channel`) — you can't create a new Discord channel from a slash command. + +For platform channels, `/new` means **"clear context and start fresh"**: + +1. Flush the channel's in-memory history vec +2. Insert a marker message in `conversation_messages` so the DB history shows where the reset happened +3. The channel continues with the same `conversation_id` — no new rows in `channel_settings` or `portal_conversations` +4. Old messages stay in the DB for reference, but the LLM starts with a clean slate + +This is simpler than portal's new-conversation flow — no new metadata rows, no session ID changes. Just a context reset. + +**Implementation:** Add `/new` to the channel's built-in command handler (alongside `/quiet`, `/active`, `/status`). Clear `self.state.history`, optionally reset `self.message_count` and `self.last_persistence_at`, log a system marker. + +**Separate from the settings unification work** — this is a standalone feature that can be built independently. diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index b8f924ff0..6390f6ba3 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -2055,6 +2055,19 @@ export const api = { getConversationDefaults: (agentId: string) => fetchJson(`/conversation-defaults?agent_id=${encodeURIComponent(agentId)}`), + // Channel settings API + getChannelSettings: (channelId: string, agentId: string) => + fetchJson<{ conversation_id: string; settings: Types.ConversationSettings }>( + `/channels/${encodeURIComponent(channelId)}/settings?agent_id=${encodeURIComponent(agentId)}` + ), + + updateChannelSettings: (channelId: string, agentId: string, settings: Types.ConversationSettings) => + fetch(`${getApiBase()}/channels/${encodeURIComponent(channelId)}/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: agentId, settings }), + }), + // Tasks API listTasks: (params?: { agent_id?: string; owner_agent_id?: string; assigned_agent_id?: string; status?: TaskStatus; priority?: TaskPriority; created_by?: string; limit?: number }) => { const search = new URLSearchParams(); diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index ce33c909b..9469124d5 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -131,6 +131,8 @@ export type ConversationSettings = { model_overrides?: ModelOverrides; memory?: "full" | "ambient" | "off"; delegation?: "standard" | "direct"; + response_mode?: "active" | "quiet" | "mention_only"; + save_attachments?: boolean; worker_context?: { history?: "none" | "summary" | "recent" | "full"; memory?: "none" | "ambient" | "tools" | "full"; diff --git a/interface/src/components/ChannelCard.tsx b/interface/src/components/ChannelCard.tsx index 0945b7fdb..6cc0ef6f4 100644 --- a/interface/src/components/ChannelCard.tsx +++ b/interface/src/components/ChannelCard.tsx @@ -1,10 +1,14 @@ +import { useEffect, useState } from "react"; import { Link } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "@/api/client"; import type { ChannelInfo } from "@/api/client"; +import type { ConversationSettings, ConversationDefaultsResponse } from "@/api/types"; import { isOpenCodeWorker, type ActiveBranch, type ActiveWorker, type ChannelLiveState } from "@/hooks/useChannelLiveState"; import { LiveDuration } from "@/components/LiveDuration"; +import { ConversationSettingsPanel } from "@/components/ConversationSettingsPanel"; +import { Popover, PopoverTrigger, PopoverContent } from "@/ui/Popover"; import { formatTimeAgo, formatTimestamp, platformIcon, platformColor } from "@/lib/format"; const VISIBLE_MESSAGES = 6; @@ -87,11 +91,43 @@ export function ChannelCard({ const visible = messages.slice(-VISIBLE_MESSAGES); const hasActivity = workers.length > 0 || branches.length > 0; + const [showSettings, setShowSettings] = useState(false); + const [settings, setSettings] = useState({}); + + const { data: defaults } = useQuery({ + queryKey: ["conversation-defaults", channel.agent_id], + queryFn: () => api.getConversationDefaults(channel.agent_id), + enabled: showSettings, + }); + + const { data: channelSettingsData } = useQuery({ + queryKey: ["channel-settings", channel.id, channel.agent_id], + queryFn: () => api.getChannelSettings(channel.id, channel.agent_id), + enabled: showSettings, + }); + + useEffect(() => { + if (channelSettingsData?.settings) { + setSettings(channelSettingsData.settings); + } + }, [channelSettingsData]); + const deleteChannel = useMutation({ mutationFn: () => api.deleteChannel(channel.agent_id, channel.id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }), }); + const saveSettingsMutation = useMutation({ + mutationFn: async () => { + const response = await api.updateChannelSettings(channel.id, channel.agent_id, settings); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["channel-settings", channel.id] }); + setShowSettings(false); + }, + }); + return (
+ + + + + e.preventDefault()}> + {defaults ? ( + saveSettingsMutation.mutate()} + onCancel={() => setShowSettings(false)} + saving={saveSettingsMutation.isPending} + /> + ) : ( +
Loading...
+ )} +
+
- e.preventDefault()}> + e.preventDefault()}> {defaults ? ( - + {defaultsLoading ? (
Loading...
) : defaults ? ( From d70163c058f8f1605956510da7683fde5c4eedcd Mon Sep 17 00:00:00 2001 From: James Pine Date: Sun, 29 Mar 2026 02:12:09 -0700 Subject: [PATCH 12/20] fix: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /status now shows resolved model overrides instead of routing defaults - Reset settings state when switching portal conversations - ChannelCard popover waits for channel settings to load before rendering - parse_response_mode: listen_only_mode=false maps to Active (not ignored), unknown response_mode strings return None instead of defaulting to Active - Binding settings only override enum fields when explicitly set in TOML, omitted fields inherit from agent/system defaults - Idle worker resume path loads from ChannelSettingsStore for platform channels (not just PortalConversationStore) - Batched turn path respects MemoryMode::Off — skips memory bulletin, working memory, and channel activity map rendering - Channel settings API validates channel exists before get/put Co-Authored-By: Claude Opus 4.6 (1M context) --- interface/src/components/ChannelCard.tsx | 2 +- interface/src/components/WebChatPanel.tsx | 6 ++ src/agent/channel.rs | 89 +++++++++++++---------- src/api/channels.rs | 16 ++++ src/config/load.rs | 70 ++++++++++++------ src/main.rs | 37 +++++++--- 6 files changed, 146 insertions(+), 74 deletions(-) diff --git a/interface/src/components/ChannelCard.tsx b/interface/src/components/ChannelCard.tsx index c36f88165..dbb4bdb6e 100644 --- a/interface/src/components/ChannelCard.tsx +++ b/interface/src/components/ChannelCard.tsx @@ -197,7 +197,7 @@ export function ChannelCard({ e.preventDefault()}> - {defaults ? ( + {defaults && channelSettingsData ? ( ({}); + // Reset settings when switching conversations + useEffect(() => { + setSettings({}); + setShowSettings(false); + }, [activeConversationId, agentId]); + // Fetch conversations list const { data: conversationsData, isLoading: conversationsLoading } = useQuery({ queryKey: ["portal-conversations", agentId], diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 14e2a2a9b..749950d66 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -879,8 +879,14 @@ impl Channel { match text { "/status" => { let routing = self.deps.runtime_config.routing.load(); - let channel_model = routing.resolve(ProcessType::Channel, None).to_string(); - let branch_model = routing.resolve(ProcessType::Branch, None).to_string(); + let channel_model = self + .resolved_settings + .resolve_model("channel") + .unwrap_or_else(|| routing.resolve(ProcessType::Channel, None)); + let branch_model = self + .resolved_settings + .resolve_model("branch") + .unwrap_or_else(|| routing.resolve(ProcessType::Branch, None)); let mode = match self.resolved_settings.response_mode { ResponseMode::Active => "active", ResponseMode::Quiet => "quiet (only command/@mention/reply)", @@ -1554,50 +1560,57 @@ impl Channel { let project_context = self.build_project_context(&prompt_engine).await; - // Render working memory layers (Layers 2 + 3). - let wm_config = **rc.working_memory.load(); - let timezone = self.deps.working_memory.timezone(); - let working_memory = match crate::memory::working::render_working_memory( - &self.deps.working_memory, - self.id.as_ref(), - &wm_config, - timezone, - ) - .await - { - Ok(text) => { - if text.is_empty() { - tracing::debug!(channel_id = %self.id, "working memory rendered empty (disabled?)"); - } else { - tracing::debug!(channel_id = %self.id, len = text.len(), "working memory rendered"); + // Only inject memory context if not in Off mode (same guard as build_system_prompt). + let (working_memory, memory_bulletin_text) = if matches!( + self.resolved_settings.memory, + MemoryMode::Off + ) { + (String::new(), None) + } else { + let wm_config = **rc.working_memory.load(); + let timezone = self.deps.working_memory.timezone(); + let wm = match crate::memory::working::render_working_memory( + &self.deps.working_memory, + self.id.as_ref(), + &wm_config, + timezone, + ) + .await + { + Ok(text) => text, + Err(error) => { + tracing::warn!(channel_id = %self.id, %error, "working memory render failed"); + String::new() } - text - } - Err(error) => { - tracing::warn!(channel_id = %self.id, %error, "working memory render failed"); - String::new() - } + }; + (wm, Some(memory_bulletin.to_string())) }; - let channel_activity_map = match crate::memory::working::render_channel_activity_map( - &self.deps.sqlite_pool, - &self.deps.working_memory, - self.id.as_ref(), - &wm_config, - timezone, - ) - .await - { - Ok(text) => text, - Err(error) => { - tracing::warn!(channel_id = %self.id, %error, "channel activity map render failed"); - String::new() + let channel_activity_map = if matches!(self.resolved_settings.memory, MemoryMode::Off) { + String::new() + } else { + let wm_config = **rc.working_memory.load(); + let timezone = self.deps.working_memory.timezone(); + match crate::memory::working::render_channel_activity_map( + &self.deps.sqlite_pool, + &self.deps.working_memory, + self.id.as_ref(), + &wm_config, + timezone, + ) + .await + { + Ok(text) => text, + Err(error) => { + tracing::warn!(channel_id = %self.id, %error, "channel activity map render failed"); + String::new() + } } }; prompt_engine.render_channel_prompt_with_links( empty_to_none(identity_context), - empty_to_none(memory_bulletin.to_string()), + memory_bulletin_text, empty_to_none(skills_prompt), worker_capabilities, self.conversation_context.clone(), diff --git a/src/api/channels.rs b/src/api/channels.rs index 85f353ec7..d7904392c 100644 --- a/src/api/channels.rs +++ b/src/api/channels.rs @@ -1027,6 +1027,14 @@ pub(super) async fn get_channel_settings( let pools = state.agent_pools.load(); let pool = pools.get(&query.agent_id).ok_or(StatusCode::NOT_FOUND)?; + // Validate channel exists + let channel_store = ChannelStore::new(pool.clone()); + channel_store + .get(&channel_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + let store = crate::conversation::ChannelSettingsStore::new(pool.clone()); let settings = store .get(&query.agent_id, &channel_id) @@ -1064,6 +1072,14 @@ pub(super) async fn update_channel_settings( let pools = state.agent_pools.load(); let pool = pools.get(&request.agent_id).ok_or(StatusCode::NOT_FOUND)?; + // Validate channel exists + let channel_store = ChannelStore::new(pool.clone()); + channel_store + .get(&channel_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + let store = crate::conversation::ChannelSettingsStore::new(pool.clone()); store .upsert(&request.agent_id, &channel_id, &request.settings) diff --git a/src/config/load.rs b/src/config/load.rs index a4c12c7ca..3cf73b9cc 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -121,18 +121,28 @@ fn parse_response_mode( use crate::conversation::settings::ResponseMode; if let Some(mode) = response_mode { - return Some(match mode { - "quiet" => ResponseMode::Quiet, - "mention_only" => ResponseMode::MentionOnly, - _ => ResponseMode::Active, - }); + return match mode { + "active" => Some(ResponseMode::Active), + "quiet" => Some(ResponseMode::Quiet), + "mention_only" => Some(ResponseMode::MentionOnly), + unknown => { + tracing::warn!( + response_mode = unknown, + "unknown response_mode value, ignoring" + ); + None + } + }; } - // Backwards compat: listen_only_mode = true maps to Quiet - if let Some(true) = listen_only_mode { - tracing::warn!("listen_only_mode is deprecated, use response_mode = \"quiet\" instead"); - return Some(ResponseMode::Quiet); + // Backwards compat: listen_only_mode maps to response_mode + match listen_only_mode { + Some(true) => { + tracing::warn!("listen_only_mode is deprecated, use response_mode = \"quiet\" instead"); + Some(ResponseMode::Quiet) + } + Some(false) => Some(ResponseMode::Active), + None => None, } - None } fn parse_close_policy(value: Option<&str>) -> Option { @@ -2334,25 +2344,37 @@ impl Config { .map(|b| { let settings = b.settings.map(|s| { use crate::conversation::settings::*; - ConversationSettings { + let mut cs = ConversationSettings { model: s.model, - memory: match s.memory.as_deref() { - Some("ambient") => MemoryMode::Ambient, - Some("off") => MemoryMode::Off, + save_attachments: s.save_attachments, + ..Default::default() + }; + // Only override enum fields when explicitly set in TOML, + // so omitted fields inherit from agent/system defaults. + if let Some(m) = s.memory.as_deref() { + cs.memory = match m { + "ambient" => MemoryMode::Ambient, + "off" => MemoryMode::Off, + "full" => MemoryMode::Full, _ => MemoryMode::Full, - }, - delegation: match s.delegation.as_deref() { - Some("direct") => DelegationMode::Direct, + }; + } + if let Some(d) = s.delegation.as_deref() { + cs.delegation = match d { + "direct" => DelegationMode::Direct, + "standard" => DelegationMode::Standard, _ => DelegationMode::Standard, - }, - response_mode: match s.response_mode.as_deref() { - Some("quiet") => ResponseMode::Quiet, - Some("mention_only") => ResponseMode::MentionOnly, + }; + } + if let Some(r) = s.response_mode.as_deref() { + cs.response_mode = match r { + "quiet" => ResponseMode::Quiet, + "mention_only" => ResponseMode::MentionOnly, + "active" => ResponseMode::Active, _ => ResponseMode::Active, - }, - save_attachments: s.save_attachments, - ..Default::default() + }; } + cs }); Binding { agent_id: b.agent_id, diff --git a/src/main.rs b/src/main.rs index 7bb06662d..afd1d63bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1920,20 +1920,35 @@ async fn run( .as_ref() .clone(); - // Load per-conversation settings for portal conversations (idle worker resume). + // Load per-conversation settings (idle worker resume). + // Try portal store first, then channel_settings for platform channels. let resolved_settings = { - let store = spacebot::conversation::PortalConversationStore::new( + let agent_id_str = agent_id.to_string(); + let portal_store = spacebot::conversation::PortalConversationStore::new( agent.deps.sqlite_pool.clone(), ); - match store.get(&agent_id.to_string(), &conversation_id).await { - Ok(Some(conv)) => { - spacebot::conversation::settings::ResolvedConversationSettings::resolve( - conv.settings.as_ref(), - None, - None, - ) - } - _ => spacebot::conversation::settings::ResolvedConversationSettings::default(), + let channel_store = spacebot::conversation::ChannelSettingsStore::new( + agent.deps.sqlite_pool.clone(), + ); + if let Ok(Some(conv)) = + portal_store.get(&agent_id_str, &conversation_id).await + { + spacebot::conversation::settings::ResolvedConversationSettings::resolve( + conv.settings.as_ref(), + None, + None, + ) + } else if let Ok(Some(settings)) = + channel_store.get(&agent_id_str, &conversation_id).await + { + spacebot::conversation::settings::ResolvedConversationSettings::resolve( + Some(&settings), + None, + None, + ) + } else { + spacebot::conversation::settings::ResolvedConversationSettings::default( + ) } }; From f165c2525c03477c0abae22662468290c4cbb6db Mon Sep 17 00:00:00 2001 From: James Pine Date: Sun, 29 Mar 2026 02:53:34 -0700 Subject: [PATCH 13/20] fix: address remaining PR review feedback - ChannelCard: reset settings from DB on popover reopen, invalidate ["channels"] query on save so badge refreshes - WebChatPanel: hydrate settings from conversationsData on switch, fix declaration ordering - channel.rs reload_settings: preserve existing settings on DB errors instead of resetting to defaults - channel.rs set_response_mode: load existing settings and merge response_mode instead of overwriting all fields, use tokio::spawn for non-blocking DB write - channel.rs /help: update descriptions for /quiet, add /mention-only - channels.rs: log event_tx.send errors instead of discarding - config/load.rs: log unknown enum values in binding settings - main.rs: log DB errors in idle worker resume path, preserve binding defaults on DB errors in channel creation path Co-Authored-By: Claude Opus 4.6 (1M context) --- interface/src/components/ChannelCard.tsx | 7 ++- interface/src/components/WebChatPanel.tsx | 15 +++-- src/agent/channel.rs | 69 ++++++++++++++------- src/api/channels.rs | 22 ++++--- src/config/load.rs | 43 +++++++------ src/main.rs | 73 ++++++++++++++++------- 6 files changed, 153 insertions(+), 76 deletions(-) diff --git a/interface/src/components/ChannelCard.tsx b/interface/src/components/ChannelCard.tsx index dbb4bdb6e..0105b4591 100644 --- a/interface/src/components/ChannelCard.tsx +++ b/interface/src/components/ChannelCard.tsx @@ -107,10 +107,10 @@ export function ChannelCard({ }); useEffect(() => { - if (channelSettingsData?.settings) { - setSettings(channelSettingsData.settings); + if (showSettings) { + setSettings(channelSettingsData?.settings ?? {}); } - }, [channelSettingsData]); + }, [channelSettingsData, showSettings]); const deleteChannel = useMutation({ mutationFn: () => api.deleteChannel(channel.agent_id, channel.id), @@ -124,6 +124,7 @@ export function ChannelCard({ }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["channel-settings", channel.id] }); + queryClient.invalidateQueries({ queryKey: ["channels"] }); setShowSettings(false); }, }); diff --git a/interface/src/components/WebChatPanel.tsx b/interface/src/components/WebChatPanel.tsx index 89c0f94f2..a0239f074 100644 --- a/interface/src/components/WebChatPanel.tsx +++ b/interface/src/components/WebChatPanel.tsx @@ -193,12 +193,6 @@ export function WebChatPanel({agentId}: WebChatPanelProps) { const [showSettings, setShowSettings] = useState(false); const [settings, setSettings] = useState({}); - // Reset settings when switching conversations - useEffect(() => { - setSettings({}); - setShowSettings(false); - }, [activeConversationId, agentId]); - // Fetch conversations list const { data: conversationsData, isLoading: conversationsLoading } = useQuery({ queryKey: ["portal-conversations", agentId], @@ -211,6 +205,15 @@ export function WebChatPanel({agentId}: WebChatPanelProps) { const conversations = conversationsData?.conversations ?? []; + // Reset settings when switching conversations, hydrating from cached data if available + useEffect(() => { + const activeConv = conversations.find( + (c: any) => c.id === activeConversationId + ); + setSettings(activeConv?.settings ?? {}); + setShowSettings(false); + }, [activeConversationId, agentId, conversationsData]); + // Fetch conversation defaults const {data: defaults, isLoading: defaultsLoading, error: defaultsError} = useQuery({ queryKey: ["conversation-defaults", agentId], diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 749950d66..3b7882e6f 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -649,14 +649,29 @@ impl Channel { crate::conversation::PortalConversationStore::new(self.deps.sqlite_pool.clone()); match store.get(&agent_id, channel_id).await { Ok(Some(conv)) => conv.settings, - _ => None, + Ok(None) => None, + Err(error) => { + tracing::warn!( + %error, + channel_id = %self.id, + "failed to reload portal settings, preserving existing" + ); + return; + } } } else { let store = crate::conversation::ChannelSettingsStore::new(self.deps.sqlite_pool.clone()); match store.get(&agent_id, channel_id).await { Ok(settings) => settings, - Err(_) => None, + Err(error) => { + tracing::warn!( + %error, + channel_id = %self.id, + "failed to reload channel settings, preserving existing" + ); + return; + } } }; @@ -688,23 +703,36 @@ impl Channel { async fn set_response_mode(&mut self, mode: ResponseMode) { self.resolved_settings.response_mode = mode; - // Persist to channel_settings table - let store = crate::conversation::ChannelSettingsStore::new(self.deps.sqlite_pool.clone()); - let settings = crate::conversation::ConversationSettings { - response_mode: mode, - ..Default::default() - }; - if let Err(error) = store - .upsert(&self.deps.agent_id, self.id.as_ref(), &settings) - .await - { - tracing::warn!( - %error, - channel_id = %self.id, - ?mode, - "failed to persist response_mode to channel_settings" - ); - } + // Persist to channel_settings table — load existing settings first so we + // don't overwrite other fields, then spawn the DB write to avoid blocking. + let pool = self.deps.sqlite_pool.clone(); + let agent_id = self.deps.agent_id.clone(); + let channel_id: String = self.id.as_ref().to_owned(); + tokio::spawn(async move { + let store = crate::conversation::ChannelSettingsStore::new(pool); + let mut settings = match store.get(&agent_id, &channel_id).await { + Ok(Some(existing)) => existing, + Ok(None) => crate::conversation::ConversationSettings::default(), + Err(error) => { + tracing::warn!( + %error, + %channel_id, + ?mode, + "failed to load existing settings before persisting response_mode" + ); + crate::conversation::ConversationSettings::default() + } + }; + settings.response_mode = mode; + if let Err(error) = store.upsert(&agent_id, &channel_id, &settings).await { + tracing::warn!( + %error, + %channel_id, + ?mode, + "failed to persist response_mode to channel_settings" + ); + } + }); } fn persist_inbound_user_message( @@ -947,7 +975,8 @@ impl Channel { "- /today: in-progress + ready task snapshot".to_string(), "- /tasks: ready task list".to_string(), "- /digest: one-shot day digest (00:00 -> now)".to_string(), - "- /quiet: listen-only mode".to_string(), + "- /quiet: only reply to commands, @mentions, or replies".to_string(), + "- /mention-only: only respond when @mentioned or replied to".to_string(), "- /active: normal reply mode".to_string(), "- /agent-id: runtime agent id".to_string(), ]; diff --git a/src/api/channels.rs b/src/api/channels.rs index d7904392c..1021dbe0e 100644 --- a/src/api/channels.rs +++ b/src/api/channels.rs @@ -1093,13 +1093,21 @@ pub(super) async fn update_channel_settings( { let channel_states = state.channel_states.read().await; if let Some(channel_state) = channel_states.get(&channel_id) { - let _ = channel_state - .deps - .event_tx - .send(crate::ProcessEvent::SettingsUpdated { - agent_id: channel_state.deps.agent_id.clone(), - channel_id: channel_state.channel_id.clone(), - }); + if let Err(error) = + channel_state + .deps + .event_tx + .send(crate::ProcessEvent::SettingsUpdated { + agent_id: channel_state.deps.agent_id.clone(), + channel_id: channel_state.channel_id.clone(), + }) + { + tracing::warn!( + %error, + %channel_id, + "failed to send SettingsUpdated event to channel" + ); + } } } diff --git a/src/config/load.rs b/src/config/load.rs index 3cf73b9cc..046585d1d 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -2352,27 +2352,36 @@ impl Config { // Only override enum fields when explicitly set in TOML, // so omitted fields inherit from agent/system defaults. if let Some(m) = s.memory.as_deref() { - cs.memory = match m { - "ambient" => MemoryMode::Ambient, - "off" => MemoryMode::Off, - "full" => MemoryMode::Full, - _ => MemoryMode::Full, - }; + match m { + "ambient" => cs.memory = MemoryMode::Ambient, + "off" => cs.memory = MemoryMode::Off, + "full" => cs.memory = MemoryMode::Full, + other => tracing::warn!( + value = other, + "unknown memory mode in binding settings, ignoring" + ), + } } if let Some(d) = s.delegation.as_deref() { - cs.delegation = match d { - "direct" => DelegationMode::Direct, - "standard" => DelegationMode::Standard, - _ => DelegationMode::Standard, - }; + match d { + "direct" => cs.delegation = DelegationMode::Direct, + "standard" => cs.delegation = DelegationMode::Standard, + other => tracing::warn!( + value = other, + "unknown delegation mode in binding settings, ignoring" + ), + } } if let Some(r) = s.response_mode.as_deref() { - cs.response_mode = match r { - "quiet" => ResponseMode::Quiet, - "mention_only" => ResponseMode::MentionOnly, - "active" => ResponseMode::Active, - _ => ResponseMode::Active, - }; + match r { + "quiet" => cs.response_mode = ResponseMode::Quiet, + "mention_only" => cs.response_mode = ResponseMode::MentionOnly, + "active" => cs.response_mode = ResponseMode::Active, + other => tracing::warn!( + value = other, + "unknown response_mode in binding settings, ignoring" + ), + } } cs }); diff --git a/src/main.rs b/src/main.rs index afd1d63bc..8d54eb113 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1930,25 +1930,44 @@ async fn run( let channel_store = spacebot::conversation::ChannelSettingsStore::new( agent.deps.sqlite_pool.clone(), ); - if let Ok(Some(conv)) = - portal_store.get(&agent_id_str, &conversation_id).await - { - spacebot::conversation::settings::ResolvedConversationSettings::resolve( - conv.settings.as_ref(), - None, - None, - ) - } else if let Ok(Some(settings)) = - channel_store.get(&agent_id_str, &conversation_id).await - { - spacebot::conversation::settings::ResolvedConversationSettings::resolve( - Some(&settings), - None, - None, - ) - } else { - spacebot::conversation::settings::ResolvedConversationSettings::default( - ) + match portal_store.get(&agent_id_str, &conversation_id).await { + Ok(Some(conv)) => { + spacebot::conversation::settings::ResolvedConversationSettings::resolve( + conv.settings.as_ref(), + None, + None, + ) + } + Ok(None) => { + match channel_store.get(&agent_id_str, &conversation_id).await { + Ok(Some(settings)) => { + spacebot::conversation::settings::ResolvedConversationSettings::resolve( + Some(&settings), + None, + None, + ) + } + Ok(None) => { + spacebot::conversation::settings::ResolvedConversationSettings::default() + } + Err(error) => { + tracing::warn!( + %error, + %conversation_id, + "idle worker resume: failed to load channel settings, using defaults" + ); + spacebot::conversation::settings::ResolvedConversationSettings::default() + } + } + } + Err(error) => { + tracing::warn!( + %error, + %conversation_id, + "idle worker resume: failed to load portal settings, using defaults" + ); + spacebot::conversation::settings::ResolvedConversationSettings::default() + } } }; @@ -2190,9 +2209,13 @@ async fn run( tracing::warn!( %error, %conversation_id, - "failed to load portal conversation settings, using defaults" + "failed to load portal conversation settings, falling back to binding defaults" ); - spacebot::conversation::settings::ResolvedConversationSettings::default() + spacebot::conversation::settings::ResolvedConversationSettings::resolve( + None, + binding_settings.as_ref(), + None, + ) } } } else { @@ -2219,9 +2242,13 @@ async fn run( tracing::warn!( %error, %conversation_id, - "failed to load channel settings, using defaults" + "failed to load channel settings, falling back to binding defaults" ); - spacebot::conversation::settings::ResolvedConversationSettings::default() + spacebot::conversation::settings::ResolvedConversationSettings::resolve( + None, + binding_settings.as_ref(), + None, + ) } } }; From 0f1406bed0ef5a6c0785d6e428c85992f7eaa22a Mon Sep 17 00:00:00 2001 From: James Pine Date: Sun, 29 Mar 2026 17:02:26 -0700 Subject: [PATCH 14/20] fix: add missing `settings` field to all Binding initializers in tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/config.rs b/src/config.rs index 8bce51853..50047f72b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -565,6 +565,7 @@ bind = "127.0.0.1" app_token: "xapp-test".into(), instances: vec![], dm_allowed_users, + settings: None, commands: vec![], } } @@ -582,6 +583,7 @@ bind = "127.0.0.1" channel_ids: vec![], require_mention: false, dm_allowed_users, + settings: None, } } @@ -1439,6 +1441,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }; assert_eq!(binding.runtime_adapter_key(), "telegram:sales"); } @@ -1456,6 +1459,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }; assert!(binding.uses_default_adapter()); } @@ -1488,6 +1492,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }; let message = test_inbound_message("telegram", None); assert!(binding_adapter_matches(&binding, &message)); @@ -1506,6 +1511,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }; let message = test_inbound_message("telegram", Some("telegram:support")); assert!(binding_adapter_matches(&binding, &message)); @@ -1524,6 +1530,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }; let message = test_inbound_message("telegram", None); assert!(!binding_adapter_matches(&binding, &message)); @@ -1542,6 +1549,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }; let message = test_inbound_message("telegram", Some("telegram:support")); assert!(!binding_adapter_matches(&binding, &message)); @@ -1560,6 +1568,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }; let message = test_inbound_message("telegram", Some("telegram:sales")); assert!(!binding_adapter_matches(&binding, &message)); @@ -1578,8 +1587,10 @@ maintenance_merge_similarity_threshold = 1.1 enabled: true, token: "tok2".into(), dm_allowed_users: vec![], + settings: None, }], dm_allowed_users: vec![], + settings: None, }), email: None, webhook: None, @@ -1599,6 +1610,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }, Binding { agent_id: "support-agent".into(), @@ -1611,6 +1623,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }, ]; let result = validate_named_messaging_adapters(&messaging, bindings, false) @@ -1628,6 +1641,7 @@ maintenance_merge_similarity_threshold = 1.1 token: "tok".into(), instances: vec![], dm_allowed_users: vec![], + settings: None, }), email: None, webhook: None, @@ -1646,6 +1660,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }]; let result = validate_named_messaging_adapters(&messaging, bindings, false) .expect("bindings should be resolvable"); @@ -1713,6 +1728,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }]; let result = validate_named_messaging_adapters(&messaging, bindings, false) .expect("bindings should be resolvable"); @@ -1735,8 +1751,10 @@ maintenance_merge_similarity_threshold = 1.1 enabled: true, token: "tok".into(), dm_allowed_users: vec![], + settings: None, }], dm_allowed_users: vec![], + settings: None, }), email: None, webhook: None, @@ -1756,6 +1774,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }]; let result = validate_named_messaging_adapters(&messaging, bindings, false) .expect("bindings should be resolvable"); @@ -1778,8 +1797,10 @@ maintenance_merge_similarity_threshold = 1.1 enabled: true, token: "tok2".into(), dm_allowed_users: vec![], + settings: None, }], dm_allowed_users: vec![], + settings: None, }), email: None, webhook: None, @@ -1800,6 +1821,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }, // Invalid: references a non-existent named adapter Binding { @@ -1813,6 +1835,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }, // Valid: references an existing named adapter Binding { @@ -1826,6 +1849,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }, // Invalid: no discord config at all Binding { @@ -1839,6 +1863,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }, ]; let result = validate_named_messaging_adapters(&messaging, bindings, false) @@ -1875,6 +1900,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }]; let result = validate_named_messaging_adapters(&messaging, bindings, false) .expect("bindings should be resolvable"); @@ -1907,6 +1933,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }]; let result = validate_named_messaging_adapters(&messaging, bindings, true); assert!( @@ -1928,8 +1955,10 @@ maintenance_merge_similarity_threshold = 1.1 enabled: false, token: "tok2".into(), dm_allowed_users: vec![], + settings: None, }], dm_allowed_users: vec![], + settings: None, }), email: None, webhook: None, @@ -1948,6 +1977,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }]; let result = validate_named_messaging_adapters(&messaging, bindings, false) .expect("bindings should be resolvable"); @@ -1967,6 +1997,7 @@ maintenance_merge_similarity_threshold = 1.1 token: "tok".into(), instances: vec![], dm_allowed_users: vec![], + settings: None, }), email: None, webhook: None, @@ -1985,6 +2016,7 @@ maintenance_merge_similarity_threshold = 1.1 channel_ids: vec![], require_mention: false, dm_allowed_users: vec![], + settings: None, }]; let result = validate_named_messaging_adapters(&messaging, bindings, false) .expect("bindings should be resolvable"); From f9263a0dd82b9698de19f652d5ecd99d1c999e78 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 30 Mar 2026 20:04:49 -0700 Subject: [PATCH 15/20] fix: remove stale `settings` field from platform config structs and update tests The `settings` field was removed from SlackConfig, TelegramConfig, and TelegramInstanceConfig but test code still referenced it. Also adds missing `model_overrides` and `worker_context_settings` fields to ChannelState initializers and missing args to create_worker_tool_server calls in context_dump tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.rs | 11 ----------- tests/context_dump.rs | 8 ++++++++ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/config.rs b/src/config.rs index 50047f72b..db0e51b98 100644 --- a/src/config.rs +++ b/src/config.rs @@ -565,7 +565,6 @@ bind = "127.0.0.1" app_token: "xapp-test".into(), instances: vec![], dm_allowed_users, - settings: None, commands: vec![], } } @@ -1587,10 +1586,8 @@ maintenance_merge_similarity_threshold = 1.1 enabled: true, token: "tok2".into(), dm_allowed_users: vec![], - settings: None, }], dm_allowed_users: vec![], - settings: None, }), email: None, webhook: None, @@ -1641,7 +1638,6 @@ maintenance_merge_similarity_threshold = 1.1 token: "tok".into(), instances: vec![], dm_allowed_users: vec![], - settings: None, }), email: None, webhook: None, @@ -1751,10 +1747,8 @@ maintenance_merge_similarity_threshold = 1.1 enabled: true, token: "tok".into(), dm_allowed_users: vec![], - settings: None, }], dm_allowed_users: vec![], - settings: None, }), email: None, webhook: None, @@ -1797,10 +1791,8 @@ maintenance_merge_similarity_threshold = 1.1 enabled: true, token: "tok2".into(), dm_allowed_users: vec![], - settings: None, }], dm_allowed_users: vec![], - settings: None, }), email: None, webhook: None, @@ -1955,10 +1947,8 @@ maintenance_merge_similarity_threshold = 1.1 enabled: false, token: "tok2".into(), dm_allowed_users: vec![], - settings: None, }], dm_allowed_users: vec![], - settings: None, }), email: None, webhook: None, @@ -1997,7 +1987,6 @@ maintenance_merge_similarity_threshold = 1.1 token: "tok".into(), instances: vec![], dm_allowed_users: vec![], - settings: None, }), email: None, webhook: None, diff --git a/tests/context_dump.rs b/tests/context_dump.rs index 84710273c..bee401ea6 100644 --- a/tests/context_dump.rs +++ b/tests/context_dump.rs @@ -250,6 +250,8 @@ async fn dump_channel_context() { live_worker_transcripts: Arc::new(tokio::sync::RwLock::new( std::collections::HashMap::new(), )), + worker_context_settings: Arc::new(tokio::sync::RwLock::new(Default::default())), + model_overrides: Arc::new(Default::default()), }; let tool_server = rig::tool::server::ToolServer::new().run(); @@ -401,6 +403,8 @@ async fn dump_worker_context() { deps.sandbox.clone(), vec![], deps.runtime_config.clone(), + Default::default(), + deps.memory_search.clone(), ); let tool_defs = worker_tool_server @@ -490,6 +494,8 @@ async fn dump_all_contexts() { live_worker_transcripts: Arc::new(tokio::sync::RwLock::new( std::collections::HashMap::new(), )), + worker_context_settings: Arc::new(tokio::sync::RwLock::new(Default::default())), + model_overrides: Arc::new(Default::default()), }; let channel_tool_server = rig::tool::server::ToolServer::new().run(); let skip_flag = spacebot::tools::new_skip_flag(); @@ -581,6 +587,8 @@ async fn dump_all_contexts() { deps.sandbox.clone(), vec![], deps.runtime_config.clone(), + Default::default(), + deps.memory_search.clone(), ); let worker_tool_defs = worker_tool_server.get_tool_defs(None).await.unwrap(); let worker_tools_text = format_tool_defs(&worker_tool_defs); From 75d0ca418a7903ad24dcb8b4299ab6a9a82d0401 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 30 Mar 2026 20:25:38 -0700 Subject: [PATCH 16/20] fix: resolve clippy errors (too_many_arguments, if_same_then_else, collapsible_if, collapsible_match, unwrap_or_default) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agent/branch.rs | 1 + src/agent/channel.rs | 7 +--- src/api/channels.rs | 17 ++++----- src/llm/model.rs | 90 +++++++++++++++++++++----------------------- 4 files changed, 53 insertions(+), 62 deletions(-) diff --git a/src/agent/branch.rs b/src/agent/branch.rs index a7bb7806d..cfba7ffe4 100644 --- a/src/agent/branch.rs +++ b/src/agent/branch.rs @@ -47,6 +47,7 @@ pub struct BranchExecutionConfig { impl Branch { /// Create a new branch from a channel. + #[allow(clippy::too_many_arguments)] pub fn new( channel_id: ChannelId, description: impl Into, diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 3b7882e6f..ad9932f81 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -2340,12 +2340,7 @@ impl Channel { }; // In Ambient mode, we still show memory but don't trigger persistence - let memory_bulletin_text = - if matches!(self.resolved_settings.memory, MemoryMode::Ambient) { - Some(memory_bulletin.to_string()) - } else { - Some(memory_bulletin.to_string()) - }; + let memory_bulletin_text = Some(memory_bulletin.to_string()); (working_memory, channel_activity_map, memory_bulletin_text) }; diff --git a/src/api/channels.rs b/src/api/channels.rs index 1021dbe0e..0236e5628 100644 --- a/src/api/channels.rs +++ b/src/api/channels.rs @@ -1092,8 +1092,8 @@ pub(super) async fn update_channel_settings( // Notify the running channel to hot-reload its settings. { let channel_states = state.channel_states.read().await; - if let Some(channel_state) = channel_states.get(&channel_id) { - if let Err(error) = + if let Some(channel_state) = channel_states.get(&channel_id) + && let Err(error) = channel_state .deps .event_tx @@ -1101,13 +1101,12 @@ pub(super) async fn update_channel_settings( agent_id: channel_state.deps.agent_id.clone(), channel_id: channel_state.channel_id.clone(), }) - { - tracing::warn!( - %error, - %channel_id, - "failed to send SettingsUpdated event to channel" - ); - } + { + tracing::warn!( + %error, + %channel_id, + "failed to send SettingsUpdated event to channel" + ); } } diff --git a/src/llm/model.rs b/src/llm/model.rs index 72879d89c..5d7d7bc1e 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -2416,29 +2416,27 @@ fn process_openai_responses_stream_event( let mut events = Vec::new(); match event { - OpenAiResponsesStreamingCompletionChunk::Delta(chunk) => { - match &chunk.data { - OpenAiResponsesItemChunkKind::OutputItemAdded(message) => { - if let OpenAiResponsesStreamingItemDoneOutput { - item: OpenAiResponsesOutput::FunctionCall(function_call), - .. - } = message - { - let entry = pending_tool_calls - .entry(function_call.id.clone()) - .or_insert_with(OpenAiStreamingToolCall::default); - entry.id = function_call.id.clone(); - entry.name = function_call.name.clone(); - events.push(RawStreamingChoice::ToolCallDelta { - id: function_call.id.clone(), - internal_call_id: entry.internal_call_id.clone(), - content: rig::streaming::ToolCallDeltaContent::Name( - function_call.name.clone(), - ), - }); - } - } - OpenAiResponsesItemChunkKind::OutputItemDone(message) => match message { + OpenAiResponsesStreamingCompletionChunk::Delta(chunk) => match &chunk.data { + OpenAiResponsesItemChunkKind::OutputItemAdded( + OpenAiResponsesStreamingItemDoneOutput { + item: OpenAiResponsesOutput::FunctionCall(function_call), + .. + }, + ) => { + let entry = pending_tool_calls + .entry(function_call.id.clone()) + .or_default(); + entry.id = function_call.id.clone(); + entry.name = function_call.name.clone(); + events.push(RawStreamingChoice::ToolCallDelta { + id: function_call.id.clone(), + internal_call_id: entry.internal_call_id.clone(), + content: rig::streaming::ToolCallDeltaContent::Name(function_call.name.clone()), + }); + } + OpenAiResponsesItemChunkKind::OutputItemAdded(_) => {} + OpenAiResponsesItemChunkKind::OutputItemDone(message) => { + match message { OpenAiResponsesStreamingItemDoneOutput { item: OpenAiResponsesOutput::FunctionCall(function_call), .. @@ -2486,32 +2484,30 @@ fn process_openai_responses_stream_event( }); } } - }, - OpenAiResponsesItemChunkKind::OutputTextDelta(delta) - | OpenAiResponsesItemChunkKind::RefusalDelta(delta) => { - events.push(RawStreamingChoice::Message(delta.delta.clone())); - } - OpenAiResponsesItemChunkKind::ReasoningSummaryTextDelta(delta) => { - events.push(RawStreamingChoice::ReasoningDelta { - id: None, - reasoning: delta.delta.clone(), - }); } - OpenAiResponsesItemChunkKind::FunctionCallArgsDelta(delta) => { - let entry = pending_tool_calls - .entry(delta.item_id.clone()) - .or_insert_with(OpenAiStreamingToolCall::default); - entry.id = delta.item_id.clone(); - entry.arguments.push_str(&delta.delta); - events.push(RawStreamingChoice::ToolCallDelta { - id: delta.item_id.clone(), - internal_call_id: entry.internal_call_id.clone(), - content: rig::streaming::ToolCallDeltaContent::Delta(delta.delta.clone()), - }); - } - _ => {} } - } + OpenAiResponsesItemChunkKind::OutputTextDelta(delta) + | OpenAiResponsesItemChunkKind::RefusalDelta(delta) => { + events.push(RawStreamingChoice::Message(delta.delta.clone())); + } + OpenAiResponsesItemChunkKind::ReasoningSummaryTextDelta(delta) => { + events.push(RawStreamingChoice::ReasoningDelta { + id: None, + reasoning: delta.delta.clone(), + }); + } + OpenAiResponsesItemChunkKind::FunctionCallArgsDelta(delta) => { + let entry = pending_tool_calls.entry(delta.item_id.clone()).or_default(); + entry.id = delta.item_id.clone(); + entry.arguments.push_str(&delta.delta); + events.push(RawStreamingChoice::ToolCallDelta { + id: delta.item_id.clone(), + internal_call_id: entry.internal_call_id.clone(), + content: rig::streaming::ToolCallDeltaContent::Delta(delta.delta.clone()), + }); + } + _ => {} + }, OpenAiResponsesStreamingCompletionChunk::Response(chunk) => { if !matches!( chunk.kind, From 7cb18396fb052544adc0f4e3d25c9dde45cca721 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 30 Mar 2026 20:32:49 -0700 Subject: [PATCH 17/20] fix: replace unnecessary `to_string()` with `as_ref()` in main.rs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8d54eb113..41f4e73e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2190,7 +2190,7 @@ async fn run( let store = spacebot::conversation::PortalConversationStore::new( agent.deps.sqlite_pool.clone(), ); - match store.get(&agent_id.to_string(), &conversation_id).await { + match store.get(agent_id.as_ref(), &conversation_id).await { Ok(Some(conv)) => { spacebot::conversation::settings::ResolvedConversationSettings::resolve( conv.settings.as_ref(), @@ -2223,7 +2223,7 @@ async fn run( let store = spacebot::conversation::ChannelSettingsStore::new( agent.deps.sqlite_pool.clone(), ); - match store.get(&agent_id.to_string(), &conversation_id).await { + match store.get(agent_id.as_ref(), &conversation_id).await { Ok(Some(settings)) => { spacebot::conversation::settings::ResolvedConversationSettings::resolve( Some(&settings), From 3e910e4c639c7e82e0d165bdf13692e708f3713e Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 30 Mar 2026 20:46:13 -0700 Subject: [PATCH 18/20] fix: show agent display name instead of ID in portal chat header Co-Authored-By: Claude Opus 4.6 (1M context) --- interface/src/components/WebChatPanel.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/interface/src/components/WebChatPanel.tsx b/interface/src/components/WebChatPanel.tsx index a0239f074..f4671bbdc 100644 --- a/interface/src/components/WebChatPanel.tsx +++ b/interface/src/components/WebChatPanel.tsx @@ -220,6 +220,13 @@ export function WebChatPanel({agentId}: WebChatPanelProps) { queryFn: () => api.getConversationDefaults(agentId), }); + const agentsQuery = useQuery({ + queryKey: ["agents"], + queryFn: () => api.agents(), + staleTime: 10_000, + }); + const agentDisplayName = agentsQuery.data?.agents.find((a) => a.id === agentId)?.display_name; + const liveState = liveStates[activeConversationId]; const timeline = liveState?.timeline ?? []; const isTyping = liveState?.isTyping ?? false; @@ -322,7 +329,7 @@ export function WebChatPanel({agentId}: WebChatPanelProps) { {/* Header */}
-

{agentId}

+

{agentDisplayName || agentId}

{defaults && ( {defaults.available_models.find((m) => m.id === (settings.model || defaults.model))?.name From 584e55e011a30ddac3c68ef4d008d2390f0b83a5 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 30 Mar 2026 21:22:17 -0700 Subject: [PATCH 19/20] fix: use accent color for user message bubbles in portal chat Co-Authored-By: Claude Opus 4.6 (1M context) --- interface/src/components/WebChatPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interface/src/components/WebChatPanel.tsx b/interface/src/components/WebChatPanel.tsx index f4671bbdc..8f65933c1 100644 --- a/interface/src/components/WebChatPanel.tsx +++ b/interface/src/components/WebChatPanel.tsx @@ -386,7 +386,7 @@ export function WebChatPanel({agentId}: WebChatPanelProps) { {timeline.length === 0 && !isTyping && (

- Start a conversation with {agentId} + Start a conversation with {agentDisplayName || agentId}

)} @@ -397,7 +397,7 @@ export function WebChatPanel({agentId}: WebChatPanelProps) {
{item.role === "user" ? (
-
+

{item.content}

From a73bde2f15dccf8ae7978159bdacd69d5d7da983 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 30 Mar 2026 21:26:05 -0700 Subject: [PATCH 20/20] fix: use subtler hover/active colors in conversation sidebar Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/ConversationsSidebar.tsx | 494 ++++++++++-------- interface/src/components/WebChatPanel.tsx | 2 +- 2 files changed, 271 insertions(+), 225 deletions(-) diff --git a/interface/src/components/ConversationsSidebar.tsx b/interface/src/components/ConversationsSidebar.tsx index 637542c0e..7a0ff651b 100644 --- a/interface/src/components/ConversationsSidebar.tsx +++ b/interface/src/components/ConversationsSidebar.tsx @@ -1,244 +1,290 @@ -import { useState } from "react"; -import { Button } from "@/ui/Button"; +import {useState} from "react"; +import {Button} from "@/ui/Button"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, } from "@/ui/Dialog"; -import { Input } from "@/ui/Input"; -import type { PortalConversationSummary } from "@/api/types"; +import {Input} from "@/ui/Input"; +import type {PortalConversationSummary} from "@/api/types"; interface ConversationsSidebarProps { - conversations: PortalConversationSummary[]; - activeConversationId: string | null; - onSelectConversation: (id: string) => void; - onCreateConversation: () => void; - onDeleteConversation: (id: string) => void; - onRenameConversation: (id: string, title: string) => void; - onArchiveConversation: (id: string, archived: boolean) => void; - isLoading: boolean; + conversations: PortalConversationSummary[]; + activeConversationId: string | null; + onSelectConversation: (id: string) => void; + onCreateConversation: () => void; + onDeleteConversation: (id: string) => void; + onRenameConversation: (id: string, title: string) => void; + onArchiveConversation: (id: string, archived: boolean) => void; + isLoading: boolean; } export function ConversationsSidebar({ - conversations, - activeConversationId, - onSelectConversation, - onCreateConversation, - onDeleteConversation, - onRenameConversation, - onArchiveConversation, - isLoading, + conversations, + activeConversationId, + onSelectConversation, + onCreateConversation, + onDeleteConversation, + onRenameConversation, + onArchiveConversation, + isLoading, }: ConversationsSidebarProps) { - const [renameDialogOpen, setRenameDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [selectedConversation, setSelectedConversation] = useState(null); - const [newTitle, setNewTitle] = useState(""); + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedConversation, setSelectedConversation] = + useState(null); + const [newTitle, setNewTitle] = useState(""); - const activeConversations = conversations.filter((c) => !c.archived); - const archivedConversations = conversations.filter((c) => c.archived); + const activeConversations = conversations.filter((c) => !c.archived); + const archivedConversations = conversations.filter((c) => c.archived); - const handleRename = (conv: PortalConversationSummary) => { - setSelectedConversation(conv); - setNewTitle(conv.title); - setRenameDialogOpen(true); - }; + const handleRename = (conv: PortalConversationSummary) => { + setSelectedConversation(conv); + setNewTitle(conv.title); + setRenameDialogOpen(true); + }; - const handleDelete = (conv: PortalConversationSummary) => { - setSelectedConversation(conv); - setDeleteDialogOpen(true); - }; + const handleDelete = (conv: PortalConversationSummary) => { + setSelectedConversation(conv); + setDeleteDialogOpen(true); + }; - const confirmRename = () => { - if (selectedConversation && newTitle.trim()) { - onRenameConversation(selectedConversation.id, newTitle.trim()); - setRenameDialogOpen(false); - setSelectedConversation(null); - } - }; + const confirmRename = () => { + if (selectedConversation && newTitle.trim()) { + onRenameConversation(selectedConversation.id, newTitle.trim()); + setRenameDialogOpen(false); + setSelectedConversation(null); + } + }; - const confirmDelete = () => { - if (selectedConversation) { - onDeleteConversation(selectedConversation.id); - setDeleteDialogOpen(false); - setSelectedConversation(null); - } - }; + const confirmDelete = () => { + if (selectedConversation) { + onDeleteConversation(selectedConversation.id); + setDeleteDialogOpen(false); + setSelectedConversation(null); + } + }; - const formatDate = (dateStr: string) => { - const date = new Date(dateStr); - const now = new Date(); - const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); - - if (diffDays === 0) { - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else if (diffDays === 1) { - return "Yesterday"; - } else if (diffDays < 7) { - return date.toLocaleDateString([], { weekday: 'short' }); - } else { - return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); - } - }; + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + const now = new Date(); + const diffDays = Math.floor( + (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24), + ); - return ( -
- {/* New conversation button */} -
- -
+ if (diffDays === 0) { + return date.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"}); + } else if (diffDays === 1) { + return "Yesterday"; + } else if (diffDays < 7) { + return date.toLocaleDateString([], {weekday: "short"}); + } else { + return date.toLocaleDateString([], {month: "short", day: "numeric"}); + } + }; - {/* Conversations List */} -
- {isLoading ? ( -
Loading...
- ) : activeConversations.length === 0 ? ( -
- No conversations yet -
- ) : ( -
- {activeConversations.map((conv) => ( -
onSelectConversation(conv.id)} - > -
-
{conv.title}
-
- - {formatDate(conv.updated_at)} - -
- - - -
-
- ))} -
- )} + return ( +
+ {/* New conversation button */} +
+ +
+ {/* Conversations List */} +
+ {isLoading ? ( +
+ Loading... +
+ ) : activeConversations.length === 0 ? ( +
+ No conversations yet +
+ ) : ( +
+ {activeConversations.map((conv) => ( +
onSelectConversation(conv.id)} + > +
+
{conv.title}
+
+ + {formatDate(conv.updated_at)} + +
+ + + +
+
+ ))} +
+ )} - {/* Archived Section */} - {archivedConversations.length > 0 && ( -
-
- Archived -
-
- {archivedConversations.map((conv) => ( -
onSelectConversation(conv.id)} - > -
-
{conv.title}
-
- -
- ))} -
-
- )} -
+ {/* Archived Section */} + {archivedConversations.length > 0 && ( +
+
+ Archived +
+
+ {archivedConversations.map((conv) => ( +
onSelectConversation(conv.id)} + > +
+
{conv.title}
+
+ +
+ ))} +
+
+ )} +
- {/* Rename Dialog */} - - - - Rename Conversation - - setNewTitle(e.target.value)} - placeholder="Conversation title" - onKeyDown={(e) => { - if (e.key === "Enter") confirmRename(); - }} - /> - - - - - - + {/* Rename Dialog */} + + + + Rename Conversation + + setNewTitle(e.target.value)} + placeholder="Conversation title" + onKeyDown={(e) => { + if (e.key === "Enter") confirmRename(); + }} + /> + + + + + + - {/* Delete Dialog */} - - - - Delete Conversation - -

- Are you sure you want to delete "{selectedConversation?.title}"? This cannot be undone. -

- - - - -
-
-
- ); + {/* Delete Dialog */} + + + + Delete Conversation + +

+ Are you sure you want to delete "{selectedConversation?.title}"? + This cannot be undone. +

+ + + + +
+
+
+ ); } diff --git a/interface/src/components/WebChatPanel.tsx b/interface/src/components/WebChatPanel.tsx index 8f65933c1..e8836ce91 100644 --- a/interface/src/components/WebChatPanel.tsx +++ b/interface/src/components/WebChatPanel.tsx @@ -397,7 +397,7 @@ export function WebChatPanel({agentId}: WebChatPanelProps) {
{item.role === "user" ? (
-
+

{item.content}