Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 12 additions & 18 deletions memory/index_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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 == "" {
Expand Down
84 changes: 84 additions & 0 deletions memory/index_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down