Phase 5: :memory-vector (JVector + SQLite + LLM-эмбеддинги), memory-abstraction, compaction, MessageContext

- :memory-api — общий контракт MemoryStore/Prefetcher/Reviewer/Tools/MemorySystem
- :memory-md (KMP, kotlinx-io) — Hermes-style §-файлы, keyword overlap
- :memory-vector (JVM-only) — JVector ANN + SQLite + HttpEmbeddingClient
- :standalone — AGENTIK_MEMORY_BACKEND={md,vector,off}, выбор в Main.kt
- :standalone — compaction рабочего контекста (LiteLlmContextCompactor + reviewPreCompaction)
- :proto — MessageContext (origin: user/system/event) на send и в Message
- :server — backward-compat dual-format для POST /messages
- README — env-vars, vector-бэкенд docs
This commit is contained in:
2026-09-15 03:44:15 +03:00
parent 0faad45f3d
commit 65365da89c
71 changed files with 5856 additions and 142 deletions
@@ -0,0 +1,34 @@
package pw.binom.agentik.memory.md
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryStore
/**
* Prefetcher поверх [MdMemoryStore]. Делает keyword-поиск (см. [MdMemoryFormat.keywordScore])
* и бампит `lastUsedAt`/`useCount` у выданных заметок через [MemoryStore.markUsed].
*
* Вектор-бэкенд (будущая `:memory-vector`) поставит сюда эмбеддинг-семантику
* с тем же контрактом.
*/
class KeywordMdPrefetcher(private val store: MemoryStore) : MemoryPrefetcher {
override suspend fun prefetch(
query: String,
topK: Int,
category: MemoryCategory?,
): List<MemoryNote> {
if (query.isBlank()) return emptyList()
val results = store.search(
pw.binom.agentik.memory.MemorySearchQuery(
query = query,
topK = topK,
category = category,
),
)
val notes = results.map { it.note }
for (n in notes) store.markUsed(n.id)
return notes
}
}
@@ -0,0 +1,88 @@
package pw.binom.agentik.memory.md
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemorySystemGuidance
import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn
/**
* Простая эвристика для review-loop'а: режет user/assistant-текст на предложения
* и помечает те, что содержат явные user/preference-маркеры (рус/англ).
*
* Это намеренно тупее LLM-реализации, которая появится в `:standalone` —
* без неё всё равно можно прогонять review-loop и набивать базовую память.
* Когда LLM-реализация подключится, она станет дефолтной, а эта останется
* для тестов и offline-сценариев.
*/
class KeywordMdReviewer(
private val maxFactsPerTurn: Int = 5,
private val maxFactsTotal: Int = 20,
) : MemoryReviewer {
private val userMarkers = listOf(
"я ", "я.", "я,", "мой ", "моя ", "моё ", "мои ", "мне ", "у меня ",
"i ", "i'm", "i am", "my ", "mine",
)
private val preferenceMarkers = listOf(
"я обычно", "я люблю", "я предпочитаю", "я не люблю", "мне нравится", "мне не нравится",
"i usually", "i prefer", "i like", "i don't like", "i hate",
)
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision {
// Эвристика берёт только user-message: ассистентские фразы вида
// "I can help with anything" ложно матчат "i " маркер, а настоящие
// предпочтения пользователя живут в его сообщениях. LLM-реализация
// (в :standalone) смотрит на обе стороны и решает тоньше.
val text = turn.userMessage.trim()
if (text.isBlank()) return MemoryReviewDecision()
return MemoryReviewDecision(toSave = extractFacts(text))
}
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision {
// Пакетный review: идём по ходам, вытаскиваем факты только из user-сообщений
// (assistant-фразы редко несут устойчивые факты о пользователе/мире).
// Дубликаты отсеиваются глобальным seen-Set'ом, лимит — maxFactsTotal,
// чтобы compaction не превращался в свалку.
val seen = HashSet<String>()
val toSave = ArrayList<NewMemoryNote>()
for (turn in turns) {
if (toSave.size >= maxFactsTotal) break
val text = turn.userMessage.trim()
if (text.isBlank()) continue
for (fact in extractFacts(text)) {
if (toSave.size >= maxFactsTotal) break
val key = fact.content.lowercase()
if (!seen.add(key)) continue
toSave.add(fact)
}
}
return MemoryReviewDecision(toSave = toSave)
}
private fun extractFacts(text: String): List<NewMemoryNote> {
val sentences = text.splitToSentences()
val result = ArrayList<NewMemoryNote>()
val seen = HashSet<String>()
for (s in sentences) {
if (result.size >= maxFactsPerTurn) break
val trimmed = s.trim()
if (trimmed.length < 6) continue
val lc = trimmed.lowercase()
val category = when {
preferenceMarkers.any { lc.contains(it) } -> MemoryCategory.PREFERENCE
userMarkers.any { lc.startsWith(it) || lc.contains(" $it") } -> MemoryCategory.USER
else -> null
} ?: continue
val dedupeKey = trimmed.lowercase()
if (!seen.add(dedupeKey)) continue
result.add(NewMemoryNote(category, trimmed))
}
return result
}
private fun String.splitToSentences(): List<String> =
split(Regex("(?<=[.!?\\n])\\s+")).filter { it.isNotBlank() }
}
@@ -0,0 +1,156 @@
package pw.binom.agentik.memory.md
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySource
import kotlin.time.Instant
/**
* Чистый парсер/сериализатор формата §-файлов памяти.
*
* Формат одного файла (например USER.md):
* ```
* § id=mem-xxx created=2026-09-14T10:00:00Z last_used=2026-09-14T10:00:00Z uses=0 source=agent_save
* Текст факта.
* Может занимать несколько строк.
* § id=mem-yyy created=...
*
* Другой факт.
* ```
*
* Разделитель записей — строка, начинающаяся с `§ ` (section-symbol + пробел).
* Это позволяет использовать `§` внутри контента, если он не стоит в начале строки
* с пробелом после него. Парсер смотрит именно на `§<пробел>` в начале строки.
*
* Запись заканчивается за один пустой строкой перед следующим `§`-заголовком.
*/
object MdMemoryFormat {
private const val SECTION_PREFIX = "§ "
/**
* Распарсить содержимое файла в список заметок. Неупорядоченно — порядок
* в файле не гарантирован, сортировка ложится на [MdMemoryStore].
*/
fun parse(category: MemoryCategory, body: String): List<MemoryNote> {
val lines = body.lines()
val out = mutableListOf<MemoryNote>()
var idx = 0
while (idx < lines.size) {
val line = lines[idx]
if (!line.startsWith(SECTION_PREFIX)) {
idx++
continue
}
val headerLine = line.removePrefix(SECTION_PREFIX).trim()
idx++
// Следующая пустая строка после заголовка — пропускаем.
if (idx < lines.size && lines[idx].isBlank()) idx++
// Контент — до следующего `§`-заголовка или EOF.
val contentLines = mutableListOf<String>()
while (idx < lines.size && !lines[idx].startsWith(SECTION_PREFIX)) {
contentLines.add(lines[idx])
idx++
}
val note = parseNote(category, headerLine, contentLines.joinToString("\n").trim())
if (note != null) out.add(note)
}
return out
}
/**
* Сериализовать список заметок в содержимое файла. Записи идут в порядке
* передачи; между ними — пустая строка. В конце всегда перевод строки.
*/
fun serialize(notes: List<MemoryNote>): String = buildString {
for ((i, note) in notes.withIndex()) {
if (i > 0) append('\n')
append(SECTION_PREFIX)
append("id=").append(note.id)
append(" created=").append(note.createdAt.toString())
append(" last_used=").append(note.lastUsedAt.toString())
append(" uses=").append(note.useCount)
append(" source=").append(note.source.id)
if (note.conversationId != null) {
append(" conv=").append(note.conversationId)
}
append('\n').append('\n')
append(note.content)
append('\n')
}
}
private fun parseNote(
category: MemoryCategory,
header: String,
content: String,
): MemoryNote? {
// Header: "id=<id> created=<iso> last_used=<iso> uses=<n> source=<id> [conv=<id>]"
var id: String? = null
var created: Instant? = null
var lastUsed: Instant? = null
var uses: Int? = null
var source: MemorySource? = null
var conv: String? = null
for (part in header.split(' ')) {
if (part.isEmpty()) continue
val eq = part.indexOf('=')
if (eq <= 0) continue
val key = part.substring(0, eq)
val value = part.substring(eq + 1)
try {
when (key) {
"id" -> id = value
"created" -> created = Instant.parse(value)
"last_used" -> lastUsed = Instant.parse(value)
"uses" -> uses = value.toInt()
"source" -> source = MemorySource.fromId(value)
"conv" -> conv = value
}
} catch (e: Throwable) {
return null
}
}
if (id == null || created == null || lastUsed == null || uses == null || source == null) {
return null
}
return MemoryNote(
id = id,
category = category,
content = content,
createdAt = created,
lastUsedAt = lastUsed,
useCount = uses,
conversationId = conv,
source = source,
)
}
/**
* Быстрый keyword-поиск по списку заметок. Используется внутри [MdMemoryStore]
* и [KeywordMdPrefetcher]. Возвращает результаты, отсортированные по score ↓.
*
* Алгоритм для v1: case-insensitive substring-match. Score = (число совпавших слов
* из запроса в заметке) / (общее число слов в запросе). Для пустого запроса
* отдаём все заметки, отсортированные по `lastUsedAt` ↓ (recency-фоллбэк).
*/
fun keywordScore(query: String, note: MemoryNote): Float {
val q = query.lowercase()
if (q.isBlank()) return 0f
val needle = q.splitToWords()
if (needle.isEmpty()) return 0f
val haystack = note.content.lowercase()
var hits = 0
for (w in needle) {
if (w.length >= 2 && w in haystack) hits++
}
return hits.toFloat() / needle.size.toFloat()
}
}
private fun String.splitToWords(): List<String> =
split(Regex("[^\\p{L}\\p{N}]+")).filter { it.isNotEmpty() }
@@ -0,0 +1,209 @@
package pw.binom.agentik.memory.md
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.io.IOException
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readString
import kotlinx.io.writeString
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySearchResult
import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* Hermes-style persistent memory store, backed by `kotlinx-io`.
*
* Каждая [MemoryCategory] живёт в отдельном файле под [root]:
* `USER.md`, `WORLD.md`, `PREFERENCES.md`. Записи разделены `§` и парсятся
* в [MemoryNote] при первом обращении к файлу. Все мутации идут под [mu],
* атомарно через `.tmp` + `atomicMove` ([SystemFileSystem.atomicMove]).
*
* Файлы инициализируются лениво — `~/.agentik/memory/{category}.md` создаётся
* при первом [upsert]/[get]/[list], не при [openMdMemory].
*/
class MdMemoryStore internal constructor(
private val root: Path,
) : MemoryStore {
private val mu = Mutex()
private val cache: MutableMap<MemoryCategory, MutableList<MemoryNote>> = HashMap()
private val dirty: MutableSet<MemoryCategory> = HashSet()
private val events = MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 64)
init {
try {
SystemFileSystem.createDirectories(root, mustCreate = false)
} catch (e: IOException) {
throw IllegalStateException("Cannot create memory root: $root", e)
}
}
fun observe(): SharedFlow<MemoryStoreEvent> = events.asSharedFlow()
private fun file(c: MemoryCategory): Path = Path(root, categoryFileName(c))
private fun ensureLoaded(c: MemoryCategory): MutableList<MemoryNote> {
cache[c]?.let { return it }
val path = file(c)
val notes: MutableList<MemoryNote> = if (SystemFileSystem.exists(path)) {
val text = SystemFileSystem.source(path).buffered().use { it.readString() }
MdMemoryFormat.parse(c, text).toMutableList()
} else {
mutableListOf()
}
cache[c] = notes
return notes
}
private suspend fun persist(c: MemoryCategory) {
val notes = cache[c] ?: return
val path = file(c)
val tmp = Path(path.toString() + ".tmp")
SystemFileSystem.sink(tmp).buffered().use { it.writeString(MdMemoryFormat.serialize(notes)) }
try {
SystemFileSystem.atomicMove(tmp, path)
} catch (e: Throwable) {
runCatching { SystemFileSystem.delete(tmp, mustExist = false) }
throw e
}
dirty.remove(c)
}
override suspend fun upsert(note: MemoryNote) {
val stored: MemoryNote
mu.withLock {
val list = ensureLoaded(note.category)
val idx = list.indexOfFirst { it.id == note.id }
stored = if (note.useCount == 0 && note.lastUsedAt == note.createdAt) {
note.copy(lastUsedAt = note.createdAt)
} else {
note
}
if (idx >= 0) list[idx] = stored else list.add(stored)
dirty.add(note.category)
persist(note.category)
}
events.tryEmit(MemoryStoreEvent.Upserted(stored))
}
override suspend fun get(id: String): MemoryNote? = mu.withLock {
for (c in MemoryCategory.entries) {
val list = ensureLoaded(c)
val idx = list.indexOfFirst { it.id == id }
if (idx >= 0) return@withLock list[idx]
}
null
}
override suspend fun list(
category: MemoryCategory?,
conversationId: String?,
limit: Int,
offset: Int,
): List<MemoryNote> = mu.withLock {
val cats: List<MemoryCategory> =
category?.let { listOf(it) } ?: MemoryCategory.entries.toList()
val all = ArrayList<MemoryNote>(64)
for (c in cats) {
for (n in ensureLoaded(c)) {
if (conversationId == null) {
if (n.conversationId == null) all.add(n)
} else {
if (n.conversationId == conversationId) all.add(n)
}
}
}
all.sortByDescending { it.createdAt }
val from = offset.coerceAtLeast(0)
if (from >= all.size) return@withLock emptyList()
val to = (from + limit).coerceAtMost(all.size)
all.subList(from, to).toList()
}
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> = mu.withLock {
if (query.query.isBlank()) return@withLock emptyList()
val cats: List<MemoryCategory> =
query.category?.let { listOf(it) } ?: MemoryCategory.entries.toList()
val out = ArrayList<MemorySearchResult>()
for (c in cats) {
for (n in ensureLoaded(c)) {
if (query.conversationId != null &&
n.conversationId != null && n.conversationId != query.conversationId
) continue
val score = MdMemoryFormat.keywordScore(query.query, n)
if (score > 0f) out.add(MemorySearchResult(n, score))
}
}
out.sortByDescending { it.score }
if (query.topK > 0 && out.size > query.topK) {
out.subList(query.topK, out.size).clear()
}
out
}
override suspend fun delete(id: String): Boolean = mu.withLock {
for (c in MemoryCategory.entries) {
val list = ensureLoaded(c)
val idx = list.indexOfFirst { it.id == id }
if (idx >= 0) {
list.removeAt(idx)
dirty.add(c)
persist(c)
events.tryEmit(MemoryStoreEvent.Deleted(id))
return@withLock true
}
}
false
}
override suspend fun markUsed(id: String, at: Instant) {
mu.withLock {
for (c in MemoryCategory.entries) {
val list = ensureLoaded(c)
val idx = list.indexOfFirst { it.id == id }
if (idx >= 0) {
val updated = list[idx].copy(lastUsedAt = at, useCount = list[idx].useCount + 1)
list[idx] = updated
dirty.add(c)
persist(c)
return@withLock
}
}
}
}
/** Сбрасывает все буферизованные записи на диск. Идемпотентно. */
suspend fun flush() = mu.withLock {
for (c in dirty.toList()) persist(c)
}
override fun close() {
runCatching {
kotlinx.coroutines.runBlocking { flush() }
}
}
}
/** Имя файла для категории: `user.md` / `world.md` / `preference.md`. */
internal fun categoryFileName(c: MemoryCategory): String = when (c) {
MemoryCategory.USER -> "user.md"
MemoryCategory.WORLD -> "world.md"
MemoryCategory.PREFERENCE -> "preference.md"
}
/**
* Открывает [MdMemoryStore] в указанной корневой директории. Директория
* создаётся (рекурсивно), если её ещё нет.
*/
fun openMdMemory(root: Path): MdMemoryStore = MdMemoryStore(root)
@@ -0,0 +1,34 @@
package pw.binom.agentik.memory.md
import kotlinx.io.files.Path
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemorySystem
/**
* Связка store + prefetcher + reviewer на одной физической базе.
* Сейчас всё держится на одном [MdMemoryStore] — keyword-префетчер и
* эвристический ревьюер смотрят в него же.
*/
class MdMemorySystem internal constructor(
override val store: MemoryStore,
override val prefetcher: MemoryPrefetcher,
override val reviewer: MemoryReviewer,
) : MemorySystem {
override fun close() = store.close()
}
/**
* Собирает [MdMemorySystem] для указанной корневой директории.
* Store и prefetcher смотрят в одну базу; reviewer — keyword-эвристика
* (LLM-импл добавится в `:standalone`).
*/
fun openMdMemorySystem(root: Path): MdMemorySystem {
val store = openMdMemory(root)
return MdMemorySystem(
store = store,
prefetcher = KeywordMdPrefetcher(store),
reviewer = KeywordMdReviewer(),
)
}
@@ -0,0 +1,68 @@
package pw.binom.agentik.memory.md
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.files.SystemTemporaryDirectory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySource
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.runBlocking
class KeywordMdPrefetcherTest {
private val idCounter = atomicCounter()
private fun newRoot(): Path {
val name = "agentik-mem-${uniqueId()}"
val root = Path(SystemTemporaryDirectory.toString(), name)
SystemFileSystem.createDirectories(root, mustCreate = true)
return root
}
private fun note(id: String, category: MemoryCategory, content: String) = MemoryNote(
id = id, category = category, content = content,
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
source = MemorySource.AGENT_SAVE,
)
@Test
fun prefetchReturnsRelevantAndBumpsUseCount() = runBlocking {
val root = newRoot()
openMdMemorySystem(root).use { sys ->
sys.store.upsert(note("u", MemoryCategory.USER, "User uses gradle 9.4.1"))
sys.store.upsert(note("w", MemoryCategory.WORLD, "Project runs on k3s"))
sys.store.upsert(note("p", MemoryCategory.PREFERENCE, "Prefers dark theme"))
val hits = sys.prefetcher.prefetch("gradle", topK = 5)
assertEquals(1, hits.size)
assertEquals("u", hits[0].id)
val after = sys.store.get("u")
assertTrue(after!!.useCount >= 1)
}
}
@Test
fun emptyQueryReturnsNothing() = runBlocking {
val root = newRoot()
openMdMemorySystem(root).use { sys ->
sys.store.upsert(note("u", MemoryCategory.USER, "anything"))
assertTrue(sys.prefetcher.prefetch("").isEmpty())
assertTrue(sys.prefetcher.prefetch(" ").isEmpty())
}
}
@Test
fun prefetchRespectsCategory() = runBlocking {
val root = newRoot()
openMdMemorySystem(root).use { sys ->
sys.store.upsert(note("u", MemoryCategory.USER, "k8s tip"))
sys.store.upsert(note("w", MemoryCategory.WORLD, "k8s is great"))
val userOnly = sys.prefetcher.prefetch("k8s", topK = 5, category = MemoryCategory.USER)
assertEquals(listOf("u"), userOnly.map { it.id })
}
}
}
@@ -0,0 +1,124 @@
package pw.binom.agentik.memory.md
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.ReviewedTurn
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlinx.coroutines.runBlocking
class KeywordMdReviewerTest {
private val reviewer = KeywordMdReviewer()
@Test
fun detectsUserPreference() = runBlocking {
val decision = reviewer.review(
ReviewedTurn(
userMessage = "Я обычно предпочитаю vim, а не emacs.",
assistantMessage = "Хорошо, запомнил.",
),
)
assertTrue(decision.toSave.isNotEmpty(), "should suggest at least one note")
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE && (it.content.contains("vim") || it.content.contains("предпочитаю")) })
}
@Test
fun ignoresNonPersonalStatements() = runBlocking {
val decision = reviewer.review(
ReviewedTurn(
userMessage = "Hello!",
assistantMessage = "Hi, I can help with anything.",
),
)
assertTrue(decision.toSave.isEmpty())
}
@Test
fun handlesEnglishPreference() = runBlocking {
val decision = reviewer.review(
ReviewedTurn(
userMessage = "I usually prefer dark mode in my IDE.",
assistantMessage = "Got it.",
),
)
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE })
}
@Test
fun deduplicatesExactMatch() = runBlocking {
val decision = reviewer.review(
ReviewedTurn(
userMessage = "I usually prefer tab over spaces.\nI usually prefer tab over spaces.",
assistantMessage = "Ok.",
),
)
val prefs = decision.toSave.filter { it.category == MemoryCategory.PREFERENCE }
assertEquals(1, prefs.size, "duplicate sentences should collapse")
}
@Test
fun preCompactionExtractsAcrossTurns() = runBlocking {
val decision = reviewer.reviewPreCompaction(
listOf(
ConversationTurn(
userMessage = "Я работаю на проекте agentik.",
assistantMessage = "Понял.",
),
ConversationTurn(
userMessage = "Я обычно использую kotlin для бэкенда.",
assistantMessage = "Хорошо.",
),
ConversationTurn(
userMessage = "Мне нравится архитектура memory-first.",
assistantMessage = "Согласен.",
),
),
)
// 3 user-фразы с маркерами — должно дать 3 факта.
assertEquals(3, decision.toSave.size, "should extract one fact per user phrase")
assertTrue(decision.toSave.any { it.category == MemoryCategory.USER && it.content.contains("agentik") })
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE && it.content.contains("kotlin") })
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE && it.content.contains("memory-first") })
}
@Test
fun preCompactionDedupesAcrossTurns() = runBlocking {
val decision = reviewer.reviewPreCompaction(
listOf(
ConversationTurn(
userMessage = "Я обычно предпочитаю vim.",
assistantMessage = "A.",
),
ConversationTurn(
userMessage = "Я обычно предпочитаю vim.",
assistantMessage = "B.",
),
),
)
val prefs = decision.toSave.filter { it.category == MemoryCategory.PREFERENCE }
assertEquals(1, prefs.size, "duplicate facts across turns should collapse")
}
@Test
fun preCompactionRespectsTotalLimit() = runBlocking {
val reviewer = KeywordMdReviewer(maxFactsTotal = 2)
val decision = reviewer.reviewPreCompaction(
(1..5).map {
ConversationTurn(
userMessage = "Я работаю над задачей #$it.",
assistantMessage = "ok",
)
},
)
assertEquals(2, decision.toSave.size, "should respect maxFactsTotal across turns")
}
@Test
fun preCompactionHandlesEmptyList() = runBlocking {
val decision = reviewer.reviewPreCompaction(emptyList())
assertTrue(decision.toSave.isEmpty())
}
}
@@ -0,0 +1,57 @@
package pw.binom.agentik.memory.md
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySource
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
class MdMemoryFormatTest {
@Test
fun parsesAndSerializes() {
val n = MemoryNote(
id = "mem-1",
category = MemoryCategory.USER,
content = "Hello, world!",
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
source = MemorySource.AGENT_SAVE,
)
val body = MdMemoryFormat.serialize(listOf(n))
val parsed = MdMemoryFormat.parse(MemoryCategory.USER, body)
assertEquals(1, parsed.size)
assertEquals(n, parsed[0])
}
@Test
fun preservesMultiLineContent() {
val n = MemoryNote(
id = "mem-multi",
category = MemoryCategory.WORLD,
content = "Line1\nLine2\nLine3",
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
source = MemorySource.AGENT_SAVE,
)
val body = MdMemoryFormat.serialize(listOf(n))
val parsed = MdMemoryFormat.parse(MemoryCategory.WORLD, body)
assertEquals(n.content, parsed[0].content)
}
@Test
fun keywordScore() {
val n = MemoryNote(
id = "k", category = MemoryCategory.WORLD,
content = "k8s kubectl kustomize",
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
source = MemorySource.AGENT_SAVE,
)
assertTrue(MdMemoryFormat.keywordScore("k8s", n) > 0f)
assertEquals(0f, MdMemoryFormat.keywordScore("python", n))
assertEquals(0f, MdMemoryFormat.keywordScore("", n))
}
}
@@ -0,0 +1,146 @@
package pw.binom.agentik.memory.md
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.files.SystemTemporaryDirectory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySource
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.coroutines.runBlocking
class MdMemoryStoreTest {
private val idCounter = atomicCounter()
private fun newRoot(): Path {
val name = "agentik-mem-${uniqueId()}"
val root = Path(SystemTemporaryDirectory.toString(), name)
SystemFileSystem.createDirectories(root, mustCreate = true)
return root
}
private fun note(
id: String = "mem-${idCounter.next()}",
category: MemoryCategory = MemoryCategory.USER,
content: String,
createdAt: Instant = Instant.parse("2026-09-14T10:00:00Z"),
lastUsedAt: Instant = createdAt,
useCount: Int = 0,
conversationId: String? = null,
source: MemorySource = MemorySource.AGENT_SAVE,
) = MemoryNote(
id = id, category = category, content = content,
createdAt = createdAt, lastUsedAt = lastUsedAt, useCount = useCount,
conversationId = conversationId, source = source,
)
@Test
fun roundTripSingleEntry() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
val n = note(
id = "mem-test-1",
category = MemoryCategory.USER,
content = "User prefers dark mode.",
conversationId = null,
)
store.upsert(n)
assertEquals(n, store.get("mem-test-1"))
}
}
@Test
fun persistsAcrossReopen() = runBlocking {
val root = newRoot()
val n1 = note(id = "mem-a", category = MemoryCategory.USER, content = "alpha")
val n2 = note(id = "mem-b", category = MemoryCategory.WORLD, content = "beta")
val n3 = note(id = "mem-c", category = MemoryCategory.PREFERENCE, content = "gamma")
openMdMemory(root).use { store ->
store.upsert(n1)
store.upsert(n2)
store.upsert(n3)
}
openMdMemory(root).use { store ->
assertEquals(n1, store.get("mem-a"))
assertEquals(n2, store.get("mem-b"))
assertEquals(n3, store.get("mem-c"))
val all = store.list()
assertEquals(3, all.size)
}
}
@Test
fun upsertReplacesById() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
store.upsert(note(id = "mem-1", content = "first"))
store.upsert(note(id = "mem-1", content = "second"))
assertEquals("second", store.get("mem-1")?.content)
}
}
@Test
fun deleteRemovesById() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
store.upsert(note(id = "mem-x", category = MemoryCategory.WORLD, content = "go"))
assertEquals(true, store.delete("mem-x"))
assertNull(store.get("mem-x"))
assertEquals(false, store.delete("mem-x"))
}
}
@Test
fun searchFiltersByCategoryAndScoresSubstring() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
store.upsert(note(id = "u1", category = MemoryCategory.USER, content = "k8s cluster is using kubeadm"))
store.upsert(note(id = "u2", category = MemoryCategory.USER, content = "loves cats and code"))
store.upsert(note(id = "w1", category = MemoryCategory.WORLD, content = "project runs on k8s"))
store.upsert(note(id = "p1", category = MemoryCategory.PREFERENCE, content = "prefers dark theme"))
val userOnly = store.search(MemorySearchQuery("k8s", topK = 10, category = MemoryCategory.USER))
assertEquals(1, userOnly.size)
assertEquals("u1", userOnly[0].note.id)
val all = store.search(MemorySearchQuery("k8s", topK = 10))
assertEquals(2, all.size)
val ordered = all.map { it.note.id }.toSet()
assertTrue(ordered.containsAll(listOf("u1", "w1")))
}
}
@Test
fun listFilterByConversationId() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
store.upsert(note(id = "g1", content = "global fact", conversationId = null))
store.upsert(note(id = "c1", content = "per-conv", conversationId = "conv-x"))
val global = store.list(conversationId = null)
assertEquals(1, global.size)
assertEquals("g1", global[0].id)
val perConv = store.list(conversationId = "conv-x")
assertEquals(1, perConv.size)
assertEquals("c1", perConv[0].id)
}
}
@Test
fun markUsedBumpsCountAndLastUsed() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
store.upsert(note(id = "m", content = "x", lastUsedAt = Instant.parse("2026-09-01T00:00:00Z"), useCount = 0))
store.markUsed("m", Instant.parse("2026-09-14T10:00:00Z"))
val after = store.get("m")
assertNotNull(after)
assertEquals(1, after.useCount)
assertEquals(Instant.parse("2026-09-14T10:00:00Z"), after.lastUsedAt)
}
}
}
@@ -0,0 +1,31 @@
package pw.binom.agentik.memory.md
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.incrementAndFetch
/**
* Простой потокобезопасный счётчик для генерации уникальных id в тестах.
* Работает на всех KMP-таргетах (JVM + native), в отличие от `java.util.UUID`.
*/
@OptIn(ExperimentalAtomicApi::class)
internal class AtomicCounter {
private val v = AtomicInt(0)
fun next(): Int = v.incrementAndFetch()
}
internal fun atomicCounter(): AtomicCounter = AtomicCounter()
/**
* Process-wide уникальный id — комбинация nanos + счётчика.
* Гарантирует уникальность имени временной директории при параллельных тестах.
*/
@OptIn(ExperimentalAtomicApi::class)
private val processCounter = AtomicInt(0)
@OptIn(ExperimentalAtomicApi::class)
internal fun uniqueId(): String {
val n = processCounter.incrementAndFetch()
val ts = kotlin.time.Clock.System.now().toEpochMilliseconds()
return "${ts}-${n}"
}