Files

9.3 KiB

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)

// 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)

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. Схема индекса (одна база на коллекцию)

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.