package memory import ( "fmt" "os" "path/filepath" "strings" ) // Set is everything memory loaded for one session: the hierarchical docs and a // handle to the auto-memory store (whose index is captured at load time). It is // assembled once at boot and folded into the system prompt by Compose. CWD and // UserDir are retained so the controller can resolve quick-add targets without // re-deriving discovery context. type Set struct { Docs []Source // REASONIX.md / AGENTS.md, ascending precedence Store Store // auto-memory store (may be a zero/disabled Store) Index string // MEMORY.md contents at load time CWD string // project working dir used for discovery UserDir string // user config root (may be "") } // Options configures discovery. CWD defaults to "." and UserDir is the user // config root (config.MemoryUserDir()); a "" UserDir disables user-global docs // and the auto-memory store. type Options struct { CWD string UserDir string } // Load discovers all memory for a session: the hierarchical docs and the // auto-memory index. It is best-effort and never errors — missing files just // mean less memory — so boot can call it unconditionally. func Load(opts Options) *Set { cwd := opts.CWD if cwd == "" { cwd = "." } store := StoreFor(opts.UserDir, cwd) return &Set{ Docs: discoverDocs(cwd, opts.UserDir), Store: store, Index: store.Index(), CWD: cwd, UserDir: opts.UserDir, } } // DocPath returns the doc-memory file a given scope writes to. To avoid splitting // a project's memory across conventions, it prefers a file that already exists // (REASONIX.md / AGENTS.md / CLAUDE.md, in that order); when none exists it // creates the universal default (AGENTS.md / AGENTS.local.md). ScopeUser → // , ScopeLocal → with the *.local.md names, anything else → . // Returns "" for ScopeUser when no user dir is configured. func (s *Set) DocPath(scope Scope) string { dir := s.CWD names, def := docNames, defaultDocName switch scope { case ScopeUser: if s.UserDir == "" { return "" } dir = s.UserDir case ScopeLocal: names, def = localNames, defaultLocalName } for _, n := range names { p := filepath.Join(dir, n) if _, err := os.Stat(p); err == nil { return p // append to the doc already in use } } return filepath.Join(dir, def) } // Empty reports whether the set carries nothing to inject, so Compose can leave // the base prompt byte-for-byte untouched (and the cache prefix maximal) when // there is no memory at all. func (s *Set) Empty() bool { return s == nil || (len(s.Docs) == 0 && strings.TrimSpace(s.Index) == "") } // docScopes are the scopes the panel can target for a quick-add or a new doc. // Ordered broad → specific for display. var docScopes = []Scope{ScopeUser, ScopeProject, ScopeLocal} // allowedDocPaths is the closed set of files WriteDoc / AppendDoc may touch: the // canonical file for each writable scope, plus every doc already discovered this // session (so an ancestor or AGENTS.md the user is already editing stays // editable). Keyed by absolute path. This bounds frontend-driven writes to real // memory files rather than arbitrary paths. func (s *Set) allowedDocPaths() map[string]bool { allow := map[string]bool{} for _, sc := range docScopes { if p := s.DocPath(sc); p != "" { allow[absOf(p)] = true } } for _, d := range s.Docs { allow[absOf(d.Path)] = true } return allow } // WriteDoc overwrites a doc-memory file with body, after checking path is a // recognized memory file (see allowedDocPaths). It is the save side of the // desktop panel's in-place editor. The write lands on disk immediately but does // NOT mutate the cache-stable system prefix — the edit folds into the prefix on // the next session; to make it apply this session, the controller separately // queues a turn-tail note. Returns the path written. func (s *Set) WriteDoc(path, body string) (string, error) { if s == nil { return "", fmt.Errorf("memory unavailable") } if strings.TrimSpace(path) == "" { return "", fmt.Errorf("no path given") } if !s.allowedDocPaths()[absOf(path)] { return "", fmt.Errorf("refusing to write %q: not a recognized memory file", path) } return path, writeDocFile(path, body) } // Block renders the memory as a single Markdown section, or "" when empty. It is // deterministic given the same files, which is what keeps it a stable cache // prefix across sessions that don't change their memory. func (s *Set) Block() string { if s.Empty() { return "" } var b strings.Builder b.WriteString("# Memory\n\n") b.WriteString("Persistent context loaded from memory files. Treat it as durable, user-authored guidance for this project.\n") for _, d := range s.Docs { fmt.Fprintf(&b, "\n## %s (%s)\n\n%s\n", d.Path, d.Scope, strings.TrimSpace(d.Body)) } if idx := strings.TrimSpace(s.Index); idx != "" { b.WriteString("\n## Saved memories\n\n") 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. " + "Read the linked file with read_file when one looks relevant, and before acting on one that names a file, function, or flag, verify it still exists. " + "Save new durable facts with the `remember` tool; delete ones that turn out wrong with `forget`.\n\n") b.WriteString(idx) var dirs []string for _, d := range s.Store.dirs() { if d != "" { dirs = append(dirs, d) } } fmt.Fprintf(&b, "\n\n(stored under %s)\n", strings.Join(dirs, " and ")) } return b.String() } // Compose folds the memory block onto the base system prompt and returns the // durable cached-prefix string. Base stays first (it is the most stable text, so // it remains a valid cache prefix even when memory changes between sessions); // memory follows. With no memory, base is returned unchanged. func Compose(base string, s *Set) string { block := s.Block() if block == "" { return base } if strings.TrimSpace(base) == "" { return block } return strings.TrimRight(base, "\n") + "\n\n" + block }