# 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-" 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` — 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.