feat(memory): implement pure-go dynamic vector memory search without vendor
This commit is contained in:
parent
f1f160ef86
commit
8c7c6f552e
9 changed files with 491 additions and 62 deletions
|
|
@ -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) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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 == "" {
|
||||
|
|
|
|||
264
pkg/agent/memory_vector.go
Normal file
264
pkg/agent/memory_vector.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue