memory-vector: LlmMemoryReviewer + SkillStore/SkillSaveTool/SkillDeleteTool

Phase 3 (Hermes-style self-improvement) and Phase 5.2 (LLM-driven review):

* :skills — SkillStore interface + DiskSkillStore (upsert/remove, file<->catalog sync)
* :skills — SkillParser.serialize for write-back path
* :standalone — SkillSaveTool/SkillDeleteTool + SkillToolsFactory
* :standalone — LlmMemoryReviewer: one-shot LiteLlm review via structured-output
  JSON prompt ({toSave:[...], toDelete:[...]}); reuses MemorySystem store
* :standalone — ReviewDecisionParser (lenient, handles json fences, missing
  arrays, malformed numbers)
* :standalone — ReviewPrompts (Russian system+user prompts, fact categories)
* :standalone/Main — wires LlmMemoryReviewer instead of KeywordMdReviewer when
  LLM is available, falls back to keyword for off/md-only mode
* :client — send(content, context) overload + SendPayload wrapper
* :proto — ExperimentalNativeApi opt-in for MessageContextTest (native targets)

Bug fixes:
* SkillTools.kt: error() shadowed kotlin.error(); renamed to Nothing
* ChatConversation: .map { when(...); error() } → .mapNotNull { when ... else -> null }
  (Kotlin type inference of LUB LiteMessage | Nothing failed across when-expr)

Tests: 284 total green (memory-vector: 17, standalone: 149).

Dropped: IRC-QUESTIONS.md (irc-server design rejected — user decision 2026-09-14).
This commit is contained in:
2026-09-15 04:08:46 +03:00
parent 65365da89c
commit 227d14b7e4
18 changed files with 1070 additions and 50 deletions
@@ -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, и на отсутствие обязательных полей. Различаем по сообщению:
@@ -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()
}
@@ -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] файл переименовывается в `<original>.archived` и больше не
* попадает в [catalog]. Это позволяет восстановить скил, если нужно.
*
* Потокобезопасность: все операции защищены одним [ReentrantLock] — write-tool
* может зваться из корутины, читатели каталога — из других.
*/
class DiskSkillStore(
private val dir: File,
) : SkillStore {
private val lock = ReentrantLock()
// Кэш: активные скилы по имени. На старте заполняется из диска, потом
// обновляется по мере upsert/remove.
private val active: MutableMap<String, SkillFile> = 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 { "_" }
}