| 1 | package memory |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "regexp" |
| 8 | "sort" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "gopkg.in/yaml.v3" |
| 14 | |
| 15 | "reasonix/internal/config" |
| 16 | fileencoding "reasonix/internal/fileutil/encoding" |
| 17 | "reasonix/internal/frontmatter" |
| 18 | ) |
| 19 | |
| 20 | // Store is the scoped auto-memory store: project and global directories of |
| 21 | // one-fact-per-file Markdown notes, each with a MEMORY.md index. |
| 22 | // The model maintains it through the `remember` tool; the index loads into the |
| 23 | // cached system-prompt prefix at boot so the model always knows what it has |
| 24 | // saved, and reads individual facts on demand with the `memory` tool. The whole |
| 25 | // thing is plain files the user can edit by hand. |
| 26 | // |
| 27 | // Scope and type are independent: callers choose whether a fact belongs to the |
| 28 | // current project or every project, while Type only classifies its contents. |
| 29 | // List() and Index() merge both directories so every session sees the full set. |
| 30 | type Store struct { |
| 31 | Dir string // ...reasonix/projects/<slug>/memory |
| 32 | GlobalDir string // ...reasonix/memory/global (shared across projects) |
| 33 | } |
| 34 | |
| 35 | // Type classifies a memory, mirroring the auto-memory taxonomy. |
| 36 | type Type string |
| 37 | |
| 38 | const ( |
| 39 | TypeUser Type = "user" // who the user is: role, preferences, expertise |
| 40 | TypeFeedback Type = "feedback" // guidance on how to work (with why + how-to-apply) |
| 41 | TypeProject Type = "project" // ongoing work / goals / constraints not in the code |
| 42 | TypeReference Type = "reference" // pointers to external resources (URLs, tickets) |
| 43 | ) |
| 44 | |
| 45 | // validTypes is the closed set the `remember` tool accepts; anything else |
| 46 | // normalises to TypeProject. |
| 47 | var validTypes = map[Type]bool{TypeUser: true, TypeFeedback: true, TypeProject: true, TypeReference: true} |
| 48 | |
| 49 | // NormalizeType coerces an arbitrary string to a known Type, defaulting to |
| 50 | // TypeProject so a sloppy tool argument never blocks a save. |
| 51 | func NormalizeType(s string) Type { |
| 52 | t := Type(strings.ToLower(strings.TrimSpace(s))) |
| 53 | if validTypes[t] { |
| 54 | return t |
| 55 | } |
| 56 | return TypeProject |
| 57 | } |
| 58 | |
| 59 | // FactScope controls where an auto-memory fact is active. It is intentionally |
| 60 | // separate from Type: project feedback should not silently become global merely |
| 61 | // because it is classified as feedback. |
| 62 | type FactScope string |
| 63 | |
| 64 | const ( |
| 65 | FactScopeProject FactScope = "project" |
| 66 | FactScopeGlobal FactScope = "global" |
| 67 | ) |
| 68 | |
| 69 | // NormalizeFactScope defaults to the current project. Global memory must be an |
| 70 | // explicit choice because it affects every workspace. |
| 71 | func NormalizeFactScope(s string) FactScope { |
| 72 | if FactScope(strings.ToLower(strings.TrimSpace(s))) == FactScopeGlobal { |
| 73 | return FactScopeGlobal |
| 74 | } |
| 75 | return FactScopeProject |
| 76 | } |
| 77 | |
| 78 | // Memory is one stored fact. |
| 79 | type Memory struct { |
| 80 | ID string // immutable identity; Name may change without changing ID |
| 81 | Revision int // monotonic content revision, starting at 1 |
| 82 | CreatedAt time.Time |
| 83 | UpdatedAt time.Time |
| 84 | Name string // kebab-case slug; also the file stem (<name>.md) |
| 85 | Title string // human-readable index label; falls back to a de-kebabed Name |
| 86 | Description string // one-line summary used for the index and recall |
| 87 | Type Type |
| 88 | Scope FactScope // project by default; global only when explicitly requested |
| 89 | Body string // the fact itself (Markdown) |
| 90 | } |
| 91 | |
| 92 | // ArchivedMemory is a saved fact that has been removed from active memory but |
| 93 | // kept on disk for traceability. |
| 94 | type ArchivedMemory struct { |
| 95 | Memory |
| 96 | Path string |
| 97 | ArchivedAt time.Time |
| 98 | } |
| 99 | |
| 100 | // StoreFor resolves the auto-memory directory for a project working dir under |
| 101 | // Reasonix home, e.g. ~/.reasonix/projects/-Users-me-proj/memory. |
| 102 | // A "" userDir (config dir unresolvable) yields a zero Store, which all methods |
| 103 | // treat as a disabled no-op. |
| 104 | func StoreFor(userDir, cwd string) Store { |
| 105 | if userDir == "" { |
| 106 | return Store{} |
| 107 | } |
| 108 | return Store{ |
| 109 | Dir: filepath.Join(userDir, "projects", config.WorkspaceSlug(absOf(cwd)), "memory"), |
| 110 | GlobalDir: filepath.Join(userDir, "memory", "global"), |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // DirFor returns the directory for an explicit fact scope. When GlobalDir is |
| 115 | // unavailable, global writes fall back to Dir rather than being dropped. |
| 116 | func (s Store) DirFor(scope FactScope) string { |
| 117 | if s.GlobalDir != "" && NormalizeFactScope(string(scope)) == FactScopeGlobal { |
| 118 | return s.GlobalDir |
| 119 | } |
| 120 | return s.Dir |
| 121 | } |
| 122 | |
| 123 | // indexFile is the human-readable index of saved memories. |
| 124 | const indexFile = "MEMORY.md" |
| 125 | |
| 126 | // dirs returns the directories to read from, in order: GlobalDir first (shared |
| 127 | // memories), then Dir (project-specific). |
| 128 | func (s Store) dirs() []string { |
| 129 | if s.GlobalDir != "" && s.GlobalDir != s.Dir { |
| 130 | return []string{s.GlobalDir, s.Dir} |
| 131 | } |
| 132 | return []string{s.Dir} |
| 133 | } |
| 134 | |
| 135 | // Index returns the MEMORY.md contents (the per-line index of saved memories), |
| 136 | // or "" if there are none yet. This is what loads into the cached prefix. |
| 137 | // When both GlobalDir and Dir have indexes, they are merged with deduplication |
| 138 | // (global first). |
| 139 | func (s Store) Index() string { |
| 140 | memories := s.List() |
| 141 | if len(memories) == 0 { |
| 142 | return "" |
| 143 | } |
| 144 | var b strings.Builder |
| 145 | for _, memory := range memories { |
| 146 | b.WriteString(renderIndexLine(memory.Name, memory)) |
| 147 | b.WriteString("\n") |
| 148 | } |
| 149 | return b.String() |
| 150 | } |
| 151 | |
| 152 | // Path returns the absolute file path a memory with the given name lives at. |
| 153 | // It checks GlobalDir first, then Dir, returning the first match. If no file |
| 154 | // exists yet, it returns the path in Dir (the default project scope). |
| 155 | func (s Store) Path(name string) string { |
| 156 | if _, path, ok := s.findActive(name); ok { |
| 157 | return path |
| 158 | } |
| 159 | ref := parseMemoryReference(name) |
| 160 | stem := ref.name + ".md" |
| 161 | if ref.qualified { |
| 162 | p, err := safeJoin(s.DirFor(ref.scope), stem) |
| 163 | if err != nil { |
| 164 | return "" |
| 165 | } |
| 166 | return p |
| 167 | } |
| 168 | for _, dir := range s.dirs() { |
| 169 | if dir == "" { |
| 170 | continue |
| 171 | } |
| 172 | p, err := safeJoin(dir, stem) |
| 173 | if err != nil { |
| 174 | continue |
| 175 | } |
| 176 | if _, err := os.Stat(p); err == nil { |
| 177 | return p |
| 178 | } |
| 179 | } |
| 180 | p, sjErr := safeJoin(s.Dir, stem) |
| 181 | if sjErr != nil { |
| 182 | return "" |
| 183 | } |
| 184 | return p |
| 185 | } |
| 186 | |
| 187 | // Save writes (or overwrites) a memory file and refreshes its MEMORY.md index |
| 188 | // line. It is the single mutation entry point — the `remember` tool, the desktop |
| 189 | // editor, and any future importer all go through here so the index never drifts |
| 190 | // from the files. Returns the path written. |
| 191 | func (s Store) Save(m Memory) (string, error) { |
| 192 | result, err := s.SaveWithOptions(m, SaveOptions{}) |
| 193 | return result.Path, err |
| 194 | } |
| 195 | |
| 196 | // Archive removes a memory from the active store and moves its file under |
| 197 | // .archive/ for traceability. A missing file is not an error; the goal state |
| 198 | // (not active) already holds. It returns the archive path, or "" when no file |
| 199 | // existed to archive. |
| 200 | // When both GlobalDir and Dir exist, it archives from every directory the |
| 201 | // memory appears in (handles migration duplicates). |
| 202 | func (s Store) Archive(name string) (string, error) { |
| 203 | memoryStoreMutationMu.Lock() |
| 204 | defer memoryStoreMutationMu.Unlock() |
| 205 | return s.archiveLocked(name) |
| 206 | } |
| 207 | |
| 208 | func (s Store) archiveLocked(name string) (string, error) { |
| 209 | if s.Dir == "" && s.GlobalDir == "" { |
| 210 | return "", fmt.Errorf("memory store unavailable (no user config dir)") |
| 211 | } |
| 212 | ref := strings.TrimSpace(name) |
| 213 | parsed := parseMemoryReference(ref) |
| 214 | if active, path, ok := s.findActive(ref); ok && ref == active.ID { |
| 215 | return archiveMemoryInDir(filepath.Dir(path), active.Name) |
| 216 | } else if ok && parsed.qualified { |
| 217 | return archiveMemoryInDir(filepath.Dir(path), active.Name) |
| 218 | } else if ok { |
| 219 | name = active.Name |
| 220 | } else if parsed.qualified { |
| 221 | if parsed.name == "" { |
| 222 | return "", fmt.Errorf("memory needs a name") |
| 223 | } |
| 224 | return archiveMemoryInDir(s.DirFor(parsed.scope), parsed.name) |
| 225 | } else { |
| 226 | name = slug(name) |
| 227 | } |
| 228 | if name == "" { |
| 229 | return "", fmt.Errorf("memory needs a name") |
| 230 | } |
| 231 | var lastPath string |
| 232 | for _, dir := range s.dirs() { |
| 233 | if dir == "" { |
| 234 | continue |
| 235 | } |
| 236 | p, err := archiveInDir(dir, name) |
| 237 | if err != nil { |
| 238 | return "", err |
| 239 | } |
| 240 | if p != "" || indexContainsIn(dir, name) { |
| 241 | if err := flushIndexIn(dir, indexLinesExceptIn(dir, name)); err != nil { |
| 242 | return "", err |
| 243 | } |
| 244 | } |
| 245 | if p != "" { |
| 246 | lastPath = p |
| 247 | } |
| 248 | } |
| 249 | return lastPath, nil |
| 250 | } |
| 251 | |
| 252 | func archiveMemoryInDir(dir, name string) (string, error) { |
| 253 | path, err := archiveInDir(dir, name) |
| 254 | if err != nil { |
| 255 | return "", err |
| 256 | } |
| 257 | if path != "" || indexContainsIn(dir, name) { |
| 258 | if err := flushIndexIn(dir, indexLinesExceptIn(dir, name)); err != nil { |
| 259 | return "", err |
| 260 | } |
| 261 | } |
| 262 | return path, nil |
| 263 | } |
| 264 | |
| 265 | // Delete removes a memory from the active store and its MEMORY.md line — the |
| 266 | // model's `forget` path and the user's way to prune a stale fact. It archives |
| 267 | // the file instead of permanently deleting it so wrong memories remain |
| 268 | // traceable. A missing file is not an error; the goal state (gone) holds either |
| 269 | // way. |
| 270 | func (s Store) Delete(name string) error { |
| 271 | _, err := s.Archive(name) |
| 272 | return err |
| 273 | } |
| 274 | |
| 275 | func archiveInDir(dir, name string) (string, error) { |
| 276 | root, err := os.OpenRoot(dir) |
| 277 | if os.IsNotExist(err) { |
| 278 | return "", nil |
| 279 | } |
| 280 | if err != nil { |
| 281 | return "", err |
| 282 | } |
| 283 | defer root.Close() |
| 284 | |
| 285 | file := name + ".md" |
| 286 | if _, err := root.Stat(file); err != nil { |
| 287 | if os.IsNotExist(err) { |
| 288 | return "", nil |
| 289 | } |
| 290 | return "", err |
| 291 | } |
| 292 | if err := root.MkdirAll(".archive", 0o755); err != nil { |
| 293 | return "", err |
| 294 | } |
| 295 | dest, err := archivePath(root, name, time.Now().UTC()) |
| 296 | if err != nil { |
| 297 | return "", err |
| 298 | } |
| 299 | if err := renameMemoryFile(root, file, dest); err != nil { |
| 300 | return "", err |
| 301 | } |
| 302 | out, err := safeJoin(dir, dest) |
| 303 | if err != nil { |
| 304 | return "", err |
| 305 | } |
| 306 | return out, nil |
| 307 | } |
| 308 | |
| 309 | func archivePath(root *os.Root, name string, when time.Time) (string, error) { |
| 310 | stem := when.Format("20060102-150405.000") + "-" + name |
| 311 | path := filepath.Join(".archive", stem+".md") |
| 312 | if _, err := root.Stat(path); os.IsNotExist(err) { |
| 313 | return path, nil |
| 314 | } else if err != nil { |
| 315 | return "", err |
| 316 | } |
| 317 | for i := 1; ; i++ { |
| 318 | path = filepath.Join(".archive", fmt.Sprintf("%s-%d.md", stem, i)) |
| 319 | if _, err := root.Stat(path); os.IsNotExist(err) { |
| 320 | return path, nil |
| 321 | } else if err != nil { |
| 322 | return "", err |
| 323 | } |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | func safeJoin(base, name string) (string, error) { |
| 328 | if base == "" { |
| 329 | return "", fmt.Errorf("memory store unavailable (no user config dir)") |
| 330 | } |
| 331 | if !filepath.IsLocal(name) { |
| 332 | return "", fmt.Errorf("memory path escapes store: %s", name) |
| 333 | } |
| 334 | baseAbs, err := filepath.Abs(base) |
| 335 | if err != nil { |
| 336 | return "", err |
| 337 | } |
| 338 | path := filepath.Join(baseAbs, name) |
| 339 | pathAbs, err := filepath.Abs(path) |
| 340 | if err != nil { |
| 341 | return "", err |
| 342 | } |
| 343 | rel, err := filepath.Rel(baseAbs, pathAbs) |
| 344 | if err != nil { |
| 345 | return "", err |
| 346 | } |
| 347 | if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { |
| 348 | return "", fmt.Errorf("memory path escapes store: %s", name) |
| 349 | } |
| 350 | return pathAbs, nil |
| 351 | } |
| 352 | |
| 353 | func renameMemoryFile(root *os.Root, path, dest string) error { |
| 354 | err := root.Rename(path, dest) |
| 355 | if err == nil || os.IsNotExist(err) { |
| 356 | return nil |
| 357 | } |
| 358 | if !os.IsPermission(err) { |
| 359 | return err |
| 360 | } |
| 361 | repairOwnerWrite(root, path, false) |
| 362 | repairOwnerWrite(root, filepath.Dir(path), true) |
| 363 | repairOwnerWrite(root, filepath.Dir(dest), true) |
| 364 | err = root.Rename(path, dest) |
| 365 | if err == nil || os.IsNotExist(err) { |
| 366 | return nil |
| 367 | } |
| 368 | return err |
| 369 | } |
| 370 | |
| 371 | func repairOwnerWrite(root *os.Root, path string, dir bool) { |
| 372 | info, err := root.Stat(path) |
| 373 | if err != nil { |
| 374 | return |
| 375 | } |
| 376 | need := os.FileMode(0o600) |
| 377 | if dir { |
| 378 | need = 0o700 |
| 379 | } |
| 380 | _ = root.Chmod(path, info.Mode().Perm()|need) |
| 381 | } |
| 382 | |
| 383 | // memoryFrontmatter is the YAML shape render emits, mirroring the auto-memory |
| 384 | // shape (name / description / metadata.type) so the files are interchangeable |
| 385 | // with that ecosystem and re-readable by loadMemory. Marshaled by yaml.v3 so a |
| 386 | // title or description containing ": ", '#', or quotes is escaped instead of |
| 387 | // corrupting the block — frontmatter.Split returns an EMPTY map for |
| 388 | // unparseable YAML, which would silently drop the memory's name/title/type on |
| 389 | // the next load. Plain values render byte-identically to the previous |
| 390 | // hand-built format. |
| 391 | type memoryFrontmatter struct { |
| 392 | ID string `yaml:"id,omitempty"` |
| 393 | Revision int `yaml:"revision,omitempty"` |
| 394 | CreatedAt string `yaml:"created_at,omitempty"` |
| 395 | UpdatedAt string `yaml:"updated_at,omitempty"` |
| 396 | Name string `yaml:"name"` |
| 397 | Title string `yaml:"title,omitempty"` |
| 398 | Desc string `yaml:"description"` |
| 399 | Metadata struct { |
| 400 | Type string `yaml:"type"` |
| 401 | FactType string `yaml:"fact_type,omitempty"` |
| 402 | Scope string `yaml:"scope"` |
| 403 | } `yaml:"metadata"` |
| 404 | } |
| 405 | |
| 406 | // render serializes a memory to frontmatter + body. |
| 407 | func render(m Memory, name string) string { |
| 408 | fm := memoryFrontmatter{ |
| 409 | ID: m.ID, Revision: m.Revision, Name: name, Title: oneLine(m.Title), Desc: oneLine(m.Description), |
| 410 | } |
| 411 | if !m.CreatedAt.IsZero() { |
| 412 | fm.CreatedAt = m.CreatedAt.UTC().Format(time.RFC3339Nano) |
| 413 | } |
| 414 | if !m.UpdatedAt.IsZero() { |
| 415 | fm.UpdatedAt = m.UpdatedAt.UTC().Format(time.RFC3339Nano) |
| 416 | } |
| 417 | actualType := NormalizeType(string(m.Type)) |
| 418 | scope := NormalizeFactScope(string(m.Scope)) |
| 419 | compatType := previousReleaseRoutingType(actualType, scope) |
| 420 | fm.Metadata.Type = string(compatType) |
| 421 | if compatType != actualType { |
| 422 | fm.Metadata.FactType = string(actualType) |
| 423 | } |
| 424 | fm.Metadata.Scope = string(scope) |
| 425 | var b strings.Builder |
| 426 | b.WriteString("---\n") |
| 427 | enc := yaml.NewEncoder(&b) |
| 428 | enc.SetIndent(2) |
| 429 | // Encoding a flat struct of strings cannot fail. |
| 430 | _ = enc.Encode(fm) |
| 431 | _ = enc.Close() |
| 432 | b.WriteString("---\n\n") |
| 433 | b.WriteString(strings.TrimSpace(m.Body)) |
| 434 | b.WriteString("\n") |
| 435 | return b.String() |
| 436 | } |
| 437 | |
| 438 | // previousReleaseRoutingType keeps scope safe when an older Reasonix binary |
| 439 | // shares the same state directory. Previous releases routed user/feedback to |
| 440 | // GlobalDir and project/reference to Dir, so metadata.type remains a compatible |
| 441 | // routing hint while metadata.fact_type preserves the independent new category. |
| 442 | func previousReleaseRoutingType(actual Type, scope FactScope) Type { |
| 443 | if scope == FactScopeGlobal { |
| 444 | if actual == TypeUser || actual == TypeFeedback { |
| 445 | return actual |
| 446 | } |
| 447 | return TypeUser |
| 448 | } |
| 449 | if actual == TypeProject || actual == TypeReference { |
| 450 | return actual |
| 451 | } |
| 452 | return TypeProject |
| 453 | } |
| 454 | |
| 455 | // indexLineRe matches a managed index line so reindex/Delete can target the line |
| 456 | // for one memory by its filename without disturbing the rest of a hand-edited |
| 457 | // MEMORY.md. |
| 458 | var indexLineRe = regexp.MustCompile(`(?m)^\s*-\s\[.+?\]\(([^)]+)\.md\)\s*—\s.*$`) |
| 459 | |
| 460 | // indexLinesExceptIn returns the managed MEMORY.md lines keyed by filename stem |
| 461 | // in the given directory, dropping the entry for name (a missing index → empty map). |
| 462 | func indexLinesExceptIn(dir, name string) map[string]string { |
| 463 | existing, _ := fileencoding.ReadFileUTF8(filepath.Join(dir, indexFile)) |
| 464 | keep := map[string]string{} |
| 465 | for _, line := range strings.Split(string(existing), "\n") { |
| 466 | if mt := indexLineRe.FindStringSubmatch(line); mt != nil && mt[1] != name { |
| 467 | keep[mt[1]] = strings.TrimRight(line, "\r") |
| 468 | } |
| 469 | } |
| 470 | return keep |
| 471 | } |
| 472 | |
| 473 | func indexContainsIn(dir, name string) bool { |
| 474 | existing, err := fileencoding.ReadFileUTF8(filepath.Join(dir, indexFile)) |
| 475 | if err != nil { |
| 476 | return false |
| 477 | } |
| 478 | for _, line := range strings.Split(string(existing), "\n") { |
| 479 | if mt := indexLineRe.FindStringSubmatch(line); mt != nil && mt[1] == name { |
| 480 | return true |
| 481 | } |
| 482 | } |
| 483 | return false |
| 484 | } |
| 485 | |
| 486 | // flushIndexIn rewrites MEMORY.md in the given directory from the managed lines, |
| 487 | // preserving hand-written content. Managed lines are updated or removed, and |
| 488 | // new managed entries are appended in sorted order. |
| 489 | func flushIndexIn(dir string, lines map[string]string) error { |
| 490 | path := filepath.Join(dir, indexFile) |
| 491 | existing, _ := fileencoding.ReadFileUTF8(path) |
| 492 | processed := map[string]bool{} |
| 493 | var preserved strings.Builder |
| 494 | preservedEmpty := true |
| 495 | for _, line := range strings.Split(string(existing), "\n") { |
| 496 | trimmed := strings.TrimRight(line, "\r") |
| 497 | if mt := indexLineRe.FindStringSubmatch(trimmed); mt != nil { |
| 498 | name := mt[1] |
| 499 | if fresh, ok := lines[name]; ok { |
| 500 | preserved.WriteString(fresh) |
| 501 | preserved.WriteString("\n") |
| 502 | processed[name] = true |
| 503 | preservedEmpty = false |
| 504 | } |
| 505 | continue |
| 506 | } |
| 507 | preserved.WriteString(trimmed) |
| 508 | preserved.WriteString("\n") |
| 509 | if strings.TrimSpace(trimmed) != "" { |
| 510 | preservedEmpty = false |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | names := make([]string, 0, len(lines)) |
| 515 | for n := range lines { |
| 516 | if !processed[n] { |
| 517 | names = append(names, n) |
| 518 | } |
| 519 | } |
| 520 | sort.Strings(names) |
| 521 | |
| 522 | var b strings.Builder |
| 523 | if preservedEmpty && len(names) > 0 { |
| 524 | b.WriteString("# Memory\n\n") |
| 525 | } else { |
| 526 | b.WriteString(preserved.String()) |
| 527 | } |
| 528 | for _, n := range names { |
| 529 | b.WriteString(lines[n]) |
| 530 | b.WriteString("\n") |
| 531 | } |
| 532 | result := strings.TrimRight(b.String(), "\n") |
| 533 | if result == "" { |
| 534 | return os.WriteFile(path, []byte(""), 0o644) |
| 535 | } |
| 536 | return os.WriteFile(path, []byte(result+"\n"), 0o644) |
| 537 | } |
| 538 | |
| 539 | // reindexIn rewrites the MEMORY.md line for name in the given directory, |
| 540 | // preserving every other managed line. |
| 541 | func reindexIn(dir, name string, m Memory) error { |
| 542 | lines := indexLinesExceptIn(dir, name) |
| 543 | lines[name] = renderIndexLine(name, m) |
| 544 | return flushIndexIn(dir, lines) |
| 545 | } |
| 546 | |
| 547 | func renderIndexLine(name string, m Memory) string { |
| 548 | return fmt.Sprintf("- [%s](%s.md) — [%s/%s] %s", |
| 549 | displayTitle(m.Title, name), name, |
| 550 | NormalizeFactScope(string(m.Scope)), NormalizeType(string(m.Type)), oneLine(m.Description)) |
| 551 | } |
| 552 | |
| 553 | // List returns the saved memories parsed from their files, sorted by name. Used |
| 554 | // by `/memory` and the desktop memory panel. Reads from both GlobalDir and Dir, |
| 555 | // merging results. Files that fail to parse are skipped so one bad file never |
| 556 | // hides the rest. |
| 557 | func (s Store) List() []Memory { |
| 558 | if s.Dir == "" && s.GlobalDir == "" { |
| 559 | return nil |
| 560 | } |
| 561 | var out []Memory |
| 562 | seen := map[string]bool{} |
| 563 | for _, dir := range s.dirs() { |
| 564 | if dir == "" { |
| 565 | continue |
| 566 | } |
| 567 | entries, err := os.ReadDir(dir) |
| 568 | if err != nil { |
| 569 | continue |
| 570 | } |
| 571 | for _, e := range entries { |
| 572 | if e.IsDir() || e.Name() == indexFile || !strings.HasSuffix(e.Name(), ".md") { |
| 573 | continue |
| 574 | } |
| 575 | if m, ok := loadMemory(filepath.Join(dir, e.Name())); ok { |
| 576 | if m.Scope == "" { |
| 577 | m.Scope = s.scopeForDir(dir) |
| 578 | } |
| 579 | if !seen[m.Name] { |
| 580 | out = append(out, m) |
| 581 | seen[m.Name] = true |
| 582 | } |
| 583 | } |
| 584 | } |
| 585 | } |
| 586 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) |
| 587 | return out |
| 588 | } |
| 589 | |
| 590 | // ListAll returns every active fact from both scopes without the legacy |
| 591 | // name-based deduplication performed by List. Callers that understand scope can |
| 592 | // use it to resolve project-over-global overrides without hiding either source. |
| 593 | func (s Store) ListAll() []Memory { |
| 594 | if s.Dir == "" && s.GlobalDir == "" { |
| 595 | return nil |
| 596 | } |
| 597 | var out []Memory |
| 598 | for _, dir := range s.dirs() { |
| 599 | if dir == "" { |
| 600 | continue |
| 601 | } |
| 602 | entries, err := os.ReadDir(dir) |
| 603 | if err != nil { |
| 604 | continue |
| 605 | } |
| 606 | for _, entry := range entries { |
| 607 | if entry.IsDir() || entry.Name() == indexFile || !strings.HasSuffix(entry.Name(), ".md") { |
| 608 | continue |
| 609 | } |
| 610 | memory, ok := loadMemory(filepath.Join(dir, entry.Name())) |
| 611 | if !ok { |
| 612 | continue |
| 613 | } |
| 614 | if memory.Scope == "" { |
| 615 | memory.Scope = s.scopeForDir(dir) |
| 616 | } |
| 617 | out = append(out, memory) |
| 618 | } |
| 619 | } |
| 620 | sort.Slice(out, func(i, j int) bool { |
| 621 | if out[i].Name != out[j].Name { |
| 622 | return out[i].Name < out[j].Name |
| 623 | } |
| 624 | if out[i].Scope != out[j].Scope { |
| 625 | return out[i].Scope < out[j].Scope |
| 626 | } |
| 627 | return out[i].ID < out[j].ID |
| 628 | }) |
| 629 | return out |
| 630 | } |
| 631 | |
| 632 | // globalGuidance snapshots global user preferences and working feedback for the |
| 633 | // stable session prefix. These categories were globally routed before explicit |
| 634 | // scopes existed, so loading their bodies preserves the established first-turn |
| 635 | // behavior without promoting project facts or references into instructions. |
| 636 | func (s Store) globalGuidance() []Memory { |
| 637 | if s.GlobalDir == "" { |
| 638 | return nil |
| 639 | } |
| 640 | entries, err := os.ReadDir(s.GlobalDir) |
| 641 | if err != nil { |
| 642 | return nil |
| 643 | } |
| 644 | var out []Memory |
| 645 | for _, e := range entries { |
| 646 | if e.IsDir() || e.Name() == indexFile || !strings.HasSuffix(e.Name(), ".md") { |
| 647 | continue |
| 648 | } |
| 649 | m, ok := loadMemory(filepath.Join(s.GlobalDir, e.Name())) |
| 650 | if !ok { |
| 651 | continue |
| 652 | } |
| 653 | if m.Scope == "" { |
| 654 | m.Scope = FactScopeGlobal |
| 655 | } |
| 656 | if NormalizeFactScope(string(m.Scope)) != FactScopeGlobal || |
| 657 | (m.Type != TypeUser && m.Type != TypeFeedback) || strings.TrimSpace(m.Body) == "" { |
| 658 | continue |
| 659 | } |
| 660 | out = append(out, m) |
| 661 | } |
| 662 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) |
| 663 | return out |
| 664 | } |
| 665 | |
| 666 | // globalGuidanceForProject removes global guidance shadowed by an equivalent |
| 667 | // project fact before the stable session prefix is built. This makes the |
| 668 | // documented project-over-global rule deterministic on the first turn instead |
| 669 | // of depending on whether automatic recall happens to match the request. |
| 670 | func (s Store) globalGuidanceForProject() []Memory { |
| 671 | guidance := s.globalGuidance() |
| 672 | if len(guidance) == 0 || s.Dir == "" { |
| 673 | return guidance |
| 674 | } |
| 675 | projectKeys := map[string]bool{} |
| 676 | for _, fact := range s.ListAll() { |
| 677 | if NormalizeFactScope(string(fact.Scope)) != FactScopeProject { |
| 678 | continue |
| 679 | } |
| 680 | for _, key := range recallIdentityKeys(fact) { |
| 681 | if strings.HasSuffix(key, ":") { |
| 682 | continue |
| 683 | } |
| 684 | projectKeys[key] = true |
| 685 | } |
| 686 | } |
| 687 | if len(projectKeys) == 0 { |
| 688 | return guidance |
| 689 | } |
| 690 | out := guidance[:0] |
| 691 | for _, fact := range guidance { |
| 692 | shadowed := false |
| 693 | for _, key := range recallIdentityKeys(fact) { |
| 694 | if projectKeys[key] { |
| 695 | shadowed = true |
| 696 | break |
| 697 | } |
| 698 | } |
| 699 | if !shadowed { |
| 700 | out = append(out, fact) |
| 701 | } |
| 702 | } |
| 703 | return out |
| 704 | } |
| 705 | |
| 706 | // ListArchived returns archived memories parsed from .archive/, newest first. |
| 707 | // Archived files stay out of List() and the prompt index, so stale facts remain |
| 708 | // inspectable without being reused as active truth. Reads from both GlobalDir |
| 709 | // and Dir. |
| 710 | func (s Store) ListArchived() []ArchivedMemory { |
| 711 | if s.Dir == "" && s.GlobalDir == "" { |
| 712 | return nil |
| 713 | } |
| 714 | var out []ArchivedMemory |
| 715 | for _, base := range s.dirs() { |
| 716 | if base == "" { |
| 717 | continue |
| 718 | } |
| 719 | dir := filepath.Join(base, ".archive") |
| 720 | entries, err := os.ReadDir(dir) |
| 721 | if err != nil { |
| 722 | continue |
| 723 | } |
| 724 | for _, e := range entries { |
| 725 | if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { |
| 726 | continue |
| 727 | } |
| 728 | path := filepath.Join(dir, e.Name()) |
| 729 | m, ok := loadMemory(path) |
| 730 | if !ok { |
| 731 | continue |
| 732 | } |
| 733 | if m.Scope == "" { |
| 734 | m.Scope = s.scopeForDir(base) |
| 735 | } |
| 736 | when := archiveTimeFromName(e.Name()) |
| 737 | if when.IsZero() { |
| 738 | if info, err := e.Info(); err == nil { |
| 739 | when = info.ModTime() |
| 740 | } |
| 741 | } |
| 742 | out = append(out, ArchivedMemory{Memory: m, Path: path, ArchivedAt: when}) |
| 743 | } |
| 744 | } |
| 745 | sort.Slice(out, func(i, j int) bool { |
| 746 | if !out[i].ArchivedAt.Equal(out[j].ArchivedAt) { |
| 747 | return out[i].ArchivedAt.After(out[j].ArchivedAt) |
| 748 | } |
| 749 | if out[i].Name != out[j].Name { |
| 750 | return out[i].Name < out[j].Name |
| 751 | } |
| 752 | return out[i].Path < out[j].Path |
| 753 | }) |
| 754 | return out |
| 755 | } |
| 756 | |
| 757 | func archiveTimeFromName(name string) time.Time { |
| 758 | const stampLen = len("20060102-150405.000") |
| 759 | if len(name) <= stampLen || name[stampLen] != '-' { |
| 760 | return time.Time{} |
| 761 | } |
| 762 | when, err := time.ParseInLocation("20060102-150405.000", name[:stampLen], time.UTC) |
| 763 | if err != nil { |
| 764 | return time.Time{} |
| 765 | } |
| 766 | return when |
| 767 | } |
| 768 | |
| 769 | // loadMemory parses one fact file back into a Memory. It tolerates the minimal |
| 770 | // frontmatter render writes; a file without frontmatter still loads with its |
| 771 | // body and a name derived from the filename. |
| 772 | func loadMemory(path string) (Memory, bool) { |
| 773 | b, err := fileencoding.ReadFileUTF8(path) |
| 774 | if err != nil { |
| 775 | return Memory{}, false |
| 776 | } |
| 777 | fm, body := splitFrontmatter(string(b)) |
| 778 | m := Memory{ |
| 779 | ID: fm["id"], |
| 780 | Revision: parsePositiveInt(fm["revision"]), |
| 781 | CreatedAt: parseMemoryTime(fm["created_at"]), |
| 782 | UpdatedAt: parseMemoryTime(fm["updated_at"]), |
| 783 | Name: fm["name"], |
| 784 | Title: fm["title"], |
| 785 | Description: fm["description"], |
| 786 | Type: persistedFactType(fm), |
| 787 | Scope: factScopeFromFrontmatter(fm["scope"]), |
| 788 | Body: strings.TrimSpace(body), |
| 789 | } |
| 790 | if m.Name == "" { |
| 791 | m.Name = strings.TrimSuffix(filepath.Base(path), ".md") |
| 792 | } |
| 793 | if m.ID == "" { |
| 794 | m.ID = legacyMemoryID(m.Name, legacyIdentityScope(m)) |
| 795 | } |
| 796 | if m.Revision <= 0 { |
| 797 | m.Revision = 1 |
| 798 | } |
| 799 | if info, err := os.Stat(path); err == nil { |
| 800 | if m.CreatedAt.IsZero() { |
| 801 | m.CreatedAt = info.ModTime().UTC() |
| 802 | } |
| 803 | if m.UpdatedAt.IsZero() { |
| 804 | m.UpdatedAt = info.ModTime().UTC() |
| 805 | } |
| 806 | } |
| 807 | return m, true |
| 808 | } |
| 809 | |
| 810 | func legacyIdentityScope(m Memory) FactScope { |
| 811 | if m.Scope != "" { |
| 812 | return NormalizeFactScope(string(m.Scope)) |
| 813 | } |
| 814 | if m.Type == TypeUser || m.Type == TypeFeedback { |
| 815 | return FactScopeGlobal |
| 816 | } |
| 817 | return FactScopeProject |
| 818 | } |
| 819 | |
| 820 | func parsePositiveInt(value string) int { |
| 821 | n, err := strconv.Atoi(strings.TrimSpace(value)) |
| 822 | if err != nil || n < 1 { |
| 823 | return 0 |
| 824 | } |
| 825 | return n |
| 826 | } |
| 827 | |
| 828 | func parseMemoryTime(value string) time.Time { |
| 829 | when, _ := time.Parse(time.RFC3339Nano, strings.TrimSpace(value)) |
| 830 | return when |
| 831 | } |
| 832 | |
| 833 | func persistedFactType(fm map[string]string) Type { |
| 834 | if t := Type(strings.ToLower(strings.TrimSpace(fm["fact_type"]))); validTypes[t] { |
| 835 | return t |
| 836 | } |
| 837 | return NormalizeType(fm["type"]) |
| 838 | } |
| 839 | |
| 840 | func factScopeFromFrontmatter(s string) FactScope { |
| 841 | switch FactScope(strings.ToLower(strings.TrimSpace(s))) { |
| 842 | case FactScopeProject: |
| 843 | return FactScopeProject |
| 844 | case FactScopeGlobal: |
| 845 | return FactScopeGlobal |
| 846 | default: |
| 847 | return "" |
| 848 | } |
| 849 | } |
| 850 | |
| 851 | func (s Store) scopeForDir(dir string) FactScope { |
| 852 | if s.GlobalDir != "" && sameDir(dir, s.GlobalDir) { |
| 853 | return FactScopeGlobal |
| 854 | } |
| 855 | return FactScopeProject |
| 856 | } |
| 857 | |
| 858 | func (s Store) scopeForPath(path string) FactScope { |
| 859 | return s.scopeForDir(filepath.Dir(path)) |
| 860 | } |
| 861 | |
| 862 | // splitFrontmatter is a thin wrapper; the real parser lives in |
| 863 | // internal/frontmatter. |
| 864 | func splitFrontmatter(s string) (map[string]string, string) { |
| 865 | return frontmatter.Split(s) |
| 866 | } |
| 867 | |
| 868 | // slugRe strips everything but Unicode letters and digits. |
| 869 | var slugRe = regexp.MustCompile(`[^\p{L}\p{N}]+`) |
| 870 | |
| 871 | // slug normalises a name into a kebab-case, filesystem-safe stem. The stem is |
| 872 | // bounded so `<stem>.md` stays under the 255-byte filename component limit — |
| 873 | // a name distilled from a long title/description previously failed the write |
| 874 | // with ENAMETOOLONG. Names short enough to have ever been written are |
| 875 | // returned unchanged, so existing files keep resolving. |
| 876 | func slug(s string) string { |
| 877 | stem := strings.Trim(slugRe.ReplaceAllString(strings.ToLower(strings.TrimSpace(s)), "-"), "-") |
| 878 | return config.BoundFilenameComponent(stem, 255-len(".md")) |
| 879 | } |
| 880 | |
| 881 | // oneLine collapses whitespace so a description can't break the single-line |
| 882 | // index or frontmatter format. |
| 883 | func oneLine(s string) string { |
| 884 | return strings.Join(strings.Fields(s), " ") |
| 885 | } |
| 886 | |
| 887 | // displayTitle is the index link label: the given title, or a de-kebabed name |
| 888 | // when none was supplied, so a bare slug never leaks into the index. |
| 889 | func displayTitle(title, name string) string { |
| 890 | if t := oneLine(title); t != "" { |
| 891 | return t |
| 892 | } |
| 893 | return strings.ReplaceAll(name, "-", " ") |
| 894 | } |
| 895 |