| 1 | package memory |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/retrieval" |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | const ( |
| 15 | defaultRecallLimit = 8 |
| 16 | maxRecallLimit = 20 |
| 17 | maxRecallSnippet = 260 |
| 18 | recallScoreFloor = 0.15 |
| 19 | ) |
| 20 | |
| 21 | type recallTool struct{ store Store } |
| 22 | |
| 23 | // NewRecallTool returns the read-only `memory` tool for searching saved facts. |
| 24 | func NewRecallTool(store Store) tool.Tool { return recallTool{store: store} } |
| 25 | |
| 26 | func (recallTool) Name() string { return "memory" } |
| 27 | |
| 28 | func (recallTool) Description() string { |
| 29 | return "Search, list, and read saved background memories for this project, including explicitly global facts. " + |
| 30 | "Use this before saving a new memory to avoid duplicates, and when a saved memory from the index looks relevant but needs its full body. " + |
| 31 | "This tool is read-only; use remember to save or update a memory, and forget to archive one." |
| 32 | } |
| 33 | |
| 34 | func (recallTool) Schema() json.RawMessage { |
| 35 | return json.RawMessage(`{ |
| 36 | "type": "object", |
| 37 | "properties": { |
| 38 | "operation": {"type": "string", "enum": ["search", "read", "list"], "description": "search ranks saved memories; read returns one full memory by stable id or legacy name; list returns the saved-memory index."}, |
| 39 | "query": {"type": "string", "description": "Search query for operation=search."}, |
| 40 | "name": {"type": "string", "description": "Stable memory id, project/<name>.md or global/<name>.md reference, or legacy slug for operation=read."}, |
| 41 | "type": {"type": "string", "enum": ["user", "feedback", "project", "reference"], "description": "Optional memory type filter for search or list."}, |
| 42 | "scope": {"type": "string", "enum": ["project", "global"], "description": "Optional scope filter for search or list."}, |
| 43 | "limit": {"type": "integer", "description": "Maximum search/list results to return, default 8, max 20."} |
| 44 | }, |
| 45 | "required": ["operation"] |
| 46 | }`) |
| 47 | } |
| 48 | |
| 49 | func (t recallTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { |
| 50 | var in struct { |
| 51 | Operation string `json:"operation"` |
| 52 | Query string `json:"query"` |
| 53 | Name string `json:"name"` |
| 54 | Type string `json:"type"` |
| 55 | Scope string `json:"scope"` |
| 56 | Limit int `json:"limit"` |
| 57 | } |
| 58 | if err := json.Unmarshal(args, &in); err != nil { |
| 59 | return "", fmt.Errorf("invalid arguments: %w", err) |
| 60 | } |
| 61 | if t.store.Dir == "" { |
| 62 | return "Memory store is unavailable.", nil |
| 63 | } |
| 64 | memType, err := recallTypeFilter(in.Type) |
| 65 | if err != nil { |
| 66 | return "", err |
| 67 | } |
| 68 | memScope, err := recallScopeFilter(in.Scope) |
| 69 | if err != nil { |
| 70 | return "", err |
| 71 | } |
| 72 | limit := clampRecallLimit(in.Limit) |
| 73 | switch strings.TrimSpace(in.Operation) { |
| 74 | case "search": |
| 75 | hits, err := searchMemories(ctx, t.store, in.Query, memType, memScope, limit) |
| 76 | if err != nil { |
| 77 | return "", err |
| 78 | } |
| 79 | return formatMemoryHits(in.Query, hits), nil |
| 80 | case "read": |
| 81 | m, ok := readMemoryByName(t.store, in.Name) |
| 82 | if !ok { |
| 83 | return "", fmt.Errorf("memory %q not found", slug(in.Name)) |
| 84 | } |
| 85 | return formatMemory(t.store, m), nil |
| 86 | case "list": |
| 87 | return formatMemoryList(t.store, filterMemories(t.store.ListAll(), memType, memScope), limit), nil |
| 88 | case "": |
| 89 | return "", fmt.Errorf("operation is required") |
| 90 | default: |
| 91 | return "", fmt.Errorf("unknown operation %q", in.Operation) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func (recallTool) ReadOnly() bool { return true } |
| 96 | |
| 97 | type memoryHit struct { |
| 98 | Memory Memory |
| 99 | Score float64 |
| 100 | Snippet string |
| 101 | } |
| 102 | |
| 103 | type memoryDoc struct { |
| 104 | memory Memory |
| 105 | text string |
| 106 | counts map[string]int |
| 107 | length int |
| 108 | } |
| 109 | |
| 110 | func searchMemories(ctx context.Context, store Store, query string, typ Type, scope FactScope, limit int) ([]memoryHit, error) { |
| 111 | query = strings.TrimSpace(query) |
| 112 | if query == "" { |
| 113 | return nil, fmt.Errorf("query is required") |
| 114 | } |
| 115 | queryTerms, err := retrieval.QueryTerms(query) |
| 116 | if err != nil { |
| 117 | return nil, err |
| 118 | } |
| 119 | memories := filterMemories(store.ListAll(), typ, scope) |
| 120 | docs := make([]memoryDoc, 0, len(memories)) |
| 121 | for _, m := range memories { |
| 122 | if err := ctx.Err(); err != nil { |
| 123 | return nil, err |
| 124 | } |
| 125 | text := memorySearchText(m) |
| 126 | terms := retrieval.Tokens(text) |
| 127 | if len(terms) == 0 { |
| 128 | continue |
| 129 | } |
| 130 | docs = append(docs, memoryDoc{ |
| 131 | memory: m, |
| 132 | text: text, |
| 133 | counts: retrieval.Counts(terms), |
| 134 | length: len(terms), |
| 135 | }) |
| 136 | } |
| 137 | if len(docs) == 0 { |
| 138 | return nil, nil |
| 139 | } |
| 140 | counts := make([]map[string]int, 0, len(docs)) |
| 141 | totalLen := 0 |
| 142 | for _, doc := range docs { |
| 143 | counts = append(counts, doc.counts) |
| 144 | totalLen += doc.length |
| 145 | } |
| 146 | df := retrieval.DocumentFrequency(counts) |
| 147 | avgLen := float64(totalLen) / float64(len(docs)) |
| 148 | |
| 149 | var hits []memoryHit |
| 150 | for _, doc := range docs { |
| 151 | score := retrieval.BM25Score(doc.counts, doc.length, queryTerms, df, len(docs), avgLen) |
| 152 | if score <= 0 { |
| 153 | continue |
| 154 | } |
| 155 | hits = append(hits, memoryHit{ |
| 156 | Memory: doc.memory, |
| 157 | Score: score, |
| 158 | Snippet: retrieval.MakeSnippet(doc.text, query, queryTerms, maxRecallSnippet), |
| 159 | }) |
| 160 | } |
| 161 | sort.Slice(hits, func(i, j int) bool { |
| 162 | if hits[i].Score == hits[j].Score { |
| 163 | return hits[i].Memory.Name < hits[j].Memory.Name |
| 164 | } |
| 165 | return hits[i].Score > hits[j].Score |
| 166 | }) |
| 167 | hits = retrieval.KeepTopRelativeScore(hits, recallScoreFloor, func(hit memoryHit) float64 { |
| 168 | return hit.Score |
| 169 | }) |
| 170 | if len(hits) > limit { |
| 171 | hits = hits[:limit] |
| 172 | } |
| 173 | return hits, nil |
| 174 | } |
| 175 | |
| 176 | func recallTypeFilter(s string) (Type, error) { |
| 177 | if strings.TrimSpace(s) == "" { |
| 178 | return "", nil |
| 179 | } |
| 180 | t := Type(strings.ToLower(strings.TrimSpace(s))) |
| 181 | if !validTypes[t] { |
| 182 | return "", fmt.Errorf("type must be one of user, feedback, project, reference") |
| 183 | } |
| 184 | return t, nil |
| 185 | } |
| 186 | |
| 187 | func recallScopeFilter(s string) (FactScope, error) { |
| 188 | if strings.TrimSpace(s) == "" { |
| 189 | return "", nil |
| 190 | } |
| 191 | scope := FactScope(strings.ToLower(strings.TrimSpace(s))) |
| 192 | if scope != FactScopeProject && scope != FactScopeGlobal { |
| 193 | return "", fmt.Errorf("scope must be one of project, global") |
| 194 | } |
| 195 | return scope, nil |
| 196 | } |
| 197 | |
| 198 | func filterMemories(memories []Memory, typ Type, scope FactScope) []Memory { |
| 199 | if typ == "" && scope == "" { |
| 200 | return memories |
| 201 | } |
| 202 | out := memories[:0] |
| 203 | for _, m := range memories { |
| 204 | if (typ == "" || NormalizeType(string(m.Type)) == typ) && |
| 205 | (scope == "" || NormalizeFactScope(string(m.Scope)) == scope) { |
| 206 | out = append(out, m) |
| 207 | } |
| 208 | } |
| 209 | return out |
| 210 | } |
| 211 | |
| 212 | func readMemoryByName(store Store, name string) (Memory, bool) { |
| 213 | return store.Read(name) |
| 214 | } |
| 215 | |
| 216 | func memorySearchText(m Memory) string { |
| 217 | return strings.Join([]string{ |
| 218 | m.Name, |
| 219 | m.Title, |
| 220 | string(NormalizeType(string(m.Type))), |
| 221 | string(NormalizeFactScope(string(m.Scope))), |
| 222 | m.Description, |
| 223 | m.Body, |
| 224 | }, "\n") |
| 225 | } |
| 226 | |
| 227 | func formatMemoryHits(query string, hits []memoryHit) string { |
| 228 | if len(hits) == 0 { |
| 229 | return strings.Join([]string{ |
| 230 | "No saved memories matched " + strconvQuote(query) + ".", |
| 231 | "", |
| 232 | "0 results does not prove the fact was never recorded. Try:", |
| 233 | "1. Retry with 1-3 distinctive terms (function name, task id, rare phrase) instead of a long generic sentence.", |
| 234 | "2. For exact literals that punctuation splits (URLs, ports, file paths, command flags), search one distinctive token or inspect the memory directory directly.", |
| 235 | "3. For verbatim original wording or exact command output, use the history tool; saved memories may paraphrase.", |
| 236 | }, "\n") |
| 237 | } |
| 238 | var b strings.Builder |
| 239 | fmt.Fprintf(&b, "Memory search results for %s:\n", strconvQuote(query)) |
| 240 | for i, hit := range hits { |
| 241 | m := hit.Memory |
| 242 | fmt.Fprintf(&b, "\n%d. score=%.3f id=%s revision=%d name=%s scope=%s type=%s title=%s\n reference: %s\n description: %s\n snippet: %s\n", |
| 243 | i+1, hit.Score, m.ID, m.Revision, m.Name, NormalizeFactScope(string(m.Scope)), NormalizeType(string(m.Type)), displayTitle(m.Title, m.Name), providerMemoryReference(m), oneLine(m.Description), hit.Snippet) |
| 244 | } |
| 245 | b.WriteString("\nUse operation=\"read\" with a stable memory id to inspect the full saved fact.") |
| 246 | return strings.TrimSpace(b.String()) |
| 247 | } |
| 248 | |
| 249 | func formatMemory(_ Store, m Memory) string { |
| 250 | var b strings.Builder |
| 251 | fmt.Fprintf(&b, "Memory %s\n", m.Name) |
| 252 | fmt.Fprintf(&b, "id: %s\n", m.ID) |
| 253 | fmt.Fprintf(&b, "revision: %d\n", m.Revision) |
| 254 | fmt.Fprintf(&b, "title: %s\n", displayTitle(m.Title, m.Name)) |
| 255 | fmt.Fprintf(&b, "scope: %s\n", NormalizeFactScope(string(m.Scope))) |
| 256 | fmt.Fprintf(&b, "type: %s\n", NormalizeType(string(m.Type))) |
| 257 | if desc := oneLine(m.Description); desc != "" { |
| 258 | fmt.Fprintf(&b, "description: %s\n", desc) |
| 259 | } |
| 260 | fmt.Fprintf(&b, "reference: %s\n\n%s", providerMemoryReference(m), strings.TrimSpace(m.Body)) |
| 261 | return strings.TrimSpace(b.String()) |
| 262 | } |
| 263 | |
| 264 | func formatMemoryList(_ Store, memories []Memory, limit int) string { |
| 265 | if len(memories) == 0 { |
| 266 | return "No saved memories found." |
| 267 | } |
| 268 | if len(memories) > limit { |
| 269 | memories = memories[:limit] |
| 270 | } |
| 271 | var b strings.Builder |
| 272 | b.WriteString("Saved memories:\n") |
| 273 | for _, m := range memories { |
| 274 | fmt.Fprintf(&b, "- [%s](%s.md) reference=%s id=%s revision=%d scope=%s type=%s - %s\n", |
| 275 | displayTitle(m.Title, m.Name), m.Name, providerMemoryReference(m), m.ID, m.Revision, NormalizeFactScope(string(m.Scope)), NormalizeType(string(m.Type)), oneLine(m.Description)) |
| 276 | } |
| 277 | return strings.TrimSpace(b.String()) |
| 278 | } |
| 279 | |
| 280 | func providerMemoryReference(m Memory) string { |
| 281 | return string(NormalizeFactScope(string(m.Scope))) + "/" + slug(m.Name) + ".md" |
| 282 | } |
| 283 | |
| 284 | func clampRecallLimit(n int) int { |
| 285 | if n <= 0 { |
| 286 | return defaultRecallLimit |
| 287 | } |
| 288 | if n > maxRecallLimit { |
| 289 | return maxRecallLimit |
| 290 | } |
| 291 | return n |
| 292 | } |
| 293 | |
| 294 | func strconvQuote(s string) string { |
| 295 | b, _ := json.Marshal(s) |
| 296 | return string(b) |
| 297 | } |
| 298 |