| 1 | // Subject keys: the knowledge-conflict model. A fact may declare which |
| 2 | // question it answers (project.package_manager, user.response_style); one |
| 3 | // scope holds at most one active value per subject, so a new answer becomes |
| 4 | // an update of the existing fact instead of a silent contradiction. |
| 5 | package memory |
| 6 | |
| 7 | import ( |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | ) |
| 13 | |
| 14 | // NormalizeSubjectKey canonicalizes a subject key: lowercase, dot-separated |
| 15 | // segments of [a-z0-9_-], empty segments collapsed. Returns "" (no subject) |
| 16 | // for keys with no usable content so punctuation typos never mint a distinct |
| 17 | // identity. |
| 18 | func NormalizeSubjectKey(s string) string { |
| 19 | var segments []string |
| 20 | for seg := range strings.SplitSeq(strings.ToLower(strings.TrimSpace(s)), ".") { |
| 21 | var b strings.Builder |
| 22 | for _, r := range seg { |
| 23 | switch { |
| 24 | case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': |
| 25 | b.WriteRune(r) |
| 26 | case r == ' ', r == '_': |
| 27 | b.WriteRune('_') |
| 28 | } |
| 29 | } |
| 30 | if cleaned := strings.Trim(b.String(), "_-"); cleaned != "" { |
| 31 | segments = append(segments, cleaned) |
| 32 | } |
| 33 | } |
| 34 | return strings.Join(segments, ".") |
| 35 | } |
| 36 | |
| 37 | // findActiveSubject returns the active fact holding a subject key within one |
| 38 | // scope's directory, if any. |
| 39 | func (s Store) findActiveSubject(scope FactScope, key string) (Memory, bool) { |
| 40 | dir := s.DirFor(scope) |
| 41 | if dir == "" || key == "" { |
| 42 | return Memory{}, false |
| 43 | } |
| 44 | entries, err := os.ReadDir(dir) |
| 45 | if err != nil { |
| 46 | return Memory{}, false |
| 47 | } |
| 48 | for _, e := range entries { |
| 49 | if e.IsDir() || e.Name() == indexFile || !strings.HasSuffix(e.Name(), ".md") { |
| 50 | continue |
| 51 | } |
| 52 | m, ok := loadMemory(filepath.Join(dir, e.Name())) |
| 53 | if !ok || NormalizeSubjectKey(m.SubjectKey) != key { |
| 54 | continue |
| 55 | } |
| 56 | if m.Scope == "" { |
| 57 | m.Scope = s.scopeForDir(dir) |
| 58 | } |
| 59 | return m, true |
| 60 | } |
| 61 | return Memory{}, false |
| 62 | } |
| 63 | |
| 64 | // validateSubjectKey enforces one active value per (scope, subject): a save |
| 65 | // claiming an already-held subject is rejected with directions to update the |
| 66 | // holder, so "npm -> pnpm" becomes a revision of one fact, not a second |
| 67 | // contradicting fact. The error is written for the model to act on. |
| 68 | func (s Store) validateSubjectKey(m Memory) error { |
| 69 | key := NormalizeSubjectKey(m.SubjectKey) |
| 70 | if key == "" { |
| 71 | return nil |
| 72 | } |
| 73 | holder, ok := s.findActiveSubject(NormalizeFactScope(string(m.Scope)), key) |
| 74 | if !ok || holder.ID == m.ID { |
| 75 | return nil |
| 76 | } |
| 77 | return fmt.Errorf( |
| 78 | "subject %q is already tracked by memory id=%s revision=%d name=%s (current: %s); "+ |
| 79 | "if this is the new value of the same fact, update that id instead of creating a second one", |
| 80 | key, holder.ID, holder.Revision, holder.Name, oneLine(firstNonEmpty(holder.Description, holder.Body))) |
| 81 | } |
| 82 | |
| 83 | func firstNonEmpty(values ...string) string { |
| 84 | for _, v := range values { |
| 85 | if strings.TrimSpace(v) != "" { |
| 86 | return v |
| 87 | } |
| 88 | } |
| 89 | return "" |
| 90 | } |
| 91 |