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