memo: спека, ТЗ, тест-план, MCP-зонд, gradle wrapper
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
|||||||
|
build/
|
||||||
|
.gradle/
|
||||||
|
models/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.memo/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
local.properties
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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 (там индексация по запросу): библиотеки это позволяют.
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user