diff --git a/pkg/mcp/brain/provider.go b/pkg/mcp/brain/provider.go new file mode 100644 index 0000000..9640fd8 --- /dev/null +++ b/pkg/mcp/brain/provider.go @@ -0,0 +1,336 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package brain + +import ( + "net/http" + + "forge.lthn.ai/core/go-api" + "forge.lthn.ai/core/go-api/pkg/provider" + "forge.lthn.ai/core/go-ws" + "forge.lthn.ai/core/mcp/pkg/mcp/ide" + "github.com/gin-gonic/gin" +) + +// BrainProvider wraps the brain Subsystem as a service provider with REST +// endpoints. It delegates to the same IDE bridge that the MCP tools use. +type BrainProvider struct { + bridge *ide.Bridge + hub *ws.Hub +} + +// compile-time interface checks +var ( + _ provider.Provider = (*BrainProvider)(nil) + _ provider.Streamable = (*BrainProvider)(nil) + _ provider.Describable = (*BrainProvider)(nil) + _ provider.Renderable = (*BrainProvider)(nil) +) + +// NewProvider creates a brain provider that proxies to Laravel via the IDE bridge. +// The WS hub is used to emit brain events. Pass nil for hub if not needed. +func NewProvider(bridge *ide.Bridge, hub *ws.Hub) *BrainProvider { + return &BrainProvider{ + bridge: bridge, + hub: hub, + } +} + +// Name implements api.RouteGroup. +func (p *BrainProvider) Name() string { return "brain" } + +// BasePath implements api.RouteGroup. +func (p *BrainProvider) BasePath() string { return "/api/brain" } + +// Channels implements provider.Streamable. +func (p *BrainProvider) Channels() []string { + return []string{ + "brain.remember.complete", + "brain.recall.complete", + "brain.forget.complete", + } +} + +// Element implements provider.Renderable. +func (p *BrainProvider) Element() provider.ElementSpec { + return provider.ElementSpec{ + Tag: "core-brain-panel", + Source: "/assets/brain-panel.js", + } +} + +// RegisterRoutes implements api.RouteGroup. +func (p *BrainProvider) RegisterRoutes(rg *gin.RouterGroup) { + rg.POST("/remember", p.remember) + rg.POST("/recall", p.recall) + rg.POST("/forget", p.forget) + rg.GET("/list", p.list) + rg.GET("/status", p.status) +} + +// Describe implements api.DescribableGroup. +func (p *BrainProvider) Describe() []api.RouteDescription { + return []api.RouteDescription{ + { + Method: "POST", + Path: "/remember", + Summary: "Store a memory", + Description: "Store a memory in the shared OpenBrain knowledge store via the Laravel backend.", + Tags: []string{"brain"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string"}, + "tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "project": map[string]any{"type": "string"}, + "confidence": map[string]any{"type": "number"}, + }, + "required": []string{"content", "type"}, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "success": map[string]any{"type": "boolean"}, + "memoryId": map[string]any{"type": "string"}, + "timestamp": map[string]any{"type": "string", "format": "date-time"}, + }, + }, + }, + { + Method: "POST", + Path: "/recall", + Summary: "Semantic search memories", + Description: "Semantic search across the shared OpenBrain knowledge store.", + Tags: []string{"brain"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + "top_k": map[string]any{"type": "integer"}, + "filter": map[string]any{ + "type": "object", + "properties": map[string]any{ + "project": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string"}, + }, + }, + }, + "required": []string{"query"}, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "success": map[string]any{"type": "boolean"}, + "count": map[string]any{"type": "integer"}, + "memories": map[string]any{"type": "array"}, + }, + }, + }, + { + Method: "POST", + Path: "/forget", + Summary: "Remove a memory", + Description: "Permanently delete a memory from the knowledge store.", + Tags: []string{"brain"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "reason": map[string]any{"type": "string"}, + }, + "required": []string{"id"}, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "success": map[string]any{"type": "boolean"}, + "forgotten": map[string]any{"type": "string"}, + }, + }, + }, + { + Method: "GET", + Path: "/list", + Summary: "List memories", + Description: "List memories with optional filtering by project, type, and agent.", + Tags: []string{"brain"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "success": map[string]any{"type": "boolean"}, + "count": map[string]any{"type": "integer"}, + "memories": map[string]any{"type": "array"}, + }, + }, + }, + { + Method: "GET", + Path: "/status", + Summary: "Brain bridge status", + Description: "Returns whether the Laravel bridge is connected.", + Tags: []string{"brain"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "connected": map[string]any{"type": "boolean"}, + }, + }, + }, + } +} + +// -- Handlers ----------------------------------------------------------------- + +func (p *BrainProvider) remember(c *gin.Context) { + if p.bridge == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("bridge_unavailable", "brain bridge not available")) + return + } + + var input RememberInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_input", err.Error())) + return + } + + err := p.bridge.Send(ide.BridgeMessage{ + Type: "brain_remember", + Data: map[string]any{ + "content": input.Content, + "type": input.Type, + "tags": input.Tags, + "project": input.Project, + "confidence": input.Confidence, + "supersedes": input.Supersedes, + "expires_in": input.ExpiresIn, + }, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("bridge_error", err.Error())) + return + } + + p.emitEvent("brain.remember.complete", map[string]any{ + "type": input.Type, + "project": input.Project, + }) + + c.JSON(http.StatusOK, api.OK(map[string]any{"success": true})) +} + +func (p *BrainProvider) recall(c *gin.Context) { + if p.bridge == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("bridge_unavailable", "brain bridge not available")) + return + } + + var input RecallInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_input", err.Error())) + return + } + + err := p.bridge.Send(ide.BridgeMessage{ + Type: "brain_recall", + Data: map[string]any{ + "query": input.Query, + "top_k": input.TopK, + "filter": input.Filter, + }, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("bridge_error", err.Error())) + return + } + + p.emitEvent("brain.recall.complete", map[string]any{ + "query": input.Query, + }) + + c.JSON(http.StatusOK, api.OK(RecallOutput{ + Success: true, + Memories: []Memory{}, + })) +} + +func (p *BrainProvider) forget(c *gin.Context) { + if p.bridge == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("bridge_unavailable", "brain bridge not available")) + return + } + + var input ForgetInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_input", err.Error())) + return + } + + err := p.bridge.Send(ide.BridgeMessage{ + Type: "brain_forget", + Data: map[string]any{ + "id": input.ID, + "reason": input.Reason, + }, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("bridge_error", err.Error())) + return + } + + p.emitEvent("brain.forget.complete", map[string]any{ + "id": input.ID, + }) + + c.JSON(http.StatusOK, api.OK(map[string]any{ + "success": true, + "forgotten": input.ID, + })) +} + +func (p *BrainProvider) list(c *gin.Context) { + if p.bridge == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("bridge_unavailable", "brain bridge not available")) + return + } + + err := p.bridge.Send(ide.BridgeMessage{ + Type: "brain_list", + Data: map[string]any{ + "project": c.Query("project"), + "type": c.Query("type"), + "agent_id": c.Query("agent_id"), + "limit": c.Query("limit"), + }, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("bridge_error", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(ListOutput{ + Success: true, + Memories: []Memory{}, + })) +} + +func (p *BrainProvider) status(c *gin.Context) { + connected := false + if p.bridge != nil { + connected = p.bridge.Connected() + } + c.JSON(http.StatusOK, api.OK(map[string]any{ + "connected": connected, + })) +} + +// emitEvent sends a WS event if the hub is available. +func (p *BrainProvider) emitEvent(channel string, data any) { + if p.hub == nil { + return + } + _ = p.hub.SendToChannel(channel, ws.Message{ + Type: ws.TypeEvent, + Data: data, + }) +}