468 lines
12 KiB
Go
468 lines
12 KiB
Go
|
|
// SPDX-License-Identifier: EUPL-1.2
|
||
|
|
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"crypto/sha256"
|
||
|
|
"database/sql"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
coreerr "dappco.re/go/core/log"
|
||
|
|
coremcp "forge.lthn.ai/core/mcp/pkg/mcp"
|
||
|
|
brainpkg "forge.lthn.ai/core/mcp/pkg/mcp/brain"
|
||
|
|
_ "github.com/marcboeker/go-duckdb"
|
||
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
defaultBrainAPIURL = "https://api.lthn.sh"
|
||
|
|
defaultBrainCacheTTL = 5 * time.Minute
|
||
|
|
defaultBrainCacheDriver = "duckdb"
|
||
|
|
)
|
||
|
|
|
||
|
|
type (
|
||
|
|
RememberInput = brainpkg.RememberInput
|
||
|
|
RememberOutput = brainpkg.RememberOutput
|
||
|
|
RecallInput = brainpkg.RecallInput
|
||
|
|
RecallFilter = brainpkg.RecallFilter
|
||
|
|
RecallOutput = brainpkg.RecallOutput
|
||
|
|
Memory = brainpkg.Memory
|
||
|
|
ForgetInput = brainpkg.ForgetInput
|
||
|
|
ForgetOutput = brainpkg.ForgetOutput
|
||
|
|
)
|
||
|
|
|
||
|
|
// BrainDirectSubsystem implements the OpenBrain MCP tools over the direct HTTP API.
|
||
|
|
// brain_recall is cached locally in DuckDB to avoid repeated semantic searches.
|
||
|
|
type BrainDirectSubsystem struct {
|
||
|
|
workspaceRoot string
|
||
|
|
apiURL string
|
||
|
|
apiKey string
|
||
|
|
client *http.Client
|
||
|
|
|
||
|
|
cache *brainRecallCache
|
||
|
|
}
|
||
|
|
|
||
|
|
var _ coremcp.Subsystem = (*BrainDirectSubsystem)(nil)
|
||
|
|
var _ coremcp.SubsystemWithShutdown = (*BrainDirectSubsystem)(nil)
|
||
|
|
|
||
|
|
// NewCachedBrainDirect creates the direct OpenBrain subsystem with a DuckDB-backed
|
||
|
|
// cache for brain_recall responses.
|
||
|
|
func NewCachedBrainDirect(workspaceRoot string) (*BrainDirectSubsystem, error) {
|
||
|
|
apiURL := strings.TrimSpace(os.Getenv("CORE_BRAIN_URL"))
|
||
|
|
if apiURL == "" {
|
||
|
|
apiURL = defaultBrainAPIURL
|
||
|
|
}
|
||
|
|
|
||
|
|
apiKey := strings.TrimSpace(os.Getenv("CORE_BRAIN_KEY"))
|
||
|
|
if apiKey == "" {
|
||
|
|
if home, err := os.UserHomeDir(); err == nil {
|
||
|
|
keyPath := filepath.Join(home, ".claude", "brain.key")
|
||
|
|
if data, readErr := os.ReadFile(keyPath); readErr == nil {
|
||
|
|
apiKey = strings.TrimSpace(string(data))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
cacheTTL := defaultBrainCacheTTL
|
||
|
|
if raw := strings.TrimSpace(os.Getenv("CORE_BRAIN_RECALL_CACHE_TTL")); raw != "" {
|
||
|
|
if parsed, err := time.ParseDuration(raw); err == nil && parsed > 0 {
|
||
|
|
cacheTTL = parsed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
cache, err := newBrainRecallCache(workspaceRoot, apiURL, apiKey, cacheTTL)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
return &BrainDirectSubsystem{
|
||
|
|
workspaceRoot: workspaceRoot,
|
||
|
|
apiURL: apiURL,
|
||
|
|
apiKey: apiKey,
|
||
|
|
client: &http.Client{Timeout: 30 * time.Second},
|
||
|
|
cache: cache,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Name implements mcp.Subsystem.
|
||
|
|
func (s *BrainDirectSubsystem) Name() string { return "brain" }
|
||
|
|
|
||
|
|
// RegisterTools implements mcp.Subsystem.
|
||
|
|
func (s *BrainDirectSubsystem) RegisterTools(server *mcp.Server) {
|
||
|
|
mcp.AddTool(server, &mcp.Tool{
|
||
|
|
Name: "brain_remember",
|
||
|
|
Description: "Store a memory in OpenBrain. Types: fact, decision, observation, plan, convention, architecture, research, documentation, service, bug, pattern, context, procedure.",
|
||
|
|
}, s.brainRemember)
|
||
|
|
|
||
|
|
mcp.AddTool(server, &mcp.Tool{
|
||
|
|
Name: "brain_recall",
|
||
|
|
Description: "Semantic search across OpenBrain memories. Returns memories ranked by similarity. Use agent_id 'cladius' for Cladius's memories.",
|
||
|
|
}, s.brainRecall)
|
||
|
|
|
||
|
|
mcp.AddTool(server, &mcp.Tool{
|
||
|
|
Name: "brain_forget",
|
||
|
|
Description: "Remove a memory from OpenBrain by ID.",
|
||
|
|
}, s.brainForget)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Shutdown implements mcp.SubsystemWithShutdown.
|
||
|
|
func (s *BrainDirectSubsystem) Shutdown(ctx context.Context) error {
|
||
|
|
if s.cache != nil {
|
||
|
|
return s.cache.Close()
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *BrainDirectSubsystem) apiCall(ctx context.Context, method, path string, body any) (map[string]any, error) {
|
||
|
|
if s.apiKey == "" {
|
||
|
|
return nil, coreerr.E("brain.apiCall", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
var reqBody io.Reader
|
||
|
|
if body != nil {
|
||
|
|
data, err := json.Marshal(body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, coreerr.E("brain.apiCall", "marshal request", err)
|
||
|
|
}
|
||
|
|
reqBody = bytes.NewReader(data)
|
||
|
|
}
|
||
|
|
|
||
|
|
req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody)
|
||
|
|
if err != nil {
|
||
|
|
return nil, coreerr.E("brain.apiCall", "create request", err)
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
req.Header.Set("Accept", "application/json")
|
||
|
|
req.Header.Set("Authorization", "Bearer "+s.apiKey)
|
||
|
|
|
||
|
|
resp, err := s.client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return nil, coreerr.E("brain.apiCall", "API call failed", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
respData, err := io.ReadAll(resp.Body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, coreerr.E("brain.apiCall", "read response", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if resp.StatusCode >= 400 {
|
||
|
|
return nil, coreerr.E("brain.apiCall", "API returned "+string(respData), nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
var result map[string]any
|
||
|
|
if err := json.Unmarshal(respData, &result); err != nil {
|
||
|
|
return nil, coreerr.E("brain.apiCall", "parse response", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *BrainDirectSubsystem) brainRemember(ctx context.Context, _ *mcp.CallToolRequest, input RememberInput) (*mcp.CallToolResult, RememberOutput, error) {
|
||
|
|
result, err := s.apiCall(ctx, "POST", "/v1/brain/remember", map[string]any{
|
||
|
|
"content": input.Content,
|
||
|
|
"type": input.Type,
|
||
|
|
"tags": input.Tags,
|
||
|
|
"project": input.Project,
|
||
|
|
"agent_id": "cladius",
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, RememberOutput{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
if s.cache != nil {
|
||
|
|
_ = s.cache.clear(ctx)
|
||
|
|
}
|
||
|
|
|
||
|
|
id, _ := result["id"].(string)
|
||
|
|
return nil, RememberOutput{
|
||
|
|
Success: true,
|
||
|
|
MemoryID: id,
|
||
|
|
Timestamp: time.Now(),
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *BrainDirectSubsystem) brainRecall(ctx context.Context, _ *mcp.CallToolRequest, input RecallInput) (*mcp.CallToolResult, RecallOutput, error) {
|
||
|
|
request := s.normalisedRecallRequest(input)
|
||
|
|
cacheKey, err := s.cacheKey(request)
|
||
|
|
if err != nil {
|
||
|
|
return nil, RecallOutput{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
if s.cache != nil {
|
||
|
|
if cached, ok, err := s.cache.get(ctx, cacheKey); err != nil {
|
||
|
|
return nil, RecallOutput{}, err
|
||
|
|
} else if ok {
|
||
|
|
return nil, cached, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
body := map[string]any{
|
||
|
|
"query": request.Query,
|
||
|
|
"top_k": request.TopK,
|
||
|
|
"agent_id": request.AgentID,
|
||
|
|
}
|
||
|
|
if request.Project != "" {
|
||
|
|
body["project"] = request.Project
|
||
|
|
}
|
||
|
|
if request.Type != nil {
|
||
|
|
body["type"] = request.Type
|
||
|
|
}
|
||
|
|
|
||
|
|
result, err := s.apiCall(ctx, "POST", "/v1/brain/recall", body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, RecallOutput{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
output := brainRecallResult(result)
|
||
|
|
if s.cache != nil {
|
||
|
|
_ = s.cache.set(ctx, cacheKey, output)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil, output, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *BrainDirectSubsystem) brainForget(ctx context.Context, _ *mcp.CallToolRequest, input ForgetInput) (*mcp.CallToolResult, ForgetOutput, error) {
|
||
|
|
_, err := s.apiCall(ctx, "DELETE", "/v1/brain/forget/"+input.ID, nil)
|
||
|
|
if err != nil {
|
||
|
|
return nil, ForgetOutput{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
if s.cache != nil {
|
||
|
|
_ = s.cache.clear(ctx)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil, ForgetOutput{
|
||
|
|
Success: true,
|
||
|
|
Forgotten: input.ID,
|
||
|
|
Timestamp: time.Now(),
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type brainRecallRequest struct {
|
||
|
|
WorkspaceRoot string `json:"workspace_root"`
|
||
|
|
APIURL string `json:"api_url"`
|
||
|
|
APIKeyHash string `json:"api_key_hash"`
|
||
|
|
Query string `json:"query"`
|
||
|
|
TopK int `json:"top_k"`
|
||
|
|
AgentID string `json:"agent_id"`
|
||
|
|
Project string `json:"project,omitempty"`
|
||
|
|
Type any `json:"type,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *BrainDirectSubsystem) normalisedRecallRequest(input RecallInput) brainRecallRequest {
|
||
|
|
topK := input.TopK
|
||
|
|
if topK <= 0 {
|
||
|
|
topK = 10
|
||
|
|
}
|
||
|
|
|
||
|
|
return brainRecallRequest{
|
||
|
|
WorkspaceRoot: strings.TrimSpace(s.workspaceRoot),
|
||
|
|
APIURL: strings.TrimSpace(s.apiURL),
|
||
|
|
APIKeyHash: hashString(s.apiKey),
|
||
|
|
Query: strings.TrimSpace(input.Query),
|
||
|
|
TopK: topK,
|
||
|
|
AgentID: "cladius",
|
||
|
|
Project: strings.TrimSpace(input.Filter.Project),
|
||
|
|
Type: input.Filter.Type,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *BrainDirectSubsystem) cacheKey(request brainRecallRequest) (string, error) {
|
||
|
|
data, err := json.Marshal(request)
|
||
|
|
if err != nil {
|
||
|
|
return "", coreerr.E("brain.cacheKey", "marshal recall request", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
sum := sha256.Sum256(data)
|
||
|
|
return hex.EncodeToString(sum[:]), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func brainRecallResult(result map[string]any) RecallOutput {
|
||
|
|
var memories []Memory
|
||
|
|
if mems, ok := result["memories"].([]any); ok {
|
||
|
|
for _, m := range mems {
|
||
|
|
mm, ok := m.(map[string]any)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
mem := Memory{
|
||
|
|
Content: fmt.Sprintf("%v", mm["content"]),
|
||
|
|
Type: fmt.Sprintf("%v", mm["type"]),
|
||
|
|
Project: fmt.Sprintf("%v", mm["project"]),
|
||
|
|
AgentID: fmt.Sprintf("%v", mm["agent_id"]),
|
||
|
|
CreatedAt: fmt.Sprintf("%v", mm["created_at"]),
|
||
|
|
}
|
||
|
|
if id, ok := mm["id"].(string); ok {
|
||
|
|
mem.ID = id
|
||
|
|
}
|
||
|
|
if score, ok := mm["score"].(float64); ok {
|
||
|
|
mem.Confidence = score
|
||
|
|
}
|
||
|
|
if source, ok := mm["source"].(string); ok {
|
||
|
|
mem.Tags = append(mem.Tags, "source:"+source)
|
||
|
|
}
|
||
|
|
memories = append(memories, mem)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return RecallOutput{
|
||
|
|
Success: true,
|
||
|
|
Count: len(memories),
|
||
|
|
Memories: memories,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func hashString(value string) string {
|
||
|
|
sum := sha256.Sum256([]byte(value))
|
||
|
|
return hex.EncodeToString(sum[:])
|
||
|
|
}
|
||
|
|
|
||
|
|
type brainRecallCache struct {
|
||
|
|
db *sql.DB
|
||
|
|
ttl time.Duration
|
||
|
|
path string
|
||
|
|
}
|
||
|
|
|
||
|
|
func newBrainRecallCache(workspaceRoot, apiURL, apiKey string, ttl time.Duration) (*brainRecallCache, error) {
|
||
|
|
cachePath, err := brainRecallCachePath(workspaceRoot, apiURL, apiKey)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
db, err := sql.Open(defaultBrainCacheDriver, cachePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, coreerr.E("brain.cache.open", "open duckdb cache", err)
|
||
|
|
}
|
||
|
|
db.SetMaxOpenConns(1)
|
||
|
|
db.SetMaxIdleConns(1)
|
||
|
|
|
||
|
|
schema := `
|
||
|
|
CREATE TABLE IF NOT EXISTS brain_recall_cache (
|
||
|
|
cache_key TEXT PRIMARY KEY,
|
||
|
|
payload TEXT NOT NULL,
|
||
|
|
created_at_unix_ms BIGINT NOT NULL,
|
||
|
|
expires_at_unix_ms BIGINT NOT NULL
|
||
|
|
);
|
||
|
|
`
|
||
|
|
if _, err := db.Exec(schema); err != nil {
|
||
|
|
_ = db.Close()
|
||
|
|
return nil, coreerr.E("brain.cache.schema", "initialise duckdb cache", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return &brainRecallCache{
|
||
|
|
db: db,
|
||
|
|
ttl: ttl,
|
||
|
|
path: cachePath,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func brainRecallCachePath(workspaceRoot, apiURL, apiKey string) (string, error) {
|
||
|
|
baseDir, err := os.UserCacheDir()
|
||
|
|
if err != nil || baseDir == "" {
|
||
|
|
baseDir = os.TempDir()
|
||
|
|
}
|
||
|
|
|
||
|
|
identity := strings.Join([]string{
|
||
|
|
strings.TrimSpace(workspaceRoot),
|
||
|
|
strings.TrimSpace(apiURL),
|
||
|
|
hashString(apiKey),
|
||
|
|
}, "\x00")
|
||
|
|
|
||
|
|
sum := sha256.Sum256([]byte(identity))
|
||
|
|
fileName := "brain-recall-" + hex.EncodeToString(sum[:]) + ".duckdb"
|
||
|
|
cacheDir := filepath.Join(baseDir, "core-ide", "brain")
|
||
|
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||
|
|
return "", coreerr.E("brain.cache.path", "create cache directory", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return filepath.Join(cacheDir, fileName), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *brainRecallCache) Close() error {
|
||
|
|
if c == nil || c.db == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return c.db.Close()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *brainRecallCache) get(ctx context.Context, key string) (RecallOutput, bool, error) {
|
||
|
|
if c == nil || c.db == nil {
|
||
|
|
return RecallOutput{}, false, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
var payload string
|
||
|
|
var expires int64
|
||
|
|
err := c.db.QueryRowContext(ctx, `SELECT payload, expires_at_unix_ms FROM brain_recall_cache WHERE cache_key = ?`, key).Scan(&payload, &expires)
|
||
|
|
if err == sql.ErrNoRows {
|
||
|
|
return RecallOutput{}, false, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return RecallOutput{}, false, coreerr.E("brain.cache.get", "read recall cache", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if time.Now().UTC().UnixMilli() >= expires {
|
||
|
|
_, _ = c.db.ExecContext(ctx, `DELETE FROM brain_recall_cache WHERE cache_key = ?`, key)
|
||
|
|
return RecallOutput{}, false, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
var out RecallOutput
|
||
|
|
if err := json.Unmarshal([]byte(payload), &out); err != nil {
|
||
|
|
return RecallOutput{}, false, coreerr.E("brain.cache.get", "decode cached recall output", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return out, true, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *brainRecallCache) set(ctx context.Context, key string, value RecallOutput) error {
|
||
|
|
if c == nil || c.db == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
payload, err := json.Marshal(value)
|
||
|
|
if err != nil {
|
||
|
|
return coreerr.E("brain.cache.set", "encode recall output", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
now := time.Now().UTC().UnixMilli()
|
||
|
|
expires := now + int64(c.ttl/time.Millisecond)
|
||
|
|
if expires <= now {
|
||
|
|
expires = now + 1
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err = c.db.ExecContext(ctx, `
|
||
|
|
INSERT INTO brain_recall_cache (cache_key, payload, created_at_unix_ms, expires_at_unix_ms)
|
||
|
|
VALUES (?, ?, ?, ?)
|
||
|
|
ON CONFLICT(cache_key) DO UPDATE SET
|
||
|
|
payload = excluded.payload,
|
||
|
|
created_at_unix_ms = excluded.created_at_unix_ms,
|
||
|
|
expires_at_unix_ms = excluded.expires_at_unix_ms
|
||
|
|
`, key, string(payload), now, expires)
|
||
|
|
if err != nil {
|
||
|
|
return coreerr.E("brain.cache.set", "store recall cache", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *brainRecallCache) clear(ctx context.Context) error {
|
||
|
|
if c == nil || c.db == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, err := c.db.ExecContext(ctx, `DELETE FROM brain_recall_cache`); err != nil {
|
||
|
|
return coreerr.E("brain.cache.clear", "clear recall cache", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|