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
This commit is contained in:
2026-09-15 03:44:15 +03:00
parent 0faad45f3d
commit 65365da89c
71 changed files with 5856 additions and 142 deletions
+330
View File
@@ -0,0 +1,330 @@
# 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.