Go 139 lines
// Package memory is the working-memory store and its garbage collector.
//
// Memory facts live one-per-file under <workspace>/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
}