post inicial
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user