Compare commits

...

2 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
9 changed files with 893 additions and 24 deletions
+51 -12
View File
@@ -68,21 +68,54 @@
```bash
cd /root/WORK/memo
# 1. Собрать всё
./gradlew installDist
# 2. Скачать модель (в артефакты не входит, 287 МБ, один раз)
mkdir -p models/siglip2
curl -fSL -o models/siglip2/text_model_int8.onnx http://static.binom.pw/models/siglip2/text_model_int8.onnx
curl -fSL -o models/siglip2/tokenizer.model http://static.binom.pw/models/siglip2/tokenizer.model
# 3. Сказать, где модель (добавьте в ~/.bashrc, чтобы не повторять)
export MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2
```
Без переменной `MEMO_MODEL_DIR` программы ищут модель в `/root/WORK/memo/models/siglip2` —
если у вас этот путь, шаг 3 можно пропустить.
**Больше ничего делать не надо.** Модель (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, чтобы не повторять
```
После сборки появятся три программы:
@@ -124,6 +157,7 @@ $CLI search ~/notes/infra "где публикуются релизы"
```bash
$CLI index <путь> # проиндексировать (повторный прогон — быстрый)
$CLI model # скачать модель (обычно не нужно — сама скачается)
$CLI search <путь> "<вопрос>" # искать: и смысл, и точные слова
$CLI search <путь> "<вопрос>" --k 5 # вернуть 5 результатов (по умолчанию 8)
$CLI search <путь> "<вопрос>" --json # машинный вывод (для скриптов и агента)
@@ -307,6 +341,11 @@ $CLI index ~/notes
MCP-серверы читаются при старте Hermes: начните новую сессию. Проверьте, что путь к `memo-mcp`
в конфиге верный и файл исполняемый.
**Агент долго не отвечает на первый запрос.**
Скорее всего, скачивается модель (287 МБ) — в stderr `memo-mcp` идёт строка
`модель не найдена ..., скачиваю ...`. Это разовое: скачайте заранее командой `$CLI model`
или подложите файлы в `MEMO_MODEL_DIR`.
**Где посмотреть, что вообще происходит.**
```bash
$CLI status ~/notes # по каждой коллекции: файлов, чанков, когда индексировали
+1
View File
@@ -50,6 +50,7 @@ memo-mcp # MCP-сервер (stdio): memo_search / memo_status / memo_reinde
./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
+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`.
+5
View File
@@ -22,3 +22,8 @@ memo-core/src/main/kotlin/memo/core/Searcher.kt .sortedByDescending { it.value }
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.
+80 -9
View File
@@ -4,6 +4,7 @@ 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
@@ -22,6 +23,7 @@ data class SearchCmd(
) : 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 {
@@ -36,12 +38,40 @@ fun parseArgs(args: Array<String>): Cmd {
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]
@@ -100,12 +130,18 @@ private fun parseMcpProbe(rest: List<String>): Cmd {
}
fun main(args: Array<String>) {
when (val cmd = parseArgs(args)) {
is IndexCmd -> runIndex(cmd)
is SearchCmd -> runSearch(cmd)
is StatusCmd -> runStatus(cmd)
is McpProbeCmd -> runMcpProbe(cmd)
HelpCmd -> printHelp()
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)
}
}
@@ -116,14 +152,46 @@ private fun printHelp() {
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 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 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) {
@@ -134,6 +202,7 @@ private fun runIndex(cmd: IndexCmd) {
return
}
val (modelPath, tokenizerPath) = modelPaths()
ensureModel(modelDir())
var totalUpdated = 0
for (coll in collections) {
val memoDir = File(coll, ".memo")
@@ -168,6 +237,7 @@ private fun runSearch(cmd: SearchCmd) {
return
}
val (modelPath, tokenizerPath) = modelPaths()
ensureModel(modelDir())
val allHits = ArrayList<Hit>()
for (coll in targets) {
val dbPath = File(coll, ".memo/index.db")
@@ -325,6 +395,7 @@ private fun performSearch(path: String, query: String, k: Int, mode: SearchMode)
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")
@@ -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,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) }
}
}
+20 -3
View File
@@ -3,6 +3,7 @@ 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
@@ -115,6 +116,7 @@ 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>()
@@ -200,6 +202,7 @@ 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)
@@ -231,9 +234,23 @@ private fun selectTargets(root: File): List<File> {
return findCollections(root)
}
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 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 {