返回 DeepSeek-Reasonix
store.go
根目录 / internal / memory / store.go
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
895 lines GO