240 lines
20 KiB
Markdown
240 lines
20 KiB
Markdown
# memo — заметки как миллион файлов, каждый со своей семантикой
|
||
|
||
Спека v0.1 (только проект, кода нет). Автор идеи — пользователь; форма зафиксирована 02.10.2026.
|
||
|
||
## 0. Идея в одном абзаце
|
||
|
||
Обычный провайдер памяти в Hermes — **один** стор на весь профиль (MEMORY.md, holographic, mem0).
|
||
Здесь форма другая: **markdown-файлы лежат в дереве папок и являются истиной**, а рядом с каждой
|
||
папкой-темой лежит пересобираемый векторный индекс. Коллекций — сколько угодно, они не мешают друг
|
||
другу, и «спрашивают про X → ищем в X» решается детерминированной маршрутизацией, а не надеждой на
|
||
автопоиск по всему корпусу. Это не память (маленькая и всегда в промпте) и не файловая система
|
||
(слепая к смыслу) — это **библиотека со смысловым индексом**.
|
||
|
||
## 1. Границы: что это и чем не является
|
||
|
||
- НЕ замена MEMORY.md / USER.md. Курируемая память по-прежнему маленькая и всегда в промпте.
|
||
- НЕ граф знаний, НЕ авто-суммаризация, НЕ «агент сам решает, что запомнить».
|
||
- НЕ облако: модель эмбеддингов локальная (CPU/ONNX), сеть не нужна ни при индексации, ни при поиске.
|
||
- НЕ альтернатива grep: файлы остаются человекочитаемыми, любой поиск по тексту работает и без индекса.
|
||
|
||
## 2. Правило номер один
|
||
|
||
**Markdown — истина, `.db` — кэш, который не жалко.**
|
||
|
||
Следствие: индекс можно удалить целиком и он пересоберётся. Ничего, что существует только в базе,
|
||
быть не должно. Любая миграция схемы = `rm -rf` + ленивая пересборка при первом обращении к коллекции.
|
||
|
||
## 3. Модель данных
|
||
|
||
```
|
||
<root>/ # корень библиотеки, напр. ~/notes/memo
|
||
_registry.yaml # реестр коллекций: имя → описание, путь, эмбеддер, счётчики
|
||
work/
|
||
jira/
|
||
_meta.yaml # описание коллекции, теги, модель/димы, версия чанкера
|
||
notes.md # основная заметка (markdown, истина)
|
||
pitfalls.md
|
||
.memo/
|
||
index.db # sqlite: FTS5 + vec0, пересобираемо
|
||
state.json # версия чанкера/модели, sweep-время
|
||
life/
|
||
books/
|
||
_meta.yaml
|
||
iphuck10.md
|
||
.memo/index.db
|
||
inbox.md # неразобранное; коллекция НЕ индексируется
|
||
```
|
||
|
||
Правила:
|
||
|
||
1. **Коллекция = папка** (не файл). Один sqlite на заметку — запрещено: inode, соединения, фрагментация.
|
||
2. **Заметка = `.md`-файл** внутри коллекции. Ориентир: 10–300 файлов на коллекцию, до ~10⁵ коллекций в теории.
|
||
3. **Максимум ~2 уровня вложенности** папок. Глубже — значит тема слишком крупная, её надо дробить.
|
||
4. Черновики — в `inbox.md` в корне: не индексируются, агент за них не отвечает.
|
||
5. `_meta.yaml` коллекции обязателен: без описания коллекция невидима для маршрутизатора.
|
||
|
||
```yaml
|
||
# _meta.yaml
|
||
name: work/jira
|
||
description: "Корпоративная Jira: тикеты, флоу, доступы, грабли MCP"
|
||
tags: [corp, jira, otp]
|
||
embedder: intfloat/multilingual-e5-small # 384 dims
|
||
chunker: headings-v1 # меняешь — индекс пересобирается
|
||
```
|
||
|
||
## 4. Формат заметки
|
||
|
||
- Границы чанков = **markdown-заголовки** (`##`/`###`); слишком длинный раздел режется по ~800 токенов
|
||
с перекрытием 15%. Никогда не резать по символам «вслепую».
|
||
- Опциональный frontmatter: `id`, `tags`, `updated`. Обязателен только `updated` (руками или скриптом).
|
||
- Имена файлов — ASCII-slug, без пробелов: `postgres-restore.md`, не `про восстановление.md`.
|
||
- В текст заметки полезно писать **явные синонимы и аббревиатуры** («PBR (policy based routing)») —
|
||
это бесплатно лечит промахи чистого вектора на жаргоне.
|
||
|
||
## 5. Индекс (одна база на коллекцию)
|
||
|
||
```sql
|
||
CREATE TABLE chunks(
|
||
id INTEGER PRIMARY KEY, path TEXT, heading TEXT, line INTEGER, ord INTEGER,
|
||
text TEXT, h1 TEXT, mtime REAL, hash TEXT);
|
||
CREATE VIRTUAL TABLE chunks_fts USING fts5(text, heading, tokenize='unicode61');
|
||
CREATE VIRTUAL TABLE chunks_vec USING vec0(embedding float[768]); -- sqlite-vec, dim по модели
|
||
```
|
||
|
||
- **Гибридный поиск:** BM25 (FTS5) + вектор, слияние RRF (`k=60`). Чистый вектор обязателен… и
|
||
недостаточен: числа, аббревиатуры, имена файлов находятся только лексикой.
|
||
- **Инкремент:** сравниваем `(path, mtime, size)` со таблицей `files`; переэмбеддиваем только
|
||
изменившиеся файлы. Полная пересборка коллекции — по требованию.
|
||
- Версия чанкера/модели в `.memo/state.json`: не совпала — коллекция переиндексируется целиком.
|
||
- Сортировка результата: RRF-скор, затем свежесть (`mtime`) как тайбрейкер, чтобы свежая заметка
|
||
выигрывала у устаревшей при равном совпадении.
|
||
|
||
## 6. Эмбеддер
|
||
|
||
- База: `intfloat/multilingual-e5-small` (384 dims, ~118M параметров, ONNX/CPU через fastembed).
|
||
Мультиязычность не опция: `nomic-embed-text` на русском молча проигрывает.
|
||
- Префиксы e5 обязательны: документ — `"passage: …"`, запрос — `"query: …"`; нормализация L2.
|
||
- Апгрейд до `bge-m3` (1024) — если качество на реальных вопросах не устроит; тогда переиндекс всех коллекций.
|
||
- **Модель грузится один раз** на процесс, лениво, при первом обращении. Это аргумент за
|
||
долгоживущий процесс (MCP-сервер), а не за CLI-утилиту.
|
||
|
||
## 7. Маршрутизация (самое узкое место)
|
||
|
||
Проблема не в поиске, а в выборе **где** искать. Миллион коллекций в промпт не влезет.
|
||
|
||
- До ~50 коллекций: плоский список `name — description` целиком в инструмент (или промпт).
|
||
- До ~5 000: дерево имён (`work/*`, `work/jira/*`) — фильтр по префиксу + поиск по описаниям.
|
||
- Больше: отдельный роутер (BM25/эмбеддинг по `description` коллекций) → кандидаты → агент выбирает.
|
||
- Всегда доступен режим `scope="*"`: искать по всем индексам, но это дорого и только осознанно.
|
||
|
||
## 8. Интерфейс: инструмент-тень, а не способ записи (v0.2)
|
||
|
||
Решение v0.2 (идея пользователя, 02.10.2026): **агент пишет и читает только markdown своими обычными
|
||
средствами** (`write_file` / `patch` / `read_file`). Индекс никто через API не наполняет — он
|
||
наблюдает за файловой системой и догоняет. Инструмент поиска — только чтение, для агента он такой же
|
||
обычный, как grep. Источник правды один, и это файл.
|
||
|
||
| Инструмент | Аргументы | Возврат |
|
||
|---|---|---|
|
||
| `memo_search` | `path` (файл ИЛИ папка), `query`, `k=8`, `mode=hybrid\|lex\|vec` | `path:line`, `heading`, `score`, текст чанка (~1 200 символов) |
|
||
| `memo_status` | `path` (опц.) | что проиндексировано, когда, отстаёт ли индекс от mtime |
|
||
| `memo_reindex` | `path`, `full=false` | аварийная пересборка, если watcher ослеп |
|
||
|
||
- `path` принимает **папку** — поиск рекурсивно по коллекции. Типовой вопрос звучит «где в этой папке
|
||
про X», а не «в этой заметке».
|
||
- `memo_write` из v0.1 удалён: две дороги записи = два источника правды.
|
||
- Возврат всегда с `path:line` — агент дочитывает нужное `read_file(offset)`, а не открывает файл целиком.
|
||
|
||
## 8-bis. Наблюдатель (watcher)
|
||
|
||
- Отдельный процесс: inotify (`watchdog`) → debounce 500 мс → `stat` + `hash` → переэмбеддинг только изменившегося.
|
||
- **Событие — триггер, не истина.** Решение «изменилось ли» принимает хэш: редакторы бросают пачку
|
||
событий на одну запись (vim `4913`, atomic rename в VS Code и Obsidian).
|
||
- **Реконсиляция обязательна:** inotify слепнет на FUSE/sshfs/SMB и теряет события при переполнении
|
||
очереди. Раз в 10–15 мин — скан `(path, mtime, size)` по коллекции; холодный поиск тоже форсит перескан.
|
||
- **Два процесса над одним sqlite → WAL** (`journal_mode=WAL`), иначе «database is locked» на живой записи.
|
||
- Разделение: watcher пишет, MCP-сервер читает. stdio-MCP живёт только внутри сессии Hermes, поэтому
|
||
фоновая индексация в нём невозможна — watcher это **отдельный сервис** (`/opt/memo/`, systemd или
|
||
podman-compose), MCP подключается к его базе. Альтернатива «всё в одном» — streamable-http демон
|
||
со встроенным потоком-watcher.
|
||
- **Задержки быть не должно:** `memo_search` перед поиском делает быстрый `stat`+`hash` по целевому
|
||
пути и догоняет разницу сам. Тогда «агент записал и сразу ищет» находит правку сразу, а watcher
|
||
остаётся страховкой для правок, сделанных человеком и другими инструментами.
|
||
- Остаётся от v0.1: **карта тем** (раздел 9). Прозрачность файлового слоя не отменяет вопрос «в какой
|
||
папке искать» — её даёт скилл, а не инструмент.
|
||
|
||
## 9. Правила записи (скилл, а не код)
|
||
|
||
Файлы гниют не от поиска, а от записи «куда попало». Нужен тонкий слой дисциплины — скилл вида:
|
||
|
||
```
|
||
Тема → коллекция
|
||
инфра/серверы → infra
|
||
работа/Jira → work/jira
|
||
книги → life/books
|
||
личное → life/misc
|
||
нет темы → inbox.md (не индексируется)
|
||
```
|
||
|
||
- Новая тема = новая папка + `_meta.yaml` + строчка в скилле. Один раз.
|
||
- Дедуп: перед записью — `memo_search` по этой же коллекции; хэш точной копии не пишем дважды.
|
||
- Заметка в двух местах — запрещено; при переносе файла индекс коллекции переиндексируется, история mtime сохраняется.
|
||
|
||
## 10. Что проверяем до того, как что-то оптимизировать
|
||
|
||
1. 20–30 **реальных** вопросов к своим заметкам (не «тестовых») — вручную смотрим recall@5.
|
||
2. Отдельно — вопросы на точные значения: числа, версии, имена хостов, аббревиатуры (тут решает BM25).
|
||
3. Отдельно — вопросы «смысловые», другими словами, чем в заметке (тут решает вектор).
|
||
4. Замер: холодная индексация коллекции на N файлов, горячий поиск. Целевые ориентиры: поиск < 100 мс.
|
||
|
||
Если recall@5 на реальных вопросах ниже ~0.8 — виноват не индекс, а формулировки заметок и slug'и.
|
||
|
||
## 11. Открытые развилки (решать после прототипа, не сейчас)
|
||
|
||
- Корень библиотеки: `~/notes/memo` или внутри конкретного репо?
|
||
- Один MCP-сервер на библиотеку или свой процесс на коллекцию? (Сейчас: один на всё, коллекции — аргумент.)
|
||
- Нужен ли `memo_gc` (перемещение/удаление) или файлы двигает только человек?
|
||
- Автоматический захват сессий в `inbox.md` (крон) — или только ручные записи агента.
|
||
|
||
## 12. Чем отличается от готового в Hermes
|
||
|
||
| | провайдер Hermes (holographic / mem0) | memo |
|
||
|---|---|---|
|
||
| Стор | один на профиль | много папок, по теме |
|
||
| Истина | база данных | markdown на диске |
|
||
| Пересборка | миграция | удалить `.db` |
|
||
| Гранулярность | факт/эпизод | заметка/раздел |
|
||
| Маршрутизация | не нужна | требуется (реестр + scope) |
|
||
|
||
Вывод: для Hermes-агента это не замена провайдера, а дополнение «библиотека»; для своего агента —
|
||
самостоятельный сервис, подключаемый по MCP.
|
||
|
||
## 13. Портатив на Kotlin — из чего собирается (проверено 02.10.2026)
|
||
|
||
Разведка нашла всё, о чём говорил пользователь. Своего кода нужно мало.
|
||
|
||
| Нужда | Что берём | Где лежит | Координаты / состояние |
|
||
|---|---|---|---|
|
||
| SQLite + **вектор** + FTS5 | `caffeine-mgn/ksqlite` (наш, GitHub) | GitHub `caffeine-mgn/ksqlite`, README.ru есть | Maven Central: `pw.binom.db:ksqlite:0.1.4`. Внутри амальгама SQLite 3.53.4 + **sqlite-vec 0.1.9** (авто-регистрация), FTS3/4/5, RTREE, JSON1. JVM (JNI `.so`) + linux/mingw/Android/a… klib |
|
||
| Текстовый эмбеддинг | `subochev/text-embedding-kmp` — SigLIP2 int8, **768 dims** | git.binom.pw | caffeine: `pw.binom.ai.embeddingtext:api` / `:siglip` — актуальная **5**. Модель с `http://static.binom.pw/models/siglip2/` |
|
||
| Текст + vision в одном | `subochev/embedder-kmp` | git.binom.pw | caffeine: `pw.binom.embedder:embedder-api-text` / `-impl-text` (и vision) — **1**. Модели в артефакт не кладутся, пути отдаёт потребитель |
|
||
| Qdrant (если б понадобился) | `subochev/qdrant` (core/ktor/strong) | git.binom.pw | в caffeine **не выложен**, и это HTTP-клиент к внешнему серверу → при embedded-схеме не нужен |
|
||
| MCP (stdio + http) | `subochev/mcp` (server-shared / server-cli / server-http, `AITool<T>`) | git.binom.pw | **не опубликован** (наружу только `shared`) → либо submodule/исходники, либо свой MCP на Ktor |
|
||
| Слежение за файлами | **готового нет** | — | JVM: `java.nio.file.WatchService` (inotify под капотом), register RECURSIVE + debounce + reconcile — пишем сами, ~150 строк |
|
||
|
||
Вывод разведки: «клёвая библиотека для векторного поиска» = **sqlite-vec внутри ksqlite**, а не
|
||
отдельный Qdrant. Это ровно та схема, что в разделе 5 спеки — то есть зависимость одна:
|
||
`pw.binom.db:ksqlite` + наш эмбеддер.
|
||
|
||
### Модули (JVM-first, один Gradle-проект `memo`)
|
||
|
||
1. `memo-core` — чанкер по заголовкам, хэши/`files`-таблица, схема БД через ksqlite,
|
||
инкрементальная переиндексация, гибридный поиск (FTS5 BM25 + `vec0` KNN + RRF).
|
||
2. `memo-watch` — демон: WatchService на корень, debounce 500 мс, очередь, reconcile раз в 10–15 мин, WAL.
|
||
3. `memo-mcp` — MCP stdio+http: `memo_search` / `memo_status` / `memo_reindex`.
|
||
4. `memo-cli` — `memo index|search|status` для ручных проверок и приёмки без агента.
|
||
|
||
`core` не знает ни про MCP, ни про watcher — это драйверы. Так ядро потом переиспользуется
|
||
на Android (`-android`/`-jvm` артефакты уже есть у обеих библиотек).
|
||
|
||
### Что придётся написать руками
|
||
|
||
Чанкер, RRF-слияние, watcher с реконсиляцией, схему/миграцию, MCP-инструменты. Ориентир суммарно:
|
||
**600–900 строк Kotlin**. Всё остальное — готовые артефакты из таблицы.
|
||
|
||
### Приёмка (то, что пойдёт в ТЗ для opencode)
|
||
|
||
1. `memo index ~/notes/memo` создаёт `~/notes/memo/<тема>/.memo/index.db`, повторный прогон — no-op.
|
||
2. `memo search "<смысловой вопрос>"` находит нужный раздел (recall@5 на 20 реальных вопросах).
|
||
3. Вопрос с числом/аббревиатурой находит точное совпадение (проверка BM25, не вектора).
|
||
4. Файл, поправленный руками (`echo >> note.md`), виден в поиске ≤1 с; правка, сделанная сразу
|
||
перед поиском, видна **в том же вызове** (stat-догон).
|
||
5. MCP-сервер проходит протокольный зонд: `tools/list` + `tools/call` с непустым результатом.
|
||
6. Без сети: эмбеддинг и поиск работают при выключенном внешнем интерфейсе.
|
||
|
||
### Развилка, зафиксированная по умолчанию
|
||
|
||
Берём **JVM** (демон + watcher + CLI + MCP — серверная история, KMP-таргеты тут ничего не дают).
|
||
Android-таргеты — вторым шагом и без watcher (там индексация по запросу): библиотеки это позволяют.
|