From b77193b852d6916811e908f09863df13469ec874 Mon Sep 17 00:00:00 2001 From: mosaxiv Date: Wed, 18 Feb 2026 00:23:02 +0900 Subject: [PATCH 1/2] Simplify memory embedding provider config --- README.md | 19 ++++++++ memory/index_manager.go | 38 +++++++++------- memory/index_manager_test.go | 84 ++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5222f2d..26a35c0 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,25 @@ To enable semantic memory search, add `memorySearch` to the agent defaults: } ``` +Local embedding (Ollama / OpenAI-compatible local endpoint): + +```json +{ + "agents": { + "defaults": { + "memorySearch": { + "enabled": true, + "provider": "openai", + "model": "nomic-embed-text", + "remote": { + "baseURL": "http://localhost:11434/v1" + } + } + } + } +} +``` + When enabled: - The agent gains `memory_search` and `memory_get` tools for retrieving past context. - clawlet indexes `MEMORY.md`, `memory.md`, and `memory/**/*.md` for retrieval. diff --git a/memory/index_manager.go b/memory/index_manager.go index be524d7..547c1ac 100644 --- a/memory/index_manager.go +++ b/memory/index_manager.go @@ -1057,7 +1057,7 @@ func resolveSearchConfig(cfg *config.Config, workspace string) (resolvedSearchCo raw := cfg.Agents.Defaults.MemorySearch out := resolvedSearchConfig{ enabled: raw.EnabledValue(), - provider: strings.ToLower(strings.TrimSpace(raw.Provider)), + provider: normalizeMemorySearchProvider(raw.Provider), model: strings.TrimSpace(raw.Model), baseURL: strings.TrimSpace(raw.Remote.BaseURL), apiKey: strings.TrimSpace(raw.Remote.APIKey), @@ -1084,33 +1084,29 @@ func resolveSearchConfig(cfg *config.Config, workspace string) (resolvedSearchCo if raw.Query.Hybrid.TextWeight != nil { out.hybridTextWeight = *raw.Query.Hybrid.TextWeight } - if out.provider == "" { - out.provider = "openai" - } if out.enabled { if out.model == "" { return out, errors.New("agents.defaults.memorySearch.model is required when enabled") } switch out.provider { - case "openai", "openrouter": + case "openai": default: return out, fmt.Errorf("unsupported memorySearch.provider: %s", out.provider) } } if out.baseURL == "" { - if out.provider == "openrouter" { - out.baseURL = config.DefaultOpenRouterBaseURL - } else { - out.baseURL = config.DefaultOpenAIBaseURL - } + out.baseURL = config.DefaultOpenAIBaseURL } if out.apiKey == "" { - if out.provider == "openrouter" { + if looksLikeOpenRouterBaseURL(out.baseURL) { out.apiKey = strings.TrimSpace(cfg.Env["OPENROUTER_API_KEY"]) } if out.apiKey == "" { out.apiKey = strings.TrimSpace(cfg.Env["OPENAI_API_KEY"]) } + if out.apiKey == "" { + out.apiKey = strings.TrimSpace(cfg.Env["OPENROUTER_API_KEY"]) + } if out.apiKey == "" { out.apiKey = strings.TrimSpace(cfg.LLM.APIKey) } @@ -1150,6 +1146,19 @@ func resolveSearchConfig(cfg *config.Config, workspace string) (resolvedSearchCo return out, nil } +func looksLikeOpenRouterBaseURL(raw string) bool { + s := strings.ToLower(strings.TrimSpace(raw)) + return strings.Contains(s, "openrouter.ai") +} + +func normalizeMemorySearchProvider(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + if s == "" { + return "openai" + } + return s +} + func (p *openAIEmbeddingProvider) EmbedBatch(ctx context.Context, texts []string) ([][]float64, error) { if len(texts) == 0 { return [][]float64{}, nil @@ -1157,9 +1166,6 @@ func (p *openAIEmbeddingProvider) EmbedBatch(ctx context.Context, texts []string if strings.TrimSpace(p.model) == "" { return nil, errors.New("memory embedding model is empty") } - if strings.TrimSpace(p.apiKey) == "" { - return nil, errors.New("memory embedding apiKey is empty") - } endpoint := strings.TrimRight(p.baseURL, "/") + "/embeddings" reqBody := map[string]any{ "model": p.model, @@ -1171,7 +1177,9 @@ func (p *openAIEmbeddingProvider) EmbedBatch(ctx context.Context, texts []string return nil, err } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+p.apiKey) + if strings.TrimSpace(p.apiKey) != "" { + req.Header.Set("Authorization", "Bearer "+p.apiKey) + } for k, v := range p.headers { k = strings.TrimSpace(k) if k == "" { diff --git a/memory/index_manager_test.go b/memory/index_manager_test.go index b17ca34..e05d4a8 100644 --- a/memory/index_manager_test.go +++ b/memory/index_manager_test.go @@ -94,6 +94,43 @@ func TestIndexManager_SearchAndRead(t *testing.T) { } } +func TestResolveSearchConfig_OpenRouterProviderUnsupported(t *testing.T) { + cfg := config.Default() + enabled := true + cfg.Agents.Defaults.MemorySearch.Enabled = &enabled + cfg.Agents.Defaults.MemorySearch.Provider = "openrouter" + cfg.Agents.Defaults.MemorySearch.Model = "text-embedding-3-small" + + _, err := resolveSearchConfig(cfg, t.TempDir()) + if err == nil { + t.Fatalf("expected unsupported provider error") + } + if !strings.Contains(err.Error(), "unsupported memorySearch.provider") { + t.Fatalf("error=%v", err) + } +} + +func TestResolveSearchConfig_OpenRouterBaseURLUsesOpenRouterKey(t *testing.T) { + cfg := config.Default() + enabled := true + cfg.Agents.Defaults.MemorySearch.Enabled = &enabled + cfg.Agents.Defaults.MemorySearch.Provider = "openai" + cfg.Agents.Defaults.MemorySearch.Model = "text-embedding-3-small" + cfg.Agents.Defaults.MemorySearch.Remote.BaseURL = config.DefaultOpenRouterBaseURL + cfg.Env["OPENROUTER_API_KEY"] = "sk-or-test" + + got, err := resolveSearchConfig(cfg, t.TempDir()) + if err != nil { + t.Fatalf("resolveSearchConfig error: %v", err) + } + if got.provider != "openai" { + t.Fatalf("provider=%q", got.provider) + } + if got.apiKey != "sk-or-test" { + t.Fatalf("apiKey=%q", got.apiKey) + } +} + func newEmbeddingTestServer(t *testing.T) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -130,6 +167,53 @@ func newEmbeddingTestServer(t *testing.T) *httptest.Server { })) } +func TestOpenAIEmbeddingProvider_EmbedBatch_WithoutAPIKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/embeddings" { + http.NotFound(w, r) + return + } + if got := strings.TrimSpace(r.Header.Get("Authorization")); got != "" { + http.Error(w, "authorization header must be empty", http.StatusBadRequest) + return + } + var req struct { + Input []string `json:"input"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode req: %v", err) + } + data := make([]map[string]any, 0, len(req.Input)) + for i, txt := range req.Input { + data = append(data, map[string]any{ + "index": i, + "embedding": fakeEmbedding(txt), + }) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + })) + defer server.Close() + + p := &openAIEmbeddingProvider{ + provider: "openai", + baseURL: server.URL + "/v1", + apiKey: "", + model: "nomic-embed-text", + headers: map[string]string{}, + client: server.Client(), + } + out, err := p.EmbedBatch(context.Background(), []string{"hello local embedding"}) + if err != nil { + t.Fatalf("EmbedBatch error: %v", err) + } + if len(out) != 1 { + t.Fatalf("embedding count=%d", len(out)) + } + if len(out[0]) == 0 { + t.Fatalf("embedding dims=%d", len(out[0])) + } +} + func toString(v any) string { if s, ok := v.(string); ok { return s From d04804784c6835ceafd2fdfba9087957cd666dc1 Mon Sep 17 00:00:00 2001 From: mosaxiv Date: Wed, 18 Feb 2026 00:30:16 +0900 Subject: [PATCH 2/2] Inline memory search provider normalization --- memory/index_manager.go | 26 ++++++-------------------- memory/index_manager_test.go | 2 +- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/memory/index_manager.go b/memory/index_manager.go index 547c1ac..73b8399 100644 --- a/memory/index_manager.go +++ b/memory/index_manager.go @@ -1055,9 +1055,13 @@ func (m *IndexManager) listMemoryFilesLocked() ([]memoryFileEntry, error) { func resolveSearchConfig(cfg *config.Config, workspace string) (resolvedSearchConfig, error) { raw := cfg.Agents.Defaults.MemorySearch + provider := strings.ToLower(strings.TrimSpace(raw.Provider)) + if provider == "" { + provider = "openai" + } out := resolvedSearchConfig{ enabled: raw.EnabledValue(), - provider: normalizeMemorySearchProvider(raw.Provider), + provider: provider, model: strings.TrimSpace(raw.Model), baseURL: strings.TrimSpace(raw.Remote.BaseURL), apiKey: strings.TrimSpace(raw.Remote.APIKey), @@ -1098,12 +1102,7 @@ func resolveSearchConfig(cfg *config.Config, workspace string) (resolvedSearchCo out.baseURL = config.DefaultOpenAIBaseURL } if out.apiKey == "" { - if looksLikeOpenRouterBaseURL(out.baseURL) { - out.apiKey = strings.TrimSpace(cfg.Env["OPENROUTER_API_KEY"]) - } - if out.apiKey == "" { - out.apiKey = strings.TrimSpace(cfg.Env["OPENAI_API_KEY"]) - } + out.apiKey = strings.TrimSpace(cfg.Env["OPENAI_API_KEY"]) if out.apiKey == "" { out.apiKey = strings.TrimSpace(cfg.Env["OPENROUTER_API_KEY"]) } @@ -1146,19 +1145,6 @@ func resolveSearchConfig(cfg *config.Config, workspace string) (resolvedSearchCo return out, nil } -func looksLikeOpenRouterBaseURL(raw string) bool { - s := strings.ToLower(strings.TrimSpace(raw)) - return strings.Contains(s, "openrouter.ai") -} - -func normalizeMemorySearchProvider(raw string) string { - s := strings.ToLower(strings.TrimSpace(raw)) - if s == "" { - return "openai" - } - return s -} - func (p *openAIEmbeddingProvider) EmbedBatch(ctx context.Context, texts []string) ([][]float64, error) { if len(texts) == 0 { return [][]float64{}, nil diff --git a/memory/index_manager_test.go b/memory/index_manager_test.go index e05d4a8..3694b4f 100644 --- a/memory/index_manager_test.go +++ b/memory/index_manager_test.go @@ -110,7 +110,7 @@ func TestResolveSearchConfig_OpenRouterProviderUnsupported(t *testing.T) { } } -func TestResolveSearchConfig_OpenRouterBaseURLUsesOpenRouterKey(t *testing.T) { +func TestResolveSearchConfig_FallsBackToOpenRouterKey(t *testing.T) { cfg := config.Default() enabled := true cfg.Agents.Defaults.MemorySearch.Enabled = &enabled