package memory import ( "crypto/sha256" "encoding/hex" "fmt" "path/filepath" "strings" "gopkg.in/yaml.v3" ) // Locator is the parsed identity of a memory file on disk: which scope it // belongs to, the scope id (project-id / session-id / slug; "" for global), the // semantic type, and the absolute path of the file itself. type Locator struct { Scope Scope ScopeID string Type Type Path string } // knownTypes maps a layout directory segment (or a frontmatter // metadata.type value) to its Type. Anything not present here resolves to // TypeFree. var knownTypes = map[string]Type{ string(TypeUser): TypeUser, string(TypeFeedback): TypeFeedback, string(TypeProject): TypeProject, string(TypeReference): TypeReference, string(TypeCheckpoint): TypeCheckpoint, string(TypeProgress): TypeProgress, string(TypeNotes): TypeNotes, string(TypeFree): TypeFree, } // scopeForMarker maps a top-level layout directory name to its Scope. var scopeForMarker = map[string]Scope{ "global": ScopeGlobal, "projects": ScopeProjects, "sessions": ScopeSessions, } // typeFromDir maps a directory segment to a Type, defaulting to TypeFree // for unknown segments. func typeFromDir(seg string) Type { if t, ok := knownTypes[strings.ToLower(seg)]; ok { return t } return TypeFree } // parsePath parses an absolute path in the mimo memory layout, relative to root, // into a Locator. Recognized shapes: // // /global//*.md -> ScopeGlobal, ScopeID="", Type from // /projects///*.md -> ScopeProjects, ScopeID=projectId, Type from // /sessions///*.md -> ScopeSessions, ScopeID=sessionId, Type from // // A .md file directly under a scope dir (e.g. /projects//MEMORY.md, or // /global/MEMORY.md) has no segment and resolves to TypeFree. // Unknown segments also map to TypeFree. It returns (nil, false) for any // path outside root or outside the layout. func parsePath(root, absPath string) (*Locator, bool) { rel, ok := relComponents(root, absPath) if !ok || len(rel) == 0 { return nil, false } scope, ok := scopeForMarker[rel[0]] if !ok { return nil, false } // rest is everything below the top-level scope marker. rest := rel[1:] var scopeID string switch scope { case ScopeGlobal: // rest = [/].md case ScopeProjects, ScopeSessions: // rest = /[/].md if len(rest) == 0 { return nil, false } scopeID = rest[0] rest = rest[1:] default: return nil, false } // A file component is mandatory and must be a Markdown file. if len(rest) == 0 || !isMarkdown(rest[len(rest)-1]) { return nil, false } typ := TypeFree if len(rest) >= 2 { // rest = /.../.md — the first segment names the type. typ = typeFromDir(rest[0]) } return &Locator{ Scope: scope, ScopeID: scopeID, Type: typ, Path: filepath.Clean(absPath), }, true } // parseCcPath parses a Claude Code layout path relative to ccBase: // // //memory/**/*.md -> ScopeCC, ScopeID=, Type=TypeFree // // The real type is derived later from the file's YAML frontmatter via // parseCcFrontmatterType. It returns (nil, false) for paths outside the layout. func parseCcPath(ccBase, absPath string) (*Locator, bool) { rel, ok := relComponents(ccBase, absPath) if !ok { return nil, false } // Need at least /memory/.md. if len(rel) < 3 || rel[1] != "memory" { return nil, false } if !isMarkdown(rel[len(rel)-1]) { return nil, false } return &Locator{ Scope: ScopeCC, ScopeID: rel[0], Type: TypeFree, Path: filepath.Clean(absPath), }, true } // ccFrontmatter is the minimal shape read from a Claude Code memory file's // leading YAML frontmatter: either a nested metadata.type or a top-level type. type ccFrontmatter struct { Type string `yaml:"type"` Metadata struct { Type string `yaml:"type"` } `yaml:"metadata"` } // parseCcFrontmatterType reads the semantic type from a leading YAML frontmatter // block (a "---" fence at the very start of body). It prefers metadata.type, // falling back to a top-level type. Absent, malformed, or unrecognized values // yield TypeFree. func parseCcFrontmatterType(body string) Type { block, ok := frontmatterBlock(body) if !ok { return TypeFree } var fm ccFrontmatter if err := yaml.Unmarshal([]byte(block), &fm); err != nil { return TypeFree } candidate := fm.Metadata.Type if candidate == "" { candidate = fm.Type } if t, ok := knownTypes[strings.ToLower(strings.TrimSpace(candidate))]; ok { return t } return TypeFree } // resolveProjectId derives a stable project id from an absolute repository path: // the first 12 hex characters of sha256(absRepoPath). It is deterministic and is // used as the scope_id for the projects scope. func resolveProjectId(absRepoPath string) string { sum := sha256.Sum256([]byte(absRepoPath)) return hex.EncodeToString(sum[:])[:12] } // assertSafeComponent rejects a caller-supplied path component that would escape // the memory root: any ".." segment or a leading "/" (absolute path). Empty // components are also rejected. The write path uses this to sanitize // scope_id/type/filename before joining them under root. func assertSafeComponent(name string) error { if name == "" { return fmt.Errorf("memory: empty path component") } if strings.HasPrefix(name, "/") { return fmt.Errorf("memory: unsafe path component %q: leading %q", name, "/") } for _, seg := range strings.Split(filepath.ToSlash(name), "/") { if seg == ".." { return fmt.Errorf("memory: unsafe path component %q: %q segment", name, "..") } } return nil } // relComponents returns absPath expressed relative to base, split into // non-empty components. ok is false when absPath is not located under base. func relComponents(base, absPath string) (parts []string, ok bool) { rel, err := filepath.Rel(filepath.Clean(base), filepath.Clean(absPath)) if err != nil { return nil, false } rel = filepath.ToSlash(rel) if rel == "." || rel == "" { return nil, false } // Escaping base (e.g. "../foo") means the path is outside the layout. if rel == ".." || strings.HasPrefix(rel, "../") { return nil, false } return strings.Split(rel, "/"), true } // isMarkdown reports whether name has a .md extension (case-insensitive). func isMarkdown(name string) bool { return strings.EqualFold(filepath.Ext(name), ".md") } // frontmatterBlock returns the raw YAML between a leading "---" fence and the // next "---" line. ok is false when body has no opening fence at its very start. func frontmatterBlock(body string) (string, bool) { rest := strings.ReplaceAll(body, "\r\n", "\n") if !strings.HasPrefix(rest, "---\n") { return "", false } rest = strings.TrimPrefix(rest, "---\n") end := strings.Index(rest, "\n---") if end < 0 { return "", false } return rest[:end], true }