Files
BlackBean/pigo/internal/runtime/memory_reminder.go
T
2026-08-14 23:41:57 +08:00

186 lines
5.4 KiB
Go

package runtime
import (
"context"
"sort"
"strings"
"sync"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/memory"
)
// defaultMemoryReminderMaxChars is the per-turn character budget for the
// injected memory body. It is deliberately modest so relevant memory context
// does not crowd out the live conversation in the model's window.
const defaultMemoryReminderMaxChars = 600
// defaultMemoryReminderLimit is the number of search hits fetched per turn
// before budget trimming.
const defaultMemoryReminderLimit = 5
// MemoryReminderProvider injects relevant persistent memory as ephemeral
// background context on each turn (issue #478). It derives a query from the
// most recent user message, runs a BM25 search over the memory store, and
// surfaces the top-ranked snippets — budget-capped and deduped so identical
// context is never repeated turn after turn.
//
// The returned body is RAW plain text; the reminder registry wraps it in
// <system-reminder> tags and injects it only into the per-turn LLM request, so
// it never enters persisted history.
type MemoryReminderProvider struct {
// Store is the persistent memory database. When nil the provider never
// fires.
Store *memory.Store
// MaxChars caps the injected body length. <=0 uses
// defaultMemoryReminderMaxChars.
MaxChars int
// Limit caps the number of search hits considered. <=0 uses
// defaultMemoryReminderLimit.
Limit int
// Scope and ScopeID, when non-empty, focus the search on a single memory
// scope (e.g. the current project id) via SearchOptions.
Scope string
ScopeID string
// mu guards lastBody so Reminder is safe to call across turns.
mu sync.Mutex
lastBody string
}
// Name implements ReminderProvider.
func (p *MemoryReminderProvider) Name() string { return "memory" }
// Reminder implements ReminderProvider. It fires when the latest user message
// yields a search query that matches stored memory, returning a concise,
// budget-capped body of ranked snippets. It stays silent when the store is nil,
// there is no user text to search on, the search errors or returns nothing, or
// the produced body is identical to the one injected on the previous firing
// turn (dedupe).
func (p *MemoryReminderProvider) Reminder(ctx context.Context, msgs agentcore.MessageList) (string, bool) {
if p == nil || p.Store == nil {
return "", false
}
query := latestUserText(msgs)
if strings.TrimSpace(query) == "" {
return "", false
}
limit := p.Limit
if limit <= 0 {
limit = defaultMemoryReminderLimit
}
results, err := p.Store.Search(query, memory.SearchOptions{
Scope: p.Scope,
ScopeID: p.ScopeID,
Limit: limit,
ReconcileFirst: true,
})
if err != nil || len(results) == 0 {
return "", false
}
body := p.buildBody(results)
if body == "" {
return "", false
}
// Dedupe: never re-inject the identical body on a subsequent firing turn.
p.mu.Lock()
defer p.mu.Unlock()
if body == p.lastBody {
return "", false
}
p.lastBody = body
return body, true
}
// buildBody renders the ranked results into a concise, budget-capped body.
// MEMORY.md index files (and free-type entries) are ordered first as they are
// the most useful high-level context.
func (p *MemoryReminderProvider) buildBody(results []memory.SearchResult) string {
maxChars := p.MaxChars
if maxChars <= 0 {
maxChars = defaultMemoryReminderMaxChars
}
// Stable sort so index-type / MEMORY.md hits float to the top while
// preserving the underlying BM25 order among equals.
ordered := make([]memory.SearchResult, len(results))
copy(ordered, results)
sort.SliceStable(ordered, func(i, j int) bool {
return memoryRank(ordered[i]) < memoryRank(ordered[j])
})
const heading = "Relevant memory:"
var b strings.Builder
b.WriteString(heading)
for _, r := range ordered {
snippet := strings.TrimSpace(strings.ReplaceAll(r.Snippet, "\n", " "))
if snippet == "" {
continue
}
line := "\n- " + r.Path + ": " + snippet
// Enforce the budget: stop before exceeding maxChars, and never emit a
// heading-only body.
if b.Len()+len(line) > maxChars {
if b.Len() > len(heading) {
break
}
// The very first line already overflows: hard-truncate it so we
// still surface something within budget.
room := maxChars - b.Len()
if room <= 0 {
break
}
if room < len(line) {
line = line[:room]
}
b.WriteString(line)
break
}
b.WriteString(line)
}
if b.Len() <= len(heading) {
return ""
}
return b.String()
}
// memoryRank returns a sort key that floats MEMORY.md index files and free-type
// entries to the front (rank 0) ahead of everything else (rank 1).
func memoryRank(r memory.SearchResult) int {
if strings.HasSuffix(r.Path, "MEMORY.md") || r.Type == memory.TypeFree {
return 0
}
return 1
}
// latestUserText returns the flattened text of the most recent user message in
// msgs, or "" if there is none. Reminder messages are user-role too, but they
// carry the <system-reminder> preamble; those are skipped so the search query
// reflects the genuine user request rather than previously injected context.
func latestUserText(msgs agentcore.MessageList) string {
for i := len(msgs) - 1; i >= 0; i-- {
um, ok := msgs[i].(agentcore.UserMessage)
if !ok {
continue
}
text := agentcore.ContentToText(um.Content)
if strings.Contains(text, "<system-reminder>") {
continue
}
if strings.TrimSpace(text) == "" {
continue
}
return text
}
return ""
}