| 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 |