Compare commits
12 Commits
d62a26f5b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 607137fdf7 | |||
| 69a0ebe0fa | |||
| 3054705176 | |||
| 2c7bca0d82 | |||
| dc8d9bc598 | |||
| 2ed736a9bc | |||
| f2956e3cef | |||
| f1d497d336 | |||
| 8b78309c56 | |||
| a2f09fffb6 | |||
| c33f94fa13 | |||
| de3cb24edc |
@@ -0,0 +1,366 @@
|
||||
# Как пользоваться 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
|
||||
./gradlew installDist
|
||||
```
|
||||
|
||||
**Больше ничего делать не надо.** Модель (287 МБ) скачается сама при первом использовании —
|
||||
при первом `index`, `search` или `status`. Выглядит это так:
|
||||
|
||||
```
|
||||
модель не найдена в /root/WORK/memo/models/siglip2, скачиваю с http://static.binom.pw/models/siglip2 (≈287 МБ, один раз)
|
||||
скачиваю text_model_int8.onnx: 45% (128 МБ / 283 МБ)
|
||||
скачано text_model_int8.onnx: 283 МБ
|
||||
скачано tokenizer.model: 4 МБ
|
||||
```
|
||||
|
||||
Качается один раз: если файлы на месте, в сеть никто не ходит — поиск и индексация работают офлайн.
|
||||
|
||||
### Скачать заранее (необязательно)
|
||||
|
||||
Если хотите подготовить модель до первого запуска:
|
||||
|
||||
```bash
|
||||
CLI=/root/WORK/memo/memo-cli/build/install/memo/bin/memo
|
||||
$CLI model
|
||||
#> скачано: text_model_int8.onnx, tokenizer.model (287679278 байт)
|
||||
|
||||
$CLI model # повторный запуск: ничего не качает
|
||||
#> модель уже на месте в /root/WORK/memo/models/siglip2: text_model_int8.onnx, tokenizer.model
|
||||
|
||||
$CLI model --force # перекачать принудительно
|
||||
$CLI model --dir /другой/путь
|
||||
```
|
||||
|
||||
Скачивание можно прервать и продолжить: файл пишется как `<имя>.part`, а при следующем запуске
|
||||
закачка **дописывается с места обрыва** (сервер поддерживает докачку). Целевой файл появляется
|
||||
только после того, как размер сошёлся, — «битого, но выглядящего рабочим» файла не будет.
|
||||
|
||||
### Где живёт модель и как это менять
|
||||
|
||||
По умолчанию — `/root/WORK/memo/models/siglip2`. Одна копия на все три программы, поэтому
|
||||
в дистрибутивы она не кладётся (иначе было бы 287 МБ × 3).
|
||||
|
||||
| Переменная | Смысл |
|
||||
|---|---|
|
||||
| `MEMO_MODEL_DIR` | где лежит модель (иначе `/root/WORK/memo/models/siglip2`) |
|
||||
| `MEMO_MODEL_AUTO_DOWNLOAD=0` | запретить автоматическое скачивание. Тогда при отсутствии модели будет ошибка `модель не найдена в <путь>; запустите: memo model` |
|
||||
|
||||
```bash
|
||||
export MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 # добавьте в ~/.bashrc, чтобы не повторять
|
||||
```
|
||||
|
||||
После сборки появятся три программы:
|
||||
|
||||
```
|
||||
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 model # скачать модель (обычно не нужно — сама скачается)
|
||||
$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`
|
||||
в конфиге верный и файл исполняемый.
|
||||
|
||||
**Агент долго не отвечает на первый запрос.**
|
||||
Скорее всего, скачивается модель (287 МБ) — в stderr `memo-mcp` идёт строка
|
||||
`модель не найдена ..., скачиваю ...`. Это разовое: скачайте заранее командой `$CLI model`
|
||||
или подложите файлы в `MEMO_MODEL_DIR`.
|
||||
|
||||
**Где посмотреть, что вообще происходит.**
|
||||
```bash
|
||||
$CLI status ~/notes # по каждой коллекции: файлов, чанков, когда индексировали
|
||||
find ~/notes -name index.db # где лежат индексы
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Что можно удалять без страха
|
||||
|
||||
| Путь | Что это | Удалять? |
|
||||
|---|---|---|
|
||||
| `~/notes/**/*.md` | **ваши заметки** | Нет — это ваши данные |
|
||||
| `~/notes/**/.memo/` | индексы | **Да**, пересоберутся командой `index` |
|
||||
| `models/siglip2/` | модель | Да, но придётся скачать заново |
|
||||
| `build/` | сборка | Да, но придётся пересобрать |
|
||||
|
||||
Правило: **всё, что не `.md`, — расходник.**
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Локальная библиотека заметок с семантическим поиском.
|
||||
|
||||
> **Как этим пользоваться — [`MANUAL.md`](MANUAL.md).** Пошаговая инструкция для человека:
|
||||
> установка, команды, подключение к агенту, что мониторит демон, что можно удалять.
|
||||
> Остальной README — для разработки.
|
||||
|
||||
**Markdown на диске — истина, `.db` рядом — пересобираемый кэш.** Агент (или человек) пишет и
|
||||
читает обычные `.md`-файлы своими штатными средствами: `memo` не требует ни специального API записи,
|
||||
ни изменения привычек. Он следит за файловой системой, держит рядом с каждой папкой-темой
|
||||
@@ -10,17 +14,21 @@
|
||||
|
||||
## Статус
|
||||
|
||||
Проектирование завершено. Код не написан.
|
||||
Реализовано и принято на эталонном корпусе. 38 тестов, мутационная проверка, сквозной recall@5 = 20/20.
|
||||
|
||||
| Документ | Что внутри |
|
||||
| Что | Вердикт приёмки |
|
||||
|---|---|
|
||||
| [`docs/SPEC.md`](docs/SPEC.md) | полная спека: модель данных, индекс, маршрутизация, watcher, интерфейс, приёмка |
|
||||
| [`TASK.md`](TASK.md) | ТЗ для исполнителя (opencode): стек, схема БД, контракты, чего не делать |
|
||||
| [`TESTING.md`](TESTING.md) | тест-план: 6 приёмочных проверок, команды, признаки провала |
|
||||
| юнит-тесты всех модулей | ✅ |
|
||||
| мутационная проверка (13 мутаций) | ✅ все убиты |
|
||||
| индексация: 3 коллекции, повторный прогон — 0 обновлений | ✅ |
|
||||
| recall@5 на эталонном корпусе (sem 20/20, lex 3/3) | ✅ |
|
||||
| MCP-протокол живым клиентом + холодный старт | ✅ |
|
||||
|
||||
## Идея
|
||||
|
||||
- Коллекций (папок-тем) — сколько угодно; они не мешают друг другу.
|
||||
- **Коллекция — каталог, в котором есть `*.md` напрямую** (не в подкаталогах). Вложенные папки
|
||||
без своих заметок коллекциями не считаются — иначе одни и те же файлы индексируются по нескольку раз.
|
||||
- Индекс каждой коллекции живёт в её `.memo/index.db` и **удаляется без потерь** — пересобирается.
|
||||
- Поиск — обычный read-only инструмент; записи в markdown он не делает никогда.
|
||||
- Только локально: эмбеддинг на CPU, сеть не нужна ни при индексации, ни при поиске.
|
||||
@@ -36,6 +44,63 @@ memo-mcp # MCP-сервер (stdio): memo_search / memo_status / memo_reinde
|
||||
|
||||
`memo-core` не знает ни про watcher, ни про MCP — это драйверы поверх ядра.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
./gradlew installDist # собрать все дистрибутивы
|
||||
|
||||
CLI=memo-cli/build/install/memo/bin/memo
|
||||
$CLI model # скачать модель (необязательно — скачается сама при первом запуске)
|
||||
$CLI index ~/notes # проиндексировать дерево (коллекции найдутся сами)
|
||||
$CLI search ~/notes "чем чинят карточку в jellyfin"
|
||||
$CLI search ~/notes "76.132" --mode lex --json
|
||||
$CLI status ~/notes
|
||||
```
|
||||
|
||||
MCP-сервер (stdio), регистрируется как обычный MCP-сервер:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"memo": {
|
||||
"command": "/opt/memo/memo-mcp",
|
||||
"env": { "MEMO_MODEL_DIR": "/opt/memo/models/siglip2" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Инструменты: `memo_search(path, query, k, mode)`, `memo_status(path)`, `memo_reindex(path)`.
|
||||
`memo_search` **сам создаёт индекс**, если его ещё нет — можно просто писать `.md` и сразу искать.
|
||||
|
||||
Демон слежения (для правок мимо агента — людьми, git, сторонними редакторами):
|
||||
|
||||
```bash
|
||||
memo-watch/build/install/memo-watch/bin/memo-watch ~/notes
|
||||
```
|
||||
|
||||
## Приёмка
|
||||
|
||||
```bash
|
||||
bash scripts/accept.sh # полный прогон: сборка, тесты, мутации, индекс, recall, MCP
|
||||
bash scripts/accept.sh --fast # то же без мутационной проверки (самая долгая)
|
||||
```
|
||||
|
||||
Правило проекта: **«зелёная сборка» ничего не доказывает.** Тесты проверяются мутациями
|
||||
(`e2e/mutations.tsv`): боевой код ломается, и нужный тест обязан упасть. Выжившая мутация —
|
||||
дыра в покрытии, а не удача. Приёмка идёт на реальном корпусе (`scripts/make_e2e_corpus.sh`),
|
||||
а не на моках.
|
||||
|
||||
## Документы
|
||||
|
||||
| Документ | Что внутри |
|
||||
|---|---|
|
||||
| [`MANUAL.md`](MANUAL.md) | **инструкция для человека**: как пользоваться, команды, подключение к агенту |
|
||||
| [`docs/SPEC.md`](docs/SPEC.md) | полная спека: модель данных, индекс, маршрутизация, watcher, интерфейс, приёмка |
|
||||
| [`TASK.md`](TASK.md) | ТЗ для исполнителя: стек, схема БД, контракты, чего не делать |
|
||||
| [`TESTING.md`](TESTING.md) | тест-план: приёмочные проверки, команды, признаки провала |
|
||||
| [`docs/orders/`](docs/orders/) | журнал заказов исполнителю — с доказательствами дефектов, а не «сделай хорошо» |
|
||||
|
||||
## Зависимости (проверено 02.10.2026)
|
||||
|
||||
| Что | Координата | Откуда |
|
||||
|
||||
+7
-5
@@ -23,16 +23,18 @@ export MEMO_ROOT=/root/WORK/memo-e2e
|
||||
## T1. Индексация и её повтор
|
||||
|
||||
```bash
|
||||
cd /root/WORK/memo && ./gradlew :memo-cli:installDist -q
|
||||
CLI=./memo-cli/build/install/memo-cli/bin/memo
|
||||
cd /root/WORK/memo && ./gradlew installDist -q
|
||||
CLI=./memo-cli/build/install/memo/bin/memo
|
||||
$CLI index "$MEMO_ROOT"; $CLI status "$MEMO_ROOT"
|
||||
ls -l "$MEMO_ROOT"/*/.memo/index.db
|
||||
find "$MEMO_ROOT" -name index.db | sort # ожидается 3 пути, по одному на коллекцию
|
||||
$CLI index "$MEMO_ROOT" # второй прогон
|
||||
```
|
||||
|
||||
Ожидается: в каждой папке появился `.memo/index.db`; второй прогон **не переэмбеддивает** ни одного файла
|
||||
Ожидается: в каждой коллекции появился `.memo/index.db` (**ровно три** — вложенные папки без своих
|
||||
заметок коллекциями не считаются); второй прогон **не переэмбеддивает** ни одного файла
|
||||
(в выводе 0 обновлённых, `status` показывает те же `indexed_at`). Провал: нет `.memo/index.db`;
|
||||
второй прогон молча переиндексирует всё.
|
||||
баз больше трёх (значит, коллекцией считается каждая папка с .md внутри); второй прогон молча
|
||||
переиндексирует всё.
|
||||
|
||||
## T2. Смысловой поиск (вектор работает)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ fun chunkMarkdown(text: String): List<Chunk>
|
||||
## ChunkerTest.kt — ровно 5 тестов, имена ровно такие
|
||||
|
||||
1. `splitsByHeadingLevels` — вход с '# A', '## B', '### C' → 3 чанка, heading = ["A","B","C"], line = [1,3,5], ord = [0,1,2].
|
||||
2. `textBeforeFirstHeadingBecomesChunkWithEmptyHeading` — ввод «преамбула\n# Заголовок\nтело» → 2 чанка, первый: heading "»", line 1; второй heading "Заголовок".
|
||||
2. `textBeforeFirstHeadingBecomesChunkWithEmptyHeading` — ввод "преамбула\n# Заголовок\nтело" → 2 чанка, первый: heading = "" (пустая строка), line 1; второй heading "Заголовок".
|
||||
3. `fourHashesIsNotAHeading` — строка '#### не заголовок' остаётся частью текста предыдущего раздела.
|
||||
4. `emptySectionsAreDropped` — ввод с двумя заголовками подряд без текста между ними → 1 чанк.
|
||||
5. `longSectionIsSplitWithOverlap` — раздел из 6000 символов 'а' → 2 чанка, длина первого 4000,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по индексации markdown-файлов в базу.
|
||||
|
||||
Создать РОВНО ОДИН новый файл: memo-core/src/main/kotlin/memo/core/Indexer.kt
|
||||
Дополнить существующий: memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt (добавить ОДИН новый тест-метод в конец класса, существующие не трогать)
|
||||
|
||||
## Indexer.kt
|
||||
|
||||
package memo.core
|
||||
|
||||
class Indexer(private val db: Db, private val embedder: Embedder) {
|
||||
|
||||
/** Индексирует один файл. Возвращает true, если файл был (пере)проиндексирован,
|
||||
* false — если пропущен (не изменился с прошлой индексации). */
|
||||
fun indexFile(path: java.io.File): Boolean
|
||||
|
||||
/** Индексирует все .md файлы в дереве каталога root (рекурсивно). Возвращает число
|
||||
* реально переиндексированных файлов. */
|
||||
fun indexTree(root: java.io.File): Int
|
||||
}
|
||||
|
||||
Правила:
|
||||
1. Перед индексацией файла сравнить с записью в таблице files по колонке path:
|
||||
- если записи нет — индексировать;
|
||||
- если mtime и size совпадают с сохранёнными — вернуть false, ничего не делать (НЕ считать хэш);
|
||||
- если mtime/size изменились — посчитать sha-256 содержимого; если хэш совпал с сохранённым —
|
||||
обновить в files поля mtime/size/indexed_at (файл тронули, содержимое то же) и вернуть false;
|
||||
- иначе — полная переиндексация файла.
|
||||
2. Полная переиндексация файла: удалить из chunks все строки с этим path; удалить из chunks_fts строки
|
||||
с rowid из удалённых chunks (DELETE FROM chunks_fts WHERE rowid IN (...)); то же для chunks_vec.
|
||||
Затем разбить текст через chunkMarkdown (см. memo-core/src/main/kotlin/memo/core/Chunker.kt),
|
||||
для каждого чанка: посчитать эмбеддинг embedder.embed(chunk.text), вставить строку в chunks,
|
||||
взять last_insert_rowid() и вставить те же rowid в chunks_fts (колонки text, heading)
|
||||
и в chunks_vec (колонка embedding). Всё — в одной транзакции (conn.beginTransaction/commit/rollback).
|
||||
3. В files писать: path (абсолютный), mtime (доля секунды, REAL), size, hash (hex sha-256), indexed_at (epoch секунды).
|
||||
4. indexTree: обход рекурсивно, только файлы с расширением .md, скрытые каталоги (начинающиеся с '.')
|
||||
и каталоги .memo не обходить. Возвращать число переиндексированных файлов.
|
||||
|
||||
## Тест: добавить в CoreSmokeTest.kt метод
|
||||
|
||||
fun indexerSkipsUnchangedFile()
|
||||
|
||||
- создать временный каталог, в нём файл note.md с содержимым "# Заголовок\nтело заметки\n## Второй\nещё текст";
|
||||
- создать Db на временном index.db (не забыть db.init()) и Embedder на модели
|
||||
(путь брать так же, как в существующем тесте embedderProduces768 — через MEMO_MODEL_DIR);
|
||||
- первый indexFile(...) должен вернуть true;
|
||||
- второй indexFile(...) сразу же — обязан вернуть false (файл не менялся);
|
||||
- дописать в файл "\n## Третий\nновый текст\n", второй indexFile обязан вернуть true;
|
||||
- в конце: assertTrue, что SELECT count(*) FROM chunks без условия больше нуля (запрос через db.conn).
|
||||
|
||||
После: ./gradlew :memo-core:test --rerun-tasks — все тесты зелёные.
|
||||
Коммит: git add -A && git commit -m "core: индексатор файлов с пропуском неизменённых"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу создавай/правь файлы.
|
||||
- Не трогать Chunker.kt, Embedder.kt, Db.kt, build.gradle.kts и любые другие файлы.
|
||||
- Не менять существующие тесты.
|
||||
- Не добавлять зависимости.
|
||||
@@ -0,0 +1,76 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по поиску.
|
||||
|
||||
Создать РОВНО ДВА новых файла:
|
||||
memo-core/src/main/kotlin/memo/core/Searcher.kt
|
||||
memo-core/src/test/kotlin/memo/core/SearcherTest.kt
|
||||
|
||||
## Searcher.kt
|
||||
|
||||
package memo.core
|
||||
|
||||
enum class SearchMode { HYBRID, LEX, VEC }
|
||||
|
||||
data class Hit(
|
||||
val path: String,
|
||||
val line: Int,
|
||||
val heading: String,
|
||||
val score: Double,
|
||||
val text: String, // текст чанка, обрезанный до 1200 символов
|
||||
)
|
||||
|
||||
fun interface RefreshHook { fun refresh(root: java.io.File) }
|
||||
|
||||
class Searcher(
|
||||
private val db: Db,
|
||||
private val embedder: Embedder,
|
||||
private val refresh: RefreshHook? = null,
|
||||
) {
|
||||
/** Ищет по индексу. Если задан [refresh] — сначала вызывает его для root
|
||||
* (догон свежих правок файлов до поиска). */
|
||||
fun search(root: java.io.File, query: String, k: Int = 8, mode: SearchMode = SearchMode.HYBRID): List<Hit>
|
||||
}
|
||||
|
||||
Правила реализации:
|
||||
1. Если refresh != null — вызвать refresh.refresh(root) ПЕРЕД любым запросом к базе.
|
||||
2. Лексический список (режимы LEX и HYBRID): разбить query на токены по всем символам, кроме букв
|
||||
и цифр (unicode: Character.isLetterOrDigit); оставить токены длиной >= 2; взять первые 8;
|
||||
собрать строку вида "ток1" OR "ток2" (каждый токен в двойных кавычках);
|
||||
если токенов нет — лексический список пуст, без ошибки.
|
||||
Запрос: SELECT rowid, bm25(chunks_fts) AS s FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY s LIMIT 32.
|
||||
Ошибку SQLite на MATCH не ронять наружу, а считать списком пустым.
|
||||
3. Векторный список (режимы VEC и HYBRID): embedder.embed(query) → FloatArray[768];
|
||||
запрос: SELECT rowid, distance FROM chunks_vec WHERE embedding MATCH ? ORDER BY distance LIMIT 32.
|
||||
Байндить вектор тем же способом, что в тесте vec0KnnRoundTrip (bindVector).
|
||||
4. Слияние RRF: score(rowid) = сумма по спискам 1.0/(60.0 + rank), где rank — позиция в списке, начиная с 1.
|
||||
В режиме LEX — только лексический список, в VEC — только векторный, в HYBRID — оба.
|
||||
5. По итоговому score взять первые k rowid; вычитать из chunks поля path, line, heading, text
|
||||
(SELECT path, line, heading, text FROM chunks WHERE id IN (...)); сохранить порядок по score убыванию.
|
||||
6. text обрезать до 1200 символов (если длиннее — substring(0,1200) без многоточий).
|
||||
7. Пустой индекс → пустой список, без исключений.
|
||||
|
||||
## SearcherTest.kt — ровно 5 тестов, имена ровно такие
|
||||
|
||||
Подготовка (общий помощник): временный каталог + Db(index.db) + db.init() + Embedder(модель из MEMO_MODEL_DIR
|
||||
как в CoreSmokeTest.embedderProduces768). Помощник `addChunk(path, heading, line, text)`, который вставляет
|
||||
строку в chunks, берёт last_insert_rowid() и вставляет тот же rowid в chunks_fts(text, heading)
|
||||
и в chunks_vec(embedding) с эмбеддингом embedder.embed(text).
|
||||
|
||||
1. `lexModeFindsExactValue` — 3 чанка, в одном текст "Прокси корпоративных доменов на 76.132";
|
||||
поиск с mode = LEX по запросу "76.132" → первый хит содержит "76.132".
|
||||
2. `vecModeFindsSemanticMatch` — 3 чанка на разные темы (сервер/книга/CI);
|
||||
запрос про «настройку выпуска приложения», mode = VEC → первый хит — чанк про CI.
|
||||
3. `lexModeIgnoresVectorOnlyMatch` — запрос, не имеющий ни одного общего токена с чанками
|
||||
(например, при тексте чанков "опрос", "случай", "знание" запрос "автомобиль");
|
||||
mode = LEX → пустой список, mode = VEC → непустой.
|
||||
4. `hybridCombinesBoth` — в индексе чанк с точным значением и чанк со смысловым совпадением;
|
||||
запрос, попадающий в оба, mode = HYBRID, k = 5 → в результате присутствуют оба чанка.
|
||||
5. `resultsRespectKAndTextLength` — 4 чанка; k = 2 → ровно 2 хита; у каждого text непустой и его длина <= 1200.
|
||||
|
||||
После: ./gradlew :memo-core:test --rerun-tasks — все тесты зелёные.
|
||||
Коммит: git add -A && git commit -m "core: гибридный поиск (BM25 + вектор + RRF)"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу создавай файлы.
|
||||
- Не трогать существующие файлы (Chunker.kt, Indexer.kt, Embedder.kt, Db.kt, CoreSmokeTest.kt, ChunkerTest.kt, build.gradle.kts).
|
||||
- Не добавлять зависимости.
|
||||
- Не создавать другие файлы.
|
||||
@@ -0,0 +1,79 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по CLI — руками проверяемые команды.
|
||||
|
||||
Правки:
|
||||
- memo-cli/build.gradle.kts: добавить application-плагин и зависимость на :memo-core
|
||||
- новый файл memo-cli/src/main/kotlin/memo/cli/Main.kt
|
||||
- новый файл memo-cli/src/test/kotlin/memo/cli/CliArgsTest.kt
|
||||
|
||||
## Настройка модуля memo-cli
|
||||
|
||||
build.gradle.kts модуля:
|
||||
plugins { kotlin("jvm"); application }
|
||||
application { mainClass.set("memo.cli.MainKt") }
|
||||
dependencies { implementation(project(":memo-core")) }
|
||||
|
||||
## Main.kt — контракт команд
|
||||
|
||||
fun main(args: Array<String>)
|
||||
|
||||
Разбор аргументов вынести в чистую функцию (тестируемую без процесса):
|
||||
sealed interface Cmd
|
||||
data class IndexCmd(val path: String) : Cmd
|
||||
data class SearchCmd(val path: String, val query: String, val k: Int, val mode: SearchMode, val json: Boolean) : Cmd
|
||||
data class StatusCmd(val path: String) : Cmd
|
||||
data class McpProbeCmd(val tool: String, val argsJson: String) : Cmd
|
||||
object HelpCmd : Cmd
|
||||
fun parseArgs(args: Array<String>): Cmd // при ошибке — HelpCmd
|
||||
|
||||
Правила разбора:
|
||||
- `index <path>` → IndexCmd
|
||||
- `search <path> <query> [--k N] [--mode hybrid|lex|vec] [--json]` → SearchCmd
|
||||
(по умолчанию k = 8, mode = HYBRID); неизвестный флаг или нечисловой --k → HelpCmd
|
||||
- `status <path>` → StatusCmd
|
||||
- `mcp-probe --tool <name> [--args <json>]` → McpProbeCmd (args по умолчанию "{}")
|
||||
- нет аргументов, `--help`, `-h`, неизвестная команда → HelpCmd
|
||||
|
||||
Поведение команд:
|
||||
1. `index <path>`: определить корень так — если path оканчивается на "/.memo" или содержит его, взять родителя;
|
||||
каталог `X` считается коллекцией, если в нём есть файлы *.md (искать на глубине до 2 от path).
|
||||
Для каждой найденной коллекции: создать каталог `<коллекция>/.memo`, базу `<коллекция>/.memo/index.db`,
|
||||
Db(...).init(), затем Indexer(db, embedder).indexTree(коллекция).
|
||||
Путь к модели: переменная окружения MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2.
|
||||
Печатать по строке на коллекцию: `индексировано: <N> обновлено, <файлов всего>` и в конце
|
||||
`итого: <сумма> обновлено в <число коллекций> коллекциях`.
|
||||
2. `search <path> <query>`: path — ЛИБО коллекция, ЛИБО файл *.md (тогда коллекция = его каталог),
|
||||
ЛИБО корень, содержащий коллекции (тогда искать по всем коллекциям внутри, объединив результаты
|
||||
по убыванию score и обрезав до k).
|
||||
Модель/эмбеддер обязателен: без него базы не откроются — это нормально.
|
||||
Refresh-хук передавать обязательно: он делает Indexer(db, embedder).indexTree(root) перед поиском.
|
||||
Без `--json` печатать человекочитаемо:
|
||||
`<score с 3 знаками> <path>:<line> <heading>`
|
||||
далее текст чанка с отступом 4 пробела, обрезанный до 300 символов.
|
||||
С `--json` печатать ОДИН JSON-массив, без пояснений и без лишних строк:
|
||||
[{"path":"...","line":12,"heading":"...","score":0.51,"text":"..."}]
|
||||
Если ничего не найдено — пустой JSON-массив `[]` (в режиме --json) и код возврата 0.
|
||||
3. `status <path>`: по каждой коллекции печатать `<<коллекция>>: файлов <N>, чанков <M>, индекс <дата ISO>`
|
||||
(дата — максимальный indexed_at из files).
|
||||
4. `mcp-probe --tool <name> --args <json>`: выполнить то же, что делает MCP-инструмент, и напечатать
|
||||
результат в формате MCP-ответа:
|
||||
{"content":[{"type":"text","text":"<результат>"}],"isError":false}
|
||||
Поддерживаемые инструменты на этом шаге: memo_search (аргументы path, query, k, mode) и memo_status (path).
|
||||
memo_reindex — вернуть isError: true с текстом "not implemented yet".
|
||||
|
||||
## CliArgsTest.kt — ровно 5 тестов, имена ровно такие
|
||||
|
||||
1. `indexCommandParsed` — ["index", "/tmp/x"] → IndexCmd с path "/tmp/x".
|
||||
2. `searchDefaultsAreK8HybridNoJson` — ["search", "/tmp/x", "вопрос"] → SearchCmd(k = 8, mode = HYBRID, json = false).
|
||||
3. `searchFlagsParsed` — ["search", "/tmp/x", "вопрос", "--k", "3", "--mode", "lex", "--json"] → k = 3, mode = LEX, json = true.
|
||||
4. `badKValueFallsBackToHelp` — ["search", "/tmp/x", "вопрос", "--k", "abc"] → HelpCmd.
|
||||
5. `unknownCommandFallsBackToHelp` — ["сломать"] → HelpCmd.
|
||||
|
||||
После: ./gradlew :memo-cli:test --rerun-tasks и ./gradlew :memo-cli:installDist — обе команды зелёные.
|
||||
Проверь руками, что бинарь появился: ls memo-cli/build/install/memo-cli/bin/memo
|
||||
Коммит: git add -A && git commit -m "cli: index/search/status/mcp-probe"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- Не трогать memo-core (ни src/main, ни src/test), кроме использования его публичного API.
|
||||
- Не добавлять зависимости, кроме :memo-core.
|
||||
- Не создавать другие файлы.
|
||||
@@ -0,0 +1,74 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по слежению за файлами.
|
||||
|
||||
Создать РОВНО ДВА новых файла:
|
||||
memo-watch/build.gradle.kts (плагин kotlin("jvm") + зависимость на :memo-core)
|
||||
memo-watch/src/main/kotlin/memo/watch/Watcher.kt
|
||||
memo-watch/src/main/kotlin/memo/watch/WatchMain.kt
|
||||
и ОДИН тестовый:
|
||||
memo-watch/src/test/kotlin/memo/watch/WatcherTest.kt
|
||||
|
||||
## Watcher.kt
|
||||
|
||||
package memo.watch
|
||||
|
||||
class Watcher(
|
||||
private val collections: List<java.io.File>, // каталоги-коллекции
|
||||
private val index: (java.io.File) -> Unit, // функция индексации одной коллекции
|
||||
private val debounceMillis: Long = 500,
|
||||
private val reconcileMillis: Long = 10 * 60 * 1000,
|
||||
) : AutoCloseable {
|
||||
|
||||
/** Запускает фоновый поток: WatchService на каждый каталог рекурсивно,
|
||||
* debounce, плюс периодическая полная реконсиляция по mtime. */
|
||||
fun start()
|
||||
|
||||
/** Останавливает поток, закрывает WatchService. Идемпотентно. */
|
||||
override fun close()
|
||||
|
||||
/** Количество выполненных вызовов index(...) — для тестов. */
|
||||
val indexCalls: Int
|
||||
}
|
||||
|
||||
Требования:
|
||||
1. Регистрировать каждый каталог через FileSystems.getDefault().newWatchService() с ENTRY_CREATE,
|
||||
ENTRY_MODIFY, ENTRY_DELETE и при регистрации рекурсивно обойти подкаталоги (WatchService не рекурсивен).
|
||||
Каталоги, начинающиеся с '.', не обходить и не регистрировать.
|
||||
2. События собирать в Set<File> (какие коллекции затронуты). Поток: take() с таймаутом debounceMillis;
|
||||
при накоплении — по истечении debounce вызвать index(коллекция) для каждой затронутой коллекции.
|
||||
Не вызывать index десятки раз на одно сохранение файла.
|
||||
3. Раз в reconcileMillis — полная реконсиляция: обойти все файлы *.md во всех коллекциях,
|
||||
сравнить (path, mtime, size) со таблицей files, при расхождении вызвать index(коллекция).
|
||||
Реализацию сравнения НЕ дублировать: у :memo-core уже есть Indexer.indexFile, который сам
|
||||
пропускает неизменённые файлы и возвращает Boolean. Использовать его: index(коллекция) должен
|
||||
внутри вызывать Indexer.indexTree(коллекция).
|
||||
4. Все исключения в рабочем потоке не должны убивать поток: ловить, печатать в stderr, продолжать.
|
||||
5. close(): interrupt + закрыть WatchService, поток завершается не дольше 2 секунд.
|
||||
|
||||
## WatchMain.kt
|
||||
|
||||
fun main(args: Array<String>)
|
||||
- Аргумент: корень, содержащий коллекции (или одну коллекцию).
|
||||
- Определение коллекций — как в CLI (каталог с *.md на глубине до 2).
|
||||
- Создаёт Db(коллекция/.memo/index.db), .init(), Indexer(db, embedder) — по одному на коллекцию,
|
||||
Watcher с index = { коллекция -> indexer.indexTree(коллекция) }.
|
||||
- Печатает `наблюдаю: <коллекция>` по строке на каждую, затем блокируется навсегда до SIGINT.
|
||||
- Модель: MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2.
|
||||
|
||||
## WatcherTest.kt — ровно 3 теста, имена ровно такие
|
||||
|
||||
1. `modifyTriggersSingleIndexCall` — временный каталог с note.md; Watcher с index-счётчиком и
|
||||
debounce 200 мс; start(); правка файла; подождать 1.5 с; indexCalls == 1 (не больше!);
|
||||
close(). (Перед стартом дать watcher 300 мс прогреться.)
|
||||
2. `unchangedFileDoesNotTriggerIndex` — после прогрева ничего не менять 1.5 с → indexCalls == 0.
|
||||
3. `closeIsIdempotentAndStopsThread` — start(); close(); close(); повторный вызов не бросает исключение.
|
||||
|
||||
Тесты должны быть устойчивыми: ждать не фиксированным sleep, а опросом условия с таймаутом
|
||||
(например, до 5 с с шагом 100 мс), чтобы не флапать на медленной машине.
|
||||
|
||||
После: ./gradlew :memo-watch:test --rerun-tasks — зелёные; ./gradlew build -x test компилируется.
|
||||
Коммит: git add -A && git commit -m "watch: слежение за файлами (WatchService + debounce + реконсиляция)"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу создавай файлы.
|
||||
- Не трогать memo-core и memo-cli.
|
||||
- Не добавлять зависимости, кроме :memo-core.
|
||||
@@ -0,0 +1,62 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по MCP-серверу. Контракт уже в docs/orders/07-mcp.md —
|
||||
читать его как исходное ТЗ, а здесь перечислены обязательные требования и известные грабли.
|
||||
|
||||
Создать:
|
||||
memo-mcp/build.gradle.kts
|
||||
memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt
|
||||
memo-mcp/src/test/kotlin/memo/mcp/McpProtocolTest.kt
|
||||
|
||||
## Зависимости модуля memo-mcp
|
||||
|
||||
plugins { kotlin("jvm"); application; kotlin("plugin.serialization") version "2.4.10" }
|
||||
application { mainClass.set("memo.mcp.McpServerKt") }
|
||||
dependencies {
|
||||
implementation(project(":memo-core"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
}
|
||||
ВАЖНО: в kotlin("plugin.serialization") версию указать обязательно 2.4.10, иначе сборка упадёт на несовместимости версий плагина и Kotlin.
|
||||
Если сериализация начнёт мешать — НЕ добавляй её, собери JSON строками вручную (ручной JSON-рендер допустим и проще).
|
||||
|
||||
## McpServer.kt
|
||||
|
||||
fun main(args: Array<String>) — читает stdin построчно, на каждый запрос печатает в stdout РОВНО одну строку
|
||||
JSON-RPC 2.0. В stdout НИЧЕГО кроме JSON (включая баннеры/логи) — диагностика только в stderr.
|
||||
|
||||
Методы:
|
||||
- initialize → {"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"memo","version":"0.1.0"}}
|
||||
- notifications/initialized → ответ не отправлять (вернуть null)
|
||||
- tools/list → 3 инструмента: memo_search {path, query, k=8, mode="hybrid"}, memo_status {path}, memo_reindex {path}
|
||||
- tools/call {"name":..., "arguments":{...}} → {"content":[{"type":"text","text":"..."}],"isError":false}
|
||||
Ошибки (нет обязательного аргумента, неизвестный инструмент, исключение) → isError:true + текст, НЕ JSON-RPC error.
|
||||
- неизвестный метод → {"jsonrpc":"2.0","id":<id>,"error":{"code":-32601,"message":"Method not found"}}
|
||||
- уведомление (нет "id" в запросе) → ответ не отправлять
|
||||
|
||||
Тестируемые без процесса функции: handleRequest(line: String): String?, toolSearch(path, query, k, mode): String,
|
||||
toolStatus(path): String, toolReindex(path): String.
|
||||
|
||||
Поведение инструментов:
|
||||
- Коллекции определять ТАК ЖЕ, как CLI: см. memo-cli/src/main/kotlin/memo/cli/Main.kt — переиспользуй ту
|
||||
логику определения корня и коллекций (если это private — сделай в memo-core отдельный файл
|
||||
memo-core/src/main/kotlin/memo/core/Collections.kt с функциями
|
||||
`fun resolveCollection(path: java.io.File): java.io.File` и `fun findCollections(root: java.io.File): List<java.io.File>`,
|
||||
и переключи CLI и MCP на них; CLI при этом должен остаться с зелёными тестами).
|
||||
- toolSearch: Searcher с refresh-хуком (indexTree перед поиском), текст ответа — по строке на хит:
|
||||
`<path>:<line> <heading>` и следом текст чанка; пусто → "ничего не найдено".
|
||||
- toolStatus: `<коллекция>: файлов <N>, чанков <M>, индекс <ISO>`.
|
||||
- toolReindex: удалить `<коллекция>/.memo` целиком и переиндексировать; вернуть `переиндексировано файлов: <N>`.
|
||||
|
||||
## McpProtocolTest.kt — ровно 5 тестов, имена ровно такие
|
||||
|
||||
1. `initializeHandshake` — ответ содержит "2024-11-05" и "memo".
|
||||
2. `toolsListHasThreeTools` — ответ содержит memo_search, memo_status, memo_reindex.
|
||||
3. `unknownMethodReturns32601` — ответ содержит "-32601".
|
||||
4. `searchWithoutPathIsError` — tools/call memo_search с arguments {} → содержит "isError":true.
|
||||
5. `notificationProducesNoResponse` — handleRequest(уведомление notifications/initialized) == null.
|
||||
|
||||
После: ./gradlew :memo-mcp:test --rerun-tasks и ./gradlew :memo-mcp:installDist — зелёные.
|
||||
Коммит: git add -A && git commit -m "mcp: сервер memo_search/memo_status/memo_reindex"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу создавай файлы.
|
||||
- Не трогать логику memo-core, кроме добавления Collections.kt и переключения CLI на него.
|
||||
- Ничего в stdout, кроме JSON-RPC строк.
|
||||
@@ -0,0 +1,78 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ на ИСПРАВЛЕНИЕ дефекта определения коллекций.
|
||||
|
||||
## Доказанный дефект
|
||||
|
||||
На корпусе /root/WORK/memo-e2e (3 папки с заметками: `infra`, `work/jira`, `life/books`,
|
||||
всего 12 .md) команда
|
||||
|
||||
memo index /root/WORK/memo-e2e
|
||||
|
||||
создаёт ШЕСТЬ баз индекса вместо трёх:
|
||||
|
||||
/root/WORK/memo-e2e/.memo/index.db
|
||||
/root/WORK/memo-e2e/infra/.memo/index.db
|
||||
/root/WORK/memo-e2e/life/.memo/index.db
|
||||
/root/WORK/memo-e2e/life/books/.memo/index.db
|
||||
/root/WORK/memo-e2e/work/.memo/index.db
|
||||
/root/WORK/memo-e2e/work/jira/.memo/index.db
|
||||
|
||||
и «итого: 24 обновлено в 6 коллекциях» — одни и те же заметки проиндексированы по 2-3 раза.
|
||||
Причина: каталог считается коллекцией, если .md-файлы есть где-то на глубине до 2 ВНУТРИ него.
|
||||
|
||||
## Правило, которое надо реализовать
|
||||
|
||||
Коллекция — это каталог, в котором есть хотя бы один файл `*.md` **непосредственно в нём самом**
|
||||
(не в подкаталогах). Всё остальное коллекцией не является.
|
||||
|
||||
Правки только в memo-core/src/main/kotlin/memo/core/Collections.kt (и, при необходимости,
|
||||
в местах его вызова в memo-cli и memo-mcp — но менять надо только вызовы, не логику):
|
||||
|
||||
1. `fun findCollections(root: java.io.File): List<java.io.File>` — рекурсивный обход root,
|
||||
возвращает ВСЕ каталоги (включая сам root, если подходит), в которых есть `*.md` напрямую.
|
||||
Скрытые каталоги (имя начинается с '.') пропускать, каталог `.memo` не обходить.
|
||||
Порядок — по пути, детерминированный.
|
||||
2. `fun resolveCollection(path: java.io.File): java.io.File` — если path каталог, вернуть ближайший
|
||||
каталог, содержащий .md напрямую: сам path, иначе подняться вверх по родителям до первого такого
|
||||
(но не выше, чем файловая система); если не найден — вернуть сам path.
|
||||
Если path — файл, вернуть его родительский каталог (file.parentFile).
|
||||
3. `fun isCollection(dir: java.io.File): Boolean` — вспомогательная, реализует правило из абзаца выше
|
||||
(ровно один уровень, без рекурсии).
|
||||
|
||||
## Тесты: добавить в memo-core/src/test/kotlin/memo/core/ новый файл CollectionsTest.kt
|
||||
|
||||
Ровно 4 теста, имена ровно такие:
|
||||
1. `findCollectionsReturnsOnlyDirsWithDirectMarkdown` — временный корень с такой структурой:
|
||||
`root/a.md`, `root/sub/b.md`, `root/sub/deep/c.md`, `root/empty/` (без .md), `root/nested/only/deep/d.md`
|
||||
→ ровно 4 коллекции: root, root/sub, root/sub/deep, root/nested/only/deep. Каталогов `empty`, `nested`,
|
||||
`nested/only` в результате быть НЕ должно.
|
||||
2. `parentDirWithoutDirectMarkdownIsNotCollection` — структура `root/top/inner/x.md` →
|
||||
findCollections даёт ровно один элемент — `root/top/inner`.
|
||||
3. `resolveCollectionRaisesToNearestWithMarkdown` — для файла `root/top/inner/x.md` resolveCollection
|
||||
возвращает `root/top/inner`; для каталога `root/top` (внутри .md нет, есть только в inner) —
|
||||
тоже `root/top/inner`.
|
||||
4. `hiddenDirsAreSkipped` — структура `root/.hidden/a.md`, `root/vis/b.md` → ровно одна коллекция `root/vis`.
|
||||
Плюс: если в корне лежит `root/.memo/index.db`, это ничего не меняет и не ломает результат.
|
||||
|
||||
## Обязательная сквозная проверка (сделать самому, приложить вывод)
|
||||
|
||||
cd /root/WORK/memo
|
||||
./gradlew :memo-cli:installDist -q
|
||||
rm -rf /root/WORK/memo-e2e/infra/.memo /root/WORK/memo-e2e/work/jira/.memo /root/WORK/memo-e2e/life/books/.memo \
|
||||
/root/WORK/memo-e2e/.memo /root/WORK/memo-e2e/work/.memo /root/WORK/memo-e2e/life/.memo
|
||||
memo-cli/build/install/memo/bin/memo index /root/WORK/memo-e2e
|
||||
find /root/WORK/memo-e2e -name index.db | sort
|
||||
|
||||
Ожидается РОВНО три пути:
|
||||
/root/WORK/memo-e2e/infra/.memo/index.db
|
||||
/root/WORK/memo-e2e/life/books/.memo/index.db
|
||||
/root/WORK/memo-e2e/work/jira/.memo/index.db
|
||||
и в выводе index — «итого: 12 обновлено в 3 коллекциях». Второй прогон index — «итого: 0 обновлено».
|
||||
|
||||
После: ./gradlew test --rerun-tasks — ВСЕ тесты проекта зелёные (включая те 5+memo-cli+memo-mcp).
|
||||
Коммит: git add -A && git commit -m "core: коллекция — каталог с markdown напрямую; исправлен тройной обход"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- Не менять логику Searcher, Indexer, Chunker, Embedder, Db.
|
||||
- Не менять контракт CLI (имена команд и флагов) и MCP-инструментов.
|
||||
- Не добавлять зависимости.
|
||||
@@ -0,0 +1,72 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по холодному старту MCP-поиска.
|
||||
|
||||
## Доказанные дефекты (воспроизведено зондом)
|
||||
|
||||
1. **Пустой старт не работает.** Команда
|
||||
rm -rf /root/WORK/memo-e2e/*/.memo
|
||||
python3 scripts/mcp_probe.py --cmd "./memo-mcp/build/install/memo-mcp/bin/memo-mcp" \
|
||||
--call memo_search --args '{"path":"/root/WORK/memo-e2e/infra","query":"основа для разрешения имён в сети","k":3}'
|
||||
возвращает текст `коллекции не найдены`, потому что `selectTargets()` оставляет только коллекции,
|
||||
у которых УЖЕ существует `.memo/index.db`. То есть главный сценарий — агент пишет .md штатными
|
||||
инструментами и сразу ищет — не работает: сначала надо вручную звать CLI `index`.
|
||||
Ожидаемое поведение: `memo_search` сам создаёт `.memo/index.db` и индексирует коллекцию,
|
||||
если индекса ещё нет, и возвращает результаты.
|
||||
|
||||
2. **Модель грузится по разу на каждую коллекцию.** В `toolSearch` `Embedder(...)` создаётся внутри цикла
|
||||
по коллекциям. На трёх коллекциях это три загрузки модели.
|
||||
|
||||
## Что сделать
|
||||
|
||||
Правки ТОЛЬКО в `memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt` (+ новый тестовый файл). Логику memo-core не менять.
|
||||
|
||||
1. `toolSearch(path, query, k, mode)`:
|
||||
- Определить цели так: если `File(path)` сам является коллекцией (в нём есть `*.md` напрямую) —
|
||||
цель ровно он; иначе — все коллекции внутри `findCollections(File(path))`; если и их нет —
|
||||
вернуть `коллекции не найдены`.
|
||||
- Для каждой цели: если `<коллекция>/.memo/index.db` не существует — создать каталог `.memo`,
|
||||
`Db(...)`, `db.init()`, проиндексировать `Indexer(db, embedder).indexTree(коллекция)`.
|
||||
Затем обычный поиск с refresh-хуком.
|
||||
- `Embedder` создать ОДИН РАЗ до цикла по коллекциям и закрыть один раз после (try/finally).
|
||||
`Db` — по одному на коллекцию, как сейчас.
|
||||
- Не глотать ошибки молча: если по коллекции поиск упал, дописать в результат строку
|
||||
`ошибка в <коллекция>: <текст>` (в конец возвращаемого текста), остальные коллекции всё равно обрабатывать.
|
||||
2. `toolStatus` — оставить как есть (статус по несуществующему индексу законно говорит «нет индекса»).
|
||||
3. `toolReindex` — оставить как есть, но `Embedder` тоже вынести из цикла (один на все коллекции).
|
||||
|
||||
## Тест: новый файл memo-mcp/src/test/kotlin/memo/mcp/McpColdStartTest.kt
|
||||
|
||||
Ровно 2 теста, имена ровно такие:
|
||||
|
||||
1. `searchIndexesCollectionOnColdStart` — временный каталог-коллекция с двумя .md (например
|
||||
"# Прокси\nвнутренние домены ходят через шлюз 76.1" и "# Прочее\nсовсем другая заметка про книгу"),
|
||||
модель берётся из MEMO_MODEL_DIR (как в CoreSmokeTest). До вызова `.memo` НЕ существует.
|
||||
Вызвать `toolSearch(dir.absolutePath, "внутренние домены шлюз", 5, SearchMode.HYBRID)`.
|
||||
Проверить: (а) в ответе нет подстроки "коллекции не найдены"; (б) ответ содержит "76.1";
|
||||
(в) после вызова `File(dir, ".memo/index.db").exists()` == true.
|
||||
2. `searchOnEmptyDirReportsNoCollections` — пустой временный каталог без .md:
|
||||
ответ содержит "коллекции не найдены", каталог `.memo` не создан.
|
||||
|
||||
Тесты должны работать без запуска процесса (через `toolSearch` напрямую).
|
||||
|
||||
## Обязательная сквозная проверка (приложить вывод)
|
||||
|
||||
cd /root/WORK/memo
|
||||
./gradlew :memo-mcp:installDist -q
|
||||
cp -r /root/WORK/memo-e2e /tmp/memo-cold && rm -rf /tmp/memo-cold/*/.memo /tmp/memo-cold/.memo
|
||||
MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 python3 scripts/mcp_probe.py \
|
||||
--cmd "./memo-mcp/build/install/memo-mcp/bin/memo-mcp" \
|
||||
--call memo_search --args '{"path":"/tmp/memo-cold/infra","query":"основа для разрешения имён в сети","k":3}'
|
||||
MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 python3 scripts/mcp_probe.py \
|
||||
--cmd "./memo-mcp/build/install/memo-mcp/bin/memo-mcp" \
|
||||
--call memo_status --args '{"path":"/tmp/memo-cold/infra"}'
|
||||
|
||||
Ожидается: поиск отдаёт непустой текст с `infra/domains.md` (или infra/hosts.md) и без "коллекции не найдены";
|
||||
status после этого показывает ненулевые файлы и чанки. Также приложить `find /tmp/memo-cold -name index.db`.
|
||||
|
||||
После: ./gradlew test --rerun-tasks — ВСЕ тесты проекта зелёные.
|
||||
Коммит: git add -A && git commit -m "mcp: поиск индексирует коллекцию на холодном старте"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- Не менять memo-core, memo-cli, memo-watch.
|
||||
- Не добавлять зависимости.
|
||||
@@ -0,0 +1,61 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ на закрытие ТРЁХ дыр в покрытии, доказанных мутационной проверкой
|
||||
(`python3 e2e/mutation_check.py --gradle ./gradlew`). Мутация ломает боевой код, нужный тест обязан упасть.
|
||||
Сейчас три мутации выживают — тесты не проверяют то, ради чего написаны.
|
||||
|
||||
Правки — ТОЛЬКО в тестовых файлах. Боевой код (src/main) НЕ менять ни в одном модуле.
|
||||
|
||||
## Дыра 1. memo-core/src/test/kotlin/memo/core/CollectionsTest.kt
|
||||
|
||||
Мутация `it.isFile && it.extension == "md"` -> `it.name.endsWith("md")` в Collections.kt ВЫЖИВАЕТ:
|
||||
в тесте все файлы — .md, поэтому подмена незаметна.
|
||||
|
||||
Дополнить тест `findCollectionsReturnsOnlyDirsWithDirectMarkdown` (имя и остальные проверки не менять):
|
||||
- добавить каталог `File(root, "fakemd").mkdirs()` — имя каталога заканчивается на "md", но .md-файлов
|
||||
внутри него нет; этот каталог НЕ должен попасть в результат (добавить проверку);
|
||||
- добавить файл `File(root, "readme.txt").writeText("не markdown")` — не должен ничего менять;
|
||||
- в конце теста добавить проверку: результат НЕ содержит `File(root, "fakemd").absolutePath`.
|
||||
|
||||
## Дыра 2. memo-core/src/test/kotlin/memo/core/SearcherTest.kt
|
||||
|
||||
Мутация `if (mode == SearchMode.LEX || mode == SearchMode.HYBRID)` -> `if (mode == SearchMode.LEX)` ВЫЖИВАЕТ:
|
||||
в тесте `hybridCombinesBoth` «смысловой» чанк лексически содержит слова запроса («выпуск приложения»),
|
||||
поэтому его находит и чистый BM25, и вклад вектора ничем не доказан.
|
||||
|
||||
Дополнить тест `hybridCombinesBoth`:
|
||||
- заменить текст второго чанка на такой, у которого НЕТ общих токенов с запросом:
|
||||
запрос оставить `"выпуск приложения 76.132"` (то есть токены: выпуск, приложения, 76, 132),
|
||||
а чанк `/sem.md` сделать: `"Конвейер поставки продуктов: компиляция, испытания, выкладка в кластер"`.
|
||||
Убедиться, что ни один токен запроса (кроме служебных длиной < 2) не встречается в этом тексте;
|
||||
- оставить проверку `paths.contains("/exact.md")` и `paths.contains("/sem.md")` — теперь вторая проверка
|
||||
выполнима ТОЛЬКО если векторный список участвует в слиянии.
|
||||
Имя теста и остальные тесты не менять.
|
||||
|
||||
## Дыра 3. memo-watch/src/test/kotlin/memo/watch/WatcherTest.kt
|
||||
|
||||
Мутация `_indexCalls.incrementAndGet()` -> `_indexCalls.get()` в Watcher.kt ВЫЖИВАЕТ:
|
||||
тест `modifyTriggersSingleIndexCall` считает вызовы своим собственным AtomicInteger в лямбде `index = {...}`
|
||||
и встроенный счётчик `watcher.indexCalls` не проверяет вообще.
|
||||
|
||||
Дополнить тест `modifyTriggersSingleIndexCall` (имя и существующие проверки не менять):
|
||||
- в конце, рядом с `assertEquals(1, counter.get(), ...)`, добавить:
|
||||
`assertTrue(watcher.indexCalls >= 1, "встроенный счётчик indexCalls не растёт, получено ${watcher.indexCalls}")`.
|
||||
|
||||
## Обязательная проверка (приложить вывод)
|
||||
|
||||
cd /root/WORK/memo
|
||||
python3 e2e/mutation_check.py --gradle ./gradlew
|
||||
|
||||
Ожидается строка `ВСЕ МУТАЦИИ УБИТЫ` в конце. Если какая-то мутация выжила — доработать тест,
|
||||
пока все 13 не будут убиты. Затем:
|
||||
|
||||
./gradlew test --rerun-tasks
|
||||
|
||||
— все тесты зелёные.
|
||||
|
||||
Коммит: git add -A && git commit -m "tests: закрыты три дыры в покрытии, найденные мутационным анализом"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- НЕ менять боевой код ни в memo-core, memo-cli, memo-mcp, memo-watch.
|
||||
- Не менять имена тестов, не удалять существующие проверки.
|
||||
- Не добавлять зависимости.
|
||||
@@ -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.
|
||||
- Не добавлять зависимости.
|
||||
@@ -0,0 +1,162 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ: скачивание модели — командой и автоматически.
|
||||
|
||||
## Зачем
|
||||
|
||||
Сейчас модель (287 МБ) надо скачивать руками curl-ом по инструкции. Это единственный шаг,
|
||||
который ломает «взял и пользуешься». Нужно: (1) явная команда «просто скачай модель»,
|
||||
(2) автоматическое скачивание, когда модель понадобилась, а её нет.
|
||||
|
||||
## Что уже известно про сервер (проверено)
|
||||
|
||||
```bash
|
||||
curl -sSIL http://static.binom.pw/models/siglip2/text_model_int8.onnx
|
||||
# HTTP/1.1 200 OK, Content-Length: 283438275, Etag: "6a9d4b7c-10e4ecc3"
|
||||
curl -sSIL http://static.binom.pw/models/siglip2/tokenizer.model
|
||||
# HTTP/1.1 200 OK, Content-Length: 4241003
|
||||
curl -sS -r 100-199 -D - http://static.binom.pw/models/siglip2/tokenizer.model
|
||||
# HTTP/1.1 206 Partial Content, Content-Range: bytes 100-199/4241003, Accept-Ranges: bytes
|
||||
```
|
||||
|
||||
Сервер поддерживает **докачку (Range)** и отдаёт **Content-Length**. Файлов контрольных сумм
|
||||
на сервере НЕТ (`.sha256` → 404), листинг каталога закрыт (403). Значит, проверка целостности —
|
||||
по размеру из `Content-Length`, а не по хэшу.
|
||||
|
||||
## Что сделать
|
||||
|
||||
### 1. `memo-core`: `ModelStore.kt` (новый файл, пакет `memo.core`)
|
||||
|
||||
Объект/класс без внешних зависимостей — только JDK 21 (`java.net.http.HttpClient`).
|
||||
|
||||
```kotlin
|
||||
object ModelStore {
|
||||
const val DEFAULT_BASE_URL = "http://static.binom.pw/models/siglip2"
|
||||
val FILES = listOf("text_model_int8.onnx", "tokenizer.model")
|
||||
|
||||
data class Result(val downloaded: List<String>, val skipped: List<String>, val bytes: Long)
|
||||
|
||||
/** Гарантирует наличие всех файлов модели в dir. Возвращает имена скачанных/пропущенных. */
|
||||
fun ensure(
|
||||
dir: java.io.File,
|
||||
baseUrl: String = DEFAULT_BASE_URL,
|
||||
force: Boolean = false,
|
||||
log: (String) -> Unit = {},
|
||||
timeoutMillis: Long = 60_000,
|
||||
): Result
|
||||
|
||||
/** Пути к модели в том же порядке (modelPath, tokenizerPath). */
|
||||
fun paths(dir: java.io.File): Pair<String, String>
|
||||
}
|
||||
```
|
||||
|
||||
Требования к `ensure`:
|
||||
|
||||
1. **Пропуск без сети.** Если файл существует, непустой и не `force` → он в `skipped`, **ни одного
|
||||
сетевого запроса** по нему не делается. Это важно: обычный запуск поиска офлайн обязан работать.
|
||||
2. **Докачка.** Качать в `<имя>.part` рядом с целевым файлом. Если `.part` уже есть и непустой —
|
||||
продолжить с его размера, отправив `Range: bytes=<size>-`; ответ `206` → дописывать в конец;
|
||||
ответ `200` (сервер проигнорировал Range) → начать файл заново.
|
||||
3. **Проверка размера.** После завершения сравнить размер с `Content-Length` из того же ответа
|
||||
(или из `HEAD`). Не совпало → удалить `.part`, бросить `IllegalStateException` с обоими числами.
|
||||
4. **Атомарность.** Только после успешной проверки `.part` переименовывается в целевое имя
|
||||
(`File.renameTo`), чтобы оборванная закачка не оставила «валидный на вид» файл.
|
||||
5. **Прогресс в stderr**, не чаще раза в 2 секунды: `скачиваю <имя>: 45% (128 МБ / 283 МБ)`.
|
||||
В stdout — ничего.
|
||||
6. **Директорию создать** (`mkdirs`), если её нет.
|
||||
7. Ошибки сети/HTTP-кода (не 200/206) — исключение с понятным текстом и именем файла.
|
||||
|
||||
### 2. `memo-cli`: команда `memo model`
|
||||
|
||||
```
|
||||
memo model [--dir <путь>] [--url <база>] [--force]
|
||||
```
|
||||
|
||||
- без `--dir` — директория из `MEMO_MODEL_DIR` (или `/root/WORK/memo/models/siglip2`, как уже
|
||||
заведено в `modelPaths()`);
|
||||
- печатает по-русски, что скачано, что уже было, сколько байт;
|
||||
- exit 0 при успехе, 1 при ошибке.
|
||||
- Добавить эту команду в текст `usage` (он печатается в `HelpCmd`).
|
||||
|
||||
### 3. Автоматическое скачивание
|
||||
|
||||
Во всех точках, где модель нужна (`memo-cli` index/search/status, `memo-mcp` при старте):
|
||||
перед созданием `Embedder` вызвать `ModelStore.ensure(dir)` — **если файлов нет или они пустые**.
|
||||
Если файлы на месте — вызова сети не происходит (см. п.1), поведение не меняется.
|
||||
|
||||
Отключение: переменная `MEMO_MODEL_AUTO_DOWNLOAD=0` → скачивание не выполняется, а при отсутствии
|
||||
модели выдаётся внятная ошибка: `модель не найдена в <dir>; запустите: memo model`.
|
||||
|
||||
Сообщение о скачивании выводить в stderr с первой строкой вида
|
||||
`модель не найдена в <dir>, скачиваю с <url> (≈287 МБ, один раз)`.
|
||||
|
||||
### 4. Тесты: `memo-core/src/test/kotlin/memo/core/ModelStoreTest.kt` (новый файл)
|
||||
|
||||
Поднять **локальный HTTP-сервер на JDK** (`com.sun.net.httpserver.HttpServer`, без зависимостей),
|
||||
слушать на `127.0.0.1` со случайным портом. Никакого выхода в интернет.
|
||||
|
||||
1. `downloadsMissingFiles` — на диске пусто, «сервер» отдаёт 2 файла → оба скачаны, размеры совпали,
|
||||
содержимое совпало побайтно, `.part` не остался.
|
||||
2. `skipsExistingWithoutNetwork` — файлы уже есть; **сервер считать запросы (AtomicInteger)** →
|
||||
после `ensure` счётчик равен **0**, оба файла в `skipped`.
|
||||
3. `resumesPartialDownload` — в `.part` лежит первая половина файла; сервер на запрос с `Range`
|
||||
отвечает `206` с хвостом → итоговый файл полный, побайтно равен исходному.
|
||||
4. `failsOnSizeMismatch` — сервер объявляет `Content-Length` больше, чем реально отдаёт, и рвёт
|
||||
соединение → `ensure` бросает исключение, целевого файла нет, `.part` удалён.
|
||||
5. `forceRedownloads` — файл есть, `force = true` → скачан заново.
|
||||
|
||||
## Обязательная сквозная проверка (приложить вывод)
|
||||
|
||||
```bash
|
||||
cd /root/WORK/memo
|
||||
./gradlew installDist -q
|
||||
CLI=memo-cli/build/install/memo/bin/memo
|
||||
|
||||
# 1) пустая директория: команда model скачивает (база — локальный сервер, НЕ интернет, чтобы
|
||||
# проверка была воспроизводимой; для этого в проверке используем свой http-сервер на python)
|
||||
rm -rf /tmp/memo-models && mkdir -p /tmp/memo-models/src
|
||||
cp models/siglip2/* /tmp/memo-models/src/
|
||||
cd /tmp/memo-models/src && python3 -m http.server 18999 > /tmp/memo-http.log 2>&1 &
|
||||
HPID=$!
|
||||
sleep 2
|
||||
cd /root/WORK/memo
|
||||
MEMO_MODEL_DIR=/tmp/memo-models/dst $CLI model --dir /tmp/memo-models/dst --url http://127.0.0.1:18999
|
||||
echo "--- что появилось:"; ls -l /tmp/memo-models/dst; md5sum /tmp/memo-models/dst/* models/siglip2/*
|
||||
|
||||
# 2) повторный запуск: ничего не качает
|
||||
MEMO_MODEL_DIR=/tmp/memo-models/dst $CLI model --dir /tmp/memo-models/dst --url http://127.0.0.1:18999
|
||||
|
||||
# 3) автоскачивание: поиск на пустом месте сам тянет модель
|
||||
rm -rf /tmp/memo-models/dst2
|
||||
MEMO_MODEL_DIR=/tmp/memo-models/dst2 $CLI index /root/WORK/memo-e2e 2>&1 | head -4
|
||||
ls -l /tmp/memo-models/dst2
|
||||
|
||||
# 4) отключение автоскачивания
|
||||
rm -rf /tmp/memo-models/dst3
|
||||
MEMO_MODEL_AUTO_DOWNLOAD=0 MEMO_MODEL_DIR=/tmp/memo-models/dst3 $CLI index /root/WORK/memo-e2e; echo "exit=$?"
|
||||
|
||||
kill $HPID 2>/dev/null
|
||||
```
|
||||
|
||||
Ожидается: (1) оба файла скачаны, md5 совпадают с оригиналом, `.part` нет; (2) во второй раз
|
||||
«уже на месте», сеть не тронута; (3) при автоскачивании модель появилась и индексация прошла;
|
||||
(4) с `MEMO_MODEL_AUTO_DOWNLOAD=0` — ошибка с подсказкой `memo model`, ненулевой код.
|
||||
|
||||
**Важно:** шаг 3 и 4 проверяют автоскачивание с продакшн-URL по умолчанию (интернет доступен в этом
|
||||
окружении). Если интернета в момент проверки нет — приложить вывод и явно сказать об этом.
|
||||
|
||||
После: `./gradlew test --rerun-tasks` — все тесты зелёные (было 36, станет больше).
|
||||
Коммит осмысленным сообщением.
|
||||
|
||||
## Обновить документацию
|
||||
|
||||
- `MANUAL.md`: в §3 убрать ручной curl и написать, что модель скачивается сама при первом
|
||||
использовании, плюс команда `$CLI model` для скачивания заранее; упомянуть
|
||||
`MEMO_MODEL_AUTO_DOWNLOAD=0`.
|
||||
- `README.md`: в разделе «Быстрый старт» — строка про `memo model`.
|
||||
|
||||
## СТРОГИЕ ЗАПРЕТЫ
|
||||
|
||||
- Ни одной новой внешней зависимости (только JDK).
|
||||
- Не менять `memo-core`'s схему БД, `Chunker`, `Searcher`, `Indexer`, `Collections`.
|
||||
- Не менять смысл существующих тестов.
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- В тестах не ходить в интернет (только локальный HttpServer).
|
||||
@@ -0,0 +1,104 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ: закрыть ДВЕ дыры в покрытии `ModelStoreTest`,
|
||||
доказанные мутационной проверкой. Правки только в тестовом файле, боевой код НЕ трогать.
|
||||
|
||||
Прогон, который их нашёл:
|
||||
python3 e2e/mutation_check.py --gradle ./gradlew
|
||||
|
||||
Выжившие мутации:
|
||||
1. `ModelStore.kt`: `if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-")`
|
||||
→ `if (false) ...` | ожидался провал `resumesPartialDownload` — НЕ упал.
|
||||
2. `ModelStore.kt`: `if (declaredBody >= 0 && written != declaredBody) {`
|
||||
→ `if (false) {` | ожидался провал `failsOnSizeMismatch` — НЕ упал.
|
||||
|
||||
## Почему они выжили (разобрано, не догадка)
|
||||
|
||||
**1. `resumesPartialDownload`.** Тестовый сервер (`serveBytes`) отдаёт `206` с хвостом только если
|
||||
в запросе есть заголовок `Range`; без него он отдаёт `200` и **полное содержимое**. Поэтому при
|
||||
мутации (Range не отправляется) срабатывает штатный запасной путь «начать заново», итоговый файл
|
||||
получается правильным — и тест, проверяющий только «файл в итоге верный», проходит.
|
||||
Тест не проверяет того, ради чего написан: что докачка **действительно** шла хвостом.
|
||||
|
||||
**2. `failsOnSizeMismatch`.** Сервер объявляет `Content-Length` больше, чем пишет, соединение
|
||||
обрывается — у клиента вылетает `IOException`, который `download` превращает в
|
||||
`IllegalStateException("не удалось скачать ...")`. Тест принимает `msg.contains("не удалось скачать")`,
|
||||
поэтому проверка размера не проверяется вообще: исключение приходит из другого места.
|
||||
|
||||
**Важное наблюдение по бою.** В `ModelStore.download` две проверки размера:
|
||||
`written != declaredBody` (первая) и `part.length() != expectedTotal` (вторая, из `Content-Range`).
|
||||
Через `java.net.http` первая на практике недостижима: оборванный ответ всегда даёт `IOException`,
|
||||
а не чистый EOF. Значит это защитный код, а не дыра в покрытии — в таблице мутаций его надо
|
||||
заменить на мутацию ВТОРОЙ проверки, которая достижима (см. ниже).
|
||||
|
||||
## Что сделать
|
||||
|
||||
### Правка теста `resumesPartialDownload`
|
||||
|
||||
Сделать так, чтобы «докачка» была доказана, а не предположена:
|
||||
|
||||
- сервер записывает в счётчики: сколько запросов пришло, у скольких был заголовок `Range`,
|
||||
и сколько всего байт тела он отдал;
|
||||
- **если заголовка `Range` нет для файла, у которого уже есть `.part`** — сервер отвечает кодом
|
||||
`400` и тела не отдаёт (докачки без `Range` не бывает; тест не должен иметь запасного пути);
|
||||
- после `ensure` тест обязан утверждать:
|
||||
- `rangeRequests >= 1` — докачка действительно была запрошена;
|
||||
- файл в итоге побайтно равен исходному (оставить);
|
||||
- `.part` переименован (оставить).
|
||||
|
||||
При мутации №1 (`Range` не отправляется) сервер ответит `400` → `ensure` бросит исключение → тест упадёт.
|
||||
|
||||
### Замена сценария `failsOnSizeMismatch` на два теста
|
||||
|
||||
**`rejectsWrongTotalFromContentRange`** (новый, закрывает мутацию №2) — «сервер соврал про общий
|
||||
размер, файл принимать нельзя»:
|
||||
|
||||
- в директории лежит `.part` = первые 4000 байт файла из 8000;
|
||||
- сервер на запрос с `Range: bytes=4000-` отвечает `206`:
|
||||
- `Content-Length: 4000`, тело — реальный хвост 4000 байт (то есть транспорт отдаёт ровно
|
||||
столько, сколько объявил — никакой `IOException`);
|
||||
- `Content-Range: bytes 4000-7999/999999` — **итог соврал**;
|
||||
- ожидание: `ModelStore.ensure` бросает `IllegalStateException`, сообщение содержит `размер`,
|
||||
целевого файла нет, `.part` удалён.
|
||||
|
||||
Проверить, что сценарий действительно бьёт в нужную проверку: при `if (false)` на
|
||||
`part.length() != expectedTotal` тест обязан провалиться (файл будет установлен, исключения не будет).
|
||||
|
||||
**`truncatedResponseDoesNotProduceFile`** (переименовать бывший `failsOnSizeMismatch`) — оставить
|
||||
как проверку поведения «оборванный ответ не оставляет файла», но **убрать из принимаемых сообщение
|
||||
«не удалось скачать»**, чтобы тест не «зеленел» за счёт сетевой ошибки. Ожидать явно любое
|
||||
`IllegalStateException` с непустым сообщением и отсутствие целевого файла и `.part`.
|
||||
Отдельно проверить и записать в отчёте: какую ветку кода реально ловит этот тест (по сообщению) —
|
||||
то есть является ли он проверкой размера или сетевого обрыва. В отчёте написать прямо.
|
||||
|
||||
### Таблица мутаций `e2e/mutations.tsv`
|
||||
|
||||
- **Убрать** строку, целящуюся в `if (declaredBody >= 0 && written != declaredBody) {`
|
||||
(по разбору выше — недостижимо через `java.net.http`; ложная цель).
|
||||
- **Добавить** мутацию во вторую проверку:
|
||||
`if (expectedTotal >= 0 && actualTotal != expectedTotal) {` → `if (false) {`,
|
||||
обязанный уронить `rejectsWrongTotalFromContentRange`.
|
||||
- Строку про `Range` оставить, но теперь она обязана валить `resumesPartialDownload`.
|
||||
|
||||
Правило: каждая строка таблицы — либо убитая мутация, либо честное объяснение в комментарии,
|
||||
почему цель недостижима (с доказательством прогоном).
|
||||
|
||||
## Обязательная проверка (приложить вывод)
|
||||
|
||||
```bash
|
||||
cd /root/WORK/memo
|
||||
python3 e2e/mutation_check.py --gradle ./gradlew 2>&1 | tail -25
|
||||
echo "--- ожидается: ВСЕ МУТАЦИИ УБИТЫ"
|
||||
./gradlew test --rerun-tasks -q 2>&1 | tail -3
|
||||
```
|
||||
|
||||
Плюс отдельно, для каждого из двух новых/изменённых тестов — доказательство, что он валит мутацию:
|
||||
применить мутацию руками, прогнать только `ModelStoreTest`, показать FAIL, откатить
|
||||
(`git checkout -- memo-core/src/main/kotlin/memo/core/ModelStore.kt`), убедиться в PASS.
|
||||
|
||||
## СТРОГИЕ ЗАПРЕТЫ
|
||||
|
||||
- **Не менять боевой код** `ModelStore.kt` (и вообще ничего в `src/main`). Заказ — только тесты
|
||||
и таблица мутаций.
|
||||
- Не менять другие тесты.
|
||||
- Не добавлять зависимости.
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- Не удалять `.gitignore`, не коммитить `models/` и `*.db`.
|
||||
+17
-2
@@ -42,19 +42,31 @@ def main() -> int:
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
f, old, new, must_fail = line.split("\t")
|
||||
rows.append((f, old.replace("\\n", "\n"), new.replace("\\n", "\n"), must_fail))
|
||||
old = old.replace("\\n", "\n")
|
||||
new = new.replace("\\n", "\n")
|
||||
# no-op мутация ничего не проверяет — это ошибка таблицы, а не дыра в покрытии.
|
||||
if old == new:
|
||||
print(f"ОШИБКА ТАБЛИЦЫ: no-op мутация для {must_fail} (old == new)")
|
||||
return 2
|
||||
rows.append((f, old, new, must_fail))
|
||||
|
||||
print("=== базовая линия (без мутаций) ===")
|
||||
base = run_tests(a.gradle)
|
||||
print(f"тестов найдено: {len(base)}; упавших: {[k for k, v in base.items() if v == 'FAIL']}")
|
||||
|
||||
survivors = []
|
||||
misapplied = 0
|
||||
for f, old, new, must_fail in rows:
|
||||
backup = f + ".bak"
|
||||
shutil.copy2(f, backup)
|
||||
try:
|
||||
src = open(f, encoding="utf-8").read()
|
||||
assert old in src, f"МУТАЦИЯ НЕ ПРИМЕНИЛАСЬ (нет фрагмента) в {f}: {old[:60]!r}"
|
||||
if old not in src:
|
||||
# Мутация не применилась: фрагмента нет в файле. Это ошибка таблицы,
|
||||
# а не доказательство покрытия — такой прогон ничего не значит.
|
||||
print(f"ОШИБКА ТАБЛИЦЫ | мутация не применилась в {f}: {old[:70]!r}")
|
||||
misapplied += 1
|
||||
continue
|
||||
open(f, "w", encoding="utf-8").write(src.replace(old, new, 1))
|
||||
st = run_tests(a.gradle)
|
||||
got = st.get(must_fail, "НЕ НАЙДЕН")
|
||||
@@ -74,6 +86,9 @@ def main() -> int:
|
||||
for t, f, old, new in survivors:
|
||||
print(f" - {t}: {f} `{old[:60]}` -> `{new[:40]}`")
|
||||
return 1
|
||||
if misapplied:
|
||||
print(f"\nПРОВАЛ: {misapplied} мутаций не применились — прогон недействителен")
|
||||
return 2
|
||||
print("\nВСЕ МУТАЦИИ УБИТЫ")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# file<TAB>old<TAB>new<TAB>must_fail_test
|
||||
# Прогон: python3 e2e/mutation_check.py --gradle ./gradlew
|
||||
# Правило: мутация обязана уронить ровно тот тест, который её ловит. Выжившая мутация = дыра в покрытии.
|
||||
# no-op мутации (old == new) запрещены — харнесс их отклоняет.
|
||||
# --- memo-core: ksqlite / FTS5 / vec0 ---
|
||||
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt CREATE VIRTUAL TABLE v USING vec0(embedding float[4]) CREATE VIRTUAL TABLE v USING vec0(embedding float[8]) vec0KnnRoundTrip
|
||||
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt floatArrayOf(0.1f, 0.9f, 0.0f, 0.0f) floatArrayOf(0.0f, 0.0f, 0.0f, 1.0f) vec0KnnRoundTrip
|
||||
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt tokenize='unicode61' tokenize='ascii' fts5FindsCyrillic
|
||||
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt "SELECT a FROM t" "SELECT a+1 FROM t" ksqliteSmoke
|
||||
# --- Чанкер ---
|
||||
memo-core/src/main/kotlin/memo/core/Chunker.kt Regex("^(#{1,3}) (.*)$") Regex("^(#+) (.*)$") fourHashesIsNotAHeading
|
||||
memo-core/src/main/kotlin/memo/core/Chunker.kt val trimmed = body.trim() val trimmed = body.uppercase() fourHashesIsNotAHeading
|
||||
memo-core/src/main/kotlin/memo/core/Chunker.kt private const val CHUNK_OVERLAP = 600 private const val CHUNK_OVERLAP = 0 longSectionIsSplitWithOverlap
|
||||
# --- Коллекции (дефект тройного обхода, найден на приёмке) ---
|
||||
memo-core/src/main/kotlin/memo/core/Collections.kt it.isFile && it.extension == "md" it.name.endsWith("md") findCollectionsReturnsOnlyDirsWithDirectMarkdown
|
||||
# --- Индексатор: пропуск неизменённого файла ---
|
||||
memo-core/src/main/kotlin/memo/core/Indexer.kt if (existing.mtime == mtime && existing.size == size) {\n return false if (existing.mtime == mtime && existing.size == size) {\n return true indexerSkipsUnchangedFile
|
||||
# --- Поиск: слияние режимов и ограничение k ---
|
||||
memo-core/src/main/kotlin/memo/core/Searcher.kt if (mode == SearchMode.LEX || mode == SearchMode.HYBRID) lexSearch(query) else emptyList() if (mode == SearchMode.LEX) lexSearch(query) else emptyList() hybridCombinesBoth
|
||||
memo-core/src/main/kotlin/memo/core/Searcher.kt .sortedByDescending { it.value }.take(k) .sortedByDescending { it.value }.take(k + 1) resultsRespectKAndTextLength
|
||||
# --- MCP: ошибка инструмента обязана помечаться isError ---
|
||||
memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt ],"isError":true} ],"isError":false} searchWithoutPathIsError
|
||||
# --- Watcher: debounce сводит правки к одному вызову ---
|
||||
memo-watch/src/main/kotlin/memo/watch/Watcher.kt _indexCalls.incrementAndGet() _indexCalls.get() modifyTriggersSingleIndexCall
|
||||
# --- ModelStore: скачивание модели (ModelStoreTest) ---
|
||||
memo-core/src/main/kotlin/memo/core/ModelStore.kt if (!force && target.isFile && target.length() > 0L) { if (false) { skipsExistingWithoutNetwork
|
||||
memo-core/src/main/kotlin/memo/core/ModelStore.kt part.renameTo(target) part.renameTo(java.io.File(dir, "$name.WRONG")) downloadsMissingFiles
|
||||
memo-core/src/main/kotlin/memo/core/ModelStore.kt if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-") if (false) requestBuilder.header("Range", "bytes=$startAt-") resumesPartialDownload
|
||||
memo-core/src/main/kotlin/memo/core/ModelStore.kt if (expectedTotal >= 0 && actualTotal != expectedTotal) { if (false) { rejectsWrongTotalFromContentRange
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 54.
|
+3
-3
@@ -1,7 +1,7 @@
|
||||
# Вопросы приёмки T2/T3. Формат: question<TAB>path<TAB>expected_file<TAB>kind
|
||||
# kind: sem — смысловой (должен вытянуть вектор), lex — точное значение (должен вытянуть BM25)
|
||||
# Ожидаемый файл должен попасть в top-5. Порог: recall@5 >= 16/20 по sem, 3/3 по lex.
|
||||
как понять, почему сборка съедает место на диске infra work/jira/ci.md sem
|
||||
как понять, почему сборка съедает место на диске work/jira work/jira/ci.md sem
|
||||
чем разметить диск, если привычной утилиты нет infra infra/servers.md sem
|
||||
на какой карте разрешено ставить опыты infra infra/servers.md sem
|
||||
что служит основой для разрешения имён в сети infra infra/domains.md sem
|
||||
@@ -19,8 +19,8 @@
|
||||
до какого правителя доходит первый том life/books life/books/history.md sem
|
||||
как я фиксирую цитаты из прочитанного life/books life/books/notes.md sem
|
||||
чем слушают книги в очках life/books life/books/audiobooks.md sem
|
||||
что запрещено трогать на машине с двумя картами life/books infra/servers.md sem
|
||||
где живёт хранилище контейнерных образов life/books infra/hosts.md sem
|
||||
что запрещено трогать на машине с двумя картами infra infra/servers.md sem
|
||||
где живёт хранилище контейнерных образов infra infra/hosts.md sem
|
||||
76.132 infra infra/hosts.md lex
|
||||
TEST-4173 work/jira work/jira/flow.md lex
|
||||
192.168.88.35:8080 infra infra/hosts.md lex
|
||||
|
||||
|
@@ -1,3 +1,14 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
application
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("memo.cli.MainKt")
|
||||
applicationName = "memo"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":memo-core"))
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
package memo.cli
|
||||
|
||||
import memo.core.Db
|
||||
import memo.core.Embedder
|
||||
import memo.core.Hit
|
||||
import memo.core.Indexer
|
||||
import memo.core.ModelStore
|
||||
import memo.core.RefreshHook
|
||||
import memo.core.SearchMode
|
||||
import memo.core.Searcher
|
||||
import memo.core.findCollections
|
||||
import memo.core.resolveCollection
|
||||
import java.io.File
|
||||
|
||||
sealed interface Cmd
|
||||
data class IndexCmd(val path: String) : Cmd
|
||||
data class SearchCmd(
|
||||
val path: String,
|
||||
val query: String,
|
||||
val k: Int,
|
||||
val mode: SearchMode,
|
||||
val json: Boolean,
|
||||
) : Cmd
|
||||
data class StatusCmd(val path: String) : Cmd
|
||||
data class McpProbeCmd(val tool: String, val argsJson: String) : Cmd
|
||||
data class ModelCmd(val dir: String, val baseUrl: String, val force: Boolean) : Cmd
|
||||
data object HelpCmd : Cmd
|
||||
|
||||
fun parseArgs(args: Array<String>): Cmd {
|
||||
if (args.isEmpty()) return HelpCmd
|
||||
return when (val cmd = args[0]) {
|
||||
"index" -> {
|
||||
val path = args.getOrNull(1) ?: return HelpCmd
|
||||
IndexCmd(path)
|
||||
}
|
||||
"search" -> parseSearch(args.drop(1))
|
||||
"status" -> {
|
||||
val path = args.getOrNull(1) ?: return HelpCmd
|
||||
StatusCmd(path)
|
||||
}
|
||||
"model" -> parseModel(args.drop(1))
|
||||
"mcp-probe" -> parseMcpProbe(args.drop(1))
|
||||
"--help", "-h" -> HelpCmd
|
||||
else -> HelpCmd
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseModel(rest: List<String>): Cmd {
|
||||
var dir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||
var url = ModelStore.DEFAULT_BASE_URL
|
||||
var force = false
|
||||
var i = 0
|
||||
while (i < rest.size) {
|
||||
when (rest[i]) {
|
||||
"--dir" -> {
|
||||
val v = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
dir = v
|
||||
i += 2
|
||||
}
|
||||
"--url" -> {
|
||||
val v = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
url = v
|
||||
i += 2
|
||||
}
|
||||
"--force" -> {
|
||||
force = true
|
||||
i += 1
|
||||
}
|
||||
else -> return HelpCmd
|
||||
}
|
||||
}
|
||||
return ModelCmd(dir, url, force)
|
||||
}
|
||||
|
||||
private fun parseSearch(rest: List<String>): Cmd {
|
||||
if (rest.size < 2) return HelpCmd
|
||||
val path = rest[0]
|
||||
val query = rest[1]
|
||||
var k = 8
|
||||
var mode = SearchMode.HYBRID
|
||||
var json = false
|
||||
var i = 2
|
||||
while (i < rest.size) {
|
||||
when (val a = rest[i]) {
|
||||
"--k" -> {
|
||||
val v = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
val n = v.toIntOrNull() ?: return HelpCmd
|
||||
k = n
|
||||
i += 2
|
||||
}
|
||||
"--mode" -> {
|
||||
val v = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
mode = when (v) {
|
||||
"hybrid" -> SearchMode.HYBRID
|
||||
"lex" -> SearchMode.LEX
|
||||
"vec" -> SearchMode.VEC
|
||||
else -> return HelpCmd
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
"--json" -> {
|
||||
json = true
|
||||
i += 1
|
||||
}
|
||||
else -> return HelpCmd
|
||||
}
|
||||
}
|
||||
return SearchCmd(path, query, k, mode, json)
|
||||
}
|
||||
|
||||
private fun parseMcpProbe(rest: List<String>): Cmd {
|
||||
var tool: String? = null
|
||||
var argsJson = "{}"
|
||||
var i = 0
|
||||
while (i < rest.size) {
|
||||
when (rest[i]) {
|
||||
"--tool" -> {
|
||||
tool = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
i += 2
|
||||
}
|
||||
"--args" -> {
|
||||
argsJson = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
i += 2
|
||||
}
|
||||
else -> return HelpCmd
|
||||
}
|
||||
}
|
||||
val t = tool ?: return HelpCmd
|
||||
return McpProbeCmd(t, argsJson)
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
try {
|
||||
when (val cmd = parseArgs(args)) {
|
||||
is IndexCmd -> runIndex(cmd)
|
||||
is SearchCmd -> runSearch(cmd)
|
||||
is StatusCmd -> runStatus(cmd)
|
||||
is ModelCmd -> runModel(cmd)
|
||||
is McpProbeCmd -> runMcpProbe(cmd)
|
||||
HelpCmd -> printHelp()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
System.err.println(t.message ?: t.toString())
|
||||
kotlin.system.exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun printHelp() {
|
||||
println(
|
||||
"""
|
||||
usage:
|
||||
memo index <path>
|
||||
memo search <path> <query> [--k N] [--mode hybrid|lex|vec] [--json]
|
||||
memo status <path>
|
||||
memo model [--dir <path>] [--url <base>] [--force]
|
||||
memo mcp-probe --tool <name> [--args <json>]
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
private fun modelDir(): File =
|
||||
File(System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2")
|
||||
|
||||
private fun modelPaths(): Pair<String, String> = ModelStore.paths(modelDir())
|
||||
|
||||
private fun ensureModel(dir: File) {
|
||||
if (ModelStore.isComplete(dir)) return
|
||||
if (System.getenv("MEMO_MODEL_AUTO_DOWNLOAD") == "0") {
|
||||
val msg = "модель не найдена в ${dir.absolutePath}; запустите: memo model"
|
||||
System.err.println(msg)
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
System.err.println(
|
||||
"модель не найдена в ${dir.absolutePath}, скачиваю с ${ModelStore.DEFAULT_BASE_URL} (≈287 МБ, один раз)",
|
||||
)
|
||||
ModelStore.ensure(dir, log = { System.err.println(it) })
|
||||
}
|
||||
|
||||
private fun runModel(cmd: ModelCmd) {
|
||||
val dir = File(cmd.dir)
|
||||
val result = ModelStore.ensure(
|
||||
dir = dir,
|
||||
baseUrl = cmd.baseUrl,
|
||||
force = cmd.force,
|
||||
log = { System.err.println(it) },
|
||||
)
|
||||
if (result.downloaded.isEmpty()) {
|
||||
println("модель уже на месте в ${dir.absolutePath}: ${result.skipped.joinToString(", ")}")
|
||||
} else {
|
||||
println("скачано: ${result.downloaded.joinToString(", ")} (${result.bytes} байт)")
|
||||
if (result.skipped.isNotEmpty()) {
|
||||
println("уже было: ${result.skipped.joinToString(", ")}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runIndex(cmd: IndexCmd) {
|
||||
val base = resolveCollection(File(cmd.path))
|
||||
val collections = findCollections(base)
|
||||
if (collections.isEmpty()) {
|
||||
System.err.println("коллекции не найдены в ${base.absolutePath}")
|
||||
return
|
||||
}
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
ensureModel(modelDir())
|
||||
var totalUpdated = 0
|
||||
for (coll in collections) {
|
||||
val memoDir = File(coll, ".memo")
|
||||
memoDir.mkdirs()
|
||||
val dbPath = File(memoDir, "index.db").absolutePath
|
||||
val totalFiles = coll.walkTopDown()
|
||||
.maxDepth(8)
|
||||
.count { it.isFile && it.extension == "md" }
|
||||
val db = Db(dbPath)
|
||||
try {
|
||||
db.init()
|
||||
val embedder = Embedder(modelPath, tokenizerPath)
|
||||
try {
|
||||
val updated = Indexer(db, embedder).indexTree(coll)
|
||||
println("индексировано: $updated обновлено, $totalFiles файлов всего")
|
||||
totalUpdated += updated
|
||||
} finally {
|
||||
embedder.close()
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
println("итого: $totalUpdated обновлено в ${collections.size} коллекциях")
|
||||
}
|
||||
|
||||
private fun runSearch(cmd: SearchCmd) {
|
||||
val targets = resolveSearchTargets(File(cmd.path))
|
||||
if (targets.isEmpty()) {
|
||||
if (cmd.json) println("[]")
|
||||
else println("коллекции не найдены")
|
||||
return
|
||||
}
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
ensureModel(modelDir())
|
||||
val allHits = ArrayList<Hit>()
|
||||
for (coll in targets) {
|
||||
val dbPath = File(coll, ".memo/index.db")
|
||||
if (!dbPath.exists()) continue
|
||||
runCatching {
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
val embedder = Embedder(modelPath, tokenizerPath)
|
||||
try {
|
||||
val refresh = RefreshHook { root ->
|
||||
val innerDbPath = File(root, ".memo/index.db")
|
||||
if (innerDbPath.exists()) {
|
||||
val innerDb = Db(innerDbPath.absolutePath)
|
||||
try {
|
||||
Indexer(innerDb, embedder).indexTree(root)
|
||||
} finally {
|
||||
innerDb.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val hits = Searcher(db, embedder, refresh)
|
||||
.search(coll, cmd.query, cmd.k, cmd.mode)
|
||||
allHits.addAll(hits)
|
||||
} finally {
|
||||
embedder.close()
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val merged = allHits.sortedByDescending { it.score }.take(cmd.k)
|
||||
if (cmd.json) {
|
||||
printJsonHits(merged)
|
||||
} else {
|
||||
for (h in merged) {
|
||||
val text = if (h.text.length > 300) h.text.substring(0, 300) else h.text
|
||||
println(String.format("%.3f %s:%d %s", h.score, h.path, h.line, h.heading))
|
||||
for (line in text.lines()) {
|
||||
println(" $line")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveSearchTargets(path: File): List<File> {
|
||||
if (path.isFile && path.extension == "md") {
|
||||
return listOfNotNull(path.parentFile)
|
||||
}
|
||||
if (File(path, ".memo/index.db").exists()) {
|
||||
return listOf(path)
|
||||
}
|
||||
val subs = path.listFiles()
|
||||
?.filter { it.isDirectory && File(it, ".memo/index.db").exists() }
|
||||
?: emptyList()
|
||||
return subs
|
||||
}
|
||||
|
||||
private fun runStatus(cmd: StatusCmd) {
|
||||
val base = resolveCollection(File(cmd.path))
|
||||
val collections = if (File(base, ".memo/index.db").exists()) {
|
||||
listOf(base)
|
||||
} else {
|
||||
findCollections(base)
|
||||
}
|
||||
if (collections.isEmpty()) {
|
||||
println("коллекции не найдены")
|
||||
return
|
||||
}
|
||||
for (coll in collections) {
|
||||
val dbPath = File(coll, ".memo/index.db")
|
||||
if (!dbPath.exists()) {
|
||||
println("${coll.name}: нет индекса")
|
||||
continue
|
||||
}
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
val fileCount = countInt(db, "SELECT COUNT(*) FROM files")
|
||||
val chunkCount = countInt(db, "SELECT COUNT(*) FROM chunks")
|
||||
val indexedAt = maxDouble(db, "SELECT indexed_at FROM files WHERE indexed_at IS NOT NULL")
|
||||
val date = if (indexedAt != null) {
|
||||
java.time.Instant.ofEpochSecond(indexedAt.toLong()).toString()
|
||||
} else {
|
||||
"—"
|
||||
}
|
||||
println("$coll: файлов $fileCount, чанков $chunkCount, индекс $date")
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun countInt(db: Db, sql: String): Int {
|
||||
val stmt = db.conn.prepare(sql)
|
||||
return try {
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
if (rs.next()) rs.getInt(0) ?: 0 else 0
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun maxDouble(db: Db, sql: String): Double? {
|
||||
val stmt = db.conn.prepare(sql)
|
||||
return try {
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
var best: Double? = null
|
||||
while (rs.next()) {
|
||||
val v = rs.getDouble(0) ?: continue
|
||||
if (best == null || v > best) best = v
|
||||
}
|
||||
best
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runMcpProbe(cmd: McpProbeCmd) {
|
||||
when (cmd.tool) {
|
||||
"memo_reindex" -> printMcpError("not implemented yet")
|
||||
"memo_search" -> {
|
||||
val args = parseJsonArgs(cmd.argsJson)
|
||||
val path = (args["path"] as? String) ?: ""
|
||||
val query = (args["query"] as? String) ?: ""
|
||||
val k = (args["k"] as? Number)?.toInt() ?: 8
|
||||
val mode = when (args["mode"] as? String) {
|
||||
"lex" -> SearchMode.LEX
|
||||
"vec" -> SearchMode.VEC
|
||||
else -> SearchMode.HYBRID
|
||||
}
|
||||
val jsonOutput = (args["json"] as? Boolean) ?: true
|
||||
val hits = performSearch(path, query, k, mode)
|
||||
val text = if (jsonOutput) formatHitsJson(hits) else formatHitsHuman(hits)
|
||||
printMcpOk(text)
|
||||
}
|
||||
"memo_status" -> {
|
||||
val args = parseJsonArgs(cmd.argsJson)
|
||||
val path = (args["path"] as? String) ?: ""
|
||||
val text = formatStatusInner(path)
|
||||
printMcpOk(text)
|
||||
}
|
||||
else -> printMcpError("unknown tool: ${cmd.tool}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSearch(path: String, query: String, k: Int, mode: SearchMode): List<Hit> {
|
||||
val targets = resolveSearchTargets(File(path))
|
||||
if (targets.isEmpty()) return emptyList()
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
ensureModel(modelDir())
|
||||
val allHits = ArrayList<Hit>()
|
||||
for (coll in targets) {
|
||||
val dbPath = File(coll, ".memo/index.db")
|
||||
if (!dbPath.exists()) continue
|
||||
runCatching {
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
val embedder = Embedder(modelPath, tokenizerPath)
|
||||
try {
|
||||
val refresh = RefreshHook { root ->
|
||||
val innerDbPath = File(root, ".memo/index.db")
|
||||
if (innerDbPath.exists()) {
|
||||
val innerDb = Db(innerDbPath.absolutePath)
|
||||
try {
|
||||
Indexer(innerDb, embedder).indexTree(root)
|
||||
} finally {
|
||||
innerDb.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val hits = Searcher(db, embedder, refresh)
|
||||
.search(coll, query, k, mode)
|
||||
allHits.addAll(hits)
|
||||
} finally {
|
||||
embedder.close()
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
return allHits.sortedByDescending { it.score }.take(k)
|
||||
}
|
||||
|
||||
private fun formatStatusInner(path: String): String {
|
||||
val base = resolveCollection(File(path))
|
||||
val collections = if (File(base, ".memo/index.db").exists()) {
|
||||
listOf(base)
|
||||
} else {
|
||||
findCollections(base)
|
||||
}
|
||||
if (collections.isEmpty()) return "коллекции не найдены"
|
||||
val sb = StringBuilder()
|
||||
for (coll in collections) {
|
||||
val dbPath = File(coll, ".memo/index.db")
|
||||
if (!dbPath.exists()) {
|
||||
sb.append("${coll.name}: нет индекса\n")
|
||||
continue
|
||||
}
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
val fileCount = countInt(db, "SELECT COUNT(*) FROM files")
|
||||
val chunkCount = countInt(db, "SELECT COUNT(*) FROM chunks")
|
||||
val indexedAt = maxDouble(db, "SELECT indexed_at FROM files WHERE indexed_at IS NOT NULL")
|
||||
val date = if (indexedAt != null) {
|
||||
java.time.Instant.ofEpochSecond(indexedAt.toLong()).toString()
|
||||
} else {
|
||||
"—"
|
||||
}
|
||||
sb.append("$coll: файлов $fileCount, чанков $chunkCount, индекс $date\n")
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
return sb.toString().trimEnd('\n')
|
||||
}
|
||||
|
||||
private fun printJsonHits(hits: List<Hit>) {
|
||||
println(formatHitsJson(hits))
|
||||
}
|
||||
|
||||
private fun formatHitsJson(hits: List<Hit>): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append('[')
|
||||
for ((i, h) in hits.withIndex()) {
|
||||
if (i > 0) sb.append(',')
|
||||
sb.append('{')
|
||||
sb.append("\"path\":").append(jsonStr(h.path)).append(',')
|
||||
sb.append("\"line\":").append(h.line).append(',')
|
||||
sb.append("\"heading\":").append(jsonStr(h.heading)).append(',')
|
||||
sb.append("\"score\":").append("%.6f".format(h.score)).append(',')
|
||||
sb.append("\"text\":").append(jsonStr(h.text))
|
||||
sb.append('}')
|
||||
}
|
||||
sb.append(']')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun formatHitsHuman(hits: List<Hit>): String {
|
||||
val sb = StringBuilder()
|
||||
for (h in hits) {
|
||||
val text = if (h.text.length > 300) h.text.substring(0, 300) else h.text
|
||||
sb.append(String.format("%.3f %s:%d %s", h.score, h.path, h.line, h.heading))
|
||||
sb.append('\n')
|
||||
for (line in text.lines()) {
|
||||
sb.append(" ").append(line).append('\n')
|
||||
}
|
||||
}
|
||||
return sb.toString().trimEnd('\n')
|
||||
}
|
||||
|
||||
private fun jsonStr(s: String): String {
|
||||
val sb = StringBuilder(s.length + 2)
|
||||
sb.append('"')
|
||||
for (c in s) {
|
||||
when (c) {
|
||||
'"' -> sb.append("\\\"")
|
||||
'\\' -> sb.append("\\\\")
|
||||
'\n' -> sb.append("\\n")
|
||||
'\r' -> sb.append("\\r")
|
||||
'\t' -> sb.append("\\t")
|
||||
'\b' -> sb.append("\\b")
|
||||
'\u000C' -> sb.append("\\f")
|
||||
else -> if (c.code < 0x20) {
|
||||
sb.append("\\u%04x".format(c.code))
|
||||
} else {
|
||||
sb.append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun printMcpOk(text: String) {
|
||||
println("""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":false}""")
|
||||
}
|
||||
|
||||
private fun printMcpError(text: String) {
|
||||
println("""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":true}""")
|
||||
}
|
||||
|
||||
private fun parseJsonArgs(json: String): Map<String, Any?> {
|
||||
val map = HashMap<String, Any?>()
|
||||
val trimmed = json.trim()
|
||||
if (trimmed.isEmpty() || trimmed == "{}") return map
|
||||
val inner = trimmed.trim().removePrefix("{").removeSuffix("}")
|
||||
if (inner.isBlank()) return map
|
||||
var i = 0
|
||||
while (i < inner.length) {
|
||||
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||
if (i >= inner.length) break
|
||||
if (inner[i] != '"') return map
|
||||
val keyEnd = readJsonString(inner, i)
|
||||
val key = unescapeJson(inner.substring(i + 1, keyEnd))
|
||||
i = keyEnd + 1
|
||||
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||
if (i >= inner.length || inner[i] != ':') return map
|
||||
i++
|
||||
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||
if (i >= inner.length) return map
|
||||
val (value, next) = readJsonValue(inner, i)
|
||||
map[key] = value
|
||||
i = next
|
||||
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||
if (i < inner.length && inner[i] == ',') i++
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private fun readJsonString(s: String, start: Int): Int {
|
||||
var i = start + 1
|
||||
while (i < s.length) {
|
||||
if (s[i] == '\\') {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (s[i] == '"') return i
|
||||
i++
|
||||
}
|
||||
return s.length
|
||||
}
|
||||
|
||||
private fun readJsonValue(s: String, start: Int): Pair<Any?, Int> {
|
||||
val c = s[start]
|
||||
return when {
|
||||
c == '"' -> {
|
||||
val end = readJsonString(s, start)
|
||||
unescapeJson(s.substring(start + 1, end)) to (end + 1)
|
||||
}
|
||||
c == 't' && s.regionMatches(start, "true", 0, 4) -> true to (start + 4)
|
||||
c == 'f' && s.regionMatches(start, "false", 0, 5) -> false to (start + 5)
|
||||
c == 'n' && s.regionMatches(start, "null", 0, 4) -> null to (start + 4)
|
||||
c == '-' || c.isDigit() -> {
|
||||
var i = start
|
||||
while (i < s.length && (s[i].isDigit() || s[i] == '-' || s[i] == '+' || s[i] == '.' || s[i] == 'e' || s[i] == 'E')) i++
|
||||
val raw = s.substring(start, i)
|
||||
val num = raw.toDoubleOrNull() ?: raw
|
||||
num to i
|
||||
}
|
||||
else -> null to (start + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unescapeJson(s: String): String {
|
||||
val sb = StringBuilder(s.length)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '\\' && i + 1 < s.length) {
|
||||
when (s[i + 1]) {
|
||||
'"' -> sb.append('"')
|
||||
'\\' -> sb.append('\\')
|
||||
'/' -> sb.append('/')
|
||||
'n' -> sb.append('\n')
|
||||
'r' -> sb.append('\r')
|
||||
't' -> sb.append('\t')
|
||||
'b' -> sb.append('\b')
|
||||
'f' -> sb.append('\u000C')
|
||||
'u' -> {
|
||||
if (i + 5 < s.length) {
|
||||
val hex = s.substring(i + 2, i + 6)
|
||||
sb.append(hex.toInt(16).toChar())
|
||||
i += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package memo.cli
|
||||
|
||||
import memo.core.SearchMode
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CliArgsTest {
|
||||
|
||||
@Test
|
||||
fun indexCommandParsed() {
|
||||
val cmd = parseArgs(arrayOf("index", "/tmp/x"))
|
||||
assertTrue(cmd is IndexCmd, "ожидался IndexCmd, получено $cmd")
|
||||
assertEquals("/tmp/x", cmd.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchDefaultsAreK8HybridNoJson() {
|
||||
val cmd = parseArgs(arrayOf("search", "/tmp/x", "вопрос"))
|
||||
assertTrue(cmd is SearchCmd, "ожидался SearchCmd, получено $cmd")
|
||||
assertEquals(8, cmd.k)
|
||||
assertEquals(SearchMode.HYBRID, cmd.mode)
|
||||
assertEquals(false, cmd.json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchFlagsParsed() {
|
||||
val cmd = parseArgs(
|
||||
arrayOf("search", "/tmp/x", "вопрос", "--k", "3", "--mode", "lex", "--json")
|
||||
)
|
||||
assertTrue(cmd is SearchCmd, "ожидался SearchCmd, получено $cmd")
|
||||
assertEquals(3, cmd.k)
|
||||
assertEquals(SearchMode.LEX, cmd.mode)
|
||||
assertEquals(true, cmd.json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun badKValueFallsBackToHelp() {
|
||||
val cmd = parseArgs(arrayOf("search", "/tmp/x", "вопрос", "--k", "abc"))
|
||||
assertTrue(cmd is HelpCmd, "ожидался HelpCmd, получено $cmd")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownCommandFallsBackToHelp() {
|
||||
val cmd = parseArgs(arrayOf("сломать"))
|
||||
assertTrue(cmd is HelpCmd, "ожидался HelpCmd, получено $cmd")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("pw.binom.db:ksqlite:0.1.4")
|
||||
api("pw.binom.db:ksqlite:0.1.4")
|
||||
implementation("pw.binom.ai.embeddingtext:api:5")
|
||||
runtimeOnly("pw.binom.ai.embeddingtext:siglip-jvm:5")
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package memo.core
|
||||
|
||||
data class Chunk(
|
||||
val heading: String,
|
||||
val line: Int,
|
||||
val text: String,
|
||||
val ord: Int,
|
||||
)
|
||||
|
||||
private const val CHUNK_SIZE = 4000
|
||||
private const val CHUNK_OVERLAP = 600
|
||||
|
||||
private val HEADING_REGEX = Regex("^(#{1,3}) (.*)$")
|
||||
|
||||
fun chunkMarkdown(text: String): List<Chunk> {
|
||||
val sections = mutableListOf<Triple<String, Int, String>>()
|
||||
|
||||
var currentHeading = ""
|
||||
var currentLine = 1
|
||||
val currentBody = StringBuilder()
|
||||
|
||||
val preamble = StringBuilder()
|
||||
var foundHeading = false
|
||||
|
||||
fun flush(heading: String, line: Int, body: String) {
|
||||
val trimmed = body.trim()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
sections.add(Triple(heading, line, trimmed))
|
||||
}
|
||||
}
|
||||
|
||||
val lines = text.split('\n')
|
||||
for ((idx, raw) in lines.withIndex()) {
|
||||
val lineNo = idx + 1
|
||||
val match = HEADING_REGEX.matchEntire(raw)
|
||||
if (match != null) {
|
||||
if (!foundHeading) {
|
||||
flush("", 1, preamble.toString())
|
||||
foundHeading = true
|
||||
} else {
|
||||
flush(currentHeading, currentLine, currentBody.toString())
|
||||
}
|
||||
currentHeading = match.groupValues[2].trim()
|
||||
currentLine = lineNo
|
||||
currentBody.setLength(0)
|
||||
} else {
|
||||
if (foundHeading) {
|
||||
currentBody.append(raw).append('\n')
|
||||
} else {
|
||||
preamble.append(raw).append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundHeading) {
|
||||
flush(currentHeading, currentLine, currentBody.toString())
|
||||
} else {
|
||||
flush("", 1, preamble.toString())
|
||||
}
|
||||
|
||||
val result = mutableListOf<Chunk>()
|
||||
var ord = 0
|
||||
for ((heading, line, body) in sections) {
|
||||
if (body.length <= CHUNK_SIZE) {
|
||||
result.add(Chunk(heading, line, body, ord++))
|
||||
} else {
|
||||
val step = CHUNK_SIZE - CHUNK_OVERLAP
|
||||
var i = 0
|
||||
while (i < body.length) {
|
||||
val end = minOf(i + CHUNK_SIZE, body.length)
|
||||
result.add(Chunk(heading, line, body.substring(i, end), ord++))
|
||||
if (end == body.length) break
|
||||
i += step
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
|
||||
fun isCollection(dir: File): Boolean {
|
||||
if (!dir.isDirectory) return false
|
||||
return dir.listFiles()?.any { it.isFile && it.extension == "md" } == true
|
||||
}
|
||||
|
||||
fun resolveCollection(path: File): File {
|
||||
if (path.isFile) return path.parentFile ?: path
|
||||
if (isCollection(path)) return path
|
||||
val descendants = findCollections(path)
|
||||
if (descendants.size == 1) return descendants[0]
|
||||
return path
|
||||
}
|
||||
|
||||
fun findCollections(root: File): List<File> {
|
||||
if (!root.isDirectory) return emptyList()
|
||||
val result = LinkedHashSet<File>()
|
||||
fun walk(d: File) {
|
||||
if (isCollection(d)) result.add(d)
|
||||
val children = d.listFiles() ?: return
|
||||
for (c in children) {
|
||||
if (c.isDirectory && !c.name.startsWith(".")) {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return result.sortedBy { it.absolutePath }
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
class Indexer(private val db: Db, private val embedder: Embedder) {
|
||||
|
||||
fun indexFile(path: File): Boolean {
|
||||
val absPath = path.absolutePath
|
||||
val mtime = path.lastModified() / 1000.0
|
||||
val size = path.length()
|
||||
|
||||
val existing = readFileRecord(absPath)
|
||||
if (existing != null) {
|
||||
if (existing.mtime == mtime && existing.size == size) {
|
||||
return false
|
||||
}
|
||||
val content = path.readText()
|
||||
val newHash = sha256Hex(content)
|
||||
if (existing.hash == newHash) {
|
||||
updateFileTouched(absPath, mtime, size)
|
||||
return false
|
||||
}
|
||||
reindex(absPath, content, mtime, size, newHash)
|
||||
return true
|
||||
}
|
||||
|
||||
val content = path.readText()
|
||||
val hash = sha256Hex(content)
|
||||
reindex(absPath, content, mtime, size, hash)
|
||||
return true
|
||||
}
|
||||
|
||||
fun indexTree(root: File): Int {
|
||||
var count = 0
|
||||
walk(root) { f ->
|
||||
if (f.isFile && f.extension == "md") {
|
||||
if (indexFile(f)) count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private fun walk(dir: File, action: (File) -> Unit) {
|
||||
if (!dir.isDirectory) return
|
||||
val children = dir.listFiles() ?: return
|
||||
for (child in children) {
|
||||
if (child.isDirectory) {
|
||||
if (child.name.startsWith(".")) continue
|
||||
walk(child, action)
|
||||
} else {
|
||||
action(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class FileRecord(val mtime: Double, val size: Long, val hash: String)
|
||||
|
||||
private fun readFileRecord(absPath: String): FileRecord? {
|
||||
val stmt = db.conn.prepare("SELECT mtime, size, hash FROM files WHERE path = ?")
|
||||
return try {
|
||||
stmt.bindText(1, absPath)
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
if (rs.next()) {
|
||||
FileRecord(rs.getDouble(0)!!, rs.getLong(1)!!, rs.getText(2)!!)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFileTouched(absPath: String, mtime: Double, size: Long) {
|
||||
val stmt = db.conn.prepare(
|
||||
"UPDATE files SET mtime = ?, size = ?, indexed_at = ? WHERE path = ?"
|
||||
)
|
||||
try {
|
||||
stmt.bindDouble(1, mtime)
|
||||
stmt.bindLong(2, size)
|
||||
stmt.bindDouble(3, System.currentTimeMillis() / 1000.0)
|
||||
stmt.bindText(4, absPath)
|
||||
stmt.executeUpdate()
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reindex(
|
||||
absPath: String,
|
||||
content: String,
|
||||
mtime: Double,
|
||||
size: Long,
|
||||
hash: String,
|
||||
) {
|
||||
val conn = db.conn
|
||||
conn.beginTransaction()
|
||||
try {
|
||||
val delFts = conn.prepare(
|
||||
"DELETE FROM chunks_fts WHERE rowid IN (SELECT id FROM chunks WHERE path = ?)"
|
||||
)
|
||||
delFts.bindText(1, absPath)
|
||||
delFts.executeUpdate()
|
||||
delFts.close()
|
||||
|
||||
val delVec = conn.prepare(
|
||||
"DELETE FROM chunks_vec WHERE rowid IN (SELECT id FROM chunks WHERE path = ?)"
|
||||
)
|
||||
delVec.bindText(1, absPath)
|
||||
delVec.executeUpdate()
|
||||
delVec.close()
|
||||
|
||||
val delChunks = conn.prepare("DELETE FROM chunks WHERE path = ?")
|
||||
delChunks.bindText(1, absPath)
|
||||
delChunks.executeUpdate()
|
||||
delChunks.close()
|
||||
|
||||
val chunks = chunkMarkdown(content)
|
||||
val insChunk = conn.prepare(
|
||||
"INSERT INTO chunks(path, heading, line, ord, text, hash) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
val insFts = conn.prepare(
|
||||
"INSERT INTO chunks_fts(rowid, text, heading) VALUES (?, ?, ?)"
|
||||
)
|
||||
val insVec = conn.prepare(
|
||||
"INSERT INTO chunks_vec(rowid, embedding) VALUES (?, ?)"
|
||||
)
|
||||
try {
|
||||
for (chunk in chunks) {
|
||||
val embedding = embedder.embed(chunk.text)
|
||||
val chunkHash = sha256Hex(chunk.text)
|
||||
insChunk.reset()
|
||||
insChunk.bindText(1, absPath)
|
||||
insChunk.bindText(2, chunk.heading)
|
||||
insChunk.bindLong(3, chunk.line.toLong())
|
||||
insChunk.bindLong(4, chunk.ord.toLong())
|
||||
insChunk.bindText(5, chunk.text)
|
||||
insChunk.bindText(6, chunkHash)
|
||||
insChunk.executeUpdate()
|
||||
val id = conn.lastInsertRowId
|
||||
insFts.reset()
|
||||
insFts.bindLong(1, id)
|
||||
insFts.bindText(2, chunk.text)
|
||||
insFts.bindText(3, chunk.heading)
|
||||
insFts.executeUpdate()
|
||||
insVec.reset()
|
||||
insVec.bindLong(1, id)
|
||||
insVec.bindVector(2, embedding)
|
||||
insVec.executeUpdate()
|
||||
}
|
||||
} finally {
|
||||
insChunk.close()
|
||||
insFts.close()
|
||||
insVec.close()
|
||||
}
|
||||
|
||||
val upsert = conn.prepare(
|
||||
"INSERT INTO files(path, mtime, size, hash, indexed_at) VALUES (?, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT(path) DO UPDATE SET " +
|
||||
"mtime = excluded.mtime, size = excluded.size, " +
|
||||
"hash = excluded.hash, indexed_at = excluded.indexed_at"
|
||||
)
|
||||
try {
|
||||
upsert.bindText(1, absPath)
|
||||
upsert.bindDouble(2, mtime)
|
||||
upsert.bindLong(3, size)
|
||||
upsert.bindText(4, hash)
|
||||
upsert.bindDouble(5, System.currentTimeMillis() / 1000.0)
|
||||
upsert.executeUpdate()
|
||||
} finally {
|
||||
upsert.close()
|
||||
}
|
||||
|
||||
conn.commit()
|
||||
} catch (t: Throwable) {
|
||||
conn.rollback()
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256Hex(text: String): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val bytes = md.digest(text.toByteArray(Charsets.UTF_8))
|
||||
val sb = StringBuilder(bytes.size * 2)
|
||||
for (b in bytes) {
|
||||
val v = b.toInt() and 0xFF
|
||||
sb.append((v ushr 4).toString(16))
|
||||
sb.append((v and 0xF).toString(16))
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
|
||||
object ModelStore {
|
||||
const val DEFAULT_BASE_URL = "http://static.binom.pw/models/siglip2"
|
||||
val FILES = listOf("text_model_int8.onnx", "tokenizer.model")
|
||||
|
||||
data class Result(val downloaded: List<String>, val skipped: List<String>, val bytes: Long)
|
||||
|
||||
/** Пути к модели в том же порядке (modelPath, tokenizerPath). */
|
||||
fun paths(dir: File): Pair<String, String> =
|
||||
File(dir, FILES[0]).absolutePath to File(dir, FILES[1]).absolutePath
|
||||
|
||||
/** Все файлы модели присутствуют и непустые. */
|
||||
fun isComplete(dir: File): Boolean = FILES.all { name ->
|
||||
val f = File(dir, name)
|
||||
f.isFile && f.length() > 0L
|
||||
}
|
||||
|
||||
/** Гарантирует наличие всех файлов модели в dir. Возвращает имена скачанных/пропущенных. */
|
||||
fun ensure(
|
||||
dir: File,
|
||||
baseUrl: String = DEFAULT_BASE_URL,
|
||||
force: Boolean = false,
|
||||
log: (String) -> Unit = {},
|
||||
timeoutMillis: Long = 60_000,
|
||||
): Result {
|
||||
dir.mkdirs()
|
||||
val base = baseUrl.trimEnd('/')
|
||||
val client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(timeoutMillis))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build()
|
||||
val downloaded = ArrayList<String>()
|
||||
val skipped = ArrayList<String>()
|
||||
var bytes = 0L
|
||||
for (name in FILES) {
|
||||
val target = File(dir, name)
|
||||
if (!force && target.isFile && target.length() > 0L) {
|
||||
skipped.add(name)
|
||||
continue
|
||||
}
|
||||
bytes += download(client, dir, name, base, log)
|
||||
downloaded.add(name)
|
||||
}
|
||||
return Result(downloaded, skipped, bytes)
|
||||
}
|
||||
|
||||
private fun download(
|
||||
client: HttpClient,
|
||||
dir: File,
|
||||
name: String,
|
||||
baseUrl: String,
|
||||
log: (String) -> Unit,
|
||||
): Long {
|
||||
val target = File(dir, name)
|
||||
val part = File(dir, "$name.part")
|
||||
var startAt = if (part.isFile) part.length() else 0L
|
||||
if (part.exists() && startAt == 0L) part.delete()
|
||||
|
||||
val url = "$baseUrl/$name"
|
||||
val requestBuilder = HttpRequest.newBuilder(URI.create(url)).GET()
|
||||
if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-")
|
||||
val response = try {
|
||||
client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream())
|
||||
} catch (e: IOException) {
|
||||
part.delete()
|
||||
throw IllegalStateException("не удалось скачать $name с $url: ${e.message}", e)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
part.delete()
|
||||
throw IllegalStateException("скачивание $name прервано", e)
|
||||
}
|
||||
|
||||
val status = response.statusCode()
|
||||
if (status != 200 && status != 206) {
|
||||
response.body().close()
|
||||
part.delete()
|
||||
throw IllegalStateException("не удалось скачать $name: HTTP $status ($url)")
|
||||
}
|
||||
|
||||
val resuming = status == 206 && startAt > 0L
|
||||
if (!resuming) startAt = 0L
|
||||
val declaredBody = response.headers().firstValueAsLong("Content-Length").orElse(-1L)
|
||||
val rangeTotal = response.headers().firstValue("Content-Range").orElse(null)
|
||||
?.substringAfterLast('/')?.trim()?.toLongOrNull()
|
||||
val expectedTotal = when {
|
||||
resuming -> rangeTotal ?: if (declaredBody >= 0) startAt + declaredBody else -1L
|
||||
declaredBody >= 0 -> declaredBody
|
||||
else -> -1L
|
||||
}
|
||||
|
||||
var written = 0L
|
||||
try {
|
||||
response.body().use { input ->
|
||||
FileOutputStream(part, resuming).use { out ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
var lastLog = System.currentTimeMillis()
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
out.write(buf, 0, n)
|
||||
written += n
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastLog >= 2_000L) {
|
||||
lastLog = now
|
||||
log(progress(name, startAt + written, expectedTotal))
|
||||
}
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
part.delete()
|
||||
throw IllegalStateException("не удалось скачать $name с $url: ${e.message}", e)
|
||||
}
|
||||
|
||||
if (declaredBody >= 0 && written != declaredBody) {
|
||||
val actual = startAt + written
|
||||
val expected = startAt + declaredBody
|
||||
part.delete()
|
||||
throw IllegalStateException(
|
||||
"размер $name не совпал: ожидалось $expected байт, получено $actual байт",
|
||||
)
|
||||
}
|
||||
val actualTotal = part.length()
|
||||
if (expectedTotal >= 0 && actualTotal != expectedTotal) {
|
||||
part.delete()
|
||||
throw IllegalStateException(
|
||||
"размер $name не совпал: ожидалось $expectedTotal байт, получено $actualTotal байт",
|
||||
)
|
||||
}
|
||||
if (!part.renameTo(target)) {
|
||||
part.delete()
|
||||
throw IllegalStateException("не удалось переименовать $name.part в $name")
|
||||
}
|
||||
log("скачано $name: ${megabytes(actualTotal)} МБ")
|
||||
return actualTotal
|
||||
}
|
||||
|
||||
private fun progress(name: String, done: Long, total: Long): String =
|
||||
if (total > 0) {
|
||||
val pct = (done * 100 / total).coerceIn(0L, 100L)
|
||||
"скачиваю $name: $pct% (${megabytes(done)} МБ / ${megabytes(total)} МБ)"
|
||||
} else {
|
||||
"скачиваю $name: ${megabytes(done)} МБ"
|
||||
}
|
||||
|
||||
private fun megabytes(bytes: Long): Long = Math.round(bytes / 1_000_000.0)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
|
||||
enum class SearchMode { HYBRID, LEX, VEC }
|
||||
|
||||
data class Hit(
|
||||
val path: String,
|
||||
val line: Int,
|
||||
val heading: String,
|
||||
val score: Double,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
fun interface RefreshHook { fun refresh(root: File) }
|
||||
|
||||
class Searcher(
|
||||
private val db: Db,
|
||||
private val embedder: Embedder,
|
||||
private val refresh: RefreshHook? = null,
|
||||
) {
|
||||
fun search(
|
||||
root: File,
|
||||
query: String,
|
||||
k: Int = 8,
|
||||
mode: SearchMode = SearchMode.HYBRID,
|
||||
): List<Hit> {
|
||||
if (refresh != null) {
|
||||
refresh.refresh(root)
|
||||
}
|
||||
|
||||
val lexRows: List<Pair<Long, Double>> =
|
||||
if (mode == SearchMode.LEX || mode == SearchMode.HYBRID) lexSearch(query) else emptyList()
|
||||
val vecRows: List<Pair<Long, Double>> =
|
||||
if (mode == SearchMode.VEC || mode == SearchMode.HYBRID) vecSearch(query) else emptyList()
|
||||
|
||||
val scores = HashMap<Long, Double>()
|
||||
for ((idx, row) in lexRows.withIndex()) {
|
||||
val rank = idx + 1
|
||||
scores.merge(row.first, 1.0 / (60.0 + rank), Double::plus)
|
||||
}
|
||||
for ((idx, row) in vecRows.withIndex()) {
|
||||
val rank = idx + 1
|
||||
scores.merge(row.first, 1.0 / (60.0 + rank), Double::plus)
|
||||
}
|
||||
|
||||
if (scores.isEmpty()) return emptyList()
|
||||
|
||||
val topEntries = scores.entries.sortedByDescending { it.value }.take(k)
|
||||
val orderedRowids = topEntries.map { it.key }
|
||||
val scoreByRowid = orderedRowids.associateWith { scores[it]!! }
|
||||
|
||||
val placeholders = orderedRowids.joinToString(",") { "?" }
|
||||
val select = db.conn.prepare(
|
||||
"SELECT id, path, line, heading, text FROM chunks WHERE id IN ($placeholders)"
|
||||
)
|
||||
val rowData = HashMap<Long, RowData>()
|
||||
try {
|
||||
for ((idx, id) in orderedRowids.withIndex()) {
|
||||
select.bindLong(idx + 1, id)
|
||||
}
|
||||
val rs = select.executeQuery()
|
||||
try {
|
||||
while (rs.next()) {
|
||||
val id = rs.getLong(0)!!
|
||||
val path = rs.getText(1) ?: ""
|
||||
val line = rs.getInt(2)!!
|
||||
val heading = rs.getText(3) ?: ""
|
||||
val textRaw = rs.getText(4) ?: ""
|
||||
val text = if (textRaw.length > 1200) textRaw.substring(0, 1200) else textRaw
|
||||
rowData[id] = RowData(path, line, heading, text)
|
||||
}
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
select.close()
|
||||
}
|
||||
|
||||
return orderedRowids.mapNotNull { id ->
|
||||
val rd = rowData[id] ?: return@mapNotNull null
|
||||
Hit(
|
||||
path = rd.path,
|
||||
line = rd.line,
|
||||
heading = rd.heading,
|
||||
score = scoreByRowid[id] ?: 0.0,
|
||||
text = rd.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lexSearch(query: String): List<Pair<Long, Double>> {
|
||||
val tokens = tokensOf(query)
|
||||
if (tokens.isEmpty()) return emptyList()
|
||||
val matchExpr = tokens.joinToString(" OR ") { "\"$it\"" }
|
||||
val stmt = db.conn.prepare(
|
||||
"SELECT rowid, bm25(chunks_fts) AS s FROM chunks_fts " +
|
||||
"WHERE chunks_fts MATCH ? ORDER BY s LIMIT 32"
|
||||
)
|
||||
return try {
|
||||
stmt.bindText(1, matchExpr)
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
val out = ArrayList<Pair<Long, Double>>()
|
||||
while (rs.next()) {
|
||||
out.add(rs.getLong(0)!! to rs.getDouble(1)!!)
|
||||
}
|
||||
out
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun vecSearch(query: String): List<Pair<Long, Double>> {
|
||||
val stmt = db.conn.prepare(
|
||||
"SELECT rowid, distance FROM chunks_vec " +
|
||||
"WHERE embedding MATCH ? ORDER BY distance LIMIT 32"
|
||||
)
|
||||
return try {
|
||||
stmt.bindVector(1, embedder.embed(query))
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
val out = ArrayList<Pair<Long, Double>>()
|
||||
while (rs.next()) {
|
||||
out.add(rs.getLong(0)!! to rs.getDouble(1)!!)
|
||||
}
|
||||
out
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private data class RowData(val path: String, val line: Int, val heading: String, val text: String)
|
||||
}
|
||||
|
||||
private fun tokensOf(query: String): List<String> {
|
||||
val tokens = ArrayList<String>()
|
||||
val sb = StringBuilder()
|
||||
for (ch in query) {
|
||||
if (Character.isLetterOrDigit(ch)) {
|
||||
sb.append(ch)
|
||||
} else {
|
||||
if (sb.length >= 2) tokens.add(sb.toString())
|
||||
sb.setLength(0)
|
||||
}
|
||||
}
|
||||
if (sb.length >= 2) tokens.add(sb.toString())
|
||||
return if (tokens.size <= 8) tokens else tokens.subList(0, 8)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package memo.core
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ChunkerTest {
|
||||
|
||||
@Test
|
||||
fun splitsByHeadingLevels() {
|
||||
val input = "# A\nbodyA\n## B\nbodyB\n### C\nbodyC"
|
||||
val chunks = chunkMarkdown(input)
|
||||
assertEquals(3, chunks.size)
|
||||
assertEquals(listOf("A", "B", "C"), chunks.map { it.heading })
|
||||
assertEquals(listOf(1, 3, 5), chunks.map { it.line })
|
||||
assertEquals(listOf(0, 1, 2), chunks.map { it.ord })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun textBeforeFirstHeadingBecomesChunkWithEmptyHeading() {
|
||||
val input = "преамбула\n# Заголовок\nтело"
|
||||
val chunks = chunkMarkdown(input)
|
||||
assertEquals(2, chunks.size)
|
||||
assertEquals("", chunks[0].heading)
|
||||
assertEquals(1, chunks[0].line)
|
||||
assertEquals("Заголовок", chunks[1].heading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fourHashesIsNotAHeading() {
|
||||
val input = "# A\nbody\n#### не заголовок\nmore"
|
||||
val chunks = chunkMarkdown(input)
|
||||
assertEquals(1, chunks.size)
|
||||
assertEquals("A", chunks[0].heading)
|
||||
assertTrue(chunks[0].text.contains("#### не заголовок"))
|
||||
assertTrue(chunks[0].text.contains("more"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptySectionsAreDropped() {
|
||||
val input = "# A\nsome text\n## B\n## C"
|
||||
val chunks = chunkMarkdown(input)
|
||||
assertEquals(1, chunks.size)
|
||||
assertEquals("A", chunks[0].heading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longSectionIsSplitWithOverlap() {
|
||||
val body = "а".repeat(6000)
|
||||
val input = "# A\n$body"
|
||||
val chunks = chunkMarkdown(input)
|
||||
assertEquals(2, chunks.size)
|
||||
assertEquals(4000, chunks[0].text.length)
|
||||
assertEquals(2600, chunks[1].text.length)
|
||||
assertEquals(
|
||||
chunks[0].text.substring(4000 - 600),
|
||||
chunks[1].text.substring(0, 600),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CollectionsTest {
|
||||
|
||||
private fun newRoot(): File {
|
||||
val root = File.createTempFile("memo-collections-", "")
|
||||
assertTrue(root.delete(), "temp cleanup")
|
||||
assertTrue(root.mkdirs(), "temp mkdir")
|
||||
root.deleteOnExit()
|
||||
return root
|
||||
}
|
||||
|
||||
private fun touch(parent: File, vararg rel: String): File {
|
||||
var cur = parent
|
||||
for (seg in rel.dropLast(1)) {
|
||||
cur = File(cur, seg)
|
||||
cur.mkdirs()
|
||||
}
|
||||
val f = File(cur, rel.last())
|
||||
f.writeText("# ${rel.last()}\n")
|
||||
f.deleteOnExit()
|
||||
return f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findCollectionsReturnsOnlyDirsWithDirectMarkdown() {
|
||||
val root = newRoot()
|
||||
touch(root, "a.md")
|
||||
touch(root, "sub", "b.md")
|
||||
touch(root, "sub", "deep", "c.md")
|
||||
File(root, "empty").mkdirs()
|
||||
touch(root, "nested", "only", "deep", "d.md")
|
||||
File(root, "fakemd").mkdirs()
|
||||
File(root, "fakemd/submd").mkdirs()
|
||||
File(root, "readme.txt").writeText("не markdown")
|
||||
|
||||
val got = findCollections(root).map { it.absolutePath }.toSet()
|
||||
val want = setOf(
|
||||
root.absolutePath,
|
||||
File(root, "sub").absolutePath,
|
||||
File(root, "sub/deep").absolutePath,
|
||||
File(root, "nested/only/deep").absolutePath,
|
||||
)
|
||||
assertEquals(want, got)
|
||||
assertTrue(File(root, "empty").absolutePath !in got, "empty must not be a collection")
|
||||
assertTrue(File(root, "nested").absolutePath !in got, "nested must not be a collection")
|
||||
assertTrue(File(root, "nested/only").absolutePath !in got, "nested/only must not be a collection")
|
||||
assertTrue(File(root, "fakemd").absolutePath !in got, "fakemd must not be a collection")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parentDirWithoutDirectMarkdownIsNotCollection() {
|
||||
val root = newRoot()
|
||||
touch(root, "top", "inner", "x.md")
|
||||
|
||||
val got = findCollections(root).map { it.absolutePath }.toSet()
|
||||
assertEquals(setOf(File(root, "top/inner").absolutePath), got)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveCollectionRaisesToNearestWithMarkdown() {
|
||||
val root = newRoot()
|
||||
val note = touch(root, "top", "inner", "x.md")
|
||||
|
||||
val fileResult = resolveCollection(note)
|
||||
assertEquals(File(root, "top/inner").absolutePath, fileResult.absolutePath)
|
||||
|
||||
val dirResult = resolveCollection(File(root, "top"))
|
||||
assertEquals(File(root, "top/inner").absolutePath, dirResult.absolutePath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenDirsAreSkipped() {
|
||||
val root = newRoot()
|
||||
touch(root, ".hidden", "a.md")
|
||||
touch(root, "vis", "b.md")
|
||||
File(root, ".memo").mkdirs()
|
||||
File(File(root, ".memo"), "index.db").writeText("")
|
||||
|
||||
val got = findCollections(root).map { it.absolutePath }.toSet()
|
||||
assertEquals(setOf(File(root, "vis").absolutePath), got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package memo.core
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
@@ -180,4 +181,56 @@ class CoreSmokeTest {
|
||||
assertTrue(v.none { it.isNaN() }, "embedding contains NaN")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun indexerSkipsUnchangedFile() {
|
||||
val tempDir = File.createTempFile("memo-indexer-", "")
|
||||
assertTrue(tempDir.delete(), "temp dir cleanup")
|
||||
assertTrue(tempDir.mkdir(), "temp dir create")
|
||||
tempDir.deleteOnExit()
|
||||
|
||||
val note = File(tempDir, "note.md")
|
||||
note.writeText("# Заголовок\nтело заметки\n## Второй\nещё текст")
|
||||
note.deleteOnExit()
|
||||
|
||||
val dbFile = File(tempDir, "index.db")
|
||||
dbFile.deleteOnExit()
|
||||
|
||||
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||
val modelPath = "$modelDir/text_model_int8.onnx"
|
||||
val tokenizerPath = "$modelDir/tokenizer.model"
|
||||
|
||||
Db(dbFile.absolutePath).use { db ->
|
||||
db.init()
|
||||
Embedder(modelPath, tokenizerPath).use { embedder ->
|
||||
val indexer = Indexer(db, embedder)
|
||||
|
||||
assertTrue(indexer.indexFile(note), "первый indexFile обязан переиндексировать")
|
||||
assertFalse(
|
||||
indexer.indexFile(note),
|
||||
"второй indexFile без изменений обязан вернуть false",
|
||||
)
|
||||
|
||||
note.appendText("\n## Третий\nновый текст\n")
|
||||
assertTrue(
|
||||
indexer.indexFile(note),
|
||||
"indexFile после изменения содержимого обязан вернуть true",
|
||||
)
|
||||
|
||||
val stmt = db.conn.prepare("SELECT count(*) FROM chunks")
|
||||
try {
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
assertTrue(rs.next(), "SELECT count(*) должен вернуть строку")
|
||||
val count = rs.getLong(0)!!
|
||||
assertTrue(count > 0, "ожидались чанки, получено $count")
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package memo.core
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpHandler
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ModelStoreTest {
|
||||
|
||||
@Test
|
||||
fun downloadsMissingFiles() {
|
||||
val dir = createTempDir()
|
||||
val onnx = deterministicBytes(123_456, seed = 1)
|
||||
val tok = deterministicBytes(45_678, seed = 2)
|
||||
val server = startServer { ex ->
|
||||
when (ex.requestURI.path) {
|
||||
"/text_model_int8.onnx" -> serveBytes(ex, onnx, count = null)
|
||||
"/tokenizer.model" -> serveBytes(ex, tok, count = null)
|
||||
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val result = ModelStore.ensure(dir, baseUrl = server.base())
|
||||
assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.downloaded)
|
||||
assertEquals(emptyList(), result.skipped)
|
||||
assertEquals((onnx.size + tok.size).toLong(), result.bytes)
|
||||
|
||||
val onnxFile = java.io.File(dir, "text_model_int8.onnx")
|
||||
val tokFile = java.io.File(dir, "tokenizer.model")
|
||||
assertTrue(onnxFile.isFile, "onnx должен быть скачан")
|
||||
assertTrue(tokFile.isFile, "tokenizer должен быть скачан")
|
||||
assertEquals(onnx.size.toLong(), onnxFile.length())
|
||||
assertEquals(tok.size.toLong(), tokFile.length())
|
||||
assertContentEquals(onnx, onnxFile.readBytes())
|
||||
assertContentEquals(tok, tokFile.readBytes())
|
||||
assertFalse(java.io.File(dir, "text_model_int8.onnx.part").exists())
|
||||
assertFalse(java.io.File(dir, "tokenizer.model.part").exists())
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipsExistingWithoutNetwork() {
|
||||
val dir = createTempDir()
|
||||
val onnx = deterministicBytes(10_000, seed = 3)
|
||||
val tok = deterministicBytes(8_000, seed = 4)
|
||||
java.io.File(dir, "text_model_int8.onnx").writeBytes(onnx)
|
||||
java.io.File(dir, "tokenizer.model").writeBytes(tok)
|
||||
val count = AtomicInteger(0)
|
||||
val server = startServer { ex ->
|
||||
count.incrementAndGet()
|
||||
ex.sendResponseHeaders(500, -1)
|
||||
ex.close()
|
||||
}
|
||||
try {
|
||||
val result = ModelStore.ensure(dir, baseUrl = server.base())
|
||||
assertEquals(0, count.get(), "ensure не должен ходить в сеть, если всё уже на месте")
|
||||
assertEquals(emptyList(), result.downloaded)
|
||||
assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.skipped)
|
||||
assertEquals(0L, result.bytes)
|
||||
assertContentEquals(onnx, java.io.File(dir, "text_model_int8.onnx").readBytes())
|
||||
assertContentEquals(tok, java.io.File(dir, "tokenizer.model").readBytes())
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumesPartialDownload() {
|
||||
val dir = createTempDir()
|
||||
val full = deterministicBytes(20_000, seed = 5)
|
||||
java.io.File(dir, "text_model_int8.onnx").writeBytes(deterministicBytes(5_000, seed = 55))
|
||||
val part = java.io.File(dir, "tokenizer.model.part")
|
||||
val half = full.size / 2
|
||||
part.writeBytes(full.copyOfRange(0, half))
|
||||
|
||||
val totalRequests = AtomicInteger(0)
|
||||
val rangeRequests = AtomicInteger(0)
|
||||
val bytesServed = AtomicLong(0L)
|
||||
val server = startServer { ex ->
|
||||
when (ex.requestURI.path) {
|
||||
"/text_model_int8.onnx" -> ex.sendResponseHeaders(500, -1).also { ex.close() }
|
||||
"/tokenizer.model" -> {
|
||||
totalRequests.incrementAndGet()
|
||||
val range = ex.requestHeaders.getFirst("Range")
|
||||
if (range != null && range.startsWith("bytes=")) {
|
||||
rangeRequests.incrementAndGet()
|
||||
val spec = range.removePrefix("bytes=")
|
||||
val start = spec.substringBefore('-').trim().toLong()
|
||||
if (start in 0..full.size.toLong()) {
|
||||
val tail = full.copyOfRange(start.toInt(), full.size)
|
||||
ex.responseHeaders.set(
|
||||
"Content-Range",
|
||||
"bytes $start-${full.size - 1}/${full.size}",
|
||||
)
|
||||
ex.sendResponseHeaders(206, tail.size.toLong())
|
||||
bytesServed.addAndGet(tail.size.toLong())
|
||||
ex.responseBody.use { it.write(tail) }
|
||||
return@startServer
|
||||
}
|
||||
}
|
||||
ex.sendResponseHeaders(400, -1)
|
||||
ex.close()
|
||||
}
|
||||
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val result = ModelStore.ensure(dir, baseUrl = server.base())
|
||||
assertEquals(listOf("tokenizer.model"), result.downloaded)
|
||||
assertTrue(
|
||||
rangeRequests.get() >= 1,
|
||||
"докачка должна была пойти хвостом: Range-запросов=${rangeRequests.get()}, всего=${totalRequests.get()}",
|
||||
)
|
||||
assertTrue(
|
||||
bytesServed.get() < full.size.toLong(),
|
||||
"докачка хвостом обязана отдать меньше полного файла: " +
|
||||
"отдано=${bytesServed.get()}, файл=${full.size}",
|
||||
)
|
||||
val tokFile = java.io.File(dir, "tokenizer.model")
|
||||
assertTrue(tokFile.isFile, "tokenizer должен быть собран из .part + хвоста")
|
||||
assertEquals(full.size.toLong(), tokFile.length())
|
||||
assertContentEquals(full, tokFile.readBytes())
|
||||
assertFalse(java.io.File(dir, "tokenizer.model.part").exists(), ".part обязан быть переименован")
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWrongTotalFromContentRange() {
|
||||
val dir = createTempDir()
|
||||
val full = deterministicBytes(8_000, seed = 11)
|
||||
val part = java.io.File(dir, "text_model_int8.onnx.part")
|
||||
part.writeBytes(full.copyOfRange(0, 4_000))
|
||||
java.io.File(dir, "tokenizer.model").writeBytes(deterministicBytes(1_000, seed = 12))
|
||||
|
||||
val server = startServer { ex ->
|
||||
when (ex.requestURI.path) {
|
||||
"/text_model_int8.onnx" -> {
|
||||
val range = ex.requestHeaders.getFirst("Range")
|
||||
if (range == null || !range.startsWith("bytes=")) {
|
||||
ex.sendResponseHeaders(400, -1)
|
||||
ex.close()
|
||||
return@startServer
|
||||
}
|
||||
val start = range.removePrefix("bytes=").substringBefore('-').trim().toLong()
|
||||
val tail = full.copyOfRange(start.toInt(), full.size)
|
||||
ex.responseHeaders.set(
|
||||
"Content-Range",
|
||||
"bytes $start-${full.size - 1}/999999",
|
||||
)
|
||||
ex.sendResponseHeaders(206, tail.size.toLong())
|
||||
ex.responseBody.use { it.write(tail) }
|
||||
}
|
||||
"/tokenizer.model" -> ex.sendResponseHeaders(500, -1).also { ex.close() }
|
||||
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val ex = assertFailsWith<IllegalStateException>(
|
||||
"ensure обязан бросить исключение при лжи про общий размер в Content-Range",
|
||||
) {
|
||||
ModelStore.ensure(dir, baseUrl = server.base())
|
||||
}
|
||||
val msg = ex.message ?: ""
|
||||
assertTrue(
|
||||
msg.contains("размер"),
|
||||
"сообщение должно указывать на проблему с размером: $msg",
|
||||
)
|
||||
assertFalse(
|
||||
java.io.File(dir, "text_model_int8.onnx").exists(),
|
||||
"целевого файла быть не должно",
|
||||
)
|
||||
assertFalse(
|
||||
java.io.File(dir, "text_model_int8.onnx.part").exists(),
|
||||
".part обязан быть удалён",
|
||||
)
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun truncatedResponseDoesNotProduceFile() {
|
||||
val dir = createTempDir()
|
||||
val real = deterministicBytes(8_000, seed = 6)
|
||||
val declaredSize = (real.size + 5_000).toLong()
|
||||
java.io.File(dir, "tokenizer.model").writeBytes(deterministicBytes(1_000, seed = 13))
|
||||
val server = startServer { ex ->
|
||||
when (ex.requestURI.path) {
|
||||
"/text_model_int8.onnx" -> {
|
||||
ex.sendResponseHeaders(200, declaredSize)
|
||||
ex.responseBody.use { it.write(real) }
|
||||
ex.close()
|
||||
}
|
||||
"/tokenizer.model" -> ex.sendResponseHeaders(500, -1).also { ex.close() }
|
||||
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val ex = assertFailsWith<IllegalStateException>(
|
||||
"ensure обязан бросить исключение при оборванном ответе",
|
||||
) {
|
||||
ModelStore.ensure(dir, baseUrl = server.base())
|
||||
}
|
||||
assertTrue(
|
||||
(ex.message ?: "").isNotEmpty(),
|
||||
"исключение должно иметь осмысленное сообщение: '${ex.message}'",
|
||||
)
|
||||
assertFalse(
|
||||
java.io.File(dir, "text_model_int8.onnx").exists(),
|
||||
"целевого файла быть не должно",
|
||||
)
|
||||
assertFalse(
|
||||
java.io.File(dir, "text_model_int8.onnx.part").exists(),
|
||||
".part обязан быть удалён",
|
||||
)
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forceRedownloads() {
|
||||
val dir = createTempDir()
|
||||
val garbageOnnx = deterministicBytes(9_999, seed = 7)
|
||||
val garbageTok = deterministicBytes(3_333, seed = 8)
|
||||
java.io.File(dir, "text_model_int8.onnx").writeBytes(garbageOnnx)
|
||||
java.io.File(dir, "tokenizer.model").writeBytes(garbageTok)
|
||||
val onnx = deterministicBytes(11_111, seed = 9)
|
||||
val tok = deterministicBytes(4_444, seed = 10)
|
||||
val server = startServer { ex ->
|
||||
when (ex.requestURI.path) {
|
||||
"/text_model_int8.onnx" -> serveBytes(ex, onnx, count = null)
|
||||
"/tokenizer.model" -> serveBytes(ex, tok, count = null)
|
||||
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val result = ModelStore.ensure(dir, baseUrl = server.base(), force = true)
|
||||
assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.downloaded)
|
||||
assertContentEquals(onnx, java.io.File(dir, "text_model_int8.onnx").readBytes())
|
||||
assertContentEquals(tok, java.io.File(dir, "tokenizer.model").readBytes())
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTempDir(): java.io.File {
|
||||
val d = Files.createTempDirectory("memo-modelstore-").toFile()
|
||||
d.deleteOnExit()
|
||||
return d
|
||||
}
|
||||
|
||||
private fun deterministicBytes(size: Int, seed: Int): ByteArray {
|
||||
val out = ByteArray(size)
|
||||
var v = seed * 2_654_435_761 + 1
|
||||
for (i in out.indices) {
|
||||
v = v * 1_664_525 + 1_013_904_223
|
||||
out[i] = (v ushr 16 and 0xFF).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun startServer(handler: (HttpExchange) -> Unit): HttpServerWrap {
|
||||
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||
server.createContext("/", HttpHandler { ex -> handler(ex) })
|
||||
server.executor = null
|
||||
server.start()
|
||||
return HttpServerWrap(server, server.address.port)
|
||||
}
|
||||
|
||||
private class HttpServerWrap(private val server: HttpServer, val port: Int) {
|
||||
fun base(): String = "http://127.0.0.1:$port"
|
||||
fun stop(delay: Int) = server.stop(delay)
|
||||
}
|
||||
|
||||
private fun serveBytes(ex: HttpExchange, data: ByteArray, count: AtomicInteger?) {
|
||||
count?.incrementAndGet()
|
||||
val range = ex.requestHeaders.getFirst("Range")
|
||||
if (range != null && range.startsWith("bytes=")) {
|
||||
val spec = range.removePrefix("bytes=")
|
||||
val start = spec.substringBefore('-').trim().toLong()
|
||||
if (start in 0..data.size.toLong()) {
|
||||
val tail = data.copyOfRange(start.toInt(), data.size)
|
||||
ex.responseHeaders.set("Content-Range", "bytes $start-${data.size - 1}/${data.size}")
|
||||
ex.sendResponseHeaders(206, tail.size.toLong())
|
||||
ex.responseBody.use { it.write(tail) }
|
||||
return
|
||||
}
|
||||
}
|
||||
ex.sendResponseHeaders(200, data.size.toLong())
|
||||
ex.responseBody.use { it.write(data) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SearcherTest {
|
||||
|
||||
private class Fixture : AutoCloseable {
|
||||
val tempDir: File = Files.createTempDirectory("memo-searcher-").toFile()
|
||||
val db = Db(File(tempDir, "index.db").absolutePath)
|
||||
val embedder: Embedder
|
||||
|
||||
init {
|
||||
db.init()
|
||||
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||
val modelPath = "$modelDir/text_model_int8.onnx"
|
||||
val tokenizerPath = "$modelDir/tokenizer.model"
|
||||
embedder = Embedder(modelPath, tokenizerPath)
|
||||
}
|
||||
|
||||
fun addChunk(path: String, heading: String, line: Int, text: String) {
|
||||
val conn = db.conn
|
||||
val insChunk = conn.prepare(
|
||||
"INSERT INTO chunks(path, heading, line, ord, text, hash) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
try {
|
||||
insChunk.bindText(1, path)
|
||||
insChunk.bindText(2, heading)
|
||||
insChunk.bindLong(3, line.toLong())
|
||||
insChunk.bindLong(4, 0L)
|
||||
insChunk.bindText(5, text)
|
||||
insChunk.bindText(6, "$path#$line")
|
||||
insChunk.executeUpdate()
|
||||
} finally {
|
||||
insChunk.close()
|
||||
}
|
||||
val id = conn.lastInsertRowId
|
||||
val insFts = conn.prepare(
|
||||
"INSERT INTO chunks_fts(rowid, text, heading) VALUES (?, ?, ?)"
|
||||
)
|
||||
try {
|
||||
insFts.bindLong(1, id)
|
||||
insFts.bindText(2, text)
|
||||
insFts.bindText(3, heading)
|
||||
insFts.executeUpdate()
|
||||
} finally {
|
||||
insFts.close()
|
||||
}
|
||||
val insVec = conn.prepare(
|
||||
"INSERT INTO chunks_vec(rowid, embedding) VALUES (?, ?)"
|
||||
)
|
||||
try {
|
||||
insVec.bindLong(1, id)
|
||||
insVec.bindVector(2, embedder.embed(text))
|
||||
insVec.executeUpdate()
|
||||
} finally {
|
||||
insVec.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
embedder.close()
|
||||
db.close()
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lexModeFindsExactValue() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/a.md", "A", 1, "Прокси корпоративных доменов на 76.132")
|
||||
f.addChunk("/b.md", "B", 1, "Список контактов службы поддержки")
|
||||
f.addChunk("/c.md", "C", 1, "Описание архитектуры сетевого шлюза")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "76.132", k = 8, mode = SearchMode.LEX)
|
||||
assertTrue(hits.isNotEmpty(), "ожидались хиты, получено 0")
|
||||
assertTrue(hits[0].text.contains("76.132"), "первый хит должен содержать 76.132")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vecModeFindsSemanticMatch() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/server.md", "Server", 1, "Настройка сервера приложений и конфигурация Tomcat")
|
||||
f.addChunk("/book.md", "Book", 1, "Аннотация книги по истории Древнего Рима")
|
||||
f.addChunk("/ci.md", "CI", 1, "Пайплайн выпуска приложения: сборка, тесты, деплой в Kubernetes")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "настройку выпуска приложения", k = 1, mode = SearchMode.VEC)
|
||||
assertTrue(hits.isNotEmpty(), "ожидались хиты, получено 0")
|
||||
assertEquals("/ci.md", hits[0].path, "первый хит должен быть чанк про CI")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lexModeIgnoresVectorOnlyMatch() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/p.md", "P", 1, "опрос")
|
||||
f.addChunk("/s.md", "S", 1, "случай")
|
||||
f.addChunk("/z.md", "Z", 1, "знание")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val lex = s.search(f.tempDir, "автомобиль", k = 8, mode = SearchMode.LEX)
|
||||
val vec = s.search(f.tempDir, "автомобиль", k = 8, mode = SearchMode.VEC)
|
||||
assertTrue(lex.isEmpty(), "LEX должен быть пустым, получили ${lex.size} хитов")
|
||||
assertTrue(vec.isNotEmpty(), "VEC должен быть непустым, получили 0")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hybridCombinesBoth() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/exact.md", "Exact", 1, "Прокси корпоративных доменов на 76.132")
|
||||
f.addChunk("/sem.md", "Sem", 1, "Конвейер поставки продуктов: компиляция, испытания, выкладка в кластер")
|
||||
f.addChunk("/other.md", "Other", 1, "Заметки о книге по истории")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "выпуск приложения 76.132", k = 5, mode = SearchMode.HYBRID)
|
||||
val paths = hits.map { it.path }.toSet()
|
||||
assertTrue(paths.contains("/exact.md"), "чанк exact отсутствует в: $paths")
|
||||
assertTrue(paths.contains("/sem.md"), "чанк sem отсутствует в: $paths")
|
||||
assertEquals("/exact.md", hits[0].path, "exact.md должен быть первым в HYBRID за счёт лекс-буста, получено: ${hits.map { it.path }}")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultsRespectKAndTextLength() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/a.md", "A", 1, "Первый фрагмент про сервер")
|
||||
f.addChunk("/b.md", "B", 1, "Второй фрагмент про книгу")
|
||||
f.addChunk("/c.md", "C", 1, "Третий фрагмент про CI")
|
||||
f.addChunk("/d.md", "D", 1, "Четвёртый фрагмент про настройку")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "фрагмент", k = 2, mode = SearchMode.HYBRID)
|
||||
assertEquals(2, hits.size, "должно быть ровно 2 хита при k=2")
|
||||
for (h in hits) {
|
||||
assertTrue(h.text.isNotEmpty(), "text не должен быть пустым: $h")
|
||||
assertTrue(h.text.length <= 1200, "длина text превышает 1200: ${h.text.length}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,16 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
application
|
||||
kotlin("plugin.serialization") version "2.4.10"
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("memo.mcp.McpServerKt")
|
||||
applicationName = "memo-mcp"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":memo-core"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package memo.mcp
|
||||
|
||||
import memo.core.Db
|
||||
import memo.core.Embedder
|
||||
import memo.core.Indexer
|
||||
import memo.core.ModelStore
|
||||
import memo.core.RefreshHook
|
||||
import memo.core.SearchMode
|
||||
import memo.core.Searcher
|
||||
import memo.core.findCollections
|
||||
import memo.core.isCollection
|
||||
import memo.core.resolveCollection
|
||||
import java.io.File
|
||||
|
||||
private const val PROTOCOL_VERSION = "2024-11-05"
|
||||
private const val SERVER_NAME = "memo"
|
||||
private const val SERVER_VERSION = "0.1.0"
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val br = System.`in`.bufferedReader()
|
||||
while (true) {
|
||||
val line = br.readLine() ?: break
|
||||
if (line.isBlank()) continue
|
||||
try {
|
||||
val resp = handleRequest(line)
|
||||
if (resp != null) {
|
||||
println(resp)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("mcp: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleRequest(line: String): String? {
|
||||
val parsed = try {
|
||||
JsonParser(line).parse()
|
||||
} catch (t: Throwable) {
|
||||
return rpcError(null, -32700, "Parse error")
|
||||
}
|
||||
val obj = parsed as? Map<String, Any?> ?: return rpcError(null, -32600, "Invalid Request")
|
||||
if (!obj.containsKey("id")) return null
|
||||
val id = obj["id"]
|
||||
val method = obj["method"] as? String ?: return rpcError(id, -32600, "Invalid Request")
|
||||
val params = obj["params"]
|
||||
return when (method) {
|
||||
"initialize" -> result(id, initializeResult())
|
||||
"tools/list" -> result(id, toolsListResult())
|
||||
"tools/call" -> {
|
||||
try {
|
||||
val r = handleToolCall(params)
|
||||
result(id, r)
|
||||
} catch (t: Throwable) {
|
||||
result(id, toolErrorContent(t.message ?: "error"))
|
||||
}
|
||||
}
|
||||
else -> rpcError(id, -32601, "Method not found")
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeResult(): String =
|
||||
"""{"protocolVersion":"$PROTOCOL_VERSION","capabilities":{"tools":{}},"serverInfo":{"name":"$SERVER_NAME","version":"$SERVER_VERSION"}}"""
|
||||
|
||||
private fun toolsListResult(): String =
|
||||
"""{"tools":[""" +
|
||||
"""{"name":"memo_search","description":"Гибридный поиск по индексу","inputSchema":{"type":"object","properties":{"path":{"type":"string"},"query":{"type":"string"},"k":{"type":"integer","default":8},"mode":{"type":"string","enum":["hybrid","lex","vec"],"default":"hybrid"}},"required":["path","query"]}},""" +
|
||||
"""{"name":"memo_status","description":"Статус индекса коллекции","inputSchema":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}},""" +
|
||||
"""{"name":"memo_reindex","description":"Полная переиндексация","inputSchema":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}""" +
|
||||
"""]}"""
|
||||
|
||||
private fun handleToolCall(params: Any?): String {
|
||||
val p = params as? Map<String, Any?> ?: throw IllegalArgumentException("params required")
|
||||
val name = p["name"] as? String ?: throw IllegalArgumentException("name required")
|
||||
val args = p["arguments"] as? Map<String, Any?> ?: emptyMap()
|
||||
return when (name) {
|
||||
"memo_search" -> {
|
||||
val path = args["path"] as? String
|
||||
if (path.isNullOrBlank()) {
|
||||
toolErrorContent("аргумент path обязателен")
|
||||
} else {
|
||||
val query = args["query"] as? String
|
||||
if (query.isNullOrBlank()) {
|
||||
toolErrorContent("аргумент query обязателен")
|
||||
} else {
|
||||
val k = (args["k"] as? Number)?.toInt() ?: 8
|
||||
val mode = when (args["mode"] as? String) {
|
||||
"lex" -> SearchMode.LEX
|
||||
"vec" -> SearchMode.VEC
|
||||
else -> SearchMode.HYBRID
|
||||
}
|
||||
toolOk(toolSearch(path, query, k, mode))
|
||||
}
|
||||
}
|
||||
}
|
||||
"memo_status" -> {
|
||||
val path = args["path"] as? String
|
||||
if (path.isNullOrBlank()) {
|
||||
toolErrorContent("аргумент path обязателен")
|
||||
} else {
|
||||
toolOk(toolStatus(path))
|
||||
}
|
||||
}
|
||||
"memo_reindex" -> {
|
||||
val path = args["path"] as? String
|
||||
if (path.isNullOrBlank()) {
|
||||
toolErrorContent("аргумент path обязателен")
|
||||
} else {
|
||||
toolOk(toolReindex(path))
|
||||
}
|
||||
}
|
||||
else -> toolErrorContent("unknown tool: $name")
|
||||
}
|
||||
}
|
||||
|
||||
fun toolSearch(path: String, query: String, k: Int, mode: SearchMode): String {
|
||||
val root = resolveCollection(File(path))
|
||||
val targets = selectTargets(root)
|
||||
if (targets.isEmpty()) return "коллекции не найдены"
|
||||
ensureModel()
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
val allHits = ArrayList<memo.core.Hit>()
|
||||
val errors = ArrayList<String>()
|
||||
val embedder = Embedder(modelPath, tokenizerPath)
|
||||
try {
|
||||
for (coll in targets) {
|
||||
val memoDir = File(coll, ".memo")
|
||||
val dbPath = File(memoDir, "index.db")
|
||||
memoDir.mkdirs()
|
||||
val existedBefore = dbPath.exists()
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
db.init()
|
||||
if (!existedBefore) {
|
||||
Indexer(db, embedder).indexTree(coll)
|
||||
}
|
||||
val refresh = RefreshHook { r ->
|
||||
val innerDbPath = File(r, ".memo/index.db")
|
||||
if (innerDbPath.exists()) {
|
||||
val innerDb = Db(innerDbPath.absolutePath)
|
||||
try {
|
||||
innerDb.init()
|
||||
Indexer(innerDb, embedder).indexTree(r)
|
||||
} finally {
|
||||
innerDb.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val hits = Searcher(db, embedder, refresh).search(coll, query, k, mode)
|
||||
allHits.addAll(hits)
|
||||
} catch (t: Throwable) {
|
||||
errors.add("ошибка в ${coll.name}: ${t.message ?: t.javaClass.simpleName}")
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
embedder.close()
|
||||
}
|
||||
val merged = allHits.sortedByDescending { it.score }.take(k)
|
||||
val sb = StringBuilder()
|
||||
for (h in merged) {
|
||||
sb.append("${h.path}:${h.line} ${h.heading}\n${h.text}\n")
|
||||
}
|
||||
for (e in errors) {
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
sb.append(e)
|
||||
}
|
||||
val out = sb.toString().trimEnd('\n')
|
||||
return if (out.isEmpty()) "ничего не найдено" else out
|
||||
}
|
||||
|
||||
fun toolStatus(path: String): String {
|
||||
val base = resolveCollection(File(path))
|
||||
val collections = selectTargets(base)
|
||||
if (collections.isEmpty()) return "коллекции не найдены"
|
||||
val sb = StringBuilder()
|
||||
for (coll in collections) {
|
||||
val dbPath = File(coll, ".memo/index.db")
|
||||
if (!dbPath.exists()) {
|
||||
sb.append("${coll.name}: нет индекса\n")
|
||||
continue
|
||||
}
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
val fileCount = countInt(db, "SELECT COUNT(*) FROM files")
|
||||
val chunkCount = countInt(db, "SELECT COUNT(*) FROM chunks")
|
||||
val indexedAt = maxDouble(db, "SELECT indexed_at FROM files WHERE indexed_at IS NOT NULL")
|
||||
val date = if (indexedAt != null) {
|
||||
java.time.Instant.ofEpochSecond(indexedAt.toLong()).toString()
|
||||
} else {
|
||||
"—"
|
||||
}
|
||||
sb.append("$coll: файлов $fileCount, чанков $chunkCount, индекс $date\n")
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
return sb.toString().trimEnd('\n')
|
||||
}
|
||||
|
||||
fun toolReindex(path: String): String {
|
||||
val root = resolveCollection(File(path))
|
||||
val collections = selectTargets(root)
|
||||
if (collections.isEmpty()) return "коллекции не найдены"
|
||||
ensureModel()
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
var totalUpdated = 0
|
||||
val embedder = Embedder(modelPath, tokenizerPath)
|
||||
try {
|
||||
for (coll in collections) {
|
||||
val memoDir = File(coll, ".memo")
|
||||
if (memoDir.exists()) {
|
||||
memoDir.deleteRecursively()
|
||||
}
|
||||
memoDir.mkdirs()
|
||||
val dbPath = File(memoDir, "index.db")
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
db.init()
|
||||
totalUpdated += Indexer(db, embedder).indexTree(coll)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
embedder.close()
|
||||
}
|
||||
return "переиндексировано файлов: $totalUpdated"
|
||||
}
|
||||
|
||||
private fun selectTargets(root: File): List<File> {
|
||||
if (!root.exists()) return emptyList()
|
||||
if (isCollection(root)) return listOf(root)
|
||||
return findCollections(root)
|
||||
}
|
||||
|
||||
private fun modelDir(): File =
|
||||
File(System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2")
|
||||
|
||||
private fun modelPaths(): Pair<String, String> = ModelStore.paths(modelDir())
|
||||
|
||||
private fun ensureModel() {
|
||||
val dir = modelDir()
|
||||
if (ModelStore.isComplete(dir)) return
|
||||
if (System.getenv("MEMO_MODEL_AUTO_DOWNLOAD") == "0") {
|
||||
val msg = "модель не найдена в ${dir.absolutePath}; запустите: memo model"
|
||||
System.err.println(msg)
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
System.err.println(
|
||||
"модель не найдена в ${dir.absolutePath}, скачиваю с ${ModelStore.DEFAULT_BASE_URL} (≈287 МБ, один раз)",
|
||||
)
|
||||
ModelStore.ensure(dir, log = { System.err.println(it) })
|
||||
}
|
||||
|
||||
private fun countInt(db: Db, sql: String): Int {
|
||||
val stmt = db.conn.prepare(sql)
|
||||
return try {
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
if (rs.next()) rs.getInt(0) ?: 0 else 0
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun maxDouble(db: Db, sql: String): Double? {
|
||||
val stmt = db.conn.prepare(sql)
|
||||
return try {
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
var best: Double? = null
|
||||
while (rs.next()) {
|
||||
val v = rs.getDouble(0) ?: continue
|
||||
if (best == null || v > best) best = v
|
||||
}
|
||||
best
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toolOk(text: String): String =
|
||||
"""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":false}"""
|
||||
|
||||
private fun toolErrorContent(text: String): String =
|
||||
"""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":true}"""
|
||||
|
||||
private fun result(id: Any?, body: String): String =
|
||||
"""{"jsonrpc":"2.0","id":${idLiteral(id)},"result":$body}"""
|
||||
|
||||
private fun rpcError(id: Any?, code: Int, message: String): String =
|
||||
"""{"jsonrpc":"2.0","id":${idLiteral(id)},"error":{"code":$code,"message":${jsonStr(message)}}}"""
|
||||
|
||||
private fun idLiteral(id: Any?): String = when (id) {
|
||||
null -> "null"
|
||||
is Number -> if (id.toDouble().rem(1.0) == 0.0) id.toLong().toString() else id.toString()
|
||||
is Boolean -> id.toString()
|
||||
else -> jsonStr(id.toString())
|
||||
}
|
||||
|
||||
private fun jsonStr(s: String): String {
|
||||
val sb = StringBuilder(s.length + 2)
|
||||
sb.append('"')
|
||||
for (c in s) {
|
||||
when (c) {
|
||||
'"' -> sb.append("\\\"")
|
||||
'\\' -> sb.append("\\\\")
|
||||
'\n' -> sb.append("\\n")
|
||||
'\r' -> sb.append("\\r")
|
||||
'\t' -> sb.append("\\t")
|
||||
'\b' -> sb.append("\\b")
|
||||
'\u000C' -> sb.append("\\f")
|
||||
else -> if (c.code < 0x20) {
|
||||
sb.append("\\u%04x".format(c.code))
|
||||
} else {
|
||||
sb.append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private class JsonParser(private val s: String) {
|
||||
private var pos = 0
|
||||
|
||||
fun parse(): Any? {
|
||||
skipWs()
|
||||
val v = parseValue()
|
||||
skipWs()
|
||||
if (pos != s.length) throw IllegalArgumentException("trailing data at $pos")
|
||||
return v
|
||||
}
|
||||
|
||||
private fun parseValue(): Any? {
|
||||
skipWs()
|
||||
if (pos >= s.length) throw IllegalArgumentException("unexpected end")
|
||||
return when (val c = s[pos]) {
|
||||
'{' -> parseObject()
|
||||
'[' -> parseArray()
|
||||
'"' -> parseString()
|
||||
't' -> parseLiteral("true", true)
|
||||
'f' -> parseLiteral("false", false)
|
||||
'n' -> parseLiteral("null", null)
|
||||
'-', in '0'..'9' -> parseNumber()
|
||||
else -> throw IllegalArgumentException("unexpected char '$c' at $pos")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseObject(): Map<String, Any?> {
|
||||
expect('{')
|
||||
val map = LinkedHashMap<String, Any?>()
|
||||
skipWs()
|
||||
if (peek() == '}') { pos++; return map }
|
||||
while (true) {
|
||||
skipWs()
|
||||
val key = parseString()
|
||||
skipWs()
|
||||
expect(':')
|
||||
map[key] = parseValue()
|
||||
skipWs()
|
||||
when (peek()) {
|
||||
',' -> { pos++; continue }
|
||||
'}' -> { pos++; return map }
|
||||
else -> throw IllegalArgumentException("expected ',' or '}' at $pos")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseArray(): List<Any?> {
|
||||
expect('[')
|
||||
val list = ArrayList<Any?>()
|
||||
skipWs()
|
||||
if (peek() == ']') { pos++; return list }
|
||||
while (true) {
|
||||
list.add(parseValue())
|
||||
skipWs()
|
||||
when (peek()) {
|
||||
',' -> { pos++; continue }
|
||||
']' -> { pos++; return list }
|
||||
else -> throw IllegalArgumentException("expected ',' or ']' at $pos")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseString(): String {
|
||||
expect('"')
|
||||
val sb = StringBuilder()
|
||||
while (pos < s.length) {
|
||||
val c = s[pos]
|
||||
if (c == '"') {
|
||||
pos++
|
||||
return sb.toString()
|
||||
}
|
||||
if (c == '\\') {
|
||||
if (pos + 1 >= s.length) throw IllegalArgumentException("bad escape at end")
|
||||
when (s[pos + 1]) {
|
||||
'"' -> sb.append('"')
|
||||
'\\' -> sb.append('\\')
|
||||
'/' -> sb.append('/')
|
||||
'n' -> sb.append('\n')
|
||||
'r' -> sb.append('\r')
|
||||
't' -> sb.append('\t')
|
||||
'b' -> sb.append('\b')
|
||||
'f' -> sb.append('\u000C')
|
||||
'u' -> {
|
||||
if (pos + 6 > s.length) throw IllegalArgumentException("bad unicode escape")
|
||||
val hex = s.substring(pos + 2, pos + 6)
|
||||
sb.append(hex.toInt(16).toChar())
|
||||
pos += 4
|
||||
}
|
||||
else -> throw IllegalArgumentException("bad escape '\\${s[pos + 1]}'")
|
||||
}
|
||||
pos += 2
|
||||
} else {
|
||||
sb.append(c)
|
||||
pos++
|
||||
}
|
||||
}
|
||||
throw IllegalArgumentException("unterminated string")
|
||||
}
|
||||
|
||||
private fun parseNumber(): Any {
|
||||
val start = pos
|
||||
if (s[pos] == '-') pos++
|
||||
var hasDigit = false
|
||||
while (pos < s.length && s[pos].isDigit()) { hasDigit = true; pos++ }
|
||||
var isFloat = false
|
||||
if (pos < s.length && s[pos] == '.') {
|
||||
isFloat = true; pos++
|
||||
while (pos < s.length && s[pos].isDigit()) { hasDigit = true; pos++ }
|
||||
}
|
||||
if (pos < s.length && (s[pos] == 'e' || s[pos] == 'E')) {
|
||||
isFloat = true; pos++
|
||||
if (pos < s.length && (s[pos] == '+' || s[pos] == '-')) pos++
|
||||
while (pos < s.length && s[pos].isDigit()) { hasDigit = true; pos++ }
|
||||
}
|
||||
if (!hasDigit) throw IllegalArgumentException("bad number at $start")
|
||||
val raw = s.substring(start, pos)
|
||||
return if (isFloat) raw.toDouble() else raw.toLong()
|
||||
}
|
||||
|
||||
private fun parseLiteral(lit: String, value: Any?): Any? {
|
||||
if (s.regionMatches(pos, lit, 0, lit.length)) {
|
||||
pos += lit.length
|
||||
return value
|
||||
}
|
||||
throw IllegalArgumentException("expected literal '$lit' at $pos")
|
||||
}
|
||||
|
||||
private fun peek(): Char = if (pos < s.length) s[pos] else '\u0000'
|
||||
|
||||
private fun expect(c: Char) {
|
||||
if (peek() != c) throw IllegalArgumentException("expected '$c' at $pos")
|
||||
pos++
|
||||
}
|
||||
|
||||
private fun skipWs() {
|
||||
while (pos < s.length && s[pos].isWhitespace()) pos++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package memo.mcp
|
||||
|
||||
import memo.core.SearchMode
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
class McpColdStartTest {
|
||||
|
||||
private fun modelPaths(): Pair<String, String> {
|
||||
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||
return "$modelDir/text_model_int8.onnx" to "$modelDir/tokenizer.model"
|
||||
}
|
||||
|
||||
private fun requireModel() {
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
if (!File(modelPath).exists() || !File(tokenizerPath).exists()) {
|
||||
fail("модель не найдена: ${File(modelPath).parent}")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchIndexesCollectionOnColdStart() {
|
||||
requireModel()
|
||||
|
||||
val base = File.createTempFile("memo-coldstart-", "")
|
||||
assertTrue(base.delete(), "temp cleanup")
|
||||
assertTrue(base.mkdir(), "temp create")
|
||||
base.deleteOnExit()
|
||||
|
||||
File(base, "proxy.md").writeText(
|
||||
"# Прокси\nвнутренние домены ходят через шлюз 76.1\n"
|
||||
)
|
||||
File(base, "other.md").writeText(
|
||||
"# Прочее\nсовсем другая заметка про книгу\n"
|
||||
)
|
||||
File(base, "proxy.md").deleteOnExit()
|
||||
File(base, "other.md").deleteOnExit()
|
||||
|
||||
val memoDir = File(base, ".memo")
|
||||
assertFalse(memoDir.exists(), "до поиска .memo не должен существовать")
|
||||
|
||||
val resp = toolSearch(base.absolutePath, "внутренние домены шлюз", 5, SearchMode.HYBRID)
|
||||
assertFalse(
|
||||
resp.contains("коллекции не найдены"),
|
||||
"холодный старт обязан сам индексировать, ответ: $resp"
|
||||
)
|
||||
assertTrue(
|
||||
resp.contains("76.1"),
|
||||
"ожидалось упоминание 76.1 в ответе, получено: $resp"
|
||||
)
|
||||
|
||||
val dbFile = File(base, ".memo/index.db")
|
||||
assertTrue(dbFile.exists(), "после поиска .memo/index.db обязан существовать")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchOnEmptyDirReportsNoCollections() {
|
||||
val base = File.createTempFile("memo-empty-", "")
|
||||
assertTrue(base.delete(), "temp cleanup")
|
||||
assertTrue(base.mkdir(), "temp create")
|
||||
base.deleteOnExit()
|
||||
|
||||
val resp = toolSearch(base.absolutePath, "что угодно", 5, SearchMode.HYBRID)
|
||||
assertEquals(
|
||||
"коллекции не найдены",
|
||||
resp,
|
||||
"пустой каталог без .md обязан вернуть 'коллекции не найдены'"
|
||||
)
|
||||
assertFalse(
|
||||
File(base, ".memo").exists(),
|
||||
"в пустом каталоге .memo не должен создаваться"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package memo.mcp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class McpProtocolTest {
|
||||
|
||||
@Test
|
||||
fun initializeHandshake() {
|
||||
val resp = handleRequest(
|
||||
"""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"""
|
||||
)
|
||||
assertNotNull(resp, "initialize обязан вернуть ответ")
|
||||
assertTrue(resp.contains("2024-11-05"), "ожидался protocolVersion 2024-11-05, получено: $resp")
|
||||
assertTrue(resp.contains("memo"), "ожидалось имя сервера memo, получено: $resp")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolsListHasThreeTools() {
|
||||
val resp = handleRequest(
|
||||
"""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"""
|
||||
)
|
||||
assertNotNull(resp, "tools/list обязан вернуть ответ")
|
||||
assertTrue(resp.contains("memo_search"), "ожидался memo_search, получено: $resp")
|
||||
assertTrue(resp.contains("memo_status"), "ожидался memo_status, получено: $resp")
|
||||
assertTrue(resp.contains("memo_reindex"), "ожидался memo_reindex, получено: $resp")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownMethodReturns32601() {
|
||||
val resp = handleRequest(
|
||||
"""{"jsonrpc":"2.0","id":3,"method":"foo/bar"}"""
|
||||
)
|
||||
assertNotNull(resp, "неизвестный метод обязан вернуть JSON-RPC error")
|
||||
assertTrue(resp.contains("-32601"), "ожидался код -32601, получено: $resp")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchWithoutPathIsError() {
|
||||
val resp = handleRequest(
|
||||
"""{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"memo_search","arguments":{}}}"""
|
||||
)
|
||||
assertNotNull(resp, "tools/call обязан вернуть ответ")
|
||||
assertTrue(resp.contains("\"isError\":true"), "ожидался isError:true, получено: $resp")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationProducesNoResponse() {
|
||||
val resp = handleRequest(
|
||||
"""{"jsonrpc":"2.0","method":"notifications/initialized"}"""
|
||||
)
|
||||
assertNull(resp, "уведомление не должно порождать ответ, получено: $resp")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
application
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("memo.watch.WatchMainKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":memo-core"))
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
if (args.isEmpty()) {
|
||||
System.err.println("usage: watch <path>")
|
||||
exitProcess(2)
|
||||
}
|
||||
val raw = File(args[0])
|
||||
val base = resolveCollection(raw)
|
||||
val collections = findCollections(base)
|
||||
if (collections.isEmpty()) {
|
||||
System.err.println("коллекции не найдены в ${base.absolutePath}")
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||
val modelPath = "$modelDir/text_model_int8.onnx"
|
||||
val tokenizerPath = "$modelDir/tokenizer.model"
|
||||
|
||||
val ctxList = collections.map { 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)
|
||||
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 -> safeIndexTree(indexersByCollection.getValue(coll).indexer, coll) },
|
||||
)
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(
|
||||
Thread({
|
||||
try { watcher.close() } catch (_: Throwable) {}
|
||||
for (ctx in ctxList) {
|
||||
try { ctx.embedder.close() } catch (_: Throwable) {}
|
||||
try { ctx.db.close() } catch (_: Throwable) {}
|
||||
}
|
||||
}, "memo-watch-shutdown")
|
||||
)
|
||||
|
||||
watcher.start()
|
||||
for (ctx in ctxList) {
|
||||
println("наблюдаю: ${ctx.root.absolutePath}")
|
||||
}
|
||||
|
||||
CountDownLatch(1).await()
|
||||
}
|
||||
|
||||
private data class CollCtx(
|
||||
val root: File,
|
||||
val db: Db,
|
||||
val embedder: Embedder,
|
||||
val indexer: Indexer,
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package memo.watch
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.ClosedWatchServiceException
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardWatchEventKinds
|
||||
import java.nio.file.WatchKey
|
||||
import java.nio.file.WatchService
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class Watcher(
|
||||
private val collections: List<File>,
|
||||
private val index: (File) -> Unit,
|
||||
private val debounceMillis: Long = 500,
|
||||
private val reconcileMillis: Long = 10 * 60 * 1000,
|
||||
) : AutoCloseable {
|
||||
|
||||
private val watchService: WatchService = FileSystems.getDefault().newWatchService()
|
||||
private val keyToCollection = ConcurrentHashMap<WatchKey, File>()
|
||||
private val running = AtomicBoolean(false)
|
||||
private val closed = AtomicBoolean(false)
|
||||
private val _indexCalls = AtomicInteger(0)
|
||||
|
||||
val indexCalls: Int get() = _indexCalls.get()
|
||||
|
||||
private val thread: Thread = Thread({ runLoop() }, "memo-watcher").apply { isDaemon = true }
|
||||
|
||||
fun start() {
|
||||
if (!running.compareAndSet(false, true)) return
|
||||
for (coll in collections) {
|
||||
try {
|
||||
registerRecursive(coll, coll)
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("watcher: register ${coll.absolutePath} failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
thread.start()
|
||||
}
|
||||
|
||||
private fun registerRecursive(dir: File, collection: File) {
|
||||
if (!dir.isDirectory) return
|
||||
if (dir.name.startsWith(".")) return
|
||||
val key = dir.toPath().register(
|
||||
watchService,
|
||||
StandardWatchEventKinds.ENTRY_CREATE,
|
||||
StandardWatchEventKinds.ENTRY_MODIFY,
|
||||
StandardWatchEventKinds.ENTRY_DELETE,
|
||||
)
|
||||
keyToCollection[key] = collection
|
||||
val children = dir.listFiles() ?: return
|
||||
for (child in children) {
|
||||
if (child.isDirectory && !child.name.startsWith(".")) {
|
||||
registerRecursive(child, collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!closed.compareAndSet(false, true)) return
|
||||
running.set(false)
|
||||
thread.interrupt()
|
||||
try { watchService.close() } catch (_: Throwable) {}
|
||||
try { thread.join(2000) } catch (_: InterruptedException) {}
|
||||
}
|
||||
|
||||
private fun runLoop() {
|
||||
var nextReconcile = System.currentTimeMillis() + reconcileMillis
|
||||
while (running.get()) {
|
||||
try {
|
||||
val firstKey = watchService.poll(debounceMillis, TimeUnit.MILLISECONDS)
|
||||
if (firstKey != null) {
|
||||
val affected = HashSet<File>()
|
||||
processKey(firstKey, affected)
|
||||
val endDeadline = System.currentTimeMillis() + debounceMillis
|
||||
while (running.get()) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now >= endDeadline) break
|
||||
val remaining = endDeadline - now
|
||||
val nextKey = watchService.poll(remaining, TimeUnit.MILLISECONDS) ?: break
|
||||
processKey(nextKey, affected)
|
||||
}
|
||||
for (coll in affected) {
|
||||
callIndex(coll)
|
||||
}
|
||||
}
|
||||
if (System.currentTimeMillis() >= nextReconcile) {
|
||||
nextReconcile = System.currentTimeMillis() + reconcileMillis
|
||||
for (coll in collections) {
|
||||
callIndex(coll)
|
||||
}
|
||||
}
|
||||
} catch (_: ClosedWatchServiceException) {
|
||||
break
|
||||
} catch (_: InterruptedException) {
|
||||
if (!running.get()) break
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("watcher loop: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processKey(key: WatchKey, affected: MutableSet<File>) {
|
||||
val collection = keyToCollection[key] ?: return
|
||||
for (event in key.pollEvents()) {
|
||||
val kind = event.kind()
|
||||
if (kind === StandardWatchEventKinds.OVERFLOW) continue
|
||||
val ctx = event.context() as? Path ?: continue
|
||||
val watchable = key.watchable() as? Path ?: continue
|
||||
val ev = watchable.resolve(ctx).toFile()
|
||||
when (kind) {
|
||||
StandardWatchEventKinds.ENTRY_CREATE -> {
|
||||
affected.add(collection)
|
||||
if (ev.isDirectory && !ev.name.startsWith(".")) {
|
||||
try {
|
||||
registerRecursive(ev, collection)
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("watcher: registerRecursive ${ev.absolutePath} failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
StandardWatchEventKinds.ENTRY_MODIFY,
|
||||
StandardWatchEventKinds.ENTRY_DELETE -> affected.add(collection)
|
||||
}
|
||||
}
|
||||
val valid = key.reset()
|
||||
if (!valid) keyToCollection.remove(key)
|
||||
}
|
||||
|
||||
private fun callIndex(coll: File) {
|
||||
_indexCalls.incrementAndGet()
|
||||
try {
|
||||
index(coll)
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("watcher: index ${coll.absolutePath} failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package memo.watch
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class WatcherTest {
|
||||
|
||||
@Test
|
||||
fun modifyTriggersSingleIndexCall() {
|
||||
val tempDir = Files.createTempDirectory("memo-watch-").toFile()
|
||||
tempDir.deleteOnExit()
|
||||
val note = File(tempDir, "note.md")
|
||||
note.writeText("# initial")
|
||||
note.deleteOnExit()
|
||||
val counter = AtomicInteger(0)
|
||||
val watcher = Watcher(
|
||||
collections = listOf(tempDir),
|
||||
index = { counter.incrementAndGet() },
|
||||
debounceMillis = 200,
|
||||
reconcileMillis = TimeUnit.HOURS.toMillis(1),
|
||||
)
|
||||
try {
|
||||
watcher.start()
|
||||
Thread.sleep(300)
|
||||
note.appendText("\nappended")
|
||||
assertTrue(
|
||||
waitFor(5_000, 100) { counter.get() >= 1 },
|
||||
"index должен сработать в течение 5с, получено ${counter.get()}",
|
||||
)
|
||||
Thread.sleep(1500)
|
||||
assertEquals(1, counter.get(), "ожидался ровно 1 вызов index, получено ${counter.get()}")
|
||||
assertTrue(watcher.indexCalls >= 1, "встроенный счётчик indexCalls не растёт, получено ${watcher.indexCalls}")
|
||||
} finally {
|
||||
watcher.close()
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unchangedFileDoesNotTriggerIndex() {
|
||||
val tempDir = Files.createTempDirectory("memo-watch-").toFile()
|
||||
tempDir.deleteOnExit()
|
||||
val note = File(tempDir, "note.md")
|
||||
note.writeText("# initial")
|
||||
note.deleteOnExit()
|
||||
val counter = AtomicInteger(0)
|
||||
val watcher = Watcher(
|
||||
collections = listOf(tempDir),
|
||||
index = { counter.incrementAndGet() },
|
||||
debounceMillis = 200,
|
||||
reconcileMillis = TimeUnit.HOURS.toMillis(1),
|
||||
)
|
||||
try {
|
||||
watcher.start()
|
||||
Thread.sleep(300)
|
||||
Thread.sleep(1500)
|
||||
assertEquals(0, counter.get(), "не должно быть вызовов index, получено ${counter.get()}")
|
||||
} finally {
|
||||
watcher.close()
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeIsIdempotentAndStopsThread() {
|
||||
val tempDir = Files.createTempDirectory("memo-watch-").toFile()
|
||||
tempDir.deleteOnExit()
|
||||
val watcher = Watcher(
|
||||
collections = listOf(tempDir),
|
||||
index = {},
|
||||
)
|
||||
watcher.start()
|
||||
watcher.close()
|
||||
watcher.close()
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun waitFor(timeoutMs: Long, stepMs: Long, condition: () -> Boolean): Boolean {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (condition()) return true
|
||||
Thread.sleep(stepMs)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
}
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
# Единый приёмочный прогон memo. Запускает ВСЁ, что должен пройти проект, и печатает
|
||||
# один вердикт. Возвращает 0, если все контуры зелёные.
|
||||
#
|
||||
# bash scripts/accept.sh # полный прогон
|
||||
# bash scripts/accept.sh --fast # без мутационной проверки (она самая долгая)
|
||||
#
|
||||
# Контуры:
|
||||
# 1. Юнит-тесты всех модулей (gradle test --rerun-tasks).
|
||||
# 2. Мутационная проверка тестов (e2e/mutation_check.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)"
|
||||
cd "$ROOT"
|
||||
|
||||
CORPUS="${MEMO_CORPUS:-/root/WORK/memo-e2e}"
|
||||
export MEMO_MODEL_DIR="${MEMO_MODEL_DIR:-$ROOT/models/siglip2}"
|
||||
FAST=0
|
||||
[ "${1:-}" = "--fast" ] && FAST=1
|
||||
|
||||
FAILED=()
|
||||
|
||||
section() { printf '\n=== %s ===\n' "$1"; }
|
||||
|
||||
section "0. сборка"
|
||||
if ./gradlew installDist -q >/tmp/memo-build.log 2>&1; then
|
||||
echo "OK сборка и установка дистрибутивов"
|
||||
else
|
||||
echo "ПРОВАЛ сборки:"; tail -30 /tmp/memo-build.log; FAILED+=("сборка"); fi
|
||||
|
||||
CLI="$ROOT/memo-cli/build/install/memo/bin/memo"
|
||||
MCP="$ROOT/memo-mcp/build/install/memo-mcp/bin/memo-mcp"
|
||||
|
||||
section "1. юнит-тесты"
|
||||
if ./gradlew test --rerun-tasks >/tmp/memo-test.log 2>&1; then
|
||||
python3 - <<'EOF'
|
||||
import glob, xml.etree.ElementTree as ET
|
||||
tot = fail = 0
|
||||
for f in glob.glob('**/build/test-results/test/*.xml', recursive=True):
|
||||
r = ET.parse(f).getroot()
|
||||
tot += int(r.get('tests') or 0); fail += int(r.get('failures') or 0) + int(r.get('errors') or 0)
|
||||
print(f"OK тестов {tot}, провалов {fail}")
|
||||
EOF
|
||||
else
|
||||
echo "ПРОВАЛ тестов:"; grep -E "FAILED|error:" /tmp/memo-test.log | head -20; FAILED+=("тесты"); fi
|
||||
|
||||
if [ "$FAST" = "0" ]; then
|
||||
section "2. мутационная проверка"
|
||||
python3 e2e/mutation_check.py --gradle ./gradlew > /tmp/memo-mut.log 2>&1
|
||||
MUT_RC=$?
|
||||
if [ "$MUT_RC" = "0" ]; then
|
||||
echo "OK все мутации убиты"
|
||||
elif [ "$MUT_RC" = "2" ]; then
|
||||
echo "ПРОВАЛ: таблица мутаций недействительна (no-op или неприменимая мутация)"
|
||||
grep -E "ОШИБКА ТАБЛИЦЫ|ПРОВАЛ" /tmp/memo-mut.log | head -10; FAILED+=("таблица мутаций")
|
||||
else
|
||||
echo "ПРОВАЛ: есть выжившие мутации"; tail -20 /tmp/memo-mut.log; FAILED+=("мутации"); fi
|
||||
fi
|
||||
|
||||
section "3. сквозной индекс эталонного корпуса"
|
||||
# .memo лежит не только на верхнем уровне (life/books, work/jira), поэтому ищем по дереву,
|
||||
# иначе часть баз переживает очистку и прогон перестаёт быть чистым.
|
||||
find "$CORPUS" -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
|
||||
FIRST=$("$CLI" index "$CORPUS" 2>&1 | tail -1)
|
||||
SECOND=$("$CLI" index "$CORPUS" 2>&1 | tail -1)
|
||||
DBS=$(find "$CORPUS" -name index.db | wc -l)
|
||||
echo " первый прогон : $FIRST"
|
||||
echo " второй прогон : $SECOND"
|
||||
echo " баз индекса : $DBS (ожидается 3)"
|
||||
if echo "$SECOND" | grep -q "итого: 0 обновлено" && [ "$DBS" = "3" ]; then
|
||||
echo "OK индексация идемпотентна, коллекций 3"
|
||||
else
|
||||
echo "ПРОВАЛ индексации (ожидалось «итого: 0 обновлено» и 3 базы)"; FAILED+=("индексация"); fi
|
||||
|
||||
section "4. recall@5"
|
||||
if python3 e2e/run_e2e.py --cli "$CLI" --root "$CORPUS" --k 5 > /tmp/memo-e2e.log 2>&1 \
|
||||
&& grep -q "ИТОГ: ПРОЙДЕНО" /tmp/memo-e2e.log; then
|
||||
grep -E "^(sem|lex):" /tmp/memo-e2e.log; echo "OK recall"
|
||||
else
|
||||
echo "ПРОВАЛ recall:"; tail -25 /tmp/memo-e2e.log; FAILED+=("recall"); fi
|
||||
|
||||
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
|
||||
if python3 scripts/mcp_probe.py --cmd "$MCP" \
|
||||
--call memo_search \
|
||||
--args "{\"path\":\"$COLD/infra\",\"query\":\"основа для разрешения имён в сети\",\"k\":3}" \
|
||||
> /tmp/memo-mcp.log 2>&1; then
|
||||
if grep -q "коллекции не найдены" /tmp/memo-mcp.log; then
|
||||
echo "ПРОВАЛ: холодный старт не индексирует"; FAILED+=("MCP холодный старт")
|
||||
else
|
||||
echo "OK MCP: протокол отвечает, холодный старт индексирует"
|
||||
grep -E "^OK" /tmp/memo-mcp.log | sed 's/^/ /'
|
||||
fi
|
||||
else
|
||||
echo "ПРОВАЛ MCP:"; tail -20 /tmp/memo-mcp.log; FAILED+=("MCP протокол"); fi
|
||||
|
||||
section "ВЕРДИКТ"
|
||||
if [ ${#FAILED[@]} -eq 0 ]; then
|
||||
echo "ПРОЙДЕНО — все контуры зелёные"
|
||||
exit 0
|
||||
else
|
||||
printf 'ПРОВАЛ — контуры: %s\n' "$(IFS=', '; echo "${FAILED[*]}")"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user