// Package memory is the working-memory store and its garbage collector. // // Memory facts live one-per-file under /memory/ with a small // `---`-delimited frontmatter block of flat `key: value` pairs followed // by a free-text body. The package owns: // // - parsing and serialising fact files; // - loading the whole store (skipping the attic and the index); // - regenerating the MEMORY.md index; // - selecting facts that overlap with a task by word match and // bumping their last_used timestamp; // - garbage collection: archiving stale technical facts to the attic, // and queueing review items for load-bearing user/project/feedback // facts (which are never silently dropped). // // The package writes only inside cfg.Workspace and never touches files // outside cfg.RepoRoot. package memory import ( "errors" "fmt" "regexp" "slices" "strings" "time" ) // FactType is the classification of a memory fact. The five types drive // garbage-collection behaviour: reference and finding facts may be // archived to the attic; user, feedback, and project facts are queued // for review instead of being silently dropped. type FactType string const ( TypeUser FactType = "user" TypeFeedback FactType = "feedback" TypeProject FactType = "project" TypeReference FactType = "reference" TypeFinding FactType = "finding" ) // DateLayout is the canonical YYYY-MM-DD date format used everywhere in // the memory store. const DateLayout = "2006-01-02" // Fact is a single working-memory entry. One Fact corresponds to one // file on disk. type Fact struct { Name string Description string Type FactType Created time.Time LastUsed time.Time Ref string Expires *time.Time Status string Pin bool Source string Agent string Disabled bool Body string // Path is the absolute path to the source file on disk, set by the // store when the fact is loaded. It is empty for in-memory facts // constructed by callers and is not part of the file format. Path string } // MaxSourceLen caps the provenance snippet recorded on a fact. The // limit keeps the value to one short line of frontmatter; longer // context belongs in the body. const MaxSourceLen = 120 var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) // Validate checks that a fact has the required fields and that // field-specific constraints hold. It does not touch disk. func (f *Fact) Validate() error { if f == nil { return errors.New("fact is nil") } if f.Name == "" { return errors.New("name is required") } if !nameRE.MatchString(f.Name) { return fmt.Errorf("name %q: must be lower-kebab-case (a-z, 0-9, '-')", f.Name) } if f.Description == "" { return errors.New("description is required") } if !ValidType(f.Type) { return fmt.Errorf("type %q: must be one of user, feedback, project, reference, finding", f.Type) } if f.Created.IsZero() { return errors.New("created is required") } if f.LastUsed.IsZero() { return errors.New("last_used is required") } if f.Ref != "" { if err := validateRef(f.Ref); err != nil { return fmt.Errorf("ref %q: %w", f.Ref, err) } } if f.Type == TypeFinding && f.Status != "" && f.Status != "open" && f.Status != "resolved" { return fmt.Errorf("status %q: finding status must be open or resolved", f.Status) } if len(f.Source) > MaxSourceLen { return fmt.Errorf("source: must be %d chars or fewer (got %d)", MaxSourceLen, len(f.Source)) } return nil } // ValidType reports whether t is a known FactType. func ValidType(t FactType) bool { switch t { case TypeUser, TypeFeedback, TypeProject, TypeReference, TypeFinding: return true } return false } // validateRef rejects refs that would escape the repository root or // shadow an absolute path. GC stats the path; this guard prevents both // `..` traversal and absolute-path probing. func validateRef(ref string) error { if ref == "" { return nil } if strings.HasPrefix(ref, "/") || strings.HasPrefix(ref, `\`) { return errors.New("must be repo-relative (no leading slash)") } if slices.Contains(strings.Split(ref, "/"), "..") { return errors.New("must not contain '..'") } return nil }