Files

240 lines
20 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.
# 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 (там индексация по запросу): библиотеки это позволяют.