Files
agentik/docs/MEMORY-DESIGN.md
subochev 65365da89c Phase 5: :memory-vector (JVector + SQLite + LLM-эмбеддинги), memory-abstraction, compaction, MessageContext
- :memory-api — общий контракт MemoryStore/Prefetcher/Reviewer/Tools/MemorySystem
- :memory-md (KMP, kotlinx-io) — Hermes-style §-файлы, keyword overlap
- :memory-vector (JVM-only) — JVector ANN + SQLite + HttpEmbeddingClient
- :standalone — AGENTIK_MEMORY_BACKEND={md,vector,off}, выбор в Main.kt
- :standalone — compaction рабочего контекста (LiteLlmContextCompactor + reviewPreCompaction)
- :proto — MessageContext (origin: user/system/event) на send и в Message
- :server — backward-compat dual-format для POST /messages
- README — env-vars, vector-бэкенд docs
2026-09-15 03:44:15 +03:00

331 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Memory — дизайн (draft)
> Обсуждение долговременной памяти агента. Начат 2026-09-13.
> Цель — выбрать модель хранения и поиска **до** написания кода.
>
> **Update 2026-09-14:** пивот хранения. Поднимаем не один monolithic
> backend, а **абстракцию** (`MemoryStore`/`MemoryPrefetcher`/`MemoryReviewer`/
> `MemoryTools` в `:memory-api`) и подменяемые реализации. v1 идёт на
> Hermes-style **§-файлах** в `~/.agentik/memory/{USER,WORLD,PREFERENCES}.md`
> (модуль `:memory-md`). SQLite+vector sidecar из §3 ниже переезжает в
> следующий бэкенд (`:memory-vector`, фаза 2+) — обоснование там же,
> отличий в API не будет. §3.1 («почему не файлы») остаётся аргументом
> *против единого источника истины вне основной БД*, но под абстракцией
> это уже не та проблема: факты в MD живут отдельно, а всё остальное
> state диалогов по-прежнему в `agentik.db`.
## 1. Требования
Что память должна делать в agentik:
1. **Хранить факты** между сессиями: о пользователе (USER), о мире/проектах (WORLD), о предпочтениях (PREFERENCE).
2. **Быстро находить релевантное** перед каждым ходом (prefetch, top-K).
3. **Быть перезаписываемой** — пользователь и сам агент могут удалять/править/архивировать.
4. **Переживать рестарты** — данные не теряются.
5. **Работать на native таргетах** (`:server` уже KMP, `:standalone` linuxX64 в плане).
6. **Single-binary** — никаких внешних сервисов типа Qdrant.
Чего **не** обязательно в v1:
- миллионы заметок;
- мультипользовательские tenant'ы;
- sub-ms ANN на >100K векторов.
## 2. Варианты хранения
### A. Текстовые файлы (Hermes-style)
- `~/.hermes/memories/MEMORY.md` и `USER.md`, разделитель записей `§`.
- Поиск: substring / FTS5 / LLM-ранжирование.
- **+** человекочитаемо, легко бэкапить (`cp`), редактировать руками.
- **−** не семантический: «как мы деплоим» не найдёт «systemctl + k3s».
- **−** параллельно с SQLite (у нас всё остальное в `agentik.db`) — два места правды.
### B. SQLite + FTS5
- Всё в `agentik.db`: `memory_note` + `memory_note_fts` (виртуальная FTS5-таблица).
- Поиск: FTS5 BM25 + trigram (для русского).
- **+** один процесс, знакомый API, никаких новых зависимостей.
- **−** keyword-only: «k8s» не матчится с «kubernetes», «Go» не находится в «статически типизированном языке с горутинами».
### C. SQLite (canonical) + векторный sidecar
- Канонический store: `memory_note(id, category, content, created_at, last_used_at, use_count, conversation_id?, source, embedding_model, embedding_dim)` — обычные столбцы.
- Векторный индекс: либо BLOB-столбец с packed Float32Array + brute-force cosine, либо отдельная embedded-БД (sqlite-vss, LanceDB).
- **+** семантический поиск «по смыслу»; canonical store остаётся SQL-инспектируемым (можно `grep`, `sqlite3 agentik.db "SELECT …"`).
- **+** гибридный ranking: similarity × recency × use_count.
- **−** зависимость на embedding-модель.
### D. Чисто векторная БД (Qdrant / Milvus / Weaviate / Chroma)
- **+** production-grade ANN.
- **−** отдельный процесс, **ломает single-binary философию agentik**.
## 3. Рекомендация — гибрид **C**
**Канонический store — SQLite (как у нас всё остальное). Векторный индекс — sidecar.**
### 3.1 Почему не A (только файлы)
- В agentik **всё** состояние уже в SQLite: разговоры, working memory, summary, ошибки. Раздваивать на файлы = доп. синхронизация при каждом save/delete + новая категория бэкапов.
- Файлы не масштабируются на >100 заметок без индекса: `grep -F` это O(N) по байтам.
- Семантический поиск всё равно хочется — придётся добавлять векторы позже, тогда MD превращается в sidecar с теми же проблемами синхронизации, но без преимуществ.
### 3.2 Почему не B (только FTS5)
- Keyword-поиск быстро упирается в перефразирование: пользователь пишет «на чём билдим?», заметка говорит «building with Gradle 9.4.1» — без морфологии и/или эмбеддингов не находится.
- Мультиязычность (русские заметки, английские запросы) — FTS5 с trigram работает, но семантика всё равно точнее.
- Цена эмбеддингов на нашем масштабе ($0.004 на 1000 заметок единоразово + копейки на save) практически нулевая.
### 3.3 Почему не sqlite-vss / LanceDB embedded
- **sqlite-vss** — расширение SQLite, надо пересобирать под каждый native таргет (linuxX64, iosArm64, iosSimulatorArm64, mingwX64). Блокирует наш linuxX64-чек (NATIVE-COMPATIBILITY.md).
- **LanceDB Java SDK** — есть (Apache 2.0), но JVM-only (JNI к нативной `.so`); на ios/iosSimulator без отдельного билда не работает.
- На нашем масштабе (<10K заметок на агента) **brute-force cosine по BLOB-столбцу** — правильный порядок сложности:
- 1 эмбеддинг = 1536 floats × 4 байта ≈ 6 KB (OpenAI small) или 384 × 4 ≈ 1.5 KB (MiniLM).
- 10K × 6 KB = 60 MB в RAM. Дешево.
- Поиск: 10K cosine-similarity = ~0.5 ms на JVM, <0.1 ms нативно. Не нужен HNSW.
Если когда-нибудь выйдем за 50K заметок — переедем на sqlite-vss или LanceDB без поломки API: `MemoryStore.search(query, k)` остаётся прежним.
### 3.4 Схема канонического store (предложение)
```sql
CREATE TABLE memory_note (
id TEXT PRIMARY KEY, -- "mem-<uuid>"
category TEXT NOT NULL, -- 'user' | 'world' | 'preference'
content TEXT NOT NULL, -- полный текст заметки
conversation_id TEXT, -- NULL = global, иначе привязана к диалогу
source TEXT NOT NULL, -- 'agent_save' | 'user_explicit' | 'auto_review'
created_at INTEGER NOT NULL, -- epoch ms
last_used_at INTEGER NOT NULL, -- когда последний раз матчилась в prefetch
use_count INTEGER NOT NULL DEFAULT 0, -- сколько раз выдавалась в prefetch
embedding_model TEXT NOT NULL, -- 'openai/text-embedding-3-small' (для миграций)
embedding_dim INTEGER NOT NULL,
embedding BLOB NOT NULL -- packed Float32Array, little-endian
);
CREATE INDEX memory_note_category_idx ON memory_note(category);
CREATE INDEX memory_note_last_used_idx ON memory_note(last_used_at DESC);
CREATE INDEX memory_note_conversation_idx ON memory_note(conversation_id);
```
Размер: на 10K заметок ≈ 60 MB (OpenAI small) или 15 MB (MiniLM). Дешевле, чем `agentik.db`-кеш.
## 4. Embedding: где считать
| Вариант | + | − |
|---|---|---|
| **Удалённо через litellm** (`text-embedding-3-small`, 1536-dim, $0.02/1M tokens) | 0 локальных деплов, высокое качество, мультиязычный | +1 HTTP на save/query; нужен ключ OpenAI |
| **Локально через ONNX Runtime** (`all-MiniLM-L6-v2`, 384-dim, $0) | оффлайн, native-совместимо, без задержек | +25 MB к бинарю; хуже качество; инициализация ~500 ms |
| **Через GOOGLE backend** (Gemini embedding) | уже работающее API | привязывает embedding к backend; GOOGLE может не иметь OpenAI embedding-API |
| **Гибрид** (по умолчанию OpenAI, fallback на локальный если ключа нет) | resilience | +сложность |
**Рекомендация:** на v1 — **отдельный embedding-конфиг**, дефолт OpenAI через litellm, фоллбэк на локальный MiniLM через ONNX (если выйдет 0.3+ java SDK и мы соберём native-билды под все таргеты — отложим в v2).
### Стоимость на реальном использовании
- text-embedding-3-small, 1000 заметок × 200 токенов = 200K токенов = **$0.004** единоразово.
- `memory_save` ≈ $0.00001 (200 токенов на индексацию).
- `prefetch` (1 query embedding) ≈ $0.000003.
- 100 заметок/месяц, 50 ходов/день = <$0.01/месяц. Ничтожно.
## 5. Жизненный цикл заметки
```
review-loop (post-turn)
│
▼
memory_save(category, content) ──► write to memory_note
│ │
│ ├──► embedding = embed(content)
│ ├──► use_count = 0
│ └──► last_used_at = created_at
│
▼
prefetch(user_message, topK=10) ──► vector top-K + recency rerank
│ │
│ ▼
│ bump use_count, last_used_at
│ │
│ ▼
│ inject into user message prefix:
│ "[Контекст — то, что я помню]
│ - факт 1
│ - факт 2
│ [/Контекст]"
│
▼
(eventually)
│
manual memory_delete(id) ◄── пользовательская команда
│
curator: last_used_at < 90 days ago ──► status = 'archived' (не видна в prefetch)
```
`status` в схеме нет — архивация = `use_count == 0 AND last_used_at < archive_threshold`. Чисто по таймстампам, без LLM. Curator (Фаза 4) делает это в фоне раз в сутки.
## 6. Review-loop (как у Hermes, адаптированный)
Не fork-agent. **Один-shot LLM-вызов** на `LiteLlm.sendStreamContents` с урезанным туловым whitelist'ом (`memory_save`, `memory_search`, `memory_list`, `skill_save`). Тот же backend, что у основного агента.
Триггер: **каждые N ходов** (default 10, настраивается `AGENTIK_MEMORY_NUDGE_INTERVAL`).
Условие: только после **успешно** завершённого хода (не прерванного, не упавшего).
Промпт (рус/англ по языку разговора):
```
Ты — фоновый аналитик. Посмотри на последний разговор и реши, есть ли
что запомнить в долговременную память агента. Сохраняй только если:
1. Пользователь рассказал о себе: persona, привычки, предпочтения.
2. Пользователь рассказал о проекте/окружении: стек, инструменты, сроки.
3. Пользователь выразил ожидания к тому, как агент должен работать.
Если ничего нет — просто ответь "nothing to save" и не вызывай тулы.
Если есть — вызови memory_save(category, content) для каждого факта.
```
`category`: одна из `user` / `world` / `preference`. Агент решает сам.
## 7. Открытые вопросы (нужны решения)
| # | Вопрос | Предложение |
|---|---|---|
| 1 | **Переносимость embeddings между моделями.** Поменяем модель → переиндексировать всё? | Хранить `embedding_model` в строке; на старте проверять, что у всех строк он одинаковый; иначе фоновый reindex. Дёшево ($0.004 на 1000 заметок). |
| 2 | **Мультиязычность.** Русские заметки + английские запросы. | text-embedding-3-small мультиязычен (тестировал на рус+англ — ок); MiniLM — частично. На v1 OpenAI хватает. |
| 3 | **TTL заметок.** Hermes без TTL. У нас возможны устаревшие факты. | Curator (Фаза 4): `use_count == 0 AND last_used_at < 90d` → архив. Без жёсткого удаления. |
| 4 | **Персональные данные / секреты.** Можем сохранить API-ключ из разговора. | Guard rail в `memory_save`: regex-detect на токеноподобные паттерны (`sk-…`, `Bearer …`, JWT); агент не должен сохранять. Финальный review — пользователь. Шифрование at-rest — отдельная тема (см. общий security pass). |
| 5 | **Когда НЕ писать.** Review должен решить «не сохранять». | Встроено в промпт выше. Если агент сомневается — не пишет. |
| 6 | **Каталог и формат SKILL-аналога для памяти.** Hermes делает это как `USER.md` vs `MEMORY.md`. У нас одна таблица с `category`. Нужно ли разделение? | Одной таблицы достаточно (категория в строке). Преимущество: один запрос, одна транзакция. |
| 7 | **Привязка к conversation_id.** Глобальная память vs per-conversation. | Колонка nullable. По умолчанию глобальная (`NULL`). Привязка — только когда факт явно про «этот диалог» (например, «в этом диалоге используем минимальный API»). |
| 8 | **Prefetch size.** Сколько фактов впрыскивать в user message? | Default 10, настраивается `AGENTIK_MEMORY_PREFETCH_TOPK`. |
| 9 | **Где считать query embedding — клиент или «агент»?** | На стороне агента (там же, где `LiteLlm`). Один HTTP-вызов на turn. |
| 10 | **Что делать с дубликатами?** «Пользователь — DevOps» vs «Пользователь работает с k8s» — это две заметки или одна с тегами? | v1: две отдельные заметки. Curator (Фаза 4) объединяет похожие через LLM. |
## 8. Что фиксируем **до кода**
- [ ] **Канонический store**: SQLite-таблица `memory_note` в общей `agentik.db`.
- [ ] **Поиск**: семантический (embedding) + recency/use_count rerank.
- [ ] **Embedding backend**: `openai/text-embedding-3-small` через litellm; в v2 — локальный MiniLM через ONNX.
- [ ] **Vector index**: brute-force cosine по BLOB-столбцу (v1) → sqlite-vss / LanceDB (v2 если нужно).
- [ ] **Single-binary**: всё в нашем процессе, без внешних сервисов.
- [ ] **Prefetch**: топ-K (default 10) заметок в user-message-префиксе.
- [ ] **Review-loop**: один-shot LiteLlm-вызов с whitelist-тулсетом, каждые N ходов.
- [ ] **Curator** (отложен в Фазу 4): архивация по `use_count` + `last_used_at`.
## 9. Что НЕ делаем в v1
- /learn и автоматическое создание скиллов (отдельная фаза).
- Vector compression / quantization (нужно только при >100K заметок).
- Multi-agent shared memory (один пользователь — один агент — один набор заметок).
- Encryption at rest (общий security pass).
- Embedding-кэш для текстов, которые уже были заэмбеджены (можно LRU в RAM).
- Memory.conflict resolution (агент сохранил «k8s», потом «nomad» — это конфликт или эволюция? v1 не разрешает, v2 — curator).
## 10. Пофазный план
### Фаза 1 — Память (v2.1, ~600 строк кода + ~300 тестов)
**Цель:** агент запоминает между сессиями.
- `:memory` KMP-модуль: `MemoryNote`, `MemoryCategory` (`USER | WORLD | PREFERENCE`), `MemoryStore` (commonMain интерфейс) + SQLite jvm-impl.
- Тулы: `MemoryReadTool` (поиск), `MemorySaveTool`, `MemoryListTool`, `MemoryDeleteTool`.
- `MemoryPrefetcher.prefetch(query, topK): List<MemoryNote>` — embed query, brute-force cosine, rerank по recency × use_count.
- `EmbeddingClient` — обёртка над litellm `/v1/embeddings` (один HTTP-вызов, retry, кэш in-RAM LRU на 256 текстов).
- `ChatAgent.memory: MemoryStore` + `EmbeddingClient` параметры.
- `ChatConversation.send()`: перед каждым `sendStreamContents` инжектит top-K заметок в префикс user-сообщения.
- `ChatAgent.afterTurn()` hook: после успешного хода инкрементит `_turnsSinceReview`; если ≥ `memoryNudgeInterval` — запускает фоновую корутину `MemoryReviewAgent`.
- `MemoryReviewAgent`: один-shot `LiteLlm.sendStreamContents` с review-промптом + whitelist-тулсетом (только `memory_*` + `skill_save`). Результат — 0+ записей в `memory_note`.
- `SystemGuidance` константы: `MEMORY_GUIDANCE`, `SKILLS_GUIDANCE`, `TOOL_USE_ENFORCEMENT` (из Hermes `prompt_builder.py`).
- Конфиг: `AGENTIK_MEMORY_BACKEND` (openai/local/none), `AGENTIK_MEMORY_NUDGE_INTERVAL` (10), `AGENTIK_MEMORY_PREFETCH_TOPK` (10), `AGENTIK_EMBEDDING_MODEL` (text-embedding-3-small).
- **Тесты:** MemoryStore round-trip, prefetch ranking, review-loop пишет 0–N заметок на фейковом LLM, защита от секретов (regex), embedding-кэш работает, миграция схемы идемпотентна.
### Фаза 2 — Контекстная компрессия (v2.2, ~500 строк)
**Цель:** диалог переживает 50+ ходов без 400 от LLM.
- `TokenEstimator` — грубая оценка (chars / 4) по working memory.
- `Compressor.shouldCompress()` — триггер: `tokens > threshold_percent * contextLimit` (default 75%). Anti-thrashing: ≤5 неудачных попыток подряд → пауза 10 минут.
- `WorkingMemoryStore.compact(dropFromOrderIdx, summaryEntry: MessageRecord.Summary)` расширяется: drop + insert в одной транзакции. Уже сигнатура, нужно тело.
- `ChatConversation.afterTurn()` (после review-loop): если `shouldCompress` — `head + summary + tail`:
- head = system + первые 3 не-system записи;
- middle = всё что не head/tail; уходит в aux-вызов;
- tail = последние ~20K токенов (или 8 записей, что больше).
- Aux summarizer prompt — портированный из Hermes `context_compressor.py` (структура с Historical Task Snapshot / Goal / Active State / Blocked / Resolved / Remaining Work).
- Резюме пишется как `MessageRecord.Summary(text, createdAt)` в working memory, заменяет middle в `getOrCreateLiteConversation`.
- **Тесты:** каждая граничная ситуация (head+1, всё в tail, пустой middle, ошибка aux-LLM → static fallback).
### Фаза 3 — Skill self-improvement (v2.3, ~400 строк)
**Цель:** агент сам создаёт/обновляет скиллы.
- `SkillSaveTool(action=create|update, name, description, body)` (LiteTool).
- Расширить `SkillCatalog` в `:skills`: `lastUsedAt`, `useCount`, `archivedAt`. Сохранение в SQLite (`skill_usage` таблица) — нужно решить, отдельная БД или в `agentik.db`. Рекомендую в `agentik.db` (та же причина, что и для memory).
- `SkillLoader.loadDirectory` теперь читает timestamp + useCount из SQLite при наличии, иначе — из filesystem mtime.
- `MemoryReviewAgent` дополняется skill-частью (combined prompt) или запускается параллельно вторым вызовом.
- Тулы в whitelist review: `memory_*` + `skill_save` + `skill_delete`.
### Фаза 4 — Curator (v2.4, ~300 строк)
**Цель:** фоновая уборка устаревших заметок и скиллов.
- Корутина `CuratorJob` запускается в `Main.kt`, тик раз в сутки (настраивается).
- **Без LLM:** сканирует `memory_note` и `skills`. Если `last_used_at < 90d` и `use_count == 0` → `archivedAt = now()`. Не удалять.
- **С LLM (опц., выкл по умолчанию):** consolidation pass — fork-agent (один-shot) ищет похожие заметки, объединяет в umbrella-заметку, архивирует исходные. Для скиллов — то же самое.
- Через `:server` endpoint `GET /memory` / `GET /memory/archived` для пользовательского контроля.
### Фаза 5 — Умный prefetch (v2.5, отложено)
- Переход brute-force → sqlite-vss при >50K заметок.
- Локальный embedding через ONNX Runtime (`all-MiniLM-L6-v2`) как fallback при отсутствии OpenAI-ключа.
- Embedding-кэш на диске (LRU).
- Vector quantization (int8) для экономии RAM.
## 11. Что меняется в коде
### Новые модули
```
:memory (новый KMP модуль, commonMain + jvmMain)
commonMain/.../MemoryNote.kt -- sealed: id, category, content, ...
commonMain/.../MemoryCategory.kt -- USER | WORLD | PREFERENCE
commonMain/.../MemoryStore.kt -- интерфейс (insert, list, search, delete)
commonMain/.../EmbeddingClient.kt -- интерфейс (embed: String -> FloatArray)
commonTest/.../MemoryStoreTest.kt
jvmMain/.../sqlite/SqliteMemoryStore.kt -- INSERT/SELECT + brute-force cosine
jvmMain/.../embedding/LitertlmEmbedding.kt -- HTTP-вызов /v1/embeddings через ktor-client
jvmMain/.../embedding/InMemoryEmbedding.kt -- для тестов
jvmTest/.../SqliteMemoryStoreTest.kt
jvmTest/.../LitertlmEmbeddingTest.kt -- через WireMock или httptestserver
```
### Изменения в `:standalone`
```
agent/MemoryReadTool.kt / MemorySaveTool.kt / MemoryListTool.kt / MemoryDeleteTool.kt
agent/MemoryReviewAgent.kt -- один-shot review, fire-and-forget coroutine
agent/SkillSaveTool.kt -- в Фазе 3
llm/SystemGuidance.kt -- MEMORY_GUIDANCE / SKILLS_GUIDANCE константы
llm/CompressionPrompts.kt -- в Фазе 2
persistence/WorkingMemoryStore.kt -- расширение compact() в Фазе 2
agent/ChatAgent.kt -- +memory, +embedding, +afterTurn() hook, +review coroutine
agent/ChatConversation.kt -- +userMessagePrefix(memory snapshot), +afterTurn compress trigger (Фаза 2)
config/AgentikConfig.kt -- +memory config block
Main.kt -- +memory init, +curator coroutine (Фаза 4)
```
### Изменения в `:proto`
Не нужны. Память — внутренняя фича standalone, не часть протокола. (Если захотим управлять памятью через IRC/CLI — добавим `Memory` в `:proto`, как `Agent` в Фазе 4.)
### Изменения в `:server` / `:client`
Не нужны. Память не идёт через HTTP API в v1. (Только чтение дампа `GET /memory` — это Фаза 4.)
## 12. Чеклист перед кодом
- [ ] Подтвердить: канонический store в `agentik.db` (а не отдельный файл).
- [ ] Подтвердить: embedding через litellm `text-embedding-3-small` (а не локальный ONNX).
- [ ] Подтвердить: brute-force cosine в BLOB (а не sqlite-vss / LanceDB на старте).
- [ ] Подтвердить: review-loop как один-shot, не полноценный fork-agent.
- [ ] Подтвердить: prefetch инжектится в user-message-префикс (не system_instruction).
- [ ] Подтвердить: `MemoryNote.conversation_id` nullable, по умолчанию NULL (глобальная).
- [ ] Ответить на вопросы 1–10 из раздела 7.
После закрытия чеклиста — открываем Фазу 1.