diff --git a/MANUAL.md b/MANUAL.md index 8ae033a..ca78206 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -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 # по каждой коллекции: файлов, чанков, когда индексировали diff --git a/README.md b/README.md index 0bfd881..2539a61 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/orders/12-model-download.md b/docs/orders/12-model-download.md new file mode 100644 index 0000000..4e9e732 --- /dev/null +++ b/docs/orders/12-model-download.md @@ -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, val skipped: List, 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 +} +``` + +Требования к `ensure`: + +1. **Пропуск без сети.** Если файл существует, непустой и не `force` → он в `skipped`, **ни одного + сетевого запроса** по нему не делается. Это важно: обычный запуск поиска офлайн обязан работать. +2. **Докачка.** Качать в `<имя>.part` рядом с целевым файлом. Если `.part` уже есть и непустой — + продолжить с его размера, отправив `Range: bytes=-`; ответ `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` → скачивание не выполняется, а при отсутствии +модели выдаётся внятная ошибка: `модель не найдена в ; запустите: memo model`. + +Сообщение о скачивании выводить в stderr с первой строкой вида +`модель не найдена в , скачиваю с (≈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). diff --git a/memo-cli/src/main/kotlin/memo/cli/Main.kt b/memo-cli/src/main/kotlin/memo/cli/Main.kt index 81ad43c..303d6a0 100644 --- a/memo-cli/src/main/kotlin/memo/cli/Main.kt +++ b/memo-cli/src/main/kotlin/memo/cli/Main.kt @@ -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): Cmd { @@ -36,12 +38,40 @@ fun parseArgs(args: Array): 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): 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): Cmd { if (rest.size < 2) return HelpCmd val path = rest[0] @@ -100,12 +130,18 @@ private fun parseMcpProbe(rest: List): Cmd { } fun main(args: Array) { - 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 memo search [--k N] [--mode hybrid|lex|vec] [--json] memo status + memo model [--dir ] [--url ] [--force] memo mcp-probe --tool [--args ] """.trimIndent() ) } -private fun modelPaths(): Pair { - 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 = 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() 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() for (coll in targets) { val dbPath = File(coll, ".memo/index.db") diff --git a/memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt b/memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt index e7a6b43..6c72ba7 100644 --- a/memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt +++ b/memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt @@ -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() val errors = ArrayList() @@ -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 { return findCollections(root) } -private fun modelPaths(): Pair { - 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 = 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 {