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