// 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) }