ajhahn.de
← eeco
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
}