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:
@@ -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}"
|
||||
}
|
||||
Reference in New Issue
Block a user