diff --git a/.gitignore b/.gitignore index 1e1b6b8..d751c4f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,10 @@ out/ # Local tooling (Magic Context, IDE plugins, MCP configs) .cortexkit/ -.veai/ \ No newline at end of file +.veai/ + +# Runtime / test artifacts +agentik.db +agentik.db-shm +agentik.db-wal +memory-md/agentik-mem-*/ diff --git a/IRC-QUESTIONS.md b/IRC-QUESTIONS.md deleted file mode 100644 index fc5d0c7..0000000 --- a/IRC-QUESTIONS.md +++ /dev/null @@ -1,40 +0,0 @@ -# IRC-транспорт — открытые вопросы - -По мере закрытия отмечаем `- N. [x]`. Закрытый вопрос остаётся в файле с принятым решением. - -- 1. [x] **История.** Принято: новый абстрактный метод `suspend fun getLatestMessages(offset: Int, limit: Int): List` в `:proto.Conversation` (offset = пропустить С КОНЦА, 0 = самые свежие). IRC-сервер не держит своего буфера, на `CHATHISTORY` дёргает агента. CAP `draft/chathistory` объявляем. -- 2. [x] **Tool/Error/Image события — раскладка по IRC.** Принято. Каждый `Event` мапится: - - `StartReasoning` → дропаем с провода - - `StartResponse(TEXT|IMAGE)` → CTCP `AGENTIK response-start {"type":"text"|"image"}` - - `AppendText(body)` → `PRIVMSG #chan :body` - - `AppendImage(body, mime)` → через `ImageStore` → CTCP `AGENTIK image {"url":..,"mime":..,"ttl":..}` - - `ToolCall` → CTCP `AGENTIK tool-call {json}` - - `ToolResult` → CTCP `AGENTIK tool-result {json}` - - `Error` → CTCP `AGENTIK error {json}` - - `End` → CTCP `AGENTIK end` - - `Interrupted` → CTCP `AGENTIK interrupted` -- 3. [x] **Interrupt.** **Упрощение:** команды `/stop` и `/interrupt` в `PRIVMSG` (т.е. `PRIVMSG #chan :/stop`) вызывают `Conversation.interrupt()`. Если `PRIVMSG` приходит во время активного размышления — сервер сначала зовёт `interrupt()`, затем `send(content)`. CTCP-вариант дропаем. -- 4. [x] **`AgentEvent.Created/Deleted/Renamed` маппинг.** Принято: Created = IRC `JOIN`-бродкаст; Deleted = `KICK` самого себя; Renamed = `TOPIC #foo :new title`. -- 5. [x] **NICK агента.** Принято: параметр в DSL, дефолт `"Agent"`. -- 6. [x] **Multi-user в канале.** Принято: **в канале всегда только наш агент и наш пользователь. Других не будет никогда.** -- 7. [x] **Маппинг канал ↔ Conversation.** Принято: имя IRC-канала = `Conversation.title`; `Conversation.id` = UUID, выдаётся через `CTCP AGENTIK id #foo`; при переименовании канала id стабилен. -- 8. [x] **Создание канала.** Принято: `JOIN #foo` → создаём `Conversation(title="foo", id=)`. Если уже есть — заходим. -- 9. [x] **Удаление канала.** Принято: `PART` закрывает сторону клиента; `CTCP AGENTIK delete #foo` — удаление Conversation-а. -- 10. [x] **Модуль.** Принято: `:irc-server`, KMP через kotlinx-io. Также модуль содержит HTTP staging-эндпоинт для картинок (см. п.13). -- 11. [x] **Аутентификация клиента.** Принято: без auth, любой может подключиться. -- 12. [x] **Capabilities (ircv3).** Принято: в первом проходе объявляем `server-time`, `message-tags`, `batch`, `draft/chathistory`. SASL не объявляем (п.11). Остальные CAPs (echo-message, labeled-response, standard-replies, multi-user stuff) добавляем инкрементально. -- 13. [x] **ImageStore.** Принято: `ImageStore` живёт в `:irc-server`, дефолтная in-memory реализация с **TTL 600 сек**, staging-порт **авто-pick** (0 → свободный). Клиент через IRC картинки **не шлёт** (для этого HTTP `:server`). Конкретную реализацию `ImageStore` пользователь сделает позже сам, в первом проходе — наша in-memory. - -## Все вопросы закрыты - -Итого решений по `:irc-server`: -- Модуль `:irc-server`, KMP через kotlinx-io. -- Канал IRC = `Conversation` (имя = title, UUID через CTCP `AGENTIK id`). -- В канале всегда только 1 пользователь + агент (ник `Agent` по умолчанию). -- `Event` → IRC: `PRIVMSG` для текста, CTCP `AGENTIK <имя> {json}` для всего остального. `StartReasoning` дропается. -- Interrupt через `/stop` / `/interrupt` в PRIVMSG; входящий PRIVMSG во время размышления = `interrupt()` + `send()`. -- История через `CHATHISTORY` (LATEST/BEFORE/BETWEEN/AFTER), сервер не буферизует, дёргает новый `:proto` метод `getLatestMessages(offset, limit)`. -- Картинки только agent → client через `ImageStore` + HTTP staging в том же модуле. -- CAPs: `server-time`, `message-tags`, `batch`, `draft/chathistory`. Без auth. - -Можно кодить. diff --git a/client/src/main/kotlin/pw/binom/agentik/client/ConversationClient.kt b/client/src/main/kotlin/pw/binom/agentik/client/ConversationClient.kt index 931a82e..cd7a783 100644 --- a/client/src/main/kotlin/pw/binom/agentik/client/ConversationClient.kt +++ b/client/src/main/kotlin/pw/binom/agentik/client/ConversationClient.kt @@ -13,10 +13,12 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.serialization.Serializable import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Event import pw.binom.agentik.proto.Message +import pw.binom.agentik.proto.MessageContext import kotlin.time.Instant /** @@ -58,10 +60,10 @@ internal class ConversationClient( snapshot = updated } - override suspend fun send(content: List) { + override suspend fun send(content: List, context: MessageContext?) { httpClient.post("$convUrl/messages") { contentType(ContentType.Application.Json) - setBody(content) + setBody(SendPayload(content, context)) } } @@ -92,3 +94,9 @@ internal class ConversationClient( // Agent.deleteConversation(id). См. [Conversation.close] KDoc. } } + +@Serializable +private data class SendPayload( + val content: List, + val context: MessageContext? = null, +) diff --git a/proto/src/commonTest/kotlin/pw/binom/agentik/proto/MessageContextTest.kt b/proto/src/commonTest/kotlin/pw/binom/agentik/proto/MessageContextTest.kt index e9422f9..5aec7db 100644 --- a/proto/src/commonTest/kotlin/pw/binom/agentik/proto/MessageContextTest.kt +++ b/proto/src/commonTest/kotlin/pw/binom/agentik/proto/MessageContextTest.kt @@ -78,6 +78,7 @@ class MessageContextTest { assertEquals(ctx.metadata, decoded.metadata) } + @kotlin.experimental.ExperimentalNativeApi @Test fun `origin field name is origin in JSON`() { val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "boot") diff --git a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt index 02fe77d..4e004a6 100644 --- a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt +++ b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt @@ -129,6 +129,38 @@ object SkillParser { private fun String.stripBom(): String = if (isNotEmpty() && this[0] == '\uFEFF') substring(1) else this + /** + * Сериализует [skill] в формат SKILL.md (YAML-фронтматтер + markdown body). + * Используется [SkillStore] для записи скилов на диск. + * + * Формат: + * ``` + * --- + * name: backend:spring:db-base + * description: Use when ... + * --- + * + * # Body markdown... + * ``` + */ + fun serialize(skill: SkillFile): String { + val body = skill.body.trimStart('\n').trimEnd('\n') + val yamlBody = yaml.encodeToString( + Frontmatter.serializer(), + Frontmatter(name = skill.name, description = skill.description), + ) + return buildString { + append("---\n") + append(yamlBody) + append("---\n") + if (body.isNotEmpty()) { + append("\n") + append(body) + append("\n") + } + } + } + /** * kaml кидает [com.charleskorn.kaml.YamlException] и на синтаксические * ошибки YAML, и на отсутствие обязательных полей. Различаем по сообщению: diff --git a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillStore.kt b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillStore.kt new file mode 100644 index 0000000..99de923 --- /dev/null +++ b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillStore.kt @@ -0,0 +1,31 @@ +package pw.binom.agentik.skills + +/** + * Хранилище скилов с операциями upsert/remove. + * + * Реализации обязаны: + * - `upsert` — записать скил в постоянное хранилище (диск) и сделать его + * доступным в [catalog] сразу же после вызова; + * - `remove(name)` — пометить скил как archived (убрать из активного [catalog]) + * и вернуть `true`, если такой скил был; `false` — если не нашли; + * - быть потокобезопасными для конкурентных вызовов из нескольких корутин. + * + * Это контракт, через который SkillSaveTool / SkillDeleteTool общаются с + * диском. Без этого skill self-improvement в Hermes-стиле был бы невозможен. + */ +interface SkillStore : AutoCloseable { + /** Текущий каталог активных (не archived) скилов. */ + val catalog: SkillCatalog + + /** + * Создаёт или обновляет скил. Имя скила может содержать `:` (как в + * opencode: `backend:spring:db-base`); маппинг на файловую систему — + * забота реализации. + */ + fun upsert(skill: SkillFile) + + /** Архивирует скил по имени: убирает из [catalog], но не удаляет файл. */ + fun remove(name: String): Boolean + + override fun close() +} diff --git a/skills/src/jvmMain/kotlin/pw/binom/agentik/skills/DiskSkillStore.kt b/skills/src/jvmMain/kotlin/pw/binom/agentik/skills/DiskSkillStore.kt new file mode 100644 index 0000000..8e9195b --- /dev/null +++ b/skills/src/jvmMain/kotlin/pw/binom/agentik/skills/DiskSkillStore.kt @@ -0,0 +1,97 @@ +package pw.binom.agentik.skills + +import java.io.File +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** + * JVM-реализация [SkillStore] поверх папки скилов. + * + * Конвенция путей: + * - `click-on` → `skills/click-on/SKILL.md` (одна директория); + * - `backend:spring:db-base` → `skills/backend/spring/db-base/SKILL.md` — все сегменты + * после первого становятся вложенными директориями. + * + * Архивирование: + * - при [remove] файл переименовывается в `.archived` и больше не + * попадает в [catalog]. Это позволяет восстановить скил, если нужно. + * + * Потокобезопасность: все операции защищены одним [ReentrantLock] — write-tool + * может зваться из корутины, читатели каталога — из других. + */ +class DiskSkillStore( + private val dir: File, +) : SkillStore { + + private val lock = ReentrantLock() + + // Кэш: активные скилы по имени. На старте заполняется из диска, потом + // обновляется по мере upsert/remove. + private val active: MutableMap = linkedMapOf() + + init { + if (!dir.exists()) dir.mkdirs() + // При старте — перечитываем каталог, пропуская .archived файлы. + val loadResult = SkillLoader.loadDirectory(dir) + for (skill in loadResult.catalog.skills) { + active[skill.name] = skill + } + } + + override val catalog: SkillCatalog + get() = lock.withLock { SkillCatalog(active.values.toList()) } + + override fun upsert(skill: SkillFile) { + require(skill.name.isNotBlank()) { "skill name must not be blank" } + require(skill.description.isNotBlank()) { "skill description must not be blank" } + lock.withLock { + val target = skillFile(skill.name) + target.parentFile?.mkdirs() + target.writeText(SkillParser.serialize(skill), Charsets.UTF_8) + active[skill.name] = skill + } + } + + override fun remove(name: String): Boolean = lock.withLock { + val existing = active.remove(name) ?: return@withLock false + val target = skillFile(name) + if (target.exists()) { + val archived = File(target.parentFile, target.name + ".archived") + // Если архивная копия уже есть — дописываем суффикс + val finalArchived = if (archived.exists()) { + var i = 1 + var f: File + do { + f = File(target.parentFile, "${target.name}.archived.$i") + i++ + } while (f.exists()) + f + } else archived + target.renameTo(finalArchived) + } + true + } + + override fun close() { + // Никаких ресурсов: всё на диске. Просто no-op. + } + + /** + * Маппинг имени на файловый путь: + * `backend:spring:db-base` → `dir/backend/spring/db-base/SKILL.md` + * `click-on` → `dir/click-on/SKILL.md` + */ + private fun skillFile(name: String): File { + val segments = name.split(':').map { sanitize(it) } + require(segments.isNotEmpty() && segments.all { it.isNotEmpty() }) { + "invalid skill name '$name'" + } + val path = File(dir, segments.joinToString("/")) + return File(path, "SKILL.md") + } + + /** Убираем из сегментов пути всё кроме `[A-Za-z0-9_-]`. */ + private fun sanitize(s: String): String = + s.filter { it.isLetterOrDigit() || it == '_' || it == '-' } + .ifEmpty { "_" } +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt index 98fa058..0968294 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -5,7 +5,9 @@ import io.ktor.server.engine.embeddedServer import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.routing +import kotlinx.coroutines.Dispatchers import kotlinx.io.files.Path +import pw.binom.agentik.memory.MemoryReviewer import pw.binom.agentik.memory.MemorySystem import pw.binom.agentik.memory.md.openMdMemorySystem import pw.binom.agentik.memory.vector.VectorMemorySystem @@ -15,6 +17,7 @@ import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillLoader import pw.binom.agentik.standalone.agent.ChatAgent import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor +import pw.binom.agentik.standalone.agent.memory.LlmMemoryReviewer import pw.binom.agentik.standalone.config.AgentikConfig import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend import pw.binom.agentik.standalone.llm.LlmBackend @@ -118,6 +121,16 @@ fun main() { val contextWindow: Int? = config.llm.resolveContextWindow() val contextCompactor = if (contextWindow != null) LiteLlmContextCompactor(liteLlm = llm) else null + // Review-loop: всегда используем LlmMemoryReviewer поверх LiteLlm, если память включена. + // Hermes-style one-shot с structured-output (JSON). Fallback — heuristic reviewer из бэкенда. + val memoryReviewer: MemoryReviewer? = memorySystem?.store?.let { store -> + LlmMemoryReviewer( + liteLlm = llm, + store = store, + dispatcher = Dispatchers.IO, + ) + } ?: memorySystem?.reviewer + val agent = ChatAgent( id = "agentik", stores = stores, @@ -127,7 +140,7 @@ fun main() { skills = skills, memoryStore = memorySystem?.store, memoryPrefetcher = memorySystem?.prefetcher, - memoryReviewer = memorySystem?.reviewer, + memoryReviewer = memoryReviewer, soulBody = soulBody, contextWindow = contextWindow, compressionThreshold = config.compressionThreshold, diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt index c52e885..1fe0cae 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt @@ -623,21 +623,23 @@ class ChatConversation( ?.let { (it.entry as WorkingMemoryEntry.System).text } ?: systemPrompt - val pastTurns = if (record.isTemporal) emptyList() else wm + val pastTurns: List = if (record.isTemporal) emptyList() else wm .filter { row -> val isUserOrAssistant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId isUserOrAssistant && !isPendingUser } - .map { row -> - when (val e = row.entry) { + .mapNotNull { row -> + val e: WorkingMemoryEntry = row.entry + val msg: LiteMessage? = when (e) { is WorkingMemoryEntry.User -> LiteMessage( LiteRole.USER, applyContextPrefix(e.content.toLiteContents(), e.context), ) is WorkingMemoryEntry.Assistant -> LiteMessage(LiteRole.MODEL, e.content.toLiteContents()) - else -> error("unreachable") + else -> null } + msg } val config = LiteConversationConfig( diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillTools.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillTools.kt new file mode 100644 index 0000000..fcf6273 --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillTools.kt @@ -0,0 +1,168 @@ +package pw.binom.agentik.standalone.agent + +import pw.binom.agentik.skills.SkillFile +import pw.binom.agentik.skills.SkillStore +import pw.binom.litert.LiteTool + +/** + * Тул `skill_save(name, description, body)` — сохраняет скил через [SkillStore]. + * + * Схема аргументов (function-calling JSON): + * ``` + * { + * "name": "skill_save", + * "description": "Создать или обновить скил. Имя может содержать двоеточия (как в opencode: 'backend:spring:db-base').", + * "parameters": { + * "type": "object", + * "properties": { + * "name": {"type": "string", "description": "Имя скила, уникальное в каталоге."}, + * "description": {"type": "string", "description": "Одно-два предложения: когда применять."}, + * "body": {"type": "string", "description": "Markdown body скила."} + * }, + * "required": ["name", "description", "body"] + * } + * } + * ``` + * + * ВАЖНО: параметр LiteTool-лямбды не называется `invoke` — иначе Kotlin + * резолвит `invoke(x)` рекурсивно и StackOverflow (см. MemoryToolsFactory). + */ +internal class SkillSaveTool( + private val store: SkillStore, +) : LiteTool { + override fun describe(): String = SCHEMA + + override fun invoke(arguments: String): String { + val parsed = parseArgs(arguments) + ?: return error("invalid arguments: $arguments") + val (name, description, body) = parsed + return try { + store.upsert(SkillFile(name = name, description = description, body = body)) + """{"ok":true,"name":"${escape(name)}"}""" + } catch (e: Exception) { + error(e.message ?: "upsert failed") + } + } + + private fun parseArgs(arguments: String): Triple? { + // Минимальный парсер JSON-объекта — вытаскиваем три строковых поля. + // Полагаемся на порядок ключей в агенте: name, description, body. + val name = extractString(arguments, "name") ?: return null + val description = extractString(arguments, "description") ?: return null + val body = extractString(arguments, "body") ?: return null + return Triple(name, description, body) + } + + companion object { + private const val SCHEMA = """ + { + "name": "skill_save", + "description": "Создать или обновить скил. Имя может содержать двоеточия (например 'backend:spring:db-base'). Скил станет доступен в этом и следующих сеансах через read_skill.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, + "body": {"type": "string"} + }, + "required": ["name", "description", "body"] + } + } + """ + } +} + +/** + * Тул `skill_delete(name)` — архивирует скил. + */ +internal class SkillDeleteTool( + private val store: SkillStore, +) : LiteTool { + override fun describe(): String = SCHEMA + override fun invoke(arguments: String): String { + val name = extractString(arguments, "name") + ?: return error("missing 'name' in arguments: $arguments") + return try { + val removed = store.remove(name) + if (removed) { + """{"ok":true,"archived":"${escape(name)}"}""" + } else { + error("skill '$name' not found") + } + } catch (e: Exception) { + error(e.message ?: "delete failed") + } + } + + companion object { + private const val SCHEMA = """ + { + "name": "skill_delete", + "description": "Архивировать скил по имени. Скил больше не будет появляться в read_skill, но файл остаётся на диске с суффиксом .archived.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + } + } + """ + } +} + +/** + * Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер — + * JSON простой (плоский объект с известным набором ключей), как в + * MemoryToolsFactory. + */ +internal fun extractString(json: String, key: String): String? { + val keyIdx = json.indexOf("\"$key\"") + if (keyIdx < 0) return null + val colon = json.indexOf(':', keyIdx) + if (colon < 0) return null + val firstQuote = json.indexOf('"', colon) + if (firstQuote < 0) return null + + var i = firstQuote + 1 + val sb = StringBuilder() + while (i < json.length) { + val c = json[i] + when { + c == '\\' && i + 1 < json.length -> { + when (val next = json[i + 1]) { + 'n' -> sb.append('\n') + 't' -> sb.append('\t') + 'r' -> sb.append('\r') + '"' -> sb.append('"') + '\\' -> sb.append('\\') + else -> sb.append(next) + } + i += 2 + } + c == '"' -> return sb.toString() + else -> { + sb.append(c) + i++ + } + } + } + return null +} + +/** JSON-escape строки. */ +internal fun escape(s: String): String = buildString(s.length + 2) { + for (c in s) { + when (c) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(c) + } + } +} + +/** Строит `{"error":"..."}` JSON-ответ. */ +internal fun error(message: String): Nothing = throw IllegalStateException(message) diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt new file mode 100644 index 0000000..0e2b09c --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt @@ -0,0 +1,21 @@ +package pw.binom.agentik.standalone.agent + +import pw.binom.agentik.skills.SkillStore + +/** + * Фабрика tools для self-improvement'а скилов (Phase 3 Hermes-style). + * + * Возвращает два NamedTool'а: + * - `skill_save(name, description, body)` — создать или обновить скил; + * - `skill_delete(name)` — архивировать скил. + * + * Оба оборачивают [SkillStore]; агент может дёргать их в любой момент, + * не только во время review-loop. + */ +object SkillToolsFactory { + + fun create(store: SkillStore): List = listOf( + NamedTool("skill_save", SkillSaveTool(store)), + NamedTool("skill_delete", SkillDeleteTool(store)), + ) +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewer.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewer.kt new file mode 100644 index 0000000..526dadc --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewer.kt @@ -0,0 +1,137 @@ +package pw.binom.agentik.standalone.agent.memory + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +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.MemoryStore +import pw.binom.agentik.memory.MemoryStoreEvent +import pw.binom.agentik.memory.NewMemoryNote +import pw.binom.agentik.memory.ReviewedTurn +import pw.binom.agentik.standalone.persistence.Ids +import pw.binom.litert.LiteLlm +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * Реализация [MemoryReviewer] поверх on-device LLM (LiteLlm / Google LiteRT-LM). + * + * После каждого хода (или пачки ходов при compaction) зовём LiteLlm с + * специальным промптом, который просит модель вернуть JSON со списком + * новых заметок и удалений. Парсим руками (см. [ReviewDecisionParser]) — + * on-device модели с tool-calling работают ненадёжно, structured output + * стабильнее. + * + * Конструктор принимает `dispatcher` чтобы I/O LiteLlm не блокировал + * основной поток. По умолчанию — `Dispatchers.IO` (вытягивается из контекста). + * + * @param maxExistingFacts сколько последних заметок подмешивать в промпт + * как [memory-context], чтобы модель не дублировала уже сохранённые факты. + */ +class LlmMemoryReviewer( + private val liteLlm: LiteLlm, + private val store: MemoryStore, + private val dispatcher: CoroutineDispatcher, + private val maxExistingFacts: Int = 30, + private val clock: Clock = Clock.System, +) : MemoryReviewer { + + override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = withContext(dispatcher) { + // Снимок существующих заметок — чтобы модель не дублировала + val existingFacts = store.list(limit = maxExistingFacts, offset = 0) + .joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" } + + val prompt = ReviewPrompts.reviewUserPrompt(turn, existingFacts) + val conv = liteLlm.createConversation( + pw.binom.litert.LiteConversationConfig( + systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT, + temperature = 0.2f, + maxTokens = 512, + ) + ) + val raw = try { + conv.send(prompt) + } finally { + conv.close() + } + ReviewDecisionParser.parse(raw) + } + + override suspend fun reviewPreCompaction(turns: List): MemoryReviewDecision = withContext(dispatcher) { + if (turns.isEmpty()) return@withContext MemoryReviewDecision() + + val existingFacts = store.list(limit = maxExistingFacts, offset = 0) + .joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" } + + // Склеиваем все ходы в один промпт — модель посмотрит пакетом и сможет + // отсеять дубликаты между ходами. + val prompt = buildString { + if (existingFacts.isNotBlank()) { + appendLine("[memory-context — что уже сохранено]") + appendLine(existingFacts) + appendLine() + } + appendLine("[compacted-turns — будет удалено после compaction'а]") + turns.forEachIndexed { idx, t -> + appendLine() + appendLine("--- turn ${idx + 1} ---") + appendLine("[user] ${t.userMessage}") + appendLine("[assistant] ${t.assistantMessage}") + } + appendLine() + append("Верни JSON:") + } + + val conv = liteLlm.createConversation( + pw.binom.litert.LiteConversationConfig( + systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT, + temperature = 0.2f, + maxTokens = 1024, + ) + ) + val raw = try { + conv.send(prompt) + } finally { + conv.close() + } + ReviewDecisionParser.parse(raw) + } + + /** + * Применяет решение к store: сохраняет новые заметки, удаляет помеченные. + * Возвращает сколько заметок записано/удалено — для метрик. + */ + suspend fun apply(decision: MemoryReviewDecision, source: pw.binom.agentik.memory.MemorySource): ApplyResult = withContext(dispatcher) { + var saved = 0 + var deleted = 0 + + for (note in decision.toSave) { + store.upsert(toMemoryNote(note, source)) + saved++ + } + for (id in decision.toDelete) { + if (store.delete(id)) deleted++ + } + ApplyResult(saved = saved, deleted = deleted) + } + + private fun toMemoryNote( + note: NewMemoryNote, + source: pw.binom.agentik.memory.MemorySource, + ): pw.binom.agentik.memory.MemoryNote { + val now = clock.now() + return pw.binom.agentik.memory.MemoryNote( + id = Ids.new("mem-review"), + category = note.category, + content = note.content, + createdAt = now, + lastUsedAt = now, + useCount = 0, + source = source, + ) + } + + data class ApplyResult(val saved: Int, val deleted: Int) +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParser.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParser.kt new file mode 100644 index 0000000..3e355d6 --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParser.kt @@ -0,0 +1,195 @@ +package pw.binom.agentik.standalone.agent.memory + +import pw.binom.agentik.memory.MemoryCategory +import pw.binom.agentik.memory.MemoryReviewDecision +import pw.binom.agentik.memory.NewMemoryNote + +/** + * Парсер ответа LLM-review-loop'а. + * + * LiteLlm (on-device) не имеет надёжного tool-calling flow, поэтому + * модель возвращает JSON в plain text. Парсим регуляркой + минимальным + * валидатором — если что-то не так, лучше no-op, чем краш. + */ +object ReviewDecisionParser { + + /** + * Парсит ответ модели в [MemoryReviewDecision]. Возвращает пустой decision + * если ответ пустой, не JSON, или JSON битый — лучше ничего не сохранить, + * чем записать мусор. + */ + fun parse(rawOutput: String): MemoryReviewDecision { + val trimmed = rawOutput.trim() + if (trimmed.isEmpty()) return MemoryReviewDecision() + + // Ищем JSON-блок, даже если модель обернула его в ``` или добавила пояснения + val json = extractJson(trimmed) ?: return MemoryReviewDecision() + return parseJson(json) + } + + private fun extractJson(text: String): String? { + // Первый '{' до последней '}' + val start = text.indexOf('{') + val end = text.lastIndexOf('}') + if (start < 0 || end < 0 || end <= start) return null + return text.substring(start, end + 1) + } + + /** + * Минимальный JSON-парсер. Не хочу тянуть kotlinx-serialization в этот + * слой — JSON простой (плоский массив объектов), пишем руками. + */ + private fun parseJson(json: String): MemoryReviewDecision { + return try { + val save = parseArray(json, "save") { obj -> + val category = parseString(obj, "category")?.let { runCatching { MemoryCategory.valueOf(it.uppercase()) }.getOrNull() } + ?: return@parseArray null + val content = parseString(obj, "content")?.takeIf { it.isNotBlank() } + ?: return@parseArray null + NewMemoryNote(category, content) + } + val delete = parseStringArray(json, "delete") + MemoryReviewDecision(toSave = save, toDelete = delete) + } catch (e: Exception) { + MemoryReviewDecision() + } + } + + /** + * Парсит массив объектов из JSON-строки по ключу. callback получает + * содержимое одного элемента (без обрамляющих []{} и без имени ключа) + * и возвращает элемент результата либо null (пропустить). + */ + private fun parseArray(json: String, key: String, map: (String) -> T?): List { + // Ищем "key": [ ... ] + val keyIdx = json.indexOf("\"$key\"") + if (keyIdx < 0) return emptyList() + val arrayStart = json.indexOf('[', keyIdx) + val arrayEnd = json.indexOf(']', arrayStart) + if (arrayStart < 0 || arrayEnd < 0) return emptyList() + + val arrayContent = json.substring(arrayStart + 1, arrayEnd) + return splitTopLevelObjects(arrayContent).mapNotNull { map(it) } + } + + /** + * Разбивает содержимое JSON-массива на отдельные объекты верхнего уровня + * с учётом вложенности и экранирования кавычек. + */ + private fun splitTopLevelObjects(content: String): List { + val result = mutableListOf() + var depth = 0 + var start = -1 + var inString = false + var escaped = false + content.forEachIndexed { i, ch -> + if (escaped) { escaped = false; return@forEachIndexed } + when { + ch == '\\' && inString -> escaped = true + ch == '"' -> inString = !inString + !inString && ch == '{' -> { + if (depth == 0) start = i + depth++ + } + !inString && ch == '}' -> { + depth-- + if (depth == 0 && start >= 0) { + result.add(content.substring(start, i + 1)) + start = -1 + } + } + } + } + return result + } + + /** + * Парсит массив строк из JSON по ключу. Используется для `delete` + * (там элементы — голые строки, не объекты). + */ + private fun parseStringArray(json: String, key: String): List { + val keyIdx = json.indexOf("\"$key\"") + if (keyIdx < 0) return emptyList() + val arrayStart = json.indexOf('[', keyIdx) + val arrayEnd = json.indexOf(']', arrayStart) + if (arrayStart < 0 || arrayEnd < 0) return emptyList() + + val arrayContent = json.substring(arrayStart + 1, arrayEnd) + val result = mutableListOf() + var i = 0 + while (i < arrayContent.length) { + // Skip whitespace and commas + while (i < arrayContent.length && (arrayContent[i].isWhitespace() || arrayContent[i] == ',')) i++ + if (i >= arrayContent.length || arrayContent[i] != '"') break + i++ // skip opening quote + + val sb = StringBuilder() + var escaped = false + while (i < arrayContent.length) { + val c = arrayContent[i] + if (escaped) { + when (c) { + 'n' -> sb.append('\n') + 't' -> sb.append('\t') + 'r' -> sb.append('\r') + '"' -> sb.append('"') + '\\' -> sb.append('\\') + else -> sb.append(c) + } + escaped = false + i++ + continue + } + when (c) { + '\\' -> { escaped = true; i++ } + '"' -> { + result.add(sb.toString()) + i++ + break + } + else -> { sb.append(c); i++ } + } + } + } + return result + } + + /** + * Парсит строковое значение по ключу в JSON-объекте. Возвращает + * содержимое без обрамляющих кавычек и с раскрытыми базовыми escape. + */ + private fun parseString(obj: String, key: String): String? { + val keyIdx = obj.indexOf("\"$key\"") + if (keyIdx < 0) return null + val colon = obj.indexOf(':', keyIdx) + if (colon < 0) return null + val firstQuote = obj.indexOf('"', colon) + if (firstQuote < 0) return null + + // Ищем закрывающую кавычку с учётом escape + var i = firstQuote + 1 + val sb = StringBuilder() + while (i < obj.length) { + val c = obj[i] + when { + c == '\\' && i + 1 < obj.length -> { + when (val next = obj[i + 1]) { + 'n' -> sb.append('\n') + 't' -> sb.append('\t') + 'r' -> sb.append('\r') + '"' -> sb.append('"') + '\\' -> sb.append('\\') + else -> sb.append(next) + } + i += 2 + } + c == '"' -> return sb.toString() + else -> { + sb.append(c) + i++ + } + } + } + return null + } +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewPrompts.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewPrompts.kt new file mode 100644 index 0000000..facdc0d --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewPrompts.kt @@ -0,0 +1,59 @@ +package pw.binom.agentik.standalone.agent.memory + +import pw.binom.agentik.memory.ReviewedTurn + +/** + * Промпты для review-loop'а через LiteLlm. + * + * Hermes делает это с OpenAI/Anthropic tool-calling flow. У нас on-device + * движок (Google LiteRT-LM) — там tool-calling ненадёжен, поэтому используем + * structured-output: модель должна вернуть JSON, который мы парсим регуляркой. + */ +object ReviewPrompts { + + /** + * Системная инструкция для review-loop'а. На русском — модель у нас + * русскоязычная (gemma-2-9b / qwen / и т.п.), английский промпт часто + * даёт хуже результат на ru-данных. + */ + const val REVIEW_SYSTEM_PROMPT = """Ты — агент ревью памяти. Твоя задача — проанализировать пару (сообщение пользователя, ответ ассистента) и решить, что из неё стоит сохранить в долговременную память. + +Категории памяти: +- USER — факты о пользователе (имя, профессия, предпочтения, контекст его жизни) +- WORLD — факты о внешнем мире (проекты, технологии, организации, конкретные API/документация) +- PREFERENCE — предпочтения по формату/поведению ассистента (стиль кода, длины ответов, инструменты) + +Правила: +1. Сохраняй ТОЛЬКО durable facts — то, что останется актуальным через недели. Не сохраняй "пользователь поздоровался" или "ассистент использовал grep". +2. Не дублируй уже сохранённое — если факт уже есть в [memory-context], пропусти. +3. Каждый факт — одна короткая фраза. Не абзацы, не "the user mentioned...". +4. Не выдумывай. Если ничего достойного — верни пустой массив. + +Формат ответа — строго JSON без обрамления ```json и без пояснений: +{"save":[{"category":"USER|WORLD|PREFERENCE","content":"..."}],"delete":[]} + +Если нечего сохранять: +{"save":[],"delete":[]}""" + + /** + * Форматирует user-prompt для review-loop'а. Подаёт текущий ход + + * текущее состояние долговременной памяти (чтобы избежать дубликатов). + */ + fun reviewUserPrompt( + turn: ReviewedTurn, + existingFacts: String, + ): String = buildString { + if (existingFacts.isNotBlank()) { + appendLine("[memory-context — что уже сохранено]") + appendLine(existingFacts) + appendLine() + } + appendLine("[user]") + appendLine(turn.userMessage) + appendLine() + appendLine("[assistant]") + appendLine(turn.assistantMessage) + appendLine() + append("Верни JSON:") + } +} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt index 765baf5..f91932f 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt @@ -414,7 +414,7 @@ class ChatAgentTest { private fun fakeLiteLlmForReload(): LiteLlm = object : LiteLlm { override val backendName: String = "fake" - override val capabilities: pw.binom.litert.LiteCapabilities? = null + override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null) override fun isInitialized(): Boolean = true override fun createConversation(config: LiteConversationConfig): LiteConversation = error("not used in reload test") diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt index 5ea7bb7..3ebc858 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt @@ -16,7 +16,7 @@ import pw.binom.litert.LiteRole */ internal class FakeLiteLlm : LiteLlm { override val backendName: String = "fake" - override val capabilities: pw.binom.litert.LiteCapabilities? = null + override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null) var reply: String = "" var rememberHistory: Boolean = false var slow: Boolean = false diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt new file mode 100644 index 0000000..cd0acdd --- /dev/null +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt @@ -0,0 +1,184 @@ +package pw.binom.agentik.standalone.agent.memory + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import pw.binom.agentik.memory.MemoryCategory +import pw.binom.agentik.memory.MemoryNote +import pw.binom.agentik.memory.MemorySource +import pw.binom.agentik.memory.MemoryStore +import pw.binom.agentik.memory.MemoryStoreEvent +import pw.binom.agentik.memory.ReviewedTurn +import pw.binom.agentik.standalone.agent.FakeLiteLlm +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class LlmMemoryReviewerTest { + + /** + * Минимальный in-memory store для тестов — реализует [MemoryStore], + * хранит заметки в MutableList, поддерживает events flow. + */ + private class InMemoryStore : MemoryStore { + private val notes = mutableMapOf() + private val _events = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 16) + + override suspend fun upsert(note: MemoryNote) { + notes[note.id] = note + _events.emit(MemoryStoreEvent.Upserted(note)) + } + + override suspend fun get(id: String): MemoryNote? = notes[id] + override suspend fun list( + category: MemoryCategory?, + conversationId: String?, + limit: Int, + offset: Int, + ): List = notes.values + .filter { category == null || it.category == category } + .filter { conversationId == null || it.conversationId == conversationId } + .sortedByDescending { it.lastUsedAt } + .drop(offset) + .take(limit) + + override suspend fun search(query: pw.binom.agentik.memory.MemorySearchQuery): List = emptyList() + + override suspend fun delete(id: String): Boolean = notes.remove(id) != null + + override suspend fun markUsed(id: String, at: Instant) { + notes[id]?.let { + notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1) + } + } + + override fun events(): kotlinx.coroutines.flow.Flow = _events + + override fun close() {} + + // Helper for tests to seed notes + fun seed(note: MemoryNote) { + notes[note.id] = note + } + } + + @Test + fun `review parses save JSON and applies upsert`() = runTest { + val llm = FakeLiteLlm().apply { + reply = """{"save":[{"category":"USER","content":"Имя — Саша"}],"delete":[]}""" + } + val store = InMemoryStore() + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + val decision = reviewer.review( + ReviewedTurn( + userMessage = "Меня Саша зовут", + assistantMessage = "Приятно познакомиться, Саша!", + ) + ) + assertEquals(1, decision.toSave.size) + assertEquals(MemoryCategory.USER, decision.toSave[0].category) + + val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW) + assertEquals(1, applied.saved) + + val all = store.list() + assertEquals(1, all.size) + assertEquals("Имя — Саша", all[0].content) + assertEquals(MemorySource.AUTO_REVIEW, all[0].source) + } + + @Test + fun `review applies delete decisions`() = runTest { + val llm = FakeLiteLlm().apply { + reply = """{"save":[],"delete":["mem-stale-1"]}""" + } + val store = InMemoryStore().apply { + seed( + MemoryNote( + id = "mem-stale-1", + category = MemoryCategory.USER, + content = "stale", + createdAt = Instant.parse("2026-01-01T00:00:00Z"), + lastUsedAt = Instant.parse("2026-01-01T00:00:00Z"), + useCount = 0, + source = MemorySource.AUTO_REVIEW, + ) + ) + } + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + val decision = reviewer.review(ReviewedTurn("удали это", "ок")) + val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW) + assertEquals(0, applied.saved) + assertEquals(1, applied.deleted) + assertEquals(0, store.list().size) + } + + @Test + fun `review returns empty decision when LLM produces garbage`() = runTest { + val llm = FakeLiteLlm().apply { reply = "Извини, я не могу помочь с этим." } + val store = InMemoryStore() + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + val decision = reviewer.review(ReviewedTurn("hi", "hello")) + assertTrue(decision.toSave.isEmpty()) + assertTrue(decision.toDelete.isEmpty()) + } + + @Test + fun `review handles empty LLM reply`() = runTest { + val llm = FakeLiteLlm().apply { reply = "" } + val store = InMemoryStore() + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + val decision = reviewer.review(ReviewedTurn("hi", "hello")) + assertTrue(decision.toSave.isEmpty()) + } + + @Test + fun `review creates conversation with review system prompt`() = runTest { + val llm = FakeLiteLlm().apply { + reply = """{"save":[],"delete":[]}""" + } + val store = InMemoryStore() + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + reviewer.review(ReviewedTurn("u", "a")) + + assertNotNull(llm.lastConfig) + assertEquals(ReviewPrompts.REVIEW_SYSTEM_PROMPT, llm.lastConfig!!.systemInstruction) + } + + @Test + fun `reviewPreCompaction processes batch of turns`() = runTest { + val llm = FakeLiteLlm().apply { + reply = """ + {"save":[ + {"category":"USER","content":"Работает в Яндексе"}, + {"category":"WORLD","content":"JVector — pure-Java ANN"} + ],"delete":[]} + """.trimIndent() + } + val store = InMemoryStore() + val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined) + + val turns = listOf( + pw.binom.agentik.memory.ConversationTurn( + userMessage = "Я в Яндексе работаю", + assistantMessage = "Круто!", + ), + pw.binom.agentik.memory.ConversationTurn( + userMessage = "А что за JVector?", + assistantMessage = "ANN-библиотека на Java.", + ), + ) + + val decision = reviewer.reviewPreCompaction(turns) + val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW) + + assertEquals(2, applied.saved) + assertEquals(2, store.list().size) + } +} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt new file mode 100644 index 0000000..48df50a --- /dev/null +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt @@ -0,0 +1,106 @@ +package pw.binom.agentik.standalone.agent.memory + +import pw.binom.agentik.memory.MemoryCategory +import pw.binom.agentik.memory.MemoryReviewDecision +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ReviewDecisionParserTest { + + @Test + fun `parses save array with USER category`() { + val raw = """{"save":[{"category":"USER","content":"Имя пользователя — Саша"}],"delete":[]}""" + val decision = ReviewDecisionParser.parse(raw) + assertEquals(1, decision.toSave.size) + assertEquals(MemoryCategory.USER, decision.toSave[0].category) + assertEquals("Имя пользователя — Саша", decision.toSave[0].content) + assertTrue(decision.toDelete.isEmpty()) + } + + @Test + fun `parses all three categories`() { + val raw = """ + {"save":[ + {"category":"USER","content":"Работает в Яндексе"}, + {"category":"WORLD","content":"JVector — pure-Java ANN от DataStax"}, + {"category":"PREFERENCE","content":"Отвечать кратко"} + ],"delete":[]} + """.trimIndent() + val decision = ReviewDecisionParser.parse(raw) + assertEquals(3, decision.toSave.size) + assertEquals(MemoryCategory.USER, decision.toSave[0].category) + assertEquals(MemoryCategory.WORLD, decision.toSave[1].category) + assertEquals(MemoryCategory.PREFERENCE, decision.toSave[2].category) + } + + @Test + fun `parses delete array with ids`() { + val raw = """{"save":[],"delete":["mem-123","mem-456"]}""" + val decision = ReviewDecisionParser.parse(raw) + assertTrue(decision.toSave.isEmpty()) + assertEquals(listOf("mem-123", "mem-456"), decision.toDelete) + } + + @Test + fun `returns empty decision on empty input`() { + assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse("")) + assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(" ")) + } + + @Test + fun `returns empty decision on non-JSON garbage`() { + val raw = "Извини, я не могу помочь с этим." + assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw)) + } + + @Test + fun `returns empty decision on malformed JSON`() { + val raw = """{"save":[{"category":"USER","content":"foo""" // truncated + assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw)) + } + + @Test + fun `extracts JSON from markdown code block`() { + val raw = """ + Вот JSON: + ```json + {"save":[{"category":"WORLD","content":"SQLite 3.51"}],"delete":[]} + ``` + """.trimIndent() + val decision = ReviewDecisionParser.parse(raw) + assertEquals(1, decision.toSave.size) + assertEquals(MemoryCategory.WORLD, decision.toSave[0].category) + assertEquals("SQLite 3.51", decision.toSave[0].content) + } + + @Test + fun `skips entries with unknown category`() { + val raw = """{"save":[ + {"category":"USER","content":"valid"}, + {"category":"NOT_A_CATEGORY","content":"should be skipped"}, + {"category":"WORLD","content":"valid too"} + ],"delete":[]}""" + val decision = ReviewDecisionParser.parse(raw) + assertEquals(2, decision.toSave.size) + assertEquals("valid", decision.toSave[0].content) + assertEquals("valid too", decision.toSave[1].content) + } + + @Test + fun `skips entries with blank content`() { + val raw = """{"save":[ + {"category":"USER","content":""}, + {"category":"WORLD","content":" "} + ],"delete":[]}""" + assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw)) + } + + @Test + fun `handles escaped quotes in content`() { + val raw = """{"save":[{"category":"USER","content":"Сказал \"привет\""}],"delete":[]}""" + val decision = ReviewDecisionParser.parse(raw) + assertEquals(1, decision.toSave.size) + assertEquals("Сказал \"привет\"", decision.toSave[0].content) + } +}