| 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: hierarchical standing docs, |
| 13 | // a background snapshot, and a handle to the auto-memory store. Compose folds |
| 14 | // only stable policy and standing docs into system; the controller reloads the |
| 15 | // background snapshot for session-context. CWD and UserDir are retained so |
| 16 | // quick-add targets can be resolved without re-deriving discovery context. |
| 17 | type Set struct { |
| 18 | Docs []Source // REASONIX.md / AGENTS.md, ascending precedence |
| 19 | PinnedGuidance []Memory // snapshot of pinned fact bodies (incl. legacy global user/feedback) |
| 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 | // recall is the snapshot's prebuilt retrieval index (nil when memory is |
| 27 | // hidden or empty); Set.AutoRecall serves each turn from it without disk. |
| 28 | recall *RecallIndex |
| 29 | } |
| 30 | |
| 31 | // Options configures discovery. CWD defaults to "." and UserDir is the user |
| 32 | // config root (config.MemoryUserDir()); a "" UserDir disables user-global docs |
| 33 | // and the auto-memory store. |
| 34 | type Options struct { |
| 35 | CWD string |
| 36 | UserDir string |
| 37 | } |
| 38 | |
| 39 | // Load discovers all memory for a session: the hierarchical docs and the |
| 40 | // auto-memory index. It is best-effort and never errors — missing files just |
| 41 | // mean less memory — so boot can call it unconditionally. |
| 42 | func Load(opts Options) *Set { |
| 43 | cwd := opts.CWD |
| 44 | if cwd == "" { |
| 45 | cwd = "." |
| 46 | } |
| 47 | resolved := instruction.Resolve(instruction.ResolveOptions{TargetDir: cwd, UserDir: opts.UserDir}) |
| 48 | // MemoryBench's counterfactual arm: hide the store, index, pinned |
| 49 | // guidance, and recall so paired runs measure memory's contribution. |
| 50 | // Instruction docs stay — standing instructions are not under test. |
| 51 | if os.Getenv("REASONIX_EXPERIMENT_NO_MEMORY") == "1" { |
| 52 | return &Set{Docs: resolved.Documents, CWD: cwd, UserDir: opts.UserDir, |
| 53 | InstructionDiagnostics: resolved.Diagnostics} |
| 54 | } |
| 55 | store := StoreFor(opts.UserDir, cwd) |
| 56 | return &Set{ |
| 57 | Docs: resolved.Documents, |
| 58 | PinnedGuidance: store.pinnedGuidanceForProject(), |
| 59 | Store: store, |
| 60 | Index: store.Index(), |
| 61 | CWD: cwd, |
| 62 | UserDir: opts.UserDir, |
| 63 | InstructionDiagnostics: resolved.Diagnostics, |
| 64 | recall: BuildRecallIndex(store), |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // DocPath returns the doc-memory file a given scope writes to. To avoid splitting |
| 69 | // a project's memory across conventions, it prefers a file that already exists |
| 70 | // (REASONIX.md / AGENTS.md / CLAUDE.md, in that order); when none exists it |
| 71 | // creates the universal default (AGENTS.md / AGENTS.local.md). ScopeUser → |
| 72 | // <userDir>, ScopeLocal → <cwd> with the *.local.md names, anything else → <cwd>. |
| 73 | // Returns "" for ScopeUser when no user dir is configured. |
| 74 | func (s *Set) DocPath(scope Scope) string { |
| 75 | dir := s.CWD |
| 76 | names, def := docNames, defaultDocName |
| 77 | switch scope { |
| 78 | case ScopeUser: |
| 79 | if s.UserDir == "" { |
| 80 | return "" |
| 81 | } |
| 82 | dir = s.UserDir |
| 83 | case ScopeLocal: |
| 84 | names, def = localNames, defaultLocalName |
| 85 | } |
| 86 | for _, n := range names { |
| 87 | p := filepath.Join(dir, n) |
| 88 | if _, err := os.Stat(p); err == nil { |
| 89 | return p // append to the doc already in use |
| 90 | } |
| 91 | } |
| 92 | return filepath.Join(dir, def) |
| 93 | } |
| 94 | |
| 95 | // Empty reports whether the set carries nothing to inject, so Compose can leave |
| 96 | // the base prompt byte-for-byte untouched (and the cache prefix maximal) when |
| 97 | // there is no memory at all. |
| 98 | func (s *Set) Empty() bool { |
| 99 | return s == nil || (len(s.Docs) == 0 && len(s.PinnedGuidance) == 0 && strings.TrimSpace(s.Index) == "") |
| 100 | } |
| 101 | |
| 102 | // docScopes are the scopes the panel can target for a quick-add or a new doc. |
| 103 | // Ordered broad → specific for display. |
| 104 | var docScopes = []Scope{ScopeUser, ScopeProject, ScopeLocal} |
| 105 | |
| 106 | // allowedDocPaths is the closed set of files WriteDoc / AppendDoc may touch: the |
| 107 | // canonical file for each writable scope, plus every doc already discovered this |
| 108 | // session (so an ancestor or AGENTS.md the user is already editing stays |
| 109 | // editable). Keyed by absolute path. This bounds frontend-driven writes to real |
| 110 | // memory files rather than arbitrary paths. |
| 111 | func (s *Set) allowedDocPaths() map[string]bool { |
| 112 | allow := map[string]bool{} |
| 113 | for _, sc := range docScopes { |
| 114 | if p := s.DocPath(sc); p != "" { |
| 115 | allow[absOf(p)] = true |
| 116 | } |
| 117 | } |
| 118 | for _, d := range s.Docs { |
| 119 | allow[absOf(d.Path)] = true |
| 120 | } |
| 121 | return allow |
| 122 | } |
| 123 | |
| 124 | // WriteDoc overwrites a doc-memory file with body, after checking path is a |
| 125 | // recognized memory file (see allowedDocPaths). It is the save side of the |
| 126 | // desktop panel's in-place editor. The write lands on disk immediately but does |
| 127 | // NOT mutate the cache-stable system prefix — the edit folds into the prefix on |
| 128 | // the next session; to make it apply this session, the controller separately |
| 129 | // queues a turn-tail note. Returns the path written. |
| 130 | func (s *Set) WriteDoc(path, body string) (string, error) { |
| 131 | if s == nil { |
| 132 | return "", fmt.Errorf("memory unavailable") |
| 133 | } |
| 134 | if strings.TrimSpace(path) == "" { |
| 135 | return "", fmt.Errorf("no path given") |
| 136 | } |
| 137 | if !s.allowedDocPaths()[absOf(path)] { |
| 138 | return "", fmt.Errorf("refusing to write %q: not a recognized memory file", path) |
| 139 | } |
| 140 | return path, writeDocFile(path, body) |
| 141 | } |
| 142 | |
| 143 | // PolicyBlock renders the stable rules for interpreting background memory. |
| 144 | // Dynamic fact bodies and index entries live in BackgroundDataBlock so changes |
| 145 | // to them do not rewrite the provider-cached system prefix. |
| 146 | func (s *Set) PolicyBlock() string { |
| 147 | if s == nil || (s.Store.Dir == "" && s.Store.GlobalDir == "" && len(s.PinnedGuidance) == 0 && strings.TrimSpace(s.Index) == "") { |
| 148 | return "" |
| 149 | } |
| 150 | return "# Memory\n\n" + |
| 151 | "The latest host-generated `<session-context>` may contain pinned preferences, feedback, and a background memory index. " + |
| 152 | "Treat those facts as potentially stale background rather than standing instructions; the current user request and more specific standing instructions take precedence. " + |
| 153 | "Read a relevant linked fact with the `memory` tool, verify file/function/flag claims before acting, save durable facts with `remember`, and archive facts that prove wrong with `forget`." |
| 154 | } |
| 155 | |
| 156 | // BackgroundDataBlock renders durable preferences and the fact index without |
| 157 | // their stable policy or standing instruction files. It belongs in the latest |
| 158 | // host-generated session-context snapshot. |
| 159 | func (s *Set) BackgroundDataBlock() string { |
| 160 | if s == nil || (len(s.PinnedGuidance) == 0 && strings.TrimSpace(s.Index) == "") { |
| 161 | return "" |
| 162 | } |
| 163 | var b strings.Builder |
| 164 | if len(s.PinnedGuidance) > 0 { |
| 165 | b.WriteString("### Pinned preferences and feedback\n") |
| 166 | for _, m := range s.PinnedGuidance { |
| 167 | fmt.Fprintf(&b, "\n#### %s (%s/%s)\n\n%s\n", displayTitle(m.Title, m.Name), |
| 168 | NormalizeFactScope(string(m.Scope)), NormalizeType(string(m.Type)), strings.TrimSpace(m.Body)) |
| 169 | } |
| 170 | } |
| 171 | if idx := strings.TrimSpace(s.Index); idx != "" { |
| 172 | b.WriteString("\n### Background memory index\n\n") |
| 173 | b.WriteString(idx) |
| 174 | } |
| 175 | return strings.TrimSpace(b.String()) |
| 176 | } |
| 177 | |
| 178 | // StandingBlock renders only high-authority instruction documents. |
| 179 | func (s *Set) StandingBlock() string { |
| 180 | if s == nil { |
| 181 | return "" |
| 182 | } |
| 183 | return instruction.Block(s.Docs) |
| 184 | } |
| 185 | |
| 186 | // BackgroundBlock retains the historical combined background representation |
| 187 | // for management and compatibility callers. Provider prompt assembly uses the |
| 188 | // policy and data renderers separately. |
| 189 | func (s *Set) BackgroundBlock() string { |
| 190 | if s == nil { |
| 191 | return "" |
| 192 | } |
| 193 | parts := []string{} |
| 194 | if policy := s.PolicyBlock(); policy != "" { |
| 195 | parts = append(parts, policy) |
| 196 | } |
| 197 | if data := s.BackgroundDataBlock(); data != "" { |
| 198 | parts = append(parts, data) |
| 199 | } |
| 200 | return strings.Join(parts, "\n\n") |
| 201 | } |
| 202 | |
| 203 | // Block combines background memory with separately resolved standing |
| 204 | // instructions. Background comes first so the higher-authority, more specific |
| 205 | // instruction sources remain closest to the conversation tail. |
| 206 | func (s *Set) Block() string { |
| 207 | if s == nil { |
| 208 | return "" |
| 209 | } |
| 210 | parts := []string{} |
| 211 | if background := s.BackgroundBlock(); background != "" { |
| 212 | parts = append(parts, background) |
| 213 | } |
| 214 | if instructions := s.StandingBlock(); instructions != "" { |
| 215 | parts = append(parts, instructions) |
| 216 | } |
| 217 | return strings.Join(parts, "\n\n") |
| 218 | } |
| 219 | |
| 220 | // SystemBlock contains only cache-stable memory policy and standing documents. |
| 221 | func (s *Set) SystemBlock() string { |
| 222 | if s == nil { |
| 223 | return "" |
| 224 | } |
| 225 | parts := []string{} |
| 226 | if policy := s.PolicyBlock(); policy != "" { |
| 227 | parts = append(parts, policy) |
| 228 | } |
| 229 | if instructions := s.StandingBlock(); instructions != "" { |
| 230 | parts = append(parts, instructions) |
| 231 | } |
| 232 | return strings.Join(parts, "\n\n") |
| 233 | } |
| 234 | |
| 235 | // Compose folds only stable memory policy and standing instructions onto the |
| 236 | // cached system prefix. BackgroundDataBlock is delivered by session-context. |
| 237 | func Compose(base string, s *Set) string { |
| 238 | block := s.SystemBlock() |
| 239 | if block == "" { |
| 240 | return base |
| 241 | } |
| 242 | if strings.TrimSpace(base) == "" { |
| 243 | return block |
| 244 | } |
| 245 | return strings.TrimRight(base, "\n") + "\n\n" + block |
| 246 | } |
| 247 |