Files
iasysadmin/internal/memory/memoria.go
T
2026-09-27 16:04:02 -06:00

336 lines
8.7 KiB
Go

// Package memory gestiona la memoria persistente del agente:
//
// - memoria.md: conocimiento estable descubierto durante las tareas (§21).
// - contexto.mem: histórico operativo de ejecución (§17), en state.go.
package memory
import (
"errors"
"fmt"
"os"
"regexp"
"strings"
"sync"
"sysagent/internal/fsutil"
)
// Store administra memoria.md como un documento Markdown estructurado:
//
// # título
// texto libre
// ## Sección (p.ej. Hosts)
// ### Entrada (p.ej. db01)
// - Etiqueta: valor
//
// Las operaciones Upsert/Forget modifican solo la entrada afectada y
// conservan literalmente todo lo demás (incluido lo escrito a mano), para
// que memoria.md siga siendo editable por el operador.
type Store struct {
mu sync.Mutex
path string
}
// NewStore crea el almacén para la ruta de memoria.md.
func NewStore(path string) *Store { return &Store{path: path} }
// Path devuelve la ruta del archivo.
func (s *Store) Path() string { return s.path }
// Read devuelve el contenido actual ("" si no existe).
func (s *Store) Read() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.read()
}
func (s *Store) read() (string, error) {
b, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("failed to read %s: %w", s.path, err)
}
return string(b), nil
}
// write guarda de forma atómica con permisos 0600 (§23).
func (s *Store) write(content string) error {
if err := fsutil.WriteAtomic(s.path, []byte(content), 0o600, nil); err != nil {
return fmt.Errorf("failed to write %s: %w", s.path, err)
}
return nil
}
// UpsertResult describe qué cambió.
type UpsertResult struct {
Created bool // la entrada no existía
Added []string // hechos nuevos
Updated []string // hechos cuya etiqueta existía con otro valor
Existing []string // hechos que ya estaban (no se duplican)
}
// Changed indica si el archivo se modificó.
func (r UpsertResult) Changed() bool { return r.Created || len(r.Added) > 0 || len(r.Updated) > 0 }
// Upsert inserta o actualiza la entrada section/entry con facts (§81, §82).
//
// Reglas para no duplicar:
// - Un hecho "Etiqueta: valor" reemplaza la línea con la misma etiqueta
// (sin distinguir mayúsculas), así un dato que cambió se actualiza.
// - Un hecho sin etiqueta se añade solo si no existe una línea idéntica.
// - replace=true sustituye la entrada completa por facts.
func (s *Store) Upsert(section, entry string, facts []string, replace bool) (UpsertResult, error) {
section, entry = cleanTitle(section), cleanTitle(entry)
if section == "" || entry == "" {
return UpsertResult{}, errors.New("section and entry are required")
}
var clean []string
for _, f := range facts {
f = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(f), "- "))
if f != "" {
clean = append(clean, strings.ReplaceAll(f, "\n", " "))
}
}
if len(clean) == 0 {
return UpsertResult{}, errors.New("at least one fact is required")
}
s.mu.Lock()
defer s.mu.Unlock()
content, err := s.read()
if err != nil {
return UpsertResult{}, err
}
doc := parseDoc(content)
sec := doc.section(section, true)
ent := sec.entry(entry, true)
var res UpsertResult
if len(ent.lines) == 0 {
res.Created = true
}
if replace {
ent.lines = nil
}
for _, f := range clean {
label := factLabel(f)
idx := -1
for i, l := range ent.lines {
ll := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(l), "- "))
if strings.EqualFold(ll, f) {
idx = -2
break
}
if label != "" && strings.EqualFold(factLabel(ll), label) {
idx = i
}
}
switch {
case idx == -2:
res.Existing = append(res.Existing, f)
case idx >= 0:
ent.lines[idx] = "- " + f
res.Updated = append(res.Updated, f)
default:
ent.lines = append(ent.lines, "- "+f)
res.Added = append(res.Added, f)
}
}
if !res.Changed() && !replace {
return res, nil
}
return res, s.write(doc.render())
}
// Forget elimina una entrada (o una sección completa si entry está vacío).
// Devuelve false si no existía.
func (s *Store) Forget(section, entry string) (bool, error) {
section, entry = cleanTitle(section), cleanTitle(entry)
s.mu.Lock()
defer s.mu.Unlock()
content, err := s.read()
if err != nil {
return false, err
}
doc := parseDoc(content)
sec := doc.section(section, false)
if sec == nil {
return false, nil
}
if entry == "" {
for i, x := range doc.sections {
if x == sec {
doc.sections = append(doc.sections[:i], doc.sections[i+1:]...)
break
}
}
} else {
found := false
for i, e := range sec.entries {
if strings.EqualFold(e.title, entry) {
sec.entries = append(sec.entries[:i], sec.entries[i+1:]...)
found = true
break
}
}
if !found {
return false, nil
}
}
return true, s.write(doc.render())
}
// Ensure crea memoria.md con cabecera y 0600 si no existe.
func (s *Store) Ensure() (created bool, err error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := os.Stat(s.path); err == nil {
return false, nil
}
return true, s.write("# memoria.md\n\nConocimiento persistente descubierto por sysagent.\n")
}
var labelRe = regexp.MustCompile(`^([^:]{1,60}):\s*\S`)
// factLabel devuelve la etiqueta de "Etiqueta: valor" (o "").
func factLabel(f string) string {
if m := labelRe.FindStringSubmatch(f); m != nil && !strings.Contains(m[1], "://") {
return strings.TrimSpace(m[1])
}
return ""
}
func cleanTitle(s string) string {
s = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(s), "#"))
return strings.ReplaceAll(s, "\n", " ")
}
// ---- modelo del documento ----
type document struct {
preamble []string // todo lo anterior a la primera sección "## "
sections []*mdSection
}
type mdSection struct {
title string
intro []string // líneas entre "## " y la primera "### "
entries []*mdEntry
}
type mdEntry struct {
title string
lines []string
}
func parseDoc(content string) *document {
d := &document{}
var sec *mdSection
var ent *mdEntry
inFence := false
for _, line := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
inFence = !inFence
}
switch {
case !inFence && strings.HasPrefix(line, "## "):
sec = &mdSection{title: strings.TrimSpace(line[3:])}
d.sections = append(d.sections, sec)
ent = nil
case !inFence && strings.HasPrefix(line, "### ") && sec != nil:
ent = &mdEntry{title: strings.TrimSpace(line[4:])}
sec.entries = append(sec.entries, ent)
case ent != nil:
ent.lines = append(ent.lines, line)
case sec != nil:
sec.intro = append(sec.intro, line)
default:
d.preamble = append(d.preamble, line)
}
}
return d
}
func (d *document) section(title string, create bool) *mdSection {
for _, s := range d.sections {
if strings.EqualFold(s.title, title) {
return s
}
}
if !create {
return nil
}
s := &mdSection{title: title}
d.sections = append(d.sections, s)
return s
}
func (s *mdSection) entry(title string, create bool) *mdEntry {
for _, e := range s.entries {
if strings.EqualFold(e.title, title) {
return e
}
}
if !create {
return nil
}
e := &mdEntry{title: title}
s.entries = append(s.entries, e)
return e
}
// render reconstruye el Markdown normalizando líneas en blanco entre bloques.
func (d *document) render() string {
var b strings.Builder
writeBlock := func(lines []string) {
trimmed := trimBlank(lines)
for _, l := range trimmed {
b.WriteString(l)
b.WriteByte('\n')
}
if len(trimmed) > 0 {
b.WriteByte('\n')
}
}
writeBlock(d.preamble)
for _, s := range d.sections {
fmt.Fprintf(&b, "## %s\n\n", s.title)
writeBlock(s.intro)
for _, e := range s.entries {
fmt.Fprintf(&b, "### %s\n\n", e.title)
writeBlock(e.lines)
}
}
return strings.TrimRight(b.String(), "\n") + "\n"
}
func trimBlank(lines []string) []string {
start, end := 0, len(lines)
for start < end && strings.TrimSpace(lines[start]) == "" {
start++
}
for end > start && strings.TrimSpace(lines[end-1]) == "" {
end--
}
return lines[start:end]
}
// secretRe detecta hechos que parecen contener secretos en claro.
var secretRe = regexp.MustCompile(`(?i)(password|passwd|contraseña|clave|secret|token|api[_-]?key|private key|BEGIN [A-Z ]*PRIVATE KEY|\bpwd\b)\s*[:=]?\s*\S`)
// LooksSecret informa si un hecho parece contener una credencial (§23).
// Las referencias a almacenes seguros ("secret-tool lookup ...", "en
// /root/.pgpass") no se consideran secretos.
func LooksSecret(fact string) bool {
lower := strings.ToLower(fact)
for _, ref := range []string{"secret-tool", "almacenad", "stored in", "vault", "pass show", "keyring", ".pgpass", "referencia", "reference"} {
if strings.Contains(lower, ref) {
return false
}
}
return secretRe.MatchString(fact)
}