diff --git a/README.md b/README.md index 5008a18e..9a5588e6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Batteries included - one click setup (for [Beeper Plus](https://www.beeper.com/p Coming soon to Beeper Desktop as an experiment. Join the [Developer Community](beeper://connect) on [Matrix](https://matrix.to/#/#beeper-developers:beeper.com?via=beeper.com) for early access. -Connect all your chats with one click and manage your inbox with agents. Supports image generation, reminders, web search, and memory. Create basic AI Chats to talk to models with no tools and customizable system prompt. +Connect all your chats with one click and manage your inbox with agents. Supports image generation, reminders, web search, and memory. Create direct model chats for simple conversations or agent chats for richer workflows. Made by humans using agentic coding. @@ -20,7 +20,7 @@ Experimental Matrix ↔ AI bridge for Beeper, built on top of [mautrix/bridgev2] - Per-model chats (each model shows up as its own contact) - Streaming responses - Multimodal input (images, PDFs, audio, video) when supported by the model -- Per-room settings (model, temperature, system prompt, context limits, tools) +- Ghost-based chat targeting for models and agents - Login flows for Beeper, Magic Proxy, or custom (BYOK) - OpenClaw-style memory search (stored in the bridge DB) diff --git a/docs/matrix-ai-matrix-spec-v1.md b/docs/matrix-ai-matrix-spec-v1.md index a554ac25..5a6d4c73 100644 --- a/docs/matrix-ai-matrix-spec-v1.md +++ b/docs/matrix-ai-matrix-spec-v1.md @@ -31,7 +31,7 @@ This document specifies a Matrix transport profile for real-time AI: - primary: ephemeral events (`com.beeper.ai.stream_event` with AI SDK `UIMessageChunk`) - fallback: debounced `m.replace` timeline edits when ephemeral delivery is unavailable - `com.beeper.ai.*` timeline projection events (tool call/result, compaction status, etc). -- `com.beeper.ai.*` state events (room settings/capabilities). +- standard Matrix room features for capability advertising. - Tool approvals (MCP approvals + selected builtin tools). - Auxiliary `com.beeper.ai*` keys used for routing/metadata. @@ -82,9 +82,6 @@ Authoritative identifiers are defined in `pkg/matrixevents/matrixevents.go`. | `m.room.message` | message | timeline | Canonical assistant message carrier (`com.beeper.ai`) | [Canonical](#canonical) | | `com.beeper.ai.stream_event` | ephemeral | ephemeral | Streaming `UIMessageChunk` deltas | [Streaming](#streaming) | | `com.beeper.ai.compaction_status` | message | timeline | Context compaction lifecycle/status | [Projections](#projection-compaction) | -| `com.beeper.ai.room_capabilities` | state | state | Producer-controlled capabilities and effective settings | [State](#state-room-capabilities) | -| `com.beeper.ai.room_settings` | state | state | User-editable room settings | [State](#state-room-settings) | -| `com.beeper.ai.model_capabilities` | state | state | Per-model capabilities (e.g. supported features) | — | | `com.beeper.ai.agents` | state | state | Agent definitions for the room | — | ### Content Keys (Inside Standard Events) @@ -288,56 +285,7 @@ Example: ## State Events -State events broadcast room configuration and capabilities. - - -### `com.beeper.ai.room_capabilities` -Producer-controlled capabilities and effective settings. - -Fields (see `RoomCapabilitiesEventContent` in `pkg/connector/events.go`): -- `capabilities?: ModelCapabilities` -- `available_tools?: ToolInfo[]` -- `reasoning_effort_options?: { value: string, label: string }[]` -- `provider?: string` -- `effective_settings?: object` - -Example: -```json -{ - "capabilities": { - "supports_reasoning": true, - "supports_tool_calling": true - }, - "available_tools": [ - {"name": "web_search", "display_name": "Web Search", "type": "provider", "enabled": true, "available": true} - ], - "provider": "beeper" -} -``` - - -### `com.beeper.ai.room_settings` -User-editable room settings. - -Fields (see `RoomSettingsEventContent` in `pkg/connector/events.go`): -- `model?: string` -- `system_prompt?: string` -- `temperature?: number` -- `max_context_messages?: number` -- `max_completion_tokens?: number` -- `reasoning_effort?: string` -- `agent_id?: string` -- `emit_thinking?: boolean` -- `emit_tool_args?: boolean` - -Example: -```json -{ - "model": "openai/gpt-5", - "temperature": 0.7, - "agent_id": "boss" -} -``` +This bridge no longer uses custom room state for editable AI configuration. Room target selection is determined by ghost identity and membership, while room-level capability advertising uses standard Matrix room features. ## Tool Approvals diff --git a/docs/msc/com.beeper.mscXXXX-commands.md b/docs/msc/com.beeper.mscXXXX-commands.md index 26dce85c..7b3a0a3a 100644 --- a/docs/msc/com.beeper.mscXXXX-commands.md +++ b/docs/msc/com.beeper.mscXXXX-commands.md @@ -8,7 +8,7 @@ This is a profile document, not a new MSC. It specifies which commands ai-bridge ## Motivation -Text-based bot commands (`!ai model gpt-4o`, `!ai reset`) have several problems: +Text-based bot commands (`!ai status`, `!ai reset`) have several problems: - **Undiscoverable:** Users must read documentation or type `!ai help` to learn available commands. There is no in-client autocomplete or parameter hinting. - **Fragile parsing:** Free-text command parsing leads to ambiguous inputs and poor error messages. Typed parameters eliminate this class of bugs. @@ -36,22 +36,6 @@ The bot MUST broadcast one state event per command when it joins a room. The `st ``` ```json -{ - "type": "org.matrix.msc4391.command_description", - "state_key": "model", - "content": { - "description": "Get or set the AI model", - "arguments": { - "model_id": { - "description": "Model identifier (e.g. gpt-4o, claude-sonnet)", - "required": false, - "type": "string" - } - } - } -} -``` - ### Structured Invocation When a client sends a command, it MUST include the `org.matrix.msc4391.command` field in the message content: @@ -61,12 +45,10 @@ When a client sends a command, it MUST include the `org.matrix.msc4391.command` "type": "m.room.message", "content": { "msgtype": "m.text", - "body": "!ai model gpt-4o", + "body": "!ai status", "org.matrix.msc4391.command": { - "command": "model", - "arguments": { - "model_id": "gpt-4o" - } + "command": "status", + "arguments": {} } } } @@ -80,19 +62,10 @@ Commands broadcast by ai-bridge: | Command | Description | Arguments | |---------|-------------|-----------| +| `new` | Create a new chat of the same type | `agent?: string` | | `status` | Show current session status | — | -| `model` | Get or set the AI model | `model_id?: string` | | `reset` | Start a new session/thread | — | | `stop` | Abort current run and clear queue | — | -| `think` | Get or set thinking level | `level?: off\|minimal\|low\|medium\|high\|xhigh` | -| `verbose` | Get or set verbosity | `level?: off\|on\|full` | -| `reasoning` | Get or set reasoning visibility | `level?: off\|on\|low\|medium\|high\|xhigh` | -| `elevated` | Get or set elevated access | `level?: off\|on\|ask\|full` | -| `activation` | Set group activation policy | `policy: mention\|always` | -| `send` | Allow/deny sending messages | `mode: on\|off\|inherit` | -| `queue` | Inspect or configure message queue | `action?: status\|reset\|` | -| `whoami` | Show your Matrix user ID | — | -| `last-heartbeat` | Show last heartbeat event | — | Dynamic commands from integrations and modules are also broadcast as state events. @@ -104,7 +77,7 @@ When both are present, the structured `org.matrix.msc4391.command` field takes p ## Security Considerations -- **Command authorization:** The bot SHOULD check room power levels before executing commands that modify room or session state. Commands like `reset`, `model`, and `elevated` affect all users in the room. +- **Command authorization:** The bot SHOULD check room power levels before executing commands that modify room or session state. - **Argument validation:** The bot MUST validate structured arguments against the published schema before execution. Malformed arguments MUST be rejected with an error message. ## Unstable Prefix diff --git a/pkg/connector/agent_activity.go b/pkg/connector/agent_activity.go index 058408ec..a98543cd 100644 --- a/pkg/connector/agent_activity.go +++ b/pkg/connector/agent_activity.go @@ -74,7 +74,7 @@ func (oc *AIClient) lastActivePortal(agentID string) *bridgev2.Portal { portal := oc.portalByRoomID(context.Background(), id.RoomID(room)) // Guard against stale mappings when a room's agent assignment changes. if portal != nil { - if meta := portalMeta(portal); meta != nil && normalizeAgentID(meta.AgentID) != normalizeAgentID(agentID) { + if meta := portalMeta(portal); meta != nil && normalizeAgentID(resolveAgentID(meta)) != normalizeAgentID(agentID) { return nil } } diff --git a/pkg/connector/agent_display.go b/pkg/connector/agent_display.go index f5bb981a..8bcf3ed9 100644 --- a/pkg/connector/agent_display.go +++ b/pkg/connector/agent_display.go @@ -53,9 +53,6 @@ func (oc *AIClient) agentDefaultModel(agent *agents.AgentDefinition) string { if agent == nil { return oc.effectiveModel(nil) } - if override := oc.agentModelOverride(agent.ID); override != "" { - return ResolveAlias(override) - } if agent.Model.Primary != "" { return ResolveAlias(agent.Model.Primary) } diff --git a/pkg/connector/agentstore.go b/pkg/connector/agentstore.go index 20fd8611..3a96d667 100644 --- a/pkg/connector/agentstore.go +++ b/pkg/connector/agentstore.go @@ -522,13 +522,12 @@ func (b *BossStoreAdapter) CreateRoom(ctx context.Context, room tools.RoomData) return "", fmt.Errorf("failed to get created portal: %w", err) } - // Apply custom name and system prompt if provided + // Apply custom room name if provided. pm := portalMeta(portal) originalName := portal.Name originalNameSet := portal.NameSet originalTitle := pm.Title originalTitleGenerated := pm.TitleGenerated - originalSystemPrompt := pm.SystemPrompt if room.Name != "" { pm.Title = room.Name @@ -538,12 +537,6 @@ func (b *BossStoreAdapter) CreateRoom(ctx context.Context, room tools.RoomData) resp.PortalInfo.Name = &room.Name } } - if room.SystemPrompt != "" { - pm.SystemPrompt = room.SystemPrompt - // Note: portal.Topic is NOT set to SystemPrompt - they are separate concepts - // Topic is for display only, SystemPrompt is for LLM context - } - // Create the Matrix room if err := portal.CreateMatrixRoom(ctx, b.store.client.UserLogin, resp.PortalInfo); err != nil { cleanupPortal(ctx, b.store.client, portal, "failed to create Matrix room") @@ -562,13 +555,6 @@ func (b *BossStoreAdapter) CreateRoom(ctx context.Context, room tools.RoomData) pm.TitleGenerated = originalTitleGenerated } } - if room.SystemPrompt != "" { - if err := b.store.client.setRoomSystemPromptNoSave(ctx, portal, room.SystemPrompt); err != nil { - b.store.client.log.Warn().Err(err).Msg("Failed to set room system prompt") - pm.SystemPrompt = originalSystemPrompt - } - } - if err := portal.Save(ctx); err != nil { return "", fmt.Errorf("failed to save room overrides: %w", err) } @@ -597,29 +583,18 @@ func (b *BossStoreAdapter) ModifyRoom(ctx context.Context, roomID string, update if err != nil { return fmt.Errorf("agent '%s' not found: %w", updates.AgentID, err) } - pm.AgentID = agent.ID - pm.Model = "" - modelID := b.store.client.effectiveModel(pm) - pm.Capabilities = getModelCapabilities(modelID, b.store.client.findModelInfo(modelID)) portal.OtherUserID = agentUserID(agent.ID) + pm.ResolvedTarget = resolveTargetFromGhostID(portal.OtherUserID) + modelID := b.store.client.effectiveModel(pm) agentName := b.store.client.resolveAgentDisplayName(ctx, agent) b.store.client.ensureAgentGhostDisplayName(ctx, agent.ID, modelID, agentName) } - if updates.SystemPrompt != "" { - pm.SystemPrompt = updates.SystemPrompt - // Note: portal.Topic is NOT set to SystemPrompt - they are separate concepts - } if updates.Name != "" && portal.MXID != "" { if err := b.store.client.setRoomName(ctx, portal, updates.Name); err != nil { b.store.client.log.Warn().Err(err).Msg("Failed to set Matrix room name") } } - if updates.SystemPrompt != "" && portal.MXID != "" { - if err := b.store.client.setRoomSystemPrompt(ctx, portal, updates.SystemPrompt); err != nil { - b.store.client.log.Warn().Err(err).Msg("Failed to set room system prompt") - } - } return portal.Save(ctx) } @@ -645,7 +620,7 @@ func (b *BossStoreAdapter) ListRooms(ctx context.Context) ([]tools.RoomData, err rooms = append(rooms, tools.RoomData{ ID: roomID, Name: name, - AgentID: pm.AgentID, + AgentID: resolveAgentID(pm), }) } diff --git a/pkg/connector/builder.go b/pkg/connector/builder.go deleted file mode 100644 index 3131403f..00000000 --- a/pkg/connector/builder.go +++ /dev/null @@ -1,152 +0,0 @@ -package connector - -import ( - "context" - "fmt" - - "go.mau.fi/util/ptr" - - "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/networkid" - - "github.com/beeper/ai-bridge/pkg/agents" -) - -// Builder room constants -const ( - BuilderRoomSlug = "builder" - BuilderRoomName = "Manage AI Chats" -) - -// ensureBuilderRoom creates or retrieves the "Manage AI Chats" room. -// This special room is where users interact with the Boss agent to manage their agents and rooms. -func (oc *AIClient) ensureBuilderRoom(ctx context.Context) error { - meta := loginMetadata(oc.UserLogin) - - // Check if we already have a Builder room - if meta.BuilderRoomID != "" { - // Verify it still exists - portal, err := oc.UserLogin.Bridge.GetPortalByKey(ctx, networkid.PortalKey{ - ID: meta.BuilderRoomID, - Receiver: oc.UserLogin.ID, - }) - if err == nil && portal != nil && portal.MXID != "" { - oc.loggerForContext(ctx).Debug().Str("room_id", string(meta.BuilderRoomID)).Msg("Manage AI Chats room already exists") - return nil - } - // Room doesn't exist anymore, clear the reference - meta.BuilderRoomID = "" - } - - oc.loggerForContext(ctx).Info().Msg("Creating Manage AI Chats room") - - // Create the Builder room with Boss agent as the ghost - portal, chatInfo, err := oc.createBuilderRoom(ctx) - if err != nil { - return fmt.Errorf("failed to create builder room: %w", err) - } - - // Create Matrix room - if err := portal.CreateMatrixRoom(ctx, oc.UserLogin, chatInfo); err != nil { - cleanupPortal(ctx, oc, portal, "failed to create builder Matrix room") - return fmt.Errorf("failed to create matrix room for builder: %w", err) - } - - // Send welcome message (excluded from LLM history) - oc.sendWelcomeMessage(ctx, portal) - - // Store the Builder room ID - meta.BuilderRoomID = portal.PortalKey.ID - if err := oc.UserLogin.Save(ctx); err != nil { - meta.BuilderRoomID = "" - cleanupPortal(ctx, oc, portal, "failed to save BuilderRoomID") - return fmt.Errorf("failed to save BuilderRoomID: %w", err) - } - - oc.loggerForContext(ctx).Info(). - Str("portal_id", string(portal.PortalKey.ID)). - Str("mxid", string(portal.MXID)). - Msg("Manage AI Chats room created") - - return nil -} - -// createBuilderRoom creates the "Manage AI Chats" room portal and chat info. -func (oc *AIClient) createBuilderRoom(ctx context.Context) (*bridgev2.Portal, *bridgev2.ChatInfo, error) { - bossAgent := agents.GetBossAgent() - - // Use a standard chat initialization with the management room title - opts := PortalInitOpts{ - Title: BuilderRoomName, - } - - portal, chatInfo, err := oc.initPortalForChat(ctx, opts) - if err != nil { - return nil, nil, err - } - - // Set up the portal metadata for the Boss agent - pm := portalMeta(portal) - pm.Slug = BuilderRoomSlug // Override slug to "builder" - pm.AgentID = bossAgent.ID - pm.SystemPrompt = agents.BossSystemPrompt - pm.Model = bossAgent.Model.Primary // Explicit model - always use Boss agent's model - pm.IsBuilderRoom = true // Mark as protected from overrides - - // Use agent ghost for the Boss agent - modelID := pm.Model - if modelID == "" { - modelID = oc.effectiveModel(nil) - } - bossGhostID := agentUserID(bossAgent.ID) - bossDisplayName := oc.resolveAgentDisplayName(ctx, bossAgent) - portal.OtherUserID = bossGhostID - - if chatInfo != nil && chatInfo.Members != nil { - members := chatInfo.Members - if members.MemberMap == nil { - members.MemberMap = make(bridgev2.ChatMemberMap) - } - members.OtherUserID = bossGhostID - humanID := humanUserID(oc.UserLogin.ID) - humanMember := members.MemberMap[humanID] - humanMember.EventSender = bridgev2.EventSender{ - IsFromMe: true, - SenderLogin: oc.UserLogin.ID, - } - bossMember := members.MemberMap[bossGhostID] - bossMember.EventSender = bridgev2.EventSender{ - Sender: bossGhostID, - SenderLogin: oc.UserLogin.ID, - } - bossMember.UserInfo = &bridgev2.UserInfo{ - Name: ptr.Ptr(bossDisplayName), - IsBot: ptr.Ptr(true), - Identifiers: agentContactIdentifiers(bossAgent.ID, modelID, oc.findModelInfo(modelID)), - } - bossMember.MemberEventExtra = map[string]any{ - "displayname": bossDisplayName, - "com.beeper.ai.model_id": modelID, - "com.beeper.ai.agent": bossAgent.ID, - } - members.MemberMap = bridgev2.ChatMemberMap{ - humanID: humanMember, - bossGhostID: bossMember, - } - chatInfo.Members = members - } - - // Re-save portal with updated metadata - if err := portal.Save(ctx); err != nil { - return nil, nil, fmt.Errorf("failed to save portal with agent config: %w", err) - } - oc.ensureAgentGhostDisplayName(ctx, bossAgent.ID, modelID, bossDisplayName) - - return portal, chatInfo, nil -} - -// isBuilderRoom checks if a portal is the Builder room. -func (oc *AIClient) isBuilderRoom(portal *bridgev2.Portal) bool { - meta := loginMetadata(oc.UserLogin) - return meta.BuilderRoomID != "" && portal.PortalKey.ID == meta.BuilderRoomID -} diff --git a/pkg/connector/chat.go b/pkg/connector/chat.go index 36481c0a..76f7bf91 100644 --- a/pkg/connector/chat.go +++ b/pkg/connector/chat.go @@ -7,7 +7,6 @@ import ( "strings" "time" - "github.com/rs/zerolog" "go.mau.fi/util/ptr" "github.com/beeper/ai-bridge/pkg/agents" @@ -20,7 +19,6 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" - "maunium.net/go/mautrix/bridgev2/simplevent" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -37,17 +35,11 @@ const defaultSimpleModeSystemPrompt = "You are a helpful assistant." var ErrDMGhostImmutable = errors.New("can't change the counterpart ghost in a DM") func hasAssignedAgent(meta *PortalMetadata) bool { - if meta == nil { - return false - } - return meta.AgentID != "" + return resolveAgentID(meta) != "" } func hasBossAgent(meta *PortalMetadata) bool { - if meta == nil { - return false - } - return agents.IsBossAgent(meta.AgentID) + return agents.IsBossAgent(resolveAgentID(meta)) } func dmModelSwitchGuidance(targetModel string) string { @@ -72,28 +64,6 @@ func modelRedirectTarget(requested, resolved string) networkid.UserID { // validateDMModelSwitch enforces the DM invariant that counterpart ghosts are immutable. // Agent rooms are exempt because the stable counterpart ghost is the agent ghost. -func (oc *AIClient) validateDMModelSwitch(portal *bridgev2.Portal, meta *PortalMetadata, targetModel string) error { - if oc == nil || portal == nil || meta == nil || strings.TrimSpace(targetModel) == "" { - return nil - } - if portal.RoomType != database.RoomTypeDM { - return nil - } - if resolveAgentID(meta) != "" { - return nil - } - currentModel := oc.effectiveModel(meta) - if currentModel == "" || currentModel == targetModel { - return nil - } - currentGhost := modelUserID(currentModel) - targetGhost := modelUserID(targetModel) - if currentGhost == targetGhost { - return nil - } - return fmt.Errorf("%w: %s -> %s", ErrDMGhostImmutable, currentModel, targetModel) -} - // buildAvailableTools returns a list of ToolInfo for all tools based on tool policy. func (oc *AIClient) buildAvailableTools(meta *PortalMetadata) []ToolInfo { names := oc.toolNamesForPortal(meta) @@ -583,9 +553,8 @@ func (oc *AIClient) createAgentChatWithModel(ctx context.Context, agent *agents. agentName := oc.resolveAgentDisplayName(ctx, agent) portal, chatInfo, err := oc.initPortalForChat(ctx, PortalInitOpts{ - ModelID: modelID, - Title: fmt.Sprintf("Chat with %s", agentName), - SystemPrompt: agent.SystemPrompt, + ModelID: modelID, + Title: fmt.Sprintf("Chat with %s", agentName), }) if err != nil { return nil, err @@ -593,21 +562,15 @@ func (oc *AIClient) createAgentChatWithModel(ctx context.Context, agent *agents. // Set agent-specific metadata pm := portalMeta(portal) - pm.AgentID = agent.ID - if agent.SystemPrompt != "" { - pm.SystemPrompt = agent.SystemPrompt - } - if agent.ReasoningEffort != "" { - pm.ReasoningEffort = agent.ReasoningEffort - } - if !applyModelOverride { - pm.Model = "" - } agentGhostID := agentUserID(agent.ID) // Update the OtherUserID to be the agent ghost portal.OtherUserID = agentGhostID + pm.ResolvedTarget = resolveTargetFromGhostID(agentGhostID) + if applyModelOverride { + pm.RuntimeModelOverride = ResolveAlias(modelID) + } agentAvatar := strings.TrimSpace(agent.AvatarURL) if agentAvatar == "" { agentAvatar = strings.TrimSpace(agents.DefaultAgentAvatarMXC) @@ -641,22 +604,13 @@ func (oc *AIClient) createAgentChatWithModel(ctx context.Context, agent *agents. // createNewChat creates a new portal for a specific model func (oc *AIClient) createNewChat(ctx context.Context, modelID string) (*bridgev2.CreateChatResponse, error) { portal, chatInfo, err := oc.initPortalForChat(ctx, PortalInitOpts{ - ModelID: modelID, - SystemPrompt: defaultSimpleModeSystemPrompt, + ModelID: modelID, }) if err != nil { return nil, err } // Keep simple mode chats non-agentic by default. - meta := portalMeta(portal) - if meta != nil && !meta.IsSimpleMode { - meta.IsSimpleMode = true - if err := portal.Save(ctx); err != nil { - return nil, fmt.Errorf("failed to save portal simple mode: %w", err) - } - } - // Rooms created via provisioning (ResolveIdentifier/CreateDM) won't go through our explicit // post-CreateMatrixRoom call sites. Schedule the welcome notice for when the Matrix room exists. oc.scheduleWelcomeMessage(ctx, portal.PortalKey) @@ -685,31 +639,25 @@ func (oc *AIClient) allocateNextChatIndex(ctx context.Context) (int, error) { // PortalInitOpts contains options for initializing a chat portal type PortalInitOpts struct { - ModelID string - Title string - SystemPrompt string - CopyFrom *PortalMetadata // For forked chats - copies config from source - PortalKey *networkid.PortalKey + ModelID string + Title string + CopyFrom *PortalMetadata // For forked chats - copies config from source + PortalKey *networkid.PortalKey } func cloneForkPortalMetadata(src *PortalMetadata, slug, title string) *PortalMetadata { if src == nil { return nil } - return &PortalMetadata{ - Model: src.Model, - Slug: slug, - Title: title, - SystemPrompt: src.SystemPrompt, - Temperature: src.Temperature, - MaxContextMessages: src.MaxContextMessages, - MaxCompletionTokens: src.MaxCompletionTokens, - ReasoningEffort: src.ReasoningEffort, - Capabilities: src.Capabilities, - AgentID: src.AgentID, - AgentPrompt: src.AgentPrompt, - IsSimpleMode: src.IsSimpleMode, + clone := &PortalMetadata{ + Slug: slug, + Title: title, } + if src.ResolvedTarget != nil { + target := *src.ResolvedTarget + clone.ResolvedTarget = &target + } + return clone } // initPortalForChat handles common portal initialization logic. @@ -745,14 +693,10 @@ func (oc *AIClient) initPortalForChat(ctx context.Context, opts PortalInitOpts) var pmeta *PortalMetadata if opts.CopyFrom != nil { pmeta = cloneForkPortalMetadata(opts.CopyFrom, slug, title) - modelID = opts.CopyFrom.Model } else { pmeta = &PortalMetadata{ - Model: modelID, - Slug: slug, - Title: title, - SystemPrompt: opts.SystemPrompt, - Capabilities: getModelCapabilities(modelID, oc.findModelInfo(modelID)), + Slug: slug, + Title: title, } } portal.Metadata = pmeta @@ -766,8 +710,6 @@ func (oc *AIClient) initPortalForChat(ctx context.Context, opts PortalInitOpts) portal.AvatarID = networkid.AvatarID(defaultAvatar) portal.AvatarMXC = id.ContentURIString(defaultAvatar) } - // Note: portal.Topic is NOT set to SystemPrompt - they are separate concepts - if err := portal.Save(ctx); err != nil { return nil, nil, fmt.Errorf("failed to save portal: %w", err) } @@ -777,90 +719,6 @@ func (oc *AIClient) initPortalForChat(ctx context.Context, opts PortalInitOpts) return portal, chatInfo, nil } -// handleFork creates a new chat and copies messages from the current conversation -func (oc *AIClient) handleFork( - ctx context.Context, - _ *event.Event, - portal *bridgev2.Portal, - meta *PortalMetadata, - arg string, -) { - runCtx := oc.backgroundContext(ctx) - - // 1. Retrieve all messages from current chat - messages, err := oc.UserLogin.Bridge.DB.Message.GetLastNInPortal(runCtx, portal.PortalKey, 10000) - if err != nil { - oc.sendSystemNotice(runCtx, portal, "Couldn't load messages: "+err.Error()) - return - } - - if len(messages) == 0 { - oc.sendSystemNotice(runCtx, portal, "No messages to fork.") - return - } - - // 2. If event ID specified, filter messages up to that point - var messagesToCopy []*database.Message - if arg != "" { - // Validate Matrix event ID format - if !strings.HasPrefix(arg, "$") { - oc.sendSystemNotice(runCtx, portal, "Invalid event ID. Must start with '$'.") - return - } - - // Messages are newest-first, reverse iterate to find target - found := false - for i := len(messages) - 1; i >= 0; i-- { - msg := messages[i] - messagesToCopy = append(messagesToCopy, msg) - - // Check MXID field (Matrix event ID) - if msg.MXID != "" && string(msg.MXID) == arg { - found = true - break - } - // Check message ID format "mx:$eventid" - if strings.HasSuffix(string(msg.ID), arg) { - found = true - break - } - } - - if !found { - oc.sendSystemNotice(runCtx, portal, fmt.Sprintf("Couldn't find event: %s", arg)) - return - } - } else { - // Copy all messages (reverse to get chronological order) - for i := len(messages) - 1; i >= 0; i-- { - messagesToCopy = append(messagesToCopy, messages[i]) - } - } - - // 3. Create new chat with same configuration - newPortal, chatInfo, err := oc.createForkedChat(runCtx, portal, meta) - if err != nil { - oc.sendSystemNotice(runCtx, portal, "Couldn't create the forked chat: "+err.Error()) - return - } - - // 4. Create Matrix room - if err := newPortal.CreateMatrixRoom(runCtx, oc.UserLogin, chatInfo); err != nil { - oc.sendSystemNotice(runCtx, portal, "Couldn't create the room: "+err.Error()) - return - } - - // 5. Copy messages to new chat - copiedCount := oc.copyMessagesToChat(runCtx, newPortal, messagesToCopy) - - // 6. Send notice with link - roomLink := fmt.Sprintf("https://matrix.to/#/%s", newPortal.MXID) - oc.sendSystemNotice(runCtx, portal, fmt.Sprintf( - "Forked %d messages to new chat.\nOpen: %s", - copiedCount, roomLink, - )) -} - // handleNewChat creates a new chat using the current room's agent/model, // or an explicitly provided agent/model. func (oc *AIClient) handleNewChat( @@ -905,7 +763,7 @@ func (oc *AIClient) handleNewChat( // No args: create new room of same type if meta == nil { - oc.sendSystemNotice(runCtx, portal, "Couldn't read current room settings.") + oc.sendSystemNotice(runCtx, portal, "Couldn't resolve the current chat target.") return } agentID := resolveAgentID(meta) @@ -921,8 +779,7 @@ func (oc *AIClient) handleNewChat( oc.sendSystemNotice(runCtx, portal, err.Error()) return } - modelOverride := meta != nil && meta.Model != "" - oc.createAndOpenAgentChat(runCtx, portal, agent, modelID, modelOverride) + oc.createAndOpenAgentChat(runCtx, portal, agent, modelID, false) return } @@ -1019,146 +876,16 @@ func (oc *AIClient) createAndOpenSimpleChat(ctx context.Context, portal *bridgev )) } -// createForkedChat creates a new portal inheriting config from source -func (oc *AIClient) createForkedChat( - ctx context.Context, - sourcePortal *bridgev2.Portal, - sourceMeta *PortalMetadata, -) (*bridgev2.Portal, *bridgev2.ChatInfo, error) { - sourceTitle := sourceMeta.Title - if sourceTitle == "" { - sourceTitle = sourcePortal.Name - } - title := fmt.Sprintf("%s (Fork)", sourceTitle) - - portal, chatInfo, err := oc.initPortalForChat(ctx, PortalInitOpts{ - Title: title, - CopyFrom: sourceMeta, - }) - if err != nil { - return nil, nil, err - } - - agentID := sourceMeta.AgentID - if agentID != "" { - pm := portalMeta(portal) - pm.AgentID = agentID - - modelID := oc.effectiveModel(pm) - portal.OtherUserID = agentUserID(agentID) - - agentName := agentID - agentAvatar := "" - // Try preset first - guaranteed to work for built-in agents (like "beeper") - if preset := agents.GetPresetByID(agentID); preset != nil { - agentName = oc.resolveAgentDisplayName(ctx, preset) - agentAvatar = preset.AvatarURL - } else { - // Custom agent - need Matrix state lookup - store := NewAgentStoreAdapter(oc) - if agent, err := store.GetAgentByID(ctx, agentID); err == nil && agent != nil { - agentName = oc.resolveAgentDisplayName(ctx, agent) - agentAvatar = agent.AvatarURL - } - } - if strings.TrimSpace(agentAvatar) == "" { - agentAvatar = strings.TrimSpace(agents.DefaultAgentAvatarMXC) - } - if agentAvatar != "" { - portal.AvatarID = networkid.AvatarID(agentAvatar) - portal.AvatarMXC = id.ContentURIString(agentAvatar) - } - oc.applyAgentChatInfo(chatInfo, agentID, agentName, modelID) - oc.ensureAgentGhostDisplayName(ctx, agentID, modelID, agentName) - - if err := portal.Save(ctx); err != nil { - return nil, nil, err - } - } - - return portal, chatInfo, nil -} - -// copyMessagesToChat queues messages to be bridged to the new chat -// Returns the count of successfully queued messages -func (oc *AIClient) copyMessagesToChat( - ctx context.Context, - destPortal *bridgev2.Portal, - messages []*database.Message, -) int { - copiedCount := 0 - skippedCount := 0 - - for _, srcMsg := range messages { - srcMeta := messageMeta(srcMsg) - if srcMeta == nil || srcMeta.Body == "" { - skippedCount++ - continue - } - - // Determine sender - var sender bridgev2.EventSender - if srcMeta.Role == "user" { - sender = bridgev2.EventSender{ - Sender: humanUserID(oc.UserLogin.ID), - SenderLogin: oc.UserLogin.ID, - IsFromMe: true, - } - } else { - sender = bridgev2.EventSender{ - Sender: srcMsg.SenderID, - SenderLogin: oc.UserLogin.ID, - IsFromMe: false, - } - } - - // Create remote message for bridging - remoteMsg := &OpenAIRemoteMessage{ - PortalKey: destPortal.PortalKey, - ID: bridgeadapter.NewMessageID("fork"), - Sender: sender, - Content: srcMeta.Body, - Timestamp: srcMsg.Timestamp, - Metadata: &MessageMetadata{ - BaseMessageMetadata: bridgeadapter.BaseMessageMetadata{Role: srcMeta.Role, Body: srcMeta.Body}, - }, - } - - oc.UserLogin.QueueRemoteEvent(remoteMsg) - copiedCount++ - } - - // Log if partial copy occurred (some messages were skipped) - if skippedCount > 0 { - oc.loggerForContext(ctx).Warn(). - Int("copied", copiedCount). - Int("skipped", skippedCount). - Int("total", len(messages)). - Msg("Partial fork - some messages were skipped due to missing metadata") - } - - return copiedCount -} - // createNewSimpleChat creates a new simple mode chat portal with the specified model. func (oc *AIClient) createNewSimpleChat(ctx context.Context, modelID string) (*bridgev2.Portal, *bridgev2.ChatInfo, error) { portal, chatInfo, err := oc.initPortalForChat(ctx, PortalInitOpts{ - ModelID: modelID, - SystemPrompt: defaultSimpleModeSystemPrompt, + ModelID: modelID, }) if err != nil { return nil, nil, err } // Simple mode rooms are non-agentic. This disables directive processing. - meta := portalMeta(portal) - if meta != nil && !meta.IsSimpleMode { - meta.IsSimpleMode = true - if err := portal.Save(ctx); err != nil { - return nil, nil, err - } - } - return portal, chatInfo, nil } @@ -1208,13 +935,11 @@ func (oc *AIClient) composeChatInfo(title, modelID string) *bridgev2.ChatInfo { title = modelName } chatInfo := bridgeadapter.BuildDMChatInfo(bridgeadapter.DMChatInfoParams{ - Title: title, - HumanUserID: humanUserID(oc.UserLogin.ID), - LoginID: oc.UserLogin.ID, - BotUserID: modelUserID(modelID), - BotDisplayName: modelName, - CapabilitiesEvent: RoomCapabilitiesEventType, - SettingsEvent: RoomSettingsEventType, + Title: title, + HumanUserID: humanUserID(oc.UserLogin.ID), + LoginID: oc.UserLogin.ID, + BotUserID: modelUserID(modelID), + BotDisplayName: modelName, }) // Override bot member with model-specific UserInfo and extra fields. chatInfo.Members.MemberMap[modelUserID(modelID)] = modelJoinMember(oc.UserLogin.ID, modelID, modelName, modelInfo) @@ -1272,460 +997,10 @@ func (oc *AIClient) applyAgentChatInfo(chatInfo *bridgev2.ChatInfo, agentID, age chatInfo.Members = members } -// updatePortalConfig applies room settings to portal metadata with optimistic updates. -// If persistence fails, metadata is rolled back to the previous values. -func (oc *AIClient) updatePortalConfig(ctx context.Context, portal *bridgev2.Portal, config *RoomSettingsEventContent) error { - meta := portalMeta(portal) - before := clonePortalMetadata(meta) - - // Track old model for membership change - oldModel := meta.Model - - if config.Model != "" { - if err := oc.validateDMModelSwitch(portal, meta, config.Model); err != nil { - return dmModelSwitchBlockedError(config.Model) - } - } - - // Update only non-empty/non-zero values - if config.Model != "" { - meta.Model = config.Model - // Update capabilities when model changes - meta.Capabilities = getModelCapabilities(config.Model, oc.findModelInfo(config.Model)) - } - if config.SystemPrompt != "" { - meta.SystemPrompt = config.SystemPrompt - } - if config.Temperature != nil { - meta.Temperature = *config.Temperature - } - if config.MaxContextMessages > 0 { - meta.MaxContextMessages = config.MaxContextMessages - } - if config.MaxCompletionTokens > 0 { - meta.MaxCompletionTokens = config.MaxCompletionTokens - } - if config.ReasoningEffort != "" { - meta.ReasoningEffort = config.ReasoningEffort - } - if config.AgentID != "" { - meta.AgentID = config.AgentID - } - - meta.LastRoomStateSync = time.Now().Unix() - - // Persist changes - if err := portal.Save(ctx); err != nil { - if before != nil { - *meta = *before - } - oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to save portal after config update") - return err - } - - // Re-broadcast room state to confirm changes to all clients - if err := oc.BroadcastRoomState(ctx, portal); err != nil { - if before != nil { - *meta = *before - if saveErr := portal.Save(ctx); saveErr != nil { - oc.loggerForContext(ctx).Warn().Err(saveErr).Msg("Failed to save rollback portal metadata after state broadcast failure") - } - } - oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to re-broadcast room state after config update") - return err - } - - // Handle model switch - generate membership events if model changed. - // This is done after persistence succeeds so optimistic updates can roll back safely. - if config.Model != "" && oldModel != "" && config.Model != oldModel { - oc.handleModelSwitch(ctx, portal, oldModel, config.Model) - } - - return nil -} - -// handleModelSwitch generates membership change events when switching models -// This creates leave/join events to show the model transition in the room timeline -// For agent rooms, it updates the agent ghost metadata. -func (oc *AIClient) handleModelSwitch(ctx context.Context, portal *bridgev2.Portal, oldModel, newModel string) { - if oldModel == newModel || oldModel == "" || newModel == "" { - return - } - - meta := portalMeta(portal) - agentID := resolveAgentID(meta) - - // Check if this is an agent room - update agent ghost metadata - if agentID != "" { - oc.handleAgentModelSwitch(ctx, portal, agentID, oldModel, newModel) - return - } - - // For non-agent rooms, use simple mode ghosts - oc.loggerForContext(ctx).Info(). - Str("old_model", oldModel). - Str("new_model", newModel). - Stringer("portal", portal.PortalKey). - Msg("Handling model switch") - - oldInfo := oc.findModelInfo(oldModel) - newInfo := oc.findModelInfo(newModel) - oldModelName := modelContactName(oldModel, oldInfo) - newModelName := modelContactName(newModel, newInfo) - - // Pre-update the new model ghost's profile before queueing the event - // This ensures the ghost has a display name set in its Matrix profile - newGhost, err := oc.UserLogin.Bridge.GetGhostByID(ctx, modelUserID(newModel)) - if err != nil { - oc.loggerForContext(ctx).Warn().Err(err).Str("model", newModel).Msg("Failed to get ghost for model switch") - } else { - oc.ensureGhostDisplayNameWithGhost(ctx, newGhost, newModel, newInfo) - } - - // Create member changes: old model leaves, new model joins - // Use MemberEventExtra to set displayname directly in the membership event - // This works because MemberEventContent.Displayname has omitempty, so our Raw value is preserved - memberChanges := &bridgev2.ChatMemberList{ - MemberMap: bridgev2.ChatMemberMap{ - modelUserID(oldModel): { - EventSender: bridgev2.EventSender{ - Sender: modelUserID(oldModel), - SenderLogin: oc.UserLogin.ID, - }, - Membership: event.MembershipLeave, - PrevMembership: event.MembershipJoin, - }, - modelUserID(newModel): modelJoinMember(oc.UserLogin.ID, newModel, newModelName, newInfo), - }, - } - - // Update portal's OtherUserID to new model - portal.OtherUserID = modelUserID(newModel) - - // Queue the ChatInfoChange event - evt := &simplevent.ChatInfoChange{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventChatInfoChange, - PortalKey: portal.PortalKey, - Timestamp: time.Now(), - LogContext: func(c zerolog.Context) zerolog.Context { - return c.Str("action", "model_switch"). - Str("old_model", oldModel). - Str("new_model", newModel) - }, - }, - ChatInfoChange: &bridgev2.ChatInfoChange{ - MemberChanges: memberChanges, - }, - } - - oc.UserLogin.QueueRemoteEvent(evt) - - // Send a notice about the model change from the bridge bot - notice := fmt.Sprintf("Switched from %s to %s", oldModelName, newModelName) - oc.sendSystemNotice(ctx, portal, notice) - - // Update bridge info and capabilities to resend room features state event with new capabilities - // This ensures the client knows what features the new model supports (vision, audio, etc.) - portal.UpdateBridgeInfo(ctx) - portal.UpdateCapabilities(ctx, oc.UserLogin, true) - - // Ensure only 1 AI ghost in room - if err := oc.ensureSingleAIGhost(ctx, portal); err != nil { - oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to ensure single AI ghost after model switch") - } -} - -// handleAgentModelSwitch handles model switching for agent rooms. -// Keeps a single agent ghost and updates member metadata. -func (oc *AIClient) handleAgentModelSwitch(ctx context.Context, portal *bridgev2.Portal, agentID, oldModel, newModel string) { - // Get the agent to determine display name - store := NewAgentStoreAdapter(oc) - agent, err := store.GetAgentByID(ctx, agentID) - if err != nil || agent == nil { - oc.loggerForContext(ctx).Warn().Err(err).Str("agent", agentID).Msg("Agent not found for model switch") - return - } - - oc.loggerForContext(ctx).Info(). - Str("agent", agentID). - Str("old_model", oldModel). - Str("new_model", newModel). - Stringer("portal", portal.PortalKey). - Msg("Handling agent model switch") - - ghostID := agentUserID(agentID) - agentName := oc.resolveAgentDisplayName(ctx, agent) - displayName := agentName - oldModelName := modelContactName(oldModel, oc.findModelInfo(oldModel)) - newModelName := modelContactName(newModel, oc.findModelInfo(newModel)) - oldGhostID := portal.OtherUserID - - // Update member metadata for the agent ghost - memberMap := bridgev2.ChatMemberMap{ - ghostID: { - EventSender: bridgev2.EventSender{ - Sender: ghostID, - SenderLogin: oc.UserLogin.ID, - }, - Membership: event.MembershipJoin, - UserInfo: &bridgev2.UserInfo{ - Name: ptr.Ptr(displayName), - IsBot: ptr.Ptr(true), - Identifiers: agentContactIdentifiers(agentID, newModel, oc.findModelInfo(newModel)), - }, - MemberEventExtra: map[string]any{ - "displayname": displayName, - "com.beeper.ai.model_id": newModel, - "com.beeper.ai.agent": agentID, - }, - }, - } - if oldGhostID != "" && oldGhostID != ghostID { - memberMap[oldGhostID] = bridgev2.ChatMember{ - EventSender: bridgev2.EventSender{ - Sender: oldGhostID, - SenderLogin: oc.UserLogin.ID, - }, - Membership: event.MembershipLeave, - PrevMembership: event.MembershipJoin, - } - } - memberChanges := &bridgev2.ChatMemberList{MemberMap: memberMap} - - // Update portal's OtherUserID to agent ghost - portal.OtherUserID = ghostID - oc.ensureAgentGhostDisplayName(ctx, agentID, newModel, agentName) - - // Queue the ChatInfoChange event - evt := &simplevent.ChatInfoChange{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventChatInfoChange, - PortalKey: portal.PortalKey, - Timestamp: time.Now(), - LogContext: func(c zerolog.Context) zerolog.Context { - return c.Str("action", "agent_model_switch"). - Str("agent", agentID). - Str("old_model", oldModel). - Str("new_model", newModel) - }, - }, - ChatInfoChange: &bridgev2.ChatInfoChange{ - MemberChanges: memberChanges, - }, - } - - oc.UserLogin.QueueRemoteEvent(evt) - - // Send a notice about the model change - notice := fmt.Sprintf("Switched model from %s to %s", oldModelName, newModelName) - oc.sendSystemNotice(ctx, portal, notice) - - // Update bridge info and capabilities - portal.UpdateBridgeInfo(ctx) - portal.UpdateCapabilities(ctx, oc.UserLogin, true) - - // Ensure only 1 AI ghost in room - if err := oc.ensureSingleAIGhost(ctx, portal); err != nil { - oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to ensure single AI ghost after agent model switch") - } -} - -// ensureSingleAIGhost ensures only 1 model/agent ghost is in the room at a time. -// Updates portal.OtherUserID if it doesn't match the expected ghost. -func (oc *AIClient) ensureSingleAIGhost(ctx context.Context, portal *bridgev2.Portal) error { - meta := portalMeta(portal) - - // Determine which ghost SHOULD be in the room - var expectedGhostID networkid.UserID - agentID := resolveAgentID(meta) - - modelID := oc.effectiveModel(meta) - if agentID != "" { - expectedGhostID = agentUserID(agentID) - } else { - expectedGhostID = modelUserID(modelID) - } - - // Update portal.OtherUserID if mismatched - if portal.OtherUserID != expectedGhostID { - oc.loggerForContext(ctx).Debug(). - Str("old_ghost", string(portal.OtherUserID)). - Str("new_ghost", string(expectedGhostID)). - Stringer("portal", portal.PortalKey). - Msg("Updating portal OtherUserID to match expected ghost") - portal.OtherUserID = expectedGhostID - return portal.Save(ctx) - } - return nil -} - -// BroadcastRoomState sends current room capabilities and settings to Matrix room state +// BroadcastRoomState refreshes standard Matrix room capabilities and command descriptions. func (oc *AIClient) BroadcastRoomState(ctx context.Context, portal *bridgev2.Portal) error { - if err := oc.broadcastCapabilities(ctx, portal); err != nil { - return err - } - if err := oc.broadcastSettings(ctx, portal); err != nil { - return err - } - // Broadcast command descriptions so clients can discover slash commands. - oc.BroadcastCommandDescriptions(ctx, portal) - return nil -} - -// buildEffectiveSettings builds the effective settings with source explanations -func (oc *AIClient) buildEffectiveSettings(meta *PortalMetadata) *EffectiveSettings { - loginMeta := loginMetadata(oc.UserLogin) - - return &EffectiveSettings{ - Model: oc.getModelWithSource(meta, loginMeta), - SystemPrompt: oc.getPromptWithSource(meta, loginMeta), - Temperature: oc.getTempWithSource(meta, loginMeta), - ReasoningEffort: oc.getReasoningWithSource(meta, loginMeta), - } -} - -func (oc *AIClient) getModelWithSource(meta *PortalMetadata, loginMeta *UserLoginMetadata) SettingExplanation { - if meta != nil && meta.Model != "" { - return SettingExplanation{Value: meta.Model, Source: SourceRoomOverride} - } - if loginMeta.Defaults != nil && loginMeta.Defaults.Model != "" { - return SettingExplanation{Value: loginMeta.Defaults.Model, Source: SourceUserDefault} - } - return SettingExplanation{Value: oc.defaultModelForProvider(), Source: SourceProviderConfig} -} - -func (oc *AIClient) getPromptWithSource(meta *PortalMetadata, loginMeta *UserLoginMetadata) SettingExplanation { - if meta != nil && meta.SystemPrompt != "" { - return SettingExplanation{Value: meta.SystemPrompt, Source: SourceRoomOverride} - } - if loginMeta.Defaults != nil && loginMeta.Defaults.SystemPrompt != "" { - return SettingExplanation{Value: loginMeta.Defaults.SystemPrompt, Source: SourceUserDefault} - } - if oc.connector.Config.DefaultSystemPrompt != "" { - return SettingExplanation{Value: oc.connector.Config.DefaultSystemPrompt, Source: SourceProviderConfig} - } - return SettingExplanation{Value: "", Source: SourceGlobalDefault} -} - -func (oc *AIClient) getTempWithSource(meta *PortalMetadata, loginMeta *UserLoginMetadata) SettingExplanation { - if meta != nil && meta.Temperature > 0 { - return SettingExplanation{Value: meta.Temperature, Source: SourceRoomOverride} - } - if loginMeta.Defaults != nil && loginMeta.Defaults.Temperature != nil { - return SettingExplanation{Value: *loginMeta.Defaults.Temperature, Source: SourceUserDefault} - } - return SettingExplanation{Value: nil, Source: SourceGlobalDefault, Reason: "provider/model default (unset)"} -} - -func (oc *AIClient) getReasoningWithSource(meta *PortalMetadata, loginMeta *UserLoginMetadata) SettingExplanation { - // Check model support first - if meta != nil && !meta.Capabilities.SupportsReasoning { - return SettingExplanation{Value: nil, Source: SourceModelLimit, Reason: "Model does not support reasoning"} - } - if meta != nil && meta.ReasoningEffort != "" { - return SettingExplanation{Value: meta.ReasoningEffort, Source: SourceRoomOverride} - } - if loginMeta.Defaults != nil && loginMeta.Defaults.ReasoningEffort != "" { - return SettingExplanation{Value: loginMeta.Defaults.ReasoningEffort, Source: SourceUserDefault} - } - if meta != nil && meta.Capabilities.SupportsReasoning { - return SettingExplanation{Value: defaultReasoningEffort, Source: SourceGlobalDefault} - } - return SettingExplanation{Value: "", Source: SourceGlobalDefault} -} - -// broadcastCapabilities sends bridge-controlled capabilities to Matrix room state -// This event is protected by power levels (100) so only the bridge bot can modify -func (oc *AIClient) broadcastCapabilities(ctx context.Context, portal *bridgev2.Portal) error { - if portal.MXID == "" { - return errors.New("portal has no Matrix room ID") - } - - meta := portalMeta(portal) - loginMeta := loginMetadata(oc.UserLogin) - - // Refresh stored model capabilities (room capabilities may add image-understanding union separately) - modelCaps := oc.getModelCapabilitiesForMeta(meta) - if meta.Capabilities != modelCaps { - meta.Capabilities = modelCaps - if err := portal.Save(ctx); err != nil { - oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to save portal after capability refresh") - } - } - - roomCaps := oc.getRoomCapabilities(ctx, meta) - - // Build reasoning effort options if model supports reasoning - var reasoningEfforts []ReasoningEffortOption - if roomCaps.SupportsReasoning { - reasoningEfforts = []ReasoningEffortOption{ - {Value: "low", Label: "Low"}, - {Value: "medium", Label: "Medium"}, - {Value: "high", Label: "High"}, - } - } - - content := &RoomCapabilitiesEventContent{ - Capabilities: &roomCaps, - AvailableTools: oc.buildAvailableTools(meta), - ReasoningEffortOptions: reasoningEfforts, - Provider: loginMeta.Provider, - EffectiveSettings: oc.buildEffectiveSettings(meta), - } - - bot := oc.UserLogin.Bridge.Bot - _, err := bot.SendState(ctx, portal.MXID, RoomCapabilitiesEventType, "", &event.Content{ - Parsed: content, - }, time.Time{}) - - if err != nil { - oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to broadcast room capabilities") - return err - } - - // Also update standard room features for clients portal.UpdateCapabilities(ctx, oc.UserLogin, true) - - oc.loggerForContext(ctx).Debug().Str("model", meta.Model).Msg("Broadcasted room capabilities") - return nil -} - -// broadcastSettings sends user-editable settings to Matrix room state -// This event uses normal power levels (0) so users can modify -func (oc *AIClient) broadcastSettings(ctx context.Context, portal *bridgev2.Portal) error { - if portal.MXID == "" { - return errors.New("portal has no Matrix room ID") - } - - meta := portalMeta(portal) - - content := &RoomSettingsEventContent{ - Model: meta.Model, - SystemPrompt: meta.SystemPrompt, - Temperature: &meta.Temperature, - MaxContextMessages: meta.MaxContextMessages, - MaxCompletionTokens: meta.MaxCompletionTokens, - ReasoningEffort: meta.ReasoningEffort, - AgentID: meta.AgentID, - } - - bot := oc.UserLogin.Bridge.Bot - _, err := bot.SendState(ctx, portal.MXID, RoomSettingsEventType, "", &event.Content{ - Parsed: content, - }, time.Time{}) - - if err != nil { - oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to broadcast room settings") - return err - } - - meta.LastRoomStateSync = time.Now().Unix() - if err := portal.Save(ctx); err != nil { - oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to save portal after state broadcast") - } - - oc.loggerForContext(ctx).Debug().Str("model", meta.Model).Msg("Broadcasted room settings") + oc.BroadcastCommandDescriptions(ctx, portal) return nil } @@ -1901,10 +1176,9 @@ func (oc *AIClient) ensureDefaultChat(ctx context.Context) error { } portal, chatInfo, err := oc.initPortalForChat(ctx, PortalInitOpts{ - ModelID: modelID, - Title: "New AI Chat", - SystemPrompt: beeperAgent.SystemPrompt, - PortalKey: &defaultPortalKey, + ModelID: modelID, + Title: "New AI Chat", + PortalKey: &defaultPortalKey, }) if err != nil { existingPortal, existingErr := oc.UserLogin.Bridge.GetExistingPortalByKey(ctx, defaultPortalKey) @@ -1934,14 +1208,11 @@ func (oc *AIClient) ensureDefaultChat(ctx context.Context) error { // Set agent-specific metadata pm := portalMeta(portal) - pm.AgentID = beeperAgent.ID - if beeperAgent.SystemPrompt != "" { - pm.SystemPrompt = beeperAgent.SystemPrompt - } // Update the OtherUserID to be the agent ghost agentGhostID := agentUserID(beeperAgent.ID) portal.OtherUserID = agentGhostID + pm.ResolvedTarget = resolveTargetFromGhostID(agentGhostID) if err := portal.Save(ctx); err != nil { oc.loggerForContext(ctx).Err(err).Msg("Failed to save portal with agent config") diff --git a/pkg/connector/chat_fork_test.go b/pkg/connector/chat_fork_test.go index 8320f7fb..cf4e3c0e 100644 --- a/pkg/connector/chat_fork_test.go +++ b/pkg/connector/chat_fork_test.go @@ -4,19 +4,11 @@ import "testing" func TestCloneForkPortalMetadata_PreservesSimpleMode(t *testing.T) { src := &PortalMetadata{ - Model: "openai/gpt-5", - SystemPrompt: "You are helpful.", - Temperature: 0.3, - MaxContextMessages: 42, - MaxCompletionTokens: 2048, - ReasoningEffort: "medium", - Capabilities: ModelCapabilities{ - SupportsToolCalling: true, + ResolvedTarget: &ResolvedTarget{ + Kind: ResolvedTargetModel, + GhostID: modelUserID("openai/gpt-5"), + ModelID: "openai/gpt-5", }, - AgentID: "beeper", - AgentPrompt: "agent prompt", - IsSimpleMode: true, - GroupActivation: "always", // Not copied in fork metadata. } got := cloneForkPortalMetadata(src, "chat-99", "Forked Chat") @@ -29,10 +21,7 @@ func TestCloneForkPortalMetadata_PreservesSimpleMode(t *testing.T) { if got.Title != "Forked Chat" { t.Fatalf("expected title Forked Chat, got %q", got.Title) } - if !got.IsSimpleMode { - t.Fatalf("expected IsSimpleMode=true on forked metadata") - } - if got.GroupActivation != "" { - t.Fatalf("expected GroupActivation to remain unset in fork metadata copy, got %q", got.GroupActivation) + if !isSimpleMode(got) { + t.Fatalf("expected forked metadata to keep resolved simple-mode target") } } diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 950780a9..50b059a2 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -1089,7 +1089,7 @@ func (oc *AIClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*br store := NewAgentStoreAdapter(oc) agent, err := store.GetAgentByID(ctx, agentID) displayName := "Unknown Agent" - modelID := oc.agentModelOverride(agentID) + modelID := "" if err == nil && agent != nil { displayName = oc.resolveAgentDisplayName(ctx, agent) if displayName == "" { @@ -1148,8 +1148,7 @@ func updateGhostLastSync(_ context.Context, ghost *bridgev2.Ghost) bool { func (oc *AIClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { meta := portalMeta(portal) - // Always recompute effective room capabilities to ensure they're up-to-date - // (includes image-understanding union for agent rooms) + // Always recompute effective room capabilities from the resolved room target. modelCaps := oc.getRoomCapabilities(ctx, meta) allowTextFiles := oc.canUseMediaUnderstanding(meta) supportsPDF := modelCaps.SupportsPDF || oc.isOpenRouterProvider() @@ -1245,61 +1244,29 @@ func (oc *AIClient) supportsMessageActionsFeature(meta *PortalMetadata) bool { } // effectiveModel returns the full prefixed model ID (e.g., "openai/gpt-5.2") -// Priority: Room → Agent → User → Provider → Global -// Exception: Boss agent rooms always use the Boss agent's model (no overrides) +// based only on the resolved room target. func (oc *AIClient) effectiveModel(meta *PortalMetadata) string { - // Check if an agent is assigned - if meta != nil { - agentID := resolveAgentID(meta) - if agentID != "" { - // Load the agent to get its model + if meta != nil && strings.TrimSpace(meta.RuntimeModelOverride) != "" { + return ResolveAlias(meta.RuntimeModelOverride) + } + if meta != nil && meta.ResolvedTarget != nil { + switch meta.ResolvedTarget.Kind { + case ResolvedTargetModel: + return ResolveAlias(meta.ResolvedTarget.ModelID) + case ResolvedTargetAgent: store := NewAgentStoreAdapter(oc) - agent, err := store.GetAgentByID(context.Background(), agentID) - if err == nil && agent != nil { - // Boss agent rooms always use the Boss model - no overrides allowed - if agents.IsBossAgent(agentID) && agent.Model.Primary != "" { - return ResolveAlias(agent.Model.Primary) - } - // For other agents, room override takes priority, then agent model - if meta.Model != "" { - return ResolveAlias(meta.Model) - } - if override := oc.agentModelOverride(agentID); override != "" { - return ResolveAlias(override) - } - if agent.Model.Primary != "" { - return ResolveAlias(agent.Model.Primary) - } + agent, err := store.GetAgentByID(context.Background(), meta.ResolvedTarget.AgentID) + if err == nil && agent != nil && agent.Model.Primary != "" { + return ResolveAlias(agent.Model.Primary) } + return "" + default: + return "" } } - - // Room-level model override (for rooms without an agent) - if meta != nil && meta.Model != "" { - return ResolveAlias(meta.Model) - } - - // User-level default - loginMeta := loginMetadata(oc.UserLogin) - if loginMeta.Defaults != nil && loginMeta.Defaults.Model != "" { - return ResolveAlias(loginMeta.Defaults.Model) - } - - // Provider default from config return oc.defaultModelForProvider() } -func (oc *AIClient) agentModelOverride(agentID string) string { - if agentID == "" || oc.UserLogin == nil { - return "" - } - loginMeta := loginMetadata(oc.UserLogin) - if loginMeta == nil || loginMeta.AgentModelOverrides == nil { - return "" - } - return strings.TrimSpace(loginMeta.AgentModelOverrides[agentID]) -} - // effectiveModelForAPI returns the actual model name to send to the API // For OpenRouter/Beeper, returns the full model ID (e.g., "openai/gpt-5.2") // For direct providers, strips the prefix (e.g., "openai/gpt-5.2" → "gpt-5.2") @@ -1331,7 +1298,13 @@ func (oc *AIClient) modelIDForAPI(modelID string) string { // defaultModelForProvider returns the configured default model for this login's provider func (oc *AIClient) defaultModelForProvider() string { + if oc == nil || oc.connector == nil || oc.UserLogin == nil { + return DefaultModelOpenRouter + } loginMeta := loginMetadata(oc.UserLogin) + if loginMeta == nil { + return DefaultModelOpenRouter + } providers := oc.connector.Config.Providers switch loginMeta.Provider { @@ -1355,29 +1328,50 @@ func (oc *AIClient) defaultModelForProvider() string { } } -// effectivePrompt returns the system prompt to use -// Priority: Room ? User ? Bridge Config +// effectivePrompt returns the base system prompt to use for non-agent rooms. func (oc *AIClient) effectivePrompt(meta *PortalMetadata) string { - // Room-level override takes priority - var base string - if meta != nil && meta.SystemPrompt != "" { - base = meta.SystemPrompt - } else { - loginMeta := loginMetadata(oc.UserLogin) - if loginMeta.Defaults != nil && loginMeta.Defaults.SystemPrompt != "" { - base = loginMeta.Defaults.SystemPrompt - } else { - base = oc.connector.Config.DefaultSystemPrompt - } - } - gravatarContext := oc.gravatarContext() - if gravatarContext == "" { + base := oc.connector.Config.DefaultSystemPrompt + supplement := oc.profilePromptSupplement() + if supplement == "" { return base } if strings.TrimSpace(base) == "" { - return gravatarContext + return supplement } - return fmt.Sprintf("%s\n\n%s", base, gravatarContext) + return fmt.Sprintf("%s\n\n%s", base, supplement) +} + +func (oc *AIClient) profilePromptSupplement() string { + if oc == nil || oc.UserLogin == nil { + return strings.TrimSpace(oc.gravatarContext()) + } + loginMeta := loginMetadata(oc.UserLogin) + if loginMeta == nil { + return strings.TrimSpace(oc.gravatarContext()) + } + + var lines []string + if profile := loginMeta.Profile; profile != nil { + if v := strings.TrimSpace(profile.Name); v != "" { + lines = append(lines, "Name: "+v) + } + if v := strings.TrimSpace(profile.Occupation); v != "" { + lines = append(lines, "Occupation: "+v) + } + if v := strings.TrimSpace(profile.AboutUser); v != "" { + lines = append(lines, "About the user: "+v) + } + if v := strings.TrimSpace(profile.CustomInstructions); v != "" { + lines = append(lines, "Custom instructions: "+v) + } + } + if gravatar := strings.TrimSpace(oc.gravatarContext()); gravatar != "" { + lines = append(lines, gravatar) + } + if len(lines) == 0 { + return "" + } + return "User profile:\n- " + strings.Join(lines, "\n- ") } // getLinkPreviewConfig returns the link preview configuration, with defaults filled in. @@ -1416,9 +1410,7 @@ func getLinkPreviewConfig(connectorConfig *Config) LinkPreviewConfig { return config } -// effectiveAgentPrompt returns the system prompt for the agent assigned to the room. -// This uses BuildSystemPrompt to generate a full prompt with room context when an agent is configured. -// Returns empty string if no agent is configured. +// effectiveAgentPrompt returns the resolved agent prompt for the current room target. func (oc *AIClient) effectiveAgentPrompt(ctx context.Context, portal *bridgev2.Portal, meta *PortalMetadata) string { if meta == nil { return "" @@ -1444,9 +1436,6 @@ func (oc *AIClient) effectiveAgentPrompt(ctx context.Context, portal *bridgev2.P if strings.TrimSpace(agent.SystemPrompt) != "" { extraParts = append(extraParts, strings.TrimSpace(agent.SystemPrompt)) } - if meta != nil && strings.TrimSpace(meta.SystemPrompt) != "" { - extraParts = append(extraParts, strings.TrimSpace(meta.SystemPrompt)) - } extraSystemPrompt := strings.Join(extraParts, "\n\n") // Build params for prompt generation (OpenClaw template) @@ -1464,7 +1453,7 @@ func (oc *AIClient) effectiveAgentPrompt(ctx context.Context, portal *bridgev2.P } } } - params.UserIdentitySupplement = oc.gravatarContext() + params.UserIdentitySupplement = oc.profilePromptSupplement() params.ContextFiles = oc.buildBootstrapContextFiles(ctx, agentID, meta) if meta != nil && strings.TrimSpace(meta.SubagentParentRoomID) != "" { params.PromptMode = agents.PromptModeMinimal @@ -1487,21 +1476,23 @@ func (oc *AIClient) effectiveAgentPrompt(ctx context.Context, portal *bridgev2.P params.ToolSummaries = toolSummaries } - // Build capabilities list from metadata + modelCaps := oc.getModelCapabilitiesForMeta(meta) + + // Build capabilities list from model resolution var caps []string - if meta.Capabilities.SupportsVision { + if modelCaps.SupportsVision { caps = append(caps, "vision") } - if meta.Capabilities.SupportsToolCalling { + if modelCaps.SupportsToolCalling { caps = append(caps, "tools") } - if meta.Capabilities.SupportsReasoning { + if modelCaps.SupportsReasoning { caps = append(caps, "reasoning") } - if meta.Capabilities.SupportsAudio { + if modelCaps.SupportsAudio { caps = append(caps, "audio") } - if meta.Capabilities.SupportsVideo { + if modelCaps.SupportsVideo { caps = append(caps, "video") } @@ -1528,7 +1519,7 @@ func (oc *AIClient) effectiveAgentPrompt(ctx context.Context, portal *bridgev2.P } // Reasoning hints and level - params.ReasoningTagHint = meta.Capabilities.SupportsReasoning && meta.EmitThinking + params.ReasoningTagHint = false params.ReasoningLevel = resolvePromptReasoningLevel(meta) // Default thinking level (OpenClaw-style): low for reasoning-capable models, otherwise off. @@ -1537,31 +1528,13 @@ func (oc *AIClient) effectiveAgentPrompt(ctx context.Context, portal *bridgev2.P return agents.BuildSystemPrompt(params) } -// effectiveTemperature returns the temperature to use. -// Priority: Room → User → Default (unset / provider default). func (oc *AIClient) effectiveTemperature(meta *PortalMetadata) float64 { - if meta != nil && meta.Temperature > 0 { - return meta.Temperature - } - var loginMeta *UserLoginMetadata - if oc != nil && oc.UserLogin != nil { - loginMeta = loginMetadata(oc.UserLogin) - } - if loginMeta != nil && loginMeta.Defaults != nil && loginMeta.Defaults.Temperature != nil { - return *loginMeta.Defaults.Temperature - } return defaultTemperature } // defaultThinkLevel resolves the default think level in an OpenClaw-compatible way: // low for reasoning-capable models, off otherwise. func (oc *AIClient) defaultThinkLevel(meta *PortalMetadata) string { - if meta != nil { - level := strings.ToLower(strings.TrimSpace(meta.ThinkingLevel)) - if level != "" { - return level - } - } switch effort := strings.ToLower(strings.TrimSpace(oc.effectiveReasoningEffort(meta))); effort { case "off", "none": return "off" @@ -1571,39 +1544,33 @@ func (oc *AIClient) defaultThinkLevel(meta *PortalMetadata) string { } return effort } - if meta != nil && meta.Capabilities.SupportsReasoning { + if caps := oc.getModelCapabilitiesForMeta(meta); caps.SupportsReasoning { return "low" } + if modelID := strings.TrimSpace(oc.effectiveModel(meta)); modelID != "" { + if info := oc.findModelInfo(modelID); info != nil && info.SupportsReasoning { + return "low" + } + } return "off" } -// effectiveReasoningEffort returns the reasoning effort to use -// Priority: Room ? User ? "" (none) func (oc *AIClient) effectiveReasoningEffort(meta *PortalMetadata) string { - if meta != nil && !meta.Capabilities.SupportsReasoning { + if !oc.getModelCapabilitiesForMeta(meta).SupportsReasoning { return "" } - if meta != nil && meta.ReasoningEffort != "" { - return meta.ReasoningEffort - } - var loginMeta *UserLoginMetadata - if oc != nil && oc.UserLogin != nil { - loginMeta = loginMetadata(oc.UserLogin) - } - if loginMeta != nil && loginMeta.Defaults != nil && loginMeta.Defaults.ReasoningEffort != "" { - return loginMeta.Defaults.ReasoningEffort - } - if meta != nil && meta.Capabilities.SupportsReasoning { - return defaultReasoningEffort + if meta != nil { + switch effort := strings.ToLower(strings.TrimSpace(meta.RuntimeReasoning)); effort { + case "low", "medium", "high": + return effort + case "off", "none": + return "" + } } - return "" + return defaultReasoningEffort } func (oc *AIClient) historyLimit(ctx context.Context, portal *bridgev2.Portal, meta *PortalMetadata) int { - if meta != nil && meta.MaxContextMessages > 0 { - return meta.MaxContextMessages - } - isGroup := portal != nil && oc.isGroupChat(ctx, portal) if oc != nil && oc.connector != nil && oc.connector.Config.Messages != nil { if isGroup { @@ -1624,18 +1591,11 @@ func (oc *AIClient) historyLimit(ctx context.Context, portal *bridgev2.Portal, m func (oc *AIClient) effectiveMaxTokens(meta *PortalMetadata) int { var maxTokens int - // 1. Per-room override (highest priority) - if meta != nil && meta.MaxCompletionTokens > 0 { - maxTokens = meta.MaxCompletionTokens + modelID := oc.effectiveModel(meta) + if info := oc.findModelInfo(modelID); info != nil && info.MaxOutputTokens > 0 { + maxTokens = info.MaxOutputTokens } else { - // 2. Model catalog MaxOutputTokens - modelID := oc.effectiveModel(meta) - if info := oc.findModelInfo(modelID); info != nil && info.MaxOutputTokens > 0 { - maxTokens = info.MaxOutputTokens - } else { - // 3. Hardcoded fallback - maxTokens = defaultMaxTokens - } + maxTokens = defaultMaxTokens } // Cap at context window to prevent impossible requests. // When max output tokens >= context window (common for thinking/reasoning diff --git a/pkg/connector/client_capabilities_test.go b/pkg/connector/client_capabilities_test.go index 9f769197..db40ff0e 100644 --- a/pkg/connector/client_capabilities_test.go +++ b/pkg/connector/client_capabilities_test.go @@ -15,10 +15,8 @@ func TestGetCapabilities_SimpleModeDisablesReplyEditReaction(t *testing.T) { oc := &AIClient{connector: &OpenAIConnector{}} portal := &bridgev2.Portal{ Portal: &database.Portal{ - Metadata: &PortalMetadata{ - IsSimpleMode: true, - Capabilities: ModelCapabilities{SupportsToolCalling: true}, - }, + OtherUserID: modelUserID("openai/gpt-5"), + Metadata: simpleModeTestMeta("openai/gpt-5"), }, } @@ -46,9 +44,8 @@ func TestGetCapabilities_NonSimpleEnablesReplyEditReaction(t *testing.T) { oc := &AIClient{connector: &OpenAIConnector{}} portal := &bridgev2.Portal{ Portal: &database.Portal{ - Metadata: &PortalMetadata{ - Capabilities: ModelCapabilities{SupportsToolCalling: true}, - }, + OtherUserID: agentUserID("beeper"), + Metadata: agentModeTestMeta("beeper"), }, } @@ -68,8 +65,9 @@ func TestGetCapabilities_MessageToolDisabledDisablesReplyEditReaction(t *testing oc := &AIClient{connector: &OpenAIConnector{}} portal := &bridgev2.Portal{ Portal: &database.Portal{ + OtherUserID: agentUserID("beeper"), Metadata: &PortalMetadata{ - Capabilities: ModelCapabilities{SupportsToolCalling: true}, + ResolvedTarget: agentModeTestMeta("beeper").ResolvedTarget, DisabledTools: []string{ ToolNameMessage, }, diff --git a/pkg/connector/command_aliases.go b/pkg/connector/command_aliases.go index ecccdbe5..5b122c33 100644 --- a/pkg/connector/command_aliases.go +++ b/pkg/connector/command_aliases.go @@ -1,36 +1,5 @@ package connector -var thinkLevelAliases = map[string]string{ - "off": "off", - "on": "low", - "minimal": "minimal", - "low": "low", - "medium": "medium", - "high": "high", - "xhigh": "xhigh", -} - -var verboseLevelAliases = map[string]string{ - "off": "off", - "on": "on", - "full": "full", -} - -var reasoningLevelAliases = map[string]string{ - "off": "off", - "on": "on", - "low": "low", - "medium": "medium", - "high": "high", - "xhigh": "xhigh", -} - -var sendPolicyAliases = map[string]string{ - "on": "allow", - "off": "deny", - "inherit": "inherit", -} - var groupActivationAliases = map[string]string{ "mention": "mention", "always": "always", diff --git a/pkg/connector/command_registry.go b/pkg/connector/command_registry.go index 049dc1c6..55e3432f 100644 --- a/pkg/connector/command_registry.go +++ b/pkg/connector/command_registry.go @@ -20,6 +20,17 @@ import ( var aiCommandRegistry = commandregistry.NewRegistry() var moduleCommandRegisterMu sync.Mutex var moduleCommandsRegistered = map[string]struct{}{} +var allowedUserCommandNames = map[string]struct{}{ + "new": {}, + "reset": {}, + "status": {}, + "stop": {}, +} + +func isUserFacingCommand(name string) bool { + _, ok := allowedUserCommandNames[strings.TrimSpace(strings.ToLower(name))] + return ok +} func registerAICommand(def commandregistry.Definition) *commands.FullHandler { return aiCommandRegistry.Register(def) @@ -101,6 +112,9 @@ func registerCommandsWithOwnerGuard(proc *commands.Processor, cfg *Config, log * if handler == nil || handler.Func == nil { continue } + if !isUserFacingCommand(handler.Name) { + continue + } original := handler.Func handler.Func = func(ce *commands.Event) { senderID := "" @@ -151,6 +165,9 @@ func (oc *AIClient) BroadcastCommandDescriptions(ctx context.Context, portal *br if handler == nil || handler.Name == "" { continue } + if !isUserFacingCommand(handler.Name) { + continue + } stateKey := handler.Name content := buildCommandDescriptionContent(handler) _, err := bot.SendState(ctx, portal.MXID, event.StateMSC4391BotCommand, stateKey, &event.Content{ diff --git a/pkg/connector/commands.go b/pkg/connector/commands.go index db44f646..ffdc2a6b 100644 --- a/pkg/connector/commands.go +++ b/pkg/connector/commands.go @@ -3,32 +3,20 @@ package connector import ( "context" "errors" - "fmt" - "strconv" - "strings" - "time" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/commands" "maunium.net/go/mautrix/bridgev2/networkid" - "github.com/beeper/ai-bridge/pkg/agents" - "github.com/beeper/ai-bridge/pkg/agents/toolpolicy" "github.com/beeper/ai-bridge/pkg/connector/commandregistry" ) -// HelpSectionAI is the help section for AI-related commands +// HelpSectionAI is the help section for AI-related commands. var HelpSectionAI = commands.HelpSection{ Name: "AI Chat", Order: 30, } -var reservedAgentIDs = map[string]struct{}{ - "none": {}, - "clear": {}, - "boss": {}, -} - func resolveLoginForCommand( ctx context.Context, portal *bridgev2.Portal, @@ -95,1319 +83,20 @@ func isValidAgentID(agentID string) bool { return true } -func splitQuotedArgs(input string) ([]string, error) { - var args []string - var current strings.Builder - var quote rune - escaped := false - - flush := func() { - if current.Len() > 0 { - args = append(args, current.String()) - current.Reset() - } - } - - for _, r := range input { - if escaped { - current.WriteRune(r) - escaped = false - continue - } - - if r == '\\' && quote != '\'' { - escaped = true - continue - } - - if quote != 0 { - if r == quote { - quote = 0 - continue - } - current.WriteRune(r) - continue - } - - switch r { - case '\'', '"': - quote = r - case ' ', '\t', '\n', '\r': - flush() - default: - current.WriteRune(r) - } - } - - if quote != 0 { - return nil, errors.New("unterminated quote") - } - if escaped { - current.WriteRune('\\') - } - flush() - return args, nil -} - -// CommandModel handles the !ai model command -var _ = registerAICommand(commandregistry.Definition{ - Name: "model", - Description: "Get or set the AI model for this chat", - Args: "[_model name_]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnModel, -}) - -func fnModel(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - if len(ce.Args) == 0 { - ce.Reply("Current model: %s", client.effectiveModel(meta)) - return - } - - if rejectBossOverrides(ce, meta, "Can't change the model in a room managed by the Boss agent.") { - return - } - - modelID := strings.TrimSpace(ce.Args[0]) - resolvedModel, valid, err := client.resolveModelID(ce.Ctx, modelID) - if err != nil || !valid || resolvedModel == "" { - ce.Reply("That model isn't available: %s", modelID) - return - } - - agentID := resolveAgentID(meta) - if agentID != "" { - ce.Reply("Can't set the room model while an agent is assigned. Edit the agent instead.") - return - } - - if err := client.validateDMModelSwitch(ce.Portal, meta, resolvedModel); err != nil { - ce.Reply("%s", dmModelSwitchGuidance(resolvedModel)) - return - } - - meta.Model = resolvedModel - meta.Capabilities = getModelCapabilities(resolvedModel, client.findModelInfo(resolvedModel)) - client.savePortalQuiet(ce.Ctx, ce.Portal, "model change") - client.ensureGhostDisplayName(ce.Ctx, resolvedModel) - ce.Reply("Model set to %s.", resolvedModel) -} - -// CommandTemp handles the !ai temp command -var _ = registerAICommand(commandregistry.Definition{ - Name: "temp", - Description: "Get or set the temperature (0-2)", - Args: "[_value_]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnTemp, -}) - -func fnTemp(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - if len(ce.Args) == 0 { - if temp := client.effectiveTemperature(meta); temp > 0 { - ce.Reply("Current temperature: %.2f", temp) - } else { - ce.Reply("Current temperature: provider default (unset)") - } - return - } - - if rejectBossOverrides(ce, meta, "Can't change the temperature in a room managed by the Boss agent.") { - return - } - - var temp float64 - if _, err := fmt.Sscanf(ce.Args[0], "%f", &temp); err != nil || temp < 0 || temp > 2 { - ce.Reply("Invalid temperature. Must be between 0 and 2.") - return - } - - meta.Temperature = temp - client.savePortalQuiet(ce.Ctx, ce.Portal, "temperature change") - if temp > 0 { - ce.Reply("Temperature set to %.2f.", temp) - } else { - ce.Reply("Temperature reset to provider default (unset).") - } -} - -// CommandSystemPrompt handles the !ai system-prompt command -var _ = registerAICommand(commandregistry.Definition{ - Name: "system-prompt", - Description: "Get or set the system prompt (shows full constructed prompt)", - Args: "[_text_]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnSystemPrompt, -}) - -func fnSystemPrompt(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - if len(ce.Args) == 0 { - // Show full constructed prompt (agent + room levels merged) - fullPrompt := client.effectiveAgentPrompt(ce.Ctx, ce.Portal, meta) - if fullPrompt == "" { - fullPrompt = client.effectivePrompt(meta) - } - if fullPrompt == "" { - fullPrompt = "(none)" - } - // Truncate for display - totalLen := len(fullPrompt) - if totalLen > 500 { - fullPrompt = fullPrompt[:500] + "...\n\n(truncated, full prompt is " + strconv.Itoa(totalLen) + " chars)" - } - ce.Reply("Current system prompt:\n%s", fullPrompt) - return - } - - if rejectBossOverrides(ce, meta, "Can't change the system prompt in a room managed by the Boss agent.") { - return - } - - meta.SystemPrompt = ce.RawArgs - client.savePortalQuiet(ce.Ctx, ce.Portal, "system prompt change") - ce.Reply("System prompt updated.") -} - -// CommandContext handles the !ai context command -var _ = registerAICommand(commandregistry.Definition{ - Name: "context", - Description: "Get or set context message limit (1-100)", - Args: "[_count_]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnContext, -}) - -func fnContext(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - if len(ce.Args) == 0 { - ce.Reply("%s", client.buildContextStatus(ce.Ctx, ce.Portal, meta)) - return - } - - var limit int - if _, err := fmt.Sscanf(ce.Args[0], "%d", &limit); err != nil || limit < 1 || limit > 100 { - ce.Reply("Invalid context limit. Must be between 1 and 100.") - return - } - - meta.MaxContextMessages = limit - client.savePortalQuiet(ce.Ctx, ce.Portal, "context change") - ce.Reply("Context limit set to %d messages.", limit) -} - -// CommandTokens handles the !ai tokens command -var _ = registerAICommand(commandregistry.Definition{ - Name: "tokens", - Description: "Get or set max completion tokens (1-16384)", - Args: "[_count_]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnTokens, -}) - -func fnTokens(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - if len(ce.Args) == 0 { - ce.Reply("Current max tokens: %d", client.effectiveMaxTokens(meta)) - return - } - - var tokens int - if _, err := fmt.Sscanf(ce.Args[0], "%d", &tokens); err != nil || tokens < 1 || tokens > 16384 { - ce.Reply("Invalid max tokens. Must be between 1 and 16384.") - return - } - - meta.MaxCompletionTokens = tokens - client.savePortalQuiet(ce.Ctx, ce.Portal, "tokens change") - ce.Reply("Max tokens set to %d.", tokens) -} - -// CommandConfig handles the !ai config command var _ = registerAICommand(commandregistry.Definition{ - Name: "config", - Description: "Show current chat configuration", + Name: "new", + Description: "Create a new chat of the same type (agent or model)", + Args: "[agent ]", Section: HelpSectionAI, RequiresPortal: true, RequiresLogin: true, - Handler: fnConfig, -}) - -// CommandDesktopAPI handles the !ai desktop-api command -var _ = registerAICommand(commandregistry.Definition{ - Name: "desktop-api", - Description: "Manage Beeper Desktop API instances", - Args: " [args]", - Section: HelpSectionAI, - RequiresPortal: false, - RequiresLogin: true, - Handler: fnDesktopAPI, -}) - -const desktopAPIManageUsage = "`!ai desktop-api list` | `!ai desktop-api add [baseURL]` | `!ai desktop-api add [baseURL]` | `!ai desktop-api remove [name]`." - -var _ = registerAICommand(commandregistry.Definition{ - Name: "commands", - Description: "Show AI command groups and recommended command forms", - Section: HelpSectionAI, - RequiresPortal: false, - RequiresLogin: true, - Handler: fnCommands, + Handler: fnNew, }) -func fnCommands(ce *commands.Event) { - ce.Reply( - "AI command groups (preferred forms):\n\n" + - "Core chat:\n" + - "- `!ai status`\n" + - "- `!ai config`\n" + - "- `!ai model [model]`\n" + - "- `!ai temp [0-2]`\n" + - "- `!ai system-prompt [text]`\n" + - "- `!ai context [1-100]`\n" + - "- `!ai tokens [1-16384]`\n" + - "- `!ai tools [on|off] [tool]`\n" + - "- `!ai typing [never|instant|thinking|message|off|reset|interval ]`\n" + - "- `!ai debounce [ms|off|default]`\n\n" + - "Controls:\n" + - "- `!ai think off|minimal|low|medium|high|xhigh`\n" + - "- `!ai verbose on|off|full`\n" + - "- `!ai reasoning off|on|low|medium|high|xhigh`\n" + - "- `!ai elevated off|on|ask|full`\n" + - "- `!ai activation mention|always` (group chats)\n" + - "- `!ai send on|off|inherit`\n" + - "- `!ai queue status|reset| [debounce:] [cap:] [drop:]`\n\n" + - "Session actions:\n" + - "- `!ai approve [reason]`\n" + - "- `!ai new` — New chat of the same type\n" + - "- `!ai reset` — Reset this session/thread\n" + - "- `!ai stop` — Abort the current run\n" + - "- `!ai fork`\n" + - "- `!ai regenerate`\n" + - "- `!ai title [text]`\n" + - "- `!ai timezone [IANA_TZ]`\n\n" + - "Simple Mode:\n" + - "- `!ai simple new [model]` — Create a new AI chat\n" + - "- `!ai simple list` — List available models\n\n" + - "Agents:\n" + - "- `!ai agent [id|none]`\n" + - "- `!ai agents`\n" + - "- `!ai create-agent [model] [system prompt...]`\n" + - "- `!ai delete-agent `\n" + - "- `!ai manage`\n\n" + - "Integrations:\n" + - "- MCP: `!ai mcp ...`\n" + - "- Desktop API: `!ai desktop-api ...`\n" + - "- Gravatar: `!ai gravatar ...`\n\n" + - "Use `!help` for the full command list from the command processor.", - ) -} - -func fnConfig(ce *commands.Event) { +func fnNew(ce *commands.Event) { client, meta, ok := requireClientMeta(ce) if !ok { return } - - roomCaps := client.getRoomCapabilities(ce.Ctx, meta) - tempLabel := "provider default" - if temp := client.effectiveTemperature(meta); temp > 0 { - tempLabel = fmt.Sprintf("%.2f", temp) - } - config := fmt.Sprintf( - "Current configuration:\n• Model: %s\n• Temperature: %s\n• Context: %d messages\n• Max tokens: %d\n• Vision: %v", - client.effectiveModel(meta), tempLabel, client.historyLimit(ce.Ctx, ce.Portal, meta), - client.effectiveMaxTokens(meta), roomCaps.SupportsVision) - ce.Reply(config) -} - -func fnSetDesktopAPIToken(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - login := client.UserLogin - if login == nil { - ce.Reply("You're not signed in. Sign in and try again.") - return - } - meta := loginMetadata(login) - if meta == nil { - ce.Reply("Couldn't load your settings. Try again.") - return - } - - token := strings.TrimSpace(ce.Args[0]) - baseURL := "" - if len(ce.Args) > 1 { - baseURL = strings.TrimSpace(strings.Join(ce.Args[1:], " ")) - } - if token == "" { - ce.Reply("Usage: `!ai desktop-api add [baseURL]`.") - return - } - if meta.ServiceTokens == nil { - meta.ServiceTokens = &ServiceTokens{} - } - meta.ServiceTokens.DesktopAPI = token - if meta.ServiceTokens.DesktopAPIInstances == nil { - meta.ServiceTokens.DesktopAPIInstances = map[string]DesktopAPIInstance{} - } - defaultConfig := meta.ServiceTokens.DesktopAPIInstances[desktopDefaultInstance] - defaultConfig.Token = token - if baseURL != "" { - defaultConfig.BaseURL = baseURL - } - meta.ServiceTokens.DesktopAPIInstances[desktopDefaultInstance] = defaultConfig - if err := login.Save(ce.Ctx); err != nil { - ce.Reply("Couldn't save the Desktop API token: %s", err) - return - } - if baseURL != "" { - ce.Reply("Desktop API token saved (base URL %s)", baseURL) - return - } - ce.Reply("Desktop API token saved") -} - -func fnAddDesktopAPIInstance(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - login := client.UserLogin - if login == nil { - ce.Reply("You're not signed in. Sign in and try again.") - return - } - meta := loginMetadata(login) - if meta == nil { - ce.Reply("Couldn't load your settings. Try again.") - return - } - if len(ce.Args) < 2 { - ce.Reply("Usage: `!ai desktop-api add [baseURL]`.") - return - } - name := normalizeDesktopInstanceName(ce.Args[0]) - if name == "" { - ce.Reply("Instance name is required") - return - } - token := strings.TrimSpace(ce.Args[1]) - if token == "" { - ce.Reply("Token is required") - return - } - baseURL := "" - if len(ce.Args) > 2 { - baseURL = strings.TrimSpace(strings.Join(ce.Args[2:], " ")) - } - if meta.ServiceTokens == nil { - meta.ServiceTokens = &ServiceTokens{} - } - if meta.ServiceTokens.DesktopAPIInstances == nil { - meta.ServiceTokens.DesktopAPIInstances = map[string]DesktopAPIInstance{} - } - config := meta.ServiceTokens.DesktopAPIInstances[name] - config.Token = token - if baseURL != "" { - config.BaseURL = baseURL - } - meta.ServiceTokens.DesktopAPIInstances[name] = config - if name == desktopDefaultInstance { - meta.ServiceTokens.DesktopAPI = token - } - if err := login.Save(ce.Ctx); err != nil { - ce.Reply("Couldn't save the Desktop API instance: %s", err) - return - } - if baseURL != "" { - ce.Reply("Desktop API instance '%s' saved (base URL %s)", name, baseURL) - return - } - ce.Reply("Desktop API instance '%s' saved", name) -} - -func fnRemoveDesktopAPIInstance(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - login := client.UserLogin - if login == nil { - ce.Reply("You're not signed in. Sign in and try again.") - return - } - meta := loginMetadata(login) - if meta == nil { - ce.Reply("Couldn't load your settings. Try again.") - return - } - name := "" - if len(ce.Args) == 0 { - if meta.ServiceTokens == nil || len(meta.ServiceTokens.DesktopAPIInstances) == 0 { - ce.Reply("Desktop API instances: none configured") - return - } - if len(meta.ServiceTokens.DesktopAPIInstances) > 1 { - ce.Reply("Multiple Desktop API instances configured. Provide a name. Use `!ai desktop-api list`.") - return - } - for instanceName := range meta.ServiceTokens.DesktopAPIInstances { - name = instanceName - break - } - } else { - name = normalizeDesktopInstanceName(strings.Join(ce.Args, " ")) - if name == "" { - ce.Reply("Instance name is required") - return - } - } - if meta.ServiceTokens == nil || meta.ServiceTokens.DesktopAPIInstances == nil { - ce.Reply("Desktop API instance '%s' not found", name) - return - } - if _, ok := meta.ServiceTokens.DesktopAPIInstances[name]; !ok { - ce.Reply("Desktop API instance '%s' not found", name) - return - } - delete(meta.ServiceTokens.DesktopAPIInstances, name) - if name == desktopDefaultInstance { - meta.ServiceTokens.DesktopAPI = "" - } - if len(meta.ServiceTokens.DesktopAPIInstances) == 0 { - meta.ServiceTokens.DesktopAPIInstances = nil - } - if err := login.Save(ce.Ctx); err != nil { - ce.Reply("Couldn't remove the Desktop API instance: %s", err) - return - } - ce.Reply("Desktop API instance '%s' removed", name) -} - -func fnListDesktopAPIInstances(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - instances := client.desktopAPIInstances() - if len(instances) == 0 { - ce.Reply("Desktop API instances: none configured") - return - } - lines := make([]string, 0, len(instances)) - for _, name := range client.desktopAPIInstanceNames() { - config := instances[name] - status := "set" - if strings.TrimSpace(config.Token) == "" { - status = "missing token" - } - if strings.TrimSpace(config.BaseURL) != "" { - lines = append(lines, fmt.Sprintf("- %s: %s (base URL %s)", name, status, strings.TrimSpace(config.BaseURL))) - } else { - lines = append(lines, fmt.Sprintf("- %s: %s", name, status)) - } - } - ce.Reply("Desktop API instances:\n%s", strings.Join(lines, "\n")) -} - -func fnDesktopAPI(ce *commands.Event) { - if len(ce.Args) == 0 { - ce.Reply("Usage: %s", desktopAPIManageUsage) - return - } - - sub := strings.ToLower(strings.TrimSpace(ce.Args[0])) - switch sub { - case "list": - ce.Args = ce.Args[1:] - fnListDesktopAPIInstances(ce) - return - case "add": - parsedName, parsedToken, parsedBaseURL, parsedErr := parseDesktopAPIAddArgs(ce.Args[1:]) - if parsedErr != nil { - ce.Reply("Usage: %s", desktopAPIManageUsage) - return - } - if parsedName == "" || parsedName == desktopDefaultInstance { - nextArgs := []string{parsedToken} - if parsedBaseURL != "" { - nextArgs = append(nextArgs, parsedBaseURL) - } - ce.Args = nextArgs - fnSetDesktopAPIToken(ce) - return - } - nextArgs := []string{parsedName, parsedToken} - if parsedBaseURL != "" { - nextArgs = append(nextArgs, parsedBaseURL) - } - ce.Args = nextArgs - fnAddDesktopAPIInstance(ce) - return - case "remove": - ce.Args = ce.Args[1:] - fnRemoveDesktopAPIInstance(ce) - return - default: - ce.Reply("Usage: %s", desktopAPIManageUsage) - } -} - -func parseDesktopAPIAddArgs(args []string) (name, token, baseURL string, err error) { - if len(args) == 0 { - return "", "", "", errors.New("missing args") - } - - trimmed := make([]string, 0, len(args)) - for _, raw := range args { - part := strings.TrimSpace(raw) - if part != "" { - trimmed = append(trimmed, part) - } - } - if len(trimmed) == 0 { - return "", "", "", errors.New("missing args") - } - - if len(trimmed) == 1 { - return "", trimmed[0], "", nil - } - - if len(trimmed) == 2 { - if isLikelyHTTPURL(trimmed[1]) { - return "", trimmed[0], trimmed[1], nil - } - return normalizeDesktopInstanceName(trimmed[0]), trimmed[1], "", nil - } - - return normalizeDesktopInstanceName(trimmed[0]), trimmed[1], strings.TrimSpace(strings.Join(trimmed[2:], " ")), nil -} - -func isLikelyHTTPURL(raw string) bool { - value := strings.TrimSpace(strings.ToLower(raw)) - return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") -} - -// CommandDebounce handles the !ai debounce command -var _ = registerAICommand(commandregistry.Definition{ - Name: "debounce", - Description: "Get or set message debounce delay (ms), 'off' to disable, 'default' to reset", - Args: "[_delay_|off|default]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnDebounce, -}) - -func fnDebounce(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - if len(ce.Args) == 0 { - // Show current setting - switch { - case meta.DebounceMs < 0: - ce.Reply("Message debouncing is **disabled** for this room") - case meta.DebounceMs == 0: - ce.Reply("Message debounce: **%d ms** (default)", DefaultDebounceMs) - default: - ce.Reply("Message debounce: **%d ms**", meta.DebounceMs) - } - return - } - - arg := strings.ToLower(ce.Args[0]) - switch arg { - case "off", "disable", "disabled": - meta.DebounceMs = -1 - client.savePortalQuiet(ce.Ctx, ce.Portal, "debounce disabled") - ce.Reply("Message debouncing disabled for this room") - case "default", "reset": - meta.DebounceMs = 0 - client.savePortalQuiet(ce.Ctx, ce.Portal, "debounce reset") - ce.Reply("Message debounce reset to default (%d ms)", DefaultDebounceMs) - default: - // Parse as integer - delay, err := strconv.Atoi(arg) - if err != nil || delay < 0 || delay > 10000 { - ce.Reply("Invalid debounce delay. Use a number 0-10000 (ms), 'off', or 'default'.") - return - } - meta.DebounceMs = delay - client.savePortalQuiet(ce.Ctx, ce.Portal, "debounce change") - if delay == 0 { - ce.Reply("Message debounce reset to default (%d ms)", DefaultDebounceMs) - } else { - ce.Reply("Message debounce set to %d ms.", delay) - } - } -} - -// CommandTyping handles the !ai typing command -var _ = registerAICommand(commandregistry.Definition{ - Name: "typing", - Description: "Get or set typing indicator behavior for this chat", - Args: "[never|instant|thinking|message|off|reset|interval ]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnTyping, -}) - -func fnTyping(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - isGroup := client.isGroupChat(ce.Ctx, ce.Portal) - if len(ce.Args) == 0 { - mode := client.resolveTypingMode(meta, &TypingContext{IsGroup: isGroup, WasMentioned: !isGroup}, false) - interval := client.resolveTypingInterval(meta) - response := fmt.Sprintf("Typing: mode=%s interval=%s", mode, formatTypingInterval(interval)) - if meta.TypingMode != "" || meta.TypingIntervalSeconds != nil { - overrideMode := "default" - if meta.TypingMode != "" { - overrideMode = meta.TypingMode - } - overrideInterval := "default" - if meta.TypingIntervalSeconds != nil { - overrideInterval = fmt.Sprintf("%ds", *meta.TypingIntervalSeconds) - } - response = fmt.Sprintf("%s (session override: mode=%s interval=%s)", response, overrideMode, overrideInterval) - } - ce.Reply(response) - return - } - - token := strings.ToLower(strings.TrimSpace(ce.Args[0])) - switch token { - case "reset", "default": - meta.TypingMode = "" - meta.TypingIntervalSeconds = nil - client.savePortalQuiet(ce.Ctx, ce.Portal, "typing reset") - ce.Reply("Typing settings reset to defaults.") - return - case "off": - meta.TypingMode = string(TypingModeNever) - client.savePortalQuiet(ce.Ctx, ce.Portal, "typing mode") - ce.Reply("Typing disabled for this session.") - return - case "interval": - if len(ce.Args) < 2 { - ce.Reply("Usage: `!ai typing interval `") - return - } - seconds, err := parsePositiveInt(ce.Args[1]) - if err != nil || seconds <= 0 { - ce.Reply("Interval must be a positive integer (seconds).") - return - } - meta.TypingIntervalSeconds = &seconds - client.savePortalQuiet(ce.Ctx, ce.Portal, "typing interval") - ce.Reply("Typing interval set to %ds.", seconds) - return - default: - if mode, ok := normalizeTypingMode(token); ok { - meta.TypingMode = string(mode) - client.savePortalQuiet(ce.Ctx, ce.Portal, "typing mode") - ce.Reply("Typing mode set to %s.", mode) - return - } - } - - ce.Reply("Usage: `!ai typing ` | `!ai typing interval ` | `!ai typing off` | `!ai typing reset`") -} - -// CommandTools handles the !ai tools command -var _ = registerAICommand(commandregistry.Definition{ - Name: "tools", - Description: "Enable/disable tools", - Args: "[on|off] [_tool_]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnTools, -}) - -func fnTools(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - // Run async to avoid blocking - go client.handleToolsCommand(ce.Ctx, ce.Portal, meta, ce.RawArgs) -} - -// CommandNew handles the !ai new command -var _ = registerAICommand(commandregistry.Definition{ - Name: "new", - Description: "Create a new chat of the same type (agent or model)", - Args: "[agent ]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnNew, -}) - -func fnNew(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - // Run async go client.handleNewChat(ce.Ctx, nil, ce.Portal, meta, ce.Args) } - -// CommandFork handles the !ai fork command -var _ = registerAICommand(commandregistry.Definition{ - Name: "fork", - Description: "Fork conversation to a new chat", - Args: "[_event_id_]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnFork, -}) - -func fnFork(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - var arg string - if len(ce.Args) > 0 { - arg = ce.Args[0] - } - - // Run async - go client.handleFork(ce.Ctx, nil, ce.Portal, meta, arg) -} - -// CommandRegenerate handles the !ai regenerate command -var _ = registerAICommand(commandregistry.Definition{ - Name: "regenerate", - Description: "Regenerate the last AI response", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnRegenerate, -}) - -func fnRegenerate(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - // Run async - go client.handleRegenerate(ce.Ctx, nil, ce.Portal, meta) -} - -// CommandTitle handles the !ai title command -var _ = registerAICommand(commandregistry.Definition{ - Name: "title", - Description: "Regenerate the chat room title", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnTitle, -}) - -func fnTitle(ce *commands.Event) { - client, _, ok := requireClientMeta(ce) - if !ok { - return - } - - // Run async - go client.handleRegenerateTitle(ce.Ctx, ce.Portal) -} - -// CommandModels handles the !ai models command -var _ = registerAICommand(commandregistry.Definition{ - Name: "models", - Description: "List all available models", - Section: HelpSectionAI, - RequiresLogin: true, - Handler: fnModels, -}) - -func fnModels(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - - // Get portal meta if available (for showing current model) - meta := getPortalMeta(ce) - - models, err := client.listAvailableModels(ce.Ctx, false) - if err != nil { - ce.Reply("Couldn't load models. Try again.") - return - } - - var sb strings.Builder - sb.WriteString("Available models:\n\n") - for _, m := range models { - var caps []string - if m.SupportsVision { - caps = append(caps, "Vision") - } - if m.SupportsReasoning { - caps = append(caps, "Reasoning") - } - if m.SupportsWebSearch { - caps = append(caps, "Web Search") - } - if m.SupportsImageGen { - caps = append(caps, "Image Gen") - } - if m.SupportsToolCalling { - caps = append(caps, "Tools") - } - sb.WriteString(fmt.Sprintf("• **%s** (`%s`)\n", m.Name, m.ID)) - if m.Description != "" { - sb.WriteString(fmt.Sprintf(" %s\n", m.Description)) - } - if len(caps) > 0 { - sb.WriteString(fmt.Sprintf(" %s\n", strings.Join(caps, " · "))) - } - sb.WriteString("\n") - } - - currentModel := client.effectiveModel(meta) - sb.WriteString(fmt.Sprintf("Current: **%s**\nUse `!ai model ` to switch models", currentModel)) - ce.Reply(sb.String()) -} - -// CommandTimezone handles the !ai timezone command -var _ = registerAICommand(commandregistry.Definition{ - Name: "timezone", - Description: "Get or set your timezone for all chats (IANA name)", - Args: "[_timezone_|reset]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnTimezone, -}) - -func fnTimezone(ce *commands.Event) { - client, _, ok := requireClientMeta(ce) - if !ok { - return - } - - loginMeta := loginMetadata(client.UserLogin) - if loginMeta == nil { - ce.Reply("Couldn't load your settings. Try again.") - return - } - - if len(ce.Args) == 0 { - tz := strings.TrimSpace(loginMeta.Timezone) - if tz == "" { - ce.Reply("No timezone set. Use `!ai timezone ` (example: `America/Los_Angeles`).") - return - } - ce.Reply("Timezone: %s", tz) - return - } - - arg := strings.TrimSpace(ce.Args[0]) - switch strings.ToLower(arg) { - case "reset", "default", "clear": - loginMeta.Timezone = "" - if err := client.UserLogin.Save(ce.Ctx); err != nil { - ce.Reply("Couldn't clear the timezone: %s", err.Error()) - return - } - ce.Reply("Timezone cleared. Falling back to UTC unless TZ is set.") - return - default: - tz, _, err := normalizeTimezone(arg) - if err != nil { - ce.Reply("Invalid timezone. Use an IANA name like `America/Los_Angeles` or `Europe/London`.") - return - } - loginMeta.Timezone = tz - if err := client.UserLogin.Save(ce.Ctx); err != nil { - ce.Reply("Couldn't save the timezone: %s", err.Error()) - return - } - ce.Reply("Timezone set to %s.", tz) - } -} - -// CommandGravatar handles the !ai gravatar command -var _ = registerAICommand(commandregistry.Definition{ - Name: "gravatar", - Description: "Fetch or set the Gravatar profile for this login", - Args: "[fetch|set] [email]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnGravatar, -}) - -func fnGravatar(ce *commands.Event) { - client, _, ok := requireClientMeta(ce) - if !ok { - return - } - - if len(ce.Args) == 0 { - loginMeta := loginMetadata(client.UserLogin) - if loginMeta == nil || loginMeta.Gravatar == nil || loginMeta.Gravatar.Primary == nil { - ce.Reply("No Gravatar profile set. Use `!ai gravatar set `.") - return - } - ce.Reply(formatGravatarMarkdown(loginMeta.Gravatar.Primary, "primary")) - return - } - - action := strings.ToLower(strings.TrimSpace(ce.Args[0])) - switch action { - case "fetch": - email := "" - if len(ce.Args) > 1 { - email = ce.Args[1] - } - if strings.TrimSpace(email) == "" { - loginMeta := loginMetadata(client.UserLogin) - if loginMeta != nil && loginMeta.Gravatar != nil && loginMeta.Gravatar.Primary != nil { - email = loginMeta.Gravatar.Primary.Email - } - } - if strings.TrimSpace(email) == "" { - ce.Reply("Email is required. Usage: `!ai gravatar fetch `.") - return - } - profile, err := fetchGravatarProfile(ce.Ctx, email) - if err != nil { - ce.Reply("Couldn't fetch the Gravatar profile: %s", err.Error()) - return - } - ce.Reply(formatGravatarMarkdown(profile, "fetched")) - return - case "set": - if len(ce.Args) < 2 || strings.TrimSpace(ce.Args[1]) == "" { - ce.Reply("Email is required. Usage: `!ai gravatar set `.") - return - } - profile, err := fetchGravatarProfile(ce.Ctx, ce.Args[1]) - if err != nil { - ce.Reply("Couldn't fetch the Gravatar profile: %s", err.Error()) - return - } - state := ensureGravatarState(loginMetadata(client.UserLogin)) - state.Primary = profile - if err := client.UserLogin.Save(ce.Ctx); err != nil { - ce.Reply("Couldn't save the Gravatar profile: %s", err.Error()) - return - } - ce.Reply(formatGravatarMarkdown(profile, "primary set")) - return - default: - ce.Reply("Usage: `!ai gravatar fetch ` or `!ai gravatar set `.") - } -} - -// CommandAgent handles the !ai agent command -var _ = registerAICommand(commandregistry.Definition{ - Name: "agent", - Description: "Get or set the agent for this chat", - Args: "[_agent id_]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnAgent, -}) - -func fnAgent(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - - store := NewAgentStoreAdapter(client) - - if len(ce.Args) == 0 { - // Show current agent - agentID := resolveAgentID(meta) - if agentID == "" { - ce.Reply("No agent configured. Using default model: %s", client.effectiveModel(meta)) - return - } - agent, err := store.GetAgentByID(ce.Ctx, agentID) - if err != nil { - ce.Reply("Current agent ID: %s (not found)", agentID) - return - } - displayName := client.resolveAgentDisplayName(ce.Ctx, agent) - if displayName == "" { - displayName = agent.ID - } - ce.Reply("Current agent: **%s** (`%s`)\n%s", displayName, agent.ID, agent.Description) - return - } - - if rejectBossOverrides(ce, meta, "Can't change the agent in a room managed by the Boss agent.") { - return - } - - // Set agent - agentID := ce.Args[0] - - // Special case: "none" clears the agent - if agentID == "none" || agentID == "clear" { - meta.AgentID = "" - meta.AgentPrompt = "" - modelID := client.effectiveModel(meta) - ce.Portal.OtherUserID = modelUserID(modelID) - client.savePortalQuiet(ce.Ctx, ce.Portal, "agent cleared") - _ = client.BroadcastRoomState(ce.Ctx, ce.Portal) - ce.Reply("Agent cleared. Using default model.") - return - } - - agent, err := store.GetAgentByID(ce.Ctx, agentID) - if err != nil { - ce.Reply("Agent not found: %s", agentID) - return - } - - meta.AgentID = agent.ID - meta.AgentPrompt = agent.SystemPrompt - meta.Model = "" - modelID := client.effectiveModel(meta) - meta.Capabilities = getModelCapabilities(modelID, client.findModelInfo(modelID)) - ce.Portal.OtherUserID = agentUserID(agent.ID) - client.savePortalQuiet(ce.Ctx, ce.Portal, "agent change") - agentName := client.resolveAgentDisplayName(ce.Ctx, agent) - client.ensureAgentGhostDisplayName(ce.Ctx, agent.ID, modelID, agentName) - _ = client.BroadcastRoomState(ce.Ctx, ce.Portal) - displayName := agentName - if displayName == "" { - displayName = agent.ID - } - ce.Reply("Agent set to **%s** (`%s`)", displayName, agent.ID) -} - -// CommandAgents handles the !ai agents command -var _ = registerAICommand(commandregistry.Definition{ - Name: "agents", - Description: "List available agents", - Section: HelpSectionAI, - RequiresLogin: true, - Handler: fnAgents, -}) - -func fnAgents(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - - store := NewAgentStoreAdapter(client) - agentsMap, err := store.LoadAgents(ce.Ctx) - if err != nil { - ce.Reply("Couldn't load agents: %v", err) - return - } - - var sb strings.Builder - sb.WriteString("## Available Agents\n\n") - - // Group by preset vs custom - var presets, custom []string - for id, agent := range agentsMap { - agentName := client.resolveAgentDisplayName(ce.Ctx, agent) - line := fmt.Sprintf("• **%s** (`%s`)", agentName, id) - if agent.Description != "" { - line += fmt.Sprintf(" - %s", agent.Description) - } - if agent.IsPreset { - presets = append(presets, line) - } else { - custom = append(custom, line) - } - } - - if len(presets) > 0 { - sb.WriteString("**Presets:**\n") - for _, line := range presets { - sb.WriteString(line + "\n") - } - sb.WriteString("\n") - } - - if len(custom) > 0 { - sb.WriteString("**Custom:**\n") - for _, line := range custom { - sb.WriteString(line + "\n") - } - sb.WriteString("\n") - } - - sb.WriteString("Use `!ai agent ` to switch agents") - ce.Reply(sb.String()) -} - -// CommandCreateAgent handles the !ai create-agent command -var _ = registerAICommand(commandregistry.Definition{ - Name: "create-agent", - Description: "Create a new custom agent", - Args: " [model] [system prompt...]", - Section: HelpSectionAI, - RequiresLogin: true, - Handler: fnCreateAgent, -}) - -func fnCreateAgent(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - - args := ce.Args - if raw := strings.TrimSpace(ce.RawArgs); raw != "" { - if parsed, err := splitQuotedArgs(raw); err == nil && len(parsed) > 0 { - args = parsed - } - } - - if len(args) < 2 { - ce.Reply("Usage: !ai create-agent [model] [system prompt...]\nExample: !ai create-agent my-helper \"My Helper\" gpt-4o You are a helpful assistant.") - return - } - - agentID := args[0] - agentName := args[1] - - if _, reserved := reservedAgentIDs[agentID]; reserved { - ce.Reply("Agent ID '%s' is reserved. Choose a different ID.", agentID) - return - } - if !isValidAgentID(agentID) { - ce.Reply("Invalid agent ID '%s'. Use only lowercase letters, numbers, and hyphens.", agentID) - return - } - - // Parse optional model and system prompt - var model, systemPrompt string - if len(args) > 2 { - model = args[2] - } - if len(args) > 3 { - systemPrompt = strings.Join(args[3:], " ") - } - - store := NewAgentStoreAdapter(client) - - // Check if agent already exists - if _, err := store.GetAgentByID(ce.Ctx, agentID); err == nil { - ce.Reply("Agent with ID '%s' already exists", agentID) - return - } - - // Create new agent - newAgent := &agents.AgentDefinition{ - ID: agentID, - Name: agentName, - SystemPrompt: systemPrompt, - Tools: &toolpolicy.ToolPolicyConfig{Profile: toolpolicy.ProfileFull}, - IsPreset: false, - CreatedAt: time.Now().Unix(), - UpdatedAt: time.Now().Unix(), - } - if model != "" { - newAgent.Model = agents.ModelConfig{Primary: model} - } - - if err := store.SaveAgent(ce.Ctx, newAgent); err != nil { - ce.Reply("Couldn't create the agent: %v", err) - return - } - - ce.Reply("Created agent: **%s** (`%s`)\nUse `!ai agent %s` to use it", agentName, agentID, agentID) -} - -// CommandDeleteAgent handles the !ai delete-agent command -var _ = registerAICommand(commandregistry.Definition{ - Name: "delete-agent", - Description: "Delete a custom agent", - Args: "", - Section: HelpSectionAI, - RequiresLogin: true, - Handler: fnDeleteAgent, -}) - -func fnDeleteAgent(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - - if len(ce.Args) < 1 { - ce.Reply("Usage: !ai delete-agent ") - return - } - - agentID := ce.Args[0] - store := NewAgentStoreAdapter(client) - - // Check if it's a preset - if agents.IsPreset(agentID) || agents.IsBossAgent(agentID) { - ce.Reply("Can't delete a preset agent: %s", agentID) - return - } - - if err := store.DeleteAgent(ce.Ctx, agentID); err != nil { - ce.Reply("Couldn't delete the agent: %v", err) - return - } - - ce.Reply("Deleted agent: %s", agentID) -} diff --git a/pkg/connector/commands_helpers.go b/pkg/connector/commands_helpers.go index 2be022e0..771f9a49 100644 --- a/pkg/connector/commands_helpers.go +++ b/pkg/connector/commands_helpers.go @@ -2,8 +2,6 @@ package connector import ( "maunium.net/go/mautrix/bridgev2/commands" - - "github.com/beeper/ai-bridge/pkg/agents" ) func requireClientMeta(ce *commands.Event) (*AIClient, *PortalMetadata, bool) { @@ -15,20 +13,3 @@ func requireClientMeta(ce *commands.Event) (*AIClient, *PortalMetadata, bool) { } return client, meta, true } - -func requireClient(ce *commands.Event) (*AIClient, bool) { - client := getAIClient(ce) - if client == nil { - ce.Reply("Couldn't load AI settings. Try again.") - return nil, false - } - return client, true -} - -func rejectBossOverrides(ce *commands.Event, meta *PortalMetadata, message string) bool { - if agents.IsBossAgent(resolveAgentID(meta)) { - ce.Reply(message) - return true - } - return false -} diff --git a/pkg/connector/commands_manage.go b/pkg/connector/commands_manage.go deleted file mode 100644 index c5350a8a..00000000 --- a/pkg/connector/commands_manage.go +++ /dev/null @@ -1,61 +0,0 @@ -package connector - -import ( - "maunium.net/go/mautrix/bridgev2/commands" - "maunium.net/go/mautrix/bridgev2/networkid" - - "github.com/beeper/ai-bridge/pkg/connector/commandregistry" -) - -// CommandManage handles the !ai manage command. -// This creates or opens the Builder room for advanced users to manage custom agents. -var _ = registerAICommand(commandregistry.Definition{ - Name: "manage", - Description: "Open the agent management room (for creating custom agents)", - Section: HelpSectionAI, - RequiresLogin: true, - Handler: fnManage, -}) - -func fnManage(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - - meta := loginMetadata(client.UserLogin) - - // Check if Builder room already exists - if meta.BuilderRoomID != "" { - portalKey := networkid.PortalKey{ - ID: meta.BuilderRoomID, - Receiver: client.UserLogin.ID, - } - portal, err := client.UserLogin.Bridge.GetPortalByKey(ce.Ctx, portalKey) - if err == nil && portal != nil && portal.MXID != "" { - ce.Reply("Agent management room: %s", portal.MXID) - return - } - // Room doesn't exist anymore, will create new one - } - - // Create Builder room on-demand - if err := client.ensureBuilderRoom(ce.Ctx); err != nil { - ce.Reply("Couldn't create the management room: %v", err) - return - } - - // Get the newly created room - meta = loginMetadata(client.UserLogin) - portalKey := networkid.PortalKey{ - ID: meta.BuilderRoomID, - Receiver: client.UserLogin.ID, - } - portal, err := client.UserLogin.Bridge.GetPortalByKey(ce.Ctx, portalKey) - if err != nil || portal == nil || portal.MXID == "" { - ce.Reply("Management room created, but the link isn't available.") - return - } - - ce.Reply("Created agent management room: %s\n\nIn this room you can:\n- Create custom agents\n- Manage existing agents\n- Configure advanced settings", portal.MXID) -} diff --git a/pkg/connector/commands_mcp.go b/pkg/connector/commands_mcp.go deleted file mode 100644 index 7fbd57c2..00000000 --- a/pkg/connector/commands_mcp.go +++ /dev/null @@ -1,472 +0,0 @@ -package connector - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "maunium.net/go/mautrix/bridgev2/commands" - - "github.com/beeper/ai-bridge/pkg/connector/commandregistry" -) - -func mcpAddUsage(allowStdio bool) string { - if allowStdio { - return "`!ai mcp add [token] [authType] [authURL]` | `!ai mcp add streamable_http [token] [authType] [authURL]` | `!ai mcp add stdio [args...]`" - } - return "`!ai mcp add [token] [authType] [authURL]` | `!ai mcp add streamable_http [token] [authType] [authURL]`" -} - -func mcpManageUsage(allowStdio bool) string { - return fmt.Sprintf("`!ai mcp list` | %s | `!ai mcp connect [name] [token]` | `!ai mcp disconnect [name]` | `!ai mcp remove [name]`.", mcpAddUsage(allowStdio)) -} - -// CommandMCP handles the !ai mcp command. -var _ = registerAICommand(commandregistry.Definition{ - Name: "mcp", - Description: "Manage MCP servers for this login", - Args: " [args]", - Section: HelpSectionAI, - RequiresLogin: true, - Handler: fnMCPCommand, -}) - -func fnMCPCommand(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - allowStdio := client.isMCPStdioEnabled() - - if len(ce.Args) == 0 { - ce.Reply("Usage: %s", mcpManageUsage(allowStdio)) - return - } - - sub := strings.ToLower(strings.TrimSpace(ce.Args[0])) - switch sub { - case "list": - fnMCPList(ce, client) - return - case "add": - fnMCPAdd(ce, client) - return - case "connect": - fnMCPConnect(ce, client) - return - case "disconnect": - fnMCPDisconnect(ce, client) - return - case "remove": - fnMCPRemove(ce, client) - return - default: - ce.Reply("Usage: %s", mcpManageUsage(allowStdio)) - } -} - -func fnMCPList(ce *commands.Event, client *AIClient) { - servers := client.configuredMCPServers() - if len(servers) == 0 { - ce.Reply("No MCP servers are set up yet. Run `!ai mcp add` to add one.") - return - } - - toolCounts := map[string]int{} - ctx, cancel := context.WithTimeout(ce.Ctx, 3*time.Second) - defer cancel() - defs, err := client.mcpToolDefinitions(ctx) - if err == nil { - for _, def := range defs { - name := client.cachedMCPServerForTool(def.Name) - if name == "" { - continue - } - toolCounts[name]++ - } - } - - lines := make([]string, 0, len(servers)) - for _, server := range servers { - cfg := normalizeMCPServerConfig(server.Config) - status := "disconnected" - if cfg.Connected { - status = "connected" - } - auth := cfg.AuthType - if auth == "" { - auth = "none" - } - token := "missing" - if cfg.Token != "" || cfg.AuthType == "none" { - token = "set" - } - line := fmt.Sprintf("- %s: %s (transport=%s, target=%s, auth=%s, token=%s)", server.Name, status, cfg.Transport, mcpServerTargetLabel(cfg), auth, token) - if count, ok := toolCounts[server.Name]; ok { - line = fmt.Sprintf("%s, tools=%d", line, count) - } - if server.Source == "config" { - line += " [from config]" - } - lines = append(lines, line) - } - ce.Reply("MCP servers:\n%s", strings.Join(lines, "\n")) -} - -func parseMCPHTTPAuthArgs(rest []string) (token, authType, authURL string) { - authType = "bearer" - if len(rest) > 0 { - token = strings.TrimSpace(rest[0]) - } - if len(rest) > 1 { - authType = strings.TrimSpace(rest[1]) - } - if len(rest) > 2 { - authURL = strings.TrimSpace(strings.Join(rest[2:], " ")) - } - return token, authType, authURL -} - -func parseMCPAddArgs(args []string, allowStdio bool) (name string, cfg MCPServerConfig, err error) { - trimmed := make([]string, 0, len(args)) - for _, raw := range args { - part := strings.TrimSpace(raw) - if part != "" { - trimmed = append(trimmed, part) - } - } - if len(trimmed) == 0 { - return "", MCPServerConfig{}, errors.New("missing args") - } - - if len(trimmed) < 2 { - return "", MCPServerConfig{}, errors.New("missing target") - } - name = normalizeMCPServerName(trimmed[0]) - targetIndex := 1 - - rawTransportOrTarget := strings.TrimSpace(trimmed[targetIndex]) - normalizedTransport := normalizeMCPServerTransport(rawTransportOrTarget) - if normalizedTransport == mcpTransportStdio { - if !allowStdio { - return "", MCPServerConfig{}, errors.New("stdio disabled") - } - if len(trimmed) <= targetIndex+1 { - return "", MCPServerConfig{}, errors.New("missing command") - } - cfg = normalizeMCPServerConfig(MCPServerConfig{ - Transport: mcpTransportStdio, - Command: strings.TrimSpace(trimmed[targetIndex+1]), - Args: trimmed[targetIndex+2:], - AuthType: "none", - Connected: false, - Kind: mcpServerKindGeneric, - }) - if cfg.Command == "" { - return "", MCPServerConfig{}, errors.New("missing command") - } - return name, cfg, nil - } - - endpoint := rawTransportOrTarget - rest := trimmed[targetIndex+1:] - if normalizedTransport == mcpTransportStreamableHTTP { - if len(trimmed) <= targetIndex+1 { - return "", MCPServerConfig{}, errors.New("missing endpoint") - } - endpoint = strings.TrimSpace(trimmed[targetIndex+1]) - rest = trimmed[targetIndex+2:] - } - if !isLikelyHTTPURL(endpoint) { - return "", MCPServerConfig{}, errors.New("invalid endpoint") - } - token, authType, authURL := parseMCPHTTPAuthArgs(rest) - cfg = normalizeMCPServerConfig(MCPServerConfig{ - Transport: mcpTransportStreamableHTTP, - Endpoint: endpoint, - Token: token, - AuthType: authType, - AuthURL: authURL, - Connected: false, - Kind: mcpServerKindGeneric, - }) - return name, cfg, nil -} - -func fnMCPAdd(ce *commands.Event, client *AIClient) { - login := client.UserLogin - if login == nil { - ce.Reply("You're not signed in. Sign in and try again.") - return - } - meta := loginMetadata(login) - if meta == nil { - ce.Reply("Couldn't load your settings. Try again.") - return - } - - allowStdio := client.isMCPStdioEnabled() - name, cfg, err := parseMCPAddArgs(ce.Args[1:], allowStdio) - if err != nil { - if err.Error() == "stdio disabled" { - ce.Reply("Stdio MCP servers are disabled by the bridge configuration.") - return - } - ce.Reply("Usage: %s", mcpAddUsage(allowStdio)) - return - } - - if meta.ServiceTokens == nil { - meta.ServiceTokens = &ServiceTokens{} - } - if meta.ServiceTokens.MCPServers == nil { - meta.ServiceTokens.MCPServers = map[string]MCPServerConfig{} - } - meta.ServiceTokens.MCPServers[name] = cfg - if err := login.Save(ce.Ctx); err != nil { - ce.Reply("Couldn't save the MCP server: %s", err) - return - } - client.invalidateMCPToolCache() - - ce.Reply("Saved MCP server '%s' (%s). Connect with `!ai mcp connect %s`.", name, mcpServerTargetLabel(cfg), name) -} - -func resolveMCPServerArg(client *AIClient, args []string) (namedMCPServer, string, error) { - servers := client.configuredMCPServers() - if len(servers) == 0 { - return namedMCPServer{}, "", errors.New("none configured") - } - - if len(args) == 0 { - if len(servers) == 1 { - return servers[0], "", nil - } - return namedMCPServer{}, "", errors.New("ambiguous") - } - - candidate := strings.TrimSpace(args[0]) - for _, server := range servers { - if server.Name == normalizeMCPServerName(candidate) { - token := "" - if len(args) > 1 { - token = strings.TrimSpace(strings.Join(args[1:], " ")) - } - return server, token, nil - } - } - return namedMCPServer{}, "", errors.New("not found") -} - -func sendMCPAuthURLNotice(client *AIClient, ce *commands.Event, server namedMCPServer) { - if strings.TrimSpace(server.Config.AuthURL) == "" { - return - } - message := fmt.Sprintf("Sign in to MCP server '%s': %s", server.Name, server.Config.AuthURL) - if ce != nil && ce.Portal != nil { - client.sendSystemNotice(ce.Ctx, ce.Portal, message) - return - } - if ce != nil { - ce.Reply(message) - } -} - -func (oc *AIClient) verifyMCPServerConnection(ctx context.Context, server namedMCPServer) (int, error) { - if ctx == nil { - ctx = context.Background() - } - callCtx := ctx - var cancel context.CancelFunc - if _, hasDeadline := callCtx.Deadline(); !hasDeadline { - timeout := oc.mcpRequestTimeout() - if timeout > 10*time.Second { - timeout = 10 * time.Second - } - callCtx, cancel = context.WithTimeout(ctx, timeout) - } - if cancel != nil { - defer cancel() - } - defs, err := oc.fetchMCPToolsForServer(callCtx, server) - if err != nil { - return 0, err - } - return len(defs), nil -} - -func setLoginMCPServer(meta *UserLoginMetadata, name string, cfg MCPServerConfig) { - if meta.ServiceTokens == nil { - meta.ServiceTokens = &ServiceTokens{} - } - if meta.ServiceTokens.MCPServers == nil { - meta.ServiceTokens.MCPServers = map[string]MCPServerConfig{} - } - meta.ServiceTokens.MCPServers[name] = normalizeMCPServerConfig(cfg) -} - -func clearLoginMCPServer(meta *UserLoginMetadata, name string) { - if meta == nil || meta.ServiceTokens == nil || meta.ServiceTokens.MCPServers == nil { - return - } - delete(meta.ServiceTokens.MCPServers, name) - if len(meta.ServiceTokens.MCPServers) == 0 { - meta.ServiceTokens.MCPServers = nil - } - if serviceTokensEmpty(meta.ServiceTokens) { - meta.ServiceTokens = nil - } -} - -func fnMCPConnect(ce *commands.Event, client *AIClient) { - login := client.UserLogin - if login == nil { - ce.Reply("You're not signed in. Sign in and try again.") - return - } - meta := loginMetadata(login) - if meta == nil { - ce.Reply("Couldn't load your settings. Try again.") - return - } - - target, tokenOverride, err := resolveMCPServerArg(client, ce.Args[1:]) - if err != nil { - switch err.Error() { - case "none configured": - ce.Reply("No MCP servers are set up yet. Run `!ai mcp add` first.") - case "ambiguous": - ce.Reply("Multiple MCP servers are set up. Include a server name, or run `!ai mcp list`.") - default: - ce.Reply("Couldn't find that MCP server. Run `!ai mcp list`.") - } - return - } - - cfg := normalizeMCPServerConfig(target.Config) - if tokenOverride != "" && !mcpServerUsesStdio(cfg) { - cfg.Token = strings.TrimSpace(tokenOverride) - if cfg.Token != "" && cfg.AuthType == "none" { - cfg.AuthType = "bearer" - } - } - if !mcpServerHasTarget(cfg) { - ce.Reply("MCP server '%s' isn't configured with a target.", target.Name) - return - } - if mcpServerNeedsToken(cfg) && cfg.Token == "" { - cfg.Connected = false - setLoginMCPServer(meta, target.Name, cfg) - if saveErr := login.Save(ce.Ctx); saveErr != nil { - ce.Reply("Couldn't update MCP server '%s': %s", target.Name, saveErr) - return - } - client.invalidateMCPToolCache() - sendMCPAuthURLNotice(client, ce, namedMCPServer{Name: target.Name, Config: cfg, Source: "login"}) - ce.Reply("MCP server '%s' needs a token. Add one: `!ai mcp connect %s `.", target.Name, target.Name) - return - } - - cfg.Connected = true - count, connectErr := client.verifyMCPServerConnection(ce.Ctx, namedMCPServer{Name: target.Name, Config: cfg, Source: "login"}) - if connectErr != nil { - cfg.Connected = false - setLoginMCPServer(meta, target.Name, cfg) - if saveErr := login.Save(ce.Ctx); saveErr != nil { - ce.Reply("Couldn't save MCP server '%s': %s", target.Name, saveErr) - return - } - client.invalidateMCPToolCache() - if mcpCallLikelyAuthError(connectErr) { - sendMCPAuthURLNotice(client, ce, namedMCPServer{Name: target.Name, Config: cfg, Source: "login"}) - } - ce.Reply("Couldn't connect to MCP server '%s': %v", target.Name, connectErr) - return - } - - setLoginMCPServer(meta, target.Name, cfg) - if err := login.Save(ce.Ctx); err != nil { - ce.Reply("Couldn't save MCP server '%s': %s", target.Name, err) - return - } - client.invalidateMCPToolCache() - ce.Reply("Connected to MCP server '%s' (%d tools found).", target.Name, count) -} - -func fnMCPDisconnect(ce *commands.Event, client *AIClient) { - login := client.UserLogin - if login == nil { - ce.Reply("You're not signed in. Sign in and try again.") - return - } - meta := loginMetadata(login) - if meta == nil { - ce.Reply("Couldn't load your settings. Try again.") - return - } - - target, _, err := resolveMCPServerArg(client, ce.Args[1:]) - if err != nil { - switch err.Error() { - case "none configured": - ce.Reply("No MCP servers are set up yet.") - case "ambiguous": - ce.Reply("Multiple MCP servers are set up. Include a server name, or run `!ai mcp list`.") - default: - ce.Reply("Couldn't find that MCP server. Run `!ai mcp list`.") - } - return - } - - cfg := normalizeMCPServerConfig(target.Config) - cfg.Connected = false - setLoginMCPServer(meta, target.Name, cfg) - if err := login.Save(ce.Ctx); err != nil { - ce.Reply("Couldn't disconnect MCP server '%s': %s", target.Name, err) - return - } - client.invalidateMCPToolCache() - ce.Reply("Disconnected from MCP server '%s'.", target.Name) -} - -func fnMCPRemove(ce *commands.Event, client *AIClient) { - login := client.UserLogin - if login == nil { - ce.Reply("You're not signed in. Sign in and try again.") - return - } - meta := loginMetadata(login) - if meta == nil { - ce.Reply("Couldn't load your settings. Try again.") - return - } - - target, _, err := resolveMCPServerArg(client, ce.Args[1:]) - if err != nil { - switch err.Error() { - case "none configured": - ce.Reply("No MCP servers are set up yet.") - case "ambiguous": - ce.Reply("Multiple MCP servers are set up. Include a server name, or run `!ai mcp list`.") - default: - ce.Reply("Couldn't find that MCP server. Run `!ai mcp list`.") - } - return - } - - loginServers := client.loginMCPServers() - if _, ok := loginServers[target.Name]; !ok { - ce.Reply("MCP server '%s' is managed by the bridge configuration and can't be removed here. To override it for this login, run `!ai mcp disconnect %s`.", target.Name, target.Name) - return - } - - clearLoginMCPServer(meta, target.Name) - if err := login.Save(ce.Ctx); err != nil { - ce.Reply("Couldn't remove MCP server '%s': %s", target.Name, err) - return - } - client.invalidateMCPToolCache() - ce.Reply("Removed MCP server '%s'.", target.Name) -} diff --git a/pkg/connector/commands_parity.go b/pkg/connector/commands_parity.go index d8bfee85..bd41e568 100644 --- a/pkg/connector/commands_parity.go +++ b/pkg/connector/commands_parity.go @@ -1,19 +1,14 @@ package connector import ( - "encoding/json" - "fmt" - "strings" "time" "maunium.net/go/mautrix/bridgev2/commands" "github.com/beeper/ai-bridge/pkg/connector/commandregistry" airuntime "github.com/beeper/ai-bridge/pkg/runtime" - "github.com/beeper/ai-bridge/pkg/shared/stringutil" ) -// CommandStatus handles the !ai status command. var _ = registerAICommand(commandregistry.Definition{ Name: "status", Description: "Show current session status", @@ -23,16 +18,6 @@ var _ = registerAICommand(commandregistry.Definition{ Handler: fnStatus, }) -// CommandLastHeartbeat handles the !ai last-heartbeat command. -var _ = registerAICommand(commandregistry.Definition{ - Name: "last-heartbeat", - Description: "Show the last heartbeat event for this login", - Section: HelpSectionAI, - RequiresPortal: false, - RequiresLogin: true, - Handler: fnLastHeartbeat, -}) - func fnStatus(ce *commands.Event) { client, meta, ok := requireClientMeta(ce) if !ok { @@ -43,32 +28,6 @@ func fnStatus(ce *commands.Event) { ce.Reply("%s", client.buildStatusText(ce.Ctx, ce.Portal, meta, isGroup, queueSettings)) } -func fnLastHeartbeat(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - evt := getLastHeartbeatEventForLogin(client.UserLogin) - if evt == nil { - ce.Reply("No heartbeat yet.") - return - } - pretty, err := json.MarshalIndent(evt, "", " ") - if err != nil { - ce.Reply("Failed to serialize last heartbeat: %s", err.Error()) - return - } - // Keep replies bounded; fall back to compact JSON if needed. - if len(pretty) > 8000 { - compact, err2 := json.Marshal(evt) - if err2 == nil { - pretty = compact - } - } - ce.Reply("```json\n%s\n```", string(pretty)) -} - -// CommandReset handles the !ai reset command. var _ = registerAICommand(commandregistry.Definition{ Name: "reset", Description: "Start a new session/thread in this room", @@ -85,8 +44,6 @@ func fnReset(ce *commands.Event) { } meta.SessionResetAt = time.Now().UnixMilli() - meta.GroupIntroSent = false - meta.GroupActivationNeedsIntro = true client.savePortalQuiet(ce.Ctx, ce.Portal, "session reset") client.clearPendingQueue(ce.Portal.MXID) client.cancelRoomRun(ce.Portal.MXID) @@ -94,7 +51,6 @@ func fnReset(ce *commands.Event) { ce.Reply("%s", formatSystemAck("Session reset.")) } -// CommandStop handles the !ai stop command. var _ = registerAICommand(commandregistry.Definition{ Name: "stop", Description: "Abort the current run and clear the pending queue", @@ -112,317 +68,3 @@ func fnStop(ce *commands.Event) { stopped := client.abortRoom(ce.Ctx, ce.Portal, meta) ce.Reply("%s", formatAbortNotice(stopped)) } - -// CommandQueue handles the !ai queue command. -var _ = registerAICommand(commandregistry.Definition{ - Name: "queue", - Description: "Inspect or configure the message queue", - Args: "[status|reset|] [debounce:] [cap:] [drop:]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnQueue, -}) - -func fnQueue(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - portal := ce.Portal - - queueSettings, _, storeRef, sessionKey := client.resolveQueueSettingsForPortal(ce.Ctx, portal, meta, "", airuntime.QueueInlineOptions{}) - - if len(ce.Args) == 0 || strings.EqualFold(strings.TrimSpace(ce.Args[0]), "status") { - ce.Reply("%s", buildQueueStatusLine(queueSettings)) - return - } - - if strings.EqualFold(strings.TrimSpace(ce.Args[0]), "reset") { - if sessionKey != "" { - client.updateSessionEntry(ce.Ctx, storeRef, sessionKey, func(entry sessionEntry) sessionEntry { - entry.QueueMode = "" - entry.QueueDebounceMs = nil - entry.QueueCap = nil - entry.QueueDrop = "" - entry.UpdatedAt = time.Now().UnixMilli() - return entry - }) - } - client.clearPendingQueue(portal.MXID) - queueSettings, _, _, _ = client.resolveQueueSettingsForPortal(ce.Ctx, portal, meta, "", airuntime.QueueInlineOptions{}) - ce.Reply("%s", buildQueueStatusLine(queueSettings)) - return - } - - raw := strings.TrimSpace(strings.Join(ce.Args, " ")) - _, directive := parseQueueDirectiveArgs(raw) - if directive.HasDebounce && directive.DebounceMs == nil { - ce.Reply("Invalid debounce \"%s\". Use ms/s/m (e.g. debounce:1500ms, debounce:2s).", directive.RawDebounce) - return - } - if directive.HasCap && directive.Cap == nil { - ce.Reply("Invalid cap \"%s\". Use a positive integer (e.g. cap:10).", directive.RawCap) - return - } - if directive.HasDrop && directive.DropPolicy == nil { - ce.Reply("Invalid drop policy \"%s\". Use drop:old, drop:new, or drop:summarize.", directive.RawDrop) - return - } - if directive.QueueMode == "" && !directive.HasOptions { - ce.Reply("Usage: `!ai queue [status|reset|] [debounce:] [cap:] [drop:]`") - return - } - - if sessionKey != "" { - client.updateSessionEntry(ce.Ctx, storeRef, sessionKey, func(entry sessionEntry) sessionEntry { - if directive.QueueMode != "" { - entry.QueueMode = string(directive.QueueMode) - } - if directive.DebounceMs != nil { - entry.QueueDebounceMs = directive.DebounceMs - } - if directive.Cap != nil { - entry.QueueCap = directive.Cap - } - if directive.DropPolicy != nil { - entry.QueueDrop = string(*directive.DropPolicy) - } - entry.UpdatedAt = time.Now().UnixMilli() - return entry - }) - } - - queueSettings, _, _, _ = client.resolveQueueSettingsForPortal(ce.Ctx, portal, meta, "", airuntime.QueueInlineOptions{}) - ce.Reply("%s", buildQueueStatusLine(queueSettings)) -} - -// CommandThink handles the !ai think command. -var _ = registerAICommand(commandregistry.Definition{ - Name: "think", - Description: "Get or set thinking level (off|minimal|low|medium|high|xhigh)", - Args: "[level]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnThink, -}) - -func fnThink(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - if len(ce.Args) == 0 { - ce.Reply("Thinking: %s", client.defaultThinkLevel(meta)) - return - } - level, ok := stringutil.NormalizeEnum(ce.Args[0], thinkLevelAliases) - if !ok { - ce.Reply("Usage: `!ai think off|minimal|low|medium|high|xhigh`") - return - } - applyThinkingLevel(meta, level) - client.savePortalQuiet(ce.Ctx, ce.Portal, "think change") - ce.Reply("%s", formatThinkingAck(level)) -} - -// CommandVerbose handles the !ai verbose command. -var _ = registerAICommand(commandregistry.Definition{ - Name: "verbose", - Description: "Get or set verbosity (off|on|full)", - Args: "[level]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnVerbose, -}) - -func fnVerbose(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - if len(ce.Args) == 0 { - current := meta.VerboseLevel - if current == "" { - current = "off" - } - ce.Reply("Verbosity: %s", current) - return - } - level, ok := stringutil.NormalizeEnum(ce.Args[0], verboseLevelAliases) - if !ok { - ce.Reply("Usage: `!ai verbose on|off|full`") - return - } - meta.VerboseLevel = level - client.savePortalQuiet(ce.Ctx, ce.Portal, "verbose change") - ce.Reply("%s", formatVerboseAck(level)) -} - -// CommandReasoning handles the !ai reasoning command. -var _ = registerAICommand(commandregistry.Definition{ - Name: "reasoning", - Description: "Get or set reasoning visibility/effort (off|on|low|medium|high|xhigh)", - Args: "[level]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnReasoning, -}) - -func fnReasoning(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - if len(ce.Args) == 0 { - current := strings.TrimSpace(meta.ReasoningEffort) - if current == "" { - if meta.EmitThinking { - current = "on" - } else { - current = "off" - } - } - ce.Reply("Reasoning: %s", current) - return - } - level, ok := stringutil.NormalizeEnum(ce.Args[0], reasoningLevelAliases) - if !ok { - ce.Reply("Usage: `!ai reasoning off|on|low|medium|high|xhigh`") - return - } - applyReasoningLevel(meta, level) - client.savePortalQuiet(ce.Ctx, ce.Portal, "reasoning change") - ce.Reply("%s", formatReasoningAck(level)) -} - -// CommandElevated handles the !ai elevated command. -var _ = registerAICommand(commandregistry.Definition{ - Name: "elevated", - Description: "Get or set elevated access (off|on|ask|full)", - Args: "[level]", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnElevated, -}) - -func fnElevated(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - if len(ce.Args) == 0 { - current := meta.ElevatedLevel - if current == "" { - current = "off" - } - ce.Reply("Elevated access: %s", current) - return - } - level, ok := stringutil.NormalizeElevatedLevel(ce.Args[0]) - if !ok { - ce.Reply("Usage: `!ai elevated off|on|ask|full`") - return - } - meta.ElevatedLevel = level - client.savePortalQuiet(ce.Ctx, ce.Portal, "elevated change") - ce.Reply("%s", formatElevatedAck(level)) -} - -// CommandActivation handles the !ai activation command. -var _ = registerAICommand(commandregistry.Definition{ - Name: "activation", - Description: "Set group activation policy (mention|always)", - Args: "", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnActivation, -}) - -func fnActivation(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - isGroup := client.isGroupChat(ce.Ctx, ce.Portal) - if !isGroup { - ce.Reply("%s", formatSystemAck("Group activation only applies to group chats.")) - return - } - if len(ce.Args) == 0 { - ce.Reply("%s", formatSystemAck("Usage: `!ai activation mention|always`")) - return - } - level, ok := stringutil.NormalizeEnum(ce.Args[0], groupActivationAliases) - if !ok { - ce.Reply("%s", formatSystemAck("Usage: `!ai activation mention|always`")) - return - } - meta.GroupActivation = level - meta.GroupActivationNeedsIntro = true - meta.GroupIntroSent = false - client.savePortalQuiet(ce.Ctx, ce.Portal, "activation change") - ce.Reply("%s", formatSystemAck(fmt.Sprintf("Group activation set to %s.", level))) -} - -// CommandSend handles the !ai send command. -var _ = registerAICommand(commandregistry.Definition{ - Name: "send", - Description: "Allow/deny sending messages (on|off|inherit)", - Args: "", - Section: HelpSectionAI, - RequiresPortal: true, - RequiresLogin: true, - Handler: fnSend, -}) - -func fnSend(ce *commands.Event) { - client, meta, ok := requireClientMeta(ce) - if !ok { - return - } - if len(ce.Args) == 0 { - ce.Reply("%s", formatSystemAck("Usage: `!ai send on|off|inherit`")) - return - } - mode, ok := stringutil.NormalizeEnum(ce.Args[0], sendPolicyAliases) - if !ok { - ce.Reply("%s", formatSystemAck("Usage: `!ai send on|off|inherit`")) - return - } - if mode == "inherit" { - meta.SendPolicy = "" - } else { - meta.SendPolicy = mode - } - client.savePortalQuiet(ce.Ctx, ce.Portal, "send policy change") - label := mode - if mode == "allow" { - label = "on" - } else if mode == "deny" { - label = "off" - } - ce.Reply("%s", formatSystemAck(fmt.Sprintf("Send policy set to %s.", label))) -} - -// CommandWhoami handles the !ai whoami command. -var _ = registerAICommand(commandregistry.Definition{ - Name: "whoami", - Description: "Show your Matrix user ID", - Section: HelpSectionAI, - RequiresPortal: false, - RequiresLogin: false, - Handler: fnWhoami, -}) - -func fnWhoami(ce *commands.Event) { - if ce == nil || ce.User == nil { - return - } - ce.Reply("You are %s.", ce.User.MXID.String()) -} diff --git a/pkg/connector/commands_simple.go b/pkg/connector/commands_simple.go deleted file mode 100644 index cf7377bb..00000000 --- a/pkg/connector/commands_simple.go +++ /dev/null @@ -1,86 +0,0 @@ -package connector - -import ( - "fmt" - "strings" - - "maunium.net/go/mautrix/bridgev2/commands" - - "github.com/beeper/ai-bridge/pkg/connector/commandregistry" -) - -// CommandSimple handles the !ai simple command with sub-commands. -var _ = registerAICommand(commandregistry.Definition{ - Name: "simple", - Description: "Manage AI chat rooms (new, list)", - Args: "", - Section: HelpSectionAI, - RequiresLogin: true, - Handler: fnSimple, -}) - -func fnSimple(ce *commands.Event) { - client, ok := requireClient(ce) - if !ok { - return - } - - subCmd := "" - if len(ce.Args) > 0 { - subCmd = strings.ToLower(ce.Args[0]) - } - - switch subCmd { - case "new": - var modelID string - if len(ce.Args) > 1 { - resolved, valid, err := client.resolveModelID(ce.Ctx, ce.Args[1]) - if err != nil || !valid || resolved == "" { - ce.Reply("That model isn't available: %s", ce.Args[1]) - return - } - modelID = resolved - } else { - modelID = client.effectiveModel(nil) - } - go client.createAndOpenSimpleChat(ce.Ctx, ce.Portal, modelID) - ce.Reply("Creating AI chat with %s...", modelID) - - case "list": - models, err := client.listAvailableModels(ce.Ctx, false) - if err != nil { - ce.Reply("Couldn't load models.") - return - } - var sb strings.Builder - sb.WriteString("Available models:\n\n") - for _, m := range models { - var caps []string - if m.SupportsVision { - caps = append(caps, "Vision") - } - if m.SupportsReasoning { - caps = append(caps, "Reasoning") - } - if m.SupportsWebSearch { - caps = append(caps, "Web Search") - } - if m.SupportsImageGen { - caps = append(caps, "Image Gen") - } - if m.SupportsToolCalling { - caps = append(caps, "Tools") - } - sb.WriteString(fmt.Sprintf("• **%s** (`%s`)\n", m.Name, m.ID)) - if len(caps) > 0 { - sb.WriteString(fmt.Sprintf(" %s\n", strings.Join(caps, " · "))) - } - sb.WriteString("\n") - } - sb.WriteString("Use `!ai simple new [model]` to create a chat") - ce.Reply(sb.String()) - - default: - ce.Reply("Usage:\n• `!ai simple new [model]` — Create a new AI chat\n• `!ai simple list` — List available models") - } -} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 5b3c0f46..7f5bd399 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -2,13 +2,11 @@ package connector import ( "context" - "encoding/json" "fmt" "strings" "sync" "time" - "github.com/rs/zerolog" "go.mau.fi/util/configupgrade" "go.mau.fi/util/dbutil" @@ -145,7 +143,7 @@ func (oc *OpenAIConnector) SetLocalAIBridgeLogin(userMXID id.UserID, accessToken } } -// registerCustomEventHandlers registers handlers for custom Matrix state events +// registerCustomEventHandlers registers connector-owned event handlers. func (oc *OpenAIConnector) registerCustomEventHandlers() { // Type assert the Matrix connector to get the concrete type with EventProcessor matrixConnector, ok := oc.br.Matrix.(*matrix.Connector) @@ -154,200 +152,10 @@ func (oc *OpenAIConnector) registerCustomEventHandlers() { return } - // Register handler for direct room settings state events - matrixConnector.EventProcessor.On(RoomSettingsEventType, oc.handleRoomSettingsEvent) - - // Register handler for BeeperSendState wrapper events (desktop E2EE state updates) - matrixConnector.EventProcessor.On(event.BeeperSendState, oc.handleBeeperSendStateEvent) - // Register handler for internal scheduler delayed ticks. matrixConnector.EventProcessor.On(ScheduleTickEventType, oc.handleScheduleTickEvent) - oc.br.Log.Info(). - Str("beeper_send_state_type", event.BeeperSendState.Type). - Str("beeper_send_state_class", event.BeeperSendState.Class.Name()). - Msg("Registered room settings event handlers (direct and BeeperSendState)") -} - -// handleRoomSettingsEvent processes Matrix room settings state events from users -func (oc *OpenAIConnector) handleRoomSettingsEvent(ctx context.Context, evt *event.Event) { - log := oc.br.Log.With(). - Str("component", "room_settings_handler"). - Str("room_id", evt.RoomID.String()). - Str("sender", evt.Sender.String()). - Logger() - - // Parse event content - var content RoomSettingsEventContent - if err := json.Unmarshal(evt.Content.VeryRaw, &content); err != nil { - log.Warn().Err(err).Msg("Failed to parse room settings event content") - return - } - - oc.processRoomSettingsContent(ctx, evt, &content, log) -} - -// processRoomSettingsContent handles the common logic for updating portal settings -// Called by both handleRoomSettingsEvent and handleBeeperSendStateEvent -func (oc *OpenAIConnector) processRoomSettingsContent( - ctx context.Context, - evt *event.Event, - content *RoomSettingsEventContent, - log zerolog.Logger, -) { - if evt == nil { - return - } - roomID := evt.RoomID - sender := evt.Sender - // Look up portal by Matrix room ID - portal, err := oc.br.GetPortalByMXID(ctx, roomID) - if err != nil { - log.Err(err).Msg("Failed to get portal for room settings event") - return - } - if portal == nil { - log.Debug().Msg("No portal found for room, ignoring settings event") - return - } - - // Get the user who sent the event and their login - user, err := oc.br.GetUserByMXID(ctx, sender) - if err != nil || user == nil { - log.Warn().Err(err).Msg("Failed to get user for room settings event") - return - } - - // Use getLoginForPortal to find the correct login based on portal's receiver - // This ensures we use the right provider when user has multiple accounts - login := oc.getLoginForPortal(ctx, user, portal) - if login == nil { - log.Warn().Msg("User has no active login, cannot process settings") - return - } - - client, ok := login.Client.(*AIClient) - if !ok || client == nil { - log.Warn().Msg("Invalid client type for user login") - return - } - - // Validate model if specified - if content.Model != "" { - resolved, valid, err := client.resolveModelID(ctx, content.Model) - if err != nil { - log.Warn().Err(err).Str("model", content.Model).Msg("Failed to validate model") - } else if !valid { - log.Warn().Str("model", content.Model).Msg("Invalid model specified, ignoring") - client.sendSystemNotice(ctx, portal, fmt.Sprintf("That model isn't available: %s. Settings weren't applied.", content.Model)) - return - } - content.Model = resolved - } - - // Update portal metadata with optimistic update + rollback behavior. - if err := client.updatePortalConfig(ctx, portal, content); err != nil { - sendStateEventFailureStatus(ctx, portal, evt, err) - log.Warn().Err(err).Msg("Failed to apply room settings state event") - return - } - - sendStateEventSuccessStatus(ctx, portal, evt) - - // Send confirmation notice - var changes []string - if content.Model != "" { - changes = append(changes, fmt.Sprintf("model=%s", content.Model)) - } - if content.Temperature != nil { - changes = append(changes, fmt.Sprintf("temperature=%.2f", *content.Temperature)) - } - if content.MaxContextMessages > 0 { - changes = append(changes, fmt.Sprintf("context=%d messages", content.MaxContextMessages)) - } - if content.MaxCompletionTokens > 0 { - changes = append(changes, fmt.Sprintf("max_tokens=%d", content.MaxCompletionTokens)) - } - if content.SystemPrompt != "" { - changes = append(changes, "system_prompt updated") - } - if content.ReasoningEffort != "" { - changes = append(changes, fmt.Sprintf("reasoning_effort=%s", content.ReasoningEffort)) - } - if len(changes) > 0 { - client.sendSystemNotice(ctx, portal, fmt.Sprintf("Configuration updated: %s", strings.Join(changes, ", "))) - } - - logEvent := log.Info().Str("model", content.Model) - if content.Temperature != nil { - logEvent = logEvent.Float64("temperature", *content.Temperature) - } - logEvent.Msg("Updated room settings from state event") -} - -// handleBeeperSendStateEvent processes com.beeper.send_state wrapper events -// This is used by the desktop client to send state events in encrypted rooms -func (oc *OpenAIConnector) handleBeeperSendStateEvent(ctx context.Context, evt *event.Event) { - log := oc.br.Log.With(). - Str("component", "beeper_send_state_handler"). - Str("room_id", evt.RoomID.String()). - Str("sender", evt.Sender.String()). - Str("event_type", evt.Type.Type). - Str("event_class", evt.Type.Class.Name()). - Logger() - - log.Info().RawJSON("raw_content", evt.Content.VeryRaw).Msg("Received BeeperSendState event") - - // Parse the wrapper content - var wrapperContent event.BeeperSendStateEventContent - if err := json.Unmarshal(evt.Content.VeryRaw, &wrapperContent); err != nil { - log.Debug().Err(err).Msg("Failed to parse BeeperSendState content") - return - } - - // Only process AI room settings events - if wrapperContent.Type != RoomSettingsEventType.Type { - return - } - - log.Debug(). - Str("inner_type", wrapperContent.Type). - Str("state_key", wrapperContent.StateKey). - Msg("Processing BeeperSendState wrapper for AI room settings") - - // Parse the inner room settings content - var content RoomSettingsEventContent - if err := json.Unmarshal(wrapperContent.Content.VeryRaw, &content); err != nil { - log.Warn().Err(err).Msg("Failed to parse inner room settings content") - return - } - - // Reuse existing handler logic with the parsed content - oc.processRoomSettingsContent(ctx, evt, &content, log) -} - -func sendStateEventFailureStatus(ctx context.Context, portal *bridgev2.Portal, evt *event.Event, err error) { - if portal == nil || portal.Bridge == nil || evt == nil || err == nil { - return - } - msgStatus := bridgev2.WrapErrorInStatus(err). - WithStatus(event.MessageStatusRetriable). - WithErrorReason(event.MessageStatusGenericError). - WithMessage("Failed to apply room settings. Your change was rolled back."). - WithIsCertain(true). - WithSendNotice(false) - portal.Bridge.Matrix.SendMessageStatus(ctx, &msgStatus, bridgev2.StatusEventInfoFromEvent(evt)) -} - -func sendStateEventSuccessStatus(ctx context.Context, portal *bridgev2.Portal, evt *event.Event) { - if portal == nil || portal.Bridge == nil || evt == nil { - return - } - msgStatus := bridgev2.MessageStatus{ - Status: event.MessageStatusSuccess, - IsCertain: true, - } - portal.Bridge.Matrix.SendMessageStatus(ctx, &msgStatus, bridgev2.StatusEventInfoFromEvent(evt)) + oc.br.Log.Info().Msg("Registered connector event handlers") } func (oc *OpenAIConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { @@ -434,31 +242,3 @@ func (oc *OpenAIConnector) CreateLogin(ctx context.Context, user *bridgev2.User, } return &OpenAILogin{User: user, Connector: oc, FlowID: flowID}, nil } - -// getLoginForPortal finds the correct user login based on the portal's Receiver. -// This ensures we use the correct provider/API credentials when a user has multiple accounts. -func (oc *OpenAIConnector) getLoginForPortal(ctx context.Context, user *bridgev2.User, portal *bridgev2.Portal) *bridgev2.UserLogin { - if portal == nil { - return oc.getPreferredUserLogin(ctx, user) - } - - // The portal's Receiver field contains the UserLogin ID that owns this portal - receiverID := portal.Receiver - if receiverID == "" { - oc.br.Log.Warn().Stringer("portal", portal.PortalKey).Msg("Portal has no receiver, using default login") - return oc.getPreferredUserLogin(ctx, user) - } - - // Get the specific login that matches the portal's receiver - login, err := oc.br.GetExistingUserLoginByID(ctx, receiverID) - if err != nil || login == nil { - oc.br.Log.Warn(). - Err(err). - Stringer("portal", portal.PortalKey). - Str("receiver", string(receiverID)). - Msg("Failed to get login for portal receiver, using default login") - return oc.getPreferredUserLogin(ctx, user) - } - - return login -} diff --git a/pkg/connector/defaults_alignment_test.go b/pkg/connector/defaults_alignment_test.go index 86f492aa..1f1afc3f 100644 --- a/pkg/connector/defaults_alignment_test.go +++ b/pkg/connector/defaults_alignment_test.go @@ -1,6 +1,11 @@ package connector -import "testing" +import ( + "testing" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" +) func TestEffectiveTemperatureDefaultUnset(t *testing.T) { client := &AIClient{} @@ -10,11 +15,22 @@ func TestEffectiveTemperatureDefaultUnset(t *testing.T) { } func TestDefaultThinkLevelModelAware(t *testing.T) { - client := &AIClient{} + client := &AIClient{ + connector: &OpenAIConnector{}, + UserLogin: &bridgev2.UserLogin{UserLogin: &database.UserLogin{Metadata: &UserLoginMetadata{ + Provider: ProviderOpenRouter, + ModelCache: &ModelCache{Models: []ModelInfo{ + {ID: "openai/o4-mini", SupportsReasoning: true}, + {ID: "openai/gpt-4o-mini", SupportsReasoning: false}, + }}, + }}}, + } reasoningMeta := &PortalMetadata{ - Capabilities: ModelCapabilities{ - SupportsReasoning: true, + ResolvedTarget: &ResolvedTarget{ + Kind: ResolvedTargetModel, + GhostID: modelUserID("openai/o4-mini"), + ModelID: "openai/o4-mini", }, } if got := client.defaultThinkLevel(reasoningMeta); got != "low" { @@ -22,39 +38,13 @@ func TestDefaultThinkLevelModelAware(t *testing.T) { } nonReasoningMeta := &PortalMetadata{ - Capabilities: ModelCapabilities{ - SupportsReasoning: false, + ResolvedTarget: &ResolvedTarget{ + Kind: ResolvedTargetModel, + GhostID: modelUserID("openai/gpt-4o-mini"), + ModelID: "openai/gpt-4o-mini", }, } if got := client.defaultThinkLevel(nonReasoningMeta); got != "off" { t.Fatalf("expected off for non-reasoning models, got %q", got) } } - -func TestDefaultThinkLevelHonorsExplicitThinkingLevel(t *testing.T) { - client := &AIClient{} - meta := &PortalMetadata{ - ThinkingLevel: "high", - Capabilities: ModelCapabilities{ - SupportsReasoning: true, - }, - } - - if got := client.defaultThinkLevel(meta); got != "high" { - t.Fatalf("expected explicit thinking level to win, got %q", got) - } -} - -func TestDefaultThinkLevelUsesReasoningEffortFallback(t *testing.T) { - client := &AIClient{} - meta := &PortalMetadata{ - Capabilities: ModelCapabilities{ - SupportsReasoning: true, - }, - ReasoningEffort: "medium", - } - - if got := client.defaultThinkLevel(meta); got != "medium" { - t.Fatalf("expected medium from reasoning effort, got %q", got) - } -} diff --git a/pkg/connector/desktop_api_helpers.go b/pkg/connector/desktop_api_helpers.go new file mode 100644 index 00000000..a3cb81ac --- /dev/null +++ b/pkg/connector/desktop_api_helpers.go @@ -0,0 +1,36 @@ +package connector + +import ( + "errors" + "strings" +) + +func parseDesktopAPIAddArgs(args []string) (name, token, baseURL string, err error) { + if len(args) == 0 { + return "", "", "", errors.New("missing args") + } + + trimmed := make([]string, 0, len(args)) + for _, raw := range args { + part := strings.TrimSpace(raw) + if part != "" { + trimmed = append(trimmed, part) + } + } + if len(trimmed) == 0 { + return "", "", "", errors.New("missing args") + } + + if len(trimmed) == 1 { + return "", trimmed[0], "", nil + } + + if len(trimmed) == 2 { + if isLikelyHTTPURL(trimmed[1]) { + return "", trimmed[0], trimmed[1], nil + } + return normalizeDesktopInstanceName(trimmed[0]), trimmed[1], "", nil + } + + return normalizeDesktopInstanceName(trimmed[0]), trimmed[1], strings.TrimSpace(strings.Join(trimmed[2:], " ")), nil +} diff --git a/pkg/connector/error_logging.go b/pkg/connector/error_logging.go index dd1191c6..6b0f9646 100644 --- a/pkg/connector/error_logging.go +++ b/pkg/connector/error_logging.go @@ -46,9 +46,7 @@ func addRequestSummary(event *zerolog.Event, meta *PortalMetadata, messages []op event.Int("message_count", len(messages)) event.Bool("has_audio", hasAudioContent(messages)) event.Bool("has_multimodal", hasMultimodalContent(messages)) - if meta != nil { - event.Bool("tool_calling", meta.Capabilities.SupportsToolCalling) - } + _ = meta } func addResponsesParamsSummary(event *zerolog.Event, params responses.ResponseNewParams) { diff --git a/pkg/connector/events.go b/pkg/connector/events.go index 8e63c6f7..a1ed4bf0 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -13,9 +13,6 @@ import ( // init registers custom AI event types with mautrix's TypeMap // so the state store can properly parse them during sync func init() { - event.TypeMap[RoomCapabilitiesEventType] = reflect.TypeOf(RoomCapabilitiesEventContent{}) - event.TypeMap[RoomSettingsEventType] = reflect.TypeOf(RoomSettingsEventContent{}) - event.TypeMap[ModelCapabilitiesEventType] = reflect.TypeOf(ModelCapabilitiesEventContent{}) event.TypeMap[AgentsEventType] = reflect.TypeOf(AgentsEventContent{}) } @@ -25,17 +22,6 @@ var StreamEventMessageType = matrixevents.StreamEventMessageType // CompactionStatusEventType notifies clients about context compaction var CompactionStatusEventType = matrixevents.CompactionStatusEventType -// RoomCapabilitiesEventType is the Matrix state event type for bridge-controlled capabilities -// Protected by power levels (100) so only the bridge bot can modify -var RoomCapabilitiesEventType = matrixevents.RoomCapabilitiesEventType - -// RoomSettingsEventType is the Matrix state event type for user-editable settings -// Normal power level (0) so users can modify -var RoomSettingsEventType = matrixevents.RoomSettingsEventType - -// ModelCapabilitiesEventType is the Matrix state event type for broadcasting available models -var ModelCapabilitiesEventType = matrixevents.ModelCapabilitiesEventType - // AgentsEventType configures active agents in a room var AgentsEventType = matrixevents.AgentsEventType @@ -69,82 +55,29 @@ const ( ToolTypeMCP = matrixevents.ToolTypeMCP ) -// ReasoningEffortOption represents an available reasoning effort level -type ReasoningEffortOption struct { - Value string `json:"value"` // minimal, low, medium, high, xhigh - Label string `json:"label"` // Display name -} - -// SettingSource indicates where a setting value came from +// SettingSource indicates where a setting or availability decision came from. type SettingSource string const ( SourceAgentPolicy SettingSource = "agent_policy" - SourceRoomOverride SettingSource = "room_override" - SourceUserDefault SettingSource = "user_default" SourceProviderConfig SettingSource = "provider_config" SourceGlobalDefault SettingSource = "global_default" SourceModelLimit SettingSource = "model_limitation" SourceProviderLimit SettingSource = "provider_limitation" ) -// SettingExplanation describes why a setting has its current value -type SettingExplanation struct { - Value any `json:"value"` - Source SettingSource `json:"source"` - Reason string `json:"reason,omitempty"` // Only when limited/unavailable -} - -// EffectiveSettings shows current values with source explanations -type EffectiveSettings struct { - Model SettingExplanation `json:"model"` - SystemPrompt SettingExplanation `json:"system_prompt"` - Temperature SettingExplanation `json:"temperature"` - ReasoningEffort SettingExplanation `json:"reasoning_effort"` -} - -// RoomCapabilitiesEventContent represents bridge-controlled room capabilities -// This is protected by power levels (100) so only the bridge bot can modify -type RoomCapabilitiesEventContent struct { - Capabilities *ModelCapabilities `json:"capabilities,omitempty"` - AvailableTools []ToolInfo `json:"available_tools,omitempty"` - ReasoningEffortOptions []ReasoningEffortOption `json:"reasoning_effort_options,omitempty"` - Provider string `json:"provider,omitempty"` - EffectiveSettings *EffectiveSettings `json:"effective_settings,omitempty"` -} - -// RoomSettingsEventContent represents user-editable room settings -// This uses normal power levels (0) so users can modify -type RoomSettingsEventContent struct { - Model string `json:"model,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxContextMessages int `json:"max_context_messages,omitempty"` - MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - AgentID string `json:"agent_id,omitempty"` - EmitThinking *bool `json:"emit_thinking,omitempty"` - EmitToolArgs *bool `json:"emit_tool_args,omitempty"` -} - -// ToolInfo describes a tool and its status for room state broadcasting +// ToolInfo describes a tool and its status for internal UI/config rendering. type ToolInfo struct { Name string `json:"name"` - DisplayName string `json:"display_name"` // Human-readable name for UI - Type string `json:"type"` // "builtin", "provider", "plugin", "mcp" + DisplayName string `json:"display_name"` + Type string `json:"type"` Description string `json:"description,omitempty"` Enabled bool `json:"enabled"` - Available bool `json:"available"` // Based on model capabilities and provider - Source SettingSource `json:"source,omitempty"` // Where enabled state came from - Reason string `json:"reason,omitempty"` // Only when limited/unavailable -} - -// ModelCapabilitiesEventContent represents available models and their capabilities -type ModelCapabilitiesEventContent struct { - AvailableModels []ModelInfo `json:"available_models"` + Available bool `json:"available"` + Source SettingSource `json:"source,omitempty"` + Reason string `json:"reason,omitempty"` } -// Tool constants for model capabilities const ( ToolWebSearch = "web_search" ToolFunctionCalling = "function_calling" diff --git a/pkg/connector/group_activation.go b/pkg/connector/group_activation.go index 822b3c60..07a5db1b 100644 --- a/pkg/connector/group_activation.go +++ b/pkg/connector/group_activation.go @@ -1,17 +1,9 @@ package connector -import ( - "strings" - - "github.com/beeper/ai-bridge/pkg/shared/stringutil" -) +import "github.com/beeper/ai-bridge/pkg/shared/stringutil" func (oc *AIClient) resolveGroupActivation(meta *PortalMetadata) string { - if meta != nil { - if normalized, ok := stringutil.NormalizeEnum(meta.GroupActivation, groupActivationAliases); ok { - return normalized - } - } + _ = meta if oc != nil && oc.connector != nil && oc.connector.Config.Messages != nil && oc.connector.Config.Messages.GroupChat != nil { if normalized, ok := stringutil.NormalizeEnum(oc.connector.Config.Messages.GroupChat.Activation, groupActivationAliases); ok { return normalized @@ -19,14 +11,3 @@ func (oc *AIClient) resolveGroupActivation(meta *PortalMetadata) string { } return "mention" } - -func normalizeSendPolicyMode(raw string) string { - value := strings.ToLower(strings.TrimSpace(raw)) - if value == "deny" || value == "off" { - return "deny" - } - if value == "allow" || value == "on" { - return "allow" - } - return "" -} diff --git a/pkg/connector/handleai.go b/pkg/connector/handleai.go index 04c6f8d0..ce9da56a 100644 --- a/pkg/connector/handleai.go +++ b/pkg/connector/handleai.go @@ -219,19 +219,13 @@ func isInternalControlRoom(meta *PortalMetadata) bool { if meta == nil { return false } - return meta.IsBuilderRoom || isModuleInternalRoom(meta) + return isModuleInternalRoom(meta) } func autoGreetingBlockReason(meta *PortalMetadata) string { - sendPolicy := "" - if meta != nil { - sendPolicy = meta.SendPolicy - } switch { case isInternalControlRoom(meta): return "internal-control-room" - case normalizeSendPolicyMode(sendPolicy) == "deny": - return "send-policy-deny" case resolveAgentID(meta) == "": return "no-agent" } @@ -369,19 +363,16 @@ func (oc *AIClient) sendWelcomeMessage(ctx context.Context, portal *bridgev2.Por // Still send the welcome notice and schedule greeting; duplicates are preferable to missing UX. } - if meta.AgentID == "" { - displayName := modelContactName(meta.Model, oc.findModelInfo(meta.Model)) + if resolveAgentID(meta) == "" { + modelID := oc.effectiveModel(meta) + displayName := modelContactName(modelID, oc.findModelInfo(modelID)) oc.sendSystemNotice(bgCtx, portal, fmt.Sprintf("You are chatting with %s. AI can make mistakes.", displayName)) } else { oc.sendSystemNotice(bgCtx, portal, "AI can make mistakes.") } - // Ensure initial room state exists for clients (model/settings/capabilities). - // Only broadcast once on first-room initialization. - if meta.LastRoomStateSync == 0 { - if err := oc.BroadcastRoomState(bgCtx, portal); err != nil { - oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to broadcast initial room state") - } + if err := oc.BroadcastRoomState(bgCtx, portal); err != nil { + oc.loggerForContext(ctx).Warn().Err(err).Msg("Failed to broadcast room state") } oc.scheduleAutoGreeting(bgCtx, portal) @@ -638,29 +629,3 @@ func (oc *AIClient) getModelContextWindow(meta *PortalMetadata) int { // Default for unknown models return 128000 } - -// This is separate from room topic (which is display-only). -func (oc *AIClient) setRoomSystemPrompt(ctx context.Context, portal *bridgev2.Portal, prompt string) error { - return oc.setRoomSystemPromptInternal(ctx, portal, prompt, true) -} - -func (oc *AIClient) setRoomSystemPromptNoSave(ctx context.Context, portal *bridgev2.Portal, prompt string) error { - return oc.setRoomSystemPromptInternal(ctx, portal, prompt, false) -} - -func (oc *AIClient) setRoomSystemPromptInternal(ctx context.Context, portal *bridgev2.Portal, prompt string, save bool) error { - if portal.MXID == "" { - return errors.New("portal has no Matrix room ID") - } - - meta := portalMeta(portal) - meta.SystemPrompt = prompt - - if save { - if err := portal.Save(ctx); err != nil { - return fmt.Errorf("failed to save portal: %w", err) - } - oc.loggerForContext(ctx).Debug().Str("prompt_len", fmt.Sprintf("%d", len(prompt))).Msg("Set room system prompt") - } - return nil -} diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 39b13999..aa681fb6 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -1207,183 +1207,6 @@ func (oc *AIClient) removeAckReaction(ctx context.Context, portal *bridgev2.Port Msg("Queued ack reaction removal") } -// handleToolsCommand handles the !ai tools command for per-tool management -func (oc *AIClient) handleToolsCommand( - ctx context.Context, - portal *bridgev2.Portal, - meta *PortalMetadata, - arg string, -) { - runCtx := oc.backgroundContext(ctx) - - // No args - show status - if arg == "" { - oc.showToolsStatus(runCtx, portal, meta) - return - } - - action, _, _ := strings.Cut(arg, " ") - action = strings.ToLower(action) - - switch action { - case "list": - oc.showToolsStatus(runCtx, portal, meta) - case "on", "enable", "true", "1", "off", "disable", "false", "0": - oc.sendSystemNotice(runCtx, portal, "Per-tool toggles aren't supported anymore. Update tool policy in agent settings or the global tool_policy config.") - default: - oc.sendSystemNotice(runCtx, portal, "Usage:\n"+ - "• !ai tools - Show current tool status\n"+ - "• !ai tools list - List available tools\n"+ - "Tool toggles are managed by tool policy.") - } -} - -// showToolsStatus displays the current status of all tools -func (oc *AIClient) showToolsStatus(ctx context.Context, portal *bridgev2.Portal, meta *PortalMetadata) { - oc.sendSystemNotice(ctx, portal, oc.buildToolsStatusText(meta)) -} - -// handleRegenerate regenerates the last AI response -func (oc *AIClient) handleRegenerate( - ctx context.Context, - evt *event.Event, - portal *bridgev2.Portal, - meta *PortalMetadata, -) { - runCtx := oc.backgroundContext(ctx) - - // Get message history - history, err := oc.UserLogin.Bridge.DB.Message.GetLastNInPortal(runCtx, portal.PortalKey, 10) - if err != nil || len(history) == 0 { - oc.sendSystemNotice(runCtx, portal, "No messages to regenerate from.") - return - } - - // Find the last user message - var lastUserMessage *database.Message - for _, msg := range history { - msgMeta := messageMeta(msg) - if msgMeta != nil && msgMeta.Role == "user" { - lastUserMessage = msg - break - } - } - - if lastUserMessage == nil { - oc.sendSystemNotice(runCtx, portal, "No user message found to regenerate from.") - return - } - - userMeta := messageMeta(lastUserMessage) - if userMeta == nil || userMeta.Body == "" { - oc.sendSystemNotice(runCtx, portal, "Can't regenerate: message content isn't available.") - return - } - - oc.sendSystemNotice(runCtx, portal, "Regenerating response...") - - // Build prompt excluding the old assistant response - promptContext, err := oc.buildContextForRegenerate(runCtx, portal, meta, userMeta.Body, lastUserMessage.MXID) - if err != nil { - oc.sendSystemNotice(runCtx, portal, "Couldn't regenerate: "+err.Error()) - return - } - - queueSettings, _, _, _ := oc.resolveQueueSettingsForPortal(runCtx, portal, meta, "", airuntime.QueueInlineOptions{}) - isGroup := oc.isGroupChat(runCtx, portal) - pending := pendingMessage{ - Event: evt, - Portal: portal, - Meta: meta, - Type: pendingTypeRegenerate, - MessageBody: userMeta.Body, - SourceEventID: lastUserMessage.MXID, - Typing: &TypingContext{ - IsGroup: isGroup, - WasMentioned: true, - }, - } - queueItem := pendingQueueItem{ - pending: pending, - messageID: string(evt.ID), - summaryLine: userMeta.Body, - enqueuedAt: time.Now().UnixMilli(), - } - oc.dispatchOrQueueWithStatus(runCtx, evt, portal, meta, queueItem, queueSettings, promptContext) -} - -// handleRegenerateTitle regenerates the current room title from recent messages. -func (oc *AIClient) handleRegenerateTitle( - ctx context.Context, - portal *bridgev2.Portal, -) { - runCtx := oc.backgroundContext(ctx) - - history, err := oc.UserLogin.Bridge.DB.Message.GetLastNInPortal(runCtx, portal.PortalKey, 20) - if err != nil || len(history) == 0 { - oc.sendSystemNotice(runCtx, portal, "No messages to generate a title from.") - return - } - - var lastUserMessage *database.Message - var lastAssistantMessage *database.Message - for _, msg := range history { - msgMeta := messageMeta(msg) - if !shouldIncludeInHistory(msgMeta) { - continue - } - if lastAssistantMessage == nil && msgMeta.Role == "assistant" { - lastAssistantMessage = msg - } - if lastUserMessage == nil && msgMeta.Role == "user" { - lastUserMessage = msg - } - if lastUserMessage != nil && lastAssistantMessage != nil { - break - } - } - - if lastUserMessage == nil { - oc.sendSystemNotice(runCtx, portal, "No user message found to generate a title from.") - return - } - - userMeta := messageMeta(lastUserMessage) - if userMeta == nil || userMeta.Body == "" { - oc.sendSystemNotice(runCtx, portal, "Can't generate a title: message content isn't available.") - return - } - - assistantBody := "" - if lastAssistantMessage != nil { - assistantMeta := messageMeta(lastAssistantMessage) - if assistantMeta != nil { - assistantBody = assistantMeta.Body - } - } - - oc.sendSystemNotice(runCtx, portal, "Regenerating title...") - - title, err := oc.generateRoomTitle(runCtx, userMeta.Body, assistantBody) - if err != nil { - oc.sendSystemNotice(runCtx, portal, "Couldn't generate a title: "+err.Error()) - return - } - - title = strings.TrimSpace(title) - if title == "" { - oc.sendSystemNotice(runCtx, portal, "Couldn't generate a title: empty response.") - return - } - - if err := oc.setRoomName(runCtx, portal, title); err != nil { - oc.sendSystemNotice(runCtx, portal, "Couldn't set the room title: "+err.Error()) - return - } - - oc.sendSystemNotice(runCtx, portal, fmt.Sprintf("Room title updated to: %s", title)) -} - // buildPromptForRegenerate builds a prompt for regeneration, excluding the last assistant message func (oc *AIClient) buildContextForRegenerate( ctx context.Context, diff --git a/pkg/connector/heartbeat_context.go b/pkg/connector/heartbeat_context.go index 8241b522..c4f49d6f 100644 --- a/pkg/connector/heartbeat_context.go +++ b/pkg/connector/heartbeat_context.go @@ -14,7 +14,6 @@ type HeartbeatRunConfig struct { UseIndicator bool IncludeReasoning bool ExecEvent bool - ResponsePrefix string SessionKey string StoreAgentID string PrevUpdatedAt int64 diff --git a/pkg/connector/heartbeat_delivery.go b/pkg/connector/heartbeat_delivery.go index 6f177c06..7850970b 100644 --- a/pkg/connector/heartbeat_delivery.go +++ b/pkg/connector/heartbeat_delivery.go @@ -44,7 +44,7 @@ func (oc *AIClient) resolveHeartbeatDeliveryTarget(agentID string, heartbeat *He if target.Portal != nil && target.RoomID != "" { // Stale agent routing guard: skip if portal is now assigned to a // different agent (matches resolveHeartbeatSessionPortal behavior). - if meta := portalMeta(target.Portal); meta != nil && normalizeAgentID(meta.AgentID) != normalizeAgentID(agentID) { + if meta := portalMeta(target.Portal); meta != nil && normalizeAgentID(resolveAgentID(meta)) != normalizeAgentID(agentID) { // Fall through to lastActivePortal / defaultChatPortal. } else { return target diff --git a/pkg/connector/heartbeat_execute.go b/pkg/connector/heartbeat_execute.go index 8f65cb11..00c52ade 100644 --- a/pkg/connector/heartbeat_execute.go +++ b/pkg/connector/heartbeat_execute.go @@ -148,13 +148,6 @@ func (oc *AIClient) runHeartbeatOnce(agentID string, heartbeat *HeartbeatConfig, if promptMeta == nil { promptMeta = &PortalMetadata{} } - promptMeta.AgentID = agentID - if heartbeat != nil && heartbeat.Model != nil { - if model := strings.TrimSpace(*heartbeat.Model); model != "" { - promptMeta.Model = model - } - } - responsePrefix := resolveResponsePrefixForHeartbeat(oc, cfg, agentID, promptMeta) hbCfg := &HeartbeatRunConfig{ Reason: reason, AckMaxChars: resolveHeartbeatAckMaxChars(cfg, heartbeat), @@ -163,7 +156,6 @@ func (oc *AIClient) runHeartbeatOnce(agentID string, heartbeat *HeartbeatConfig, UseIndicator: visibility.UseIndicator, IncludeReasoning: heartbeat != nil && heartbeat.IncludeReasoning != nil && *heartbeat.IncludeReasoning, ExecEvent: hasExecCompletion, - ResponsePrefix: responsePrefix, SessionKey: storeKey, StoreAgentID: sessionResolution.StoreRef.AgentID, PrevUpdatedAt: prevUpdatedAt, @@ -329,7 +321,7 @@ func (oc *AIClient) resolveHeartbeatSessionPortal(agentID string, heartbeat *Hea } if strings.HasPrefix(session, "!") { if portal := oc.portalByRoomID(context.Background(), id.RoomID(session)); portal != nil { - if meta := portalMeta(portal); meta == nil || normalizeAgentID(meta.AgentID) == normalizeAgentID(agentID) { + if meta := portalMeta(portal); meta == nil || normalizeAgentID(resolveAgentID(meta)) == normalizeAgentID(agentID) { return portal, portal.MXID.String(), nil } } @@ -360,7 +352,7 @@ func (oc *AIClient) heartbeatSessionPortalCandidate(agentID string, session hear if portal == nil { return nil } - if meta := portalMeta(portal); meta != nil && normalizeAgentID(meta.AgentID) != normalizeAgentID(agentID) { + if meta := portalMeta(portal); meta != nil && normalizeAgentID(resolveAgentID(meta)) != normalizeAgentID(agentID) { return nil } return portal diff --git a/pkg/connector/history_limit_test.go b/pkg/connector/history_limit_test.go index ec589443..4bae5b36 100644 --- a/pkg/connector/history_limit_test.go +++ b/pkg/connector/history_limit_test.go @@ -8,17 +8,6 @@ import ( "maunium.net/go/mautrix/bridgev2/database" ) -func TestHistoryLimitMetaOverrideWins(t *testing.T) { - client := &AIClient{} - portal := &bridgev2.Portal{Portal: &database.Portal{MXID: "!room:test", RoomType: database.RoomTypeGroupDM}} - meta := &PortalMetadata{MaxContextMessages: 7} - - limit := client.historyLimit(context.Background(), portal, meta) - if limit != 7 { - t.Fatalf("expected 7, got %d", limit) - } -} - func TestHistoryLimitDefaultsByRoomType(t *testing.T) { client := &AIClient{} diff --git a/pkg/connector/identifiers.go b/pkg/connector/identifiers.go index 45c45783..3a605a18 100644 --- a/pkg/connector/identifiers.go +++ b/pkg/connector/identifiers.go @@ -105,15 +105,53 @@ func humanUserID(loginID networkid.UserLoginID) networkid.UserID { return bridgeadapter.HumanUserID("openai-user", loginID) } +const ( + ResolvedTargetUnknown = "" + ResolvedTargetModel = "model" + ResolvedTargetAgent = "agent" +) + +type ResolvedTarget struct { + Kind string + GhostID networkid.UserID + ModelID string + AgentID string +} + +func resolveTargetFromGhostID(ghostID networkid.UserID) *ResolvedTarget { + if ghostID == "" { + return nil + } + if modelID := strings.TrimSpace(parseModelFromGhostID(string(ghostID))); modelID != "" { + return &ResolvedTarget{ + Kind: ResolvedTargetModel, + GhostID: ghostID, + ModelID: modelID, + } + } + if agentID, ok := parseAgentFromGhostID(string(ghostID)); ok && strings.TrimSpace(agentID) != "" { + return &ResolvedTarget{ + Kind: ResolvedTargetAgent, + GhostID: ghostID, + AgentID: strings.TrimSpace(agentID), + } + } + return nil +} + func portalMeta(portal *bridgev2.Portal) *PortalMetadata { - return bridgeadapter.EnsurePortalMetadata[PortalMetadata](portal) + meta := bridgeadapter.EnsurePortalMetadata[PortalMetadata](portal) + if meta != nil { + meta.ResolvedTarget = resolveTargetFromGhostID(portal.OtherUserID) + } + return meta } func resolveAgentID(meta *PortalMetadata) string { - if meta == nil { + if meta == nil || meta.ResolvedTarget == nil { return "" } - return meta.AgentID + return meta.ResolvedTarget.AgentID } func messageMeta(msg *database.Message) *MessageMetadata { diff --git a/pkg/connector/inbound_directive_apply.go b/pkg/connector/inbound_directive_apply.go deleted file mode 100644 index ecdf14df..00000000 --- a/pkg/connector/inbound_directive_apply.go +++ /dev/null @@ -1,73 +0,0 @@ -package connector - -import "fmt" - -func applyThinkingLevel(meta *PortalMetadata, level string) { - if meta == nil { - return - } - meta.ThinkingLevel = level - meta.EmitThinking = level != "off" - if level == "minimal" { - meta.ReasoningEffort = "low" - } else if level == "low" || level == "medium" || level == "high" || level == "xhigh" { - meta.ReasoningEffort = level - } -} - -func applyReasoningLevel(meta *PortalMetadata, level string) { - if meta == nil { - return - } - if level == "off" { - meta.EmitThinking = false - meta.ReasoningEffort = "" - return - } - if level == "on" { - meta.EmitThinking = true - return - } - meta.EmitThinking = true - meta.ReasoningEffort = level -} - -func formatThinkingAck(level string) string { - if level == "off" { - return "Thinking disabled." - } - return fmt.Sprintf("Thinking level set to %s.", level) -} - -func formatVerboseAck(level string) string { - switch level { - case "off": - return formatSystemAck("Verbose logging disabled.") - case "full": - return formatSystemAck("Verbose logging set to full.") - default: - return formatSystemAck("Verbose logging enabled.") - } -} - -func formatReasoningAck(level string) string { - switch level { - case "off": - return formatSystemAck("Reasoning visibility disabled.") - case "stream": - return formatSystemAck("Reasoning stream enabled (Telegram only).") - default: - return formatSystemAck("Reasoning visibility enabled.") - } -} - -func formatElevatedAck(level string) string { - switch level { - case "off": - return formatSystemAck("Elevated mode disabled.") - case "full": - return formatSystemAck("Elevated mode set to full (auto-approve).") - default: - return formatSystemAck("Elevated mode set to ask (approvals may still apply).") - } -} diff --git a/pkg/connector/inbound_prompt_runtime_test.go b/pkg/connector/inbound_prompt_runtime_test.go index 29ce5c50..d39b97cb 100644 --- a/pkg/connector/inbound_prompt_runtime_test.go +++ b/pkg/connector/inbound_prompt_runtime_test.go @@ -70,7 +70,7 @@ func TestBuildPromptWithLinkContext_SimpleModeSkipsInboundRuntimeMetadata(t *tes }, }, } - meta := &PortalMetadata{IsSimpleMode: true} + meta := simpleModeTestMeta("openai/gpt-5") ctx := withInboundContext(context.Background(), airuntime.InboundContext{ Provider: "matrix", Surface: "beeper-matrix", diff --git a/pkg/connector/integration_host.go b/pkg/connector/integration_host.go index e5f09040..aadaa9f1 100644 --- a/pkg/connector/integration_host.go +++ b/pkg/connector/integration_host.go @@ -280,7 +280,7 @@ func (h *runtimeIntegrationHost) IsInternalRoom(meta any) bool { if m == nil { return false } - return m.IsBuilderRoom || isModuleInternalRoom(m) + return isModuleInternalRoom(m) } func (h *runtimeIntegrationHost) PortalMeta(portal any) any { @@ -299,18 +299,6 @@ func (h *runtimeIntegrationHost) SetMetaField(meta any, key string, value any) { return } switch key { - case "AgentID": - if v, ok := value.(string); ok { - m.AgentID = v - } - case "Model": - if v, ok := value.(string); ok { - m.Model = strings.TrimSpace(v) - } - case "ReasoningEffort": - if v, ok := value.(string); ok { - m.ReasoningEffort = strings.TrimSpace(v) - } case "DisabledTools": if v, ok := value.([]string); ok { m.DisabledTools = v diff --git a/pkg/connector/integrations.go b/pkg/connector/integrations.go index 20657b63..8b47cff2 100644 --- a/pkg/connector/integrations.go +++ b/pkg/connector/integrations.go @@ -664,9 +664,6 @@ func integrationSessionKind(currentRoomID string, portalRoomID string, meta *Por if strings.TrimSpace(meta.SubagentParentRoomID) != "" { return "other" } - if meta.IsBuilderRoom { - return "other" - } } return "group" } diff --git a/pkg/connector/integrations_config.go b/pkg/connector/integrations_config.go index 95a00a30..3efb6ef3 100644 --- a/pkg/connector/integrations_config.go +++ b/pkg/connector/integrations_config.go @@ -161,15 +161,13 @@ type ChannelsConfig struct { } type ChannelDefaultsConfig struct { - Heartbeat *ChannelHeartbeatVisibilityConfig `yaml:"heartbeat"` - ResponsePrefix string `yaml:"responsePrefix"` + Heartbeat *ChannelHeartbeatVisibilityConfig `yaml:"heartbeat"` } type ChannelConfig struct { - Heartbeat *ChannelHeartbeatVisibilityConfig `yaml:"heartbeat"` - ResponsePrefix string `yaml:"responsePrefix"` - ReplyToMode string `yaml:"replyToMode"` // off|first|all (Matrix) - ThreadReplies string `yaml:"threadReplies"` // off|inbound|always (Matrix) + Heartbeat *ChannelHeartbeatVisibilityConfig `yaml:"heartbeat"` + ReplyToMode string `yaml:"replyToMode"` // off|first|all (Matrix) + ThreadReplies string `yaml:"threadReplies"` // off|inbound|always (Matrix) } type ChannelHeartbeatVisibilityConfig struct { @@ -180,7 +178,6 @@ type ChannelHeartbeatVisibilityConfig struct { // MessagesConfig defines message rendering settings. type MessagesConfig struct { - ResponsePrefix string `yaml:"responsePrefix"` AckReaction string `yaml:"ackReaction"` AckReactionScope string `yaml:"ackReactionScope"` // group-mentions|group-all|direct|all|off|none RemoveAckAfter bool `yaml:"removeAckAfterReply"` @@ -544,7 +541,6 @@ func upgradeConfig(helper configupgrade.Helper) { helper.Copy(configupgrade.Bool, "cron", "enabled") // Messages configuration - helper.Copy(configupgrade.Str, "messages", "responsePrefix") helper.Copy(configupgrade.List, "commands", "ownerAllowFrom") helper.Copy(configupgrade.Str, "messages", "queue", "mode") helper.Copy(configupgrade.Map, "messages", "queue", "byChannel") @@ -575,11 +571,9 @@ func upgradeConfig(helper configupgrade.Helper) { helper.Copy(configupgrade.Bool, "channels", "defaults", "heartbeat", "showOk") helper.Copy(configupgrade.Bool, "channels", "defaults", "heartbeat", "showAlerts") helper.Copy(configupgrade.Bool, "channels", "defaults", "heartbeat", "useIndicator") - helper.Copy(configupgrade.Str, "channels", "defaults", "responsePrefix") helper.Copy(configupgrade.Bool, "channels", "matrix", "heartbeat", "showOk") helper.Copy(configupgrade.Bool, "channels", "matrix", "heartbeat", "showAlerts") helper.Copy(configupgrade.Bool, "channels", "matrix", "heartbeat", "useIndicator") - helper.Copy(configupgrade.Str, "channels", "matrix", "responsePrefix") helper.Copy(configupgrade.Str, "channels", "matrix", "replyToMode") helper.Copy(configupgrade.Str, "channels", "matrix", "threadReplies") diff --git a/pkg/connector/integrations_example-config.yaml b/pkg/connector/integrations_example-config.yaml index 6a28c7f1..3ad59010 100644 --- a/pkg/connector/integrations_example-config.yaml +++ b/pkg/connector/integrations_example-config.yaml @@ -54,10 +54,8 @@ model_cache_duration: 6h # Optional message rendering settings. messages: - # Prefix applied to outbound replies (and heartbeat ok acks). - responsePrefix: "" # History defaults for prompt construction. - # Set 0 to disable, or override per-room with !ai context. + # Set 0 to disable. directChat: historyLimit: 20 groupChat: @@ -94,8 +92,8 @@ tool_approvals: # Optional per-channel overrides. channels: matrix: - # Response prefix override for Matrix rooms. - responsePrefix: "" + # Matrix reply/thread behavior. + replyToMode: "first" # Session configuration. session: diff --git a/pkg/connector/mcp_helpers.go b/pkg/connector/mcp_helpers.go new file mode 100644 index 00000000..e1160f64 --- /dev/null +++ b/pkg/connector/mcp_helpers.go @@ -0,0 +1,181 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +func mcpAddUsage(allowStdio bool) string { + if allowStdio { + return "`!ai mcp add [token] [authType] [authURL]` | `!ai mcp add streamable_http [token] [authType] [authURL]` | `!ai mcp add stdio [args...]`" + } + return "`!ai mcp add [token] [authType] [authURL]` | `!ai mcp add streamable_http [token] [authType] [authURL]`" +} + +func mcpManageUsage(allowStdio bool) string { + return fmt.Sprintf("`!ai mcp list` | %s | `!ai mcp connect [name] [token]` | `!ai mcp disconnect [name]` | `!ai mcp remove [name]`.", mcpAddUsage(allowStdio)) +} + +func isLikelyHTTPURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed == nil { + return false + } + return parsed.Scheme == "http" || parsed.Scheme == "https" +} + +func parseMCPHTTPAuthArgs(rest []string) (token, authType, authURL string) { + authType = "bearer" + if len(rest) > 0 { + token = strings.TrimSpace(rest[0]) + } + if len(rest) > 1 { + authType = strings.TrimSpace(rest[1]) + } + if len(rest) > 2 { + authURL = strings.TrimSpace(strings.Join(rest[2:], " ")) + } + return token, authType, authURL +} + +func parseMCPAddArgs(args []string, allowStdio bool) (name string, cfg MCPServerConfig, err error) { + trimmed := make([]string, 0, len(args)) + for _, raw := range args { + part := strings.TrimSpace(raw) + if part != "" { + trimmed = append(trimmed, part) + } + } + if len(trimmed) == 0 { + return "", MCPServerConfig{}, errors.New("missing args") + } + + if len(trimmed) < 2 { + return "", MCPServerConfig{}, errors.New("missing target") + } + name = normalizeMCPServerName(trimmed[0]) + targetIndex := 1 + + rawTransportOrTarget := strings.TrimSpace(trimmed[targetIndex]) + normalizedTransport := normalizeMCPServerTransport(rawTransportOrTarget) + if normalizedTransport == mcpTransportStdio { + if !allowStdio { + return "", MCPServerConfig{}, errors.New("stdio disabled") + } + if len(trimmed) <= targetIndex+1 { + return "", MCPServerConfig{}, errors.New("missing command") + } + cfg = normalizeMCPServerConfig(MCPServerConfig{ + Transport: mcpTransportStdio, + Command: strings.TrimSpace(trimmed[targetIndex+1]), + Args: trimmed[targetIndex+2:], + AuthType: "none", + Connected: false, + Kind: mcpServerKindGeneric, + }) + if cfg.Command == "" { + return "", MCPServerConfig{}, errors.New("missing command") + } + return name, cfg, nil + } + + endpoint := rawTransportOrTarget + rest := trimmed[targetIndex+1:] + if normalizedTransport == mcpTransportStreamableHTTP { + if len(trimmed) <= targetIndex+1 { + return "", MCPServerConfig{}, errors.New("missing endpoint") + } + endpoint = strings.TrimSpace(trimmed[targetIndex+1]) + rest = trimmed[targetIndex+2:] + } + if !isLikelyHTTPURL(endpoint) { + return "", MCPServerConfig{}, errors.New("invalid endpoint") + } + token, authType, authURL := parseMCPHTTPAuthArgs(rest) + cfg = normalizeMCPServerConfig(MCPServerConfig{ + Transport: mcpTransportStreamableHTTP, + Endpoint: endpoint, + Token: token, + AuthType: authType, + AuthURL: authURL, + Connected: false, + Kind: mcpServerKindGeneric, + }) + return name, cfg, nil +} + +func resolveMCPServerArg(client *AIClient, args []string) (namedMCPServer, string, error) { + servers := client.configuredMCPServers() + if len(servers) == 0 { + return namedMCPServer{}, "", errors.New("none configured") + } + + if len(args) == 0 { + if len(servers) == 1 { + return servers[0], "", nil + } + return namedMCPServer{}, "", errors.New("ambiguous") + } + + candidate := strings.TrimSpace(args[0]) + for _, server := range servers { + if server.Name == normalizeMCPServerName(candidate) { + token := "" + if len(args) > 1 { + token = strings.TrimSpace(strings.Join(args[1:], " ")) + } + return server, token, nil + } + } + return namedMCPServer{}, "", errors.New("not found") +} + +func (oc *AIClient) verifyMCPServerConnection(ctx context.Context, server namedMCPServer) (int, error) { + if ctx == nil { + ctx = context.Background() + } + callCtx := ctx + var cancel context.CancelFunc + if _, hasDeadline := callCtx.Deadline(); !hasDeadline { + timeout := oc.mcpRequestTimeout() + if timeout > 10*time.Second { + timeout = 10 * time.Second + } + callCtx, cancel = context.WithTimeout(ctx, timeout) + } + if cancel != nil { + defer cancel() + } + defs, err := oc.fetchMCPToolsForServer(callCtx, server) + if err != nil { + return 0, err + } + return len(defs), nil +} + +func setLoginMCPServer(meta *UserLoginMetadata, name string, cfg MCPServerConfig) { + if meta.ServiceTokens == nil { + meta.ServiceTokens = &ServiceTokens{} + } + if meta.ServiceTokens.MCPServers == nil { + meta.ServiceTokens.MCPServers = map[string]MCPServerConfig{} + } + meta.ServiceTokens.MCPServers[name] = normalizeMCPServerConfig(cfg) +} + +func clearLoginMCPServer(meta *UserLoginMetadata, name string) { + if meta == nil || meta.ServiceTokens == nil || meta.ServiceTokens.MCPServers == nil { + return + } + delete(meta.ServiceTokens.MCPServers, name) + if len(meta.ServiceTokens.MCPServers) == 0 { + meta.ServiceTokens.MCPServers = nil + } + if serviceTokensEmpty(meta.ServiceTokens) { + meta.ServiceTokens = nil + } +} diff --git a/pkg/connector/metadata.go b/pkg/connector/metadata.go index 5aee49c5..fd4cfced 100644 --- a/pkg/connector/metadata.go +++ b/pkg/connector/metadata.go @@ -7,7 +7,6 @@ import ( "go.mau.fi/util/jsontime" "go.mau.fi/util/random" "maunium.net/go/mautrix/bridgev2/database" - "maunium.net/go/mautrix/bridgev2/networkid" "github.com/beeper/ai-bridge/pkg/bridgeadapter" "github.com/beeper/ai-bridge/pkg/shared/jsonutil" @@ -46,12 +45,11 @@ type FileAnnotation struct { CreatedAt int64 `json:"created_at"` // Unix timestamp when cached } -// UserDefaults stores user-level default settings for new chats -type UserDefaults struct { - Model string `json:"model,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` +type UserProfile struct { + Name string `json:"name,omitempty"` + Occupation string `json:"occupation,omitempty"` + AboutUser string `json:"about_user,omitempty"` + CustomInstructions string `json:"custom_instructions,omitempty"` } // ServiceTokens stores optional per-login credentials for external services. @@ -110,7 +108,6 @@ type BuiltinAlwaysAllowRule struct { // UserLoginMetadata is stored on each login row to keep per-user settings. type UserLoginMetadata struct { - Persona string `json:"persona,omitempty"` Provider string `json:"provider,omitempty"` // Selected provider (beeper, openai, openrouter) APIKey string `json:"api_key,omitempty"` BaseURL string `json:"base_url,omitempty"` // Per-user API endpoint @@ -121,26 +118,18 @@ type UserLoginMetadata struct { ChatsSynced bool `json:"chats_synced,omitempty"` // True after initial bootstrap completed successfully Gravatar *GravatarState `json:"gravatar,omitempty"` Timezone string `json:"timezone,omitempty"` - ResponsePrefix string `json:"response_prefix,omitempty"` + Profile *UserProfile `json:"profile,omitempty"` // FileAnnotationCache stores parsed PDF content from OpenRouter's file-parser plugin // Key is the file hash (SHA256), pruned after 7 days FileAnnotationCache map[string]FileAnnotation `json:"file_annotation_cache,omitempty"` - // User-level defaults for new chats (set via provisioning API) - Defaults *UserDefaults `json:"defaults,omitempty"` - // Optional per-login tokens for external services ServiceTokens *ServiceTokens `json:"service_tokens,omitempty"` // Tool approval rules (e.g. "always allow" decisions for MCP approvals or dangerous builtin tools). ToolApprovals *ToolApprovalsConfig `json:"tool_approvals,omitempty"` - // AgentModelOverrides stores per-agent model overrides (agent ID -> model ID). - AgentModelOverrides map[string]string `json:"agent_model_overrides,omitempty"` - - // Agent Builder room for managing agents - BuilderRoomID networkid.PortalID `json:"builder_room_id,omitempty"` // Custom agents store (source of truth for user-created agents). CustomAgents map[string]*AgentDefinitionContent `json:"custom_agents,omitempty"` // Last active room per agent (used for heartbeat delivery). @@ -174,53 +163,32 @@ type GravatarState struct { Primary *GravatarProfile `json:"primary,omitempty"` } -// PortalMetadata stores per-room tuning knobs for the assistant. +// PortalMetadata stores non-derivable per-room runtime state. type PortalMetadata struct { - Model string `json:"model,omitempty"` // Set from room state - SystemPrompt string `json:"system_prompt,omitempty"` // Set from room state - ResponsePrefix string `json:"response_prefix,omitempty"` // Per-room response prefix override - Temperature float64 `json:"temperature,omitempty"` // Set from room state - MaxContextMessages int `json:"max_context_messages,omitempty"` // Set from room state - MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` // Set from room state - ReasoningEffort string `json:"reasoning_effort,omitempty"` // none, low, medium, high, xhigh - Slug string `json:"slug,omitempty"` - Title string `json:"title,omitempty"` - TitleGenerated bool `json:"title_generated,omitempty"` // True if title was auto-generated - WelcomeSent bool `json:"welcome_sent,omitempty"` - AutoGreetingSent bool `json:"auto_greeting_sent,omitempty"` - Capabilities ModelCapabilities `json:"capabilities,omitempty"` - LastRoomStateSync int64 `json:"last_room_state_sync,omitempty"` // Track when we've synced room state - PDFConfig *PDFConfig `json:"pdf_config,omitempty"` // Per-room PDF processing configuration - - EmitThinking bool `json:"emit_thinking,omitempty"` - EmitToolArgs bool `json:"emit_tool_args,omitempty"` - ThinkingLevel string `json:"thinking_level,omitempty"` // off|minimal|low|medium|high|xhigh - VerboseLevel string `json:"verbose_level,omitempty"` // off|on|full - ElevatedLevel string `json:"elevated_level,omitempty"` // off|on|ask|full - GroupActivation string `json:"group_activation,omitempty"` // mention|always - GroupActivationNeedsIntro bool `json:"group_activation_needs_intro,omitempty"` - GroupIntroSent bool `json:"group_intro_sent,omitempty"` - SendPolicy string `json:"send_policy,omitempty"` // allow|deny - SessionResetAt int64 `json:"session_reset_at,omitempty"` - AbortedLastRun bool `json:"aborted_last_run,omitempty"` - CompactionCount int `json:"compaction_count,omitempty"` - SessionBootstrappedAt int64 `json:"session_bootstrapped_at,omitempty"` - SessionBootstrapByAgent map[string]int64 `json:"session_bootstrap_by_agent,omitempty"` - - // Agent-related metadata - AgentID string `json:"agent_id,omitempty"` // Which agent is the ghost for this room - AgentPrompt string `json:"agent_prompt,omitempty"` // Cached prompt for the assigned agent - IsBuilderRoom bool `json:"is_builder_room,omitempty"` // True if this is the Manage AI Chats room (protected from overrides) - IsSimpleMode bool `json:"is_simple_mode,omitempty"` // True if this is a simple mode room (no directive processing) + AckReactionEmoji string `json:"ack_reaction_emoji,omitempty"` + AckReactionRemoveAfter bool `json:"ack_reaction_remove_after,omitempty"` + PDFConfig *PDFConfig `json:"pdf_config,omitempty"` + + Slug string `json:"slug,omitempty"` + Title string `json:"title,omitempty"` + TitleGenerated bool `json:"title_generated,omitempty"` // True if title was auto-generated + WelcomeSent bool `json:"welcome_sent,omitempty"` + AutoGreetingSent bool `json:"auto_greeting_sent,omitempty"` + + SessionResetAt int64 `json:"session_reset_at,omitempty"` + AbortedLastRun bool `json:"aborted_last_run,omitempty"` + CompactionCount int `json:"compaction_count,omitempty"` + SessionBootstrappedAt int64 `json:"session_bootstrapped_at,omitempty"` + SessionBootstrapByAgent map[string]int64 `json:"session_bootstrap_by_agent,omitempty"` + ModuleMeta map[string]any `json:"module_meta,omitempty"` // Generic per-module metadata (e.g., cron room markers, memory flush state) SubagentParentRoomID string `json:"subagent_parent_room_id,omitempty"` // Parent room ID for subagent sessions - // Ack reaction config - similar to OpenClaw's ack reactions - AckReactionEmoji string `json:"ack_reaction_emoji,omitempty"` // Emoji to react with when message received (e.g., "👀", "🤔"). Empty = disabled. - AckReactionRemoveAfter bool `json:"ack_reaction_remove_after,omitempty"` // Remove the ack reaction after replying - // Runtime-only overrides (not persisted) - DisabledTools []string `json:"-"` + DisabledTools []string `json:"-"` + ResolvedTarget *ResolvedTarget `json:"-"` + RuntimeModelOverride string `json:"-"` + RuntimeReasoning string `json:"-"` // Debounce configuration (0 = use default, -1 = disabled) DebounceMs int `json:"debounce_ms,omitempty"` @@ -231,10 +199,8 @@ type PortalMetadata struct { } -// isSimpleMode reports whether the portal is in simple mode -// (no directive processing, minimal agent chrome). func isSimpleMode(meta *PortalMetadata) bool { - return meta != nil && meta.IsSimpleMode + return meta != nil && meta.ResolvedTarget != nil && meta.ResolvedTarget.Kind == ResolvedTargetModel } func clonePortalMetadata(src *PortalMetadata) *PortalMetadata { @@ -256,6 +222,7 @@ func clonePortalMetadata(src *PortalMetadata) *PortalMetadata { if len(src.DisabledTools) > 0 { clone.DisabledTools = slices.Clone(src.DisabledTools) } + clone.ResolvedTarget = src.ResolvedTarget if src.ModuleMeta != nil { clone.ModuleMeta = make(map[string]any, len(src.ModuleMeta)) @@ -263,6 +230,10 @@ func clonePortalMetadata(src *PortalMetadata) *PortalMetadata { clone.ModuleMeta[k] = jsonutil.DeepCloneAny(v) } } + if src.ResolvedTarget != nil { + target := *src.ResolvedTarget + clone.ResolvedTarget = &target + } return &clone } @@ -285,7 +256,7 @@ type MessageMetadata struct { MediaUnderstandingDecisions []MediaUnderstandingDecision `json:"media_understanding_decisions,omitempty"` // Multimodal history: media attached to this message for re-injection into prompts. - MediaURL string `json:"media_url,omitempty"` // mxc:// URL for user-sent media + MediaURL string `json:"media_url,omitempty"` // mxc:// URL for user-sent media (image, PDF, audio, video) MimeType string `json:"mime_type,omitempty"` // MIME type of user-sent media } diff --git a/pkg/connector/model_fallback.go b/pkg/connector/model_fallback.go index be93215c..47aa9483 100644 --- a/pkg/connector/model_fallback.go +++ b/pkg/connector/model_fallback.go @@ -28,18 +28,9 @@ func (e *NonFallbackError) Unwrap() error { } // modelFallbackChain returns the model chain to try in order. -// Room-level overrides take priority and disable fallbacks. +// Agent-defined fallbacks are used for agent rooms; model rooms only use their selected model. func (oc *AIClient) modelFallbackChain(ctx context.Context, meta *PortalMetadata) []string { - // Explicit room-level model overrides should not fall back. - if meta != nil && strings.TrimSpace(meta.Model) != "" { - return dedupeModels([]string{ResolveAlias(meta.Model)}) - } - - agentID := "" - if meta != nil { - agentID = meta.AgentID - } - + agentID := resolveAgentID(meta) if agentID != "" { store := NewAgentStoreAdapter(oc) agent, err := store.GetAgentByID(ctx, agentID) @@ -72,8 +63,7 @@ func (oc *AIClient) overrideModel(meta *PortalMetadata, modelID string) *PortalM return nil } metaCopy := *meta - metaCopy.Model = modelID - metaCopy.Capabilities = getModelCapabilities(modelID, oc.findModelInfo(modelID)) + metaCopy.RuntimeModelOverride = ResolveAlias(modelID) return &metaCopy } diff --git a/pkg/connector/parse_utils.go b/pkg/connector/parse_utils.go deleted file mode 100644 index 501b644a..00000000 --- a/pkg/connector/parse_utils.go +++ /dev/null @@ -1,18 +0,0 @@ -package connector - -import ( - "errors" - "strconv" - "strings" -) - -func parsePositiveInt(raw string) (int, error) { - value, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil { - return 0, err - } - if value <= 0 { - return 0, errors.New("value must be positive") - } - return value, nil -} diff --git a/pkg/connector/prompt_params.go b/pkg/connector/prompt_params.go index 32152b3f..fb4e9472 100644 --- a/pkg/connector/prompt_params.go +++ b/pkg/connector/prompt_params.go @@ -5,8 +5,5 @@ func resolvePromptWorkspaceDir() string { } func resolvePromptReasoningLevel(meta *PortalMetadata) string { - if meta != nil && meta.EmitThinking { - return "on" - } return "" } diff --git a/pkg/connector/provisioning.go b/pkg/connector/provisioning.go index 25f78a7e..31f0ad32 100644 --- a/pkg/connector/provisioning.go +++ b/pkg/connector/provisioning.go @@ -1,23 +1,34 @@ package connector import ( + "context" "encoding/json" + "errors" + "fmt" + "io" "net/http" + "slices" + "strings" + "time" + "github.com/google/uuid" "github.com/rs/zerolog" "go.mau.fi/util/exhttp" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" + + "github.com/beeper/ai-bridge/pkg/agents" + "github.com/beeper/ai-bridge/pkg/agents/toolpolicy" ) -// ProvisioningAPI handles the provisioning endpoints for user defaults +// ProvisioningAPI handles login-scoped profile, agent, and MCP configuration. type ProvisioningAPI struct { log zerolog.Logger connector *OpenAIConnector prov bridgev2.IProvisioningAPI } -// initProvisioning sets up the provisioning API endpoints +// initProvisioning sets up the provisioning API endpoints. func (oc *OpenAIConnector) initProvisioning() { c, ok := oc.br.Matrix.(bridgev2.MatrixConnectorWithProvisioning) if !ok { @@ -36,13 +47,24 @@ func (oc *OpenAIConnector) initProvisioning() { } r.HandleFunc("GET /v1/models", api.handleListModels) - r.HandleFunc("GET /v1/defaults", api.handleGetDefaults) - r.HandleFunc("PUT /v1/defaults", api.handleSetDefaults) + r.HandleFunc("GET /v1/profile", api.handleGetProfile) + r.HandleFunc("PUT /v1/profile", api.handlePutProfile) + r.HandleFunc("GET /v1/agents", api.handleListAgents) + r.HandleFunc("POST /v1/agents", api.handleCreateAgent) + r.HandleFunc("GET /v1/agents/{agent_id}", api.handleGetAgent) + r.HandleFunc("PUT /v1/agents/{agent_id}", api.handleUpdateAgent) + r.HandleFunc("DELETE /v1/agents/{agent_id}", api.handleDeleteAgent) + r.HandleFunc("GET /v1/mcp/servers", api.handleListMCPServers) + r.HandleFunc("POST /v1/mcp/servers", api.handleCreateMCPServer) + r.HandleFunc("PUT /v1/mcp/servers/{name}", api.handleUpdateMCPServer) + r.HandleFunc("DELETE /v1/mcp/servers/{name}", api.handleDeleteMCPServer) + r.HandleFunc("POST /v1/mcp/servers/{name}/connect", api.handleConnectMCPServer) + r.HandleFunc("POST /v1/mcp/servers/{name}/disconnect", api.handleDisconnectMCPServer) - oc.br.Log.Info().Msg("Registered provisioning API endpoints for user defaults") + oc.br.Log.Info().Msg("Registered provisioning API endpoints for AI profile, agents, and MCP") } -// getLogin gets the user login from the request +// getLogin gets the preferred user login from the request. func (api *ProvisioningAPI) getLogin(w http.ResponseWriter, r *http.Request) *bridgev2.UserLogin { user := api.prov.GetUser(r) login := api.connector.getPreferredUserLogin(r.Context(), user) @@ -53,13 +75,25 @@ func (api *ProvisioningAPI) getLogin(w http.ResponseWriter, r *http.Request) *br return login } -// handleListModels handles GET /v1/models -func (api *ProvisioningAPI) handleListModels(w http.ResponseWriter, r *http.Request) { +func (api *ProvisioningAPI) getClient(w http.ResponseWriter, r *http.Request) (*bridgev2.UserLogin, *AIClient) { login := api.getLogin(w, r) if login == nil { + return nil, nil + } + client, ok := login.Client.(*AIClient) + if !ok || client == nil { + mautrix.MUnknown.WithMessage("Invalid AI client for login.").Write(w) + return nil, nil + } + return login, client +} + +// handleListModels handles GET /v1/models. +func (api *ProvisioningAPI) handleListModels(w http.ResponseWriter, r *http.Request) { + _, client := api.getClient(w, r) + if client == nil { return } - client := login.Client.(*AIClient) models, err := client.listAvailableModels(r.Context(), false) if err != nil { mautrix.MUnknown.WithMessage("Couldn't list models: %v.", err).Write(w) @@ -68,91 +102,655 @@ func (api *ProvisioningAPI) handleListModels(w http.ResponseWriter, r *http.Requ exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{"models": models}) } -// handleGetDefaults handles GET /v1/defaults -func (api *ProvisioningAPI) handleGetDefaults(w http.ResponseWriter, r *http.Request) { +type profilePayload struct { + Name *string `json:"name,omitempty"` + Occupation *string `json:"occupation,omitempty"` + AboutUser *string `json:"about_user,omitempty"` + CustomInstructions *string `json:"custom_instructions,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} + +type profileResponse struct { + Name string `json:"name,omitempty"` + Occupation string `json:"occupation,omitempty"` + AboutUser string `json:"about_user,omitempty"` + CustomInstructions string `json:"custom_instructions,omitempty"` + Timezone string `json:"timezone,omitempty"` +} + +func profileResponseFromMeta(meta *UserLoginMetadata) profileResponse { + var resp profileResponse + if meta == nil { + return resp + } + if meta.Profile != nil { + resp.Name = meta.Profile.Name + resp.Occupation = meta.Profile.Occupation + resp.AboutUser = meta.Profile.AboutUser + resp.CustomInstructions = meta.Profile.CustomInstructions + } + resp.Timezone = meta.Timezone + return resp +} + +func applyProfilePayload(meta *UserLoginMetadata, payload profilePayload) error { + if meta == nil { + return errors.New("missing metadata") + } + if payload.Name != nil || payload.Occupation != nil || payload.AboutUser != nil || payload.CustomInstructions != nil { + if meta.Profile == nil { + meta.Profile = &UserProfile{} + } + if payload.Name != nil { + meta.Profile.Name = strings.TrimSpace(*payload.Name) + } + if payload.Occupation != nil { + meta.Profile.Occupation = strings.TrimSpace(*payload.Occupation) + } + if payload.AboutUser != nil { + meta.Profile.AboutUser = strings.TrimSpace(*payload.AboutUser) + } + if payload.CustomInstructions != nil { + meta.Profile.CustomInstructions = strings.TrimSpace(*payload.CustomInstructions) + } + if meta.Profile.Name == "" && meta.Profile.Occupation == "" && meta.Profile.AboutUser == "" && meta.Profile.CustomInstructions == "" { + meta.Profile = nil + } + } + if payload.Timezone != nil { + tz := strings.TrimSpace(*payload.Timezone) + if tz != "" { + if _, err := time.LoadLocation(tz); err != nil { + return fmt.Errorf("invalid timezone: %w", err) + } + } + meta.Timezone = tz + } + return nil +} + +// handleGetProfile handles GET /v1/profile. +func (api *ProvisioningAPI) handleGetProfile(w http.ResponseWriter, r *http.Request) { login := api.getLogin(w, r) if login == nil { return } + exhttp.WriteJSONResponse(w, http.StatusOK, profileResponseFromMeta(loginMetadata(login))) +} + +// handlePutProfile handles PUT /v1/profile. +func (api *ProvisioningAPI) handlePutProfile(w http.ResponseWriter, r *http.Request) { + login := api.getLogin(w, r) + if login == nil { + return + } + var req profilePayload + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + mautrix.MBadJSON.WithMessage("Invalid JSON: %v.", err).Write(w) + return + } meta := loginMetadata(login) - resp := map[string]any{} - if meta.Defaults != nil { - if meta.Defaults.Model != "" { - resp["model"] = meta.Defaults.Model + if err := applyProfilePayload(meta, req); err != nil { + mautrix.MInvalidParam.WithMessage("%v.", err).Write(w) + return + } + if err := login.Save(r.Context()); err != nil { + mautrix.MUnknown.WithMessage("Couldn't save changes: %v.", err).Write(w) + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, profileResponseFromMeta(meta)) +} + +type agentUpsertRequest struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Model string `json:"model,omitempty"` + ModelFallback []string `json:"model_fallback,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` + PromptMode string `json:"prompt_mode,omitempty"` + Tools *toolpolicy.ToolPolicyConfig `json:"tools,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + IdentityName string `json:"identity_name,omitempty"` + IdentityPersona string `json:"identity_persona,omitempty"` + HeartbeatPrompt string `json:"heartbeat_prompt,omitempty"` + MemorySearch any `json:"memory_search,omitempty"` +} + +func writeAgentError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, agents.ErrAgentNotFound): + mautrix.MNotFound.WithMessage("Agent not found.").Write(w) + case errors.Is(err, agents.ErrAgentIsPreset): + mautrix.MForbidden.WithMessage("Preset agents can't be modified.").Write(w) + case errors.Is(err, agents.ErrMissingAgentID), errors.Is(err, agents.ErrMissingAgentName): + mautrix.MInvalidParam.WithMessage("%v.", err).Write(w) + default: + mautrix.MUnknown.WithMessage("Couldn't process agent: %v.", err).Write(w) + } +} + +func normalizeAgentUpsertRequest(req agentUpsertRequest, pathID string) (*agents.AgentDefinition, error) { + agentID := strings.TrimSpace(pathID) + if agentID == "" { + agentID = strings.TrimSpace(req.ID) + } + if agentID == "" { + agentID = uuid.NewString() + } + content := &AgentDefinitionContent{ + ID: agentID, + Name: strings.TrimSpace(req.Name), + Description: strings.TrimSpace(req.Description), + AvatarURL: strings.TrimSpace(req.AvatarURL), + Model: strings.TrimSpace(req.Model), + ModelFallback: normalizeStringList(req.ModelFallback), + SystemPrompt: strings.TrimSpace(req.SystemPrompt), + PromptMode: strings.TrimSpace(req.PromptMode), + Temperature: req.Temperature, + ReasoningEffort: strings.TrimSpace(req.ReasoningEffort), + IdentityName: strings.TrimSpace(req.IdentityName), + IdentityPersona: strings.TrimSpace(req.IdentityPersona), + HeartbeatPrompt: strings.TrimSpace(req.HeartbeatPrompt), + MemorySearch: req.MemorySearch, + } + content.Tools = req.Tools + return FromAgentDefinitionContent(content), nil +} + +func normalizeStringList(input []string) []string { + if len(input) == 0 { + return nil + } + out := make([]string, 0, len(input)) + for _, item := range input { + item = strings.TrimSpace(item) + if item == "" { + continue } - if meta.Defaults.SystemPrompt != "" { - resp["system_prompt"] = meta.Defaults.SystemPrompt + out = append(out, item) + } + if len(out) == 0 { + return nil + } + return out +} + +func validateAgentModels(ctx context.Context, client *AIClient, agent *agents.AgentDefinition) error { + if agent == nil || client == nil { + return nil + } + models := []string{} + if strings.TrimSpace(agent.Model.Primary) != "" { + models = append(models, strings.TrimSpace(agent.Model.Primary)) + } + models = append(models, normalizeStringList(agent.Model.Fallbacks)...) + for _, model := range models { + resolved, valid, err := client.resolveModelID(ctx, model) + if err != nil { + return err } - if meta.Defaults.Temperature != nil { - resp["temperature"] = meta.Defaults.Temperature + if !valid || resolved == "" { + return fmt.Errorf("invalid model: %s", model) } - if meta.Defaults.ReasoningEffort != "" { - resp["reasoning_effort"] = meta.Defaults.ReasoningEffort + if model == agent.Model.Primary { + agent.Model.Primary = resolved + continue } } - exhttp.WriteJSONResponse(w, http.StatusOK, resp) + if len(agent.Model.Fallbacks) > 0 { + resolvedFallbacks := make([]string, 0, len(agent.Model.Fallbacks)) + for _, fallback := range normalizeStringList(agent.Model.Fallbacks) { + resolved, valid, err := client.resolveModelID(ctx, fallback) + if err != nil { + return err + } + if !valid || resolved == "" { + return fmt.Errorf("invalid model: %s", fallback) + } + resolvedFallbacks = append(resolvedFallbacks, resolved) + } + agent.Model.Fallbacks = resolvedFallbacks + } + return nil } -// ReqSetDefaults is the request body for PUT /v1/defaults -type ReqSetDefaults struct { - Model *string `json:"model,omitempty"` - SystemPrompt *string `json:"system_prompt,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - ReasoningEffort *string `json:"reasoning_effort,omitempty"` +func agentResponse(agent *agents.AgentDefinition) *AgentDefinitionContent { + if agent == nil { + return nil + } + return ToAgentDefinitionContent(agent) } -// handleSetDefaults handles PUT /v1/defaults -func (api *ProvisioningAPI) handleSetDefaults(w http.ResponseWriter, r *http.Request) { - login := api.getLogin(w, r) - if login == nil { +func listAgentsForResponse(ctx context.Context, store *AgentStoreAdapter) ([]*AgentDefinitionContent, error) { + loaded, err := store.LoadAgents(ctx) + if err != nil { + return nil, err + } + ids := make([]string, 0, len(loaded)) + for id := range loaded { + ids = append(ids, id) + } + slices.Sort(ids) + out := make([]*AgentDefinitionContent, 0, len(ids)) + for _, id := range ids { + if agent := loaded[id]; agent != nil { + out = append(out, agentResponse(agent)) + } + } + return out, nil +} + +func (api *ProvisioningAPI) handleListAgents(w http.ResponseWriter, r *http.Request) { + _, client := api.getClient(w, r) + if client == nil { + return + } + items, err := listAgentsForResponse(r.Context(), NewAgentStoreAdapter(client)) + if err != nil { + mautrix.MUnknown.WithMessage("Couldn't list agents: %v.", err).Write(w) return } - var req ReqSetDefaults + exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{"agents": items}) +} + +func (api *ProvisioningAPI) handleGetAgent(w http.ResponseWriter, r *http.Request) { + _, client := api.getClient(w, r) + if client == nil { + return + } + agentID := strings.TrimSpace(r.PathValue("agent_id")) + agent, err := NewAgentStoreAdapter(client).GetAgentByID(r.Context(), agentID) + if err != nil { + writeAgentError(w, err) + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, agentResponse(agent)) +} + +func (api *ProvisioningAPI) handleCreateAgent(w http.ResponseWriter, r *http.Request) { + _, client := api.getClient(w, r) + if client == nil { + return + } + var req agentUpsertRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { mautrix.MBadJSON.WithMessage("Invalid JSON: %v.", err).Write(w) return } + agent, err := normalizeAgentUpsertRequest(req, "") + if err != nil { + mautrix.MBadJSON.WithMessage("Invalid agent payload: %v.", err).Write(w) + return + } + if err = validateAgentModels(r.Context(), client, agent); err != nil { + mautrix.MInvalidParam.WithMessage("%v.", err).Write(w) + return + } + store := NewAgentStoreAdapter(client) + if existing, err := store.GetAgentByID(r.Context(), agent.ID); err == nil && existing != nil { + mautrix.MInvalidParam.WithMessage("Agent %s already exists.", agent.ID).Write(w) + return + } + if err = store.SaveAgent(r.Context(), agent); err != nil { + writeAgentError(w, err) + return + } + exhttp.WriteJSONResponse(w, http.StatusCreated, agentResponse(agent)) +} +func (api *ProvisioningAPI) handleUpdateAgent(w http.ResponseWriter, r *http.Request) { + _, client := api.getClient(w, r) + if client == nil { + return + } + var req agentUpsertRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + mautrix.MBadJSON.WithMessage("Invalid JSON: %v.", err).Write(w) + return + } + agentID := strings.TrimSpace(r.PathValue("agent_id")) + agent, err := normalizeAgentUpsertRequest(req, agentID) + if err != nil { + mautrix.MBadJSON.WithMessage("Invalid agent payload: %v.", err).Write(w) + return + } + if err = validateAgentModels(r.Context(), client, agent); err != nil { + mautrix.MInvalidParam.WithMessage("%v.", err).Write(w) + return + } + store := NewAgentStoreAdapter(client) + existing, err := store.GetAgentByID(r.Context(), agentID) + if err != nil { + writeAgentError(w, err) + return + } + if existing != nil && existing.IsPreset { + writeAgentError(w, agents.ErrAgentIsPreset) + return + } + if err = store.SaveAgent(r.Context(), agent); err != nil { + writeAgentError(w, err) + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, agentResponse(agent)) +} + +func (api *ProvisioningAPI) handleDeleteAgent(w http.ResponseWriter, r *http.Request) { + _, client := api.getClient(w, r) + if client == nil { + return + } + agentID := strings.TrimSpace(r.PathValue("agent_id")) + if err := NewAgentStoreAdapter(client).DeleteAgent(r.Context(), agentID); err != nil { + writeAgentError(w, err) + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{"deleted": true}) +} + +type mcpServerUpsertRequest struct { + Name string `json:"name,omitempty"` + Transport string `json:"transport,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + AuthType string `json:"auth_type,omitempty"` + Token string `json:"token,omitempty"` + AuthURL string `json:"auth_url,omitempty"` + Kind string `json:"kind,omitempty"` +} + +type mcpConnectRequest struct { + Token string `json:"token,omitempty"` +} + +type mcpServerResponse struct { + Name string `json:"name"` + Source string `json:"source,omitempty"` + Transport string `json:"transport,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + AuthType string `json:"auth_type,omitempty"` + TokenSet bool `json:"token_set,omitempty"` + AuthURL string `json:"auth_url,omitempty"` + Connected bool `json:"connected,omitempty"` + Kind string `json:"kind,omitempty"` +} + +func mcpServerResponseFromNamed(server namedMCPServer) mcpServerResponse { + cfg := normalizeMCPServerConfig(server.Config) + return mcpServerResponse{ + Name: server.Name, + Source: server.Source, + Transport: cfg.Transport, + Endpoint: cfg.Endpoint, + Command: cfg.Command, + Args: slices.Clone(cfg.Args), + AuthType: cfg.AuthType, + TokenSet: cfg.Token != "" || cfg.AuthType == "none", + AuthURL: cfg.AuthURL, + Connected: cfg.Connected, + Kind: cfg.Kind, + } +} + +func normalizeMCPRequest(req mcpServerUpsertRequest, pathName string) (string, MCPServerConfig, error) { + name := "" + if strings.TrimSpace(pathName) != "" { + name = normalizeMCPServerName(pathName) + } + if name == "" { + name = normalizeMCPServerName(req.Name) + } + if name == "" { + return "", MCPServerConfig{}, errors.New("server name is required") + } + cfg := normalizeMCPServerConfig(MCPServerConfig{ + Transport: strings.TrimSpace(req.Transport), + Endpoint: strings.TrimSpace(req.Endpoint), + Command: strings.TrimSpace(req.Command), + Args: normalizeStringList(req.Args), + AuthType: strings.TrimSpace(req.AuthType), + Token: strings.TrimSpace(req.Token), + AuthURL: strings.TrimSpace(req.AuthURL), + Kind: strings.TrimSpace(req.Kind), + Connected: false, + }) + if !mcpServerHasTarget(cfg) { + return "", MCPServerConfig{}, errors.New("mcp server target is required") + } + return name, cfg, nil +} + +func validateMCPConfig(client *AIClient, cfg MCPServerConfig) error { + if mcpServerUsesStdio(cfg) && !client.isMCPStdioEnabled() { + return errors.New("stdio MCP servers are disabled") + } + if cfg.Transport == mcpTransportStreamableHTTP && !isLikelyHTTPURL(cfg.Endpoint) { + return errors.New("invalid MCP endpoint") + } + return nil +} + +func resolveNamedMCPServer(client *AIClient, name string) (namedMCPServer, error) { + target, _, err := resolveMCPServerArg(client, []string{name}) + return target, err +} + +func ensureLoginMCPServer(meta *UserLoginMetadata) { + if meta.ServiceTokens == nil { + meta.ServiceTokens = &ServiceTokens{} + } + if meta.ServiceTokens.MCPServers == nil { + meta.ServiceTokens.MCPServers = map[string]MCPServerConfig{} + } +} + +func (api *ProvisioningAPI) handleListMCPServers(w http.ResponseWriter, r *http.Request) { + _, client := api.getClient(w, r) + if client == nil { + return + } + servers := client.configuredMCPServers() + items := make([]mcpServerResponse, 0, len(servers)) + for _, server := range servers { + items = append(items, mcpServerResponseFromNamed(server)) + } + slices.SortFunc(items, func(a, b mcpServerResponse) int { return strings.Compare(a.Name, b.Name) }) + exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{"servers": items}) +} + +func (api *ProvisioningAPI) handleCreateMCPServer(w http.ResponseWriter, r *http.Request) { + login, client := api.getClient(w, r) + if client == nil { + return + } + var req mcpServerUpsertRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + mautrix.MBadJSON.WithMessage("Invalid JSON: %v.", err).Write(w) + return + } + name, cfg, err := normalizeMCPRequest(req, "") + if err != nil { + mautrix.MInvalidParam.WithMessage("%v.", err).Write(w) + return + } + if err = validateMCPConfig(client, cfg); err != nil { + mautrix.MInvalidParam.WithMessage("%v.", err).Write(w) + return + } meta := loginMetadata(login) - if meta.Defaults == nil { - meta.Defaults = &UserDefaults{} + ensureLoginMCPServer(meta) + if _, exists := meta.ServiceTokens.MCPServers[name]; exists { + mautrix.MInvalidParam.WithMessage("MCP server %s already exists.", name).Write(w) + return } + setLoginMCPServer(meta, name, cfg) + if err = login.Save(r.Context()); err != nil { + mautrix.MUnknown.WithMessage("Couldn't save MCP server: %v.", err).Write(w) + return + } + client.invalidateMCPToolCache() + exhttp.WriteJSONResponse(w, http.StatusCreated, mcpServerResponseFromNamed(namedMCPServer{Name: name, Config: cfg, Source: "login"})) +} - // Validate and apply model - if req.Model != nil { - client := login.Client.(*AIClient) - if valid, _ := client.validateModel(r.Context(), *req.Model); !valid { - mautrix.MInvalidParam.WithMessage("Invalid model: %s.", *req.Model).Write(w) - return - } - meta.Defaults.Model = *req.Model +func (api *ProvisioningAPI) handleUpdateMCPServer(w http.ResponseWriter, r *http.Request) { + login, client := api.getClient(w, r) + if client == nil { + return + } + var req mcpServerUpsertRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + mautrix.MBadJSON.WithMessage("Invalid JSON: %v.", err).Write(w) + return + } + name := strings.TrimSpace(r.PathValue("name")) + _, err := resolveNamedMCPServer(client, name) + if err != nil && err.Error() != "not found" { + mautrix.MInvalidParam.WithMessage("Couldn't resolve MCP server %s.", name).Write(w) + return + } + resolvedName, cfg, err := normalizeMCPRequest(req, name) + if err != nil { + mautrix.MInvalidParam.WithMessage("%v.", err).Write(w) + return } + if err = validateMCPConfig(client, cfg); err != nil { + mautrix.MInvalidParam.WithMessage("%v.", err).Write(w) + return + } + meta := loginMetadata(login) + setLoginMCPServer(meta, resolvedName, cfg) + if err = login.Save(r.Context()); err != nil { + mautrix.MUnknown.WithMessage("Couldn't save MCP server: %v.", err).Write(w) + return + } + client.invalidateMCPToolCache() + exhttp.WriteJSONResponse(w, http.StatusOK, mcpServerResponseFromNamed(namedMCPServer{Name: resolvedName, Config: cfg, Source: "login"})) +} - // Apply other settings - if req.SystemPrompt != nil { - meta.Defaults.SystemPrompt = *req.SystemPrompt +func (api *ProvisioningAPI) handleDeleteMCPServer(w http.ResponseWriter, r *http.Request) { + login, client := api.getClient(w, r) + if client == nil { + return } - if req.Temperature != nil { - if *req.Temperature < 0 || *req.Temperature > 2 { - mautrix.MInvalidParam.WithMessage("Temperature must be between 0 and 2.").Write(w) - return + name := strings.TrimSpace(r.PathValue("name")) + target, err := resolveNamedMCPServer(client, name) + if err != nil { + mautrix.MNotFound.WithMessage("MCP server not found.").Write(w) + return + } + loginServers := client.loginMCPServers() + if _, ok := loginServers[target.Name]; !ok { + mautrix.MForbidden.WithMessage("Config-managed MCP servers can't be deleted here.").Write(w) + return + } + meta := loginMetadata(login) + clearLoginMCPServer(meta, target.Name) + if err = login.Save(r.Context()); err != nil { + mautrix.MUnknown.WithMessage("Couldn't remove MCP server: %v.", err).Write(w) + return + } + client.invalidateMCPToolCache() + exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{"deleted": true}) +} + +func connectMCPServer(ctx context.Context, client *AIClient, login *bridgev2.UserLogin, name string, tokenOverride string) (namedMCPServer, int, error) { + target, err := resolveNamedMCPServer(client, name) + if err != nil { + return namedMCPServer{}, 0, err + } + cfg := normalizeMCPServerConfig(target.Config) + if tokenOverride != "" && !mcpServerUsesStdio(cfg) { + cfg.Token = strings.TrimSpace(tokenOverride) + if cfg.Token != "" && cfg.AuthType == "none" { + cfg.AuthType = "bearer" + } + } + if !mcpServerHasTarget(cfg) { + return namedMCPServer{}, 0, errors.New("mcp server target is required") + } + if mcpServerNeedsToken(cfg) && cfg.Token == "" { + cfg.Connected = false + setLoginMCPServer(loginMetadata(login), target.Name, cfg) + if err = login.Save(ctx); err != nil { + return namedMCPServer{}, 0, err + } + client.invalidateMCPToolCache() + return namedMCPServer{Name: target.Name, Config: cfg, Source: "login"}, 0, errors.New("mcp server token is required") + } + cfg.Connected = true + count, connectErr := client.verifyMCPServerConnection(ctx, namedMCPServer{Name: target.Name, Config: cfg, Source: "login"}) + if connectErr != nil { + cfg.Connected = false + setLoginMCPServer(loginMetadata(login), target.Name, cfg) + if err = login.Save(ctx); err != nil { + return namedMCPServer{}, 0, err } - meta.Defaults.Temperature = req.Temperature + client.invalidateMCPToolCache() + return namedMCPServer{Name: target.Name, Config: cfg, Source: "login"}, 0, connectErr + } + setLoginMCPServer(loginMetadata(login), target.Name, cfg) + if err = login.Save(ctx); err != nil { + return namedMCPServer{}, 0, err + } + client.invalidateMCPToolCache() + return namedMCPServer{Name: target.Name, Config: cfg, Source: "login"}, count, nil +} + +func (api *ProvisioningAPI) handleConnectMCPServer(w http.ResponseWriter, r *http.Request) { + login, client := api.getClient(w, r) + if client == nil { + return } - if req.ReasoningEffort != nil { - switch *req.ReasoningEffort { - case "", "none", "low", "medium", "high", "xhigh": - meta.Defaults.ReasoningEffort = *req.ReasoningEffort - default: - mautrix.MInvalidParam.WithMessage("reasoning_effort must be one of: none, low, medium, high, xhigh.").Write(w) + var req mcpConnectRequest + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + mautrix.MBadJSON.WithMessage("Invalid JSON: %v.", err).Write(w) return } } - if err := login.Save(r.Context()); err != nil { - mautrix.MUnknown.WithMessage("Couldn't save changes: %v.", err).Write(w) + server, count, err := connectMCPServer(r.Context(), client, login, strings.TrimSpace(r.PathValue("name")), strings.TrimSpace(req.Token)) + if err != nil { + code := http.StatusBadRequest + if mcpCallLikelyAuthError(err) { + code = http.StatusUnauthorized + } else if strings.Contains(err.Error(), "not found") { + code = http.StatusNotFound + } + exhttp.WriteJSONResponse(w, code, map[string]any{ + "error": err.Error(), + "server": mcpServerResponseFromNamed(server), + }) return } + exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{ + "server": mcpServerResponseFromNamed(server), + "tool_count": count, + }) +} - // Return updated defaults - api.handleGetDefaults(w, r) +func (api *ProvisioningAPI) handleDisconnectMCPServer(w http.ResponseWriter, r *http.Request) { + login, client := api.getClient(w, r) + if client == nil { + return + } + target, err := resolveNamedMCPServer(client, strings.TrimSpace(r.PathValue("name"))) + if err != nil { + mautrix.MNotFound.WithMessage("MCP server not found.").Write(w) + return + } + cfg := normalizeMCPServerConfig(target.Config) + cfg.Connected = false + setLoginMCPServer(loginMetadata(login), target.Name, cfg) + if err = login.Save(r.Context()); err != nil { + mautrix.MUnknown.WithMessage("Couldn't disconnect MCP server: %v.", err).Write(w) + return + } + client.invalidateMCPToolCache() + exhttp.WriteJSONResponse(w, http.StatusOK, mcpServerResponseFromNamed(namedMCPServer{Name: target.Name, Config: cfg, Source: "login"})) } diff --git a/pkg/connector/provisioning_test.go b/pkg/connector/provisioning_test.go new file mode 100644 index 00000000..8ee4075d --- /dev/null +++ b/pkg/connector/provisioning_test.go @@ -0,0 +1,126 @@ +package connector + +import ( + "testing" + + "github.com/beeper/ai-bridge/pkg/agents/toolpolicy" +) + +func strPtr(v string) *string { + return &v +} + +func TestApplyProfilePayloadSetsAndClearsFields(t *testing.T) { + meta := &UserLoginMetadata{} + err := applyProfilePayload(meta, profilePayload{ + Name: strPtr(" Batuhan "), + Occupation: strPtr(" Product engineer "), + AboutUser: strPtr(" Works on AI tooling "), + CustomInstructions: strPtr(" Be direct "), + Timezone: strPtr("Europe/Amsterdam"), + }) + if err != nil { + t.Fatalf("applyProfilePayload returned error: %v", err) + } + if meta.Profile == nil { + t.Fatalf("expected profile to be initialized") + } + if meta.Profile.Name != "Batuhan" || meta.Profile.Occupation != "Product engineer" || meta.Profile.AboutUser != "Works on AI tooling" || meta.Profile.CustomInstructions != "Be direct" { + t.Fatalf("unexpected profile contents: %+v", meta.Profile) + } + if meta.Timezone != "Europe/Amsterdam" { + t.Fatalf("expected timezone to be stored, got %q", meta.Timezone) + } + + err = applyProfilePayload(meta, profilePayload{ + Name: strPtr(""), + Occupation: strPtr(""), + AboutUser: strPtr(""), + CustomInstructions: strPtr(""), + Timezone: strPtr(""), + }) + if err != nil { + t.Fatalf("applyProfilePayload clear returned error: %v", err) + } + if meta.Profile != nil { + t.Fatalf("expected empty profile to be cleared, got %+v", meta.Profile) + } + if meta.Timezone != "" { + t.Fatalf("expected timezone to be cleared, got %q", meta.Timezone) + } +} + +func TestApplyProfilePayloadRejectsInvalidTimezone(t *testing.T) { + meta := &UserLoginMetadata{} + err := applyProfilePayload(meta, profilePayload{Timezone: strPtr("Mars/Olympus")}) + if err == nil { + t.Fatal("expected invalid timezone error") + } +} + +func TestNormalizeAgentUpsertRequestCreatesDefinition(t *testing.T) { + agent, err := normalizeAgentUpsertRequest(agentUpsertRequest{ + Name: "Helper", + Description: "Useful", + Model: "openai/gpt-5.2", + ModelFallback: []string{" anthropic/claude-sonnet-4.6 ", ""}, + SystemPrompt: "Be useful", + PromptMode: "append", + Tools: &toolpolicy.ToolPolicyConfig{Allow: []string{"web_search"}}, + IdentityName: "Beep", + IdentityPersona: "Helpful assistant", + }, "") + if err != nil { + t.Fatalf("normalizeAgentUpsertRequest returned error: %v", err) + } + if agent == nil { + t.Fatal("expected agent definition") + } + if agent.ID == "" { + t.Fatal("expected generated agent id") + } + if agent.Name != "Helper" { + t.Fatalf("expected name Helper, got %q", agent.Name) + } + if agent.Model.Primary != "openai/gpt-5.2" { + t.Fatalf("expected primary model to be preserved, got %q", agent.Model.Primary) + } + if len(agent.Model.Fallbacks) != 1 || agent.Model.Fallbacks[0] != "anthropic/claude-sonnet-4.6" { + t.Fatalf("unexpected fallback models: %#v", agent.Model.Fallbacks) + } + if agent.Tools == nil || len(agent.Tools.Allow) != 1 || agent.Tools.Allow[0] != "web_search" { + t.Fatalf("expected tools policy to be preserved, got %#v", agent.Tools) + } +} + +func TestNormalizeMCPRequestValidatesAndNormalizes(t *testing.T) { + name, cfg, err := normalizeMCPRequest(mcpServerUpsertRequest{ + Name: " Search ", + Transport: "streamable_http", + Endpoint: "https://example.com/mcp", + AuthType: "bearer", + Token: "secret", + }, "") + if err != nil { + t.Fatalf("normalizeMCPRequest returned error: %v", err) + } + if name != "search" { + t.Fatalf("expected normalized name 'search', got %q", name) + } + if cfg.Transport != mcpTransportStreamableHTTP { + t.Fatalf("expected transport %q, got %q", mcpTransportStreamableHTTP, cfg.Transport) + } + if cfg.Endpoint != "https://example.com/mcp" { + t.Fatalf("expected endpoint to be preserved, got %q", cfg.Endpoint) + } + if cfg.Token != "secret" { + t.Fatalf("expected token to be preserved, got %q", cfg.Token) + } +} + +func TestNormalizeMCPRequestRejectsMissingTarget(t *testing.T) { + _, _, err := normalizeMCPRequest(mcpServerUpsertRequest{Name: "search"}, "") + if err == nil { + t.Fatal("expected missing target error") + } +} diff --git a/pkg/connector/queue_directive.go b/pkg/connector/queue_directive.go deleted file mode 100644 index ca13fe95..00000000 --- a/pkg/connector/queue_directive.go +++ /dev/null @@ -1,139 +0,0 @@ -package connector - -import ( - "fmt" - "strings" - - airuntime "github.com/beeper/ai-bridge/pkg/runtime" -) - -type queueDirective struct { - QueueMode airuntime.QueueMode - QueueReset bool - RawMode string - DebounceMs *int - Cap *int - DropPolicy *airuntime.QueueDropPolicy - RawDebounce string - RawCap string - RawDrop string - HasOptions bool - HasDebounce bool - HasCap bool - HasDrop bool -} - -func parseQueueDebounce(raw string) *int { - if strings.TrimSpace(raw) == "" { - return nil - } - parsed, err := parseDurationMs(raw, "ms") - if err != nil { - return nil - } - value := int(parsed) - if value < 0 { - return nil - } - return &value -} - -func parseQueueCap(raw string) *int { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil - } - value := 0 - if _, err := fmt.Sscanf(trimmed, "%d", &value); err != nil { - return nil - } - if value < 1 { - return nil - } - return &value -} - -func parseQueueDirectiveArgs(raw string) (consumed int, result queueDirective) { - i := 0 - for i < len(raw) && raw[i] <= ' ' { - i++ - } - if i < len(raw) && raw[i] == ':' { - i++ - for i < len(raw) && raw[i] <= ' ' { - i++ - } - } - consumed = i - takeToken := func() string { - if i >= len(raw) { - return "" - } - start := i - for i < len(raw) && raw[i] > ' ' { - i++ - } - token := raw[start:i] - for i < len(raw) && raw[i] <= ' ' { - i++ - } - if token == "" { - return "" - } - consumed = i - return token - } - - for i < len(raw) { - token := takeToken() - if token == "" { - break - } - lowered := strings.ToLower(strings.TrimSpace(token)) - if lowered == "reset" { - result.QueueReset = true - break - } - if strings.HasPrefix(lowered, "debounce:") { - _, value, _ := strings.Cut(token, ":") - if value != "" { - result.RawDebounce = value - result.DebounceMs = parseQueueDebounce(value) - result.HasOptions = true - result.HasDebounce = true - } - continue - } - if strings.HasPrefix(lowered, "cap:") { - _, value, _ := strings.Cut(token, ":") - if value != "" { - result.RawCap = value - result.Cap = parseQueueCap(value) - result.HasOptions = true - result.HasCap = true - } - continue - } - if strings.HasPrefix(lowered, "drop:") { - _, value, _ := strings.Cut(token, ":") - if value != "" { - result.RawDrop = value - if policy, ok := airuntime.NormalizeQueueDropPolicy(value); ok { - result.DropPolicy = &policy - } - result.HasOptions = true - result.HasDrop = true - } - continue - } - if mode, ok := airuntime.NormalizeQueueMode(token); ok { - result.QueueMode = mode - result.RawMode = token - continue - } - break - } - return consumed, result -} - -// NOTE: Slash-style inline `/queue ...` directives are intentionally not supported. diff --git a/pkg/connector/queue_notice.go b/pkg/connector/queue_notice.go deleted file mode 100644 index 5238e99f..00000000 --- a/pkg/connector/queue_notice.go +++ /dev/null @@ -1,23 +0,0 @@ -package connector - -import ( - "fmt" - - airuntime "github.com/beeper/ai-bridge/pkg/runtime" -) - -const queueDirectiveOptionsHint = "modes steer, followup, collect, steer+backlog, interrupt; debounce:, cap:, drop:old|new|summarize" - -func buildQueueStatusLine(settings airuntime.QueueSettings) string { - debounceLabel := fmt.Sprintf("%dms", settings.DebounceMs) - capLabel := fmt.Sprintf("%d", settings.Cap) - dropLabel := string(settings.DropPolicy) - return fmt.Sprintf( - "Current queue settings: mode=%s, debounce=%s, cap=%s, drop=%s.\nOptions: %s.", - settings.Mode, - debounceLabel, - capLabel, - dropLabel, - queueDirectiveOptionsHint, - ) -} diff --git a/pkg/connector/reasoning_fallback.go b/pkg/connector/reasoning_fallback.go index fd3f63c2..7f58a294 100644 --- a/pkg/connector/reasoning_fallback.go +++ b/pkg/connector/reasoning_fallback.go @@ -34,7 +34,7 @@ func (oc *AIClient) responseWithRetryAndReasoningFallback( if meta != nil && currentLevel != originalLevel { // Clone meta and override reasoning effort metaCopy := *meta - metaCopy.ReasoningEffort = currentLevel + metaCopy.RuntimeReasoning = currentLevel effectiveMeta = &metaCopy oc.loggerForContext(ctx).Info(). Str("original_level", originalLevel). diff --git a/pkg/connector/remote_message.go b/pkg/connector/remote_message.go index 8fb74ddf..7892473c 100644 --- a/pkg/connector/remote_message.go +++ b/pkg/connector/remote_message.go @@ -80,12 +80,10 @@ func (m *OpenAIRemoteMessage) ConvertMessage(ctx context.Context, portal *bridge m.Metadata.Body = m.Content } - // Get model from metadata or portal fallback + // Prefer the message metadata model when present. model := "" if m.Metadata != nil && m.Metadata.Model != "" { model = m.Metadata.Model - } else if portalMeta, ok := portal.Metadata.(*PortalMetadata); ok && portalMeta.Model != "" { - model = portalMeta.Model } var thinkingContent string diff --git a/pkg/connector/response_finalization.go b/pkg/connector/response_finalization.go index 4e784033..94215526 100644 --- a/pkg/connector/response_finalization.go +++ b/pkg/connector/response_finalization.go @@ -221,12 +221,6 @@ func (oc *AIClient) sendFinalAssistantTurn(ctx context.Context, portal *bridgev2 cleanedContent := airuntime.SanitizeChatMessageForDisplay(directives.Text, false) finalReplyTarget := oc.resolveFinalReplyTarget(meta, state, &directives) - responsePrefix := resolveResponsePrefixForReply(oc, &oc.connector.Config, meta) - if responsePrefix != "" && strings.TrimSpace(cleanedContent) != "" { - if !strings.HasPrefix(cleanedContent, responsePrefix) { - cleanedContent = responsePrefix + " " + cleanedContent - } - } rendered := format.RenderMarkdown(cleanedContent, true, true) if finalReplyTarget.ReplyTo != "" { replyTo := finalReplyTarget.ReplyTo @@ -266,12 +260,6 @@ func (oc *AIClient) sendFinalHeartbeatTurn(ctx context.Context, portal *bridgev2 } shouldSkip = false } - responsePrefix := strings.TrimSpace(hb.ResponsePrefix) - if responsePrefix != "" && strings.TrimSpace(finalText) != "" && !shouldSkip { - if !strings.HasPrefix(finalText, responsePrefix) { - finalText = responsePrefix + " " + finalText - } - } cleaned := strings.TrimSpace(finalText) hasMedia := len(state.pendingImages) > 0 shouldSkipMain := shouldSkip && !hasMedia && !hb.ExecEvent @@ -304,11 +292,7 @@ func (oc *AIClient) sendFinalHeartbeatTurn(ctx context.Context, portal *bridgev2 oc.restoreHeartbeatUpdatedAt(storeRef, hb.SessionKey, hb.PrevUpdatedAt) silent := true if hb.ShowOk && deliverable { - heartbeatOk := agents.HeartbeatToken - if responsePrefix != "" { - heartbeatOk = responsePrefix + " " + agents.HeartbeatToken - } - oc.sendPlainAssistantMessage(ctx, portal, heartbeatOk) + oc.sendPlainAssistantMessage(ctx, portal, agents.HeartbeatToken) silent = false } oc.redactInitialStreamingMessage(ctx, portal, state) @@ -728,11 +712,9 @@ func generateOutboundLinkPreviews(ctx context.Context, text string, intent bridg return UploadPreviewImages(ctx, previewsWithImages, intent, portal.MXID) } -// getAgentResponseMode returns the response mode for the current agent. -// Defaults to ResponseModeNatural if not set. -// IsSimpleMode on the portal overrides all other settings (for simple mode rooms). +// getAgentResponseMode returns the response mode for the current room target. +// Defaults to ResponseModeNatural if no agent-specific mode is configured. func (oc *AIClient) getAgentResponseMode(meta *PortalMetadata) agents.ResponseMode { - // Simple mode flag takes priority (set by simple command) if isSimpleMode(meta) { return agents.ResponseModeSimple } diff --git a/pkg/connector/response_finalization_test.go b/pkg/connector/response_finalization_test.go index ed0dfb74..0dcc444f 100644 --- a/pkg/connector/response_finalization_test.go +++ b/pkg/connector/response_finalization_test.go @@ -33,7 +33,7 @@ func TestBuildFinalEditUIMessage_IncludesSourceAndFileParts(t *testing.T) { streamui.ApplyChunk(&state.ui, map[string]any{"type": "text-delta", "id": "text-1", "delta": "hello"}) streamui.ApplyChunk(&state.ui, map[string]any{"type": "text-end", "id": "text-1"}) - ui := oc.buildFinalEditUIMessage(state, &PortalMetadata{Model: "gpt-4o"}, nil) + ui := oc.buildFinalEditUIMessage(state, simpleModeTestMeta("openai/gpt-4o"), nil) if ui == nil { t.Fatalf("expected final edit UI message") } diff --git a/pkg/connector/response_prefix.go b/pkg/connector/response_prefix.go deleted file mode 100644 index 880d8f6b..00000000 --- a/pkg/connector/response_prefix.go +++ /dev/null @@ -1,123 +0,0 @@ -package connector - -import ( - "context" - "strings" - - "github.com/beeper/ai-bridge/pkg/agents" -) - -func resolveChannelResponsePrefix(cfg *Config) string { - if cfg == nil || cfg.Channels == nil { - return "" - } - if cfg.Channels.Matrix != nil { - if trimmed := strings.TrimSpace(cfg.Channels.Matrix.ResponsePrefix); trimmed != "" { - return trimmed - } - } - if cfg.Channels.Defaults != nil { - if trimmed := strings.TrimSpace(cfg.Channels.Defaults.ResponsePrefix); trimmed != "" { - return trimmed - } - } - return "" -} - -func resolveResponsePrefixRaw(oc *AIClient, cfg *Config, meta *PortalMetadata) string { - if meta != nil { - if trimmed := strings.TrimSpace(meta.ResponsePrefix); trimmed != "" { - return trimmed - } - } - if oc != nil && oc.UserLogin != nil { - if login := loginMetadata(oc.UserLogin); login != nil { - if trimmed := strings.TrimSpace(login.ResponsePrefix); trimmed != "" { - return trimmed - } - } - } - if channelPrefix := resolveChannelResponsePrefix(cfg); channelPrefix != "" { - return channelPrefix - } - if cfg == nil || cfg.Messages == nil { - return "" - } - return strings.TrimSpace(cfg.Messages.ResponsePrefix) -} - -func resolveIdentityNameForPrefix(oc *AIClient, agentID string) string { - if oc == nil { - return "" - } - resolved := strings.TrimSpace(agentID) - if resolved == "" { - resolved = agents.DefaultAgentID - } - store := NewAgentStoreAdapter(oc) - if agent, err := store.GetAgentByID(context.Background(), resolved); err == nil && agent != nil { - if agent.Identity != nil && strings.TrimSpace(agent.Identity.Name) != "" { - return strings.TrimSpace(agent.Identity.Name) - } - } - return oc.resolveAgentIdentityName(context.Background(), resolved) -} - -func buildResponsePrefixContext(oc *AIClient, agentID string, meta *PortalMetadata) ResponsePrefixContext { - ctx := ResponsePrefixContext{ - IdentityName: resolveIdentityNameForPrefix(oc, agentID), - } - if oc == nil { - return ctx - } - modelFull := oc.effectiveModel(meta) - if modelFull != "" { - ctx.ModelFull = modelFull - ctx.Model = extractShortModelName(modelFull) - ctx.Provider, _ = splitModelProvider(modelFull) - } - if ctx.Provider == "" { - if login := loginMetadata(oc.UserLogin); login != nil { - ctx.Provider = strings.TrimSpace(login.Provider) - } - } - think := strings.TrimSpace(oc.effectiveReasoningEffort(meta)) - if think == "" { - think = "off" - } - ctx.ThinkingLevel = think - return ctx -} - -func resolveResponsePrefixForHeartbeat(oc *AIClient, cfg *Config, agentID string, meta *PortalMetadata) string { - raw := resolveResponsePrefixRaw(oc, cfg, meta) - if raw == "" { - return "" - } - if strings.EqualFold(raw, "auto") { - name := resolveIdentityNameForPrefix(oc, agentID) - if name == "" { - return "" - } - return "[" + name + "]" - } - ctx := buildResponsePrefixContext(oc, agentID, meta) - return resolveResponsePrefixTemplate(raw, ctx) -} - -func resolveResponsePrefixForReply(oc *AIClient, cfg *Config, meta *PortalMetadata) string { - raw := resolveResponsePrefixRaw(oc, cfg, meta) - if raw == "" { - return "" - } - agentID := resolveAgentID(meta) - if strings.EqualFold(raw, "auto") { - name := resolveIdentityNameForPrefix(oc, agentID) - if name == "" { - return "" - } - return "[" + name + "]" - } - ctx := buildResponsePrefixContext(oc, agentID, meta) - return resolveResponsePrefixTemplate(raw, ctx) -} diff --git a/pkg/connector/response_prefix_template.go b/pkg/connector/response_prefix_template.go deleted file mode 100644 index cf819664..00000000 --- a/pkg/connector/response_prefix_template.go +++ /dev/null @@ -1,67 +0,0 @@ -package connector - -import ( - "regexp" - "strings" -) - -// ResponsePrefixContext mirrors OpenClaw's template context. -type ResponsePrefixContext struct { - Model string - ModelFull string - Provider string - ThinkingLevel string - IdentityName string -} - -var responsePrefixTemplatePattern = regexp.MustCompile(`\{([a-zA-Z][a-zA-Z0-9.]*)\}`) -var responsePrefixDateSuffix = regexp.MustCompile(`-\d{8}$`) - -func resolveResponsePrefixTemplate(template string, ctx ResponsePrefixContext) string { - if template == "" { - return "" - } - return responsePrefixTemplatePattern.ReplaceAllStringFunc(template, func(match string) string { - groups := responsePrefixTemplatePattern.FindStringSubmatch(match) - if len(groups) < 2 { - return match - } - varName := strings.ToLower(groups[1]) - switch varName { - case "model": - if ctx.Model != "" { - return ctx.Model - } - case "modelfull": - if ctx.ModelFull != "" { - return ctx.ModelFull - } - case "provider": - if ctx.Provider != "" { - return ctx.Provider - } - case "thinkinglevel", "think": - if ctx.ThinkingLevel != "" { - return ctx.ThinkingLevel - } - case "identity.name", "identityname": - if ctx.IdentityName != "" { - return ctx.IdentityName - } - } - return match - }) -} - -func extractShortModelName(fullModel string) string { - modelPart := strings.TrimSpace(fullModel) - if modelPart == "" { - return "" - } - if idx := strings.LastIndex(modelPart, "/"); idx >= 0 && idx+1 < len(modelPart) { - modelPart = modelPart[idx+1:] - } - modelPart = responsePrefixDateSuffix.ReplaceAllString(modelPart, "") - modelPart = strings.TrimSuffix(modelPart, "-latest") - return modelPart -} diff --git a/pkg/connector/room_settings_event_content_test.go b/pkg/connector/room_settings_event_content_test.go deleted file mode 100644 index 160e6ffd..00000000 --- a/pkg/connector/room_settings_event_content_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package connector - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestRoomSettingsEventContentUnmarshalAgentID(t *testing.T) { - var content RoomSettingsEventContent - if err := json.Unmarshal([]byte(`{"agent_id":"beeper"}`), &content); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if content.AgentID != "beeper" { - t.Fatalf("expected agent_id to populate AgentID, got %q", content.AgentID) - } -} - -func TestRoomSettingsEventContentMarshalUsesCanonicalAgentID(t *testing.T) { - raw, err := json.Marshal(RoomSettingsEventContent{ - Model: "openai/gpt-5", - AgentID: "beeper", - }) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } - encoded := string(raw) - if !strings.Contains(encoded, `"agent_id":"beeper"`) { - t.Fatalf("expected canonical agent_id field, got %s", encoded) - } - if strings.Contains(encoded, "default_agent_id") { - t.Fatalf("did not expect legacy default_agent_id field in %s", encoded) - } -} diff --git a/pkg/connector/scheduler_cron.go b/pkg/connector/scheduler_cron.go index 63d70b17..b5f26878 100644 --- a/pkg/connector/scheduler_cron.go +++ b/pkg/connector/scheduler_cron.go @@ -326,12 +326,12 @@ func (s *schedulerRuntime) executeCronJob(ctx context.Context, record *scheduled if meta == nil { meta = &PortalMetadata{} } - meta.AgentID = normalizedCronAgentID(&record.Job.AgentID) - if model := strings.TrimSpace(record.Job.Payload.Model); model != "" { - meta.Model = model + if portal.OtherUserID == "" { + portal.OtherUserID = agentUserID(normalizedCronAgentID(&record.Job.AgentID)) } - if thinking := strings.TrimSpace(record.Job.Payload.Thinking); thinking != "" { - meta.ReasoningEffort = thinking + meta.ResolvedTarget = resolveTargetFromGhostID(portal.OtherUserID) + if model := strings.TrimSpace(record.Job.Payload.Model); model != "" { + meta.RuntimeModelOverride = ResolveAlias(model) } if record.Job.Delivery != nil && record.Job.Delivery.Mode == integrationcron.DeliveryAnnounce { meta.DisabledTools = appendMissingDisabledTool(meta.DisabledTools, "message") @@ -391,7 +391,7 @@ func (s *schedulerRuntime) resolveCronDeliveryTarget(agentID string, delivery *i return true } meta := portalMeta(portal) - return meta != nil && normalizeAgentID(meta.AgentID) != normalizeAgentID(agentID) + return meta != nil && normalizeAgentID(resolveAgentID(meta)) != normalizeAgentID(agentID) }, LastActiveRoomID: func(agentID string) string { if portal := s.client.lastActivePortal(agentID); portal != nil && portal.MXID != "" { diff --git a/pkg/connector/scheduler_rooms.go b/pkg/connector/scheduler_rooms.go index fb9daa28..ad810d92 100644 --- a/pkg/connector/scheduler_rooms.go +++ b/pkg/connector/scheduler_rooms.go @@ -14,7 +14,6 @@ func (s *schedulerRuntime) ensureCronRoomLocked(ctx context.Context, record *sch } portalID := fmt.Sprintf("cron:%s:%s", normalizeAgentID(record.Job.AgentID), strings.TrimSpace(record.Job.ID)) portal, err := s.getOrCreateScheduledPortal(ctx, portalID, fmt.Sprintf("Cron: %s", strings.TrimSpace(record.Job.Name)), func(meta *PortalMetadata) { - meta.AgentID = normalizeAgentID(record.Job.AgentID) if meta.ModuleMeta == nil { meta.ModuleMeta = make(map[string]any) } @@ -29,6 +28,10 @@ func (s *schedulerRuntime) ensureCronRoomLocked(ctx context.Context, record *sch if err != nil { return err } + portal.OtherUserID = agentUserID(normalizeAgentID(record.Job.AgentID)) + if err := portal.Save(ctx); err != nil { + return err + } record.RoomID = portal.MXID.String() return nil } @@ -39,7 +42,6 @@ func (s *schedulerRuntime) ensureHeartbeatRoomLocked(ctx context.Context, state } portalID := fmt.Sprintf("heartbeat:%s", normalizeAgentID(state.AgentID)) portal, err := s.getOrCreateScheduledPortal(ctx, portalID, fmt.Sprintf("Heartbeat: %s", state.AgentID), func(meta *PortalMetadata) { - meta.AgentID = normalizeAgentID(state.AgentID) if meta.ModuleMeta == nil { meta.ModuleMeta = make(map[string]any) } @@ -54,6 +56,10 @@ func (s *schedulerRuntime) ensureHeartbeatRoomLocked(ctx context.Context, state if err != nil { return err } + portal.OtherUserID = agentUserID(normalizeAgentID(state.AgentID)) + if err := portal.Save(ctx); err != nil { + return err + } state.RoomID = portal.MXID.String() return nil } diff --git a/pkg/connector/session_greeting_test.go b/pkg/connector/session_greeting_test.go index 1a629d1e..032e615f 100644 --- a/pkg/connector/session_greeting_test.go +++ b/pkg/connector/session_greeting_test.go @@ -10,7 +10,7 @@ import ( func TestMaybePrependSessionGreeting(t *testing.T) { ctx := context.Background() - meta := &PortalMetadata{AgentID: "beeper"} + meta := agentModeTestMeta("beeper") prompt := []openai.ChatCompletionMessageParamUnion{} out := maybePrependSessionGreeting(ctx, nil, meta, prompt, zerolog.Nop()) diff --git a/pkg/connector/sessions_tools.go b/pkg/connector/sessions_tools.go index 05c59370..1f70890b 100644 --- a/pkg/connector/sessions_tools.go +++ b/pkg/connector/sessions_tools.go @@ -30,7 +30,7 @@ func shouldExcludeModelVisiblePortal(meta *PortalMetadata) bool { if meta == nil { return false } - if isModuleInternalRoom(meta) || meta.IsBuilderRoom { + if isModuleInternalRoom(meta) { return true } return strings.TrimSpace(meta.SubagentParentRoomID) != "" @@ -125,11 +125,7 @@ func (oc *AIClient) executeSessionsList(ctx context.Context, portal *bridgev2.Po entry["updatedAt"] = updatedAt } if meta != nil { - model := meta.Model - if strings.TrimSpace(model) == "" { - model = oc.effectiveModel(meta) - } - if model != "" { + if model := oc.effectiveModel(meta); model != "" { entry["model"] = model } } diff --git a/pkg/connector/sessions_visibility_test.go b/pkg/connector/sessions_visibility_test.go index 16da5d17..2e7e1934 100644 --- a/pkg/connector/sessions_visibility_test.go +++ b/pkg/connector/sessions_visibility_test.go @@ -12,7 +12,6 @@ func TestShouldExcludeModelVisiblePortal(t *testing.T) { meta PortalMetadata }{ {name: "cron", meta: PortalMetadata{ModuleMeta: map[string]any{"cron": map[string]any{"is_internal_room": true}}}}, - {name: "builder", meta: PortalMetadata{IsBuilderRoom: true}}, {name: "subagent", meta: PortalMetadata{SubagentParentRoomID: "!parent:example.com"}}, } for _, tc := range cases { diff --git a/pkg/connector/simple_mode_prompt.go b/pkg/connector/simple_mode_prompt.go index e16a64d9..9de24340 100644 --- a/pkg/connector/simple_mode_prompt.go +++ b/pkg/connector/simple_mode_prompt.go @@ -16,19 +16,14 @@ import ( // Simple mode uses a single system prompt with only the current time appended. func (oc *AIClient) buildSimpleModeSystemPrompt(meta *PortalMetadata) string { base := defaultSimpleModeSystemPrompt - if meta != nil { - if v := strings.TrimSpace(meta.SystemPrompt); v != "" { - base = v - } - } - timezone, _ := oc.resolveUserTimezone() now := formatCurrentTimeForPrompt(timezone) - lines := []string{ - strings.TrimSpace(base), - "Current time: " + now, + lines := []string{strings.TrimSpace(base)} + if supplement := strings.TrimSpace(oc.profilePromptSupplement()); supplement != "" { + lines = append(lines, supplement) } + lines = append(lines, "Current time: "+now) return strings.TrimSpace(strings.Join(lines, "\n")) } diff --git a/pkg/connector/simple_mode_prompt_test.go b/pkg/connector/simple_mode_prompt_test.go index dd29db0c..03b55c1d 100644 --- a/pkg/connector/simple_mode_prompt_test.go +++ b/pkg/connector/simple_mode_prompt_test.go @@ -19,8 +19,11 @@ func TestSimpleModePrompt_HasSingleSystemPromptWithTimeAndWebSearch(t *testing.T } meta := &PortalMetadata{ - IsSimpleMode: true, - // No SystemPrompt override: should use defaultSimpleModeSystemPrompt. + ResolvedTarget: &ResolvedTarget{ + Kind: ResolvedTargetModel, + GhostID: modelUserID("openai/gpt-5.2"), + ModelID: "openai/gpt-5.2", + }, } out, err := client.buildPromptWithLinkContext(context.Background(), nil, meta, "hello", nil, "") @@ -69,9 +72,10 @@ func TestSimpleModePrompt_NoWebSearchHintEvenWhenConfigured(t *testing.T) { } meta := &PortalMetadata{ - IsSimpleMode: true, - Capabilities: ModelCapabilities{ - SupportsToolCalling: true, + ResolvedTarget: &ResolvedTarget{ + Kind: ResolvedTargetModel, + GhostID: modelUserID("openai/gpt-5.2"), + ModelID: "openai/gpt-5.2", }, } @@ -112,7 +116,7 @@ func TestSimpleModePrompt_LatestUserMessageUnchanged_NoLinkContext_NoMessageID(t }, } - meta := &PortalMetadata{IsSimpleMode: true} + meta := &PortalMetadata{ResolvedTarget: &ResolvedTarget{Kind: ResolvedTargetModel, GhostID: modelUserID("openai/gpt-5.2"), ModelID: "openai/gpt-5.2"}} latest := "check this: https://example.com" out, err := client.buildPromptWithLinkContext(context.Background(), nil, meta, latest, nil, "$evt") @@ -139,7 +143,7 @@ func TestSimpleModePrompt_LatestUserMessageUnchanged_NoLinkContext_NoMessageID(t func TestBuildMatrixInboundBody_SimpleModeBypassesEnvelopeAndSenderMeta(t *testing.T) { client := &AIClient{} - meta := &PortalMetadata{IsSimpleMode: true} + meta := &PortalMetadata{ResolvedTarget: &ResolvedTarget{Kind: ResolvedTargetModel, GhostID: modelUserID("openai/gpt-5.2"), ModelID: "openai/gpt-5.2"}} got := client.buildMatrixInboundBody(context.Background(), nil, meta, nil, " hi ", "Alice", "Room", true) if got != "hi" { diff --git a/pkg/connector/status_text.go b/pkg/connector/status_text.go index d2a5e09f..6c24244f 100644 --- a/pkg/connector/status_text.go +++ b/pkg/connector/status_text.go @@ -1,14 +1,11 @@ package connector import ( - "cmp" "context" "fmt" - "slices" "strings" "time" - "github.com/openai/openai-go/v3" "maunium.net/go/mautrix/bridgev2" airuntime "github.com/beeper/ai-bridge/pkg/runtime" @@ -81,40 +78,14 @@ func (oc *AIClient) buildStatusText( sb.WriteString(fmt.Sprintf("Group activation: %s\n", activation)) } - thinking := oc.defaultThinkLevel(meta) - reasoning := strings.TrimSpace(meta.ReasoningEffort) - if reasoning == "" { - if meta.EmitThinking { - reasoning = "on" - } else { - reasoning = "off" - } - } - verbose := strings.TrimSpace(meta.VerboseLevel) - if verbose == "" { - verbose = "off" - } - elevated := strings.TrimSpace(meta.ElevatedLevel) - if elevated == "" { - elevated = "off" - } - sendPolicy := normalizeSendPolicyMode(meta.SendPolicy) - if sendPolicy == "" { - sendPolicy = "allow" - } - sendLabel := "on" - if sendPolicy == "deny" { - sendLabel = "off" - } - responseMode := string(oc.getAgentResponseMode(meta)) + caps := oc.getRoomCapabilities(ctx, meta) sb.WriteString(fmt.Sprintf( - "Options: think=%s reasoning=%s verbose=%s elevated=%s send=%s response=%s\n", - thinking, - reasoning, - verbose, - elevated, - sendLabel, - responseMode, + "Features: tools=%t vision=%t audio=%t video=%t pdf=%t\n", + caps.SupportsToolCalling, + caps.SupportsVision, + caps.SupportsAudio, + caps.SupportsVideo, + caps.SupportsPDF, )) queueDepth := 0 @@ -208,64 +179,6 @@ func formatTypingInterval(interval time.Duration) string { return fmt.Sprintf("%ds", seconds) } -func (oc *AIClient) buildContextStatus(ctx context.Context, portal *bridgev2.Portal, meta *PortalMetadata) string { - if meta == nil || portal == nil { - return "Context unavailable" - } - var sb strings.Builder - sb.WriteString("Context\n") - modelID := oc.effectiveModel(meta) - provider := strings.TrimSpace(loginMetadata(oc.UserLogin).Provider) - if provider != "" { - sb.WriteString(fmt.Sprintf("Model: %s/%s\n", provider, modelID)) - } else { - sb.WriteString(fmt.Sprintf("Model: %s\n", modelID)) - } - - contextWindow := oc.getModelContextWindow(meta) - estimate := oc.estimatePromptTokens(ctx, portal, meta) - if estimate > 0 { - sb.WriteString(fmt.Sprintf( - "Prompt estimate: %s/%s (%s)\n", - formatCompactTokens(int64(estimate)), - formatCompactTokens(int64(contextWindow)), - formatPercent(estimate, contextWindow), - )) - } else { - sb.WriteString(fmt.Sprintf("Context window: %s tokens\n", formatCompactTokens(int64(contextWindow)))) - } - - systemPrompt := oc.effectivePrompt(meta) - if systemPrompt != "" { - sysTokens := 0 - if count, err := EstimateTokens([]openai.ChatCompletionMessageParamUnion{openai.SystemMessage(systemPrompt)}, modelID); err == nil { - sysTokens = count - } - sysLine := fmt.Sprintf("System prompt: %d chars", len(systemPrompt)) - if sysTokens > 0 { - sysLine = fmt.Sprintf("%s (%s tokens)", sysLine, formatCompactTokens(int64(sysTokens))) - } - sb.WriteString(sysLine + "\n") - } - - historyLimit := oc.historyLimit(ctx, portal, meta) - historyCount := 0 - if historyLimit > 0 { - if history, err := oc.UserLogin.Bridge.DB.Message.GetLastNInPortal(ctx, portal.PortalKey, historyLimit); err == nil { - historyCount = len(history) - } - } - sb.WriteString(fmt.Sprintf("History limit: %d messages\n", historyLimit)) - sb.WriteString(fmt.Sprintf("History loaded: %d messages\n", historyCount)) - - sb.WriteString(fmt.Sprintf("Compactions: %d\n", meta.CompactionCount)) - - if meta.SessionResetAt > 0 { - sb.WriteString(fmt.Sprintf("Session reset: %s\n", time.UnixMilli(meta.SessionResetAt).Format(time.RFC3339))) - } - return strings.TrimSpace(sb.String()) -} - type assistantUsageSnapshot struct { promptTokens int64 completionTokens int64 @@ -364,36 +277,3 @@ func formatAge(deltaMs int64) string { } return fmt.Sprintf("%dd ago", int(d.Hours()/24)) } - -func (oc *AIClient) buildToolsStatusText(meta *PortalMetadata) string { - var sb strings.Builder - sb.WriteString("Tool Status:\n\n") - - toolsList := oc.buildAvailableTools(meta) - slices.SortFunc(toolsList, func(a, b ToolInfo) int { - return cmp.Compare(a.Name, b.Name) - }) - - sb.WriteString("Tools:\n") - for _, tool := range toolsList { - status := "✗" - if tool.Enabled { - status = "✓" - } - desc := tool.Description - if desc == "" { - desc = tool.DisplayName - } - reason := "" - if !tool.Enabled && tool.Reason != "" { - reason = fmt.Sprintf(" (%s)", tool.Reason) - } - sb.WriteString(fmt.Sprintf(" [%s] %s: %s%s\n", status, tool.Name, desc, reason)) - } - - if meta != nil && !meta.Capabilities.SupportsToolCalling { - sb.WriteString(fmt.Sprintf("\nNote: Current model (%s) may not support tool calling.\n", oc.effectiveModel(meta))) - } - - return strings.TrimSpace(sb.String()) -} diff --git a/pkg/connector/streaming_chat_completions.go b/pkg/connector/streaming_chat_completions.go index e2b1f534..5619a4ab 100644 --- a/pkg/connector/streaming_chat_completions.go +++ b/pkg/connector/streaming_chat_completions.go @@ -72,8 +72,8 @@ func (oc *AIClient) streamChatCompletions( if len(enabledTools) > 0 { params.Tools = append(params.Tools, ToOpenAIChatTools(enabledTools, &oc.log)...) } - if meta.Capabilities.SupportsToolCalling && chatHasAgent { - if !oc.isBuilderRoom(portal) { + if oc.getModelCapabilitiesForMeta(meta).SupportsToolCalling && chatHasAgent { + if !hasBossAgent(meta) { var enabledSessions []*tools.Tool for _, tool := range tools.SessionTools() { if oc.isToolEnabled(meta, tool.Name) { @@ -84,7 +84,7 @@ func (oc *AIClient) streamChatCompletions( params.Tools = append(params.Tools, bossToolsToChatTools(enabledSessions, &oc.log)...) } } - if hasBossAgent(meta) || oc.isBuilderRoom(portal) { + if hasBossAgent(meta) { var enabledBoss []*tools.Tool for _, tool := range tools.BossTools() { if oc.isToolEnabled(meta, tool.Name) { diff --git a/pkg/connector/streaming_continuation.go b/pkg/connector/streaming_continuation.go index e81eb0cc..d43813cf 100644 --- a/pkg/connector/streaming_continuation.go +++ b/pkg/connector/streaming_continuation.go @@ -93,7 +93,7 @@ func (oc *AIClient) buildContinuationParams( } // Add session tools for non-boss agent rooms (needed for multi-turn tool use) - if meta.Capabilities.SupportsToolCalling && agentID != "" && !(hasBossAgent(meta) || agents.IsBossAgent(agentID)) { + if oc.getModelCapabilitiesForMeta(meta).SupportsToolCalling && agentID != "" && !(hasBossAgent(meta) || agents.IsBossAgent(agentID)) { var enabledSessions []*tools.Tool for _, tool := range tools.SessionTools() { if oc.isToolEnabled(meta, tool.Name) { diff --git a/pkg/connector/streaming_finish_reason_test.go b/pkg/connector/streaming_finish_reason_test.go index 9472a997..956707fb 100644 --- a/pkg/connector/streaming_finish_reason_test.go +++ b/pkg/connector/streaming_finish_reason_test.go @@ -87,7 +87,7 @@ func TestBuildCanonicalUIMessage_IncludesSourceAndFileParts(t *testing.T) { }}, } - ui := oc.buildCanonicalUIMessage(state, &PortalMetadata{Model: "gpt-4o"}) + ui := oc.buildCanonicalUIMessage(state, simpleModeTestMeta("openai/gpt-4o")) if ui == nil { t.Fatalf("expected canonical message") } diff --git a/pkg/connector/streaming_init_test.go b/pkg/connector/streaming_init_test.go index 4d6a72cd..1f9a4c0d 100644 --- a/pkg/connector/streaming_init_test.go +++ b/pkg/connector/streaming_init_test.go @@ -13,8 +13,11 @@ import ( func TestPrepareStreamingRun_SimpleModeClearsReplyTarget(t *testing.T) { oc := &AIClient{} meta := &PortalMetadata{ - IsSimpleMode: true, - SendPolicy: "deny", + ResolvedTarget: &ResolvedTarget{ + Kind: ResolvedTargetModel, + GhostID: modelUserID("openai/gpt-5.2"), + ModelID: "openai/gpt-5.2", + }, } evt := &event.Event{ ID: id.EventID("$evt"), @@ -50,9 +53,7 @@ func TestPrepareStreamingRun_SimpleModeClearsReplyTarget(t *testing.T) { func TestPrepareStreamingRun_NonSimpleKeepsReplyTarget(t *testing.T) { oc := &AIClient{} - meta := &PortalMetadata{ - SendPolicy: "deny", - } + meta := &PortalMetadata{} evt := &event.Event{ ID: id.EventID("$evt"), Sender: id.UserID("@alice:example.com"), diff --git a/pkg/connector/streaming_params.go b/pkg/connector/streaming_params.go index dac01b14..ccd4b3a8 100644 --- a/pkg/connector/streaming_params.go +++ b/pkg/connector/streaming_params.go @@ -35,7 +35,7 @@ func (oc *AIClient) buildResponsesAPIParams(ctx context.Context, portal *bridgev OfInputItemList: input, } - // Add reasoning effort if configured (uses inheritance: room → user → default) + // Add reasoning effort when the resolved target supports it. if reasoningEffort := oc.effectiveReasoningEffort(meta); reasoningEffort != "" { params.Reasoning = shared.ReasoningParam{ Effort: shared.ReasoningEffort(reasoningEffort), @@ -59,9 +59,9 @@ func (oc *AIClient) buildResponsesAPIParams(ctx context.Context, portal *bridgev log.Debug().Int("count", len(enabledTools)).Msg("Added builtin function tools") } - if meta.Capabilities.SupportsToolCalling && hasAgent { - // Add session tools for non-boss rooms - if !hasBossAgent(meta) && !oc.isBuilderRoom(portal) { + if oc.getModelCapabilitiesForMeta(meta).SupportsToolCalling && hasAgent { + // Add session tools for non-boss agent rooms. + if !hasBossAgent(meta) { var enabledSessions []*tools.Tool for _, tool := range tools.SessionTools() { if oc.isToolEnabled(meta, tool.Name) { @@ -76,7 +76,7 @@ func (oc *AIClient) buildResponsesAPIParams(ctx context.Context, portal *bridgev } // Add boss tools if this is a Boss room - if hasBossAgent(meta) || oc.isBuilderRoom(portal) { + if hasBossAgent(meta) { var enabledBoss []*tools.Tool for _, tool := range tools.BossTools() { if oc.isToolEnabled(meta, tool.Name) { diff --git a/pkg/connector/streaming_state.go b/pkg/connector/streaming_state.go index 6d9ab2fa..2a9eff95 100644 --- a/pkg/connector/streaming_state.go +++ b/pkg/connector/streaming_state.go @@ -111,9 +111,6 @@ func newStreamingState(ctx context.Context, meta *PortalMetadata, sourceEventID ui: ui, pendingMcpApprovalsSeen: make(map[string]bool), } - if meta != nil && normalizeSendPolicyMode(meta.SendPolicy) == "deny" { - state.suppressSend = true - } if hb := heartbeatRunFromContext(ctx); hb != nil { state.heartbeat = hb.Config state.heartbeatResultCh = hb.ResultCh diff --git a/pkg/connector/streaming_tool_selection.go b/pkg/connector/streaming_tool_selection.go index 87924315..080e1a2a 100644 --- a/pkg/connector/streaming_tool_selection.go +++ b/pkg/connector/streaming_tool_selection.go @@ -5,7 +5,7 @@ import "context" // selectedBuiltinToolsForTurn returns builtin tools exposed to the model for a turn. // Simple mode stays minimal: it only exposes web_search when tool-calling is supported. func (oc *AIClient) selectedBuiltinToolsForTurn(ctx context.Context, meta *PortalMetadata) []ToolDefinition { - if meta == nil || !meta.Capabilities.SupportsToolCalling { + if meta == nil || !oc.getModelCapabilitiesForMeta(meta).SupportsToolCalling { return nil } diff --git a/pkg/connector/streaming_tool_selection_test.go b/pkg/connector/streaming_tool_selection_test.go index f66e85cb..b6e330d5 100644 --- a/pkg/connector/streaming_tool_selection_test.go +++ b/pkg/connector/streaming_tool_selection_test.go @@ -18,12 +18,7 @@ func TestSelectedBuiltinToolsForTurn_SimpleModeEnablesOnlyWebSearch(t *testing.T }, } - meta := &PortalMetadata{ - IsSimpleMode: true, - Capabilities: ModelCapabilities{ - SupportsToolCalling: true, - }, - } + meta := simpleModeTestMeta("openai/gpt-5.2") got := client.selectedBuiltinToolsForTurn(context.Background(), meta) if len(got) != 1 { @@ -47,11 +42,7 @@ func TestSelectedBuiltinToolsForTurn_NonAgentNonSimpleGetsNoTools(t *testing.T) }, } - meta := &PortalMetadata{ - Capabilities: ModelCapabilities{ - SupportsToolCalling: true, - }, - } + meta := &PortalMetadata{} got := client.selectedBuiltinToolsForTurn(context.Background(), meta) if len(got) != 0 { diff --git a/pkg/connector/subagent_spawn.go b/pkg/connector/subagent_spawn.go index f37f6962..c80f9881 100644 --- a/pkg/connector/subagent_spawn.go +++ b/pkg/connector/subagent_spawn.go @@ -299,15 +299,8 @@ func (oc *AIClient) executeSessionsSpawn(ctx context.Context, portal *bridgev2.P childMeta := portalMeta(childPortal) childMeta.SubagentParentRoomID = portal.MXID.String() - childMeta.SystemPrompt = agents.BuildSubagentSystemPrompt(agents.SubagentPromptParams{ - RequesterSessionKey: portal.MXID.String(), - RequesterChannel: "matrix", - ChildSessionKey: childPortal.MXID.String(), - Label: label, - Task: task, - }) if reasoningEffort != "" { - childMeta.ReasoningEffort = reasoningEffort + childMeta.RuntimeReasoning = reasoningEffort } roomName := resolveSubagentRoomName(label, task) diff --git a/pkg/connector/system_prompts.go b/pkg/connector/system_prompts.go index 89f705f0..dc3f7683 100644 --- a/pkg/connector/system_prompts.go +++ b/pkg/connector/system_prompts.go @@ -34,18 +34,8 @@ func buildGroupIntro(roomName string, activation string) string { } func buildVerboseSystemHint(meta *PortalMetadata) string { - if meta == nil { - return "" - } - level := strings.ToLower(strings.TrimSpace(meta.VerboseLevel)) - switch level { - case "on": - return "Verbosity: on. Provide a bit more detail and context when helpful, but stay focused." - case "full": - return "Verbosity: full. Be thorough and detailed. Explain assumptions and reasoning clearly, without unnecessary fluff." - default: - return "" - } + _ = meta + return "" } func buildSessionIdentityHint(portal *bridgev2.Portal, meta *PortalMetadata) string { @@ -89,15 +79,9 @@ func (oc *AIClient) buildAdditionalSystemPromptsCore( if meta != nil && portal != nil && oc.isGroupChat(ctx, portal) { activation := oc.resolveGroupActivation(meta) - shouldIntro := !meta.GroupIntroSent || meta.GroupActivationNeedsIntro - if shouldIntro { - intro := buildGroupIntro(oc.matrixRoomDisplayName(ctx, portal), activation) - if strings.TrimSpace(intro) != "" { - out = append(out, openai.SystemMessage(intro)) - } - meta.GroupIntroSent = true - meta.GroupActivationNeedsIntro = false - oc.savePortalQuiet(ctx, portal, "group intro") + intro := buildGroupIntro(oc.matrixRoomDisplayName(ctx, portal), activation) + if strings.TrimSpace(intro) != "" { + out = append(out, openai.SystemMessage(intro)) } } diff --git a/pkg/connector/system_prompts_test.go b/pkg/connector/system_prompts_test.go index a583e210..05eda21d 100644 --- a/pkg/connector/system_prompts_test.go +++ b/pkg/connector/system_prompts_test.go @@ -15,7 +15,7 @@ func TestBuildSessionIdentityHint_IncludesRoomIDAndPortalID(t *testing.T) { portal.MXID = id.RoomID("!room:example.org") portal.PortalKey = networkid.PortalKey{ID: networkid.PortalID("portal-123")} - meta := &PortalMetadata{AgentID: "beeper"} + meta := agentModeTestMeta("beeper") got := buildSessionIdentityHint(portal, meta) if got == "" { t.Fatalf("expected non-empty hint") diff --git a/pkg/connector/target_test_helpers_test.go b/pkg/connector/target_test_helpers_test.go new file mode 100644 index 00000000..05c5361b --- /dev/null +++ b/pkg/connector/target_test_helpers_test.go @@ -0,0 +1,21 @@ +package connector + +func simpleModeTestMeta(modelID string) *PortalMetadata { + return &PortalMetadata{ + ResolvedTarget: &ResolvedTarget{ + Kind: ResolvedTargetModel, + GhostID: modelUserID(modelID), + ModelID: modelID, + }, + } +} + +func agentModeTestMeta(agentID string) *PortalMetadata { + return &PortalMetadata{ + ResolvedTarget: &ResolvedTarget{ + Kind: ResolvedTargetAgent, + GhostID: agentUserID(agentID), + AgentID: agentID, + }, + } +} diff --git a/pkg/connector/tool_availability_configured_test.go b/pkg/connector/tool_availability_configured_test.go index 5098ed84..92642edf 100644 --- a/pkg/connector/tool_availability_configured_test.go +++ b/pkg/connector/tool_availability_configured_test.go @@ -6,6 +6,9 @@ import ( "strings" "testing" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "github.com/beeper/ai-bridge/pkg/shared/toolspec" ) @@ -22,8 +25,11 @@ func TestToolAvailable_WebSearch_RequiresAnyProviderKey(t *testing.T) { }, }, }, + UserLogin: &bridgev2.UserLogin{UserLogin: &database.UserLogin{Metadata: &UserLoginMetadata{ + ModelCache: &ModelCache{Models: []ModelInfo{{ID: "openai/gpt-5.2", SupportsToolCalling: true}}}, + }}}, } - meta := &PortalMetadata{Capabilities: ModelCapabilities{SupportsToolCalling: true}} + meta := simpleModeTestMeta("openai/gpt-5.2") ok, source, reason := oc.isToolAvailable(meta, toolspec.WebSearchName) if ok { @@ -48,8 +54,11 @@ func TestToolAvailable_WebSearch_WithProviderKey(t *testing.T) { }, }, }, + UserLogin: &bridgev2.UserLogin{UserLogin: &database.UserLogin{Metadata: &UserLoginMetadata{ + ModelCache: &ModelCache{Models: []ModelInfo{{ID: "openai/gpt-5.2", SupportsToolCalling: true}}}, + }}}, } - meta := &PortalMetadata{Capabilities: ModelCapabilities{SupportsToolCalling: true}} + meta := simpleModeTestMeta("openai/gpt-5.2") ok, _, reason := oc.isToolAvailable(meta, toolspec.WebSearchName) if !ok { @@ -68,8 +77,11 @@ func TestToolAvailable_WebFetch_DirectDisabledAndNoExaKey(t *testing.T) { }, }, }, + UserLogin: &bridgev2.UserLogin{UserLogin: &database.UserLogin{Metadata: &UserLoginMetadata{ + ModelCache: &ModelCache{Models: []ModelInfo{{ID: "openai/gpt-5.2", SupportsToolCalling: true}}}, + }}}, } - meta := &PortalMetadata{Capabilities: ModelCapabilities{SupportsToolCalling: true}} + meta := simpleModeTestMeta("openai/gpt-5.2") ok, source, reason := oc.isToolAvailable(meta, toolspec.WebFetchName) if ok { @@ -84,8 +96,11 @@ func TestToolAvailable_TTS_PlatformBehavior(t *testing.T) { oc := &AIClient{ connector: &OpenAIConnector{Config: Config{}}, // provider/apiKey intentionally empty + UserLogin: &bridgev2.UserLogin{UserLogin: &database.UserLogin{Metadata: &UserLoginMetadata{ + ModelCache: &ModelCache{Models: []ModelInfo{{ID: "openai/gpt-5.2", SupportsToolCalling: true}}}, + }}}, } - meta := &PortalMetadata{Capabilities: ModelCapabilities{SupportsToolCalling: true}} + meta := simpleModeTestMeta("openai/gpt-5.2") ok, _, reason := oc.isToolAvailable(meta, toolspec.TTSName) if runtime.GOOS == "darwin" { diff --git a/pkg/connector/tool_descriptions.go b/pkg/connector/tool_descriptions.go index db0a1caf..c41c5e6e 100644 --- a/pkg/connector/tool_descriptions.go +++ b/pkg/connector/tool_descriptions.go @@ -11,7 +11,7 @@ func (oc *AIClient) toolDescriptionForPortal(meta *PortalMetadata, toolName stri name := strings.TrimSpace(toolName) switch name { case toolspec.ImageName: - if meta != nil && meta.Capabilities.SupportsVision { + if meta != nil && oc.getModelCapabilitiesForMeta(meta).SupportsVision { return toolspec.ImageDescriptionVisionHint } case toolspec.WebSearchName: diff --git a/pkg/connector/tool_execution.go b/pkg/connector/tool_execution.go index 4879512b..0de42e15 100644 --- a/pkg/connector/tool_execution.go +++ b/pkg/connector/tool_execution.go @@ -128,7 +128,7 @@ func (oc *AIClient) executeBuiltinToolDirect(ctx context.Context, portal *bridge return oc.executeMCPTool(ctx, toolName, args) } // Check if this is a Boss room or a session tool - use boss tool executor - if (meta != nil && hasBossAgent(meta)) || oc.isBuilderRoom(portal) || tools.IsSessionTool(toolName) || tools.IsBossTool(toolName) { + if (meta != nil && hasBossAgent(meta)) || tools.IsSessionTool(toolName) || tools.IsBossTool(toolName) { if result := oc.executeBossTool(ctx, portal, toolName, args); result != nil { return result.Content, result.Error } diff --git a/pkg/connector/tool_policy.go b/pkg/connector/tool_policy.go index 5bc0dc99..142ab4aa 100644 --- a/pkg/connector/tool_policy.go +++ b/pkg/connector/tool_policy.go @@ -45,12 +45,12 @@ func (oc *AIClient) isToolAvailable(meta *PortalMetadata, toolName string) (bool return available, source, reason } - if !meta.Capabilities.SupportsToolCalling { + if !oc.getModelCapabilitiesForMeta(meta).SupportsToolCalling { return false, SourceModelLimit, "Model does not support tools" } - if agenttools.IsBossTool(toolName) && !(meta.IsBuilderRoom || hasBossAgent(meta)) { - return false, SourceGlobalDefault, "Builder room only" + if agenttools.IsBossTool(toolName) && !hasBossAgent(meta) { + return false, SourceGlobalDefault, "Boss agent only" } // Tool runtime prerequisites (API keys, services, etc.). These are intentionally @@ -190,7 +190,7 @@ func (oc *AIClient) toolNamesForPortal(meta *PortalMetadata) []string { for _, tool := range agenttools.SessionTools() { nameSet[tool.Name] = struct{}{} } - if meta != nil && (meta.IsBuilderRoom || hasBossAgent(meta)) { + if meta != nil && hasBossAgent(meta) { for _, tool := range agenttools.BossTools() { nameSet[tool.Name] = struct{}{} } diff --git a/pkg/connector/tool_policy_apply_patch_test.go b/pkg/connector/tool_policy_apply_patch_test.go index e81c951a..cd8e74ff 100644 --- a/pkg/connector/tool_policy_apply_patch_test.go +++ b/pkg/connector/tool_policy_apply_patch_test.go @@ -8,7 +8,12 @@ import ( ) func newTestAIClientWithConfig(cfg Config) *AIClient { - login := &database.UserLogin{Metadata: &UserLoginMetadata{Provider: ProviderOpenAI}} + login := &database.UserLogin{Metadata: &UserLoginMetadata{ + Provider: ProviderOpenAI, + ModelCache: &ModelCache{Models: []ModelInfo{ + {ID: "openai/gpt-5.2", SupportsToolCalling: true}, + }}, + }} userLogin := &bridgev2.UserLogin{UserLogin: login} return &AIClient{ UserLogin: userLogin, @@ -18,12 +23,7 @@ func newTestAIClientWithConfig(cfg Config) *AIClient { func TestApplyPatchAvailability_DisabledByDefault(t *testing.T) { oc := newTestAIClientWithConfig(Config{}) - meta := &PortalMetadata{ - Model: "openai/gpt-5.2", - Capabilities: ModelCapabilities{ - SupportsToolCalling: true, - }, - } + meta := simpleModeTestMeta("openai/gpt-5.2") available, _, _ := oc.isToolAvailable(meta, ToolNameApplyPatch) if available { @@ -42,12 +42,7 @@ func TestApplyPatchAvailability_EnabledWithoutAllowlist(t *testing.T) { }, }, }) - meta := &PortalMetadata{ - Model: "openai/gpt-5.2", - Capabilities: ModelCapabilities{ - SupportsToolCalling: true, - }, - } + meta := simpleModeTestMeta("openai/gpt-5.2") available, _, _ := oc.isToolAvailable(meta, ToolNameApplyPatch) if !available { @@ -67,12 +62,7 @@ func TestApplyPatchAvailability_AllowlistMismatch(t *testing.T) { }, }, }) - meta := &PortalMetadata{ - Model: "openai/gpt-5.2", - Capabilities: ModelCapabilities{ - SupportsToolCalling: true, - }, - } + meta := simpleModeTestMeta("openai/gpt-5.2") available, _, _ := oc.isToolAvailable(meta, ToolNameApplyPatch) if available { diff --git a/pkg/connector/tools.go b/pkg/connector/tools.go index 7b8b2c49..a73c91df 100644 --- a/pkg/connector/tools.go +++ b/pkg/connector/tools.go @@ -1542,10 +1542,7 @@ func executeSessionStatus(ctx context.Context, args map[string]any) (string, err dayOfWeek := now.Weekday().String() // Get model info - model := meta.Model - if model == "" { - model = btc.Client.effectiveModel(meta) - } + model := btc.Client.effectiveModel(meta) // Parse provider from model string (format: "provider/model" or just "model") provider := "unknown" @@ -1555,15 +1552,9 @@ func executeSessionStatus(ctx context.Context, args map[string]any) (string, err modelName = parsedModel } - // Get context/token info from metadata - maxContext := meta.MaxContextMessages - if maxContext == 0 { - maxContext = 12 // default - } - maxTokens := meta.MaxCompletionTokens - if maxTokens == 0 { - maxTokens = 512 // default - } + // Get context/token info from the effective runtime only. + maxContext := btc.Client.getModelContextWindow(meta) + maxTokens := btc.Client.effectiveMaxTokens(meta) // Build session info sessionID := string(btc.Portal.PortalKey.ID) @@ -1575,74 +1566,10 @@ func executeSessionStatus(ctx context.Context, args map[string]any) (string, err title = "Untitled" } - // Handle model change if requested (OpenClaw-style "model" alias supported) - var modelChanged string - newModel := "" - if raw, ok := args["set_model"].(string); ok && strings.TrimSpace(raw) != "" { - newModel = strings.TrimSpace(raw) - } else if raw, ok := args["model"].(string); ok && strings.TrimSpace(raw) != "" { - newModel = strings.TrimSpace(raw) - } - - if newModel != "" { - if strings.EqualFold(newModel, "default") || strings.EqualFold(newModel, "reset") { - metaCopy := *meta - metaCopy.Model = "" - effective := btc.Client.effectiveModel(&metaCopy) - if err := btc.Client.validateDMModelSwitch(btc.Portal, meta, effective); err != nil { - return "", dmModelSwitchBlockedError(effective) - } - - // Clear override and recompute capabilities from effective model - meta.Model = "" - effective = btc.Client.effectiveModel(meta) - meta.Capabilities = getModelCapabilities(effective, btc.Client.findModelInfo(effective)) - if err := btc.Portal.Save(ctx); err != nil { - return "", fmt.Errorf("couldn't save model reset: %w", err) - } - btc.Portal.UpdateBridgeInfo(ctx) - btc.Client.ensureGhostDisplayName(ctx, effective) - modelChanged = fmt.Sprintf("\n\nModel reset to %s.", effective) - model = effective - if parsedProvider, parsedModel := splitModelProvider(effective); parsedProvider != "" && parsedModel != "" { - provider = parsedProvider - modelName = parsedModel - } else { - modelName = effective - } - } else { - resolvedModel, valid, err := btc.Client.resolveModelID(ctx, newModel) - if err != nil || !valid || resolvedModel == "" { - return "", fmt.Errorf("invalid model: %s", newModel) - } - if err := btc.Client.validateDMModelSwitch(btc.Portal, meta, resolvedModel); err != nil { - return "", dmModelSwitchBlockedError(resolvedModel) - } - - // Update the model in metadata - meta.Model = resolvedModel - meta.Capabilities = getModelCapabilities(resolvedModel, btc.Client.findModelInfo(resolvedModel)) - // Save portal metadata - if err := btc.Portal.Save(ctx); err != nil { - return "", fmt.Errorf("couldn't save model change: %w", err) - } - btc.Portal.UpdateBridgeInfo(ctx) - btc.Client.ensureGhostDisplayName(ctx, resolvedModel) - modelChanged = fmt.Sprintf("\n\nModel set to %s.", resolvedModel) - model = resolvedModel - if parsedProvider, parsedModel := splitModelProvider(resolvedModel); parsedProvider != "" && parsedModel != "" { - provider = parsedProvider - modelName = parsedModel - } else { - modelName = resolvedModel - } - } - } - // Get agent info if available agentInfo := "" - if meta.AgentID != "" { - agentInfo = fmt.Sprintf("\nAgent: %s", meta.AgentID) + if agentID := resolveAgentID(meta); agentID != "" { + agentInfo = fmt.Sprintf("\nAgent: %s", agentID) } // Build status card similar to OpenClaw @@ -1656,7 +1583,7 @@ Provider: %s Max Context: %d messages Max Tokens: %d -Session: %s + Session: %s Chat: %s%s%s`, timeStr, timezone, now.Format("MST"), dayOfWeek, @@ -1667,7 +1594,7 @@ Chat: %s%s%s`, sessionID, title, agentInfo, - modelChanged, + "", ) return status, nil diff --git a/pkg/connector/tools_message_actions.go b/pkg/connector/tools_message_actions.go index bc5a7357..8b6a56cc 100644 --- a/pkg/connector/tools_message_actions.go +++ b/pkg/connector/tools_message_actions.go @@ -151,11 +151,7 @@ func executeMessageMemberInfo(ctx context.Context, args map[string]any, btc *Bri } else { store := NewAgentStoreAdapter(btc.Client) if agent, err := store.GetAgentByID(ctx, agentID); err == nil && agent != nil && agent.Model.Primary != "" { - if override := btc.Client.agentModelOverride(agentID); override != "" { - modelID = ResolveAlias(override) - } else { - modelID = ResolveAlias(agent.Model.Primary) - } + modelID = ResolveAlias(agent.Model.Primary) } } } diff --git a/pkg/connector/tools_unique_test.go b/pkg/connector/tools_unique_test.go index 4368e2ac..004bb901 100644 --- a/pkg/connector/tools_unique_test.go +++ b/pkg/connector/tools_unique_test.go @@ -21,7 +21,7 @@ func TestToolNamesUnique(t *testing.T) { builtinSeen[tool.Name] = struct{}{} } - // Boss tools (combined with builtin in builder rooms) + // Boss tools (combined with builtin in boss-agent rooms) for _, tool := range agenttools.BossTools() { if tool.Name == "" { t.Fatalf("boss tool has empty name: %+v", tool) diff --git a/pkg/connector/trace.go b/pkg/connector/trace.go index 51c1e702..825e139f 100644 --- a/pkg/connector/trace.go +++ b/pkg/connector/trace.go @@ -1,30 +1,11 @@ package connector -import ( - "strings" - - "github.com/beeper/ai-bridge/pkg/shared/stringutil" -) - -func traceLevel(meta *PortalMetadata) string { - if meta == nil { - return "off" - } - if level, ok := stringutil.NormalizeEnum(meta.VerboseLevel, verboseLevelAliases); ok { - return level - } - level := strings.ToLower(strings.TrimSpace(meta.VerboseLevel)) - if level == "" { - return "off" - } - return level -} - func traceEnabled(meta *PortalMetadata) bool { - level := traceLevel(meta) - return level == "on" || level == "full" + _ = meta + return false } func traceFull(meta *PortalMetadata) bool { - return traceLevel(meta) == "full" + _ = meta + return false } diff --git a/pkg/connector/typing_queue.go b/pkg/connector/typing_queue.go index fc824f30..cf588a75 100644 --- a/pkg/connector/typing_queue.go +++ b/pkg/connector/typing_queue.go @@ -11,9 +11,6 @@ func (oc *AIClient) startQueueTyping(ctx context.Context, portal *bridgev2.Porta if oc == nil || portal == nil || portal.MXID == "" { return } - if meta != nil && normalizeSendPolicyMode(meta.SendPolicy) == "deny" { - return - } if typingCtx == nil { typingCtx = &TypingContext{IsGroup: oc.isGroupChat(ctx, portal)} }