Files
2026-07-13 13:00:08 +08:00

645 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"hash/fnv"
"path/filepath"
"strings"
"time"
"unicode"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/memory"
"reasonix/internal/provider"
"reasonix/internal/skill"
)
const (
suggestionSessionLimit = 12
memorySuggestionLimit = 6
)
// MemorySuggestion is a user-confirmed candidate for an active saved memory.
// It is generated read-only from recent local history and only persisted through
// AcceptMemorySuggestion.
type MemorySuggestion struct {
ID string `json:"id"`
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Body string `json:"body"`
Reason string `json:"reason"`
Evidence []string `json:"evidence"`
}
// SkillSuggestion is a user-confirmed candidate for a reusable skill.
type SkillSuggestion struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Scope string `json:"scope"`
Body string `json:"body"`
Reason string `json:"reason"`
Evidence []string `json:"evidence"`
}
// MemorySuggestionsView is the desktop Memory page's suggestion payload.
type MemorySuggestionsView struct {
Memories []MemorySuggestion `json:"memories"`
Skills []SkillSuggestion `json:"skills"`
GeneratedAt string `json:"generatedAt"`
Available bool `json:"available"`
Source string `json:"source"`
}
type suggestionSession struct {
Path string
ID string
Preview string
LastSeen time.Time
Messages []provider.Message
}
type workflowCategory struct {
Name string
Description string
Reason string
Keywords []string
Steps []string
}
// MemorySuggestions scans recent local history and returns draft memory/skill
// candidates. It does not modify memory, skills, sessions, or model context.
func (a *App) MemorySuggestions() MemorySuggestionsView {
return a.MemorySuggestionsForTab("")
}
// MemorySuggestionsForTab scans recent local history for the selected tab's
// session directory and workspace, instead of whichever tab is currently active.
func (a *App) MemorySuggestionsForTab(tabID string) MemorySuggestionsView {
view := MemorySuggestionsView{
Memories: []MemorySuggestion{},
Skills: []SkillSuggestion{},
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
a.mu.RLock()
tab := a.tabByIDLocked(tabID)
var ctrl control.SessionAPI
workspaceRoot := ""
if tab != nil {
ctrl = tab.Ctrl
workspaceRoot = tab.WorkspaceRoot
}
a.mu.RUnlock()
if ctrl == nil {
return view
}
sessionDir := ""
if path, ok := a.reconcileTabWithPinnedSessionMeta(tab); ok && strings.TrimSpace(path) != "" {
sessionDir = filepath.Dir(path)
workspaceRoot = tab.WorkspaceRoot
} else {
sessionDir = tabRuntimeSessionDir(tab)
}
set := ctrl.Memory()
if set == nil {
return view
}
view.Available = true
view.Source = "local-history"
sessions := loadSuggestionSessions(sessionDir, suggestionSessionLimit)
view.Memories = suggestMemories(set, sessions)
// Stable Memory v5 execution learnings join the same candidate list and
// the same explicit-confirmation flow as history-derived suggestions.
view.Memories = append(view.Memories, suggestCompilerMemories(workspaceRoot, set, view.Memories)...)
view.Skills = suggestSkills(workspaceRoot, ctrl.AllSkills(), sessions)
return view
}
// AcceptMemorySuggestion persists a previously previewed memory candidate.
func (a *App) AcceptMemorySuggestion(in MemorySuggestion) (string, error) {
return a.AcceptMemorySuggestionForTab("", in)
}
// AcceptMemorySuggestionForTab persists a memory candidate into the selected
// tab's memory store, matching the tab used to generate suggestions.
func (a *App) AcceptMemorySuggestionForTab(tabID string, in MemorySuggestion) (string, error) {
ctrl := a.ctrlByTabID(tabID)
if ctrl == nil {
return "", nil
}
desc := oneLine(in.Description)
body := strings.TrimSpace(in.Body)
if desc == "" || body == "" {
return "", fmt.Errorf("memory suggestion requires description and body")
}
name := acceptedSuggestionName(in.Name, desc)
return ctrl.SaveMemory(memory.Memory{
Name: name,
Title: oneLine(in.Title),
Description: desc,
Type: memory.NormalizeType(in.Type),
Body: body,
})
}
// AcceptSkillSuggestion writes a previewed skill candidate. It uses the regular
// skill store so name validation, scope handling, and no-overwrite behavior stay
// centralized.
func (a *App) AcceptSkillSuggestion(in SkillSuggestion) (string, error) {
return a.AcceptSkillSuggestionForTab("", in)
}
// AcceptSkillSuggestionForTab writes a skill candidate into the selected tab's
// workspace/global skill store, matching the tab used to generate suggestions.
func (a *App) AcceptSkillSuggestionForTab(tabID string, in SkillSuggestion) (string, error) {
a.mu.RLock()
tab := a.tabByIDLocked(tabID)
workspaceRoot := ""
if tab != nil {
workspaceRoot = tab.WorkspaceRoot
}
a.mu.RUnlock()
name := strings.TrimSpace(in.Name)
desc := oneLine(in.Description)
body := strings.TrimSpace(in.Body)
if name == "" || desc == "" || body == "" {
return "", fmt.Errorf("skill suggestion requires name, description, and body")
}
st := skillStoreForWorkspace(workspaceRoot)
scope := skill.ScopeProject
if strings.TrimSpace(in.Scope) == "global" || !st.HasProjectScope() {
scope = skill.ScopeGlobal
}
// skill.RenderSkillFile yaml-escapes the free-text description; the old
// local string-concatenation helper produced unparseable frontmatter for a
// description containing ": ", which loads back as an EMPTY field map (the
// skill then surfaces with no description and default run semantics).
content := skill.RenderSkillFile(skill.SkillFileOptions{Name: name, Description: desc, Body: body})
return st.CreateWithContent(name, scope, content)
}
func loadSuggestionSessions(dir string, limit int) []suggestionSession {
if strings.TrimSpace(dir) == "" || limit <= 0 {
return nil
}
infos, err := agent.ListSessions(dir)
if err != nil {
return nil
}
var out []suggestionSession
for _, info := range infos {
if len(out) >= limit {
break
}
loaded, err := agent.LoadSession(info.Path)
if err != nil {
continue
}
out = append(out, suggestionSession{
Path: info.Path,
ID: strings.TrimSuffix(filepath.Base(info.Path), filepath.Ext(info.Path)),
Preview: info.Preview,
LastSeen: info.LastActivityAt,
Messages: loaded.Snapshot(),
})
}
return out
}
func suggestMemories(set *memory.Set, sessions []suggestionSession) []MemorySuggestion {
if set == nil || len(sessions) == 0 {
return []MemorySuggestion{}
}
existing := existingMemoryText(set)
seen := map[string]bool{}
var out []MemorySuggestion
for _, sess := range sessions {
for _, msg := range sess.Messages {
if msg.Role != provider.RoleUser {
continue
}
statement, reason := extractMemoryStatement(msg.Content)
if statement == "" {
continue
}
key := normalizeSuggestionKey(statement)
if key == "" || seen[key] || existingCovers(existing, key) {
continue
}
seen[key] = true
name := stableSuggestionName(statement, "memory-candidate")
title := suggestionTitle(statement, "Memory candidate")
typ := inferMemoryType(statement)
out = append(out, MemorySuggestion{
ID: "memory-" + name,
Name: name,
Title: title,
Description: oneLine(statement),
Type: string(typ),
Body: memoryCandidateBody(statement, reason, sess),
Reason: reason,
Evidence: []string{sessionEvidence(sess, statement)},
})
if len(out) >= memorySuggestionLimit {
return out
}
}
}
return out
}
func suggestSkills(workspaceRoot string, existing []skill.Skill, sessions []suggestionSession) []SkillSuggestion {
if len(sessions) == 0 {
return []SkillSuggestion{}
}
existingNames := map[string]bool{}
for _, sk := range existing {
existingNames[config.SkillNameKey(sk.Name)] = true
}
scope := "project"
if strings.TrimSpace(workspaceRoot) == "" {
scope = "global"
}
var out []SkillSuggestion
for _, cat := range workflowCategories() {
if existingNames[config.SkillNameKey(cat.Name)] {
continue
}
evidence := workflowEvidence(cat, sessions)
if len(evidence) < 2 {
continue
}
out = append(out, SkillSuggestion{
ID: "skill-" + cat.Name,
Name: cat.Name,
Description: cat.Description,
Scope: scope,
Body: skillCandidateBody(cat, evidence),
Reason: cat.Reason,
Evidence: evidence,
})
}
return out
}
func existingMemoryText(set *memory.Set) []string {
var out []string
for _, d := range set.Docs {
out = append(out, normalizeSuggestionKey(d.Body))
}
for _, f := range set.Store.List() {
out = append(out, normalizeSuggestionKey(strings.Join([]string{f.Name, f.Title, f.Description, f.Body}, " ")))
}
return out
}
func existingCovers(existing []string, key string) bool {
if key == "" {
return true
}
for _, text := range existing {
if text != "" && (strings.Contains(text, key) || strings.Contains(key, text)) {
return true
}
}
return false
}
func extractMemoryStatement(content string) (string, string) {
text := oneLine(content)
if len([]rune(text)) < 8 || len([]rune(text)) > 420 {
return "", ""
}
lower := strings.ToLower(text)
type marker struct {
value string
reason string
}
markers := []marker{
{"记住", "explicit remember request"},
{"以后", "future-facing preference"},
{"始终", "persistent working rule"},
{"总是", "persistent working rule"},
{"每次", "repeated workflow preference"},
{"默认", "default behavior preference"},
{"不要", "negative working preference"},
{"偏好", "user preference"},
{"规则", "durable rule"},
{"约定", "project convention"},
{"remember", "explicit remember request"},
{"always", "persistent working rule"},
{"never", "negative working preference"},
{"prefer", "user preference"},
{"preference", "user preference"},
{"by default", "default behavior preference"},
}
for _, m := range markers {
if strings.Contains(lower, m.value) {
return trimMemoryLead(text, m.value), m.reason
}
}
return "", ""
}
func trimMemoryLead(text, marker string) string {
idx := strings.Index(strings.ToLower(text), marker)
if idx < 0 {
return text
}
trimmed := strings.TrimSpace(text[idx:])
for _, sep := range []string{"", ":", "-", "—"} {
trimmed = strings.TrimPrefix(trimmed, marker+sep)
}
return strings.TrimSpace(trimmed)
}
func inferMemoryType(statement string) memory.Type {
lower := strings.ToLower(statement)
if strings.Contains(lower, "http://") || strings.Contains(lower, "https://") || strings.Contains(lower, "github.com/") {
return memory.TypeReference
}
if hasAny(lower, "反馈", "回复", "回答", "不要", "always", "never", "始终", "总是") {
return memory.TypeFeedback
}
if hasAny(lower, "项目", "分支", "pr", "pull request", "仓库", "repo", "约定") {
return memory.TypeProject
}
return memory.TypeUser
}
func memoryCandidateBody(statement, reason string, sess suggestionSession) string {
var b strings.Builder
b.WriteString(strings.TrimSpace(statement))
b.WriteString("\n\n**Why:** Suggested from recent local history")
if reason != "" {
b.WriteString(" (" + reason + ")")
}
b.WriteString(".\n")
b.WriteString("**How to apply:** Treat this as durable guidance only after the user confirms it still applies.\n")
if sess.ID != "" {
b.WriteString("\nEvidence: [" + sess.ID + "] " + truncateRunes(statement, 180))
}
return b.String()
}
func workflowCategories() []workflowCategory {
return []workflowCategory{
{
Name: "reasonix-pr-followup",
Description: "Review or update a Reasonix GitHub PR, address feedback, verify, and publish safely.",
Reason: "recent history repeatedly touched PR review, bot feedback, commits, or GitHub publication",
Keywords: []string{"pr", "pull request", "github", "review", "机器人", "评审", "提交到pr", "更新pr", "code rabbit", "coderabbit"},
Steps: []string{
"Fetch the live PR state and confirm branch, base, head SHA, and review status.",
"Inspect the real diff and related implementation before changing code.",
"Fix only actionable feedback, run focused verification, and keep cache-sensitive surfaces stable.",
"Stage intended files, commit with an English behavior-focused message, push to the verified PR head, and update the PR.",
},
},
{
Name: "reasonix-memory-ui",
Description: "Iterate on the Reasonix desktop Memory page with source-backed UI decisions and browser verification.",
Reason: "recent history repeatedly discussed Memory page layout, labels, filters, and interaction details",
Keywords: []string{"memory", "记忆", "设置-记忆", "memory panel", "指令文件", "归档", "全局", "项目", "添加记忆"},
Steps: []string{
"Identify the active Memory settings component and current browser-rendered state before editing.",
"Keep active memories, archived memories, instruction files, and suggestions visually distinct.",
"Use neutral secondary actions and confirmation for persistent writes or archive operations.",
"Run frontend checks and verify the affected Memory page in the in-app browser.",
},
},
{
Name: "desktop-ui-iteration",
Description: "Apply focused desktop UI layout feedback, preserve existing design tokens, and verify in browser.",
Reason: "recent history repeatedly involved screenshot-driven desktop UI layout and interaction feedback",
Keywords: []string{"ui", "布局", "设计", "交互", "红框", "页面", "按钮", "浏览器", "frontend", "desktop"},
Steps: []string{
"Map the screenshot target to the exact component, selector, and state in source.",
"Patch the smallest component and CSS surface using existing settings/page recipes.",
"Check responsive behavior and text overflow for the changed controls.",
"Verify with the running local UI instead of relying only on code inspection.",
},
},
}
}
func workflowEvidence(cat workflowCategory, sessions []suggestionSession) []string {
seenSession := map[string]bool{}
var evidence []string
for _, sess := range sessions {
for _, msg := range sess.Messages {
if msg.Role != provider.RoleUser {
continue
}
text := oneLine(msg.Content)
if text == "" || !hasAny(strings.ToLower(text), cat.Keywords...) {
continue
}
if seenSession[sess.ID] {
continue
}
seenSession[sess.ID] = true
evidence = append(evidence, sessionEvidence(sess, text))
break
}
}
if len(evidence) > 4 {
return evidence[:4]
}
return evidence
}
func skillCandidateBody(cat workflowCategory, evidence []string) string {
var b strings.Builder
title := strings.TrimPrefix(strings.ReplaceAll(cat.Name, "-", " "), "reasonix ")
b.WriteString("# " + cases.Title(language.Und).String(title) + "\n\n")
b.WriteString("Use this skill when the user asks for this repeated Reasonix workflow.\n\n")
b.WriteString("## Evidence\n\n")
for _, ev := range evidence {
b.WriteString("- " + ev + "\n")
}
b.WriteString("\n## Workflow\n\n")
for i, step := range cat.Steps {
fmt.Fprintf(&b, "%d. %s\n", i+1, step)
}
b.WriteString("\n## Stop Condition\n\n")
b.WriteString("Finish only after the requested change is implemented, verified, and any requested PR or UI update is delivered.\n")
return b.String()
}
func skillStoreForWorkspace(workspaceRoot string) *skill.Store {
cfg, err := config.LoadForRoot(workspaceRoot)
var custom, excluded []string
var pluginPaths map[string][]string
maxDepth := 3
if err == nil && cfg != nil {
custom = cfg.SkillCustomPaths()
excluded = cfg.SkillExcludedPaths()
pluginPaths = cfg.PluginPackageSkillOwners()
maxDepth = cfg.SkillMaxDepth()
}
return skill.New(skill.Options{
ProjectRoot: strings.TrimSpace(workspaceRoot),
CustomPaths: custom,
PluginPaths: pluginPaths,
ExcludedPaths: excluded,
MaxDepth: maxDepth,
})
}
func suggestionName(given, source, fallback string) string {
if name := asciiSlug(given); name != "" {
return name
}
if name := asciiSlug(source); name != "" {
return name
}
if name := asciiSlug(fallback); name != "" {
return name
}
return "candidate"
}
// acceptedSuggestionName preserves the candidate name generated at suggestion
// time. Re-running asciiSlug here would truncate back to 56 chars and strip
// the uniqueness hash suffix, re-colliding long common-prefix candidates at
// save time even though their generated Name/ID differed. A well-formed slug
// is kept verbatim (memory.Store.Save's own slug pass cleans but never
// truncates); anything else falls back to deriving from the description as
// before.
func acceptedSuggestionName(given, desc string) string {
if isWellFormedSlug(given) {
return given
}
return suggestionName("", desc, "memory-candidate")
}
// isWellFormedSlug reports whether s already matches asciiSlug's output shape
// (lowercase ASCII letters/digits separated by single dashes), possibly with a
// hash suffix beyond asciiSlug's 56-char cap.
func isWellFormedSlug(s string) bool {
if s == "" || len(s) > 128 || s[0] == '-' || s[len(s)-1] == '-' {
return false
}
prevDash := false
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
prevDash = false
case r == '-':
if prevDash {
return false
}
prevDash = true
default:
return false
}
}
return true
}
// stableSuggestionName returns a slug that is unique per source text and stable
// across suggestion refreshes. asciiSlug drops non-ASCII runes and truncates to
// 56 chars, so two CJK-only statements (or long English statements sharing a
// prefix) can collide — colliding Names make Store.Save overwrite the earlier
// memory, and colliding IDs cross-wire the frontend's accepted-state map.
//
// When the ASCII slug is short enough that truncation cannot have caused a
// collision, it is returned as-is for backward compatibility with old-version
// candidate names. The hash suffix is only appended when the slug fell back to
// the fallback (non-ASCII source) or when the slug hit the 56-char truncation
// boundary.
func stableSuggestionName(source, fallback string) string {
slug := asciiSlug(source)
if slug != "" && len(slug) < 56 {
return slug
}
base := suggestionName("", source, fallback)
h := fnv.New32a()
_, _ = h.Write([]byte(source))
return fmt.Sprintf("%s-%08x", base, h.Sum32())
}
func asciiSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
lastDash := false
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastDash = false
case r == '-' || r == '_' || r == '.':
if b.Len() > 0 && !lastDash {
b.WriteRune('-')
lastDash = true
}
case unicode.IsSpace(r):
if b.Len() > 0 && !lastDash {
b.WriteRune('-')
lastDash = true
}
}
if b.Len() >= 56 {
break
}
}
return strings.Trim(b.String(), "-")
}
func suggestionTitle(s, fallback string) string {
title := truncateRunes(oneLine(s), 64)
if title == "" {
return fallback
}
return title
}
func sessionEvidence(sess suggestionSession, text string) string {
label := sess.ID
if label == "" {
label = filepath.Base(sess.Path)
}
return label + ": " + truncateRunes(oneLine(text), 160)
}
func normalizeSuggestionKey(s string) string {
return strings.ToLower(strings.Join(strings.Fields(s), " "))
}
func oneLine(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func truncateRunes(s string, n int) string {
if n <= 0 {
return ""
}
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
return string(r[:n-1]) + "..."
}
func hasAny(hay string, needles ...string) bool {
hay = strings.ToLower(hay)
for _, needle := range needles {
if strings.Contains(hay, strings.ToLower(needle)) {
return true
}
}
return false
}