返回 DeepSeek-Reasonix
recall.go
根目录 / internal / memory / recall.go
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
298 lines GO