Add global skill system for self-accumulated knowledge

Introduce a cross-group skill system that lets the bot distill reusable
knowledge into markdown docs and load them on demand, keeping day-to-day
context pollution low.

- SkillStore manages data/skills/*.md files with name/description
  frontmatter and an in-memory index cache (rebuilt on init/reload)
- Only the skill index (name + one-line description) is injected via the
  new {skills} system-prompt placeholder; bodies load on demand
- New tools: loadSkill / saveSkill (upsert, iterate = load+overwrite) /
  deleteSkill, gated by PluginConfig.skillsEnabled
- Skill names validated against ^[A-Za-z0-9_-]+$ to prevent traversal
- Wire SkillStore.init into onEnable and refresh on /jgpt reload; add
  /jgpt skills listing command
- Bump version to 1.12.0; update README

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-06-20 23:37:44 +08:00
co-authored by Claude Opus 4.8
parent 538fe563a0
commit 4307019ee8
9 changed files with 416 additions and 8 deletions
+19 -1
View File
@@ -52,7 +52,7 @@ object JChatGPT : KotlinPlugin(
JvmPluginDescription(
id = "top.jie65535.mirai.JChatGPT",
name = "J ChatGPT",
version = "1.11.0",
version = "1.12.0",
) {
author("jie65535")
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
@@ -82,6 +82,9 @@ object JChatGPT : KotlinPlugin(
// 初始化 token 使用日聚合存储(独立 JSON 文件,绕开 yamlkt 大数据 bug
TokenUsageStore.init(dataFolder)
// 初始化技能存储(data/skills/ 下的 markdown 文件,全局跨群)
SkillStore.init(dataFolder)
// 设置Token
LargeLanguageModels.reload()
@@ -269,6 +272,12 @@ object JChatGPT : KotlinPlugin(
} else memoryText
}
replace("{skills}") {
if (PluginConfig.skillsEnabled) {
SkillStore.buildIndexPrompt()
} else "暂无技能"
}
replace("{meme}") {
memePrompt?.let { return@replace it }
@@ -928,6 +937,15 @@ object JChatGPT : KotlinPlugin(
// 记忆修改
MemoryReplace(),
// 技能:加载
LoadSkill(),
// 技能:沉淀/迭代
SaveSkill(),
// 技能:删除
DeleteSkill(),
// 搜索聊天历史
SearchChatHistory(),
+15
View File
@@ -21,9 +21,24 @@ object PluginCommands : CompositeCommand(
PluginConfig.reload()
PluginData.reload()
LargeLanguageModels.reload()
SkillStore.reload()
sendMessage("OK")
}
@SubCommand
suspend fun CommandSender.skills() {
val all = SkillStore.all
if (all.isEmpty()) {
sendMessage("暂无技能")
return
}
val response = buildString {
appendLine("当前技能(共 ${all.size} 个):")
all.forEach { appendLine("- ${it.name}: ${it.description}") }
}
sendMessage(response.trim())
}
@SubCommand
suspend fun CommandSender.enable(contact: Contact) {
when (contact) {
+3
View File
@@ -123,6 +123,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("是否启用记忆编辑功能,记忆存在data目录,提示词中需要加上{memory}来填充记忆,每个群都有独立记忆")
val memoryEnabled by value(true)
@ValueDescription("是否启用技能系统,技能存在data/skills目录(全局跨群),提示词中需要加上{skills}来注入技能索引")
val skillsEnabled by value(true)
@ValueDescription("是否启用好感度系统")
val enableFavorabilitySystem by value(true)
+169
View File
@@ -0,0 +1,169 @@
package top.jie65535.mirai
import java.io.File
/**
* 技能元信息:用于索引展示,不含正文。
* @param name 技能名(kebab-case,同时是文件名),唯一
* @param description 一句话简介,决定 bot 何时按需加载
*/
data class SkillMeta(
val name: String,
val description: String,
)
/**
* 技能存储。每个技能 = 一个带 frontmatter 的 markdown 文件,放在 data/skills/ 下,全局跨群共享。
*
* 文件格式:
* ```
* ---
* name: kubejs-basics
* description: KubeJS 基础语法、常见报错与排查方法
* ---
*
* (正文:沉淀下来的知识/经验/提示词)
* ```
*
* 索引(name + description)常驻系统提示词,正文按需通过 loadSkill 工具加载,
* 以此实现"低上下文污染 + 可自我沉淀迭代"。
*/
object SkillStore {
private lateinit var dir: File
/** 内存索引缓存,key 为技能名。仅缓存元信息,正文每次按需读盘。 */
private val index = linkedMapOf<String, SkillMeta>()
/** 合法技能名:字母数字、下划线、连字符,防止路径穿越。 */
private val nameRegex = Regex("^[A-Za-z0-9_-]+$")
/**
* 在 onEnable 中调用一次,传入插件数据目录。随后可通过 [reload] 刷新。
*/
fun init(dataFolder: File) {
dir = File(dataFolder, "skills")
reload()
}
/** 重新扫描技能目录,重建内存索引。/jgpt reload 时调用。 */
@Synchronized
fun reload() {
if (!::dir.isInitialized) return
index.clear()
if (!dir.exists()) {
dir.mkdirs()
return
}
dir.listFiles { f -> f.isFile && f.extension.equals("md", ignoreCase = true) }
?.sortedBy { it.name }
?.forEach { file ->
try {
val (meta, _) = parse(file.readText())
val name = file.nameWithoutExtension
val desc = meta["description"].orEmpty()
index[name] = SkillMeta(name, desc)
} catch (_: Exception) {
// 单个文件解析失败不影响其它技能加载
}
}
}
/** 当前所有技能元信息。 */
val all: List<SkillMeta> @Synchronized get() = index.values.toList()
/**
* 构建注入到系统提示词 {skills} 占位符的索引文本,仅含 name + description。
*/
@Synchronized
fun buildIndexPrompt(): String {
if (index.isEmpty()) return "暂无技能"
return index.values.joinToString("\n") { "- ${it.name}: ${it.description}" }
}
/**
* 读取技能正文(不含 frontmatter)。技能不存在返回 null。
*/
@Synchronized
fun load(name: String): String? {
if (!isValidName(name)) return null
val file = File(dir, "$name.md")
if (!file.exists()) return null
return try {
parse(file.readText()).second
} catch (_: Exception) {
null
}
}
/**
* 新增或整篇覆盖一个技能(upsert)。迭代即"读全文→改→整篇写回"。
* @return 失败时返回错误信息,成功返回 null
*/
@Synchronized
fun save(name: String, description: String, content: String): String? {
if (!isValidName(name)) {
return "技能名非法,只能包含字母、数字、下划线、连字符:$name"
}
if (!::dir.isInitialized) return "技能目录未初始化"
if (!dir.exists()) dir.mkdirs()
val file = File(dir, "$name.md")
val safeDesc = description.replace('\n', ' ').trim()
val text = buildString {
appendLine("---")
appendLine("name: $name")
appendLine("description: $safeDesc")
appendLine("---")
appendLine()
append(content.trim())
appendLine()
}
return try {
file.writeText(text)
index[name] = SkillMeta(name, safeDesc)
null
} catch (e: Exception) {
"写入技能文件失败:${e.message}"
}
}
/**
* 删除一个技能。
* @return 是否删除成功(技能不存在返回 false)
*/
@Synchronized
fun delete(name: String): Boolean {
if (!isValidName(name)) return false
val file = File(dir, "$name.md")
index.remove(name)
return if (file.exists()) file.delete() else false
}
private fun isValidName(name: String): Boolean = nameRegex.matches(name)
/**
* 解析 frontmatter。返回 (元信息键值对, 正文)。
* 无 frontmatter 时元信息为空,正文为全文。
*/
private fun parse(raw: String): Pair<Map<String, String>, String> {
val text = raw.replace("\r\n", "\n")
if (!text.startsWith("---")) {
return emptyMap<String, String>() to text.trim()
}
val lines = text.split("\n")
// 第一行是 ---,找到下一处 --- 作为 frontmatter 结束
val endIdx = (1 until lines.size).firstOrNull { lines[it].trim() == "---" }
?: return emptyMap<String, String>() to text.trim()
val meta = mutableMapOf<String, String>()
for (i in 1 until endIdx) {
val line = lines[i]
val sep = line.indexOf(':')
if (sep > 0) {
val key = line.substring(0, sep).trim()
val value = line.substring(sep + 1).trim()
meta[key] = value
}
}
val body = lines.subList(endIdx + 1, lines.size).joinToString("\n").trim()
return meta to body
}
}
+50
View File
@@ -0,0 +1,50 @@
package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import net.mamoe.mirai.event.events.MessageEvent
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginConfig
import top.jie65535.mirai.SkillStore
/**
* 删除一个过时或失效的技能。
*/
class DeleteSkill : BaseAgent(
tool = Tool.function(
name = "deleteSkill",
description = "删除一个已过时或失效的技能。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("name") {
put("type", "string")
put("description", "要删除的技能名")
}
}
putJsonArray("required") {
add("name")
}
}
)
) {
override val isEnabled: Boolean
get() = PluginConfig.skillsEnabled
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
val name = args.getValue("name").jsonPrimitive.content
JChatGPT.logger.info("Delete skill: \"$name\"")
return if (SkillStore.delete(name)) {
"OK,技能 \"$name\" 已删除。"
} else {
"技能 \"$name\" 不存在。"
}
}
}
+50
View File
@@ -0,0 +1,50 @@
package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import net.mamoe.mirai.event.events.MessageEvent
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginConfig
import top.jie65535.mirai.SkillStore
/**
* 按需加载某个技能的正文进上下文。技能索引(name+简介)常驻系统提示词,
* 当话题命中某技能时调用本工具读取其完整内容。
*/
class LoadSkill : BaseAgent(
tool = Tool.function(
name = "loadSkill",
description = "当话题命中某个技能时,加载该技能的完整内容到上下文。可用技能见系统提示词中的技能索引。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("name") {
put("type", "string")
put("description", "技能名(技能索引中的 name")
}
}
putJsonArray("required") {
add("name")
}
}
)
) {
override val isEnabled: Boolean
get() = PluginConfig.skillsEnabled
override val loadingMessage: String = "翻阅资料中..."
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
val name = args.getValue("name").jsonPrimitive.content
JChatGPT.logger.info("Load skill: \"$name\"")
val content = SkillStore.load(name)
return content ?: "技能 \"$name\" 不存在,可用技能见系统提示词中的技能索引。"
}
}
+62
View File
@@ -0,0 +1,62 @@
package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import net.mamoe.mirai.event.events.MessageEvent
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginConfig
import top.jie65535.mirai.SkillStore
/**
* 新增或整篇覆盖一个技能(全局,跨群共享)。
* 用于把群里学到/被纠正的知识沉淀下来;迭代时先用 loadSkill 读全文,改好后整篇写回。
*/
class SaveSkill : BaseAgent(
tool = Tool.function(
name = "saveSkill",
description = "沉淀或更新一个技能(知识文档),全局跨群共享。新增直接写;迭代时先 loadSkill 读全文,修改后整篇写回。技能名相同则覆盖。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("name") {
put("type", "string")
put("description", "技能名,kebab-case,只能含字母/数字/下划线/连字符,如 kubejs-basics。相同则覆盖")
}
putJsonObject("description") {
put("type", "string")
put("description", "一句话简介,会常驻技能索引,决定你以后何时加载它")
}
putJsonObject("content") {
put("type", "string")
put("description", "技能正文(markdown),沉淀的知识、经验或提示词。整篇内容,会覆盖旧版本")
}
}
putJsonArray("required") {
add("name")
add("description")
add("content")
}
}
)
) {
override val isEnabled: Boolean
get() = PluginConfig.skillsEnabled
override val loadingMessage: String = "记下来了..."
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
val name = args.getValue("name").jsonPrimitive.content
val description = args.getValue("description").jsonPrimitive.content
val content = args.getValue("content").jsonPrimitive.content
JChatGPT.logger.info("Save skill: \"$name\" - \"$description\"")
val error = SkillStore.save(name, description, content)
return error ?: "OK,技能 \"$name\" 已保存。"
}
}