336 lines
8.7 KiB
Go
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)
|
|
}
|