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 (
|
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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
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"`
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue