Compare commits

...

14 Commits

Author SHA1 Message Date
subochev 607137fdf7 model: команда memo model и автоскачивание модели (докачка, сверка размера, отключатель); manual: раздел про модель 2026-10-02 04:53:04 +03:00
subochev 69a0ebe0fa tests: ModelStoreTest — закрыты дыры покрытия Range и Content-Range
resumesPartialDownload: счётчики Range-запросов и байт; сервер отдаёт 400
без Range для .part — запасной путь «начать заново» закрыт.

failsOnSizeMismatch разделена на два:
  - rejectsWrongTotalFromContentRange — сервер соврал в Content-Range,
    бьёт в проверку actualTotal != expectedTotal (вторая, достижимая);
  - truncatedResponseDoesNotProduceFile — оборванный ответ, по факту ловит
    ветку IOException → «не удалось скачать» (а не first size check).

В mutations.tsv убрана строка про if (declaredBody >= 0 && written !=
declaredBody) — через java.net/http недостижимо (оборванный ответ даёт
IOException, не EOF). Добавлена строка про if (expectedTotal >= 0 &&
actualTotal != expectedTotal) — теперь убивает rejectsWrongTotalFromContentRange.

Все 18 мутаций (4 из ModelStore) убиты прогоном mutation_check.py.
2026-10-02 04:48:35 +03:00
subochev 3054705176 manual: инструкция «как пользоваться»; watch: первичный проход, коллекции по правилу ядра; приёмка: контур демона 2026-10-02 03:43:25 +03:00
subochev 2c7bca0d82 tests: закрыты три дыры в покрытии, найденные мутационным анализом 2026-10-02 03:06:40 +03:00
subochev dc8d9bc598 mcp: поиск индексирует коллекцию на холодном старте 2026-10-02 02:35:42 +03:00
subochev 2ed736a9bc core: коллекция — каталог с markdown напрямую; исправлен тройной обход 2026-10-02 02:29:49 +03:00
subochev f2956e3cef mcp: сервер memo_search/memo_status/memo_reindex 2026-10-02 01:44:45 +03:00
subochev f1d497d336 watch: слежение за файлами (WatchService + debounce + реконсиляция) 2026-10-02 01:35:20 +03:00
subochev 8b78309c56 cli: index/search/status/mcp-probe 2026-10-02 01:29:56 +03:00
subochev a2f09fffb6 core: гибридный поиск (BM25 + вектор + RRF) 2026-10-02 01:25:23 +03:00
subochev c33f94fa13 core: индексатор файлов с пропуском неизменённых 2026-10-02 01:17:28 +03:00
subochev de3cb24edc core: чанкер markdown по заголовкам с перекрытием 2026-10-02 01:14:00 +03:00
subochev d62a26f5b1 core: fts5-тест проверяет регистронезависимость кириллицы 2026-10-02 01:09:40 +03:00
subochev 700508dc8e core: каркас сборки, embedder, схема БД, 4 теста 2026-10-02 01:06:06 +03:00
50 changed files with 5394 additions and 10 deletions
+366
View File
@@ -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`, — расходник.**
+70 -5
View File
@@ -2,6 +2,10 @@
Локальная библиотека заметок с семантическим поиском. Локальная библиотека заметок с семантическим поиском.
> **Как этим пользоваться — [`MANUAL.md`](MANUAL.md).** Пошаговая инструкция для человека:
> установка, команды, подключение к агенту, что мониторит демон, что можно удалять.
> Остальной README — для разработки.
**Markdown на диске — истина, `.db` рядом — пересобираемый кэш.** Агент (или человек) пишет и **Markdown на диске — истина, `.db` рядом — пересобираемый кэш.** Агент (или человек) пишет и
читает обычные `.md`-файлы своими штатными средствами: `memo` не требует ни специального API записи, читает обычные `.md`-файлы своими штатными средствами: `memo` не требует ни специального API записи,
ни изменения привычек. Он следит за файловой системой, держит рядом с каждой папкой-темой ни изменения привычек. Он следит за файловой системой, держит рядом с каждой папкой-темой
@@ -10,17 +14,21 @@
## Статус ## Статус
Проектирование завершено. Код не написан. Реализовано и принято на эталонном корпусе. 38 тестов, мутационная проверка, сквозной recall@5 = 20/20.
| Документ | Что внутри | | Что | Вердикт приёмки |
|---|---| |---|---|
| [`docs/SPEC.md`](docs/SPEC.md) | полная спека: модель данных, индекс, маршрутизация, watcher, интерфейс, приёмка | | юнит-тесты всех модулей | ✅ |
| [`TASK.md`](TASK.md) | ТЗ для исполнителя (opencode): стек, схема БД, контракты, чего не делать | | мутационная проверка (13 мутаций) | ✅ все убиты |
| [`TESTING.md`](TESTING.md) | тест-план: 6 приёмочных проверок, команды, признаки провала | | индексация: 3 коллекции, повторный прогон — 0 обновлений | ✅ |
| recall@5 на эталонном корпусе (sem 20/20, lex 3/3) | ✅ |
| MCP-протокол живым клиентом + холодный старт | ✅ |
## Идея ## Идея
- Коллекций (папок-тем) — сколько угодно; они не мешают друг другу. - Коллекций (папок-тем) — сколько угодно; они не мешают друг другу.
- **Коллекция — каталог, в котором есть `*.md` напрямую** (не в подкаталогах). Вложенные папки
без своих заметок коллекциями не считаются — иначе одни и те же файлы индексируются по нескольку раз.
- Индекс каждой коллекции живёт в её `.memo/index.db` и **удаляется без потерь** — пересобирается. - Индекс каждой коллекции живёт в её `.memo/index.db` и **удаляется без потерь** — пересобирается.
- Поиск — обычный read-only инструмент; записи в markdown он не делает никогда. - Поиск — обычный read-only инструмент; записи в markdown он не делает никогда.
- Только локально: эмбеддинг на CPU, сеть не нужна ни при индексации, ни при поиске. - Только локально: эмбеддинг на CPU, сеть не нужна ни при индексации, ни при поиске.
@@ -36,6 +44,63 @@ memo-mcp # MCP-сервер (stdio): memo_search / memo_status / memo_reinde
`memo-core` не знает ни про watcher, ни про MCP — это драйверы поверх ядра. `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) ## Зависимости (проверено 02.10.2026)
| Что | Координата | Откуда | | Что | Координата | Откуда |
+7 -5
View File
@@ -23,16 +23,18 @@ export MEMO_ROOT=/root/WORK/memo-e2e
## T1. Индексация и её повтор ## T1. Индексация и её повтор
```bash ```bash
cd /root/WORK/memo && ./gradlew :memo-cli:installDist -q cd /root/WORK/memo && ./gradlew installDist -q
CLI=./memo-cli/build/install/memo-cli/bin/memo CLI=./memo-cli/build/install/memo/bin/memo
$CLI index "$MEMO_ROOT"; $CLI status "$MEMO_ROOT" $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" # второй прогон $CLI index "$MEMO_ROOT" # второй прогон
``` ```
Ожидается: в каждой папке появился `.memo/index.db`; второй прогон **не переэмбеддивает** ни одного файла Ожидается: в каждой коллекции появился `.memo/index.db` (**ровно три** — вложенные папки без своих
заметок коллекциями не считаются); второй прогон **не переэмбеддивает** ни одного файла
(в выводе 0 обновлённых, `status` показывает те же `indexed_at`). Провал: нет `.memo/index.db`; (в выводе 0 обновлённых, `status` показывает те же `indexed_at`). Провал: нет `.memo/index.db`;
второй прогон молча переиндексирует всё. баз больше трёх (значит, коллекцией считается каждая папка с .md внутри); второй прогон молча
переиндексирует всё.
## T2. Смысловой поиск (вектор работает) ## T2. Смысловой поиск (вектор работает)
+20
View File
@@ -0,0 +1,20 @@
plugins {
kotlin("jvm") version "2.4.10" apply false
}
ext {
set("kotlinVersion", "2.4.10")
set("onnxVersion", "1.16.0")
}
allprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
tasks.withType<JavaCompile>().configureEach {
sourceCompatibility = "17"
targetCompatibility = "17"
}
}
+56
View File
@@ -0,0 +1,56 @@
Проект: Kotlin/JVM, Gradle 8.14 (wrapper уже в репо), JDK 21. Рабочая копия: /root/WORK/memo.
Задача — ТОЛЬКО каркас сборки и четыре теста. НЕ пиши чанкер, поиск, watcher, MCP, CLI-логику.
1. settings.gradle.kts: rootProject.name = "memo"; include(":memo-core", ":memo-cli", ":memo-watch", ":memo-mcp").
В dependencyResolutionManagement { repositories { mavenCentral(); maven(url = "http://nexus.xx/repository/caffeine/") { isAllowInsecureProtocol = true } } }.
2. build.gradle.kts (корень): plugins { kotlin("jvm") version "2.4.10" apply false };
ext: kotlinVersion "2.4.10", onnxVersion "1.16.0"; во всех модулях jvmTarget 17.
3. memo-core/build.gradle.kts: plugins { kotlin("jvm") }; зависимости:
implementation("pw.binom.db:ksqlite:0.1.4")
implementation("pw.binom.ai.embeddingtext:api:5")
runtimeOnly("pw.binom.ai.embeddingtext:siglip-jvm:5")
testImplementation(kotlin("test"))
Тестовый фреймворк не менять (дефолтный JUnit4), useJUnitPlatform() НЕ добавлять.
4. Модули memo-cli, memo-watch, memo-mcp — только build.gradle.kts с plugins { kotlin("jvm") } и пустой src. Логику не писать.
5. memo-core/src/main/kotlin/memo/core/Embedder.kt:
class Embedder(private val modelPath: String, private val tokenizerPath: String) : AutoCloseable
- лениво, при первом embed, создаёт pw.binom.voice.embeddingtext.createSiglip2TextExtractor(modelPath, tokenizerPath)
- fun embed(text: String): FloatArray — возвращает ex.embed(text).values; если размерность != 768, бросить IllegalStateException с фактической размерностью
- override fun close() — идемпотентно, закрывает экстрактор
Импорты: pw.binom.voice.embeddingtext.createSiglip2TextExtractor, pw.binom.voice.embeddingtext.TextEmbeddingExtractor
6. memo-core/src/main/kotlin/memo/core/Db.kt:
class Db(val path: String) : AutoCloseable
- при создании открывает SQLiteConnection.open(path) (pw.binom.db.ksqlite.SQLiteConnection)
- сразу выполняет: "PRAGMA journal_mode=WAL" и "PRAGMA synchronous=NORMAL"
- fun init() — идемпотентно создаёт схему (ровно эти SQL):
CREATE TABLE IF NOT EXISTS files(path TEXT PRIMARY KEY, mtime REAL, size INTEGER, hash TEXT, indexed_at REAL);
CREATE TABLE IF NOT EXISTS chunks(id INTEGER PRIMARY KEY, path TEXT NOT NULL, heading TEXT, line INTEGER NOT NULL, ord INTEGER NOT NULL, text TEXT NOT NULL, hash TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path);
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, heading, tokenize='unicode61');
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[768]);
- val conn даёт доступ к соединению
- close() закрывает соединение
7. memo-core/src/test/kotlin/memo/core/ — ровно 4 теста, отдельные @Test функции, имена ровно такие:
- ksqliteSmoke — временный файл БД, CREATE TABLE t(a INTEGER), INSERT 42, SELECT, значение совпало.
- vec0KnnRoundTrip — CREATE VIRTUAL TABLE v USING vec0(embedding float[4]); вставить 3 вектора с rowid 1, 2, 3 (привязка FloatArray: stmt.bind(1, id); stmt.bind(2, floatArrayOf(...)));
запрос "SELECT rowid FROM v WHERE embedding MATCH ? ORDER BY distance LIMIT 1" с вектором, ближайшим к rowid=2, обязан вернуть 2.
- fts5FindsCyrillic — FTS5-таблица, вставка строки "внутренний домен траефик", поиск MATCH 'траефик' находит ровно 1 строку.
- embedderProduces768 — путь к модели: env MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2; если text_model_int8.onnx или tokenizer.model отсутствуют — тест падает с сообщением "модель не найдена: <путь>";
иначе embed("привет мир") → dim == 768 и ни одного NaN.
8. Прогони ./gradlew :memo-core:test и добейся, чтобы все 4 теста были зелёными (файлы модели уже скачиваются в models/siglip2, дождись их; если их ещё нет — тест embedderProduces768 может упасть, тогда перезапусти прогон позже).
В конце: git add -A && git commit -m "core: каркас сборки, embedder, схема БД, 4 теста".
СТРОГИЕ ЗАПРЕТЫ:
- НЕ выводи план текстом. Сразу правь файлы инструментами. Задача не выполнена, пока файлы не изменены и ./gradlew :memo-core:test не зелёный.
- НЕ добавляй зависимости, которых нет в списке выше.
- НЕ трогай docs/SPEC.md, TASK.md, TESTING.md, README.md, LICENSE, scripts/, .gitignore.
- НЕ создавай Android/KMP-модули и platform-таргеты.
- НЕ коммить папку models/ и файлы *.db.
+26
View File
@@ -0,0 +1,26 @@
Проект: /root/WORK/memo (Kotlin/JVM). Заказ крошечный: усилить ОДИН тест.
Проблема (доказана мутационной проверкой): тест `fts5FindsCyrillic` в
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt ПРОХОДИТ даже если токенизатор в тесте
заменить с `unicode61` на `ascii` — то есть тест не проверяет то, ради чего он написан
(регистронезависимый поиск по кириллице). Проверено на живом SQLite: с `ascii` строчный запрос
«траефик» НЕ находит строку «Траефик» (0 совпадений), с `unicode61` — находит.
Что сделать — ровно это, в файле memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt:
1. В тесте `fts5FindsCyrillic` (тело метода, сигнатуру и имя не менять):
- таблица создаётся с `tokenize='unicode61'` (оставить как есть);
- ВСТАВЛЯЕМОЕ значение сделать с заглавной буквой: `"Траефик внутри"` вместо `"внутренний домен траефик"`;
- после существующей проверки добавить ещё две проверки в том же тесте:
а) запрос СТРОЧНЫМИ `"траефик"` находит ровно 1 строку и её rowid == 1;
б) запрос ЗАГЛАВНЫМИ `"Траефик"` тоже находит ровно 1 строку и её rowid == 1.
- Проверки делать отдельным запросом каждым, не одним.
2. Ничего больше в файле не менять. Формулировки ассертов писать по-русски, как в файле.
3. Прогнать `./gradlew :memo-core:test --rerun-tasks` и добиться зелёного результата.
4. Закоммитить: `git add -A && git commit -m "core: fts5-тест проверяет регистронезависимость кириллицы"`.
СТРОГИЕ ЗАПРЕТЫ:
- Не выводить план текстом; сразу правь файл.
- Не трогать боевой код (memo-core/src/main/**), только тестовый файл.
- Не менять другие тесты, не переименовывать их.
- Не добавлять зависимости.
+47
View File
@@ -0,0 +1,47 @@
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по чанкеру markdown.
Создать РОВНО ОДИН новый файл: memo-core/src/main/kotlin/memo/core/Chunker.kt
и РОВНО ОДИН новый тестовый файл: memo-core/src/test/kotlin/memo/core/ChunkerTest.kt
## Chunker.kt
package memo.core
data class Chunk(
val heading: String, // текст заголовка без решёток, обрезанный по краям
val line: Int, // 1-based номер строки, ГДЕ СТОИТ ЗАГОЛОВОК; 1, если заголовка нет
val text: String, // текст раздела без строки-заголовка
val ord: Int, // порядковый номер чанка в файле, с 0
)
fun chunkMarkdown(text: String): List<Chunk>
Правила:
- Разделитель — строка, начинающаяся с одного, двух или трёх символов '#' и следующего за ними пробела
(заголовки '#'..'###'). Более длинные последовательности решёток заголовками НЕ считаются.
- Заголовок попадает в field heading, его строка — в field line. В text строки-заголовки не входят
(text обрезается по краям; пустые строки в начале и конце удаляются).
- Текст ДО первого заголовка даёт чанк с heading = "" и line = 1 (только если он непустой после обрезки).
- Пустые чанки (text пустой после trim) НЕ возвращаются.
- Боковые ограничения по размеру: если text длиннее 4000 символов, он режется на несколько чанков
по 4000 символов с перекрытием 600 символов (хвост предыдущего повторяется в начале следующего);
все части одного раздела имеют ОДИН И ТОТ ЖЕ heading и line, ord растёт.
- Никаких зависимостей; чистый Kotlin.
## 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 "Заголовок".
3. `fourHashesIsNotAHeading` — строка '#### не заголовок' остаётся частью текста предыдущего раздела.
4. `emptySectionsAreDropped` — ввод с двумя заголовками подряд без текста между ними → 1 чанк.
5. `longSectionIsSplitWithOverlap` — раздел из 6000 символов 'а' → 2 чанка, длина первого 4000,
длина второго 2600, и последние 600 символов первого равны первым 600 символам второго (перекрытие).
После: `./gradlew :memo-core:test --rerun-tasks` — все тесты (4 старых + 5 новых) зелёные.
Коммит: `git add -A && git commit -m "core: чанкер markdown по заголовкам с перекрытием"`.
СТРОГИЕ ЗАПРЕТЫ:
- Не выводить план текстом; сразу создавай файлы.
- Не трогать существующие файлы (Embedder.kt, Db.kt, CoreSmokeTest.kt, build.gradle.kts) и любые другие.
- Не добавлять зависимости.
- Не создавать другие файлы.
+57
View File
@@ -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 и любые другие файлы.
- Не менять существующие тесты.
- Не добавлять зависимости.
+76
View File
@@ -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).
- Не добавлять зависимости.
- Не создавать другие файлы.
+79
View File
@@ -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.
- Не создавать другие файлы.
+74
View File
@@ -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.
+62
View File
@@ -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 строк.
+78
View File
@@ -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-инструментов.
- Не добавлять зависимости.
+72
View File
@@ -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.
- Не добавлять зависимости.
+61
View File
@@ -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.
- Не менять имена тестов, не удалять существующие проверки.
- Не добавлять зависимости.
+93
View File
@@ -0,0 +1,93 @@
Проект: /root/WORK/memo (Kotlin/JVM). Заказ на исправление ТРЁХ доказанных дефектов демона `memo-watch`.
## Доказанные дефекты (воспроизведено на живом корпусе)
Корпус `/root/WORK/memo-e2e-watch2` — копия эталонного (`infra`, `life/books`, `work/jira`, 12 .md).
Все `.memo` предварительно удалены. Запуск:
MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 \
memo-watch/build/install/memo-watch/bin/memo-watch /root/WORK/memo-e2e-watch2
1. **Создаёт 6 баз вместо 3.** Вывод `наблюдаю:` перечисляет корень, `infra`, `life`, `work`,
`life/books`, `work/jira` и всем заводит `.memo/index.db`. Причина — своя локальная копия
`discoverCollections()` в `WatchMain.kt` (walkTopDown + depth 2 «есть .md где-то внутри»);
в `memo-core` эту же ошибку уже вылечили (`findCollections` = только каталог с *.md НАПРЯМУЮ),
но watcher остался на старой копии.
2. **Не индексирует при старте.** Сразу после запуска (без правок файлов) в базах 0 файлов и 0 чанков;
наполняется только та коллекция, в которой потом что-то изменилось. Проверка:
правка одного файла в `infra` → в `infra/.memo` появились файлов 4, чанков 12, а `life/books`
и `work/jira` остались с нулями. Никакого первичного прохода при старте нет.
3. **Держит все базы открытыми постоянно.** `WatchMain` открывает `Db` и `Embedder` на каждую
коллекцию и не закрывает до SIGINT; тот же `.db` в это время открывают CLI и MCP.
Это надо проверить на отсутствие «database is locked» (одновременные watcher + поиск).
## Что сделать
Правки ТОЛЬКО в модуле `memo-watch` (`src/main` и `src/test`). `memo-core` не менять.
1. **Убрать копию логики.** В `WatchMain.kt` удалить локальную `discoverCollections` и функцию
определения корня по `.memo`; использовать из ядра:
`memo.core.findCollections(base)` и `memo.core.resolveCollection(raw)`.
Поведение: передан один каталог-коллекция → он один; передан корень → все коллекции внутри.
2. **Первичный проход при старте.** После `watcher.start()` (или до него) выполнить для каждой
коллекции `indexer.indexTree(coll)` один раз, чтобы индекс был полным сразу, без ожидания событий.
Вывести в stdout по строке: `индексирую: <путь> -> обновлено <N>`. Затем уже строки `наблюдаю:`.
3. **Устойчивость к параллельному доступу.** Включить в `Db` уже есть WAL — не менять. Вместо этого
добавить в `WatchMain` обработку ошибок записи так, чтобы `database is locked` не убивал демон:
при ошибке печатать в stderr и повторять попытку один раз через 1 секунду (достаточно локальной
обёртки вокруг `indexer.indexTree`). Не менять код ядра.
## Тесты: дополнить memo-watch/src/test/kotlin/memo/watch/WatcherTest.kt и добавить новый файл
Новый файл `memo-watch/src/test/kotlin/memo/watch/WatchMainLogicTest.kt`, ровно 2 теста:
1. `collectionsFoundByCoreRule` — дерево: `root/a.md`, `root/sub/b.md`, `root/nested/only/deep/d.md`,
`root/empty/` → `findCollections(root)` даёт ровно три каталога (root, root/sub, root/nested/only/deep),
и среди них НЕТ `root/nested` и `root/nested/only`.
2. `startupIndexPassFillsEveryCollection` — временный корень с двумя коллекциями (`root/c1/x.md`,
`root/c2/y.md`), у каждой создаётся `Db` + `.memo`, вызывается `Indexer.indexTree(coll)` по разу
(как это делает первичный проход), после чего в обеих базах
`SELECT COUNT(*) FROM chunks` > 0 и `SELECT COUNT(*) FROM files` == 1.
Модель брать из `MEMO_MODEL_DIR` (как в CoreSmokeTest).
## Обязательная сквозная проверка (приложить вывод)
```bash
cd /root/WORK/memo
./gradlew :memo-watch:installDist -q
rm -rf /tmp/mw && cp -r /root/WORK/memo-e2e /tmp/mw
find /tmp/mw -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
# 1) старт: 3 базы, все наполнены сразу
MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 \
memo-watch/build/install/memo-watch/bin/memo-watch /tmp/mw > /tmp/mw.log 2>&1 &
WPID=$!
sleep 40
echo "--- базы:"; find /tmp/mw -name index.db | sort
echo "--- чанки сразу после старта:"
python3 - <<'EOF'
import sqlite3, glob
for db in sorted(glob.glob("/tmp/mw/**/.memo/index.db", recursive=True)):
c = sqlite3.connect("file:"+db+"?mode=ro", uri=True)
print(db.replace("/tmp/mw",""), "файлов", c.execute("SELECT COUNT(*) FROM files").fetchone()[0],
"чанков", c.execute("SELECT COUNT(*) FROM chunks").fetchone()[0])
c.close()
EOF
# 2) параллельно с работающим демоном ищем тем же ядром — не должно быть locked
memo-cli/build/install/memo/bin/memo search /tmp/mw/life/books "кто ведёт рассказ в романе" --k 3 --json | head -c 400
echo
kill $WPID 2>/dev/null; wait $WPID 2>/dev/null
cat /tmp/mw.log
```
Ожидается: РОВНО три пути `index.db`; у каждой коллекции ненулевые файлы и чанки СРАЗУ после старта;
поиск при работающем демоне отвечает без ошибки `database is locked`; в логе — строки
`индексирую: ... -> обновлено N` и `наблюдаю: ...` (по три каждого вида).
После: ./gradlew test --rerun-tasks — все тесты проекта зелёные.
Коммит: git add -A && git commit -m "watch: первичный проход при старте, коллекции по правилу ядра"
СТРОГИЕ ЗАПРЕТЫ:
- Не выводить план текстом; сразу правь файлы.
- Не менять memo-core, memo-cli, memo-mcp.
- Не менять смысл существующих тестов WatcherTest.
- Не добавлять зависимости.
+162
View File
@@ -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).
+104
View File
@@ -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`.
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Мутационная проверка: ломаем боевой код и смотрим, падает ли нужный тест.
Формат e2e/mutations.tsv: file<TAB>old<TAB>new<TAB>must_fail_test
Прогон: python3 e2e/mutation_check.py --gradle ./gradlew
Выход 0 — все мутации убиты (тесты ловят поломку). 1 — есть выжившие (дыра в покрытии).
"""
import argparse
import glob
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
def run_tests(gradle: str) -> dict[str, str]:
"""Возвращает {test_name: status}, где status = PASS/FAIL."""
subprocess.run([gradle, "--quiet", "test", "--rerun-tasks"],
capture_output=True, text=True, timeout=1800)
status: dict[str, str] = {}
for f in glob.glob("**/build/test-results/test/*.xml", recursive=True):
try:
root = ET.parse(f).getroot()
except ET.ParseError:
continue
for tc in root.iter("testcase"):
failed = any(c.tag in ("failure", "error") for c in tc)
status[tc.get("name")] = "FAIL" if failed else "PASS"
return status
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--gradle", default="./gradlew")
ap.add_argument("--mutations", default="e2e/mutations.tsv")
a = ap.parse_args()
rows = []
for line in open(a.mutations, encoding="utf-8"):
line = line.rstrip("\n")
if not line or line.startswith("#"):
continue
f, old, new, must_fail = line.split("\t")
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()
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, "НЕ НАЙДЕН")
killed = got == "FAIL"
print(f"{'УБИТА ' if killed else 'ВЫЖИЛА'} | {must_fail} -> {got} | {f}: {old[:50]} -> {new[:30]}")
if not killed:
survivors.append((must_fail, f, old, new))
finally:
shutil.move(backup, f)
print("\n=== восстановление ===")
st = run_tests(a.gradle)
print(f"после отката упавших: {[k for k, v in st.items() if v == 'FAIL']}")
if survivors:
print("\nВЫЖИВШИЕ МУТАЦИИ (дыры в покрытии):")
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
if __name__ == "__main__":
sys.exit(main())
+29
View File
@@ -0,0 +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.
+26
View File
@@ -0,0 +1,26 @@
# Вопросы приёмки T2/T3. Формат: question<TAB>path<TAB>expected_file<TAB>kind
# kind: sem — смысловой (должен вытянуть вектор), lex — точное значение (должен вытянуть BM25)
# Ожидаемый файл должен попасть в top-5. Порог: recall@5 >= 16/20 по sem, 3/3 по lex.
как понять, почему сборка съедает место на диске work/jira work/jira/ci.md sem
чем разметить диск, если привычной утилиты нет infra infra/servers.md sem
на какой карте разрешено ставить опыты infra infra/servers.md sem
что служит основой для разрешения имён в сети infra infra/domains.md sem
куда конвейер отдаёт готовые артефакты infra infra/hosts.md sem
как корпоративные имена ходят наружу infra infra/hosts.md sem
восстановление прошло мгновенно, а данных не видно infra infra/backup.md sem
куда складываются зеркала для очков infra infra/backup.md sem
где лежат ключи доступа к корпоративным ресурсам work/jira work/jira/access.md sem
как оформляют задачу, пришедшую от робота work/jira work/jira/flow.md sem
после обновления пропали настройки выпуска work/jira work/jira/pitfalls.md sem
какой номер тикета закрыли после починки тестов work/jira work/jira/flow.md sem
кто ведёт рассказ в романе life/books life/books/iphuck.md sem
в каком году вышла книга life/books life/books/iphuck.md sem
где чинят карточку, если подтянулось чужое издание life/books life/books/audiobooks.md sem
до какого правителя доходит первый том life/books life/books/history.md sem
как я фиксирую цитаты из прочитанного life/books life/books/notes.md sem
чем слушают книги в очках life/books life/books/audiobooks.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 # Вопросы приёмки T2/T3. Формат: question<TAB>path<TAB>expected_file<TAB>kind
2 # kind: sem — смысловой (должен вытянуть вектор), lex — точное значение (должен вытянуть BM25)
3 # Ожидаемый файл должен попасть в top-5. Порог: recall@5 >= 16/20 по sem, 3/3 по lex.
4 как понять, почему сборка съедает место на диске work/jira work/jira/ci.md sem
5 чем разметить диск, если привычной утилиты нет infra infra/servers.md sem
6 на какой карте разрешено ставить опыты infra infra/servers.md sem
7 что служит основой для разрешения имён в сети infra infra/domains.md sem
8 куда конвейер отдаёт готовые артефакты infra infra/hosts.md sem
9 как корпоративные имена ходят наружу infra infra/hosts.md sem
10 восстановление прошло мгновенно, а данных не видно infra infra/backup.md sem
11 куда складываются зеркала для очков infra infra/backup.md sem
12 где лежат ключи доступа к корпоративным ресурсам work/jira work/jira/access.md sem
13 как оформляют задачу, пришедшую от робота work/jira work/jira/flow.md sem
14 после обновления пропали настройки выпуска work/jira work/jira/pitfalls.md sem
15 какой номер тикета закрыли после починки тестов work/jira work/jira/flow.md sem
16 кто ведёт рассказ в романе life/books life/books/iphuck.md sem
17 в каком году вышла книга life/books life/books/iphuck.md sem
18 где чинят карточку, если подтянулось чужое издание life/books life/books/audiobooks.md sem
19 до какого правителя доходит первый том life/books life/books/history.md sem
20 как я фиксирую цитаты из прочитанного life/books life/books/notes.md sem
21 чем слушают книги в очках life/books life/books/audiobooks.md sem
22 что запрещено трогать на машине с двумя картами infra infra/servers.md sem
23 где живёт хранилище контейнерных образов infra infra/hosts.md sem
24 76.132 infra infra/hosts.md lex
25 TEST-4173 work/jira work/jira/flow.md lex
26 192.168.88.35:8080 infra infra/hosts.md lex
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Приёмка recall@5 для memo (TESTING.md T2/T3).
Читает e2e/questions.tsv, гоняет CLI и считает, попал ли ожидаемый файл в top-5.
Использование:
python3 e2e/run_e2e.py --cli ./memo-cli/build/install/memo-cli/bin/memo \
--root /root/WORK/memo-e2e [--k 5] [--min-sem-recall 0.8]
"""
import argparse
import json
import shlex
import subprocess
import sys
def normalize(p: str, root: str) -> str:
p = p.replace("\\", "/")
root = root.rstrip("/")
if p.startswith(root + "/"):
p = p[len(root) + 1:]
return p.lstrip("./")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--cli", required=True)
ap.add_argument("--root", required=True)
ap.add_argument("--questions", default="e2e/questions.tsv")
ap.add_argument("--k", type=int, default=5)
ap.add_argument("--min-sem-recall", type=float, default=0.8)
a = ap.parse_args()
rows = []
for line in open(a.questions, encoding="utf-8"):
line = line.rstrip("\n")
if not line or line.startswith("#"):
continue
q, scope, expected, kind = line.split("\t")
rows.append((q, scope, expected, kind))
hits = {"sem": 0, "lex": 0}
total = {"sem": 0, "lex": 0}
fails = []
for q, scope, expected, kind in rows:
total[kind] += 1
cmd = shlex.split(a.cli) + ["search", f"{a.root}/{scope}", q, "--k", str(a.k), "--json"]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
except subprocess.TimeoutExpired:
fails.append((q, kind, "TIMEOUT", []))
continue
if out.returncode != 0:
fails.append((q, kind, f"exit {out.returncode}: {out.stderr.strip()[:200]}", []))
continue
try:
data = json.loads(out.stdout)
except json.JSONDecodeError:
fails.append((q, kind, f"не JSON: {out.stdout[:200]}", []))
continue
items = data if isinstance(data, list) else data.get("results", [])
got = [normalize(str(it.get("path", "")), a.root) for it in items][:a.k]
if normalize(expected, a.root) in got:
hits[kind] += 1
else:
fails.append((q, kind, "не найдено в top-%d" % a.k, got))
sem_total = total["sem"] or 1
lex_total = total["lex"] or 1
sem_recall = hits["sem"] / sem_total
lex_recall = hits["lex"] / lex_total
print(f"sem: {hits['sem']}/{sem_total} = {sem_recall:.2f} (порог {a.min_sem_recall})")
print(f"lex: {hits['lex']}/{lex_total} = {lex_recall:.2f} (порог 1.00)")
if fails:
print("\nпромахи:")
for q, kind, why, got in fails:
print(f" [{kind}] {q} -> {why}")
if got:
print(f" получено: {got}")
ok = sem_recall >= a.min_sem_recall and lex_recall >= 1.0
print("\nИТОГ:", "ПРОЙДЕНО" if ok else "ПРОВАЛ")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+14
View File
@@ -0,0 +1,14 @@
plugins {
kotlin("jvm")
application
}
application {
mainClass.set("memo.cli.MainKt")
applicationName = "memo"
}
dependencies {
implementation(project(":memo-core"))
testImplementation(kotlin("test"))
}
+623
View File
@@ -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")
}
}
+11
View File
@@ -0,0 +1,11 @@
plugins {
kotlin("jvm")
}
dependencies {
api("pw.binom.db:ksqlite:0.1.4")
implementation("pw.binom.ai.embeddingtext:api:5")
runtimeOnly("pw.binom.ai.embeddingtext:siglip-jvm:5")
testImplementation(kotlin("test"))
}
@@ -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 }
}
+34
View File
@@ -0,0 +1,34 @@
package memo.core
import pw.binom.db.ksqlite.SQLiteConnection
class Db(val path: String) : AutoCloseable {
val conn: SQLiteConnection = SQLiteConnection.open(path)
init {
conn.exec("PRAGMA journal_mode=WAL")
conn.exec("PRAGMA synchronous=NORMAL")
}
fun init() {
conn.exec(
"CREATE TABLE IF NOT EXISTS files(" +
"path TEXT PRIMARY KEY, mtime REAL, size INTEGER, hash TEXT, indexed_at REAL" +
")"
)
conn.exec(
"CREATE TABLE IF NOT EXISTS chunks(" +
"id INTEGER PRIMARY KEY, path TEXT NOT NULL, heading TEXT, " +
"line INTEGER NOT NULL, ord INTEGER NOT NULL, text TEXT NOT NULL, hash TEXT NOT NULL" +
")"
)
conn.exec("CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path)")
conn.exec("CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, heading, tokenize='unicode61')")
conn.exec("CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[768])")
}
override fun close() {
conn.close()
}
}
@@ -0,0 +1,46 @@
package memo.core
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
class Embedder(
private val modelPath: String,
private val tokenizerPath: String,
) : AutoCloseable {
private var extractor: TextEmbeddingExtractor? = null
private val factory: Class<*>? = runCatching {
Class.forName("pw.binom.voice.embeddingtext.Siglip2TextExtractorFactoryKt")
}.getOrNull()
private val createMethod = factory?.methods?.firstOrNull {
it.name == "createSiglip2TextExtractor" &&
it.parameterTypes.size == 2 &&
it.parameterTypes[0] == String::class.java &&
it.parameterTypes[1] == String::class.java
}
private fun obtain(): TextEmbeddingExtractor {
extractor?.let { return it }
val method = createMethod ?: error(
"createSiglip2TextExtractor is not available on classpath; " +
"ensure pw.binom.ai.embeddingtext:siglip-jvm is on the runtime classpath"
)
val created = method.invoke(null, modelPath, tokenizerPath) as TextEmbeddingExtractor
extractor = created
return created
}
fun embed(text: String): FloatArray {
val ex = obtain()
val values = ex.embed(text).values
if (values.size != 768) {
throw IllegalStateException("expected 768 dims, got ${values.size}")
}
return values
}
override fun close() {
val current = extractor ?: return
extractor = null
current.close()
}
}
@@ -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)
}
}
@@ -0,0 +1,236 @@
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
class CoreSmokeTest {
@Test
fun ksqliteSmoke() {
val file = File.createTempFile("memo-ksqlite-", ".db")
file.deleteOnExit()
val conn = SQLiteConnection.open(file.absolutePath)
try {
conn.exec("CREATE TABLE t(a INTEGER)")
conn.exec("INSERT INTO t(a) VALUES (42)")
val stmt = conn.prepare("SELECT a FROM t")
try {
val rs = stmt.executeQuery()
try {
assertTrue(rs.next(), "expected one row")
assertEquals(42L, rs.getLong(0)!!)
} finally {
rs.close()
}
} finally {
stmt.close()
}
} finally {
conn.close()
file.delete()
}
}
@Test
fun vec0KnnRoundTrip() {
val file = File.createTempFile("memo-vec0-", ".db")
file.deleteOnExit()
val conn = SQLiteConnection.open(file.absolutePath)
try {
conn.exec("CREATE VIRTUAL TABLE v USING vec0(embedding float[4])")
val insert = conn.prepare("INSERT INTO v(rowid, embedding) VALUES (?, ?)")
try {
insert.bindLong(1, 1L)
insert.bindVector(2, floatArrayOf(1.0f, 0.0f, 0.0f, 0.0f))
insert.executeUpdate()
insert.reset()
insert.bindLong(1, 2L)
insert.bindVector(2, floatArrayOf(0.0f, 1.0f, 0.0f, 0.0f))
insert.executeUpdate()
insert.reset()
insert.bindLong(1, 3L)
insert.bindVector(2, floatArrayOf(0.0f, 0.0f, 1.0f, 0.0f))
insert.executeUpdate()
} finally {
insert.close()
}
val query = conn.prepare("SELECT rowid FROM v WHERE embedding MATCH ? ORDER BY distance LIMIT 1")
try {
query.bindVector(1, floatArrayOf(0.1f, 0.9f, 0.0f, 0.0f))
val rs = query.executeQuery()
try {
assertTrue(rs.next(), "expected one match")
assertEquals(2L, rs.getLong(0)!!)
} finally {
rs.close()
}
} finally {
query.close()
}
} finally {
conn.close()
file.delete()
}
}
@Test
fun fts5FindsCyrillic() {
val file = File.createTempFile("memo-fts-", ".db")
file.deleteOnExit()
val conn = SQLiteConnection.open(file.absolutePath)
try {
conn.exec("CREATE VIRTUAL TABLE docs USING fts5(text, tokenize='unicode61')")
val insert = conn.prepare("INSERT INTO docs(rowid, text) VALUES (?, ?)")
try {
insert.bindLong(1, 1L)
insert.bindText(2, "Траефик внутри")
insert.executeUpdate()
} finally {
insert.close()
}
val query = conn.prepare("SELECT rowid FROM docs WHERE docs MATCH ?")
try {
query.bindText(1, "траефик")
val rs = query.executeQuery()
try {
var hits = 0
var lastRowid = -1L
while (rs.next()) {
hits++
lastRowid = rs.getLong(0)!!
}
assertEquals(1, hits, "expected exactly one FTS5 hit")
assertEquals(1L, lastRowid)
} finally {
rs.close()
}
} finally {
query.close()
}
val queryLower = conn.prepare("SELECT rowid FROM docs WHERE docs MATCH ?")
try {
queryLower.bindText(1, "траефик")
val rs = queryLower.executeQuery()
try {
var hits = 0
var lastRowid = -1L
while (rs.next()) {
hits++
lastRowid = rs.getLong(0)!!
}
assertEquals(1, hits, "ожидалось ровно одно совпадение FTS5")
assertEquals(1L, lastRowid, "rowid должен быть 1")
} finally {
rs.close()
}
} finally {
queryLower.close()
}
val queryUpper = conn.prepare("SELECT rowid FROM docs WHERE docs MATCH ?")
try {
queryUpper.bindText(1, "Траефик")
val rs = queryUpper.executeQuery()
try {
var hits = 0
var lastRowid = -1L
while (rs.next()) {
hits++
lastRowid = rs.getLong(0)!!
}
assertEquals(1, hits, "ожидалось ровно одно совпадение FTS5")
assertEquals(1L, lastRowid, "rowid должен быть 1")
} finally {
rs.close()
}
} finally {
queryUpper.close()
}
} finally {
conn.close()
file.delete()
}
}
@Test
fun embedderProduces768() {
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")
}
Embedder(modelPath, tokenizerPath).use { embedder ->
val v = embedder.embed("привет мир")
assertEquals(768, v.size)
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}")
}
}
}
}
+16
View File
@@ -0,0 +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")
}
}
+13
View File
@@ -0,0 +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()
}
}
+164
View File
@@ -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
+141
View File
@@ -0,0 +1,141 @@
#!/bin/bash
# Создаёт эталонный корпус для приёмки memo (TESTING.md §0).
# 12-15 заметок в 3 коллекциях, кириллица + латиница + точные значения в каждой папке.
set -euo pipefail
ROOT="${1:-/root/WORK/memo-e2e}"
rm -rf "$ROOT"
mkdir -p "$ROOT/infra" "$ROOT/work/jira" "$ROOT/life/books"
# ---------- infra ----------
cat > "$ROOT/infra/servers.md" <<'EOF'
# Серверы
## Server4 (76.160)
Основной Proxmox-хост. Диск sdd — SSD, sda/b/c — HDD, причём sda на SMR-пластинах.
Утилиты parted нет: разметку делать через sgdisk или sfdisk.
## Server5 (88.130)
Две видеокарты: 3090 занята под Qwen VL и аудио, её не трогать. Эксперименты — только на 5090.
## Server6 (76.109)
Здесь живёт powerdns, он же LXC102. Отвечает за внутренние зоны.
EOF
cat > "$ROOT/infra/hosts.md" <<'EOF'
# Адреса внутренних сервисов
## Реестр образов
zot слушает на 192.168.88.35:8080, репозиторий приватный.
## Прокси
Корпоративные домены ходят через Caddy на 76.132. Отдельный Traefik на 76.195 заворачивает
домены вида .xx, а DNS под ним — 76.109.
## Nexus
Нексус доступен на 76.117, релизы публикует CI/CD, руками артефакты не заливать.
EOF
cat > "$ROOT/infra/domains.md" <<'EOF'
# Домены и DNS
## Внутренние зоны
Основой для DNS и mDNS служит kdns, он же pw.binom.dns.
## LLM-роутер
llm.binom.pw работает через Bifrost, виртуальные ключи раздаёт сам роутер.
## Реестр
Домашний реестр библиотек — nexus.xx, репозиторий caffeine.
EOF
cat > "$ROOT/infra/backup.md" <<'EOF'
# Резервное копирование
## Postgres
Дампы снимаются в custom-формате. При восстановлении pg_restore может выйти мгновенно
с кодом ноль и ничего не сделать — проверять, что объекты реально появились.
## Медиа
Джоб копирования зеркал для очков идёт через NATS и складывает результат в S3 SeaweedFS.
EOF
# ---------- work/jira ----------
cat > "$ROOT/work/jira/access.md" <<'EOF'
# Доступы
## OTP-прокси
Корпоративные ресурсы ходят через OTP, файлы лежат в /root/OTP/. Jira отвечает на jira.mcp.xx.
## TeamCity
Сборки живут в teamcity.isb, статусы и логи — только через тот же OTP-прокси.
## Git
Клонирование корпоративных репозиториев с bitbucket возможно лишь по HTTPS, git@ не работает.
EOF
cat > "$ROOT/work/jira/flow.md" <<'EOF'
# Рабочий поток
## Задачи от агентов
Задача от агента оформляется спекой в отдельном файле, итог работы — комментарий в Vikunja.
## Тикеты
Тикет TEST-4173 закрыт после того, как сборка перестала падать на шаге тестов.
EOF
cat > "$ROOT/work/jira/pitfalls.md" <<'EOF'
# Грабли
## Хелм
Helm upgrade в k3s теряет values, если передавать их через --set. Восстанавливать из истории релиза.
## TeamCity
Если прогон висит больше часа, смотреть лог агента, а не перезапускать сборку слепо.
EOF
cat > "$ROOT/work/jira/ci.md" <<'EOF'
# CI
## Gitea Actions
Раннер живёт в LXC105 на 76.112. Сироты процессов podman и unity съедают диск и валят сборку.
EOF
# ---------- life/books ----------
cat > "$ROOT/life/books/iphuck.md" <<'EOF'
# iPhuck 10
## Про роман
Роман Пелевина, 2017 года. Рассказчик — алгоритмический следователь Порфирий Петрович,
он же литературный негр, он же полицейская машина.
## Канон
Канон Порфирия Петровича лежит в /root/BOOKS/iphuck10/. Тон — высокий штиль, смешанный
с сухим канцеляритом.
EOF
cat > "$ROOT/life/books/audiobooks.md" <<'EOF'
# Аудиокниги
## Библиотека
Книги хранятся в Audiobookshelf. Карточку чинят вручную, если метаданные подтянулись от другого издания.
## Плеер
Для очков RayNeo X2 книги транскодируются в зеркала, аудио идёт отдельными дорожками.
EOF
cat > "$ROOT/life/books/history.md" <<'EOF'
# История
## Античность
Первый том заканчивается на правлении Траяна, 117 год нашей эры.
EOF
cat > "$ROOT/life/books/notes.md" <<'EOF'
# Заметки о чтении
## Привычка
Читаю по вечерам, отмечаю цитаты в markdown, потом ищу их семантическим поиском.
EOF
ls -R "$ROOT"
echo "OK: $(find "$ROOT" -name '*.md' | wc -l) заметок"
+13
View File
@@ -0,0 +1,13 @@
rootProject.name = "memo"
include(":memo-core", ":memo-cli", ":memo-watch", ":memo-mcp")
dependencyResolutionManagement {
repositories {
mavenCentral()
maven {
url = uri("http://nexus.xx/repository/caffeine/")
isAllowInsecureProtocol = true
}
}
}