memo: спека, ТЗ, тест-план, MCP-зонд, gradle wrapper

This commit is contained in:
Hermes Agent
2026-10-02 00:59:33 +03:00
commit cefc17d782
10 changed files with 1077 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
build/
.gradle/
models/
*.db
*.db-wal
*.db-shm
.memo/
.idea/
*.iml
local.properties
+202
View File
@@ -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.
+60
View File
@@ -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.
+123
View File
@@ -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 <path>`, `memo search <path> "<query>" [--k N] [--json]`, `memo status <path>`. `--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.
+132
View File
@@ -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` попали в коммит.
+239
View File
@@ -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. Модель данных
```
<root>/ # корень библиотеки, напр. ~/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<T>`) | 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 (там индексация по запросу): библиотеки это позволяют.
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+185
View File
@@ -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" "$@"
+119
View File
@@ -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())