manual: инструкция «как пользоваться»; watch: первичный проход, коллекции по правилу ядра; приёмка: контур демона

This commit is contained in:
2026-10-02 03:43:25 +03:00
parent 2c7bca0d82
commit 3054705176
6 changed files with 648 additions and 27 deletions
+327
View File
@@ -0,0 +1,327 @@
# Как пользоваться memo
Инструкция для человека. Без внутренностей — только что делать руками.
Смысл одной фразой: **вы пишете обычные markdown-файлы, а `memo` рядом с ними отвечает на вопросы
по смыслу** — не по словам, а по содержанию, и не отправляя ничего в интернет.
---
## 1. Что это и зачем
У вас есть папка с заметками. Обычные `.md`-файлы, обычные подпапки-темы. `memo` строит рядом
с каждой папкой-темой маленький индекс (`SQLite`) и умеет искать по нему:
- **по смыслу** — «как чинят карточку в jellyfin» найдёт заметку, где написано «удаление элемента
из медиатеки», хотя слова «карточка» там нет;
- **по точному значению** — `76.117`, номер тикета, версия — найдёт точную строку;
- **сразу и то, и другое** (по умолчанию) — режим называется гибридный.
Что важно понять сразу:
| | |
|---|---|
| **Ваши файлы — главные** | `memo` только читает `.md`. Он никогда ничего в них не пишет и не переименовывает. |
| **Индекс — расходник** | `.memo/index.db` внутри папки можно удалить в любой момент. Он пересоберётся. Это не ваши данные. |
| **Всё локально** | Никаких облаков и API. Модель лежит файлом на диске, считает на процессоре. |
| **Никакого «формата memo»** | Никаких спец-тегов, баз данных для правки, экспортов. Только markdown. |
---
## 2. Структура вашей библиотеки
`memo` не навязывает вам схему — он просто следует за вашими папками.
**Коллекция** = папка, в которой лежат `.md` **напрямую**. Одна папка = одна тема = одна независимая
база. Папки друг о друге не знают.
Пример нормальной библиотеки:
```
~/notes/ <- корень библиотеки (не коллекция: тут нет .md напрямую)
├── infra/ <- КОЛЛЕКЦИЯ (тут *.md)
│ ├── servers.md
│ ├── hosts.md
│ └── .memo/index.db <- индекс, создастся сам
├── work/
│ └── jira/ <- КОЛЛЕКЦИЯ (тут *.md)
│ ├── access.md
│ └── ci.md
└── life/
└── books/ <- КОЛЛЕКЦИЯ (тут *.md)
├── history.md
└── notes.md
```
Тут три коллекции: `infra`, `work/jira`, `life/books`. Папки `work` и `life` — просто группировка,
коллекциями они не считаются (в них нет `.md` напрямую), и это правильно: иначе одни и те же заметки
индексировались бы по нескольку раз.
**Правило простое:** завёл папку, положил туда `.md` — это новая коллекция. Ничего регистрировать
не надо.
---
## 3. Установка (один раз)
Нужен JDK 21.
```bash
cd /root/WORK/memo
# 1. Собрать всё
./gradlew installDist
# 2. Скачать модель (в артефакты не входит, 287 МБ, один раз)
mkdir -p models/siglip2
curl -fSL -o models/siglip2/text_model_int8.onnx http://static.binom.pw/models/siglip2/text_model_int8.onnx
curl -fSL -o models/siglip2/tokenizer.model http://static.binom.pw/models/siglip2/tokenizer.model
# 3. Сказать, где модель (добавьте в ~/.bashrc, чтобы не повторять)
export MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2
```
Без переменной `MEMO_MODEL_DIR` программы ищут модель в `/root/WORK/memo/models/siglip2` —
если у вас этот путь, шаг 3 можно пропустить.
После сборки появятся три программы:
```
memo-cli/build/install/memo/bin/memo <- для человека и для скриптов
memo-mcp/build/install/memo-mcp/bin/memo-mcp <- для агента (MCP)
memo-watch/build/install/memo-watch/bin/memo-watch <- демон слежения (необязателен)
```
---
## 4. Первый запуск: проиндексировать и найти
```bash
CLI=/root/WORK/memo/memo-cli/build/install/memo/bin/memo
# Проиндексировать всю библиотеку (коллекции найдутся сами)
$CLI index ~/notes
#> индексировано: 4 обновлено, 4 файлов всего
#> индексировано: 4 обновлено, 4 файлов всего
#> индексировано: 4 обновлено, 4 файлов всего
#> итого: 12 обновлено в 3 коллекциях
# Спросить по смыслу
$CLI search ~/notes/infra "где публикуются релизы"
#> 0.016 /root/notes/infra/hosts.md:6 Прокси
#> Корпоративные домены проксируются кади на 76.132.
#> 0.016 /root/notes/infra/hosts.md:3 Nexus
#> Нексус доступен на 76.117, публикация — только через CI.
```
Первая строка в каждой паре — `похожесть`, адрес `файл:строка` и заголовок раздела; ниже — сам
текст куска.
Обратите внимание: первая команда была `index`, но она **не обязательна**. `search` сам до-индексирует
то, что изменилось, прямо перед поиском. Можно просто писать заметки и сразу спрашивать.
### Команды целиком
```bash
$CLI index <путь> # проиндексировать (повторный прогон — быстрый)
$CLI search <путь> "<вопрос>" # искать: и смысл, и точные слова
$CLI search <путь> "<вопрос>" --k 5 # вернуть 5 результатов (по умолчанию 8)
$CLI search <путь> "<вопрос>" --json # машинный вывод (для скриптов и агента)
$CLI search <путь> "<вопрос>" --mode lex # только точные слова (BM25)
$CLI search <путь> "<вопрос>" --mode vec # только по смыслу (вектор)
$CLI status <путь> # что проиндексировано и когда
```
Что писать в `<путь>`:
- **корень библиотеки** (`~/notes`) — поиск по всем коллекциям сразу, ответы перемешиваются и
сортируются по релевантности;
- **одну папку-коллекцию** (`~/notes/infra`) — поиск только внутри неё.
Область видно по адресам в результатах: `.../infra/hosts.md` — значит, нашли в `infra`.
---
## 5. Подключить к агенту (Hermes)
Здесь `memo` и живёт по-настоящему: агент получает **инструмент поиска** и перестаёт перечитывать
всю библиотеку целиком.
Добавьте в конфиг Hermes (`~/.hermes/config.yaml`, секция `mcp_servers`):
```yaml
mcp_servers:
memo:
command: /root/WORK/memo/memo-mcp/build/install/memo-mcp/bin/memo-mcp
env:
MEMO_MODEL_DIR: /root/WORK/memo/models/siglip2
enabled: true
```
**Инструменты подхватятся только в новой сессии Hermes** — как и любой MCP-сервер, они читаются
при старте процесса. В текущей беседе их не будет; начните новую и проверьте, что появились
`memo_search`, `memo_status`, `memo_reindex`.
Агенту после этого можно говорить просто: «поищи в заметках, чем мы чинили карточку в jellyfin».
Он вызовет `memo_search` и получит пути, строки и текст найденных кусков.
### Три инструмента
| Инструмент | Что делает |
|---|---|
| `memo_search(path, query, k, mode)` | Гибридный поиск. **Если индекса нет — создаёт его сам.** |
| `memo_status(path)` | Что проиндексировано, сколько файлов и чанков, когда обновлялось. |
| `memo_reindex(path)` | Полная переиндексация (нужна редко — обычный поиск и так до-индексирует). |
### Проверить без агента
Тот же код инструмента, но из командной строки — удобно, когда что-то не работает:
```bash
$CLI mcp-probe --tool memo_search --args '{"path":"/root/notes/infra","query":"домены","k":3}'
$CLI mcp-probe --tool memo_status --args '{"path":"/root/notes"}'
```
Ответ печатается ровно в той форме, которую получил бы агент.
---
## 6. Демон слежения — нужен ли он вам
**Короткий ответ: для работы с агентом — не нужен.** Поиск сам замечает изменения: перед каждым
запросом он сверяет дату и размер файлов и до-индексирует то, что поменялось. Написали заметку —
сразу нашли её.
Демон `memo-watch` нужен ровно в одном случае: **заметки правят мимо агента** — вы сами в редакторе,
через `git pull`, синхронизацию, чужой скрипт, — и вы хотите, чтобы индекс был свежим *заранее*,
а не в момент вопроса.
```bash
memo-watch/build/install/memo-watch/bin/memo-watch ~/notes
#> индексирую: /root/notes/infra -> обновлено 4
#> индексирую: /root/notes/life/books -> обновлено 4
#> индексирую: /root/notes/work/jira -> обновлено 4
#> наблюдаю: /root/notes/infra
#> наблюдаю: /root/notes/life/books
#> наблюдаю: /root/notes/work/jira
```
При старте он **сразу проходит по всем коллекциям** — индекс полный с первой секунды, ждать
изменений не надо. Дальше печатает `наблюдаю:` по каждой и доиндексирует по факту правок:
```
#> индексирую: /root/notes/infra -> обновлено 1
```
### Какие папки он мониторит
Ровно те, что вы передали аргументом, — по тому же правилу, что и остальные команды:
- передали **корень** (`~/notes`) — следит за всеми коллекциями внутри него (в примере — `infra`,
`work/jira`, `life/books`) и держит индекс по каждой;
- передали **одну папку** (`~/notes/infra`) — следит только за ней.
Внутрь служебных папок (`.memo`, `.git`, любые `.`-папки) он не заглядывает. Список наблюдаемых
папок печатается при старте строками `наблюдаю: <путь>` — это и есть ответ на вопрос «что он мониторит».
Демон переживает правки так: ловит изменения (с задержкой 0.5 с, чтобы не дёргаться на каждое
нажатие), раз в 10 минут делает полный сверочный проход — это страховка для случаев, когда система
о событиях файлов не сообщила (сетевые диски, sshfs).
Пока это ручной запуск: автостарта (systemd) нет, сам он нигде в системе не прописан и **сейчас не
работает** — его надо запускать руками, когда понадобится.
---
## 7. Порядок работы: как это выглядит в жизни
```bash
# 1. Завести тему — просто папка с markdown
mkdir -p ~/notes/infra
cat > ~/notes/infra/hosts.md <<'EOF'
# Хосты
## Nexus
Нексус доступен на 76.117, публикация — только через CI.
## Прокси
Корпоративные домены проксируются кади на 76.132.
EOF
# 2. Спросить
$CLI search ~/notes/infra "где публикуются релизы"
#> 0.031 /root/notes/infra/hosts.md:10 Nexus
#> Нексус доступен на 76.117, публикация — только через CI.
# 3. Дописать заметку — и сразу спросить про неё, без переиндексации
printf '\n## Свежий раздел\nуникальное_слово_дзынь_47\n' >> ~/notes/infra/hosts.md
$CLI search ~/notes/infra "уникальное слово дзынь"
#> 0.016 /root/notes/infra/hosts.md:10 Свежий раздел
#> уникальное_слово_дзынь_47
#> 0.016 /root/notes/infra/hosts.md:6 Прокси
#> Корпоративные домены проксируются кади на 76.132.
# 4. Периодически освежать всё скопом (обычно не нужно — бывает после git pull)
$CLI index ~/notes
```
Записывать заметки можно **любым способом**: редактором, `>>`, агентом, `git pull`. `memo` не
требует, чтобы записи шли через него.
---
## 8. Если что-то не так
**Поиск ничего не находит, хотя файлы на месте.**
Проверьте, что папка — коллекция, то есть `.md` лежат в ней напрямую:
```bash
$CLI status ~/notes # должны быть видны коллекции и число файлов
find ~/notes -name '*.md' | head # файлы на месте?
$CLI status ~/notes/emptygroup
#> коллекции не найдены
```
Если `status` пишет `коллекции не найдены` — вы указали папку, в которой нет `.md` напрямую
(например, общую группировку вроде `work`, где лежат только подпапки). Укажите корень библиотеки
или папку-коллекцию.
**Свежая правка не находится.**
Проверьте, что ищете в той же папке, где файл: поиск по `~/notes/infra` не увидит заметку из
`~/notes/work`. Поиск по корню видит всё.
**Хочу начать с чистого листа.**
Удалите индексы — данные не пострадают:
```bash
find ~/notes -name .memo -type d -prune -exec rm -rf {} +
$CLI index ~/notes
```
**`database is locked`.**
Значит, одновременно пишут два процесса. Подождите минуту и повторите; если повторяется — пришлите
текст ошибки, это повод для отдельного разбирательства.
**Инструменты `memo_*` не появились у агента.**
MCP-серверы читаются при старте Hermes: начните новую сессию. Проверьте, что путь к `memo-mcp`
в конфиге верный и файл исполняемый.
**Где посмотреть, что вообще происходит.**
```bash
$CLI status ~/notes # по каждой коллекции: файлов, чанков, когда индексировали
find ~/notes -name index.db # где лежат индексы
```
---
## 9. Что можно удалять без страха
| Путь | Что это | Удалять? |
|---|---|---|
| `~/notes/**/*.md` | **ваши заметки** | Нет — это ваши данные |
| `~/notes/**/.memo/` | индексы | **Да**, пересоберутся командой `index` |
| `models/siglip2/` | модель | Да, но придётся скачать заново |
| `build/` | сборка | Да, но придётся пересобрать |
Правило: **всё, что не `.md`, — расходник.**
+5
View File
@@ -2,6 +2,10 @@
Локальная библиотека заметок с семантическим поиском.
> **Как этим пользоваться — [`MANUAL.md`](MANUAL.md).** Пошаговая инструкция для человека:
> установка, команды, подключение к агенту, что мониторит демон, что можно удалять.
> Остальной README — для разработки.
**Markdown на диске — истина, `.db` рядом — пересобираемый кэш.** Агент (или человек) пишет и
читает обычные `.md`-файлы своими штатными средствами: `memo` не требует ни специального API записи,
ни изменения привычек. Он следит за файловой системой, держит рядом с каждой папкой-темой
@@ -90,6 +94,7 @@ bash scripts/accept.sh --fast # то же без мутационной про
| Документ | Что внутри |
|---|---|
| [`MANUAL.md`](MANUAL.md) | **инструкция для человека**: как пользоваться, команды, подключение к агенту |
| [`docs/SPEC.md`](docs/SPEC.md) | полная спека: модель данных, индекс, маршрутизация, watcher, интерфейс, приёмка |
| [`TASK.md`](TASK.md) | ТЗ для исполнителя: стек, схема БД, контракты, чего не делать |
| [`TESTING.md`](TESTING.md) | тест-план: приёмочные проверки, команды, признаки провала |
+93
View File
@@ -0,0 +1,93 @@
Проект: /root/WORK/memo (Kotlin/JVM). Заказ на исправление ТРЁХ доказанных дефектов демона `memo-watch`.
## Доказанные дефекты (воспроизведено на живом корпусе)
Корпус `/root/WORK/memo-e2e-watch2` — копия эталонного (`infra`, `life/books`, `work/jira`, 12 .md).
Все `.memo` предварительно удалены. Запуск:
MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 \
memo-watch/build/install/memo-watch/bin/memo-watch /root/WORK/memo-e2e-watch2
1. **Создаёт 6 баз вместо 3.** Вывод `наблюдаю:` перечисляет корень, `infra`, `life`, `work`,
`life/books`, `work/jira` и всем заводит `.memo/index.db`. Причина — своя локальная копия
`discoverCollections()` в `WatchMain.kt` (walkTopDown + depth 2 «есть .md где-то внутри»);
в `memo-core` эту же ошибку уже вылечили (`findCollections` = только каталог с *.md НАПРЯМУЮ),
но watcher остался на старой копии.
2. **Не индексирует при старте.** Сразу после запуска (без правок файлов) в базах 0 файлов и 0 чанков;
наполняется только та коллекция, в которой потом что-то изменилось. Проверка:
правка одного файла в `infra` → в `infra/.memo` появились файлов 4, чанков 12, а `life/books`
и `work/jira` остались с нулями. Никакого первичного прохода при старте нет.
3. **Держит все базы открытыми постоянно.** `WatchMain` открывает `Db` и `Embedder` на каждую
коллекцию и не закрывает до SIGINT; тот же `.db` в это время открывают CLI и MCP.
Это надо проверить на отсутствие «database is locked» (одновременные watcher + поиск).
## Что сделать
Правки ТОЛЬКО в модуле `memo-watch` (`src/main` и `src/test`). `memo-core` не менять.
1. **Убрать копию логики.** В `WatchMain.kt` удалить локальную `discoverCollections` и функцию
определения корня по `.memo`; использовать из ядра:
`memo.core.findCollections(base)` и `memo.core.resolveCollection(raw)`.
Поведение: передан один каталог-коллекция → он один; передан корень → все коллекции внутри.
2. **Первичный проход при старте.** После `watcher.start()` (или до него) выполнить для каждой
коллекции `indexer.indexTree(coll)` один раз, чтобы индекс был полным сразу, без ожидания событий.
Вывести в stdout по строке: `индексирую: <путь> -> обновлено <N>`. Затем уже строки `наблюдаю:`.
3. **Устойчивость к параллельному доступу.** Включить в `Db` уже есть WAL — не менять. Вместо этого
добавить в `WatchMain` обработку ошибок записи так, чтобы `database is locked` не убивал демон:
при ошибке печатать в stderr и повторять попытку один раз через 1 секунду (достаточно локальной
обёртки вокруг `indexer.indexTree`). Не менять код ядра.
## Тесты: дополнить memo-watch/src/test/kotlin/memo/watch/WatcherTest.kt и добавить новый файл
Новый файл `memo-watch/src/test/kotlin/memo/watch/WatchMainLogicTest.kt`, ровно 2 теста:
1. `collectionsFoundByCoreRule` — дерево: `root/a.md`, `root/sub/b.md`, `root/nested/only/deep/d.md`,
`root/empty/` → `findCollections(root)` даёт ровно три каталога (root, root/sub, root/nested/only/deep),
и среди них НЕТ `root/nested` и `root/nested/only`.
2. `startupIndexPassFillsEveryCollection` — временный корень с двумя коллекциями (`root/c1/x.md`,
`root/c2/y.md`), у каждой создаётся `Db` + `.memo`, вызывается `Indexer.indexTree(coll)` по разу
(как это делает первичный проход), после чего в обеих базах
`SELECT COUNT(*) FROM chunks` > 0 и `SELECT COUNT(*) FROM files` == 1.
Модель брать из `MEMO_MODEL_DIR` (как в CoreSmokeTest).
## Обязательная сквозная проверка (приложить вывод)
```bash
cd /root/WORK/memo
./gradlew :memo-watch:installDist -q
rm -rf /tmp/mw && cp -r /root/WORK/memo-e2e /tmp/mw
find /tmp/mw -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
# 1) старт: 3 базы, все наполнены сразу
MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 \
memo-watch/build/install/memo-watch/bin/memo-watch /tmp/mw > /tmp/mw.log 2>&1 &
WPID=$!
sleep 40
echo "--- базы:"; find /tmp/mw -name index.db | sort
echo "--- чанки сразу после старта:"
python3 - <<'EOF'
import sqlite3, glob
for db in sorted(glob.glob("/tmp/mw/**/.memo/index.db", recursive=True)):
c = sqlite3.connect("file:"+db+"?mode=ro", uri=True)
print(db.replace("/tmp/mw",""), "файлов", c.execute("SELECT COUNT(*) FROM files").fetchone()[0],
"чанков", c.execute("SELECT COUNT(*) FROM chunks").fetchone()[0])
c.close()
EOF
# 2) параллельно с работающим демоном ищем тем же ядром — не должно быть locked
memo-cli/build/install/memo/bin/memo search /tmp/mw/life/books "кто ведёт рассказ в романе" --k 3 --json | head -c 400
echo
kill $WPID 2>/dev/null; wait $WPID 2>/dev/null
cat /tmp/mw.log
```
Ожидается: РОВНО три пути `index.db`; у каждой коллекции ненулевые файлы и чанки СРАЗУ после старта;
поиск при работающем демоне отвечает без ошибки `database is locked`; в логе — строки
`индексирую: ... -> обновлено N` и `наблюдаю: ...` (по три каждого вида).
После: ./gradlew test --rerun-tasks — все тесты проекта зелёные.
Коммит: git add -A && git commit -m "watch: первичный проход при старте, коллекции по правилу ядра"
СТРОГИЕ ЗАПРЕТЫ:
- Не выводить план текстом; сразу правь файлы.
- Не менять memo-core, memo-cli, memo-mcp.
- Не менять смысл существующих тестов WatcherTest.
- Не добавлять зависимости.
@@ -3,6 +3,8 @@ package memo.watch
import memo.core.Db
import memo.core.Embedder
import memo.core.Indexer
import memo.core.findCollections
import memo.core.resolveCollection
import java.io.File
import java.util.concurrent.CountDownLatch
import kotlin.system.exitProcess
@@ -13,8 +15,8 @@ fun main(args: Array<String>) {
exitProcess(2)
}
val raw = File(args[0])
val base = if (raw.name == ".memo") raw.parentFile ?: raw else raw
val collections = discoverCollections(base)
val base = resolveCollection(raw)
val collections = findCollections(base)
if (collections.isEmpty()) {
System.err.println("коллекции не найдены в ${base.absolutePath}")
exitProcess(1)
@@ -34,10 +36,14 @@ fun main(args: Array<String>) {
CollCtx(coll, db, embedder, indexer)
}
for (ctx in ctxList) {
safeIndexTree(ctx.indexer, ctx.root)
}
val indexersByCollection = ctxList.associateBy { it.root }
val watcher = Watcher(
collections = ctxList.map { it.root },
index = { coll -> indexersByCollection.getValue(coll).indexer.indexTree(coll) },
index = { coll -> safeIndexTree(indexersByCollection.getValue(coll).indexer, coll) },
)
Runtime.getRuntime().addShutdownHook(
@@ -65,26 +71,25 @@ private data class CollCtx(
val indexer: Indexer,
)
private fun discoverCollections(base: File): List<File> {
if (!base.isDirectory) return emptyList()
val candidates = LinkedHashSet<File>()
candidates.add(base)
val q = ArrayDeque<Pair<File, Int>>()
q.addLast(base to 0)
while (q.isNotEmpty()) {
val (d, depth) = q.removeFirst()
if (depth >= 2) continue
val children = d.listFiles() ?: continue
for (c in children) {
if (c.isDirectory && !c.name.startsWith(".")) {
candidates.add(c)
q.addLast(c to depth + 1)
}
private fun safeIndexTree(indexer: Indexer, root: File): Int {
return try {
val n = indexer.indexTree(root)
println("индексирую: ${root.absolutePath} -> обновлено $n")
n
} catch (t: Throwable) {
System.err.println(
"watcher: indexTree ${root.absolutePath}: ${t.message}, повтор через 1с"
)
Thread.sleep(1000)
try {
val n = indexer.indexTree(root)
println("индексирую: ${root.absolutePath} -> обновлено $n")
n
} catch (t2: Throwable) {
System.err.println(
"watcher: повтор indexTree ${root.absolutePath} провалился: ${t2.message}"
)
0
}
}
return candidates.filter { d ->
d.walkTopDown()
.maxDepth(8)
.any { it.isFile && it.extension == "md" }
}
}
@@ -0,0 +1,135 @@
package memo.watch
import memo.core.Db
import memo.core.Embedder
import memo.core.Indexer
import memo.core.findCollections
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
class WatchMainLogicTest {
private fun newRoot(prefix: String): File {
val root = File.createTempFile(prefix, "")
assertTrue(root.delete(), "temp cleanup")
assertTrue(root.mkdirs(), "temp mkdir")
root.deleteOnExit()
return root
}
private fun touchMd(parent: File, name: String, body: String = "# $name\n"): File {
val f = File(parent, name)
f.writeText(body)
f.deleteOnExit()
return f
}
@Test
fun collectionsFoundByCoreRule() {
val root = newRoot("memo-watchlogic-coll-")
touchMd(root, "a.md")
val sub = File(root, "sub"); sub.mkdirs()
touchMd(sub, "b.md")
val nested = File(root, "nested"); nested.mkdirs()
val only = File(nested, "only"); only.mkdirs()
val deep = File(only, "deep"); deep.mkdirs()
touchMd(deep, "d.md")
File(root, "empty").mkdirs()
val got = findCollections(root).map { it.absolutePath }.toSet()
val want = setOf(
root.absolutePath,
File(root, "sub").absolutePath,
File(root, "nested/only/deep").absolutePath,
)
assertEquals(3, got.size, "ожидалось ровно 3 коллекции, получено ${got.size}")
assertEquals(want, got, "набор коллекций не совпадает с правилом ядра")
assertTrue(
File(root, "nested").absolutePath !in got,
"nested без .md напрямую не должен быть коллекцией",
)
assertTrue(
File(root, "nested/only").absolutePath !in got,
"nested/only без .md напрямую не должен быть коллекцией",
)
assertTrue(
File(root, "empty").absolutePath !in got,
"пустой каталог не должен быть коллекцией",
)
root.deleteRecursively()
}
@Test
fun startupIndexPassFillsEveryCollection() {
val root = newRoot("memo-watchlogic-startup-")
val c1 = File(root, "c1"); c1.mkdirs()
touchMd(c1, "x.md", "# x\nпривет мир\n## Второй\nещё немного текста\n")
val c2 = File(root, "c2"); c2.mkdirs()
touchMd(c2, "y.md", "# y\nдругой текст\n## Третий\nещё строка\n")
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
val modelPath = "$modelDir/text_model_int8.onnx"
val tokenizerPath = "$modelDir/tokenizer.model"
if (!File(modelPath).exists() || !File(tokenizerPath).exists()) {
fail("модель не найдена: $modelDir")
}
data class Ctx(val db: Db, val embedder: Embedder, val indexer: Indexer)
val ctxByColl: Map<File, Ctx> = listOf(c1, c2).associateWith { coll ->
val memoDir = File(coll, ".memo"); memoDir.mkdirs()
val db = Db(File(memoDir, "index.db").absolutePath)
db.init()
val embedder = Embedder(modelPath, tokenizerPath)
val indexer = Indexer(db, embedder)
Ctx(db, embedder, indexer)
}
try {
for ((coll, ctx) in ctxByColl) {
val updated = ctx.indexer.indexTree(coll)
assertTrue(
updated > 0,
"indexTree обязан переиндексировать хотя бы один файл в ${coll.absolutePath}",
)
}
for ((coll, ctx) in ctxByColl) {
val filesStmt = ctx.db.conn.prepare("SELECT COUNT(*) FROM files")
val chunksStmt = ctx.db.conn.prepare("SELECT COUNT(*) FROM chunks")
try {
val rsF = filesStmt.executeQuery()
val filesCount = try {
assertTrue(rsF.next(), "SELECT COUNT(*) FROM files должен вернуть строку")
rsF.getLong(0)!!
} finally { rsF.close() }
val rsC = chunksStmt.executeQuery()
val chunksCount = try {
assertTrue(rsC.next(), "SELECT COUNT(*) FROM chunks должен вернуть строку")
rsC.getLong(0)!!
} finally { rsC.close() }
assertEquals(
1L, filesCount,
"ожидался ровно 1 файл в ${coll.absolutePath}, получено $filesCount",
)
assertTrue(
chunksCount > 0,
"ожидались чанки в ${coll.absolutePath}, получено $chunksCount",
)
} finally {
filesStmt.close()
chunksStmt.close()
}
}
} finally {
for ((_, ctx) in ctxByColl) {
try { ctx.embedder.close() } catch (_: Throwable) {}
try { ctx.db.close() } catch (_: Throwable) {}
}
root.deleteRecursively()
}
}
}
+59 -3
View File
@@ -8,8 +8,10 @@
# Контуры:
# 1. Юнит-тесты всех модулей (gradle test --rerun-tasks).
# 2. Мутационная проверка тестов (e2e/mutation_check.py) — тесты обязаны падать на сломанном коде.
# 3. Сквозной recall@5 на эталонном корпусе (e2e/run_e2e.py).
# 4. MCP-протокол живым клиентом (scripts/mcp_probe.py), включая холодный старт.
# 3. Сквозной индекс эталонного корпуса: идемпотентность и ровно 3 коллекции.
# 4. Сквозной recall@5 на эталонном корпусе (e2e/run_e2e.py).
# 5. Демон слежения: первичный проход, 3 базы, отсутствие «locked» при параллельном чтении.
# 6. MCP-протокол живым клиентом (scripts/mcp_probe.py), включая холодный старт.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -81,7 +83,61 @@ if python3 e2e/run_e2e.py --cli "$CLI" --root "$CORPUS" --k 5 > /tmp/memo-e2e.lo
else
echo "ПРОВАЛ recall:"; tail -25 /tmp/memo-e2e.log; FAILED+=("recall"); fi
section "5. MCP: протокол + холодный старт"
section "5. демон слежения (memo-watch)"
# Демон проверяется отдельным контуром: три его дефекта (6 баз вместо 3, отсутствие первичного
# прохода, «locked» при параллельном чтении) прошли мимо юнит-тестов и зелёной приёмки.
WATCH="$ROOT/memo-watch/build/install/memo-watch/bin/memo-watch"
WROOT=/tmp/memo-accept-watch
rm -rf "$WROOT"; cp -r "$CORPUS" "$WROOT"
find "$WROOT" -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
"$WATCH" "$WROOT" > /tmp/memo-watch.log 2>&1 &
WPID=$!
WOK=1
# ждём не «на глаз», а по факту: пока в каждой коллекции не появятся чанки (максимум 120 с)
for _ in $(seq 1 120); do
READY=$(python3 - "$WROOT" <<'EOF'
import sqlite3, glob, sys
root = sys.argv[1]
dbs = sorted(glob.glob(root + "/**/.memo/index.db", recursive=True))
if len(dbs) != 3:
print(0); raise SystemExit
n = 0
for db in dbs:
try:
c = sqlite3.connect("file:" + db + "?mode=ro", uri=True)
if c.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] > 0: n += 1
c.close()
except Exception:
pass
print(n)
EOF
)
[ "$READY" = "3" ] && break
sleep 1
done
WDBS=$(find "$WROOT" -name index.db | wc -l)
echo " баз индекса : $WDBS (ожидается 3)"
if [ "$WDBS" != "3" ]; then
echo "ПРОВАЛ: демон завёл $WDBS баз вместо 3"; WOK=0
fi
if [ "$READY" = "3" ]; then
echo "OK первичный проход: все 3 коллекции наполнены сразу после старта"
else
echo "ПРОВАЛ: первичного прохода нет — наполнено коллекций: $READY из 3"; WOK=0
fi
# параллельно с работающим демоном — тот же файл читаем поиском
WSEARCH=$("$CLI" search "$WROOT/life/books" "кто ведёт рассказ в романе" --k 1 2>&1)
if echo "$WSEARCH" | grep -qi "locked"; then
echo "ПРОВАЛ: database is locked при чтении во время работы демона"; WOK=0
elif [ -n "$WSEARCH" ]; then
echo "OK поиск при работающем демоне отвечает без locked"
else
echo "ПРОВАЛ: поиск при работающем демоне ничего не вернул"; WOK=0
fi
kill "$WPID" 2>/dev/null; wait "$WPID" 2>/dev/null
[ "$WOK" = "1" ] || FAILED+=("демон слежения")
section "6. MCP: протокол + холодный старт"
COLD=/tmp/memo-accept-cold
rm -rf "$COLD"; cp -r "$CORPUS" "$COLD"
find "$COLD" -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null