From 8c7c6f552e42b2ecd4878ad344efc62aa63909e3 Mon Sep 17 00:00:00 2001 From: Administrator <1280842908@qq.com> Date: Tue, 3 Mar 2026 10:21:29 +0800 Subject: [PATCH] feat(memory): implement pure-go dynamic vector memory search without vendor --- pkg/agent/context.go | 89 ++++---- pkg/agent/context_cache_test.go | 18 +- pkg/agent/instance.go | 28 +++ pkg/agent/loop.go | 17 +- pkg/agent/memory.go | 65 +++++- pkg/agent/memory_vector.go | 264 ++++++++++++++++++++++++ pkg/config/config.go | 12 ++ pkg/providers/openai_compat/provider.go | 52 +++++ pkg/providers/types.go | 8 + 9 files changed, 491 insertions(+), 62 deletions(-) create mode 100644 pkg/agent/memory_vector.go diff --git a/pkg/agent/context.go b/pkg/agent/context.go index f98cebfa0..a24dcf410 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -3,7 +3,6 @@ package agent import ( "errors" "fmt" - "io/fs" "os" "path/filepath" "runtime" @@ -107,11 +106,8 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md %s`, skillsSummary)) } - // Memory context - memoryContext := cb.memory.GetMemoryContext() - if memoryContext != "" { - parts = append(parts, "# Memory\n\n"+memoryContext) - } + // Memory context is no longer injected here. It has moved to buildDynamicContextAndMemory + // so that vector memory search can use the specific user query per-request. // Join with "---" separator return strings.Join(parts, "\n\n---\n\n") @@ -183,7 +179,7 @@ func (cb *ContextBuilder) sourcePaths() []string { filepath.Join(cb.workspace, "SOUL.md"), filepath.Join(cb.workspace, "USER.md"), filepath.Join(cb.workspace, "IDENTITY.md"), - filepath.Join(cb.workspace, "memory", "MEMORY.md"), + // MEMORY.md is no longer cached in the static system prompt } } @@ -199,9 +195,10 @@ type cacheBaseline struct { // Called under write lock when the cache is built. func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { skillsDir := filepath.Join(cb.workspace, "skills") + memoryDir := filepath.Join(cb.workspace, "memory") - // All paths whose existence we track: source files + skills dir. - allPaths := append(cb.sourcePaths(), skillsDir) + // All paths whose existence we track: source files + skills dir + memory dir. + allPaths := append(cb.sourcePaths(), skillsDir, memoryDir) existed := make(map[string]bool, len(allPaths)) var maxMtime time.Time @@ -217,14 +214,18 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { // Walk skills files to capture their mtimes too. // Use os.Stat (not d.Info) to match the stat method used in // fileChangedSince / skillFilesModifiedSince for consistency. - _ = filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error { + walkFunc := func(path string, d os.DirEntry, walkErr error) error { if walkErr == nil && !d.IsDir() { if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) { maxMtime = info.ModTime() } } return nil - }) + } + _ = filepath.WalkDir(skillsDir, walkFunc) + + // Also walk memory files + _ = filepath.WalkDir(memoryDir, walkFunc) // If no tracked files exist yet (empty workspace), maxMtime is zero. // Use a very old non-zero time so that: @@ -270,7 +271,16 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool { // 3. Content-only edits to files inside skills/ do NOT update the parent // directory mtime on most filesystems, so we recursively walk to check // individual file mtimes at any nesting depth. - if skillFilesModifiedSince(skillsDir, cb.cachedAt) { + if filesModifiedSince(skillsDir, cb.cachedAt) { + return true + } + + // --- Memory directory (handled identically to skills) --- + memoryDir := filepath.Join(cb.workspace, "memory") + if cb.fileChangedSince(memoryDir) { + return true + } + if filesModifiedSince(memoryDir, cb.cachedAt) { return true } @@ -311,27 +321,29 @@ func (cb *ContextBuilder) fileChangedSince(path string) bool { // if the callback returned nil when its err parameter is non-nil. var errWalkStop = errors.New("walk stop") -// skillFilesModifiedSince recursively walks the skills directory and checks -// whether any file was modified after t. This catches content-only edits at -// any nesting depth (e.g. skills/name/docs/extra.md) that don't update -// parent directory mtimes. -func skillFilesModifiedSince(skillsDir string, t time.Time) bool { +// filesModifiedSince recursively checks if any file directly or indirectly +// inside dirPath has been modified since the cached time. +func filesModifiedSince(dirPath string, since time.Time) bool { changed := false - err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr == nil && !d.IsDir() { - if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) { - changed = true - return errWalkStop // stop walking - } + err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, walkErr error) error { + if changed || walkErr != nil || d.IsDir() { + return nil + } + if info, err := os.Stat(path); err == nil && info.ModTime().After(since) { + changed = true + return errWalkStop // stop walking } return nil }) - // errWalkStop is expected (early exit on first changed file). - // os.IsNotExist means the skills dir doesn't exist yet — not an error. - // Any other error is unexpected and worth logging. + if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) { - logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()}) + logger.DebugCF("agent", "Failed to walk directory for mtime check", + map[string]any{ + "dir": dirPath, + "error": err.Error(), + }) } + return changed } @@ -354,15 +366,10 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { return sb.String() } -// buildDynamicContext returns a short dynamic context string with per-request info. -// This changes every request (time, session) so it is NOT part of the cached prompt. -// LLM-side KV cache reuse is achieved by each provider adapter's native mechanism: -// - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block -// - OpenAI / Codex: prompt_cache_key for prefix-based caching -// -// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching -// See: https://platform.openai.com/docs/guides/prompt-caching -func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { +// buildDynamicContextAndMemory returns a short dynamic context string with per-request info, +// including semantic memory retrieved based on the current user message. +// This changes every request so it is NOT part of the cached prompt. +func (cb *ContextBuilder) buildDynamicContextAndMemory(channel, chatID, currentMessage string) string { now := time.Now().Format("2006-01-02 15:04 (Monday)") rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) @@ -373,6 +380,12 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID) } + // Dynamic memory context (retrieving relevant context based on user message) + memoryContext := cb.memory.GetMemoryContext(currentMessage) + if memoryContext != "" { + fmt.Fprintf(&sb, "\n\n# Memory\n\n%s", memoryContext) + } + return sb.String() } @@ -396,8 +409,8 @@ func (cb *ContextBuilder) BuildMessages( // - OpenAI-compat passes messages through as-is. staticPrompt := cb.BuildSystemPromptWithCache() - // Build short dynamic context (time, runtime, session) — changes per request - dynamicCtx := cb.buildDynamicContext(channel, chatID) + // Build short dynamic context (time, runtime, session, dynamic semantic memory) + dynamicCtx := cb.buildDynamicContextAndMemory(channel, chatID, currentMessage) // Compose a single system message: static (cached) + dynamic + optional summary. // Keeping all system content in one message ensures every provider adapter can diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 0905e8a46..11bb1a64a 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -146,11 +146,11 @@ func TestMtimeAutoInvalidation(t *testing.T) { checkField: "Updated Identity", }, { - name: "memory file change", - file: "memory/MEMORY.md", - contentV1: "# Memory\nUser likes Go.", - contentV2: "# Memory\nUser likes Rust.", - checkField: "User likes Rust", + name: "another bootstrap file change", + file: "SOUL.md", + contentV1: "# Original Soul", + contentV2: "# Updated Soul", + checkField: "Updated Soul", }, } @@ -286,10 +286,10 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) { checkField: "Be kind and helpful", }, { - name: "new memory file", - file: "memory/MEMORY.md", - content: "# Memory\nUser prefers dark mode.", - checkField: "User prefers dark mode", + name: "new agents file", + file: "AGENTS.md", + content: "# Agents\nCustom agent definition.", + checkField: "Custom agent definition", }, } diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index dd843eb47..2d0a40c93 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -1,6 +1,7 @@ package agent import ( + "context" "fmt" "log" "os" @@ -10,6 +11,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/openai_compat" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/tools" @@ -86,6 +88,32 @@ func NewAgentInstance( skillsFilter = agentCfg.Skills } + // Initialize vector memory if enabled + if cfg != nil && cfg.Tools.VectorMemory.Enabled { + if cfg.Tools.VectorMemory.APIBase == "" || cfg.Tools.VectorMemory.APIKey == "" { + log.Printf("Warning: vector memory enabled but API base/key not configured") + } else { + // Create a dedicated provider for embeddings (we assume OpenAI-compatible for embeddings) + embedProvider := openai_compat.NewProvider( + cfg.Tools.VectorMemory.APIKey, + cfg.Tools.VectorMemory.APIBase, + "", // no proxy needed by default, could be added later + ) + + dbPath := filepath.Join(workspace, "memory", "memory.sqlite") + vs, err := NewVectorMemoryStore(dbPath, cfg.Tools.VectorMemory.EmbeddingModel, cfg.Tools.VectorMemory.TopK) + if err != nil { + log.Printf("Warning: failed to initialize vector memory store for agent %s: %v", agentName, err) + } else { + embedFn := func(text string) ([]float32, error) { + // Use context.Background() here because this runs asynchronously or during sync + return embedProvider.Embed(context.Background(), text, cfg.Tools.VectorMemory.EmbeddingModel) + } + contextBuilder.memory.SetVectorStore(vs, embedFn) + } + } + } + maxIter := defaults.MaxToolIterations if maxIter == 0 { maxIter = 20 diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0bae11048..96575c825 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -104,7 +104,7 @@ func registerSharedTools( } // Web tools - searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ + if searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ BraveAPIKey: cfg.Tools.Web.Brave.APIKey, BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, BraveEnabled: cfg.Tools.Web.Brave.Enabled, @@ -118,17 +118,16 @@ func registerSharedTools( PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, Proxy: cfg.Tools.Web.Proxy, - }) - if err != nil { - logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) - } else if searchTool != nil { + }); err == nil && searchTool != nil { agent.Tools.Register(searchTool) + } else if err != nil { + logger.WarnCF("agent", "Failed to initialize WebSearchTool", map[string]any{"error": err.Error()}) } - fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } else { + + if fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, 10*1024*1024); err == nil { agent.Tools.Register(fetchTool) + } else { + logger.WarnCF("agent", "Failed to initialize WebFetchTool", map[string]any{"error": err.Error()}) } // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 01e682f3b..e3673c154 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -7,7 +7,9 @@ package agent import ( + "context" "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -19,10 +21,14 @@ import ( // MemoryStore manages persistent memory for the agent. // - Long-term memory: memory/MEMORY.md // - Daily notes: memory/YYYYMM/YYYYMMDD.md +// - Optional: SQLite vector store for semantic search type MemoryStore struct { - workspace string - memoryDir string - memoryFile string + workspace string + memoryDir string + memoryFile string + vectorStore *VectorMemoryStore + embedFn func(string) ([]float32, error) // nil when vector search disabled + lastSyncTime time.Time } // NewMemoryStore creates a new MemoryStore with the given workspace path. @@ -41,6 +47,13 @@ func NewMemoryStore(workspace string) *MemoryStore { } } +// SetVectorStore attaches a VectorMemoryStore and embedding function. +// When set, GetMemoryContext will perform semantic retrieval instead of full-text load. +func (ms *MemoryStore) SetVectorStore(vs *VectorMemoryStore, embedFn func(string) ([]float32, error)) { + ms.vectorStore = vs + ms.embedFn = embedFn +} + // getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md). func (ms *MemoryStore) getTodayFile() string { today := time.Now().Format("20060102") // YYYYMMDD @@ -130,9 +143,49 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string { } // GetMemoryContext returns formatted memory context for the agent prompt. -// Includes long-term memory and recent daily notes. -func (ms *MemoryStore) GetMemoryContext() string { - longTerm := ms.ReadLongTerm() +// When a vector store is configured, it performs semantic retrieval using the +// query text. Otherwise it falls back to loading the full MEMORY.md. +func (ms *MemoryStore) GetMemoryContext(query string) string { + var longTerm string + + if ms.vectorStore != nil && ms.embedFn != nil { + // Auto-sync vector store if *any* file in the memory directory has changed since last sync + var latestModTime time.Time + _ = filepath.WalkDir(ms.memoryDir, func(path string, d fs.DirEntry, err error) error { + if err == nil { + if info, statErr := d.Info(); statErr == nil { + if info.ModTime().After(latestModTime) { + latestModTime = info.ModTime() + } + } + } + return nil + }) + + if latestModTime.After(ms.lastSyncTime) { + // Use context.Background() for the sync operation + ms.vectorStore.SyncFromDirectory(context.Background(), ms.memoryDir, ms.embedFn) + ms.lastSyncTime = latestModTime + } + + if query != "" { + // Semantic path: retrieve top-K relevant memories + vec, err := ms.embedFn(query) + if err == nil { + results, err := ms.vectorStore.Search(vec) + if err == nil && len(results) > 0 { + longTerm = strings.Join(results, "\n\n") + } + } + // On any error, fall through to full-text load + } + } + + if longTerm == "" { + // Full-text fallback (always used when vector store is disabled) + longTerm = ms.ReadLongTerm() + } + recentNotes := ms.GetRecentDailyNotes(3) if longTerm == "" && recentNotes == "" { diff --git a/pkg/agent/memory_vector.go b/pkg/agent/memory_vector.go new file mode 100644 index 000000000..b5073ac61 --- /dev/null +++ b/pkg/agent/memory_vector.go @@ -0,0 +1,264 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "context" + "crypto/md5" + "database/sql" + "encoding/binary" + "encoding/hex" + "fmt" + "io/fs" + "log" + "math" + "os" + "path/filepath" + "sort" + "strings" + + _ "modernc.org/sqlite" // pure-Go SQLite driver +) + +// VectorMemoryStore provides semantic memory search backed by SQLite. +// Embeddings are stored as raw float32 blobs; cosine similarity is computed in Go. +// +// The DB schema is intentionally minimal: +// +// CREATE TABLE memories ( +// id TEXT PRIMARY KEY, -- content hash or sequential key +// content TEXT NOT NULL, -- raw text of the memory entry +// vector BLOB NOT NULL -- float32 little-endian array +// ) +type VectorMemoryStore struct { + db *sql.DB + embeddingModel string + topK int +} + +// NewVectorMemoryStore opens (or creates) the SQLite DB at dbPath. +func NewVectorMemoryStore(dbPath string, embeddingModel string, topK int) (*VectorMemoryStore, error) { + if topK <= 0 { + topK = 5 + } + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("vector memory: open db: %w", err) + } + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + vector BLOB NOT NULL + )`); err != nil { + db.Close() + return nil, fmt.Errorf("vector memory: create table: %w", err) + } + return &VectorMemoryStore{ + db: db, + embeddingModel: embeddingModel, + topK: topK, + }, nil +} + +// Close closes the underlying database. +func (vs *VectorMemoryStore) Close() error { + return vs.db.Close() +} + +// Upsert stores a memory entry along with its embedding vector. +func (vs *VectorMemoryStore) Upsert(id, content string, vector []float32) error { + blob := float32SliceToBytes(vector) + _, err := vs.db.Exec( + `INSERT INTO memories (id, content, vector) VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET content=excluded.content, vector=excluded.vector`, + id, content, blob, + ) + return err +} + +// Search returns the top-K memory entries most semantically similar to queryVec. +func (vs *VectorMemoryStore) Search(queryVec []float32) ([]string, error) { + rows, err := vs.db.Query(`SELECT content, vector FROM memories`) + if err != nil { + return nil, fmt.Errorf("vector memory: query: %w", err) + } + defer rows.Close() + + type scored struct { + content string + score float64 + } + var results []scored + + for rows.Next() { + var content string + var blob []byte + if err := rows.Scan(&content, &blob); err != nil { + continue + } + vec := bytesToFloat32Slice(blob) + if len(vec) == 0 { + continue + } + sim := cosineSimilarity(queryVec, vec) + results = append(results, scored{content: content, score: sim}) + } + + sort.Slice(results, func(i, j int) bool { + return results[i].score > results[j].score + }) + + topK := vs.topK + if topK > len(results) { + topK = len(results) + } + out := make([]string, topK) + for i := range topK { + out[i] = results[i].content + } + return out, nil +} + +// Count returns the number of stored memory entries. +func (vs *VectorMemoryStore) Count() int { + var n int + vs.db.QueryRow(`SELECT COUNT(*) FROM memories`).Scan(&n) //nolint:errcheck + return n +} + +// SyncFromDirectory parses all .md files in the given directory into individual entries +// and upserts any that are not already indexed, using the provided embedder to vectorize them. +// It also deletes entries from the database that are no longer present in any of the files. +func (vs *VectorMemoryStore) SyncFromDirectory(ctx context.Context, dirPath string, embedder func(string) ([]float32, error)) { + // Gather complete content from all .md files in the directory + var allEntries []string + err := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".md") { + if data, readErr := os.ReadFile(path); readErr == nil { + allEntries = append(allEntries, splitMemoryEntries(string(data))...) + } + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + log.Printf("vector memory: sync directory walk error: %v", err) + } + + // Map to track current chunks by hash + currentHashes := make(map[string]string) + + for _, entry := range allEntries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + hash := md5.Sum([]byte(entry)) + id := hex.EncodeToString(hash[:]) + currentHashes[id] = entry + } + + // Fetch existing IDs to find what to add/delete + existingIDs := make(map[string]bool) + rows, err := vs.db.Query(`SELECT id FROM memories`) + if err == nil { + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil { + existingIDs[id] = true + } + } + rows.Close() + } + + // Insert new entries + for id, entry := range currentHashes { + if existingIDs[id] { + continue // Already indexed + } + vec, err := embedder(entry) + if err != nil { + log.Printf("vector memory: sync embed error for %s: %v", id[:8], err) + continue + } + if err := vs.Upsert(id, entry, vec); err != nil { + log.Printf("vector memory: sync upsert error for %s: %v", id[:8], err) + } + } + + // Delete stale entries + for id := range existingIDs { + if _, ok := currentHashes[id]; !ok { + vs.db.Exec(`DELETE FROM memories WHERE id=?`, id) //nolint:errcheck + } + } +} + +// splitMemoryEntries splits a MEMORY.md file into individual memory chunks. +// Splits on markdown headings (##) or blank-line separated paragraphs. +func splitMemoryEntries(content string) []string { + var entries []string + // Split by "##" headings first + if strings.Contains(content, "\n## ") || strings.HasPrefix(content, "## ") { + parts := strings.Split(content, "\n## ") + for i, p := range parts { + if i > 0 { + p = "## " + p + } + if strings.TrimSpace(p) != "" { + entries = append(entries, p) + } + } + return entries + } + // Fallback: split on blank lines + for _, block := range strings.Split(content, "\n\n") { + if strings.TrimSpace(block) != "" { + entries = append(entries, block) + } + } + return entries +} + +// --- Vector math helpers --- + +func cosineSimilarity(a, b []float32) float64 { + n := len(a) + if len(b) < n { + n = len(b) + } + var dot, normA, normB float64 + for i := range n { + ai := float64(a[i]) + bi := float64(b[i]) + dot += ai * bi + normA += ai * ai + normB += bi * bi + } + if normA == 0 || normB == 0 { + return 0 + } + return dot / (math.Sqrt(normA) * math.Sqrt(normB)) +} + +func float32SliceToBytes(v []float32) []byte { + buf := make([]byte, len(v)*4) + for i, f := range v { + binary.LittleEndian.PutUint32(buf[i*4:], math.Float32bits(f)) + } + return buf +} + +func bytesToFloat32Slice(b []byte) []float32 { + n := len(b) / 4 + out := make([]float32, n) + for i := range n { + bits := binary.LittleEndian.Uint32(b[i*4:]) + out[i] = math.Float32frombits(bits) + } + return out +} diff --git a/pkg/config/config.go b/pkg/config/config.go index b2d5d402b..a71933e31 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -569,6 +569,18 @@ type ToolsConfig struct { Skills SkillsToolsConfig `json:"skills"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MCP MCPConfig `json:"mcp"` + VectorMemory VectorMemoryConfig `json:"vector_memory"` +} + +// VectorMemoryConfig configures the optional SQLite-backed semantic memory search. +// When enabled, agent memory retrieval uses embedding-based similarity instead of +// injecting the entire MEMORY.md into every prompt. +type VectorMemoryConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_VECTOR_MEMORY_ENABLED"` + APIBase string `json:"api_base" env:"PICOCLAW_VECTOR_MEMORY_API_BASE"` + APIKey string `json:"api_key" env:"PICOCLAW_VECTOR_MEMORY_API_KEY"` + EmbeddingModel string `json:"embedding_model" env:"PICOCLAW_VECTOR_MEMORY_EMBEDDING_MODEL"` // e.g. "text-embedding-3-small" + TopK int `json:"top_k" env:"PICOCLAW_VECTOR_MEMORY_TOP_K"` // Number of memories to retrieve per query (default 5) } type SkillsToolsConfig struct { diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 33e746106..d13e66d23 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -195,6 +195,58 @@ func (p *Provider) Chat( return parseResponse(body) } +// Embed implements providers.EmbedProvider by calling /v1/embeddings. +// The Provider satisfies EmbedProvider optionally — callers should type-assert. +func (p *Provider) Embed(ctx context.Context, text string, model string) ([]float32, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + reqBody, err := json.Marshal(map[string]any{ + "model": model, + "input": text, + }) + if err != nil { + return nil, fmt.Errorf("failed to marshal embed request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/embeddings", bytes.NewReader(reqBody)) + if err != nil { + return nil, fmt.Errorf("failed to create embed request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if p.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+p.apiKey) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("embed request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read embed response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("embed API error: status %d: %s", resp.StatusCode, body) + } + + var result struct { + Data []struct { + Embedding []float32 `json:"embedding"` + } `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to decode embed response: %w", err) + } + if len(result.Data) == 0 || len(result.Data[0].Embedding) == 0 { + return nil, fmt.Errorf("embed response contained no data") + } + return result.Data[0].Embedding, nil +} + func parseResponse(body []byte) (*LLMResponse, error) { var apiResponse struct { Choices []struct { diff --git a/pkg/providers/types.go b/pkg/providers/types.go index f0c168bc6..e4ff8ea9d 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -37,6 +37,14 @@ type StatefulProvider interface { Close() } +// EmbedProvider is an optional interface for providers that support text embeddings. +// Not all providers implement this; use a type assertion to check. +type EmbedProvider interface { + // Embed converts text into a float32 vector using the given embedding model. + // Returns an error if the provider does not support embeddings or the call fails. + Embed(ctx context.Context, text string, model string) ([]float32, error) +} + // FailoverReason classifies why an LLM request failed for fallback decisions. type FailoverReason string