Память и чекпоинты

Два персистентных хранилища: memory — факты о пользователе и проекте, загружаемые в каждый новый контекст сессии; checkpoint — git-снапшоты рабочего дерева для отката изменений.

File-based key-value хранилище фактов для самообучающегося цикла агента. Факты загружаются в системный промпт каждой новой сессии, позволяя агенту «помнить» предпочтения и договорённости между сессиями.

Два независимых scope, каждый — отдельный файл:

Scope Путь Назначение
profile ~/.config/creo/memory/profile Факты о пользователе (предпочтения, привычки, конвенции)
environment <cwd>/.creo/memory/environment Факты о проектном окружении (tooling, структура, соглашения)

Каждый scope — независимый инстанс Store, создаваемый через NewStore(scope, path, maxTokens).

Построчно, один файл на scope:

indent_style: tabs
go_version: 1.24
use_tools_directly: Пользователь раздражается когда пишу тестовые файлы

Формат: key: value\n. Валидация:

  • Key: только [a-z0-9_]+ — regex ^[a-z0-9_]+$
  • Value: одна строка (без \n)

Парсинг: strings.Cut(line, ":"), trim пробелов, пропуск пустых строк и строк без :. Если файл не существует — Load() возвращает nil, nil (не ошибка).

Ограничение объёма фактов в системном промпте:

1func EstimateTokens(text string) int {
2    n := len(text) / 4  // 4 символа на токен — консервативная оценка
3    if n == 0 { n = 1 }
4    return n
5}

При каждом Set() после добавления записи вычисляется usedTokens = entriesTokens(entries). Если used > cap — возврат CapOverError без записи:

1type CapOverError struct {
2    Store      Scope
3    Entries    []Entry    // текущее содержимое (для решения об eviction)
4    UsedTokens int
5    Cap        int
6}

CapOverError несёт текущие entries — вызывающая сторона может решить, какие факты удалить.

Глобальная sync.Map мьютексов для сериализации доступа к одному файлу:

1var pathLocks sync.Map
2
3func pathMutex(path string) *sync.Mutex {
4    actual, _ := pathLocks.LoadOrStore(path, &sync.Mutex{})
5    return actual.(*sync.Mutex)
6}

Позволяет сериализовать доступ из разных горутин (main-агент + dream reviewer одновременно). Разные файлы (profile vs environment) не блокируют друг друга.

Метод Сигнатура Поведение
NewStore NewStore(scope, path, maxTokens) *Store Конструктор
Load Load() ([]Entry, error) Читает файл, парсит, возвращает entries
Set Set(key, value string) error Add or replace. CapOverError при превышении
Delete Delete(key string) error Удаляет по ключу. Ошибка если не найден
List List() (entries []Entry, used int, err error) Entries + текущее потребление токенов
Cap Cap() int Token cap

Пакет memory — storage-only. Инъекция в промпт — ответственность вызывающей стороны:

  1. Load() → получение []Entry
  2. Сериализация в текст
  3. Подстановка в system prompt template (internal/prompts/system.gotmpl)

Bare-git-repo-backed snapshot store. Каждое изменение файлов агентом автоматически снимается как git-снапшот. Откат в один клик — safety net для YOLO-режима.

project/
└── .creo/
    ├── .gitignore         # содержит "*" — всё внутри .creo игнорируется
    └── checkpoints/       # bare git repository (без working tree)
        ├── HEAD            # symbolic ref → refs/heads/main
        └── objects/        # blob, tree, commit objects

Bare repository (gogit.PlainInit(path, true)) — репозиторий без working directory. Объекты хранятся напрямую в objects/, нет index и worktree. Преимущества:

  • Хранение snapshots без подкаталогов в рабочей директории
  • Работа через go-git API без конфликтов с пользовательским .git

HEAD → refs/heads/main через symbolic reference. .creo/.gitignore с "*\n" — защита от попадания в пользовательский git.

flowchart TD
    A[Store.Create] --> B[createLocked — mutex]
    B --> C[collectFiles — walk project dir]
    C --> D[Исключить: .git, .creo, node_modules, vendor, ...]
    D --> E[Каждый файл → blob → Storer.SetEncodedObject]
    E --> F[writeTree — построение nested tree objects]
    F --> G[commit — parent=HEAD, message=JSON metadata]
    G --> H[SetReference — refs/heads/main → new hash]
    H --> I[Checkpoint added to s.items]

