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