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..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: strings.ToLower(strings.TrimSpace(raw.Provider)), + provider: provider, model: strings.TrimSpace(raw.Model), baseURL: strings.TrimSpace(raw.Remote.BaseURL), apiKey: strings.TrimSpace(raw.Remote.APIKey), @@ -1084,32 +1088,23 @@ 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" { - out.apiKey = strings.TrimSpace(cfg.Env["OPENROUTER_API_KEY"]) - } + out.apiKey = strings.TrimSpace(cfg.Env["OPENAI_API_KEY"]) if out.apiKey == "" { - out.apiKey = strings.TrimSpace(cfg.Env["OPENAI_API_KEY"]) + out.apiKey = strings.TrimSpace(cfg.Env["OPENROUTER_API_KEY"]) } if out.apiKey == "" { out.apiKey = strings.TrimSpace(cfg.LLM.APIKey) @@ -1157,9 +1152,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 +1163,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..3694b4f 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_FallsBackToOpenRouterKey(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