221 lines
6.3 KiB
Go
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)
|
|
}
|