返回 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|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 "revisions":
65 ref, err := singleMemoryArgument(rest)
66 if err != nil {
67 return "usage: /memory revisions <id-or-name>"
68 }
69 return renderMemoryRevisions(api, ref)
70 case "restore":
71 ref, revision, err := parseMemoryRestore(rest)
72 if err != nil {
73 return "usage: /memory restore <id-or-name> <revision>"
74 }
75 restored, err := api.RestoreMemory(ref, revision)
76 if err != nil {
77 return "memory restore: " + err.Error()
78 }
79 return fmt.Sprintf("restored %s as revision=%d id=%s scope=%s",
80 restored.Name, restored.Revision, restored.ID, memory.NormalizeFactScope(string(restored.Scope)))
81 case "archived", "archives":
82 if rest != "" {
83 return "usage: /memory archived"
84 }
85 return renderMemoryArchives(api.Memory())
86 case "recover":
87 archivePath, err := singleMemoryArgument(rest)
88 if err != nil {
89 return "usage: /memory recover <archive-path>"
90 }
91 restored, err := api.RestoreArchivedMemory(archivePath)
92 if err != nil {
93 return "memory recover: " + err.Error()
94 }
95 return fmt.Sprintf("recovered %s as revision=%d id=%s scope=%s",
96 restored.Name, restored.Revision, restored.ID, memory.NormalizeFactScope(string(restored.Scope)))
97 case "instructions":
98 if rest != "" {
99 return "usage: /memory instructions"
100 }
101 return renderMemoryInstructions(api.Memory())
102 default:
103 return "unknown /memory subcommand " + subcommand + "\n" + memoryCommandUsage
104 }
105 }
106
107 func parseMemoryCommand(input string) (subcommand, rest string) {
108 input = strings.TrimSpace(input)
109 if strings.HasPrefix(input, "/memory") {
110 input = strings.TrimSpace(strings.TrimPrefix(input, "/memory"))
111 }
112 if input == "" {
113 return "", ""
114 }
115 if at := strings.IndexAny(input, " \t"); at >= 0 {
116 return strings.ToLower(input[:at]), strings.TrimSpace(input[at+1:])
117 }
118 return strings.ToLower(input), ""
119 }
120
121 func singleMemoryArgument(input string) (string, error) {
122 input = strings.TrimSpace(input)
123 if input == "" {
124 return "", fmt.Errorf("missing argument")
125 }
126 if len(input) >= 2 {
127 if (input[0] == '"' && input[len(input)-1] == '"') ||
128 (input[0] == '\'' && input[len(input)-1] == '\'') {
129 input = strings.TrimSpace(input[1 : len(input)-1])
130 }
131 }
132 if input == "" {
133 return "", fmt.Errorf("missing argument")
134 }
135 return input, nil
136 }
137
138 func parseMemoryRestore(input string) (string, int, error) {
139 fields := strings.Fields(input)
140 if len(fields) != 2 {
141 return "", 0, fmt.Errorf("expected reference and revision")
142 }
143 revision, err := strconv.Atoi(fields[1])
144 if err != nil || revision <= 0 {
145 return "", 0, fmt.Errorf("revision must be positive")
146 }
147 return fields[0], revision, nil
148 }
149
150 // RenderMemorySummary renders the zero-configuration overview shown by bare
151 // /memory. It intentionally uses ListAll so project/global collisions remain
152 // observable instead of being hidden by the legacy name-based merge.
153 func RenderMemorySummary(set *memory.Set, now time.Time) string {
154 if set == nil {
155 return i18n.M.ListMemoryNone
156 }
157 facts := set.Store.ListAll()
158 archives := set.Store.ListArchived()
159 if len(set.Docs) == 0 && len(facts) == 0 && len(archives) == 0 {
160 return i18n.M.ListMemoryNone
161 }
162 if now.IsZero() {
163 now = time.Now().UTC()
164 }
165 var b strings.Builder
166 b.WriteString("memory\n")
167 if len(set.Docs) > 0 {
168 b.WriteString("\ninstructions (low -> high precedence)\n")
169 for index, doc := range set.Docs {
170 fmt.Fprintf(&b, " precedence=%d scope=%s path=%s\n", index+1, doc.Scope, doc.Path)
171 if strings.TrimSpace(doc.Directory) != "" {
172 fmt.Fprintf(&b, " directory=%s\n", doc.Directory)
173 }
174 for _, imported := range doc.Imports {
175 fmt.Fprintf(&b, " import=%s\n", imported.Path)
176 }
177 }
178 }
179 if len(facts) > 0 {
180 b.WriteString("\n" + i18n.M.ListMemorySaved + "\n")
181 for _, fact := range facts {
182 fmt.Fprintf(&b, " [%s](%s.md)\n", memoryDisplayTitle(fact.Title, fact.Name), fact.Name)
183 fmt.Fprintf(&b, " id=%s\n", fact.ID)
184 fmt.Fprintf(&b, " revision=%d scope=%s type=%s freshness=%s\n",
185 fact.Revision,
186 memory.NormalizeFactScope(string(fact.Scope)), memory.NormalizeType(string(fact.Type)),
187 memory.FreshnessFor(fact, now))
188 if description := memoryOneLine(fact.Description); description != "" {
189 fmt.Fprintf(&b, " description=%s\n", description)
190 }
191 }
192 }
193 if len(archives) > 0 {
194 b.WriteByte('\n')
195 b.WriteString(renderMemoryArchives(set) + "\n")
196 }
197 if len(facts) > 0 || len(archives) > 0 {
198 for _, dir := range memoryStoreDirs(set.Store) {
199 fmt.Fprintf(&b, " stored under %s\n", dir)
200 }
201 }
202 b.WriteString("\n")
203 b.WriteString(strings.TrimSpace(i18n.M.MemoryEditHint))
204 b.WriteString("\n")
205 b.WriteString(memoryCommandUsage)
206 return strings.TrimRight(b.String(), "\n")
207 }
208
209 func renderMemoryRecall(recall memory.RecallResult) string {
210 if strings.TrimSpace(recall.Query) == "" && len(recall.Hits) == 0 && recall.Suppressed == "" {
211 return "last memory recall: none"
212 }
213 var b strings.Builder
214 fmt.Fprintf(&b, "last memory recall\n query=%s\n", memoryOneLine(recall.Query))
215 fmt.Fprintf(&b, " budget=%d/%d omitted=%d", recall.UsedChars, recall.CharBudget, recall.Omitted)
216 if recall.Suppressed != "" {
217 fmt.Fprintf(&b, " suppressed=%s", memoryOneLine(recall.Suppressed))
218 }
219 b.WriteByte('\n')
220 for _, hit := range recall.Hits {
221 fact := hit.Memory
222 fmt.Fprintf(&b, " id=%s revision=%d scope=%s type=%s freshness=%s score=%.3f\n",
223 fact.ID, fact.Revision, memory.NormalizeFactScope(string(fact.Scope)),
224 memory.NormalizeType(string(fact.Type)), hit.Freshness, hit.Score)
225 fmt.Fprintf(&b, " name=%s reason=%s\n", fact.Name, memoryOneLine(hit.Reason))
226 }
227 return strings.TrimRight(b.String(), "\n")
228 }
229
230 func renderMemoryRevisions(api MemoryControl, ref string) string {
231 set := api.Memory()
232 if set == nil {
233 return i18n.M.ListMemoryNone
234 }
235 active, ok := set.Store.Read(ref)
236 if !ok {
237 return fmt.Sprintf("memory revisions: memory %q not found", ref)
238 }
239 revisions := append([]memory.Memory{active}, api.MemoryRevisions(active.ID)...)
240 sort.SliceStable(revisions, func(i, j int) bool {
241 return revisions[i].Revision > revisions[j].Revision
242 })
243 var b strings.Builder
244 fmt.Fprintf(&b, "memory revisions id=%s name=%s\n", active.ID, active.Name)
245 for index, revision := range revisions {
246 status := "history"
247 if index == 0 && revision.Revision == active.Revision {
248 status = "active"
249 }
250 fmt.Fprintf(&b, " revision=%d %s updated=%s scope=%s type=%s description=%s\n",
251 revision.Revision, status, formatMemoryTime(revision.UpdatedAt),
252 memory.NormalizeFactScope(string(revision.Scope)), memory.NormalizeType(string(revision.Type)),
253 memoryOneLine(revision.Description))
254 }
255 return strings.TrimRight(b.String(), "\n")
256 }
257
258 func renderMemoryArchives(set *memory.Set) string {
259 if set == nil {
260 return i18n.M.ListMemoryNone
261 }
262 archives := set.Store.ListArchived()
263 if len(archives) == 0 {
264 return "archived memories: none"
265 }
266 var b strings.Builder
267 b.WriteString(i18n.M.ListMemoryArchived + "\n")
268 for _, archived := range archives {
269 fmt.Fprintf(&b, " [%s](%s)\n", memoryDisplayTitle(archived.Title, archived.Name), archived.Path)
270 fmt.Fprintf(&b, " id=%s\n", archived.ID)
271 fmt.Fprintf(&b, " revision=%d scope=%s type=%s archived=%s\n", archived.Revision,
272 memory.NormalizeFactScope(string(archived.Scope)), memory.NormalizeType(string(archived.Type)),
273 formatMemoryTime(archived.ArchivedAt))
274 if description := memoryOneLine(archived.Description); description != "" {
275 fmt.Fprintf(&b, " description=%s\n", description)
276 }
277 }
278 b.WriteString("recover with /memory recover <archive-path>")
279 return strings.TrimRight(b.String(), "\n")
280 }
281
282 func renderMemoryInstructions(set *memory.Set) string {
283 if set == nil || (len(set.Docs) == 0 && len(set.InstructionDiagnostics) == 0) {
284 return "instructions: none"
285 }
286 var b strings.Builder
287 b.WriteString("instructions (low -> high precedence)\n")
288 for index, doc := range set.Docs {
289 fmt.Fprintf(&b, " precedence=%d scope=%s path=%s\n", index+1, doc.Scope, doc.Path)
290 if strings.TrimSpace(doc.Directory) != "" {
291 fmt.Fprintf(&b, " directory=%s\n", doc.Directory)
292 }
293 for _, imported := range doc.Imports {
294 fmt.Fprintf(&b, " import=%s source=%s\n", imported.Path, imported.SourcePath)
295 }
296 }
297 if len(set.InstructionDiagnostics) > 0 {
298 b.WriteString("diagnostics\n")
299 for _, diagnostic := range set.InstructionDiagnostics {
300 source := diagnostic.SourcePath
301 if source == "" {
302 source = diagnostic.Path
303 }
304 if diagnostic.Line > 0 {
305 source += ":" + strconv.Itoa(diagnostic.Line)
306 }
307 fmt.Fprintf(&b, " code=%s source=%s path=%s message=%s\n",
308 diagnostic.Code, source, diagnostic.Path, memoryOneLine(diagnostic.Message))
309 }
310 }
311 return strings.TrimRight(b.String(), "\n")
312 }
313
314 func memoryStoreDirs(store memory.Store) []string {
315 var dirs []string
316 seen := map[string]bool{}
317 for _, dir := range []string{store.Dir, store.GlobalDir} {
318 dir = strings.TrimSpace(dir)
319 if dir == "" || seen[dir] {
320 continue
321 }
322 seen[dir] = true
323 dirs = append(dirs, dir)
324 }
325 return dirs
326 }
327
328 func formatMemoryTime(value time.Time) string {
329 if value.IsZero() {
330 return "unknown"
331 }
332 return value.UTC().Format(time.RFC3339)
333 }
334
335 func memoryDisplayTitle(title, name string) string {
336 if title = memoryOneLine(title); title != "" {
337 return title
338 }
339 return strings.ReplaceAll(name, "-", " ")
340 }
341
342 func memoryOneLine(value string) string {
343 return strings.Join(strings.Fields(value), " ")
344 }
345
345 lines GO