返回 DeepSeek-Reasonix
memory_command.go
根目录 / internal / control / memory_command.go
1 package control
2
3 import (
4 "fmt"
5 "sort"
6 "strconv"
7 "strings"
8 "time"
9
10 "reasonix/internal/i18n"
11 "reasonix/internal/memory"
12 )
13
14 const memoryCommandUsage = "usage: /memory [recall|subjects|pin <id-or-name>|unpin <id-or-name>|verify <id-or-name>|revisions <id-or-name>|restore <id-or-name> <revision>|archived|recover <archive-path>|instructions]"
15
16 // MemoryCompletionData returns stable references for structured /memory
17 // completion. IDs come first because they remain unambiguous if a fact is
18 // renamed or the same slug exists in multiple scopes.
19 func MemoryCompletionData(set *memory.Set) (refs, archives []string) {
20 if set == nil {
21 return []string{}, []string{}
22 }
23 seen := map[string]bool{}
24 for _, fact := range set.Store.ListAll() {
25 for _, ref := range []string{fact.ID, fact.Name} {
26 ref = strings.TrimSpace(ref)
27 if ref == "" || seen[ref] {
28 continue
29 }
30 seen[ref] = true
31 refs = append(refs, ref)
32 }
33 }
34 for _, archived := range set.Store.ListArchived() {
35 if path := strings.TrimSpace(archived.Path); path != "" {
36 archives = append(archives, path)
37 }
38 }
39 if refs == nil {
40 refs = []string{}
41 }
42 if archives == nil {
43 archives = []string{}
44 }
45 return refs, archives
46 }
47
48 // MemoryCommandText executes one session /memory management command and returns
49 // its diagnostic text. Both Submit-based frontends and the chat TUI use this
50 // function so reads and explicit recovery mutations follow one protocol.
51 func MemoryCommandText(api MemoryControl, input string) string {
52 if api == nil {
53 return i18n.M.ListMemoryNone
54 }
55 subcommand, rest := parseMemoryCommand(input)
56 switch subcommand {
57 case "", "list", "summary":
58 return RenderMemorySummary(api.Memory(), time.Now().UTC())
59 case "recall":
60 if rest != "" {
61 return "usage: /memory recall"
62 }
63 return renderMemoryRecall(api.LastMemoryRecall())
64 case "pin", "unpin":
65 ref, err := singleMemoryArgument(rest)
66 if err != nil {
67 return "usage: /memory " + subcommand + " <id-or-name>"
68 }
69 return setMemoryActivation(api, ref, subcommand == "pin")
70 case "verify":
71 ref, err := singleMemoryArgument(rest)
72 if err != nil {
73 return "usage: /memory verify <id-or-name>"
74 }
75 return verifyMemory(api, ref)
76 case "subjects":
77 if rest != "" {
78 return "usage: /memory subjects"
79 }
80 return renderMemorySubjects(api.Memory())
81 case "revisions":
82 ref, err := singleMemoryArgument(rest)
83 if err != nil {
84 return "usage: /memory revisions <id-or-name>"
85 }
86 return renderMemoryRevisions(api, ref)
87 case "restore":
88 ref, revision, err := parseMemoryRestore(rest)
89 if err != nil {
90 return "usage: /memory restore <id-or-name> <revision>"
91 }
92 restored, err := api.RestoreMemory(ref, revision)
93 if err != nil {
94 return "memory restore: " + err.Error()
95 }
96 return fmt.Sprintf("restored %s as revision=%d id=%s scope=%s",
97 restored.Name, restored.Revision, restored.ID, memory.NormalizeFactScope(string(restored.Scope)))
98 case "archived", "archives":
99 if rest != "" {
100 return "usage: /memory archived"
101 }
102 return renderMemoryArchives(api.Memory())
103 case "recover":
104 archivePath, err := singleMemoryArgument(rest)
105 if err != nil {
106 return "usage: /memory recover <archive-path>"
107 }
108 restored, err := api.RestoreArchivedMemory(archivePath)
109 if err != nil {
110 return "memory recover: " + err.Error()
111 }
112 return fmt.Sprintf("recovered %s as revision=%d id=%s scope=%s",
113 restored.Name, restored.Revision, restored.ID, memory.NormalizeFactScope(string(restored.Scope)))
114 case "instructions":
115 if rest != "" {
116 return "usage: /memory instructions"
117 }
118 return renderMemoryInstructions(api.Memory())
119 default:
120 return "unknown /memory subcommand " + subcommand + "\n" + memoryCommandUsage
121 }
122 }
123
124 // setMemoryActivation flips a fact between pinned and relevant through the
125 // same save path the model uses, so revisioning and the pinned budget apply.
126 // The next real user turn publishes the changed background snapshot.
127 func setMemoryActivation(api MemoryControl, ref string, pin bool) string {
128 set := api.Memory()
129 if set == nil {
130 return i18n.M.ListMemoryNone
131 }
132 fact, ok := set.Store.Read(ref)
133 if !ok {
134 return fmt.Sprintf("memory %q not found", ref)
135 }
136 activation := memory.ActivationRelevant
137 if pin {
138 activation = memory.ActivationPinned
139 }
140 if memory.ResolveActivation(fact) == activation {
141 return fmt.Sprintf("%s is already %s", fact.Name, activation)
142 }
143 fact.Activation = activation
144 if _, err := api.SaveMemory(fact); err != nil {
145 return "memory activation: " + err.Error()
146 }
147 return fmt.Sprintf("%s is now %s (takes effect in session-context on the next real user turn)", fact.Name, activation)
148 }
149
150 // verifyMemory stamps last_verified_at through the normal save path: the
151 // freshness clock renews, revision history honestly records the confirmation.
152 func verifyMemory(api MemoryControl, ref string) string {
153 set := api.Memory()
154 if set == nil {
155 return i18n.M.ListMemoryNone
156 }
157 fact, ok := set.Store.Read(ref)
158 if !ok {
159 return fmt.Sprintf("memory %q not found", ref)
160 }
161 fact.LastVerifiedAt = time.Now().UTC()
162 if _, err := api.SaveMemory(fact); err != nil {
163 return "memory verify: " + err.Error()
164 }
165 return fmt.Sprintf("%s verified; freshness clock renewed (revision %d -> %d)", fact.Name, fact.Revision, fact.Revision+1)
166 }
167
168 // renderMemorySubjects lists the subject keys in use so writers reuse them
169 // instead of minting near-duplicates.
170 func renderMemorySubjects(set *memory.Set) string {
171 if set == nil {
172 return i18n.M.ListMemoryNone
173 }
174 type holder struct{ key, line string }
175 var rows []holder
176 for _, fact := range set.Store.ListAll() {
177 key := memory.NormalizeSubjectKey(fact.SubjectKey)
178 if key == "" {
179 continue
180 }
181 rows = append(rows, holder{key, fmt.Sprintf(" %s -> id=%s scope=%s name=%s %s",
182 key, fact.ID, memory.NormalizeFactScope(string(fact.Scope)), fact.Name, memoryOneLine(fact.Description))})
183 }
184 if len(rows) == 0 {
185 return "no subject keys in use\nassign one with remember's subject_key to track single-valued facts (e.g. project.package_manager)"
186 }
187 sort.Slice(rows, func(i, j int) bool { return rows[i].key < rows[j].key })
188 var b strings.Builder
189 b.WriteString("subject keys (one active value per scope+subject)\n")
190 for _, row := range rows {
191 b.WriteString(row.line + "\n")
192 }
193 return strings.TrimRight(b.String(), "\n")
194 }
195
196 func parseMemoryCommand(input string) (subcommand, rest string) {
197 input = strings.TrimSpace(input)
198 if after, ok := strings.CutPrefix(input, "/memory"); ok {
199 input = strings.TrimSpace(after)
200 }
201 if input == "" {
202 return "", ""
203 }
204 if at := strings.IndexAny(input, " \t"); at >= 0 {
205 return strings.ToLower(input[:at]), strings.TrimSpace(input[at+1:])
206 }
207 return strings.ToLower(input), ""
208 }
209
210 func singleMemoryArgument(input string) (string, error) {
211 input = strings.TrimSpace(input)
212 if input == "" {
213 return "", fmt.Errorf("missing argument")
214 }
215 if len(input) >= 2 {
216 if (input[0] == '"' && input[len(input)-1] == '"') ||
217 (input[0] == '\'' && input[len(input)-1] == '\'') {
218 input = strings.TrimSpace(input[1 : len(input)-1])
219 }
220 }
221 if input == "" {
222 return "", fmt.Errorf("missing argument")
223 }
224 return input, nil
225 }
226
227 func parseMemoryRestore(input string) (string, int, error) {
228 fields := strings.Fields(input)
229 if len(fields) != 2 {
230 return "", 0, fmt.Errorf("expected reference and revision")
231 }
232 revision, err := strconv.Atoi(fields[1])
233 if err != nil || revision <= 0 {
234 return "", 0, fmt.Errorf("revision must be positive")
235 }
236 return fields[0], revision, nil
237 }
238
239 // RenderMemorySummary renders the zero-configuration overview shown by bare
240 // /memory. It intentionally uses ListAll so project/global collisions remain
241 // observable instead of being hidden by the legacy name-based merge.
242 func RenderMemorySummary(set *memory.Set, now time.Time) string {
243 if set == nil {
244 return i18n.M.ListMemoryNone
245 }
246 facts := set.Store.ListAll()
247 archives := set.Store.ListArchived()
248 if len(set.Docs) == 0 && len(facts) == 0 && len(archives) == 0 {
249 return i18n.M.ListMemoryNone
250 }
251 if now.IsZero() {
252 now = time.Now().UTC()
253 }
254 var b strings.Builder
255 b.WriteString("memory\n")
256 if len(set.Docs) > 0 {
257 b.WriteString("\ninstructions (low -> high precedence)\n")
258 for index, doc := range set.Docs {
259 fmt.Fprintf(&b, " precedence=%d scope=%s path=%s\n", index+1, doc.Scope, doc.Path)
260 if strings.TrimSpace(doc.Directory) != "" {
261 fmt.Fprintf(&b, " directory=%s\n", doc.Directory)
262 }
263 for _, imported := range doc.Imports {
264 fmt.Fprintf(&b, " import=%s\n", imported.Path)
265 }
266 }
267 }
268 if len(facts) > 0 {
269 b.WriteString("\n" + i18n.M.ListMemorySaved + "\n")
270 for _, fact := range facts {
271 fmt.Fprintf(&b, " [%s](%s.md)\n", memoryDisplayTitle(fact.Title, fact.Name), fact.Name)
272 fmt.Fprintf(&b, " id=%s\n", fact.ID)
273 pinned := ""
274 if memory.ResolveActivation(fact) == memory.ActivationPinned {
275 pinned = " activation=pinned"
276 }
277 fmt.Fprintf(&b, " revision=%d scope=%s type=%s freshness=%s%s\n",
278 fact.Revision,
279 memory.NormalizeFactScope(string(fact.Scope)), memory.NormalizeType(string(fact.Type)),
280 memory.FreshnessFor(fact, now), pinned)
281 if description := memoryOneLine(fact.Description); description != "" {
282 fmt.Fprintf(&b, " description=%s\n", description)
283 }
284 }
285 }
286 if len(archives) > 0 {
287 b.WriteByte('\n')
288 b.WriteString(renderMemoryArchives(set) + "\n")
289 }
290 if len(facts) > 0 || len(archives) > 0 {
291 for _, dir := range memoryStoreDirs(set.Store) {
292 fmt.Fprintf(&b, " stored under %s\n", dir)
293 }
294 }
295 b.WriteString("\n")
296 b.WriteString(strings.TrimSpace(i18n.M.MemoryEditHint))
297 b.WriteString("\n")
298 b.WriteString(memoryCommandUsage)
299 return strings.TrimRight(b.String(), "\n")
300 }
301
302 func renderMemoryRecall(recall memory.RecallResult) string {
303 if strings.TrimSpace(recall.Query) == "" && len(recall.Hits) == 0 && recall.Suppressed == "" {
304 return "last memory recall: none"
305 }
306 var b strings.Builder
307 fmt.Fprintf(&b, "last memory recall\n query=%s\n", memoryOneLine(recall.Query))
308 fmt.Fprintf(&b, " budget=%d/%d omitted=%d", recall.UsedChars, recall.CharBudget, recall.Omitted)
309 if recall.Suppressed != "" {
310 fmt.Fprintf(&b, " suppressed=%s", memoryOneLine(recall.Suppressed))
311 }
312 b.WriteByte('\n')
313 for _, hit := range recall.Hits {
314 fact := hit.Memory
315 fmt.Fprintf(&b, " id=%s revision=%d scope=%s type=%s freshness=%s score=%.3f\n",
316 fact.ID, fact.Revision, memory.NormalizeFactScope(string(fact.Scope)),
317 memory.NormalizeType(string(fact.Type)), hit.Freshness, hit.Score)
318 fmt.Fprintf(&b, " name=%s reason=%s\n", fact.Name, memoryOneLine(hit.Reason))
319 }
320 return strings.TrimRight(b.String(), "\n")
321 }
322
323 func renderMemoryRevisions(api MemoryControl, ref string) string {
324 set := api.Memory()
325 if set == nil {
326 return i18n.M.ListMemoryNone
327 }
328 active, ok := set.Store.Read(ref)
329 if !ok {
330 return fmt.Sprintf("memory revisions: memory %q not found", ref)
331 }
332 revisions := append([]memory.Memory{active}, api.MemoryRevisions(active.ID)...)
333 sort.SliceStable(revisions, func(i, j int) bool {
334 return revisions[i].Revision > revisions[j].Revision
335 })
336 var b strings.Builder
337 fmt.Fprintf(&b, "memory revisions id=%s name=%s\n", active.ID, active.Name)
338 for index, revision := range revisions {
339 status := "history"
340 if index == 0 && revision.Revision == active.Revision {
341 status = "active"
342 }
343 fmt.Fprintf(&b, " revision=%d %s updated=%s scope=%s type=%s description=%s\n",
344 revision.Revision, status, formatMemoryTime(revision.UpdatedAt),
345 memory.NormalizeFactScope(string(revision.Scope)), memory.NormalizeType(string(revision.Type)),
346 memoryOneLine(revision.Description))
347 }
348 return strings.TrimRight(b.String(), "\n")
349 }
350
351 func renderMemoryArchives(set *memory.Set) string {
352 if set == nil {
353 return i18n.M.ListMemoryNone
354 }
355 archives := set.Store.ListArchived()
356 if len(archives) == 0 {
357 return "archived memories: none"
358 }
359 var b strings.Builder
360 b.WriteString(i18n.M.ListMemoryArchived + "\n")
361 for _, archived := range archives {
362 fmt.Fprintf(&b, " [%s](%s)\n", memoryDisplayTitle(archived.Title, archived.Name), archived.Path)
363 fmt.Fprintf(&b, " id=%s\n", archived.ID)
364 fmt.Fprintf(&b, " revision=%d scope=%s type=%s archived=%s\n", archived.Revision,
365 memory.NormalizeFactScope(string(archived.Scope)), memory.NormalizeType(string(archived.Type)),
366 formatMemoryTime(archived.ArchivedAt))
367 if description := memoryOneLine(archived.Description); description != "" {
368 fmt.Fprintf(&b, " description=%s\n", description)
369 }
370 }
371 b.WriteString("recover with /memory recover <archive-path>")
372 return strings.TrimRight(b.String(), "\n")
373 }
374
375 func renderMemoryInstructions(set *memory.Set) string {
376 if set == nil || (len(set.Docs) == 0 && len(set.InstructionDiagnostics) == 0) {
377 return "instructions: none"
378 }
379 var b strings.Builder
380 b.WriteString("instructions (low -> high precedence)\n")
381 for index, doc := range set.Docs {
382 fmt.Fprintf(&b, " precedence=%d scope=%s path=%s\n", index+1, doc.Scope, doc.Path)
383 if strings.TrimSpace(doc.Directory) != "" {
384 fmt.Fprintf(&b, " directory=%s\n", doc.Directory)
385 }
386 for _, imported := range doc.Imports {
387 fmt.Fprintf(&b, " import=%s source=%s\n", imported.Path, imported.SourcePath)
388 }
389 }
390 if len(set.InstructionDiagnostics) > 0 {
391 b.WriteString("diagnostics\n")
392 for _, diagnostic := range set.InstructionDiagnostics {
393 source := diagnostic.SourcePath
394 if source == "" {
395 source = diagnostic.Path
396 }
397 if diagnostic.Line > 0 {
398 source += ":" + strconv.Itoa(diagnostic.Line)
399 }
400 fmt.Fprintf(&b, " code=%s source=%s path=%s message=%s\n",
401 diagnostic.Code, source, diagnostic.Path, memoryOneLine(diagnostic.Message))
402 }
403 }
404 return strings.TrimRight(b.String(), "\n")
405 }
406
407 func memoryStoreDirs(store memory.Store) []string {
408 var dirs []string
409 seen := map[string]bool{}
410 for _, dir := range []string{store.Dir, store.GlobalDir} {
411 dir = strings.TrimSpace(dir)
412 if dir == "" || seen[dir] {
413 continue
414 }
415 seen[dir] = true
416 dirs = append(dirs, dir)
417 }
418 return dirs
419 }
420
421 func formatMemoryTime(value time.Time) string {
422 if value.IsZero() {
423 return "unknown"
424 }
425 return value.UTC().Format(time.RFC3339)
426 }
427
428 func memoryDisplayTitle(title, name string) string {
429 if title = memoryOneLine(title); title != "" {
430 return title
431 }
432 return strings.ReplaceAll(name, "-", " ")
433 }
434
435 func memoryOneLine(value string) string {
436 return strings.Join(strings.Fields(value), " ")
437 }
438
438 lines GO