feat(memory): implement pure-go dynamic vector memory search without vendor

This commit is contained in:
Administrator 2026-03-03 10:21:29 +08:00
parent f1f160ef86
commit 8c7c6f552e
9 changed files with 491 additions and 62 deletions

View file

@ -3,7 +3,6 @@ package agent
import ( import (
"errors" "errors"
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@ -107,11 +106,8 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
%s`, skillsSummary)) %s`, skillsSummary))
} }
// Memory context // Memory context is no longer injected here. It has moved to buildDynamicContextAndMemory
memoryContext := cb.memory.GetMemoryContext() // so that vector memory search can use the specific user query per-request.
if memoryContext != "" {
parts = append(parts, "# Memory\n\n"+memoryContext)
}
// Join with "---" separator // Join with "---" separator
return strings.Join(parts, "\n\n---\n\n") 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, "SOUL.md"),
filepath.Join(cb.workspace, "USER.md"), filepath.Join(cb.workspace, "USER.md"),
filepath.Join(cb.workspace, "IDENTITY.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. // Called under write lock when the cache is built.
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
skillsDir := filepath.Join(cb.workspace, "skills") skillsDir := filepath.Join(cb.workspace, "skills")
memoryDir := filepath.Join(cb.workspace, "memory")
// All paths whose existence we track: source files + skills dir. // All paths whose existence we track: source files + skills dir + memory dir.
allPaths := append(cb.sourcePaths(), skillsDir) allPaths := append(cb.sourcePaths(), skillsDir, memoryDir)
existed := make(map[string]bool, len(allPaths)) existed := make(map[string]bool, len(allPaths))
var maxMtime time.Time var maxMtime time.Time
@ -217,14 +214,18 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
// Walk skills files to capture their mtimes too. // Walk skills files to capture their mtimes too.
// Use os.Stat (not d.Info) to match the stat method used in // Use os.Stat (not d.Info) to match the stat method used in
// fileChangedSince / skillFilesModifiedSince for consistency. // 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 walkErr == nil && !d.IsDir() {
if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) { if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) {
maxMtime = info.ModTime() maxMtime = info.ModTime()
} }
} }
return nil 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. // If no tracked files exist yet (empty workspace), maxMtime is zero.
// Use a very old non-zero time so that: // 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 // 3. Content-only edits to files inside skills/ do NOT update the parent
// directory mtime on most filesystems, so we recursively walk to check // directory mtime on most filesystems, so we recursively walk to check
// individual file mtimes at any nesting depth. // 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 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. // if the callback returned nil when its err parameter is non-nil.
var errWalkStop = errors.New("walk stop") var errWalkStop = errors.New("walk stop")
// skillFilesModifiedSince recursively walks the skills directory and checks // filesModifiedSince recursively checks if any file directly or indirectly
// whether any file was modified after t. This catches content-only edits at // inside dirPath has been modified since the cached time.
// any nesting depth (e.g. skills/name/docs/extra.md) that don't update func filesModifiedSince(dirPath string, since time.Time) bool {
// parent directory mtimes.
func skillFilesModifiedSince(skillsDir string, t time.Time) bool {
changed := false changed := false
err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error { err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, walkErr error) error {
if walkErr == nil && !d.IsDir() { if changed || walkErr != nil || d.IsDir() {
if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) { return nil
}
if info, err := os.Stat(path); err == nil && info.ModTime().After(since) {
changed = true changed = true
return errWalkStop // stop walking return errWalkStop // stop walking
} }
}
return nil 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) { 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 return changed
} }
@ -354,15 +366,10 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
return sb.String() return sb.String()
} }
// buildDynamicContext returns a short dynamic context string with per-request info. // buildDynamicContextAndMemory 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. // including semantic memory retrieved based on the current user message.
// LLM-side KV cache reuse is achieved by each provider adapter's native mechanism: // This changes every request so it is NOT part of the cached prompt.
// - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block func (cb *ContextBuilder) buildDynamicContextAndMemory(channel, chatID, currentMessage string) string {
// - 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 {
now := time.Now().Format("2006-01-02 15:04 (Monday)") now := time.Now().Format("2006-01-02 15:04 (Monday)")
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) 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) 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() return sb.String()
} }
@ -396,8 +409,8 @@ func (cb *ContextBuilder) BuildMessages(
// - OpenAI-compat passes messages through as-is. // - OpenAI-compat passes messages through as-is.
staticPrompt := cb.BuildSystemPromptWithCache() staticPrompt := cb.BuildSystemPromptWithCache()
// Build short dynamic context (time, runtime, session) — changes per request // Build short dynamic context (time, runtime, session, dynamic semantic memory)
dynamicCtx := cb.buildDynamicContext(channel, chatID) dynamicCtx := cb.buildDynamicContextAndMemory(channel, chatID, currentMessage)
// Compose a single system message: static (cached) + dynamic + optional summary. // Compose a single system message: static (cached) + dynamic + optional summary.
// Keeping all system content in one message ensures every provider adapter can // Keeping all system content in one message ensures every provider adapter can

View file

@ -146,11 +146,11 @@ func TestMtimeAutoInvalidation(t *testing.T) {
checkField: "Updated Identity", checkField: "Updated Identity",
}, },
{ {
name: "memory file change", name: "another bootstrap file change",
file: "memory/MEMORY.md", file: "SOUL.md",
contentV1: "# Memory\nUser likes Go.", contentV1: "# Original Soul",
contentV2: "# Memory\nUser likes Rust.", contentV2: "# Updated Soul",
checkField: "User likes Rust", checkField: "Updated Soul",
}, },
} }
@ -286,10 +286,10 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) {
checkField: "Be kind and helpful", checkField: "Be kind and helpful",
}, },
{ {
name: "new memory file", name: "new agents file",
file: "memory/MEMORY.md", file: "AGENTS.md",
content: "# Memory\nUser prefers dark mode.", content: "# Agents\nCustom agent definition.",
checkField: "User prefers dark mode", checkField: "Custom agent definition",
}, },
} }

