Files
goclaw/internal/memory/recall_query.go
T
viettranx 2731f99ad5 feat(memory): context-aware recall query for auto-inject
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.
2026-04-10 12:01:00 +07:00

63 lines
2.4 KiB
Go

package memory
import "strings"
// maxRecallContextRunes bounds the recent-context snippet used to enrich the
// recall query. Longer snippets dilute the embedding signal and slow down the
// vector search; shorter snippets lose the conversational frame.
//
// 400 runes ≈ 100 tokens in Latin scripts, fewer in CJK (1-2 short user turns
// either way). Tuning knob; change only if recall quality metrics show a
// clear trend in either direction.
//
// Unit is runes, not bytes, because GoClaw supports vi/zh locales: a
// byte-wise tail-clip would slice a multi-byte rune in half and emit invalid
// UTF-8 to the embedding model.
const maxRecallContextRunes = 400
// buildRecallQuery concatenates the user's latest message with a short recent
// context snippet to produce a context-aware search query. Used by auto-inject
// to improve recall on follow-up questions where the current message alone is
// ambiguous (pronouns, implicit references, one-word replies).
//
// The recent context is truncated to maxRecallContextRunes (rune-safe for
// CJK/vi/zh input) and prepended so embedding models give the latest message
// the most weight (position bias). Empty context or empty message return the
// unmodified input — zero-risk fallback for legacy callers that don't supply
// RecentContext yet.
func buildRecallQuery(userMessage, recentContext string) string {
userMessage = strings.TrimSpace(userMessage)
if recentContext == "" || userMessage == "" {
return userMessage
}
ctx := strings.TrimSpace(recentContext)
ctx = tailClipRunes(ctx, maxRecallContextRunes)
// Prepend context with a lightweight separator. "Context:" and "Query:"
// tags help instruction-tuned embedding models distinguish frame from
// focus; for non-instruction models they act as neutral separators.
var sb strings.Builder
sb.Grow(len(userMessage) + len(ctx) + 32)
sb.WriteString("Context: ")
sb.WriteString(ctx)
sb.WriteString("\nQuery: ")
sb.WriteString(userMessage)
return sb.String()
}
// tailClipRunes returns the last maxRunes runes of s, rune-safe for multi-byte
// scripts (Vietnamese, Chinese, Japanese, emoji). If s has fewer runes than
// maxRunes it's returned unchanged. Byte-wise slicing would split multi-byte
// runes and emit invalid UTF-8.
func tailClipRunes(s string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxRunes {
return s
}
return string(runes[len(runes)-maxRunes:])
}