返回 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: 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
189 lines GO