package builtin import ( "sort" "strings" adapters "github.com/memohai/memoh/internal/memory/adapters" ) // contextPackerConfig controls how memory items are packed into a context // window with a fixed character budget. type contextPackerConfig struct { TargetItems int // desired number of items in final context MaxTotalChars int // hard budget for combined snippet length MinItemChars int // minimum snippet length per item MaxItemChars int // maximum snippet length per item OverfetchRatio int // fetch TargetItems * OverfetchRatio candidates } var defaultPackerConfig = contextPackerConfig{ TargetItems: 6, MaxTotalChars: 1800, MinItemChars: 80, MaxItemChars: 360, OverfetchRatio: 3, } // contextPackResult contains the items selected for context injection. type contextPackResult struct { Items []packedItem } type packedItem struct { Item adapters.MemoryItem Snippet string } // packContext selects and truncates memory items to fit within the character // budget. Items must already be deduplicated and sorted by score descending. // // The algorithm: // 1. Assign each item its full text or MaxItemChars, whichever is shorter. // 2. Walk items in score order; greedily include items while budget allows. // 3. If we haven't reached TargetItems, try compressing already-included // items to make room for more. // 4. Apply anti-lost-in-the-middle reordering: best items at head and tail. func packContext(items []adapters.MemoryItem, cfg contextPackerConfig) contextPackResult { if len(items) == 0 { return contextPackResult{} } if cfg.TargetItems <= 0 { cfg.TargetItems = defaultPackerConfig.TargetItems } if cfg.MaxTotalChars <= 0 { cfg.MaxTotalChars = defaultPackerConfig.MaxTotalChars } if cfg.MinItemChars <= 0 { cfg.MinItemChars = defaultPackerConfig.MinItemChars } if cfg.MaxItemChars <= 0 { cfg.MaxItemChars = defaultPackerConfig.MaxItemChars } type candidate struct { item adapters.MemoryItem text string charLen int } candidates := make([]candidate, 0, len(items)) for _, it := range items { text := strings.TrimSpace(it.Memory) if text == "" { continue } runes := []rune(text) cl := len(runes) if cl > cfg.MaxItemChars { cl = cfg.MaxItemChars } candidates = append(candidates, candidate{item: it, text: text, charLen: cl}) } // Phase 1: greedily pack items at their natural (capped) length. selected := make([]candidate, 0, cfg.TargetItems) usedChars := 0 for _, c := range candidates { if len(selected) >= cfg.TargetItems { break } if usedChars+c.charLen > cfg.MaxTotalChars { // Try with minimum length. if usedChars+cfg.MinItemChars > cfg.MaxTotalChars { continue } c.charLen = cfg.MinItemChars } selected = append(selected, c) usedChars += c.charLen } // Phase 2: if we didn't reach TargetItems, try compressing existing items // to free budget for more candidates. if len(selected) < cfg.TargetItems && len(selected) < len(candidates) { for i := range selected { if selected[i].charLen > cfg.MinItemChars { freed := selected[i].charLen - cfg.MinItemChars selected[i].charLen = cfg.MinItemChars usedChars -= freed } } for _, c := range candidates[len(selected):] { if len(selected) >= cfg.TargetItems { break } needed := c.charLen if needed > cfg.MaxTotalChars-usedChars { needed = cfg.MaxTotalChars - usedChars } if needed < cfg.MinItemChars { continue } c.charLen = needed selected = append(selected, c) usedChars += needed } } // Phase 3: redistribute remaining budget to compressed items. remaining := cfg.MaxTotalChars - usedChars if remaining > 0 && len(selected) > 0 { perItem := remaining / len(selected) for i := range selected { textLen := len([]rune(selected[i].text)) maxGrow := cfg.MaxItemChars - selected[i].charLen if maxGrow > textLen-selected[i].charLen { maxGrow = textLen - selected[i].charLen } if maxGrow <= 0 { continue } grow := perItem if grow > maxGrow { grow = maxGrow } selected[i].charLen += grow usedChars += grow } } // Phase 4: anti-lost-in-the-middle reordering. // Place best items at positions 0 and last; fill middle with the rest. reordered := antiLostInMiddle(selected) result := make([]packedItem, 0, len(reordered)) for _, c := range reordered { snippet := adapters.TruncateSnippet(c.text, c.charLen) result = append(result, packedItem{Item: c.item, Snippet: snippet}) } return contextPackResult{Items: result} } // antiLostInMiddle reorders candidates so the highest-scored items appear at // the beginning and end of the sequence, reducing the "lost in the middle" // effect observed in LLMs. func antiLostInMiddle[T any](items []T) []T { n := len(items) if n <= 2 { return items } out := make([]T, n) head, tail := 0, n-1 for i, item := range items { if i%2 == 0 { out[head] = item head++ } else { out[tail] = item tail-- } } return out } // overfetchLimit returns the number of candidates to request from the backend, // given the desired target item count and overfetch ratio. func overfetchLimit(cfg contextPackerConfig) int { limit := cfg.TargetItems * cfg.OverfetchRatio if limit < cfg.TargetItems { limit = cfg.TargetItems } return limit } // deduplicateAndSort removes duplicate items by ID, then sorts by score desc. func deduplicateAndSort(items []adapters.MemoryItem) []adapters.MemoryItem { deduped := adapters.DeduplicateItems(items) sort.Slice(deduped, func(i, j int) bool { return deduped[i].Score > deduped[j].Score }) return deduped }