返回 DeepSeek-Reasonix
memory_suggestions.go
根目录 / desktop / memory_suggestions.go
1 package main
2
3 import (
4 "fmt"
5 "hash/fnv"
6 "path/filepath"
7 "strings"
8 "time"
9 "unicode"
10
11 "golang.org/x/text/cases"
12 "golang.org/x/text/language"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/config"
16 "reasonix/internal/control"
17 "reasonix/internal/memory"
18 "reasonix/internal/provider"
19 "reasonix/internal/skill"
20 )
21
22 const (
23 suggestionSessionLimit = 12
24 memorySuggestionLimit = 6
25 )
26
27 // MemorySuggestion is a user-confirmed candidate for an active saved memory.
28 // It is generated read-only from recent local history and only persisted through
29 // AcceptMemorySuggestion.
30 type MemorySuggestion struct {
31 ID string `json:"id"`
32 Name string `json:"name"`
33 Title string `json:"title"`
34 Description string `json:"description"`
35 Type string `json:"type"`
36 Scope string `json:"scope"`
37 Body string `json:"body"`
38 Reason string `json:"reason"`
39 Evidence []string `json:"evidence"`
40 }
41
42 // SkillSuggestion is a user-confirmed candidate for a reusable skill.
43 type SkillSuggestion struct {
44 ID string `json:"id"`
45 Name string `json:"name"`
46 Description string `json:"description"`
47 Scope string `json:"scope"`
48 Body string `json:"body"`
49 Reason string `json:"reason"`
50 Evidence []string `json:"evidence"`
51 }
52
53 // MemorySuggestionsView is the desktop Memory page's suggestion payload.
54 type MemorySuggestionsView struct {
55 Memories []MemorySuggestion `json:"memories"`
56 Skills []SkillSuggestion `json:"skills"`
57 GeneratedAt string `json:"generatedAt"`
58 Available bool `json:"available"`
59 Source string `json:"source"`
60 }
61
62 type suggestionSession struct {
63 Path string
64 ID string
65 Preview string
66 LastSeen time.Time
67 Messages []provider.Message
68 }
69
70 type workflowCategory struct {
71 Name string
72 Description string
73 Reason string
74 Keywords []string
75 Steps []string
76 }
77
78 // MemorySuggestions scans recent local history and returns draft memory/skill
79 // candidates. It does not modify memory, skills, sessions, or model context.
80 func (a *App) MemorySuggestions() MemorySuggestionsView {
81 return a.MemorySuggestionsForTab("")
82 }
83
84 // MemorySuggestionsForTab scans recent local history for the selected tab's
85 // session directory and workspace, instead of whichever tab is currently active.
86 func (a *App) MemorySuggestionsForTab(tabID string) MemorySuggestionsView {
87 view := emptyMemorySuggestionsView()
88
89 a.mu.RLock()
90 tab := a.tabByIDLocked(tabID)
91 var ctrl control.SessionAPI
92 workspaceRoot := ""
93 if tab != nil {
94 ctrl = tab.Ctrl
95 workspaceRoot = tab.WorkspaceRoot
96 }
97 a.mu.RUnlock()
98 if ctrl == nil {
99 return view
100 }
101 sessionDir := ""
102 if path, ok := a.reconcileTabWithPinnedSessionMeta(tab); ok && strings.TrimSpace(path) != "" {
103 sessionDir = filepath.Dir(path)
104 workspaceRoot = tab.WorkspaceRoot
105 } else {
106 sessionDir = tabRuntimeSessionDir(tab)
107 }
108 set := ctrl.Memory()
109 if set == nil {
110 return view
111 }
112 view.Available = true
113 view.Source = "local-history"
114
115 sessions := loadSuggestionSessions(sessionDir, suggestionSessionLimit)
116 view.Memories = suggestMemories(set, sessions)
117 view.Skills = suggestSkills(workspaceRoot, ctrl.AllSkills(), sessions)
118 return view
119 }
120
121 func emptyMemorySuggestionsView() MemorySuggestionsView {
122 return MemorySuggestionsView{
123 Memories: []MemorySuggestion{},
124 Skills: []SkillSuggestion{},
125 GeneratedAt: time.Now().UTC().Format(time.RFC3339),
126 }
127 }
128
129 // AcceptMemorySuggestion persists a previously previewed memory candidate.
130 func (a *App) AcceptMemorySuggestion(in MemorySuggestion) (string, error) {
131 return a.AcceptMemorySuggestionForTab("", in)
132 }
133
134 // AcceptMemorySuggestionForTab persists a memory candidate into the selected
135 // tab's memory store, matching the tab used to generate suggestions.
136 func (a *App) AcceptMemorySuggestionForTab(tabID string, in MemorySuggestion) (string, error) {
137 ctrl := a.ctrlByTabID(tabID)
138 if ctrl == nil {
139 return "", nil
140 }
141 desc := oneLine(in.Description)
142 body := strings.TrimSpace(in.Body)
143 if desc == "" || body == "" {
144 return "", fmt.Errorf("memory suggestion requires description and body")
145 }
146 name := acceptedSuggestionName(in.Name, desc)
147 return ctrl.SaveMemory(memory.Memory{
148 Name: name,
149 Title: oneLine(in.Title),
150 Description: desc,
151 Type: memory.NormalizeType(in.Type),
152 Scope: memory.NormalizeFactScope(in.Scope),
153 Body: body,
154 })
155 }
156
157 // AcceptSkillSuggestion writes a previewed skill candidate. It uses the regular
158 // skill store so name validation, scope handling, and no-overwrite behavior stay
159 // centralized.
160 func (a *App) AcceptSkillSuggestion(in SkillSuggestion) (string, error) {
161 return a.AcceptSkillSuggestionForTab("", in)
162 }
163
164 // AcceptSkillSuggestionForTab writes a skill candidate into the selected tab's
165 // workspace/global skill store, matching the tab used to generate suggestions.
166 func (a *App) AcceptSkillSuggestionForTab(tabID string, in SkillSuggestion) (string, error) {
167 a.mu.RLock()
168 tab := a.tabByIDLocked(tabID)
169 workspaceRoot := ""
170 if tab != nil {
171 workspaceRoot = tab.WorkspaceRoot
172 }
173 a.mu.RUnlock()
174
175 name := strings.TrimSpace(in.Name)
176 desc := oneLine(in.Description)
177 body := strings.TrimSpace(in.Body)
178 if name == "" || desc == "" || body == "" {
179 return "", fmt.Errorf("skill suggestion requires name, description, and body")
180 }
181 st := skillStoreForWorkspace(workspaceRoot)
182 scope := skill.ScopeProject
183 if strings.TrimSpace(in.Scope) == "global" || !st.HasProjectScope() {
184 scope = skill.ScopeGlobal
185 }
186 // skill.RenderSkillFile yaml-escapes the free-text description; the old
187 // local string-concatenation helper produced unparseable frontmatter for a
188 // description containing ": ", which loads back as an EMPTY field map (the
189 // skill then surfaces with no description and default run semantics).
190 content := skill.RenderSkillFile(skill.SkillFileOptions{Name: name, Description: desc, Body: body})
191 return st.CreateWithContent(name, scope, content)
192 }
193
194 func loadSuggestionSessions(dir string, limit int) []suggestionSession {
195 if strings.TrimSpace(dir) == "" || limit <= 0 {
196 return nil
197 }
198 infos, err := agent.ListSessions(dir)
199 if err != nil {
200 return nil
201 }
202 var out []suggestionSession
203 for _, info := range infos {
204 if len(out) >= limit {
205 break
206 }
207 loaded, err := agent.LoadSession(info.Path)
208 if err != nil {
209 continue
210 }
211 out = append(out, suggestionSession{
212 Path: info.Path,
213 ID: strings.TrimSuffix(filepath.Base(info.Path), filepath.Ext(info.Path)),
214 Preview: info.Preview,
215 LastSeen: info.LastActivityAt,
216 Messages: loaded.Snapshot(),
217 })
218 }
219 return out
220 }
221
222 func suggestMemories(set *memory.Set, sessions []suggestionSession) []MemorySuggestion {
223 if set == nil || len(sessions) == 0 {
224 return []MemorySuggestion{}
225 }
226 existing := existingMemoryText(set)
227 seen := map[string]bool{}
228 var out []MemorySuggestion
229 for _, sess := range sessions {
230 for _, msg := range sess.Messages {
231 if msg.Role != provider.RoleUser {
232 continue
233 }
234 statement, reason := extractMemoryStatement(agent.UserMessageText(msg))
235 if statement == "" {
236 continue
237 }
238 key := normalizeSuggestionKey(statement)
239 if key == "" || seen[key] || existingCovers(existing, key) {
240 continue
241 }
242 seen[key] = true
243 name := stableSuggestionName(statement, "memory-candidate")
244 title := suggestionTitle(statement, "Memory candidate")
245 typ := inferMemoryType(statement)
246 out = append(out, MemorySuggestion{
247 ID: "memory-" + name,
248 Name: name,
249 Title: title,
250 Description: oneLine(statement),
251 Type: string(typ),
252 Scope: string(memory.FactScopeProject),
253 Body: memoryCandidateBody(statement, reason, sess),
254 Reason: reason,
255 Evidence: []string{sessionEvidence(sess, statement)},
256 })
257 if len(out) >= memorySuggestionLimit {
258 return out
259 }
260 }
261 }
262 return out
263 }
264
265 func suggestSkills(workspaceRoot string, existing []skill.Skill, sessions []suggestionSession) []SkillSuggestion {
266 if len(sessions) == 0 {
267 return []SkillSuggestion{}
268 }
269 existingNames := map[string]bool{}
270 for _, sk := range existing {
271 existingNames[config.SkillNameKey(sk.Name)] = true
272 }
273 scope := "project"
274 if strings.TrimSpace(workspaceRoot) == "" {
275 scope = "global"
276 }
277
278 var out []SkillSuggestion
279 for _, cat := range workflowCategories() {
280 if existingNames[config.SkillNameKey(cat.Name)] {
281 continue
282 }
283 evidence := workflowEvidence(cat, sessions)
284 if len(evidence) < 2 {
285 continue
286 }
287 out = append(out, SkillSuggestion{
288 ID: "skill-" + cat.Name,
289 Name: cat.Name,
290 Description: cat.Description,
291 Scope: scope,
292 Body: skillCandidateBody(cat, evidence),
293 Reason: cat.Reason,
294 Evidence: evidence,
295 })
296 }
297 return out
298 }
299
300 func existingMemoryText(set *memory.Set) []string {
301 var out []string
302 for _, d := range set.Docs {
303 out = append(out, normalizeSuggestionKey(d.Body))
304 }
305 for _, f := range set.Store.ListAll() {
306 out = append(out, normalizeSuggestionKey(strings.Join([]string{f.Name, f.Title, f.Description, f.Body}, " ")))
307 }
308 return out
309 }
310
311 func existingCovers(existing []string, key string) bool {
312 if key == "" {
313 return true
314 }
315 for _, text := range existing {
316 if text != "" && (strings.Contains(text, key) || strings.Contains(key, text)) {
317 return true
318 }
319 }
320 return false
321 }
322
323 func extractMemoryStatement(content string) (string, string) {
324 text := oneLine(content)
325 if len([]rune(text)) < 8 || len([]rune(text)) > 420 {
326 return "", ""
327 }
328 lower := strings.ToLower(text)
329 type marker struct {
330 value string
331 reason string
332 }
333 markers := []marker{
334 {"记住", "explicit remember request"},
335 {"以后", "future-facing preference"},
336 {"始终", "persistent working rule"},
337 {"总是", "persistent working rule"},
338 {"每次", "repeated workflow preference"},
339 {"默认", "default behavior preference"},
340 {"不要", "negative working preference"},
341 {"偏好", "user preference"},
342 {"规则", "durable rule"},
343 {"约定", "project convention"},
344 {"remember", "explicit remember request"},
345 {"always", "persistent working rule"},
346 {"never", "negative working preference"},
347 {"prefer", "user preference"},
348 {"preference", "user preference"},
349 {"by default", "default behavior preference"},
350 }
351 for _, m := range markers {
352 if strings.Contains(lower, m.value) {
353 return trimMemoryLead(text, m.value), m.reason
354 }
355 }
356 return "", ""
357 }
358
359 func trimMemoryLead(text, marker string) string {
360 idx := strings.Index(strings.ToLower(text), marker)
361 if idx < 0 {
362 return text
363 }
364 trimmed := strings.TrimSpace(text[idx:])
365 for _, sep := range []string{":", ":", "-", "—"} {
366 trimmed = strings.TrimPrefix(trimmed, marker+sep)
367 }
368 return strings.TrimSpace(trimmed)
369 }
370
371 func inferMemoryType(statement string) memory.Type {
372 lower := strings.ToLower(statement)
373 if strings.Contains(lower, "http://") || strings.Contains(lower, "https://") || strings.Contains(lower, "github.com/") {
374 return memory.TypeReference
375 }
376 if hasAny(lower, "反馈", "回复", "回答", "不要", "always", "never", "始终", "总是") {
377 return memory.TypeFeedback
378 }
379 if hasAny(lower, "项目", "分支", "pr", "pull request", "仓库", "repo", "约定") {
380 return memory.TypeProject
381 }
382 return memory.TypeUser
383 }
384
385 func memoryCandidateBody(statement, reason string, sess suggestionSession) string {
386 var b strings.Builder
387 b.WriteString(strings.TrimSpace(statement))
388 b.WriteString("\n\n**Why:** Suggested from recent local history")
389 if reason != "" {
390 b.WriteString(" (" + reason + ")")
391 }
392 b.WriteString(".\n")
393 b.WriteString("**How to apply:** Treat this as durable guidance only after the user confirms it still applies.\n")
394 if sess.ID != "" {
395 b.WriteString("\nEvidence: [" + sess.ID + "] " + truncateRunes(statement, 180))
396 }
397 return b.String()
398 }
399
400 func workflowCategories() []workflowCategory {
401 return []workflowCategory{
402 {
403 Name: "reasonix-pr-followup",
404 Description: "Review or update a Reasonix GitHub PR, address feedback, verify, and publish safely.",
405 Reason: "recent history repeatedly touched PR review, bot feedback, commits, or GitHub publication",
406 Keywords: []string{"pr", "pull request", "github", "review", "机器人", "评审", "提交到pr", "更新pr", "code rabbit", "coderabbit"},
407 Steps: []string{
408 "Fetch the live PR state and confirm branch, base, head SHA, and review status.",
409 "Inspect the real diff and related implementation before changing code.",
410 "Fix only actionable feedback, run focused verification, and keep cache-sensitive surfaces stable.",
411 "Stage intended files, commit with an English behavior-focused message, push to the verified PR head, and update the PR.",
412 },
413 },
414 {
415 Name: "reasonix-memory-ui",
416 Description: "Iterate on the Reasonix desktop Memory page with source-backed UI decisions and browser verification.",
417 Reason: "recent history repeatedly discussed Memory page layout, labels, filters, and interaction details",
418 Keywords: []string{"memory", "记忆", "设置-记忆", "memory panel", "指令文件", "归档", "全局", "项目", "添加记忆"},
419 Steps: []string{
420 "Identify the active Memory settings component and current browser-rendered state before editing.",
421 "Keep active memories, archived memories, instruction files, and suggestions visually distinct.",
422 "Use neutral secondary actions and confirmation for persistent writes or archive operations.",
423 "Run frontend checks and verify the affected Memory page in the in-app browser.",
424 },
425 },
426 {
427 Name: "desktop-ui-iteration",
428 Description: "Apply focused desktop UI layout feedback, preserve existing design tokens, and verify in browser.",
429 Reason: "recent history repeatedly involved screenshot-driven desktop UI layout and interaction feedback",
430 Keywords: []string{"ui", "布局", "设计", "交互", "红框", "页面", "按钮", "浏览器", "frontend", "desktop"},
431 Steps: []string{
432 "Map the screenshot target to the exact component, selector, and state in source.",
433 "Patch the smallest component and CSS surface using existing settings/page recipes.",
434 "Check responsive behavior and text overflow for the changed controls.",
435 "Verify with the running local UI instead of relying only on code inspection.",
436 },
437 },
438 }
439 }
440
441 func workflowEvidence(cat workflowCategory, sessions []suggestionSession) []string {
442 seenSession := map[string]bool{}
443 var evidence []string
444 for _, sess := range sessions {
445 for _, msg := range sess.Messages {
446 if msg.Role != provider.RoleUser {
447 continue
448 }
449 text := oneLine(agent.UserMessageText(msg))
450 if text == "" || !hasAny(strings.ToLower(text), cat.Keywords...) {
451 continue
452 }
453 if seenSession[sess.ID] {
454 continue
455 }
456 seenSession[sess.ID] = true
457 evidence = append(evidence, sessionEvidence(sess, text))
458 break
459 }
460 }
461 if len(evidence) > 4 {
462 return evidence[:4]
463 }
464 return evidence
465 }
466
467 func skillCandidateBody(cat workflowCategory, evidence []string) string {
468 var b strings.Builder
469 title := strings.TrimPrefix(strings.ReplaceAll(cat.Name, "-", " "), "reasonix ")
470 b.WriteString("# " + cases.Title(language.Und).String(title) + "\n\n")
471 b.WriteString("Use this skill when the user asks for this repeated Reasonix workflow.\n\n")
472 b.WriteString("## Evidence\n\n")
473 for _, ev := range evidence {
474 b.WriteString("- " + ev + "\n")
475 }
476 b.WriteString("\n## Workflow\n\n")
477 for i, step := range cat.Steps {
478 fmt.Fprintf(&b, "%d. %s\n", i+1, step)
479 }
480 b.WriteString("\n## Stop Condition\n\n")
481 b.WriteString("Finish only after the requested change is implemented, verified, and any requested PR or UI update is delivered.\n")
482 return b.String()
483 }
484
485 func skillStoreForWorkspace(workspaceRoot string) *skill.Store {
486 cfg, err := config.LoadForRoot(workspaceRoot)
487 var custom, excluded []string
488 var pluginPaths map[string][]string
489 var pluginAgentPaths map[string][]string
490 maxDepth := 3
491 if err == nil && cfg != nil {
492 custom = cfg.SkillCustomPaths()
493 excluded = cfg.SkillExcludedPaths()
494 pluginPaths = cfg.PluginPackageSkillOwners()
495 pluginAgentPaths = cfg.PluginPackageAgentOwners()
496 maxDepth = cfg.SkillMaxDepth()
497 }
498 return skill.New(skill.Options{
499 ProjectRoot: strings.TrimSpace(workspaceRoot),
500 CustomPaths: custom,
501 PluginPaths: pluginPaths,
502 PluginAgentPaths: pluginAgentPaths,
503 ExcludedPaths: excluded,
504 MaxDepth: maxDepth,
505 })
506 }
507
508 func suggestionName(given, source, fallback string) string {
509 if name := asciiSlug(given); name != "" {
510 return name
511 }
512 if name := asciiSlug(source); name != "" {
513 return name
514 }
515 if name := asciiSlug(fallback); name != "" {
516 return name
517 }
518 return "candidate"
519 }
520
521 // acceptedSuggestionName preserves the candidate name generated at suggestion
522 // time. Re-running asciiSlug here would truncate back to 56 chars and strip
523 // the uniqueness hash suffix, re-colliding long common-prefix candidates at
524 // save time even though their generated Name/ID differed. A well-formed slug
525 // is kept verbatim (memory.Store.Save's own slug pass cleans but never
526 // truncates); anything else falls back to deriving from the description as
527 // before.
528 func acceptedSuggestionName(given, desc string) string {
529 if isWellFormedSlug(given) {
530 return given
531 }
532 return suggestionName("", desc, "memory-candidate")
533 }
534
535 // isWellFormedSlug reports whether s already matches asciiSlug's output shape
536 // (lowercase ASCII letters/digits separated by single dashes), possibly with a
537 // hash suffix beyond asciiSlug's 56-char cap.
538 func isWellFormedSlug(s string) bool {
539 if s == "" || len(s) > 128 || s[0] == '-' || s[len(s)-1] == '-' {
540 return false
541 }
542 prevDash := false
543 for _, r := range s {
544 switch {
545 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
546 prevDash = false
547 case r == '-':
548 if prevDash {
549 return false
550 }
551 prevDash = true
552 default:
553 return false
554 }
555 }
556 return true
557 }
558
559 // stableSuggestionName returns a slug that is unique per source text and stable
560 // across suggestion refreshes. asciiSlug drops non-ASCII runes and truncates to
561 // 56 chars, so two CJK-only statements (or long English statements sharing a
562 // prefix) can collide — colliding Names make Store.Save overwrite the earlier
563 // memory, and colliding IDs cross-wire the frontend's accepted-state map.
564 //
565 // When the ASCII slug is short enough that truncation cannot have caused a
566 // collision, it is returned as-is for backward compatibility with old-version
567 // candidate names. The hash suffix is only appended when the slug fell back to
568 // the fallback (non-ASCII source) or when the slug hit the 56-char truncation
569 // boundary.
570 func stableSuggestionName(source, fallback string) string {
571 slug := asciiSlug(source)
572 if slug != "" && len(slug) < 56 {
573 return slug
574 }
575 base := suggestionName("", source, fallback)
576 h := fnv.New32a()
577 _, _ = h.Write([]byte(source))
578 return fmt.Sprintf("%s-%08x", base, h.Sum32())
579 }
580
581 func asciiSlug(s string) string {
582 s = strings.ToLower(strings.TrimSpace(s))
583 var b strings.Builder
584 lastDash := false
585 for _, r := range s {
586 switch {
587 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
588 b.WriteRune(r)
589 lastDash = false
590 case r == '-' || r == '_' || r == '.':
591 if b.Len() > 0 && !lastDash {
592 b.WriteRune('-')
593 lastDash = true
594 }
595 case unicode.IsSpace(r):
596 if b.Len() > 0 && !lastDash {
597 b.WriteRune('-')
598 lastDash = true
599 }
600 }
601 if b.Len() >= 56 {
602 break
603 }
604 }
605 return strings.Trim(b.String(), "-")
606 }
607
608 func suggestionTitle(s, fallback string) string {
609 title := truncateRunes(oneLine(s), 64)
610 if title == "" {
611 return fallback
612 }
613 return title
614 }
615
616 func sessionEvidence(sess suggestionSession, text string) string {
617 label := sess.ID
618 if label == "" {
619 label = filepath.Base(sess.Path)
620 }
621 return label + ": " + truncateRunes(oneLine(text), 160)
622 }
623
624 func normalizeSuggestionKey(s string) string {
625 return strings.ToLower(strings.Join(strings.Fields(s), " "))
626 }
627
628 func oneLine(s string) string {
629 return strings.Join(strings.Fields(s), " ")
630 }
631
632 func truncateRunes(s string, n int) string {
633 if n <= 0 {
634 return ""
635 }
636 r := []rune(strings.TrimSpace(s))
637 if len(r) <= n {
638 return string(r)
639 }
640 return string(r[:n-1]) + "..."
641 }
642
643 func hasAny(hay string, needles ...string) bool {
644 hay = strings.ToLower(hay)
645 for _, needle := range needles {
646 if strings.Contains(hay, strings.ToLower(needle)) {
647 return true
648 }
649 }
650 return false
651 }
652
652 lines GO