| 1 | // The session recall index: the recall pool read and tokenized once per |
| 2 | // memory snapshot, so each user turn's automatic recall costs zero disk IO. |
| 3 | // Markdown files stay the source of truth — every write path reloads the |
| 4 | // snapshot through memory.Load, which rebuilds this index with it. |
| 5 | package memory |
| 6 | |
| 7 | import ( |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/retrieval" |
| 11 | ) |
| 12 | |
| 13 | // RecallIndex is the prebuilt retrieval state for one immutable Set snapshot. |
| 14 | type RecallIndex struct { |
| 15 | docs []autoRecallDoc |
| 16 | fielded []retrieval.FieldedDoc // V2 shadow pool, prebuilt field split |
| 17 | } |
| 18 | |
| 19 | // BuildRecallIndex reads and tokenizes the recall pool once. A zero store |
| 20 | // yields nil, which recall reports as an empty memory store. |
| 21 | func BuildRecallIndex(store Store) *RecallIndex { |
| 22 | memories := recallMemories(store.ListAll()) |
| 23 | if len(memories) == 0 { |
| 24 | return nil |
| 25 | } |
| 26 | index := &RecallIndex{} |
| 27 | for _, memory := range memories { |
| 28 | index.fielded = append(index.fielded, retrieval.FieldedDoc{ID: memory.ID, Fields: map[string]string{ |
| 29 | "name": memory.Name, "title": memory.Title, "keywords": memory.Keywords, |
| 30 | "subject": memory.SubjectKey, "description": memory.Description, "body": memory.Body, |
| 31 | }}) |
| 32 | text := autoRecallSearchText(memory) |
| 33 | terms := retrieval.Tokens(text) |
| 34 | if len(terms) == 0 { |
| 35 | continue |
| 36 | } |
| 37 | index.docs = append(index.docs, autoRecallDoc{ |
| 38 | memory: memory, |
| 39 | text: text, |
| 40 | counts: retrieval.Counts(terms), |
| 41 | length: len(terms), |
| 42 | }) |
| 43 | } |
| 44 | return index |
| 45 | } |
| 46 | |
| 47 | // AutoRecall runs automatic recall against this snapshot's prebuilt index — |
| 48 | // the per-turn path. Semantics are identical to the package-level AutoRecall. |
| 49 | func (s *Set) AutoRecall(query string, opts RecallOptions) RecallResult { |
| 50 | result := RecallResult{Query: strings.TrimSpace(query), CharBudget: recallCharBudget(opts.MaxChars)} |
| 51 | if genericRecallQuery(result.Query) { |
| 52 | result.Suppressed = "generic user turn" |
| 53 | return result |
| 54 | } |
| 55 | if s == nil { |
| 56 | result.Suppressed = "memory store is empty" |
| 57 | return result |
| 58 | } |
| 59 | index := s.recall |
| 60 | if index == nil { |
| 61 | // Hand-built sets (tests, embedders) carry no prebuilt index; the |
| 62 | // Load path always does, so per-turn recall stays disk-free there. |
| 63 | index = BuildRecallIndex(s.Store) |
| 64 | } |
| 65 | return autoRecallIndexed(index, result, opts) |
| 66 | } |
| 67 |