commit cefc17d7826fff218e8ff40b61a3b639e396c6bf Author: Hermes Agent Date: Fri Oct 2 00:59:33 2026 +0300 memo: спека, ТЗ, тест-план, MCP-зонд, gradle wrapper diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7875b0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +build/ +.gradle/ +models/ +*.db +*.db-wal +*.db-shm +.memo/ +.idea/ +*.iml +local.properties diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b124f51 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# memo + +Локальная библиотека заметок с семантическим поиском. + +**Markdown на диске — истина, `.db` рядом — пересобираемый кэш.** Агент (или человек) пишет и +читает обычные `.md`-файлы своими штатными средствами: `memo` не требует ни специального API записи, +ни изменения привычек. Он следит за файловой системой, держит рядом с каждой папкой-темой +индекс (SQLite + sqlite-vec + FTS5) и отвечает на смысловые вопросы гибридным поиском +(BM25 + вектор + RRF). + +## Статус + +Проектирование завершено. Код не написан. + +| Документ | Что внутри | +|---|---| +| [`docs/SPEC.md`](docs/SPEC.md) | полная спека: модель данных, индекс, маршрутизация, watcher, интерфейс, приёмка | +| [`TASK.md`](TASK.md) | ТЗ для исполнителя (opencode): стек, схема БД, контракты, чего не делать | +| [`TESTING.md`](TESTING.md) | тест-план: 6 приёмочных проверок, команды, признаки провала | + +## Идея + +- Коллекций (папок-тем) — сколько угодно; они не мешают друг другу. +- Индекс каждой коллекции живёт в её `.memo/index.db` и **удаляется без потерь** — пересобирается. +- Поиск — обычный read-only инструмент; записи в markdown он не делает никогда. +- Только локально: эмбеддинг на CPU, сеть не нужна ни при индексации, ни при поиске. + +## Состав (JVM-first, один Gradle-проект) + +``` +memo-cli # memo index|search|status — ручные проверки и приёмка без агента +memo-core # чанкер по заголовкам, sqlite-схема, инкрементальная переиндексация, гибридный поиск +memo-watch # демон: WatchService + debounce + реконсиляция по mtime +memo-mcp # MCP-сервер (stdio): memo_search / memo_status / memo_reindex +``` + +`memo-core` не знает ни про watcher, ни про MCP — это драйверы поверх ядра. + +## Зависимости (проверено 02.10.2026) + +| Что | Координата | Откуда | +|---|---|---| +| SQLite + sqlite-vec + FTS5 | `pw.binom.db:ksqlite:0.1.4` | Maven Central | +| Текстовый эмбеддинг (SigLIP2 int8, 768 dims) | `pw.binom.ai.embeddingtext:api:5` + `:siglip:5` | `http://nexus.xx/repository/caffeine/` | + +Модель (в артефакты не входит, скачивается отдельно): + +```bash +mkdir -p models/siglip2 +curl -fSL -o models/siglip2/text_model_int8.onnx http://static.binom.pw/models/siglip2/text_model_int8.onnx # 283M +curl -fSL -o models/siglip2/tokenizer.model http://static.binom.pw/models/siglip2/tokenizer.model # 4.2M +``` + +## Требования + +JDK 21. Индекс и поиск работают офлайн после скачивания модели. + +## Лицензия + +Apache-2.0. diff --git a/TASK.md b/TASK.md new file mode 100644 index 0000000..0bc6ec5 --- /dev/null +++ b/TASK.md @@ -0,0 +1,123 @@ +# TASK — memo (ТЗ для исполнителя) + +Исполнитель: OpenCode (`opencode run --auto`, модель `local/codding-big`). +Репозиторий: `git.binom.pw/subochev/memo`. Рабочая копия: `~/WORK/memo`. +Спека: `docs/SPEC.md`. Тест-план: `./TESTING.md`. + +## 0. Жёсткие границы + +1. **Стек фиксирован.** Kotlin/JVM, Gradle 8.14 (обёртка), JDK 21. Без Android-таргетов, без KMP-таргетов, + без новых внешних зависимостей кроме перечисленных в §2. Захотелось библиотеку — стоп, спросить. +2. **Только JVM.** Никаких `commonMain`. Ядро должно остаться переносимым по смыслу, но проект — JVM. +3. **Ни одного сетевого вызова в рантайме поиска.** Модель — локальные файлы, эмбеддинг — ONNX на CPU. +4. **Тесты пишет исполнитель, но приёмка — не его.** Заявление «тесты зелёные» не принимается: приёмка + идёт мутацией (см. `TESTING.md`). +5. **Не трогать чужие репозитории** (`text-embedding-kmp`, `ksqlite`, `mcp`). Только потребительская зависимость. +6. Не коммитить `models/`, `*.db`, `build/`, `~/notes`. + +## 1. Структура + +``` +memo/ +├── settings.gradle.kts # rootProject.name = "memo" +├── build.gradle.kts # общий ext: kotlinVersion, onnxVersion +├── gradle/wrapper/ # Gradle 8.14 (взять из ~/.gradle/wrapper/dists/gradle-8.14-bin) +├── memo-cli/ # main: CLI (index|search|status) +├── memo-core/ # ядро: чанкер, схема БД, индексация, поиск +├── memo-watch/ # демон: WatchService + debounce + reconcile +├── memo-mcp/ # MCP-сервер (stdio, JSON-RPC 2.0) +├── docs/SPEC.md +├── TASK.md TESTING.md README.md LICENSE +``` + +`memo-core` НЕ зависит от `memo-watch`, `memo-mcp`, `memo-cli`. Зависимость только в одну сторону. + +## 2. Зависимости (проверены 02.10.2026) + +```kotlin +// repositories: mavenCentral(), maven("http://nexus.xx/repository/caffeine/"){ isAllowInsecureProtocol = true } +implementation("pw.binom.db:ksqlite:0.1.4") // Maven Central; SQLite 3.53.4 + sqlite-vec 0.1.9 + FTS5 + JSON1 +implementation("pw.binom.ai.embeddingtext:api:5") // caffeine +runtimeOnly("pw.binom.ai.embeddingtext:siglip-jvm:5") // caffeine; тянет com.microsoft.onnxruntime:onnxruntime:1.16.0 +implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") // для MCP +``` + +Kotlin `2.4.10`, JVM target **17** (не 21 — совместимость с артефактами автора). + +## 3. Контракт эмбеддера (не изобретать — это готовый API) + +```kotlin +import pw.binom.voice.embeddingtext.TextEmbedding +import pw.binom.voice.embeddingtext.TextEmbeddingExtractor +import pw.binom.voice.embeddingtext.createSiglip2TextExtractor + +val ex: TextEmbeddingExtractor = createSiglip2TextExtractor( + modelPath = "/path/text_model_int8.onnx", // 283 MB + tokenizerPath = "/path/tokenizer.model", // 4.2 MB +) +val e: TextEmbedding = ex.embed("текст") // e.dim == 768, e.values: FloatArray +val cos: Float = e.cosineSimilarity(other) +ex.close() +``` + +- Размерность — **768**, константа в коде (`EMBEDDING_DIM = 768`), не вычисляется из вектора. +- Движок **дорогой в создании**: один экземпляр на процесс, создаётся лениво, `close()` на выходе. +- Модель в репозиторий не кладётся; путь — из конфига/аргумента (`models/siglip2/`). + +## 4. Заказ №1 (первый шаг, остальное — после приёмки) + +**Цель:** каркас сборки + доказательство, что ksqlite и эмбеддер работают на этой машине. + +1. Gradle-проект по §1, wrapper 8.14 из локального дистрибутива (`distributionUrl` можно оставить + `https://services.gradle.org/distributions/gradle-8.14-bin.zip` — dist уже в кэше). +2. `memo-core`: `Embedder.kt` — обёртка над `TextEmbeddingExtractor` (§3), ленивая инициализация, + один инстанс, `embed(text): FloatArray` размерности 768. +3. `memo-core`: `Db.kt` — открытие `index.db` через `SQLiteConnection.open(...)`, включение + `PRAGMA journal_mode=WAL`, `PRAGMA synchronous=NORMAL`, создание схемы из §5 спеки (chunks, chunks_fts, + chunks_vec float[768], files). +4. Тесты **jvmTest** (`memo-core/src/test/kotlin/...`) ровно четыре: + - `ksqliteSmoke`: открыть временную БД, создать таблицу, вставить, прочитать — значение совпало. + - `vec0KnnRoundTrip`: `CREATE VIRTUAL TABLE v USING vec0(embedding float[4])`, вставить 3 вектора, + `WHERE embedding MATCH ? ORDER BY distance LIMIT 1` вернул ожидаемый `rowid`. + - `fts5FindsRussianWord`: FTS5-таблица, вставка «внутренний домен траефик», поиск `MATCH 'траефик'` + находит строку (проверка unicode61 на кириллице). + - `embedderProduces768`: реальный эмбеддинг «привет мир» → `dim == 768`, без NaN; если файлов модели + нет — тест **падает с внятным сообщением** (не пропускается молча). +5. `TESTING.md` не менять (он приёмочный, написан заказчиком). + +**Критерий готовности заказа:** `./gradlew :memo-core:test` — все 4 теста зелёные; в отчёте +`memo-core/build/test-results/test/*.xml` видно 4 `testcase`. Коммит с осмысленным сообщением. + +**Запрещено в этом заказе:** писать чанкер, поиск, watcher, MCP, CLI. Только каркас и 4 теста. + +## 5. Схема индекса (одна база на коллекцию) + +```sql +CREATE TABLE files(path TEXT PRIMARY KEY, mtime REAL, size INTEGER, hash TEXT, indexed_at REAL); +CREATE TABLE 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 idx_chunks_path ON chunks(path); +CREATE VIRTUAL TABLE chunks_fts USING fts5(text, heading, tokenize='unicode61'); +CREATE VIRTUAL TABLE chunks_vec USING vec0(embedding float[768]); +``` + +- `chunks.id` = `rowid` для `chunks_vec` (вставлять `INSERT INTO chunks_vec(rowid, embedding) VALUES (?,?)`). +- `chunks_fts.rowid` = `chunks.id` (та же нумерация). +- Истина — файлы на диске. База удаляется `rm -rf <коллекция>/.memo` и пересобирается. + +## 6. Смежные заказы (после заказа №1, по одному) + +- **№2 Чанкер+индексация:** разбор markdown по заголовкам `#`..`###`, чанк ≤ 800 токенов (оценка: 4 символа ≈ 1 токен) с хвостовым перекрытием 15%; `Indexer.indexFile(path)` — хэш + mtime, пропуск неизменённых; удаление чанков исчезнувших файлов. +- **№3 Поиск:** `Searcher.search(root, query, k)`: FTS5 BM25 (`bm25(chunks_fts)`) + KNN по `chunks_vec` + RRF (`k=60`), возврат `path`, `line`, `heading`, `score`, `text` (обрезка 1200 символов). Плюс `stat`-догон: если файл изменился с момента индексации — переиндексировать перед поиском. +- **№4 CLI:** `memo index `, `memo search "" [--k N] [--json]`, `memo status `. `--json` обязателен — на нём стоит приёмка. +- **№5 Watcher:** `WatchService` (RECURSIVE), debounce 500 мс, очередь, reconcile полным сканом раз в 10–15 мин, устойчивость к удалению файла. +- **№6 MCP-сервер:** stdio, JSON-RPC 2.0, `initialize` / `tools/list` / `tools/call`; инструменты + `memo_search`, `memo_status`, `memo_reindex`. Свой минимальный, без `subochev/mcp`. + +## 7. Что считается провалом заказа + +- Агент вывел план текстом и завершился без изменений в файлах (`git status` пуст). +- Тесты зелёные, но при мутации боевого кода (см. `TESTING.md`) не падают. +- Изменения за пределами оговорённых файлов. +- Добавлены зависимости, которых нет в §2. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..ccda5a8 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,132 @@ +# TESTING — приёмочный тест-план + +Тестирует **заказчик** (Hermes) на реальном железе, а не исполнитель. Исполнитель пишет тесты; +приёмка идёт независимыми проверками ниже. Правило приёмки: **«зелёная сборка» ничего не доказывает, +пока мутация не валит нужный тест.** + +## 0. Подготовка стенда + +```bash +export MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 +mkdir -p "$MEMO_MODEL_DIR" +curl -fSL -o "$MEMO_MODEL_DIR/text_model_int8.onnx" http://static.binom.pw/models/siglip2/text_model_int8.onnx # 283M +curl -fSL -o "$MEMO_MODEL_DIR/tokenizer.model" http://static.binom.pw/models/siglip2/tokenizer.model # 4.2M + +# эталонный корпус: 12-15 заметок в 3 папках-коллекциях +export MEMO_ROOT=/root/WORK/memo-e2e +# infra/*.md (серверы, домены), work/jira/*.md, life/books/*.md — обычный markdown с заголовками +``` + +Пороговое требование: файлов не меньше 12 в трёх папках, в текстах есть и кириллица, и латиница, +и хотя бы по одному точному значению на папку (IP, номер тикета, год издания). + +## T1. Индексация и её повтор + +```bash +cd /root/WORK/memo && ./gradlew :memo-cli:installDist -q +CLI=./memo-cli/build/install/memo-cli/bin/memo +$CLI index "$MEMO_ROOT"; $CLI status "$MEMO_ROOT" +ls -l "$MEMO_ROOT"/*/.memo/index.db +$CLI index "$MEMO_ROOT" # второй прогон +``` + +Ожидается: в каждой папке появился `.memo/index.db`; второй прогон **не переэмбеддивает** ни одного файла +(в выводе 0 обновлённых, `status` показывает те же `indexed_at`). Провал: нет `.memo/index.db`; +второй прогон молча переиндексирует всё. + +## T2. Смысловой поиск (вектор работает) + +20 вопросов, сформулированных **другими словами**, чем в заметках. Ожидание: нужный раздел в top-5 +не реже 16 раз из 20 (recall@5 ≥ 0.8). Формулировки и ожидаемый файл — в `e2e/questions.tsv` (создаёт заказчик). + +```bash +$CLI search "$MEMO_ROOT/infra" "как пережить перезагрузку сервера, чтобы операция не умерла" --json +``` + +Провал: в результатах нет нужного файла; возвращаются чанки без текста (агент не сможет ими +воспользоваться); поиск идёт по всему корню вместо указанной папки. + +## T3. Лексический поиск (BM25 работает) + +Вопросы с точными значениями: IP-адрес, номер тикета, версия, год. Ожидание: точное совпадение найдено, +и именно через лексику. + +```bash +$CLI search "$MEMO_ROOT/infra" "76.132" --json # должен найти заметку с этим IP +$CLI search "$MEMO_ROOT/infra" "76.132" --mode lex --json +``` + +Провал: находит «похожее по смыслу», но не строку с точным значением; `--mode lex` даёт тот же +результат, что `--mode vec` (значит, режимы не разделены). + +## T4. Живая правка файла + +```bash +echo "\n## Свежий раздел\nуникальное_слово_дзынь_47" >> "$MEMO_ROOT/infra/hosts.md" +sleep 1 +$CLI search "$MEMO_ROOT/infra" "уникальное слово дзынь" --json # ≤1 с после правки +echo "## Ещё раздел\nвторое_слово_бамс_93" >> "$MEMO_ROOT/infra/hosts.md" +$CLI search "$MEMO_ROOT/infra" "второе слово бамс" --json # БЕЗ sleep: тот же вызов +``` + +Ожидается: первая правка подхвачена watcher'ом ≤1 с; вторая видна **в том же вызове поиска** +(stat-догон до поиска). Провал: поиск не видит свежую правку; `database is locked` при одновременной +записи watcher'ом и чтении (признак: WAL не включён). + +## T5. MCP-сервер по протоколу (не по самоотчёту) + +Инструменты подключаются к Hermes **только в новой сессии**, поэтому проверяем протоколом: + +```bash +python3 scripts/mcp_probe.py --cmd "./memo-mcp/build/install/memo-mcp/bin/memo-mcp" \ + --call memo_search --args '{"path":"'"$MEMO_ROOT"'/infra","query":"домены","k":3}' +``` + +Скрипт-зонд: пишет `initialize`, `notifications/initialized`, `tools/list`, `tools/call` в stdin процесса, +читает JSON-RPC-ответы. Ожидается: `tools/list` вернул 3 инструмента; `tools/call` — непустой +`content[0].text` с результатами; `isError` отсутствует. + +**Тестовые ручки (то же без транспорта):** + +```bash +$CLI mcp-probe --tool memo_search --args '{"path":"'"$MEMO_ROOT"'/infra","query":"домены"}' +# плюс режим "одного вызова": разобрать строку запроса и вернуть ответ, не поднимая stdio +``` + +Провал: сервер печатает что-то в stdout помимо JSON-RPC (ломает клиента); на `tools/call` отвечает +`-32601`; результат — пустая строка. + +## T6. Офлайн + +```bash +# отключаем внешний интерфейс у процесса: без сети индексация и поиск обязаны работать +$CLI index "$MEMO_ROOT" && $CLI search "$MEMO_ROOT" "любой вопрос" --json +``` + +Провал: любой сетевой вызов в рантайме (модель качается при каждом старте, эмбеддинг уходит в API). + +## M. Мутационная приёмка (обязательна) + +Для каждого пункта: сломать ровно одно место в боевом коде (python-replace с `assert old in s`), +прогнать `./gradlew cleanTest test`, **сверить XML-отчёты и их mtime**, затем `git checkout -- <файл>`. + +| Мутация | Какой тест обязан упасть | +|---|---| +| Чанкер: резать по 4000 символов, игнорируя заголовки | T2 (recall), тест чанкера | +| Поиск: `mode=lex` уходит в вектор | T3, тест разделения режимов | +| RRF: убрать слияние (только вектор) | T3 | +| Индексация: игнорировать `mtime`/хэш, всегда переиндексировать | T1 (второй прогон не no-op) | +| Watcher: убрать вызов `pollEvents`/регистрацию | T4 (первая часть) | +| Поиск: убрать stat-догон перед поиском | T4 (вторая часть) | +| MCP: `tools/list` возвращает пустой список | T5 | +| Схема: `chunks_vec` дименсия 384 вместо 768 | T3-KNN, тест round-trip | + +Мутация, которая **не валит ни одного теста**, означает дыру в покрытии: тест дописать, находку — в отчёт. + +## Признаки провала исполнителя + +- Вывод — «план реализации» текстом, `git status` пуст, exit 0. +- Тесты зелёные, но мутации не валят (тесты написаны под код, а не под поведение). +- Изменён боевой код вне заказанного списка файлов (`git diff --stat` не совпал с заказом). +- Появились зависимости, которых нет в `TASK.md` §2. +- Файлы модели или `.db` попали в коммит. diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000..112ed5f --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,239 @@ +# memo — заметки как миллион файлов, каждый со своей семантикой + +Спека v0.1 (только проект, кода нет). Автор идеи — пользователь; форма зафиксирована 02.10.2026. + +## 0. Идея в одном абзаце + +Обычный провайдер памяти в Hermes — **один** стор на весь профиль (MEMORY.md, holographic, mem0). +Здесь форма другая: **markdown-файлы лежат в дереве папок и являются истиной**, а рядом с каждой +папкой-темой лежит пересобираемый векторный индекс. Коллекций — сколько угодно, они не мешают друг +другу, и «спрашивают про X → ищем в X» решается детерминированной маршрутизацией, а не надеждой на +автопоиск по всему корпусу. Это не память (маленькая и всегда в промпте) и не файловая система +(слепая к смыслу) — это **библиотека со смысловым индексом**. + +## 1. Границы: что это и чем не является + +- НЕ замена MEMORY.md / USER.md. Курируемая память по-прежнему маленькая и всегда в промпте. +- НЕ граф знаний, НЕ авто-суммаризация, НЕ «агент сам решает, что запомнить». +- НЕ облако: модель эмбеддингов локальная (CPU/ONNX), сеть не нужна ни при индексации, ни при поиске. +- НЕ альтернатива grep: файлы остаются человекочитаемыми, любой поиск по тексту работает и без индекса. + +## 2. Правило номер один + +**Markdown — истина, `.db` — кэш, который не жалко.** + +Следствие: индекс можно удалить целиком и он пересоберётся. Ничего, что существует только в базе, +быть не должно. Любая миграция схемы = `rm -rf` + ленивая пересборка при первом обращении к коллекции. + +## 3. Модель данных + +``` +/ # корень библиотеки, напр. ~/notes/memo + _registry.yaml # реестр коллекций: имя → описание, путь, эмбеддер, счётчики + work/ + jira/ + _meta.yaml # описание коллекции, теги, модель/димы, версия чанкера + notes.md # основная заметка (markdown, истина) + pitfalls.md + .memo/ + index.db # sqlite: FTS5 + vec0, пересобираемо + state.json # версия чанкера/модели, sweep-время + life/ + books/ + _meta.yaml + iphuck10.md + .memo/index.db + inbox.md # неразобранное; коллекция НЕ индексируется +``` + +Правила: + +1. **Коллекция = папка** (не файл). Один sqlite на заметку — запрещено: inode, соединения, фрагментация. +2. **Заметка = `.md`-файл** внутри коллекции. Ориентир: 10–300 файлов на коллекцию, до ~10⁵ коллекций в теории. +3. **Максимум ~2 уровня вложенности** папок. Глубже — значит тема слишком крупная, её надо дробить. +4. Черновики — в `inbox.md` в корне: не индексируются, агент за них не отвечает. +5. `_meta.yaml` коллекции обязателен: без описания коллекция невидима для маршрутизатора. + +```yaml +# _meta.yaml +name: work/jira +description: "Корпоративная Jira: тикеты, флоу, доступы, грабли MCP" +tags: [corp, jira, otp] +embedder: intfloat/multilingual-e5-small # 384 dims +chunker: headings-v1 # меняешь — индекс пересобирается +``` + +## 4. Формат заметки + +- Границы чанков = **markdown-заголовки** (`##`/`###`); слишком длинный раздел режется по ~800 токенов + с перекрытием 15%. Никогда не резать по символам «вслепую». +- Опциональный frontmatter: `id`, `tags`, `updated`. Обязателен только `updated` (руками или скриптом). +- Имена файлов — ASCII-slug, без пробелов: `postgres-restore.md`, не `про восстановление.md`. +- В текст заметки полезно писать **явные синонимы и аббревиатуры** («PBR (policy based routing)») — + это бесплатно лечит промахи чистого вектора на жаргоне. + +## 5. Индекс (одна база на коллекцию) + +```sql +CREATE TABLE chunks( + id INTEGER PRIMARY KEY, path TEXT, heading TEXT, line INTEGER, ord INTEGER, + text TEXT, h1 TEXT, mtime REAL, hash TEXT); +CREATE VIRTUAL TABLE chunks_fts USING fts5(text, heading, tokenize='unicode61'); +CREATE VIRTUAL TABLE chunks_vec USING vec0(embedding float[768]); -- sqlite-vec, dim по модели +``` + +- **Гибридный поиск:** BM25 (FTS5) + вектор, слияние RRF (`k=60`). Чистый вектор обязателен… и + недостаточен: числа, аббревиатуры, имена файлов находятся только лексикой. +- **Инкремент:** сравниваем `(path, mtime, size)` со таблицей `files`; переэмбеддиваем только + изменившиеся файлы. Полная пересборка коллекции — по требованию. +- Версия чанкера/модели в `.memo/state.json`: не совпала — коллекция переиндексируется целиком. +- Сортировка результата: RRF-скор, затем свежесть (`mtime`) как тайбрейкер, чтобы свежая заметка + выигрывала у устаревшей при равном совпадении. + +## 6. Эмбеддер + +- База: `intfloat/multilingual-e5-small` (384 dims, ~118M параметров, ONNX/CPU через fastembed). + Мультиязычность не опция: `nomic-embed-text` на русском молча проигрывает. +- Префиксы e5 обязательны: документ — `"passage: …"`, запрос — `"query: …"`; нормализация L2. +- Апгрейд до `bge-m3` (1024) — если качество на реальных вопросах не устроит; тогда переиндекс всех коллекций. +- **Модель грузится один раз** на процесс, лениво, при первом обращении. Это аргумент за + долгоживущий процесс (MCP-сервер), а не за CLI-утилиту. + +## 7. Маршрутизация (самое узкое место) + +Проблема не в поиске, а в выборе **где** искать. Миллион коллекций в промпт не влезет. + +- До ~50 коллекций: плоский список `name — description` целиком в инструмент (или промпт). +- До ~5 000: дерево имён (`work/*`, `work/jira/*`) — фильтр по префиксу + поиск по описаниям. +- Больше: отдельный роутер (BM25/эмбеддинг по `description` коллекций) → кандидаты → агент выбирает. +- Всегда доступен режим `scope="*"`: искать по всем индексам, но это дорого и только осознанно. + +## 8. Интерфейс: инструмент-тень, а не способ записи (v0.2) + +Решение v0.2 (идея пользователя, 02.10.2026): **агент пишет и читает только markdown своими обычными +средствами** (`write_file` / `patch` / `read_file`). Индекс никто через API не наполняет — он +наблюдает за файловой системой и догоняет. Инструмент поиска — только чтение, для агента он такой же +обычный, как grep. Источник правды один, и это файл. + +| Инструмент | Аргументы | Возврат | +|---|---|---| +| `memo_search` | `path` (файл ИЛИ папка), `query`, `k=8`, `mode=hybrid\|lex\|vec` | `path:line`, `heading`, `score`, текст чанка (~1 200 символов) | +| `memo_status` | `path` (опц.) | что проиндексировано, когда, отстаёт ли индекс от mtime | +| `memo_reindex` | `path`, `full=false` | аварийная пересборка, если watcher ослеп | + +- `path` принимает **папку** — поиск рекурсивно по коллекции. Типовой вопрос звучит «где в этой папке + про X», а не «в этой заметке». +- `memo_write` из v0.1 удалён: две дороги записи = два источника правды. +- Возврат всегда с `path:line` — агент дочитывает нужное `read_file(offset)`, а не открывает файл целиком. + +## 8-bis. Наблюдатель (watcher) + +- Отдельный процесс: inotify (`watchdog`) → debounce 500 мс → `stat` + `hash` → переэмбеддинг только изменившегося. +- **Событие — триггер, не истина.** Решение «изменилось ли» принимает хэш: редакторы бросают пачку + событий на одну запись (vim `4913`, atomic rename в VS Code и Obsidian). +- **Реконсиляция обязательна:** inotify слепнет на FUSE/sshfs/SMB и теряет события при переполнении + очереди. Раз в 10–15 мин — скан `(path, mtime, size)` по коллекции; холодный поиск тоже форсит перескан. +- **Два процесса над одним sqlite → WAL** (`journal_mode=WAL`), иначе «database is locked» на живой записи. +- Разделение: watcher пишет, MCP-сервер читает. stdio-MCP живёт только внутри сессии Hermes, поэтому + фоновая индексация в нём невозможна — watcher это **отдельный сервис** (`/opt/memo/`, systemd или + podman-compose), MCP подключается к его базе. Альтернатива «всё в одном» — streamable-http демон + со встроенным потоком-watcher. +- **Задержки быть не должно:** `memo_search` перед поиском делает быстрый `stat`+`hash` по целевому + пути и догоняет разницу сам. Тогда «агент записал и сразу ищет» находит правку сразу, а watcher + остаётся страховкой для правок, сделанных человеком и другими инструментами. +- Остаётся от v0.1: **карта тем** (раздел 9). Прозрачность файлового слоя не отменяет вопрос «в какой + папке искать» — её даёт скилл, а не инструмент. + +## 9. Правила записи (скилл, а не код) + +Файлы гниют не от поиска, а от записи «куда попало». Нужен тонкий слой дисциплины — скилл вида: + +``` +Тема → коллекция +инфра/серверы → infra +работа/Jira → work/jira +книги → life/books +личное → life/misc +нет темы → inbox.md (не индексируется) +``` + +- Новая тема = новая папка + `_meta.yaml` + строчка в скилле. Один раз. +- Дедуп: перед записью — `memo_search` по этой же коллекции; хэш точной копии не пишем дважды. +- Заметка в двух местах — запрещено; при переносе файла индекс коллекции переиндексируется, история mtime сохраняется. + +## 10. Что проверяем до того, как что-то оптимизировать + +1. 20–30 **реальных** вопросов к своим заметкам (не «тестовых») — вручную смотрим recall@5. +2. Отдельно — вопросы на точные значения: числа, версии, имена хостов, аббревиатуры (тут решает BM25). +3. Отдельно — вопросы «смысловые», другими словами, чем в заметке (тут решает вектор). +4. Замер: холодная индексация коллекции на N файлов, горячий поиск. Целевые ориентиры: поиск < 100 мс. + +Если recall@5 на реальных вопросах ниже ~0.8 — виноват не индекс, а формулировки заметок и slug'и. + +## 11. Открытые развилки (решать после прототипа, не сейчас) + +- Корень библиотеки: `~/notes/memo` или внутри конкретного репо? +- Один MCP-сервер на библиотеку или свой процесс на коллекцию? (Сейчас: один на всё, коллекции — аргумент.) +- Нужен ли `memo_gc` (перемещение/удаление) или файлы двигает только человек? +- Автоматический захват сессий в `inbox.md` (крон) — или только ручные записи агента. + +## 12. Чем отличается от готового в Hermes + +| | провайдер Hermes (holographic / mem0) | memo | +|---|---|---| +| Стор | один на профиль | много папок, по теме | +| Истина | база данных | markdown на диске | +| Пересборка | миграция | удалить `.db` | +| Гранулярность | факт/эпизод | заметка/раздел | +| Маршрутизация | не нужна | требуется (реестр + scope) | + +Вывод: для Hermes-агента это не замена провайдера, а дополнение «библиотека»; для своего агента — +самостоятельный сервис, подключаемый по MCP. + +## 13. Портатив на Kotlin — из чего собирается (проверено 02.10.2026) + +Разведка нашла всё, о чём говорил пользователь. Своего кода нужно мало. + +| Нужда | Что берём | Где лежит | Координаты / состояние | +|---|---|---|---| +| SQLite + **вектор** + FTS5 | `caffeine-mgn/ksqlite` (наш, GitHub) | GitHub `caffeine-mgn/ksqlite`, README.ru есть | Maven Central: `pw.binom.db:ksqlite:0.1.4`. Внутри амальгама SQLite 3.53.4 + **sqlite-vec 0.1.9** (авто-регистрация), FTS3/4/5, RTREE, JSON1. JVM (JNI `.so`) + linux/mingw/Android/a… klib | +| Текстовый эмбеддинг | `subochev/text-embedding-kmp` — SigLIP2 int8, **768 dims** | git.binom.pw | caffeine: `pw.binom.ai.embeddingtext:api` / `:siglip` — актуальная **5**. Модель с `http://static.binom.pw/models/siglip2/` | +| Текст + vision в одном | `subochev/embedder-kmp` | git.binom.pw | caffeine: `pw.binom.embedder:embedder-api-text` / `-impl-text` (и vision) — **1**. Модели в артефакт не кладутся, пути отдаёт потребитель | +| Qdrant (если б понадобился) | `subochev/qdrant` (core/ktor/strong) | git.binom.pw | в caffeine **не выложен**, и это HTTP-клиент к внешнему серверу → при embedded-схеме не нужен | +| MCP (stdio + http) | `subochev/mcp` (server-shared / server-cli / server-http, `AITool`) | git.binom.pw | **не опубликован** (наружу только `shared`) → либо submodule/исходники, либо свой MCP на Ktor | +| Слежение за файлами | **готового нет** | — | JVM: `java.nio.file.WatchService` (inotify под капотом), register RECURSIVE + debounce + reconcile — пишем сами, ~150 строк | + +Вывод разведки: «клёвая библиотека для векторного поиска» = **sqlite-vec внутри ksqlite**, а не +отдельный Qdrant. Это ровно та схема, что в разделе 5 спеки — то есть зависимость одна: +`pw.binom.db:ksqlite` + наш эмбеддер. + +### Модули (JVM-first, один Gradle-проект `memo`) + +1. `memo-core` — чанкер по заголовкам, хэши/`files`-таблица, схема БД через ksqlite, + инкрементальная переиндексация, гибридный поиск (FTS5 BM25 + `vec0` KNN + RRF). +2. `memo-watch` — демон: WatchService на корень, debounce 500 мс, очередь, reconcile раз в 10–15 мин, WAL. +3. `memo-mcp` — MCP stdio+http: `memo_search` / `memo_status` / `memo_reindex`. +4. `memo-cli` — `memo index|search|status` для ручных проверок и приёмки без агента. + +`core` не знает ни про MCP, ни про watcher — это драйверы. Так ядро потом переиспользуется +на Android (`-android`/`-jvm` артефакты уже есть у обеих библиотек). + +### Что придётся написать руками + +Чанкер, RRF-слияние, watcher с реконсиляцией, схему/миграцию, MCP-инструменты. Ориентир суммарно: +**600–900 строк Kotlin**. Всё остальное — готовые артефакты из таблицы. + +### Приёмка (то, что пойдёт в ТЗ для opencode) + +1. `memo index ~/notes/memo` создаёт `~/notes/memo/<тема>/.memo/index.db`, повторный прогон — no-op. +2. `memo search "<смысловой вопрос>"` находит нужный раздел (recall@5 на 20 реальных вопросах). +3. Вопрос с числом/аббревиатурой находит точное совпадение (проверка BM25, не вектора). +4. Файл, поправленный руками (`echo >> note.md`), виден в поиске ≤1 с; правка, сделанная сразу + перед поиском, видна **в том же вызове** (stat-догон). +5. MCP-сервер проходит протокольный зонд: `tools/list` + `tools/call` с непустым результатом. +6. Без сети: эмбеддинг и поиск работают при выключенном внешнем интерфейсе. + +### Развилка, зафиксированная по умолчанию + +Берём **JVM** (демон + watcher + CLI + MCP — серверная история, KMP-таргеты тут ничего не дают). +Android-таргеты — вторым шагом и без watcher (там индексация по запросу): библиотеки это позволяют. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ba2b9ba --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionPath=wrapper/dists +networkTimeout=10000 +validateDistributionUrl=true +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/scripts/mcp_probe.py b/scripts/mcp_probe.py new file mode 100644 index 0000000..102ca59 --- /dev/null +++ b/scripts/mcp_probe.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Протокольный зонд MCP-сервера (stdio, JSON-RPC 2.0). + +Запускает сервер как настоящий MCP-клиент и проверяет, что он отвечает по протоколу, +а не «выглядит работающим». Использование: + + python3 scripts/mcp_probe.py --cmd ./memo-mcp/build/install/memo-mcp/bin/memo-mcp + python3 scripts/mcp_probe.py --cmd ./... --call memo_search \ + --args '{"path":"/root/WORK/memo-e2e/infra","query":"домены","k":3}' + +Выход 0 — протокол в порядке; 1 — есть ошибки (печатаются). +""" + +import argparse +import json +import shlex +import subprocess +import sys + + +def rpc(proc, method, params=None, notify=False, rid=None): + msg = {"jsonrpc": "2.0", "method": method} + if params is not None: + msg["params"] = params + if not notify: + msg["id"] = rid if rid is not None else 1 + proc.stdin.write(json.dumps(msg) + "\n") + proc.stdin.flush() + if notify: + return None + line = proc.stdout.readline() + if not line: + return None + try: + return json.loads(line) + except json.JSONDecodeError: + return {"_raw": line.strip()} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--cmd", required=True, help="команда запуска MCP-сервера (stdio)") + ap.add_argument("--call", help="имя инструмента для tools/call") + ap.add_argument("--args", default="{}", help="JSON-аргументы инструмента") + ap.add_argument("--timeout", type=float, default=60.0) + a = ap.parse_args() + + errors: list[str] = [] + proc = subprocess.Popen( + shlex.split(a.cmd), + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1, + ) + try: + init = rpc(proc, "initialize", { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "mcp_probe", "version": "1"}, + }, rid=1) + if not init or "result" not in init: + errors.append(f"initialize: нет result (ответ {init})") + else: + info = init["result"].get("serverInfo", {}) + print(f"OK initialize: {info.get('name')} {info.get('version')}") + + rpc(proc, "notifications/initialized", {}, notify=True) + + tools = rpc(proc, "tools/list", {}, rid=2) + names: list[str] = [] + if not tools or "result" not in tools: + errors.append(f"tools/list: нет result (ответ {tools})") + else: + names = [t["name"] for t in tools["result"].get("tools", [])] + if not names: + errors.append("tools/list: пустой список инструментов") + else: + print(f"OK tools/list: {len(names)} -> {', '.join(names)}") + + if a.call: + if names and a.call not in names: + errors.append(f"tools/call: инструмента {a.call} нет в tools/list") + else: + res = rpc(proc, "tools/call", + {"name": a.call, "arguments": json.loads(a.args)}, rid=3) + if not res or "result" not in res: + errors.append(f"tools/call: нет result (ответ {res})") + elif res["result"].get("isError"): + errors.append(f"tools/call: isError=true ({res['result']})") + else: + content = res["result"].get("content") or [] + text = content[0].get("text", "") if content else "" + if not text.strip(): + errors.append("tools/call: пустой content[0].text") + else: + print(f"OK tools/call {a.call}: {len(text)} символов") + print("---- первые 600 символов ----") + print(text[:600]) + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + err = proc.stderr.read() + if err.strip(): + print("---- stderr сервера ----") + print(err[:1000]) + + if errors: + print("\nОШИБКИ:") + for e in errors: + print(" -", e) + return 1 + print("\nПРОТОКОЛ В ПОРЯДКЕ") + return 0 + + +if __name__ == "__main__": + sys.exit(main())