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

221 lines
6.3 KiB
Go

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