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 // 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 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, "") { continue } if strings.TrimSpace(text) == "" { continue } return text } return "" }