filepath.WalkDir обходит рабочую директорию:

  • Пропуск excluded dirs: .git, .creo, node_modules, vendor, build, dist, bin, .next, __pycache__, target, obj, .gradle
  • Symlinks: читает target path как blob content
  • Regular files: читает содержимое
  • Executable: mode = filemode.Executable
  • Каждый файл → blobStorer.SetEncodedObject → hash

Результат: map[string]blobEntry{path → {hash, mode}}

Из flat path-to-blobEntry map рекурсивно строятся nested tree objects:

  • Промежуточный тип tnode с entries []TreeEntry и children map[string]*tnode
  • Рекурсивный build(): сначала children → subtree hashes → entries текущего узла
  • Сортировка entries: директории как будто имеют trailing slash (git convention)
  • Возвращает hash корневого tree object
  • commitMeta (JSON в message)
  • Signature: Name: "Creo", Email: "creo@checkpoint"
  • Parent: текущий HEAD (если существует) — цепочка коммитов
  • object.Commit → encode → Storer.SetEncodedObject → hash
  • SetReference → обновление refs/heads/main

Каждый checkpoint commit имеет message в формате однострочного JSON:

1{"turn":7,"trigger_tool":"write","trigger_args":"main.go","pre_rollback":false}
1type commitMeta struct {
2    Turn        int    `json:"turn"`
3    TriggerTool string `json:"trigger_tool"`
4    TriggerArgs string `json:"trigger_args"`
5    PreRollback bool   `json:"pre_rollback"`
6}
Поле Описание
turn Номер хода агента
trigger_tool Инструмент, вызвавший чекпоинт (write, edit, terminal, rollback)
trigger_args Краткое описание аргументов (путь файла, команда)
pre_rollback true для auto-checkpoint перед откатом
sequenceDiagram
    participant U as User/Agent
    participant S as Store
    participant R as Bare Repo
    participant FS as Filesystem

    U->>S: Restore(idOrPrefix)
    S->>S: Lookup checkpoint
    S->>S: Pre-rollback checkpoint (createLocked, preRollback=true)
    S->>R: Get tree by checkpoint hash
    S->>FS: cleanDir — удалить всё кроме .git и .creo
    S->>FS: extractTree — распаковать файлы из tree
    S->>S: Return restored checkpoint

Pre-rollback checkpoint — перед восстановлением автоматически сохраняется текущее состояние. Это позволяет откатить откат.

cleanDir — удаляет всё кроме .git и .creo. Если файл был в snapshot — он будет восстановлен. Если файла не было — он будет удалён.

extractTree — для каждого файла в tree:

  • Создаёт родительские директории
  • Symlink: os.Symlink(target, path)
  • Executable: mode 0o755
  • Regular: mode 0o644
Операция Метод Описание
Init checkpoint.Init(projectDir) Создаёт .creo/, .gitignore, bare repo. Idempotent
Open checkpoint.NewStore(projectDir) Открывает/создаёт repo, loadCommits()
Create Store.Create(tool, args, turn) Snapshot → commit. Захватывает mutex
List Store.List() []Checkpoint, отсортированы newest-first
Lookup Store.Lookup(idOrPrefix) Полный hash или prefix (≥8 chars)
Count Store.Count() Количество чекпоинтов
Restore Store.Restore(idOrPrefix) Pre-rollback → clean → extract

loadCommits итерирует repo.Log от HEAD → все commits, парсит commitMeta, сортировка sortNewestFirst.

1type Checkpoint struct {
2    Hash        plumbing.Hash
3    CreatedAt   time.Time
4    TriggerTool string
5    TriggerArgs string
6    Turn        int
7    PreRollback bool
8}
  • ID() → full hex hash
  • ShortID() → 8-char prefix
Файл Назначение
internal/memory/store.go Store, NewStore, Load, Set, Delete, List, pathMutex
internal/memory/tokens.go EstimateTokens, CapOverError
Файл Назначение
internal/checkpoint/checkpoint.go Checkpoint struct, commitMeta, Init
internal/checkpoint/store.go Store, NewStore, Create, List, Count, Lookup
internal/checkpoint/repo.go openOrInitRepo, loadCommits, setMainHead
internal/checkpoint/tree.go collectFiles, writeTree — сбор и построение дерева
internal/checkpoint/restore.go Restore, cleanDir, extractTree