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