feat(memory): add :memory-md-vector hybrid backend + :reflection-api + outbox/journal entity splits
ci / JVM build + tests (push) Failing after 2m59s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 9s

- :memory-md-vector (KMP jvm+linuxX64+mingwX64): .md-файлы как source of
  truth, векторный индекс (sqlite-vec) как derived cache. reconcile()
  на старте: orphan-cleanup + content-hash-gated re-embed. Гибридный
  скор 0.7*vector + 0.3*keyword. Заменяет EmbeddingProvider на
  KMP-TextEmbeddingExecutor из :memory-api.

- :reflection-api: новый 4-й API-модуль (Reflection, ReflectionStore,
  ReflectionEvent). Зависит только от :memory-api.

- :journal-api получил ConversationRecord/ConversationStore/Ids (бывший
  :message-store-api, полностью удалён). :outbox-api получил Event,
  CommonEvent, AgentEvent (бывший :event-store).

- :memory-api получил MemoryVectorIndex + NoteMatches +
  TextEmbeddingExecutor (suspend-обёртка над TextEmbeddingExtractor).

- :memory-vector KMP-цели достигнуты через commonMain-only TextEmbedding-
  Executor, EmbeddingProvider выпилен; :memory-md-vector тянет
  text-embedding-api транзитивно через :memory-api.

- :standalone flatten в commonMain/commonTest завершён (тесты из jvmTest
  переехали в commonTest). Включён optional деп :memory-md-vector через
  AGENTIK_MEMORY_BACKEND=md-vector.

jvmTest: 96 задач, 407 тестов, 0 падений.
This commit is contained in:
subochev
2026-09-21 23:10:28 +03:00
parent 161be41adf
commit 0a7c40688c
41 changed files with 4952 additions and 0 deletions
@@ -0,0 +1,58 @@
package pw.binom.agentik.memory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
/**
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score
* в [0..1]. Чем ближе к 1.0, тем семантически ближе query к заметке.
*
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
* `:memory-md-vector` (KMP linuxX64/mingwX64), который больше не зависит
* от JVM-only `:memory-vector`.
*/
data class ScoredVector(
val id: String,
val score: Float,
)
/**
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
* векторов к query. Метаданные заметок лежат в `MemoryStore` (для
* vector-бэкенда — отдельный `MemoryMetaStore` в `:memory-vector`);
* индекс хранит только embedding'и + id-маппинг.
*
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
* `:memory-md-vector` (KMP).
*
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации —
* это инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
*/
interface MemoryVectorIndex : AutoCloseable {
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
val dimension: Int
/** Количество записей в индексе. */
suspend fun size(): Long
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */
suspend fun add(id: String, embedding: FloatArray)
/** Удалить запись по [id]. Возвращает true если запись была. */
suspend fun remove(id: String): Boolean
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id. */
suspend fun search(
query: FloatArray,
k: Int,
filter: (MemoryNote) -> Boolean = { true },
): List<ScoredVector>
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
suspend fun flush()
override fun close()
}
@@ -0,0 +1,22 @@
package pw.binom.agentik.memory
/**
* Доп. контекст для vector-индекса: фильтр по категории и conversationId.
*
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`), но с
* переездом `:memory-md-vector` на KMP (linuxX64/mingwX64 и др.) он перенесён
* сюда — `:memory-md-vector` больше не зависит от JVM-only `:memory-vector`.
*
* Реализация `MemoryStore` (и `:memory-md`, и `:memory-vector`, и любые
* будущие) должны использовать этот хелпер при фильтрации результатов search,
* чтобы контракт был единый.
*/
fun noteMatches(
note: MemoryNote,
category: MemoryCategory? = null,
conversationId: String? = null,
): Boolean {
if (category != null && note.category != category) return false
if (conversationId != null && note.conversationId != conversationId) return false
return true
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.memory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
/**
* Suspend-обёртка над [TextEmbeddingExtractor] из `pw.binom.ai.embeddingtext:api`.
*
* `TextEmbeddingExtractor.embed()` — **блокирующий** (ONNX-инференс, HTTP),
* поэтому [embed] оборачивает его в [Dispatchers.Default] — caller'ы получают
* честный suspend, а блокирующая работа уходит в background dispatcher.
*
* Размерность вектора фиксируется extractor'ом (SigLIP2-base = 768, OpenAI
* text-embedding-3 = 1536, и т.п.). Если [knownDimension] указан — используем
* его; иначе — определяем лениво по первому [embed] (probe-vector на пустом
* тексте). `MemoryVectorIndex`-ы требуют размерность на момент конструирования,
* так что для prod-использования рекомендуется всегда передавать [knownDimension]
* явно (избегаем лишнего embed'а + непредсказуемой стоимости probe'а).
*
* @param extractor underlying extractor (не null)
* @param knownDimension заранее известная размерность; null = определить по probe
*/
class TextEmbeddingExecutor(
val extractor: TextEmbeddingExtractor,
val knownDimension: Int? = null,
) : AutoCloseable {
/** Размерность векторов. Эффективно константа после первого обращения. */
val dimension: Int by lazy {
knownDimension ?: extractor.embed("").dim
}
/**
* Эмбеддинг одного текста. Блокирующий [TextEmbeddingExtractor.embed] уходит
* в [Dispatchers.Default] — caller может безопасно await'ить.
*/
suspend fun embed(text: String): FloatArray =
withContext(Dispatchers.Default) { extractor.embed(text).values }
/** Батч-эмбеддинг (последовательно). Для ONNX/HTTP оверхед минимален. */
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
texts.map { embed(it) }
/** Делегирует [TextEmbeddingExtractor.close]. Идемпотентно. */
override fun close() {
extractor.close()
}
}