feat(tools): implement CRUD operations for thoughts and projects
* Add tools for creating, retrieving, updating, and deleting thoughts. * Implement project management tools for creating and listing projects. * Introduce linking functionality between thoughts. * Add search and recall capabilities for thoughts based on semantic queries. * Implement statistics and summarization tools for thought analysis. * Create database migrations for thoughts, projects, and links. * Add helper functions for UUID parsing and project resolution.
This commit is contained in:
119
internal/config/config.go
Normal file
119
internal/config/config.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
DefaultConfigPath = "./configs/dev.yaml"
|
||||
DefaultSource = "mcp"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
MCP MCPConfig `yaml:"mcp"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
AI AIConfig `yaml:"ai"`
|
||||
Capture CaptureConfig `yaml:"capture"`
|
||||
Search SearchConfig `yaml:"search"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Observability ObservabilityConfig `yaml:"observability"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
ReadTimeout time.Duration `yaml:"read_timeout"`
|
||||
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||
IdleTimeout time.Duration `yaml:"idle_timeout"`
|
||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
ServerName string `yaml:"server_name"`
|
||||
Version string `yaml:"version"`
|
||||
Transport string `yaml:"transport"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Mode string `yaml:"mode"`
|
||||
HeaderName string `yaml:"header_name"`
|
||||
QueryParam string `yaml:"query_param"`
|
||||
AllowQueryParam bool `yaml:"allow_query_param"`
|
||||
Keys []APIKey `yaml:"keys"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID string `yaml:"id"`
|
||||
Value string `yaml:"value"`
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
MaxConns int32 `yaml:"max_conns"`
|
||||
MinConns int32 `yaml:"min_conns"`
|
||||
MaxConnLifetime time.Duration `yaml:"max_conn_lifetime"`
|
||||
MaxConnIdleTime time.Duration `yaml:"max_conn_idle_time"`
|
||||
}
|
||||
|
||||
type AIConfig struct {
|
||||
Provider string `yaml:"provider"`
|
||||
Embeddings AIEmbeddingConfig `yaml:"embeddings"`
|
||||
Metadata AIMetadataConfig `yaml:"metadata"`
|
||||
LiteLLM LiteLLMConfig `yaml:"litellm"`
|
||||
OpenRouter OpenRouterAIConfig `yaml:"openrouter"`
|
||||
}
|
||||
|
||||
type AIEmbeddingConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
Dimensions int `yaml:"dimensions"`
|
||||
}
|
||||
|
||||
type AIMetadataConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
Temperature float64 `yaml:"temperature"`
|
||||
}
|
||||
|
||||
type LiteLLMConfig struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
UseResponsesAPI bool `yaml:"use_responses_api"`
|
||||
RequestHeaders map[string]string `yaml:"request_headers"`
|
||||
EmbeddingModel string `yaml:"embedding_model"`
|
||||
MetadataModel string `yaml:"metadata_model"`
|
||||
}
|
||||
|
||||
type OpenRouterAIConfig struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
AppName string `yaml:"app_name"`
|
||||
SiteURL string `yaml:"site_url"`
|
||||
ExtraHeaders map[string]string `yaml:"extra_headers"`
|
||||
}
|
||||
|
||||
type CaptureConfig struct {
|
||||
Source string `yaml:"source"`
|
||||
MetadataDefaults CaptureMetadataDefault `yaml:"metadata_defaults"`
|
||||
}
|
||||
|
||||
type CaptureMetadataDefault struct {
|
||||
Type string `yaml:"type"`
|
||||
TopicFallback string `yaml:"topic_fallback"`
|
||||
}
|
||||
|
||||
type SearchConfig struct {
|
||||
DefaultLimit int `yaml:"default_limit"`
|
||||
DefaultThreshold float64 `yaml:"default_threshold"`
|
||||
MaxLimit int `yaml:"max_limit"`
|
||||
}
|
||||
|
||||
type LoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
type ObservabilityConfig struct {
|
||||
MetricsEnabled bool `yaml:"metrics_enabled"`
|
||||
PprofEnabled bool `yaml:"pprof_enabled"`
|
||||
}
|
||||
113
internal/config/loader.go
Normal file
113
internal/config/loader.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func Load(explicitPath string) (*Config, string, error) {
|
||||
path := ResolvePath(explicitPath)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, path, fmt.Errorf("read config %q: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := defaultConfig()
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, path, fmt.Errorf("decode config %q: %w", path, err)
|
||||
}
|
||||
|
||||
applyEnvOverrides(&cfg)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, path, err
|
||||
}
|
||||
|
||||
return &cfg, path, nil
|
||||
}
|
||||
|
||||
func ResolvePath(explicitPath string) string {
|
||||
if strings.TrimSpace(explicitPath) != "" {
|
||||
return explicitPath
|
||||
}
|
||||
|
||||
if envPath := strings.TrimSpace(os.Getenv("OB1_CONFIG")); envPath != "" {
|
||||
return envPath
|
||||
}
|
||||
|
||||
return DefaultConfigPath
|
||||
}
|
||||
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
Server: ServerConfig{
|
||||
Host: "0.0.0.0",
|
||||
Port: 8080,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
},
|
||||
MCP: MCPConfig{
|
||||
Path: "/mcp",
|
||||
ServerName: "amcs",
|
||||
Version: "0.1.0",
|
||||
Transport: "streamable_http",
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
Mode: "api_keys",
|
||||
HeaderName: "x-brain-key",
|
||||
QueryParam: "key",
|
||||
},
|
||||
AI: AIConfig{
|
||||
Provider: "litellm",
|
||||
Embeddings: AIEmbeddingConfig{
|
||||
Model: "openai/text-embedding-3-small",
|
||||
Dimensions: 1536,
|
||||
},
|
||||
Metadata: AIMetadataConfig{
|
||||
Model: "gpt-4o-mini",
|
||||
Temperature: 0.1,
|
||||
},
|
||||
},
|
||||
Capture: CaptureConfig{
|
||||
Source: DefaultSource,
|
||||
MetadataDefaults: CaptureMetadataDefault{
|
||||
Type: "observation",
|
||||
TopicFallback: "uncategorized",
|
||||
},
|
||||
},
|
||||
Search: SearchConfig{
|
||||
DefaultLimit: 10,
|
||||
DefaultThreshold: 0.5,
|
||||
MaxLimit: 50,
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnvOverrides(cfg *Config) {
|
||||
overrideString(&cfg.Database.URL, "OB1_DATABASE_URL")
|
||||
overrideString(&cfg.AI.LiteLLM.BaseURL, "OB1_LITELLM_BASE_URL")
|
||||
overrideString(&cfg.AI.LiteLLM.APIKey, "OB1_LITELLM_API_KEY")
|
||||
overrideString(&cfg.AI.OpenRouter.APIKey, "OB1_OPENROUTER_API_KEY")
|
||||
|
||||
if value, ok := os.LookupEnv("OB1_SERVER_PORT"); ok {
|
||||
if port, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
|
||||
cfg.Server.Port = port
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func overrideString(target *string, envKey string) {
|
||||
if value, ok := os.LookupEnv(envKey); ok {
|
||||
*target = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
71
internal/config/loader_test.go
Normal file
71
internal/config/loader_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolvePathPrecedence(t *testing.T) {
|
||||
t.Setenv("OB1_CONFIG", "/tmp/from-env.yaml")
|
||||
|
||||
if got := ResolvePath("/tmp/explicit.yaml"); got != "/tmp/explicit.yaml" {
|
||||
t.Fatalf("ResolvePath explicit = %q, want %q", got, "/tmp/explicit.yaml")
|
||||
}
|
||||
|
||||
if got := ResolvePath(""); got != "/tmp/from-env.yaml" {
|
||||
t.Fatalf("ResolvePath env = %q, want %q", got, "/tmp/from-env.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesEnvOverrides(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "test.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(`
|
||||
server:
|
||||
port: 8080
|
||||
mcp:
|
||||
path: "/mcp"
|
||||
auth:
|
||||
keys:
|
||||
- id: "test"
|
||||
value: "secret"
|
||||
database:
|
||||
url: "postgres://from-file"
|
||||
ai:
|
||||
provider: "litellm"
|
||||
embeddings:
|
||||
dimensions: 1536
|
||||
litellm:
|
||||
base_url: "http://localhost:4000/v1"
|
||||
api_key: "file-key"
|
||||
search:
|
||||
default_limit: 10
|
||||
max_limit: 50
|
||||
logging:
|
||||
level: "info"
|
||||
`), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("OB1_DATABASE_URL", "postgres://from-env")
|
||||
t.Setenv("OB1_LITELLM_API_KEY", "env-key")
|
||||
t.Setenv("OB1_SERVER_PORT", "9090")
|
||||
|
||||
cfg, loadedFrom, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if loadedFrom != configPath {
|
||||
t.Fatalf("loadedFrom = %q, want %q", loadedFrom, configPath)
|
||||
}
|
||||
if cfg.Database.URL != "postgres://from-env" {
|
||||
t.Fatalf("database url = %q, want env override", cfg.Database.URL)
|
||||
}
|
||||
if cfg.AI.LiteLLM.APIKey != "env-key" {
|
||||
t.Fatalf("litellm api key = %q, want env override", cfg.AI.LiteLLM.APIKey)
|
||||
}
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Fatalf("server port = %d, want 9090", cfg.Server.Port)
|
||||
}
|
||||
}
|
||||
71
internal/config/validate.go
Normal file
71
internal/config/validate.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if strings.TrimSpace(c.Database.URL) == "" {
|
||||
return fmt.Errorf("invalid config: database.url is required")
|
||||
}
|
||||
|
||||
if len(c.Auth.Keys) == 0 {
|
||||
return fmt.Errorf("invalid config: auth.keys must not be empty")
|
||||
}
|
||||
|
||||
for i, key := range c.Auth.Keys {
|
||||
if strings.TrimSpace(key.ID) == "" {
|
||||
return fmt.Errorf("invalid config: auth.keys[%d].id is required", i)
|
||||
}
|
||||
if strings.TrimSpace(key.Value) == "" {
|
||||
return fmt.Errorf("invalid config: auth.keys[%d].value is required", i)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.MCP.Path) == "" {
|
||||
return fmt.Errorf("invalid config: mcp.path is required")
|
||||
}
|
||||
|
||||
switch c.AI.Provider {
|
||||
case "litellm", "openrouter":
|
||||
default:
|
||||
return fmt.Errorf("invalid config: unsupported ai.provider %q", c.AI.Provider)
|
||||
}
|
||||
|
||||
if c.AI.Embeddings.Dimensions <= 0 {
|
||||
return fmt.Errorf("invalid config: ai.embeddings.dimensions must be greater than zero")
|
||||
}
|
||||
|
||||
switch c.AI.Provider {
|
||||
case "litellm":
|
||||
if strings.TrimSpace(c.AI.LiteLLM.BaseURL) == "" {
|
||||
return fmt.Errorf("invalid config: ai.litellm.base_url is required when ai.provider=litellm")
|
||||
}
|
||||
if strings.TrimSpace(c.AI.LiteLLM.APIKey) == "" {
|
||||
return fmt.Errorf("invalid config: ai.litellm.api_key is required when ai.provider=litellm")
|
||||
}
|
||||
case "openrouter":
|
||||
if strings.TrimSpace(c.AI.OpenRouter.BaseURL) == "" {
|
||||
return fmt.Errorf("invalid config: ai.openrouter.base_url is required when ai.provider=openrouter")
|
||||
}
|
||||
if strings.TrimSpace(c.AI.OpenRouter.APIKey) == "" {
|
||||
return fmt.Errorf("invalid config: ai.openrouter.api_key is required when ai.provider=openrouter")
|
||||
}
|
||||
}
|
||||
|
||||
if c.Server.Port <= 0 {
|
||||
return fmt.Errorf("invalid config: server.port must be greater than zero")
|
||||
}
|
||||
if c.Search.DefaultLimit <= 0 {
|
||||
return fmt.Errorf("invalid config: search.default_limit must be greater than zero")
|
||||
}
|
||||
if c.Search.MaxLimit < c.Search.DefaultLimit {
|
||||
return fmt.Errorf("invalid config: search.max_limit must be greater than or equal to search.default_limit")
|
||||
}
|
||||
if strings.TrimSpace(c.Logging.Level) == "" {
|
||||
return fmt.Errorf("invalid config: logging.level is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
60
internal/config/validate_test.go
Normal file
60
internal/config/validate_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func validConfig() Config {
|
||||
return Config{
|
||||
Server: ServerConfig{Port: 8080},
|
||||
MCP: MCPConfig{Path: "/mcp"},
|
||||
Auth: AuthConfig{
|
||||
Keys: []APIKey{{ID: "test", Value: "secret"}},
|
||||
},
|
||||
Database: DatabaseConfig{URL: "postgres://example"},
|
||||
AI: AIConfig{
|
||||
Provider: "litellm",
|
||||
Embeddings: AIEmbeddingConfig{
|
||||
Dimensions: 1536,
|
||||
},
|
||||
LiteLLM: LiteLLMConfig{
|
||||
BaseURL: "http://localhost:4000/v1",
|
||||
APIKey: "key",
|
||||
},
|
||||
OpenRouter: OpenRouterAIConfig{
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
APIKey: "key",
|
||||
},
|
||||
},
|
||||
Search: SearchConfig{DefaultLimit: 10, MaxLimit: 50},
|
||||
Logging: LoggingConfig{Level: "info"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsLiteLLMAndOpenRouter(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate litellm error = %v", err)
|
||||
}
|
||||
|
||||
cfg.AI.Provider = "openrouter"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate openrouter error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidProvider(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.AI.Provider = "unknown"
|
||||
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("Validate() error = nil, want error for unsupported provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsEmptyAuthKeyValue(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.Auth.Keys[0].Value = ""
|
||||
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("Validate() error = nil, want error for empty auth key value")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user