| 1 | package memory |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/instruction" |
| 10 | ) |
| 11 | |
| 12 | // Set is everything memory loaded for one session: the hierarchical docs and a |
| 13 | // handle to the auto-memory store (whose index is captured at load time). It is |
| 14 | // assembled once at boot and folded into the system prompt by Compose. CWD and |
| 15 | // UserDir are retained so the controller can resolve quick-add targets without |
| 16 | // re-deriving discovery context. |
| 17 | type Set struct { |
| 18 | Docs []Source // REASONIX.md / AGENTS.md, ascending precedence |
| 19 | GlobalGuidance []Memory // stable snapshot of global user/feedback bodies |
| 20 | Store Store // auto-memory store (may be a zero/disabled Store) |
| 21 | Index string // MEMORY.md contents at load time |
| 22 | CWD string // project working dir used for discovery |
| 23 | UserDir string // user config root (may be "") |
| 24 | InstructionDiagnostics []instruction.Diagnostic |
| 25 | } |
| 26 | |
| 27 | // Options configures discovery. CWD defaults to "." and UserDir is the user |
| 28 | // config root (config.MemoryUserDir()); a "" UserDir disables user-global docs |
| 29 | // and the auto-memory store. |
| 30 | type Options struct { |
| 31 | CWD string |
| 32 | UserDir string |
| 33 | } |
| 34 | |
| 35 | // Load discovers all memory for a session: the hierarchical docs and the |
| 36 | // auto-memory index. It is best-effort and never errors — missing files just |
| 37 | // mean less memory — so boot can call it unconditionally. |
| 38 | func Load(opts Options) *Set { |
| 39 | cwd := opts.CWD |
| 40 | if cwd == "" { |
| 41 | cwd = "." |
| 42 | } |
| 43 | store := StoreFor(opts.UserDir, cwd) |
| 44 | resolved := instruction.Resolve(instruction.ResolveOptions{TargetDir: cwd, UserDir: opts.UserDir}) |
| 45 | return &Set{ |
| 46 | Docs: resolved.Documents, |
| 47 | GlobalGuidance: store.globalGuidanceForProject(), |
| 48 | Store: store, |
| 49 | Index: store.Index(), |
| 50 | CWD: cwd, |
| 51 | UserDir: opts.UserDir, |
| 52 | InstructionDiagnostics: resolved.Diagnostics, |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | // DocPath returns the doc-memory file a given scope writes to. To avoid splitting |
| 57 | // a project's memory across conventions, it prefers a file that already exists |
| 58 | // (REASONIX.md / AGENTS.md / CLAUDE.md, in that order); when none exists it |
| 59 | // creates the universal default (AGENTS.md / AGENTS.local.md). ScopeUser → |
| 60 | // <userDir>, ScopeLocal → <cwd> with the *.local.md names, anything else → <cwd>. |
| 61 | // Returns "" for ScopeUser when no user dir is configured. |
| 62 | func (s *Set) DocPath(scope Scope) string { |
| 63 | dir := s.CWD |
| 64 | names, def := docNames, defaultDocName |
| 65 | switch scope { |
| 66 | case ScopeUser: |
| 67 | if s.UserDir == "" { |
| 68 | return "" |
| 69 | } |
| 70 | dir = s.UserDir |
| 71 | case ScopeLocal: |
| 72 | names, def = localNames, defaultLocalName |
| 73 | } |
| 74 | for _, n := range names { |
| 75 | p := filepath.Join(dir, n) |
| 76 | if _, err := os.Stat(p); err == nil { |
| 77 | return p // append to the doc already in use |
| 78 | } |
| 79 | } |
| 80 | return filepath.Join(dir, def) |
| 81 | } |
| 82 | |
| 83 | // Empty reports whether the set carries nothing to inject, so Compose can leave |
| 84 | // the base prompt byte-for-byte untouched (and the cache prefix maximal) when |
| 85 | // there is no memory at all. |
| 86 | func (s *Set) Empty() bool { |
| 87 | return s == nil || (len(s.Docs) == 0 && len(s.GlobalGuidance) == 0 && strings.TrimSpace(s.Index) == "") |
| 88 | } |
| 89 | |
| 90 | // docScopes are the scopes the panel can target for a quick-add or a new doc. |
| 91 | // Ordered broad → specific for display. |
| 92 | var docScopes = []Scope{ScopeUser, ScopeProject, ScopeLocal} |
| 93 | |
| 94 | // allowedDocPaths is the closed set of files WriteDoc / AppendDoc may touch: the |
| 95 | // canonical file for each writable scope, plus every doc already discovered this |
| 96 | // session (so an ancestor or AGENTS.md the user is already editing stays |
| 97 | // editable). Keyed by absolute path. This bounds frontend-driven writes to real |
| 98 | // memory files rather than arbitrary paths. |
| 99 | func (s *Set) allowedDocPaths() map[string]bool { |
| 100 | allow := map[string]bool{} |
| 101 | for _, sc := range docScopes { |
| 102 | if p := s.DocPath(sc); p != "" { |
| 103 | allow[absOf(p)] = true |
| 104 | } |
| 105 | } |
| 106 | for _, d := range s.Docs { |
| 107 | allow[absOf(d.Path)] = true |
| 108 | } |
| 109 | return allow |
| 110 | } |
| 111 | |
| 112 | // WriteDoc overwrites a doc-memory file with body, after checking path is a |
| 113 | // recognized memory file (see allowedDocPaths). It is the save side of the |
| 114 | // desktop panel's in-place editor. The write lands on disk immediately but does |
| 115 | // NOT mutate the cache-stable system prefix — the edit folds into the prefix on |
| 116 | // the next session; to make it apply this session, the controller separately |
| 117 | // queues a turn-tail note. Returns the path written. |
| 118 | func (s *Set) WriteDoc(path, body string) (string, error) { |
| 119 | if s == nil { |
| 120 | return "", fmt.Errorf("memory unavailable") |
| 121 | } |
| 122 | if strings.TrimSpace(path) == "" { |
| 123 | return "", fmt.Errorf("no path given") |
| 124 | } |
| 125 | if !s.allowedDocPaths()[absOf(path)] { |
| 126 | return "", fmt.Errorf("refusing to write %q: not a recognized memory file", path) |
| 127 | } |
| 128 | return path, writeDocFile(path, body) |
| 129 | } |
| 130 | |
| 131 | // BackgroundBlock renders durable preferences and the fact index without |
| 132 | // standing instruction files. Keeping these sections separate prevents stale |
| 133 | // facts from acquiring instruction authority. |
| 134 | func (s *Set) BackgroundBlock() string { |
| 135 | if s == nil || (len(s.GlobalGuidance) == 0 && strings.TrimSpace(s.Index) == "") { |
| 136 | return "" |
| 137 | } |
| 138 | var b strings.Builder |
| 139 | b.WriteString("# Memory\n\n") |
| 140 | if len(s.GlobalGuidance) > 0 { |
| 141 | b.WriteString("## Global preferences and feedback\n\n") |
| 142 | b.WriteString("Cross-project preferences and working feedback saved in memory. Apply them when relevant. " + |
| 143 | "The current user request and more specific standing instructions take precedence, and factual details may be stale.\n") |
| 144 | for _, m := range s.GlobalGuidance { |
| 145 | fmt.Fprintf(&b, "\n### %s (global/%s)\n\n%s\n", displayTitle(m.Title, m.Name), NormalizeType(string(m.Type)), strings.TrimSpace(m.Body)) |
| 146 | } |
| 147 | } |
| 148 | if idx := strings.TrimSpace(s.Index); idx != "" { |
| 149 | b.WriteString("\n## Background memory index\n\n") |
| 150 | b.WriteString("Facts you saved in earlier sessions. They reflect what was true when written and may now be stale — treat them as background, not standing instructions. " + |
| 151 | "Read a relevant linked fact with the `memory` tool, and before acting on one that names a file, function, or flag, verify it still exists. " + |
| 152 | "Save new durable facts with the `remember` tool; archive ones that turn out wrong with `forget`.\n\n") |
| 153 | b.WriteString(idx) |
| 154 | } |
| 155 | return strings.TrimSpace(b.String()) |
| 156 | } |
| 157 | |
| 158 | // Block combines background memory with separately resolved standing |
| 159 | // instructions. Background comes first so the higher-authority, more specific |
| 160 | // instruction sources remain closest to the conversation tail. |
| 161 | func (s *Set) Block() string { |
| 162 | if s == nil { |
| 163 | return "" |
| 164 | } |
| 165 | parts := []string{} |
| 166 | if background := s.BackgroundBlock(); background != "" { |
| 167 | parts = append(parts, background) |
| 168 | } |
| 169 | if instructions := instruction.Block(s.Docs); instructions != "" { |
| 170 | parts = append(parts, instructions) |
| 171 | } |
| 172 | return strings.Join(parts, "\n\n") |
| 173 | } |
| 174 | |
| 175 | // Compose folds the memory block onto the base system prompt and returns the |
| 176 | // durable cached-prefix string. Base stays first (it is the most stable text, so |
| 177 | // it remains a valid cache prefix even when memory changes between sessions); |
| 178 | // memory follows. With no memory, base is returned unchanged. |
| 179 | func Compose(base string, s *Set) string { |
| 180 | block := s.Block() |
| 181 | if block == "" { |
| 182 | return base |
| 183 | } |
| 184 | if strings.TrimSpace(base) == "" { |
| 185 | return block |
| 186 | } |
| 187 | return strings.TrimRight(base, "\n") + "\n\n" + block |
| 188 | } |
| 189 |