View file

@ -1,6 +1,7 @@
package agent package agent
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -10,6 +11,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers" "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/routing"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
@ -86,6 +88,32 @@ func NewAgentInstance(
skillsFilter = agentCfg.Skills 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 maxIter := defaults.MaxToolIterations
if maxIter == 0 { if maxIter == 0 {
maxIter = 20 maxIter = 20

View file

@ -104,7 +104,7 @@ func registerSharedTools(
} }
// Web tools // Web tools
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ if searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
BraveAPIKey: cfg.Tools.Web.Brave.APIKey, BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled, BraveEnabled: cfg.Tools.Web.Brave.Enabled,
@ -118,17 +118,16 @@ func registerSharedTools(
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
Proxy: cfg.Tools.Web.Proxy, Proxy: cfg.Tools.Web.Proxy,
}) }); err == nil && searchTool != nil {
if err != nil {
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()})
} else if searchTool != nil {
agent.Tools.Register(searchTool) 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 { if fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, 10*1024*1024); err == nil {
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
} else {
agent.Tools.Register(fetchTool) 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 // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms

View file

@ -7,7 +7,9 @@
package agent package agent
import ( import (
"context"
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -19,10 +21,14 @@ import (
// MemoryStore manages persistent memory for the agent. // MemoryStore manages persistent memory for the agent.
// - Long-term memory: memory/MEMORY.md // - Long-term memory: memory/MEMORY.md
// - Daily notes: memory/YYYYMM/YYYYMMDD.md // - Daily notes: memory/YYYYMM/YYYYMMDD.md
// - Optional: SQLite vector store for semantic search
type MemoryStore struct { type MemoryStore struct {
workspace string workspace string
memoryDir string memoryDir string
memoryFile 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. // 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). // getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md).
func (ms *MemoryStore) getTodayFile() string { func (ms *MemoryStore) getTodayFile() string {
today := time.Now().Format("20060102") // YYYYMMDD 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. // GetMemoryContext returns formatted memory context for the agent prompt.
// Includes long-term memory and recent daily notes. // When a vector store is configured, it performs semantic retrieval using the
func (ms *MemoryStore) GetMemoryContext() string { // query text. Otherwise it falls back to loading the full MEMORY.md.
longTerm := ms.ReadLongTerm() 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) recentNotes := ms.GetRecentDailyNotes(3)
if longTerm == "" && recentNotes == "" { if longTerm == "" && recentNotes == "" {

264
pkg/agent/memory_vector.go Normal file
View file

@ -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
}

View file

@ -569,6 +569,18 @@ type ToolsConfig struct {
Skills SkillsToolsConfig `json:"skills"` Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
MCP MCPConfig `json:"mcp"` 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 { type SkillsToolsConfig struct {

View file

@ -195,6 +195,58 @@ func (p *Provider) Chat(
return parseResponse(body) 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) { func parseResponse(body []byte) (*LLMResponse, error) {
var apiResponse struct { var apiResponse struct {
Choices []struct { Choices []struct {

View file

@ -37,6 +37,14 @@ type StatefulProvider interface {
Close() 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. // FailoverReason classifies why an LLM request failed for fallback decisions.
type FailoverReason string type FailoverReason string