124 lines
9.3 KiB
Markdown
124 lines
9.3 KiB
Markdown
# 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.
|