core: каркас сборки, embedder, схема БД, 4 теста

This commit is contained in:
2026-10-02 01:06:06 +03:00
parent cefc17d782
commit 700508dc8e
13 changed files with 585 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
plugins {
kotlin("jvm") version "2.4.10" apply false
}
ext {
set("kotlinVersion", "2.4.10")
set("onnxVersion", "1.16.0")
}
allprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
tasks.withType<JavaCompile>().configureEach {
sourceCompatibility = "17"
targetCompatibility = "17"
}
}
+56
View File
@@ -0,0 +1,56 @@
Проект: Kotlin/JVM, Gradle 8.14 (wrapper уже в репо), JDK 21. Рабочая копия: /root/WORK/memo.
Задача — ТОЛЬКО каркас сборки и четыре теста. НЕ пиши чанкер, поиск, watcher, MCP, CLI-логику.
1. settings.gradle.kts: rootProject.name = "memo"; include(":memo-core", ":memo-cli", ":memo-watch", ":memo-mcp").
В dependencyResolutionManagement { repositories { mavenCentral(); maven(url = "http://nexus.xx/repository/caffeine/") { isAllowInsecureProtocol = true } } }.
2. build.gradle.kts (корень): plugins { kotlin("jvm") version "2.4.10" apply false };
ext: kotlinVersion "2.4.10", onnxVersion "1.16.0"; во всех модулях jvmTarget 17.
3. memo-core/build.gradle.kts: plugins { kotlin("jvm") }; зависимости:
implementation("pw.binom.db:ksqlite:0.1.4")
implementation("pw.binom.ai.embeddingtext:api:5")
runtimeOnly("pw.binom.ai.embeddingtext:siglip-jvm:5")
testImplementation(kotlin("test"))
Тестовый фреймворк не менять (дефолтный JUnit4), useJUnitPlatform() НЕ добавлять.
4. Модули memo-cli, memo-watch, memo-mcp — только build.gradle.kts с plugins { kotlin("jvm") } и пустой src. Логику не писать.
5. memo-core/src/main/kotlin/memo/core/Embedder.kt:
class Embedder(private val modelPath: String, private val tokenizerPath: String) : AutoCloseable
- лениво, при первом embed, создаёт pw.binom.voice.embeddingtext.createSiglip2TextExtractor(modelPath, tokenizerPath)
- fun embed(text: String): FloatArray — возвращает ex.embed(text).values; если размерность != 768, бросить IllegalStateException с фактической размерностью
- override fun close() — идемпотентно, закрывает экстрактор
Импорты: pw.binom.voice.embeddingtext.createSiglip2TextExtractor, pw.binom.voice.embeddingtext.TextEmbeddingExtractor
6. memo-core/src/main/kotlin/memo/core/Db.kt:
class Db(val path: String) : AutoCloseable
- при создании открывает SQLiteConnection.open(path) (pw.binom.db.ksqlite.SQLiteConnection)
- сразу выполняет: "PRAGMA journal_mode=WAL" и "PRAGMA synchronous=NORMAL"
- fun init() — идемпотентно создаёт схему (ровно эти SQL):
CREATE TABLE IF NOT EXISTS files(path TEXT PRIMARY KEY, mtime REAL, size INTEGER, hash TEXT, indexed_at REAL);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_chunks_path ON chunks(path);
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, heading, tokenize='unicode61');
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[768]);
- val conn даёт доступ к соединению
- close() закрывает соединение
7. memo-core/src/test/kotlin/memo/core/ — ровно 4 теста, отдельные @Test функции, имена ровно такие:
- ksqliteSmoke — временный файл БД, CREATE TABLE t(a INTEGER), INSERT 42, SELECT, значение совпало.
- vec0KnnRoundTrip — CREATE VIRTUAL TABLE v USING vec0(embedding float[4]); вставить 3 вектора с rowid 1, 2, 3 (привязка FloatArray: stmt.bind(1, id); stmt.bind(2, floatArrayOf(...)));
запрос "SELECT rowid FROM v WHERE embedding MATCH ? ORDER BY distance LIMIT 1" с вектором, ближайшим к rowid=2, обязан вернуть 2.
- fts5FindsCyrillic — FTS5-таблица, вставка строки "внутренний домен траефик", поиск MATCH 'траефик' находит ровно 1 строку.
- embedderProduces768 — путь к модели: env MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2; если text_model_int8.onnx или tokenizer.model отсутствуют — тест падает с сообщением "модель не найдена: <путь>";
иначе embed("привет мир") → dim == 768 и ни одного NaN.
8. Прогони ./gradlew :memo-core:test и добейся, чтобы все 4 теста были зелёными (файлы модели уже скачиваются в models/siglip2, дождись их; если их ещё нет — тест embedderProduces768 может упасть, тогда перезапусти прогон позже).
В конце: git add -A && git commit -m "core: каркас сборки, embedder, схема БД, 4 теста".
СТРОГИЕ ЗАПРЕТЫ:
- НЕ выводи план текстом. Сразу правь файлы инструментами. Задача не выполнена, пока файлы не изменены и ./gradlew :memo-core:test не зелёный.
- НЕ добавляй зависимости, которых нет в списке выше.
- НЕ трогай docs/SPEC.md, TASK.md, TESTING.md, README.md, LICENSE, scripts/, .gitignore.
- НЕ создавай Android/KMP-модули и platform-таргеты.
- НЕ коммить папку models/ и файлы *.db.
+26
View File
@@ -0,0 +1,26 @@
# Вопросы приёмки T2/T3. Формат: question<TAB>path<TAB>expected_file<TAB>kind
# kind: sem — смысловой (должен вытянуть вектор), lex — точное значение (должен вытянуть BM25)
# Ожидаемый файл должен попасть в top-5. Порог: recall@5 >= 16/20 по sem, 3/3 по lex.
как понять, почему сборка съедает место на диске infra work/jira/ci.md sem
чем разметить диск, если привычной утилиты нет infra infra/servers.md sem
на какой карте разрешено ставить опыты infra infra/servers.md sem
что служит основой для разрешения имён в сети infra infra/domains.md sem
куда конвейер отдаёт готовые артефакты infra infra/hosts.md sem
как корпоративные имена ходят наружу infra infra/hosts.md sem
восстановление прошло мгновенно, а данных не видно infra infra/backup.md sem
куда складываются зеркала для очков infra infra/backup.md sem
где лежат ключи доступа к корпоративным ресурсам work/jira work/jira/access.md sem
как оформляют задачу, пришедшую от робота work/jira work/jira/flow.md sem
после обновления пропали настройки выпуска work/jira work/jira/pitfalls.md sem
какой номер тикета закрыли после починки тестов work/jira work/jira/flow.md sem
кто ведёт рассказ в романе life/books life/books/iphuck.md sem
в каком году вышла книга life/books life/books/iphuck.md sem
где чинят карточку, если подтянулось чужое издание life/books life/books/audiobooks.md sem
до какого правителя доходит первый том life/books life/books/history.md sem
как я фиксирую цитаты из прочитанного life/books life/books/notes.md sem
чем слушают книги в очках life/books life/books/audiobooks.md sem
что запрещено трогать на машине с двумя картами life/books infra/servers.md sem
где живёт хранилище контейнерных образов life/books infra/hosts.md sem
76.132 infra infra/hosts.md lex
TEST-4173 work/jira work/jira/flow.md lex
192.168.88.35:8080 infra infra/hosts.md lex
1 # Вопросы приёмки T2/T3. Формат: question<TAB>path<TAB>expected_file<TAB>kind
2 # kind: sem — смысловой (должен вытянуть вектор), lex — точное значение (должен вытянуть BM25)
3 # Ожидаемый файл должен попасть в top-5. Порог: recall@5 >= 16/20 по sem, 3/3 по lex.
4 как понять, почему сборка съедает место на диске infra work/jira/ci.md sem
5 чем разметить диск, если привычной утилиты нет infra infra/servers.md sem
6 на какой карте разрешено ставить опыты infra infra/servers.md sem
7 что служит основой для разрешения имён в сети infra infra/domains.md sem
8 куда конвейер отдаёт готовые артефакты infra infra/hosts.md sem
9 как корпоративные имена ходят наружу infra infra/hosts.md sem
10 восстановление прошло мгновенно, а данных не видно infra infra/backup.md sem
11 куда складываются зеркала для очков infra infra/backup.md sem
12 где лежат ключи доступа к корпоративным ресурсам work/jira work/jira/access.md sem
13 как оформляют задачу, пришедшую от робота work/jira work/jira/flow.md sem
14 после обновления пропали настройки выпуска work/jira work/jira/pitfalls.md sem
15 какой номер тикета закрыли после починки тестов work/jira work/jira/flow.md sem
16 кто ведёт рассказ в романе life/books life/books/iphuck.md sem
17 в каком году вышла книга life/books life/books/iphuck.md sem
18 где чинят карточку, если подтянулось чужое издание life/books life/books/audiobooks.md sem
19 до какого правителя доходит первый том life/books life/books/history.md sem
20 как я фиксирую цитаты из прочитанного life/books life/books/notes.md sem
21 чем слушают книги в очках life/books life/books/audiobooks.md sem
22 что запрещено трогать на машине с двумя картами life/books infra/servers.md sem
23 где живёт хранилище контейнерных образов life/books infra/hosts.md sem
24 76.132 infra infra/hosts.md lex
25 TEST-4173 work/jira work/jira/flow.md lex
26 192.168.88.35:8080 infra infra/hosts.md lex
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Приёмка recall@5 для memo (TESTING.md T2/T3).
Читает e2e/questions.tsv, гоняет CLI и считает, попал ли ожидаемый файл в top-5.
Использование:
python3 e2e/run_e2e.py --cli ./memo-cli/build/install/memo-cli/bin/memo \
--root /root/WORK/memo-e2e [--k 5] [--min-sem-recall 0.8]
"""
import argparse
import json
import shlex
import subprocess
import sys
def normalize(p: str, root: str) -> str:
p = p.replace("\\", "/")
root = root.rstrip("/")
if p.startswith(root + "/"):
p = p[len(root) + 1:]
return p.lstrip("./")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--cli", required=True)
ap.add_argument("--root", required=True)
ap.add_argument("--questions", default="e2e/questions.tsv")
ap.add_argument("--k", type=int, default=5)
ap.add_argument("--min-sem-recall", type=float, default=0.8)
a = ap.parse_args()
rows = []
for line in open(a.questions, encoding="utf-8"):
line = line.rstrip("\n")
if not line or line.startswith("#"):
continue
q, scope, expected, kind = line.split("\t")
rows.append((q, scope, expected, kind))
hits = {"sem": 0, "lex": 0}
total = {"sem": 0, "lex": 0}
fails = []
for q, scope, expected, kind in rows:
total[kind] += 1
cmd = shlex.split(a.cli) + ["search", f"{a.root}/{scope}", q, "--k", str(a.k), "--json"]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
except subprocess.TimeoutExpired:
fails.append((q, kind, "TIMEOUT", []))
continue
if out.returncode != 0:
fails.append((q, kind, f"exit {out.returncode}: {out.stderr.strip()[:200]}", []))
continue
try:
data = json.loads(out.stdout)
except json.JSONDecodeError:
fails.append((q, kind, f"не JSON: {out.stdout[:200]}", []))
continue
items = data if isinstance(data, list) else data.get("results", [])
got = [normalize(str(it.get("path", "")), a.root) for it in items][:a.k]
if normalize(expected, a.root) in got:
hits[kind] += 1
else:
fails.append((q, kind, "не найдено в top-%d" % a.k, got))
sem_total = total["sem"] or 1
lex_total = total["lex"] or 1
sem_recall = hits["sem"] / sem_total
lex_recall = hits["lex"] / lex_total
print(f"sem: {hits['sem']}/{sem_total} = {sem_recall:.2f} (порог {a.min_sem_recall})")
print(f"lex: {hits['lex']}/{lex_total} = {lex_recall:.2f} (порог 1.00)")
if fails:
print("\nпромахи:")
for q, kind, why, got in fails:
print(f" [{kind}] {q} -> {why}")
if got:
print(f" получено: {got}")
ok = sem_recall >= a.min_sem_recall and lex_recall >= 1.0
print("\nИТОГ:", "ПРОЙДЕНО" if ok else "ПРОВАЛ")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -0,0 +1,3 @@
plugins {
kotlin("jvm")
}
+11
View File
@@ -0,0 +1,11 @@
plugins {
kotlin("jvm")
}
dependencies {
implementation("pw.binom.db:ksqlite:0.1.4")
implementation("pw.binom.ai.embeddingtext:api:5")
runtimeOnly("pw.binom.ai.embeddingtext:siglip-jvm:5")
testImplementation(kotlin("test"))
}
+34
View File
@@ -0,0 +1,34 @@
package memo.core
import pw.binom.db.ksqlite.SQLiteConnection
class Db(val path: String) : AutoCloseable {
val conn: SQLiteConnection = SQLiteConnection.open(path)
init {
conn.exec("PRAGMA journal_mode=WAL")
conn.exec("PRAGMA synchronous=NORMAL")
}
fun init() {
conn.exec(
"CREATE TABLE IF NOT EXISTS files(" +
"path TEXT PRIMARY KEY, mtime REAL, size INTEGER, hash TEXT, indexed_at REAL" +
")"
)
conn.exec(
"CREATE TABLE IF NOT EXISTS 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" +
")"
)
conn.exec("CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path)")
conn.exec("CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, heading, tokenize='unicode61')")
conn.exec("CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[768])")
}
override fun close() {
conn.close()
}
}
@@ -0,0 +1,46 @@
package memo.core
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
class Embedder(
private val modelPath: String,
private val tokenizerPath: String,
) : AutoCloseable {
private var extractor: TextEmbeddingExtractor? = null
private val factory: Class<*>? = runCatching {
Class.forName("pw.binom.voice.embeddingtext.Siglip2TextExtractorFactoryKt")
}.getOrNull()
private val createMethod = factory?.methods?.firstOrNull {
it.name == "createSiglip2TextExtractor" &&
it.parameterTypes.size == 2 &&
it.parameterTypes[0] == String::class.java &&
it.parameterTypes[1] == String::class.java
}
private fun obtain(): TextEmbeddingExtractor {
extractor?.let { return it }
val method = createMethod ?: error(
"createSiglip2TextExtractor is not available on classpath; " +
"ensure pw.binom.ai.embeddingtext:siglip-jvm is on the runtime classpath"
)
val created = method.invoke(null, modelPath, tokenizerPath) as TextEmbeddingExtractor
extractor = created
return created
}
fun embed(text: String): FloatArray {
val ex = obtain()
val values = ex.embed(text).values
if (values.size != 768) {
throw IllegalStateException("expected 768 dims, got ${values.size}")
}
return values
}
override fun close() {
val current = extractor ?: return
extractor = null
current.close()
}
}
@@ -0,0 +1,143 @@
package memo.core
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
import pw.binom.db.ksqlite.SQLiteConnection
class CoreSmokeTest {
@Test
fun ksqliteSmoke() {
val file = File.createTempFile("memo-ksqlite-", ".db")
file.deleteOnExit()
val conn = SQLiteConnection.open(file.absolutePath)
try {
conn.exec("CREATE TABLE t(a INTEGER)")
conn.exec("INSERT INTO t(a) VALUES (42)")
val stmt = conn.prepare("SELECT a FROM t")
try {
val rs = stmt.executeQuery()
try {
assertTrue(rs.next(), "expected one row")
assertEquals(42L, rs.getLong(0)!!)
} finally {
rs.close()
}
} finally {
stmt.close()
}
} finally {
conn.close()
file.delete()
}
}
@Test
fun vec0KnnRoundTrip() {
val file = File.createTempFile("memo-vec0-", ".db")
file.deleteOnExit()
val conn = SQLiteConnection.open(file.absolutePath)
try {
conn.exec("CREATE VIRTUAL TABLE v USING vec0(embedding float[4])")
val insert = conn.prepare("INSERT INTO v(rowid, embedding) VALUES (?, ?)")
try {
insert.bindLong(1, 1L)
insert.bindVector(2, floatArrayOf(1.0f, 0.0f, 0.0f, 0.0f))
insert.executeUpdate()
insert.reset()
insert.bindLong(1, 2L)
insert.bindVector(2, floatArrayOf(0.0f, 1.0f, 0.0f, 0.0f))
insert.executeUpdate()
insert.reset()
insert.bindLong(1, 3L)
insert.bindVector(2, floatArrayOf(0.0f, 0.0f, 1.0f, 0.0f))
insert.executeUpdate()
} finally {
insert.close()
}
val query = conn.prepare("SELECT rowid FROM v WHERE embedding MATCH ? ORDER BY distance LIMIT 1")
try {
query.bindVector(1, floatArrayOf(0.1f, 0.9f, 0.0f, 0.0f))
val rs = query.executeQuery()
try {
assertTrue(rs.next(), "expected one match")
assertEquals(2L, rs.getLong(0)!!)
} finally {
rs.close()
}
} finally {
query.close()
}
} finally {
conn.close()
file.delete()
}
}
@Test
fun fts5FindsCyrillic() {
val file = File.createTempFile("memo-fts-", ".db")
file.deleteOnExit()
val conn = SQLiteConnection.open(file.absolutePath)
try {
conn.exec("CREATE VIRTUAL TABLE docs USING fts5(text, tokenize='unicode61')")
val insert = conn.prepare("INSERT INTO docs(rowid, text) VALUES (?, ?)")
try {
insert.bindLong(1, 1L)
insert.bindText(2, "внутренний домен траефик")
insert.executeUpdate()
} finally {
insert.close()
}
val query = conn.prepare("SELECT rowid FROM docs WHERE docs MATCH ?")
try {
query.bindText(1, "траефик")
val rs = query.executeQuery()
try {
var hits = 0
var lastRowid = -1L
while (rs.next()) {
hits++
lastRowid = rs.getLong(0)!!
}
assertEquals(1, hits, "expected exactly one FTS5 hit")
assertEquals(1L, lastRowid)
} finally {
rs.close()
}
} finally {
query.close()
}
} finally {
conn.close()
file.delete()
}
}
@Test
fun embedderProduces768() {
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
val modelPath = "$modelDir/text_model_int8.onnx"
val tokenizerPath = "$modelDir/tokenizer.model"
if (!File(modelPath).exists() || !File(tokenizerPath).exists()) {
fail("модель не найдена: $modelDir")
}
Embedder(modelPath, tokenizerPath).use { embedder ->
val v = embedder.embed("привет мир")
assertEquals(768, v.size)
assertTrue(v.none { it.isNaN() }, "embedding contains NaN")
}
}
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
kotlin("jvm")
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
kotlin("jvm")
}
+141
View File
@@ -0,0 +1,141 @@
#!/bin/bash
# Создаёт эталонный корпус для приёмки memo (TESTING.md §0).
# 12-15 заметок в 3 коллекциях, кириллица + латиница + точные значения в каждой папке.
set -euo pipefail
ROOT="${1:-/root/WORK/memo-e2e}"
rm -rf "$ROOT"
mkdir -p "$ROOT/infra" "$ROOT/work/jira" "$ROOT/life/books"
# ---------- infra ----------
cat > "$ROOT/infra/servers.md" <<'EOF'
# Серверы
## Server4 (76.160)
Основной Proxmox-хост. Диск sdd — SSD, sda/b/c — HDD, причём sda на SMR-пластинах.
Утилиты parted нет: разметку делать через sgdisk или sfdisk.
## Server5 (88.130)
Две видеокарты: 3090 занята под Qwen VL и аудио, её не трогать. Эксперименты — только на 5090.
## Server6 (76.109)
Здесь живёт powerdns, он же LXC102. Отвечает за внутренние зоны.
EOF
cat > "$ROOT/infra/hosts.md" <<'EOF'
# Адреса внутренних сервисов
## Реестр образов
zot слушает на 192.168.88.35:8080, репозиторий приватный.
## Прокси
Корпоративные домены ходят через Caddy на 76.132. Отдельный Traefik на 76.195 заворачивает
домены вида .xx, а DNS под ним — 76.109.
## Nexus
Нексус доступен на 76.117, релизы публикует CI/CD, руками артефакты не заливать.
EOF
cat > "$ROOT/infra/domains.md" <<'EOF'
# Домены и DNS
## Внутренние зоны
Основой для DNS и mDNS служит kdns, он же pw.binom.dns.
## LLM-роутер
llm.binom.pw работает через Bifrost, виртуальные ключи раздаёт сам роутер.
## Реестр
Домашний реестр библиотек — nexus.xx, репозиторий caffeine.
EOF
cat > "$ROOT/infra/backup.md" <<'EOF'
# Резервное копирование
## Postgres
Дампы снимаются в custom-формате. При восстановлении pg_restore может выйти мгновенно
с кодом ноль и ничего не сделать — проверять, что объекты реально появились.
## Медиа
Джоб копирования зеркал для очков идёт через NATS и складывает результат в S3 SeaweedFS.
EOF
# ---------- work/jira ----------
cat > "$ROOT/work/jira/access.md" <<'EOF'
# Доступы
## OTP-прокси
Корпоративные ресурсы ходят через OTP, файлы лежат в /root/OTP/. Jira отвечает на jira.mcp.xx.
## TeamCity
Сборки живут в teamcity.isb, статусы и логи — только через тот же OTP-прокси.
## Git
Клонирование корпоративных репозиториев с bitbucket возможно лишь по HTTPS, git@ не работает.
EOF
cat > "$ROOT/work/jira/flow.md" <<'EOF'
# Рабочий поток
## Задачи от агентов
Задача от агента оформляется спекой в отдельном файле, итог работы — комментарий в Vikunja.
## Тикеты
Тикет TEST-4173 закрыт после того, как сборка перестала падать на шаге тестов.
EOF
cat > "$ROOT/work/jira/pitfalls.md" <<'EOF'
# Грабли
## Хелм
Helm upgrade в k3s теряет values, если передавать их через --set. Восстанавливать из истории релиза.
## TeamCity
Если прогон висит больше часа, смотреть лог агента, а не перезапускать сборку слепо.
EOF
cat > "$ROOT/work/jira/ci.md" <<'EOF'
# CI
## Gitea Actions
Раннер живёт в LXC105 на 76.112. Сироты процессов podman и unity съедают диск и валят сборку.
EOF
# ---------- life/books ----------
cat > "$ROOT/life/books/iphuck.md" <<'EOF'
# iPhuck 10
## Про роман
Роман Пелевина, 2017 года. Рассказчик — алгоритмический следователь Порфирий Петрович,
он же литературный негр, он же полицейская машина.
## Канон
Канон Порфирия Петровича лежит в /root/BOOKS/iphuck10/. Тон — высокий штиль, смешанный
с сухим канцеляритом.
EOF
cat > "$ROOT/life/books/audiobooks.md" <<'EOF'
# Аудиокниги
## Библиотека
Книги хранятся в Audiobookshelf. Карточку чинят вручную, если метаданные подтянулись от другого издания.
## Плеер
Для очков RayNeo X2 книги транскодируются в зеркала, аудио идёт отдельными дорожками.
EOF
cat > "$ROOT/life/books/history.md" <<'EOF'
# История
## Античность
Первый том заканчивается на правлении Траяна, 117 год нашей эры.
EOF
cat > "$ROOT/life/books/notes.md" <<'EOF'
# Заметки о чтении
## Привычка
Читаю по вечерам, отмечаю цитаты в markdown, потом ищу их семантическим поиском.
EOF
ls -R "$ROOT"
echo "OK: $(find "$ROOT" -name '*.md' | wc -l) заметок"
+13
View File
@@ -0,0 +1,13 @@
rootProject.name = "memo"
include(":memo-core", ":memo-cli", ":memo-watch", ":memo-mcp")
dependencyResolutionManagement {
repositories {
mavenCentral()
maven {
url = uri("http://nexus.xx/repository/caffeine/")
isAllowInsecureProtocol = true
}
}
}