diff --git a/MANUAL.md b/MANUAL.md new file mode 100644 index 0000000..8ae033a --- /dev/null +++ b/MANUAL.md @@ -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`, — расходник.** diff --git a/README.md b/README.md index a59f319..0bfd881 100644 --- a/README.md +++ b/README.md @@ -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) | тест-план: приёмочные проверки, команды, признаки провала | diff --git a/docs/orders/11-watch-fix.md b/docs/orders/11-watch-fix.md new file mode 100644 index 0000000..9ce7277 --- /dev/null +++ b/docs/orders/11-watch-fix.md @@ -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 по строке: `индексирую: <путь> -> обновлено `. Затем уже строки `наблюдаю:`. +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. +- Не добавлять зависимости. diff --git a/memo-watch/src/main/kotlin/memo/watch/WatchMain.kt b/memo-watch/src/main/kotlin/memo/watch/WatchMain.kt index ad1e5cb..89fe7b7 100644 --- a/memo-watch/src/main/kotlin/memo/watch/WatchMain.kt +++ b/memo-watch/src/main/kotlin/memo/watch/WatchMain.kt @@ -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) { 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) { 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 { - if (!base.isDirectory) return emptyList() - val candidates = LinkedHashSet() - candidates.add(base) - val q = ArrayDeque>() - 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" } - } -} \ No newline at end of file +} diff --git a/memo-watch/src/test/kotlin/memo/watch/WatchMainLogicTest.kt b/memo-watch/src/test/kotlin/memo/watch/WatchMainLogicTest.kt new file mode 100644 index 0000000..583b7bf --- /dev/null +++ b/memo-watch/src/test/kotlin/memo/watch/WatchMainLogicTest.kt @@ -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 = 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() + } + } +} diff --git a/scripts/accept.sh b/scripts/accept.sh index 27ff39b..b4bc99f 100755 --- a/scripts/accept.sh +++ b/scripts/accept.sh @@ -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