mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 06:21:10 +00:00
Auto-inject previously searched episodic memory using only the latest user message. Follow-up questions like "what's my favorite?" returned poor matches because the embedding lost the conversational frame. InjectParams now carries an optional RecentContext field that pgAuto Injector prepends to the search query as "Context: ... \nQuery: ..." before running the FTS+vector hybrid search. The "Context:"/"Query:" framing works with both instruction-tuned embedding models (which respect the labels) and plain models (neutral separators). ContextStage walks the message history backward, collects up to 2 trailing user turns capped at 300 runes total, and threads the snippet through the AutoInject callback to the injector. Empty RecentContext preserves legacy single-message search semantics — zero-risk fallback for callers that haven't adopted the new field. Rune-based truncation (not byte) keeps vi/zh locales safe: a byte-wise tail-clip would slice multi-byte runes and emit invalid UTF-8 to the embedding model, degrading exactly the cases Phase 9 is meant to fix. tailClipRunes helper covers Vietnamese, Chinese, Japanese, emoji. 13 regression tests: recall query builder (unicode-safe clip, whitespace handling, position ordering), buildRecentContext (order preservation, turn cap, truncation, non-user skip), and tailClipRunes (CJK, short input, zero cap). All passing with -race. Refs plans/260410-1009-openclaw-ts-feature-port/phase-09-active- memory-recall.md — minimal-viable delivery; Tier 2 LLM re-ranking and per-session recall cache deferred until operational data shows context-aware search alone is insufficient.
139 lines
3.8 KiB
Go
139 lines
3.8 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// pgAutoInjector implements AutoInjector backed by EpisodicStore + FTS search.
|
|
type pgAutoInjector struct {
|
|
episodicStore store.EpisodicStore
|
|
metricsStore store.EvolutionMetricsStore // nil = metrics disabled
|
|
}
|
|
|
|
// NewAutoInjector creates an AutoInjector backed by episodic store search.
|
|
func NewAutoInjector(es store.EpisodicStore, ms store.EvolutionMetricsStore) AutoInjector {
|
|
return &pgAutoInjector{episodicStore: es, metricsStore: ms}
|
|
}
|
|
|
|
// Inject searches episodic memory for relevant L0 abstracts and formats a prompt section.
|
|
func (a *pgAutoInjector) Inject(ctx context.Context, params InjectParams) (*InjectResult, error) {
|
|
if a.episodicStore == nil {
|
|
return &InjectResult{}, nil
|
|
}
|
|
if isTrivialMessage(params.UserMessage) {
|
|
return &InjectResult{}, nil
|
|
}
|
|
|
|
maxEntries := params.MaxEntries
|
|
if maxEntries <= 0 {
|
|
maxEntries = 5
|
|
}
|
|
threshold := params.Threshold
|
|
if threshold <= 0 {
|
|
threshold = 0.3
|
|
}
|
|
|
|
// Phase 9: context-aware recall. When the caller supplied RecentContext,
|
|
// build a richer search query that captures conversational intent. Without
|
|
// this, vector search on "what's my favorite?" misses memories about the
|
|
// topic under discussion. With it, the query embedding captures the
|
|
// follow-up semantics and returns materially better matches.
|
|
searchQuery := buildRecallQuery(params.UserMessage, params.RecentContext)
|
|
|
|
// Search with FTS bias (faster than vector for auto-inject)
|
|
results, err := a.episodicStore.Search(ctx, searchQuery, params.AgentID, params.UserID,
|
|
store.EpisodicSearchOptions{
|
|
MaxResults: maxEntries * 2, // fetch more, filter by threshold
|
|
MinScore: threshold,
|
|
VectorWeight: 0.3,
|
|
TextWeight: 0.7,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("auto-inject search: %w", err)
|
|
}
|
|
if len(results) == 0 {
|
|
return &InjectResult{}, nil
|
|
}
|
|
|
|
// Build prompt section from L0 abstracts
|
|
var sb strings.Builder
|
|
sb.WriteString("## Memory Context\n\nRelevant memories from past sessions (use memory_search for details):\n")
|
|
|
|
injected := 0
|
|
var topScore float64
|
|
for _, r := range results {
|
|
if injected >= maxEntries {
|
|
break
|
|
}
|
|
if r.L0Abstract == "" {
|
|
continue
|
|
}
|
|
sb.WriteString("- ")
|
|
sb.WriteString(r.L0Abstract)
|
|
sb.WriteString("\n")
|
|
injected++
|
|
if r.Score > topScore {
|
|
topScore = r.Score
|
|
}
|
|
}
|
|
|
|
if injected == 0 {
|
|
return &InjectResult{MatchCount: len(results)}, nil
|
|
}
|
|
|
|
result := &InjectResult{
|
|
Section: sb.String(),
|
|
MatchCount: len(results),
|
|
Injected: injected,
|
|
TopScore: topScore,
|
|
}
|
|
|
|
// Record retrieval metric non-blocking (best-effort).
|
|
a.recordRetrievalMetric(params, result)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// recordRetrievalMetric records an auto-inject retrieval metric in a background goroutine.
|
|
func (a *pgAutoInjector) recordRetrievalMetric(params InjectParams, result *InjectResult) {
|
|
if a.metricsStore == nil || params.TenantID == "" {
|
|
return
|
|
}
|
|
tenantID, err := uuid.Parse(params.TenantID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
agentID, err := uuid.Parse(params.AgentID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
bgCtx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), tenantID), 5*time.Second)
|
|
defer cancel()
|
|
value, _ := json.Marshal(map[string]any{
|
|
"result_count": result.MatchCount,
|
|
"injected": result.Injected,
|
|
"top_score": result.TopScore,
|
|
"used_in_reply": result.Injected > 0,
|
|
})
|
|
if err := a.metricsStore.RecordMetric(bgCtx, store.EvolutionMetric{
|
|
ID: uuid.New(),
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
MetricType: store.MetricRetrieval,
|
|
MetricKey: "auto_inject",
|
|
Value: value,
|
|
}); err != nil {
|
|
slog.Debug("evolution.metric.auto_inject_failed", "error", err)
|
|
}
|
|
}()
|
|
}
|