post inicial

This commit is contained in:
lastdragon
2026-09-27 16:04:02 -06:00
parent d84587e1f3
commit 52864794f5
67 changed files with 15023 additions and 2 deletions
+335
View File
@@ -0,0 +1,335 @@
// 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)
}
+188
View File
@@ -0,0 +1,188 @@
package memory
import (
"os"
"path/filepath"
"strings"
"testing"
"sysagent/internal/llm"
)
func TestUpsertCreatesStructure(t *testing.T) {
s := NewStore(filepath.Join(t.TempDir(), "memoria.md"))
if _, err := s.Ensure(); err != nil {
t.Fatal(err)
}
res, err := s.Upsert("Hosts", "db01", []string{"IP: 10.10.10.15", "Servicio: PostgreSQL", "Puerto: 5432"}, false)
if err != nil || !res.Created || len(res.Added) != 3 {
t.Fatalf("res=%+v err=%v", res, err)
}
got, _ := s.Read()
want := "# memoria.md\n\nConocimiento persistente descubierto por sysagent.\n\n## Hosts\n\n### db01\n\n- IP: 10.10.10.15\n- Servicio: PostgreSQL\n- Puerto: 5432\n"
if got != want {
t.Errorf("content:\n%s\nwant:\n%s", got, want)
}
st, _ := os.Stat(s.Path())
if st.Mode().Perm() != 0o600 {
t.Errorf("perm = %o", st.Mode().Perm())
}
}
func TestUpsertNoDuplicatesAndUpdates(t *testing.T) {
s := NewStore(filepath.Join(t.TempDir(), "memoria.md"))
s.Upsert("Hosts", "db01", []string{"IP: 10.10.10.15", "Rol primario"}, false)
res, _ := s.Upsert("hosts", "DB01", []string{"IP: 10.10.10.15", "Rol primario"}, false)
if res.Changed() || len(res.Existing) != 2 {
t.Errorf("duplicates must not be added: %+v", res)
}
res, _ = s.Upsert("Hosts", "db01", []string{"ip: 10.10.10.99", "Puerto: 5433"}, false)
if len(res.Updated) != 1 || len(res.Added) != 1 {
t.Errorf("res = %+v", res)
}
got, _ := s.Read()
if strings.Contains(got, "10.10.10.15") {
t.Errorf("old IP must be replaced:\n%s", got)
}
if !strings.Contains(got, "- ip: 10.10.10.99") || strings.Count(got, "### ") != 1 || strings.Count(got, "## Hosts") != 1 {
t.Errorf("content:\n%s", got)
}
res, _ = s.Upsert("Hosts", "db01", []string{"IP: 1.2.3.4"}, true)
got, _ = s.Read()
if strings.Contains(got, "Puerto") || !strings.Contains(got, "- IP: 1.2.3.4") {
t.Errorf("replace=true must rewrite the entry:\n%s", got)
}
}
func TestUpsertPreservesManualContent(t *testing.T) {
path := filepath.Join(t.TempDir(), "memoria.md")
manual := "# Notas\n\nTexto del operador.\n\n## Hosts\n\nIntro de hosts.\n\n### web01\n\n- IP: 10.0.0.2\n```\n## no es sección\n```\n\n## Servicios\n\n- nginx en web01\n"
os.WriteFile(path, []byte(manual), 0o600)
s := NewStore(path)
if _, err := s.Upsert("Hosts", "db01", []string{"IP: 10.0.0.3"}, false); err != nil {
t.Fatal(err)
}
got, _ := s.Read()
for _, want := range []string{"Texto del operador.", "Intro de hosts.", "### web01\n\n- IP: 10.0.0.2", "## no es sección", "## Servicios\n\n- nginx en web01", "### db01\n\n- IP: 10.0.0.3"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
if strings.Index(got, "### db01") > strings.Index(got, "## Servicios") {
t.Error("new entry must go inside its section")
}
}
func TestForget(t *testing.T) {
s := NewStore(filepath.Join(t.TempDir(), "memoria.md"))
s.Upsert("Hosts", "a", []string{"x"}, false)
s.Upsert("Hosts", "b", []string{"y"}, false)
if ok, _ := s.Forget("Hosts", "a"); !ok {
t.Error("forget a")
}
if ok, _ := s.Forget("Hosts", "zz"); ok {
t.Error("forget missing entry must return false")
}
got, _ := s.Read()
if strings.Contains(got, "### a") || !strings.Contains(got, "### b") {
t.Errorf("content:\n%s", got)
}
s.Forget("Hosts", "")
got, _ = s.Read()
if strings.Contains(got, "Hosts") {
t.Errorf("section not removed:\n%s", got)
}
}
func TestLooksSecret(t *testing.T) {
secrets := []string{"password: hunter2", "Contraseña: abc123", "token=ghp_xxx", "API_KEY: sk-123", "-----BEGIN RSA PRIVATE KEY-----"}
for _, s := range secrets {
if !LooksSecret(s) {
t.Errorf("%q should look secret", s)
}
}
safe := []string{"IP: 10.0.0.1", "Password almacenado en: secret-tool lookup service postgres-admin", "Puerto: 5432", "Usuario: admin"}
for _, s := range safe {
if LooksSecret(s) {
t.Errorf("%q should not look secret", s)
}
}
}
func TestStateRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "contexto.mem")
st, warn, err := LoadState(path)
if err != nil || warn != "" || st.Summary != "" {
t.Fatalf("missing file: %v %q", err, warn)
}
st.Summary = "## OBJECTIVE\nkey sk-SECRET-999"
st.Recent = []llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("ñ", 5000)}}
st.AddJournal(JournalEntry{Kind: "tool", Task: "01.md", Text: "systemctl restart nginx → exit=0"})
st.Tasks["01.md"] = TaskRecord{Status: "DONE", Hash: "abc"}
redact := func(s string) string { return strings.ReplaceAll(s, "sk-SECRET-999", "sk-SEC...999") }
if err := st.Save(path, redact); err != nil {
t.Fatal(err)
}
fi, _ := os.Stat(path)
if fi.Mode().Perm() != 0o600 {
t.Errorf("perm = %o", fi.Mode().Perm())
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), "sk-SECRET-999") {
t.Error("secret persisted")
}
if st.Summary != "## OBJECTIVE\nkey sk-SECRET-999" {
t.Error("Save must not mutate in-memory state")
}
st2, warn, err := LoadState(path)
if err != nil || warn != "" {
t.Fatal(err, warn)
}
if st2.Tasks["01.md"].Status != "DONE" || len(st2.Journal) != 1 || len(st2.Unsummarized()) != 1 {
t.Errorf("state = %+v", st2)
}
if len(st2.Recent[0].Content) > maxPersistedChars+100 {
t.Error("recent messages must be clipped when persisted")
}
st2.MarkSummarized()
if len(st2.Unsummarized()) != 0 {
t.Error("MarkSummarized")
}
}
func TestLoadStatePlainTextAndCorrupt(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contexto.mem")
os.WriteFile(path, []byte("Servidor 192.168.20.15 con PostgreSQL 17"), 0o600)
st, warn, err := LoadState(path)
if err != nil || warn == "" || !strings.Contains(st.Summary, "192.168.20.15") {
t.Fatalf("plain text: %v %q %q", err, warn, st.Summary)
}
os.WriteFile(path, []byte("{broken json"), 0o600)
st, warn, err = LoadState(path)
if err != nil || !strings.Contains(warn, "moved to") || st.Summary != "" {
t.Fatalf("corrupt: %v %q", err, warn)
}
matches, _ := filepath.Glob(filepath.Join(dir, "contexto.mem.corrupt-*"))
if len(matches) != 1 {
t.Error("corrupt file must be preserved")
}
}
func TestStateTrimDropsSummarizedFirst(t *testing.T) {
st := NewState()
for i := 0; i < maxJournal+50; i++ {
st.AddJournal(JournalEntry{Kind: "tool", Text: "x", Summarized: i < 100})
}
st.trim()
if len(st.Journal) != maxJournal {
t.Fatalf("len = %d", len(st.Journal))
}
if len(st.Unsummarized()) != maxJournal+50-100 {
t.Errorf("unsummarized entries must be kept: %d", len(st.Unsummarized()))
}
}
+220
View File
@@ -0,0 +1,220 @@
package memory
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"unicode/utf8"
"sysagent/internal/fsutil"
"sysagent/internal/llm"
)
// StateVersion es la versión del formato de contexto.mem.
const StateVersion = 1
// Límites de lo que se persiste, para que contexto.mem no crezca sin control
// (§17: "no debe ser simplemente un dump ilimitado del chat").
const (
maxJournal = 400
maxRecentMessages = 60
maxPersistedChars = 4000 // por mensaje reciente
)
// JournalEntry es un evento operativo registrado por el propio programa
// (no por el modelo): comandos ejecutados, cambios de archivos, estados de
// tareas, errores. Es la fuente fiable para la compresión.
type JournalEntry struct {
Time time.Time `json:"time"`
Task string `json:"task,omitempty"`
Kind string `json:"kind"` // session, tool, change, status, error, answer, compaction
Text string `json:"text"`
Files []string `json:"files,omitempty"`
Backups []string `json:"backups,omitempty"`
// Summarized indica que ya se incorporó a Summary.
Summarized bool `json:"summarized,omitempty"`
}
// TaskRecord guarda el último estado conocido de una tarea.
type TaskRecord struct {
Status string `json:"status"`
Hash string `json:"hash"` // sha256 del contenido cuando se ejecutó
Updated time.Time `json:"updated"`
}
// State es el contenido de contexto.mem.
type State struct {
Version int `json:"version"`
Updated time.Time `json:"updated"`
// Summary es el contexto histórico comprimido (secciones OBJECTIVE,
// CURRENT_STATE, ...). Vacío hasta la primera compresión.
Summary string `json:"summary"`
Compactions int `json:"compactions"`
// Recent son los mensajes recientes de la última sesión.
Recent []llm.Message `json:"recent,omitempty"`
Journal []JournalEntry `json:"journal,omitempty"`
Tasks map[string]TaskRecord `json:"tasks,omitempty"`
}
// NewState crea un estado vacío.
func NewState() *State {
return &State{Version: StateVersion, Tasks: map[string]TaskRecord{}}
}
// LoadState lee contexto.mem.
//
// - Si no existe, devuelve un estado vacío.
// - Si no es JSON (p.ej. escrito a mano), su texto se conserva como
// resumen histórico y se devuelve una advertencia.
// - Si es JSON corrupto o de una versión futura, se renombra a
// contexto.mem.corrupt-<fecha> para no perderlo y se empieza de cero.
func LoadState(path string) (st *State, warning string, err error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return NewState(), "", nil
}
if err != nil {
return nil, "", fmt.Errorf("failed to read %s: %w", path, err)
}
trimmed := strings.TrimSpace(string(data))
if trimmed == "" {
return NewState(), "", nil
}
if !strings.HasPrefix(trimmed, "{") {
st := NewState()
st.Summary = trimmed
return st, "contexto.mem is plain text: imported as historical summary (it will be rewritten in JSON format)", nil
}
st = NewState()
if err := json.Unmarshal(data, st); err != nil || st.Version > StateVersion {
bad := fmt.Sprintf("%s.corrupt-%s", path, time.Now().Format("20060102-150405"))
if rerr := os.Rename(path, bad); rerr != nil {
return nil, "", fmt.Errorf("contexto.mem is unreadable (%v) and could not be moved aside: %w", err, rerr)
}
reason := "unsupported version"
if err != nil {
reason = err.Error()
}
return NewState(), fmt.Sprintf("contexto.mem could not be parsed (%s); moved to %s and starting fresh", reason, bad), nil
}
if st.Tasks == nil {
st.Tasks = map[string]TaskRecord{}
}
return st, "", nil
}
// Save escribe contexto.mem de forma atómica con permisos 0600.
// redact se aplica a todo el texto para no persistir secretos (§58).
func (s *State) Save(path string, redact func(string) string) error {
s.Version = StateVersion
s.Updated = time.Now()
s.trim()
cp := *s
cp.Summary = redact(s.Summary)
cp.Recent = make([]llm.Message, len(s.Recent))
for i, m := range s.Recent {
m.Content = redact(clipChars(m.Content, maxPersistedChars))
cp.Recent[i] = m
}
cp.Journal = make([]JournalEntry, len(s.Journal))
for i, j := range s.Journal {
j.Text = redact(j.Text)
cp.Journal[i] = j
}
data, err := json.MarshalIndent(&cp, "", " ")
if err != nil {
return fmt.Errorf("failed to encode contexto.mem: %w", err)
}
if err := fsutil.WriteAtomic(path, append(data, '\n'), 0o600, nil); err != nil {
return fmt.Errorf("failed to save %s: %w", path, err)
}
return nil
}
// AddJournal registra un evento.
func (s *State) AddJournal(e JournalEntry) {
if e.Time.IsZero() {
e.Time = time.Now()
}
s.Journal = append(s.Journal, e)
}
// Unsummarized devuelve los eventos que aún no están en Summary.
func (s *State) Unsummarized() []JournalEntry {
var out []JournalEntry
for _, j := range s.Journal {
if !j.Summarized {
out = append(out, j)
}
}
return out
}
// MarkSummarized marca todos los eventos actuales como incorporados.
func (s *State) MarkSummarized() {
for i := range s.Journal {
s.Journal[i].Summarized = true
}
}
// trim aplica los límites de tamaño. Se descartan primero los eventos ya
// resumidos (su información vive en Summary).
func (s *State) trim() {
if len(s.Recent) > maxRecentMessages {
s.Recent = s.Recent[len(s.Recent)-maxRecentMessages:]
}
if len(s.Journal) <= maxJournal {
return
}
excess := len(s.Journal) - maxJournal
kept := s.Journal[:0]
for _, j := range s.Journal {
if excess > 0 && j.Summarized {
excess--
continue
}
kept = append(kept, j)
}
if len(kept) > maxJournal {
kept = kept[len(kept)-maxJournal:]
}
s.Journal = kept
}
// FormatJournal produce texto compacto de eventos para el modelo o la consola.
func FormatJournal(entries []JournalEntry) string {
var b strings.Builder
for _, j := range entries {
fmt.Fprintf(&b, "%s [%s]", j.Time.Format(time.RFC3339), j.Kind)
if j.Task != "" {
fmt.Fprintf(&b, " (%s)", j.Task)
}
fmt.Fprintf(&b, " %s", j.Text)
if len(j.Files) > 0 {
fmt.Fprintf(&b, " files=%s", strings.Join(j.Files, ","))
}
if len(j.Backups) > 0 {
fmt.Fprintf(&b, " backups=%s", strings.Join(j.Backups, ","))
}
b.WriteByte('\n')
}
return b.String()
}
func clipChars(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n-- // no cortar un carácter UTF-8 por la mitad
}
return s[:n] + fmt.Sprintf("\n[... %d bytes not persisted ...]", len(s)-n)
}