猫咖
首页博客工具
搜索
语言
切换网站风格
选择主题颜色
点击特效
主题

猫咖 · 持续更新中,源码见 GitHub。

Index 07: Skill Loading — 按需注入式技能系统

2026年8月2日
AI智能体Kotlin后端

系列

使用Kotlin从0开发一个ClaudeCode

系列

使用Kotlin从0开发一个ClaudeCode

进度 7 / 21

使用Kotlin从0开发一个ClaudeCode

上一篇

Index 06: Subagent — 异步后台子智能体

下一篇

Index 08: Context Compact — 四层上下文压缩

目标

s01 到 s06 让智能体具备了工具调用、权限管控、Hook 扩展、TodoWrite 规划、子智能体并发能力。但它仍然"不知道自己能做什么"——所有行为都靠 LLM 从系统提示中猜。

如果你想让它支持新场景(如 "Figma 设计稿转代码"),唯一办法是改 Main.kt 写死 systemPrompt。每加一个场景就要重新编译、重新发布——这违背了"扩展不修改"的开闭原则。

Index 07 的目标是引入按需注入式技能系统:

  1. 扫描式加载——启动时扫描 ./.cat-code/skills 与 ~/.cat-code/skills 下的 SKILL.md 文件(Claude Code 规范)
  2. PRE_LLM_REQUEST hook——新增生命周期事件,让外部扩展点能在 LLM 请求发出前追加消息
  3. SkillInjectionHook——把所有已加载 skill 的 name + description 拼成 SYSTEM 消息追加到请求末尾
  4. load_skill 工具——让 LLM 通过工具调用主动获取某个 skill 的完整 body(按需加载,token 经济)
  5. ReplCommand 注册表——把 ReplLoop 硬编码的 when 块重构为命令模式,新增 /skill list|reload|show
  6. 零依赖 frontmatter 解析器——只解析 name 和 description 两个标量字段,不引入 snakeyaml

完成后的效果——用户可以在项目级或用户级目录下放一个 SKILL.md:

TEXT
.cat-code/skills/figma-to-code/SKILL.md
---
name: figma-to-code
description: Translate Figma designs into production code. Use when the user references a Figma URL or node ID.
---
# Figma to Code Workflow
1. Fetch design via the Figma MCP server
2. Extract design tokens...

启动 REPL 时会看到:

TEXT
🐱 Cat-Code — Index 07: Skill Loading (1 skills loaded)
Type /help for commands, /exit to quit.

每次 LLM 请求末尾都会被追加一条 SYSTEM 消息告诉它"现在你能做这些事,调用 load_skill 获取完整指令":

TEXT
# Available Skills

The following skills are available. Call the `load_skill` tool with the skill's name to retrieve its full instructions, then follow them.

## figma-to-code
Translate Figma designs into production code. Use when the user references a Figma URL or node ID.

LLM 看到这条消息后会根据用户输入判断是否触发该 skill。触发时调用 load_skill(name="figma-to-code") 工具拿到 body 中的完整步骤,再按步骤执行——未使用的 skill body 不占 context。


架构:按需注入式技能系统

Index 07 在 s06 的基础上增加了一个"PRE_LLM_REQUEST 扩展点"和"扫描式 skill 加载"。它不修改 AgentLoop 的核心循环逻辑,只增加一个 hook 注入点:

TEXT
                用户输入 "把这个 Figma 链接转成代码"
                              │
                              ▼
                    ┌──────────────────┐
                    │   主 AgentLoop   │
                    │  messagesHistory │
                    └──┬─────────────┘
                       │ buildRequestMessages() → [SYSTEM, USER, ...]
                       ▼
            ┌──────────────────────────────────┐
            │  s07: onPreLlmRequest hook       │
            │                                    │
            │  HookManager.firePreLlmRequest    │
            │   ↓ 遍历 PRE_LLM_REQUEST handlers │
            │  SkillInjectionHook               │
            │   → 读 SkillStore.getAll()         │
            │   → 拼 "# Available Skills..."    │
            │   → AppendMessages([SYSTEM(msg)])│
            └──────────────────────────────────┘
                       │
                       ▼ requestMessages = baseMessages + appended
                       │
                       ▼ LLM 看到 skill metadata,判断该不该用 figma-to-code
                       │
                       ▼ (s08 才注入 body;s07 LLM 只看到 name+description)

关键洞察:SkillInjectionHook 与其他 hook 一样是普通 HookHandler,注册到同一个 HookManager。它不修改 AgentLoop 的核心循环,只在 buildRequestMessages() 之后追加一条 SYSTEM 消息。

层Index 06 状态Index 07 变更
HookPRE/POST_TOOL_USE 两类事件+PRE_LLM_REQUEST 事件 + AppendMessages 结果
AgentAgentLoopHooks 3 字段+onPreLlmRequest 字段
配置AgentConfig 5 字段+skillPaths 字段(默认两条路径)
REPL硬编码 when 处理 /exit /help /clear重构为 ReplCommandRegistry,新增 /skill
Skill—新建 skill/ 包,5 个主类 + 1 个 hook
REPL 命令命令是 ReplLoop 私有常量新建 repl.commands/ 包,4 个 ReplCommand 实现

第一层:PRE_LLM_REQUEST hook 扩展点

为什么需要新事件类型?

s04 的 PRE_TOOL_USE / POST_TOOL_USE 只覆盖工具调用。但很多扩展点需要在"LLM 请求发出前"介入:

  • 注入 skill 元数据——让 LLM 知道有哪些 skill 可用
  • 注入上下文压缩摘要(s08 计划)——历史消息超过 token 上限时替换为压缩摘要
  • 注入会话级用户偏好——如用户的代码风格、语言偏好

这些都需要"在请求消息构造完成后、发出前"的扩展点。我们新增 PRE_LLM_REQUEST。

HookEvent 扩展(向后兼容)

KOTLIN
data class HookEvent(
    val type: HookEvent.Type,
    val toolCall: ToolCall = ToolCall(id = "", name = "", input = buildJsonObject {}),
    val result: ToolResult? = null,
    val messages: List<Message> = emptyList()  // 仅 PRE_LLM_REQUEST 时非空
) {
    enum class Type {
        PRE_TOOL_USE,
        POST_TOOL_USE,
        PRE_LLM_REQUEST
    }
}

三个字段都加了默认值:旧代码 HookEvent(Type.PRE_TOOL_USE, toolCall) 仍可编译,新增的 messages 字段默认空 list。Kotlin data class 的默认参数会自动生成多构造器。

HookResult 新增 AppendMessages

KOTLIN
sealed class HookResult {
    object Continue : HookResult()
    data class Block(val reason: String) : HookResult()
    data class Replace(val result: ToolResult) : HookResult()
    data class AppendMessages(val messages: List<Message>) : HookResult()  // 新增
}

每种 HookResult 语义只对特定事件类型合法——这避免了误用:

HookResultPRE_TOOL_USEPOST_TOOL_USEPRE_LLM_REQUEST
Continue✓ 放行✓ 放行✓ 不追加
Block✓ 阻止工具✗ warn 忽略✗ warn 忽略
Replace✗ warn 忽略✓ 替换结果✗ warn 忽略
AppendMessages✗ warn 忽略✗ warn 忽略✓ 追加消息

Kotlin 编译器会强制 exhaustive when (HookResult) 报错,所有 when 都必须补齐 AppendMessages 分支——编译期发现遗漏。

HookManager.firePreLlmRequest 聚合规则

KOTLIN
suspend fun firePreLlmRequest(messages: List<Message>): List<Message> {
    val event = HookEvent(type = HookEvent.Type.PRE_LLM_REQUEST, messages = messages)
    val results = fire(event)
    val appended = mutableListOf<Message>()
    for (r in results) {
        when (r) {
            HookResult.Continue -> { /* no-op */ }
            is HookResult.Block -> logger.warn { "PreLlmRequest handler returned Block, ignoring" }
            is HookResult.Replace -> logger.warn { "PreLlmRequest handler returned Replace, ignoring" }
            is HookResult.AppendMessages -> appended.addAll(r.messages)
        }
    }
    return appended
}

聚合规则与 firePreToolUse / firePostToolUse 一致:

  • 所有 AppendMessages 按注册顺序合并追加(s07 MVP 通常只有一个 handler——SkillInjectionHook)
  • Block / Replace 在 PreLlmRequest 中语义不合法,记 warn 并忽略(与 firePreToolUse 忽略 Replace、firePostToolUse 忽略 Block 一致)
  • Continue 等同空追加

AgentLoop 注入点

KOTLIN
for (iteration in 1..config.maxIterations) {
    val baseMessages = buildRequestMessages()
    // s07: PRE_LLM_REQUEST hook —— 让外部扩展点追加消息(skill 元数据等)
    val appended = hooks.onPreLlmRequest?.invoke(baseMessages) ?: emptyList()
    val requestMessages = if (appended.isEmpty()) baseMessages else baseMessages + appended
    logger.debug { "Iteration $iteration/${config.maxIterations}: sending ${requestMessages.size} messages to LLM" }

    val response = llmProvider.chat(requestMessages, options, tools)
    // ...
}

注入时机在 buildRequestMessages() 之后——保留 SYSTEM + 历史 USER/ASSISTANT 顺序,把 skill metadata 追加到末尾。LLM 会把最近上下文优先注意,这正是我们想要的(让 skill 触发判断更敏感)。

为什么用 nullable + 默认 null 而不是空 lambda?

KOTLIN
data class AgentLoopHooks(
    val onBeforeToolExecute: (suspend (ToolCall) -> Boolean)? = null,
    val onPreToolUse: (suspend (ToolCall) -> String?)? = null,
    val onPostToolUse: (suspend (ToolCall, ToolResult) -> ToolResult)? = null,
    val onPreLlmRequest: (suspend (List<Message>) -> List<Message>)? = null  // 新增
)

null 时直接 ?: emptyList() 跳过调用,零开销。空 lambda 会触发一次无用调用,虽然语义无害但违背"扩展不增加默认成本"原则。


第二层:SkillManifest 与零依赖 frontmatter 解析器

SkillManifest——不可变值对象

KOTLIN
data class SkillManifest(
    val name: String,           // frontmatter.name,全局唯一,用作 SkillStore 的 key
    val description: String,    // frontmatter.description
    val body: String,           // frontmatter 之后的 markdown 原文,由 load_skill 工具返回给 LLM
    val sourcePath: String      // SKILL.md 的绝对路径,用于 /skill show 调试
)

body 不会自动注入到 LLM 请求——否则几十个 skill 会撑爆 context。s07 通过 load_skill 工具实现"按需加载":LLM 看到某 skill 的 description 决定使用时,主动调用 load_skill(name) 拿到完整 body,再按 body 中的步骤执行。这是 token 经济性与功能完整性的折中。

为什么不引入 snakeyaml?

Claude Code 的 SKILL.md spec 明确 frontmatter 只有 name 和 description 两个标量字段:

MARKDOWN
---
name: skill-name
description: A short description of when to use this skill.
---
# Markdown body

引入 snakeyaml 会增加 200KB+ 依赖、增加攻击面(YAML deserialization gadget)、增加心智负担(anchor/alias/tag 等概念)。项目内已建立"手动解析简单文本格式"先例——参考 EnvFile.kt:91-111 解析 .env 文件的方式,约 40 行就能实现:

KOTLIN
object SkillFrontmatter {
    const val FRONTMATTER_DELIMITER = "---"

    fun parse(content: String, sourcePath: String): SkillManifest {
        // 1. 校验首行 == "---"
        // 2. 找到第二个 "---" 行作为 frontmatter 结束
        // 3. 中间行用 parseLine 解析为 Map<String, String>
        // 4. name 必需(非空),description 可选(默认空串)
        // 5. body = 第二个 "---" 之后的内容(trimLeading)
    }

    internal fun parseLine(line: String): Pair<String, String>? {
        // 支持 `key: value`、`key: "value"`、`key: 'value'`、trim 空格
        // 不支持嵌套对象/列表/锚点(s07 不需要)
    }
}

支持三种值格式:

YAML
name: simple-value
description: "quoted value with: colon"
description: 'single quoted'

未知字段被忽略——向前兼容(s08 可能新增 triggers 字段,老解析器直接跳过)。

SkillLoadException——明确的错误语义

KOTLIN
class SkillLoadException(
    val sourcePath: String?,
    message: String
) : Exception(message)

sourcePath 让 SkillLoader 聚合失败信息时能直接拿到路径,不需要在 catch 块里再拼接。


第三层:SkillLoader 多层级解析

镜像 EnvFile 的多层级模式

KOTLIN
class SkillLoader(
    private val skillPaths: List<String>
) {
    fun load(): LoadResult {
        val skills = mutableMapOf<String, SkillManifest>()  // name → manifest,后加载覆盖
        val failures = mutableListOf<LoadFailure>()

        for (rawPath in skillPaths) {
            val path = expandHome(rawPath)
            if (!Files.exists(path)) continue  // 路径不存在不算错误
            if (!Files.isDirectory(path)) continue

            Files.list(path).use { dirs ->
                dirs.filter { Files.isDirectory(it) && !Files.isHidden(it) }.forEach { skillDir ->
                    val skillFile = skillDir.resolve(SKILL_FILE)
                    if (!Files.exists(skillFile)) return@forEach

                    try {
                        val content = Files.readString(skillFile)
                        val manifest = SkillFrontmatter.parse(content, skillFile.toString())
                        skills[manifest.name] = manifest  // 后加载覆盖
                    } catch (e: SkillLoadException) {
                        failures.add(LoadFailure(...))
                    } catch (e: Exception) {
                        failures.add(LoadFailure(..., "IO error: ${e.message}"))
                    }
                }
            }
        }

        return LoadResult(skills = skills.values.toList(), failures = failures)
    }
}

设计要点:

  1. 路径优先级:skillPaths 按顺序加载,后加载的同名 skill 覆盖先加载的。默认 ["./.cat-code/skills", "~/.cat-code/skills"],用户级覆盖项目级——团队共享默认配置,个人覆盖个性化
  2. 错误隔离:单个 SKILL.md 解析失败不阻塞其他 skill 加载,失败聚合到 LoadResult.failures 中供 /skill reload 命令展示
  3. ~ 展开:路径以 ~/ 开头时展开为 System.getProperty("user.home")
  4. 子目录扫描:每个含 SKILL.md 的子目录被识别为一个 skill(不递归扫描,避免深层嵌套)

LoadResult——成功与失败分离

KOTLIN
data class LoadResult(
    val skills: List<SkillManifest>,
    val failures: List<LoadFailure>
) {
    val successCount: Int get() = skills.size
    val failureCount: Int get() = failures.size
}

/skill reload 命令展示时可以分别报告"3 loaded, 1 failed",并打印失败列表帮助用户定位问题。


第四层:SkillStore

KOTLIN
class SkillStore {
    private val skills: CopyOnWriteArrayList<SkillManifest> = CopyOnWriteArrayList()

    fun reload(skills: List<SkillManifest>) {
        this.skills.clear()
        this.skills.addAll(skills)
    }
    fun getAll(): List<SkillManifest> = skills.toList()
    fun get(name: String): SkillManifest? = skills.firstOrNull { it.name == name }
    fun size(): Int = skills.size
    fun clear() = skills.clear()
}

与 TodoStore / SubagentStore 完全一致的存储模式:

  • class(非 object)——构造注入,支持未来子智能体隔离
  • CopyOnWriteArrayList——保证并发读安全(/skill reload 可能与主 AgentLoop 异步读 getAll() 并发)
  • getAll() 返回快照副本——避免外部修改内部状态

为什么 reload 是 clear + addAll 而不是直接替换 list?

CopyOnWriteArrayList 的引用不可变,但内容可变。clear + addAll 在两次操作之间会短暂出现空列表状态——但这是可接受的:reload 是 CLI 命令,用户主动触发,且只持续几毫秒。下一轮 LLM 请求读到空列表只是不注入 skill metadata,不会出错。

SkillStore 在 /clear 中不清空

KOTLIN
class ClearCommand : ReplCommand {
    override suspend fun execute(args: String, ctx: ReplContext): ReplCommandResult {
        ctx.agentLoop.messagesHistory.clear()
        ctx.approvalStore.clear()
        ctx.todoStore.clear()
        ctx.subagentStore.clear()
        // 不清空 ctx.skillStore —— skill 是项目/用户级配置,不是会话状态
        return ReplCommandResult.Continue
    }
}

/clear 是会话级重置——清空对话历史、权限记忆、待办、子任务。但 skill 是项目/用户级配置,跨会话保留——清空 skill 反而违背用户预期。


第五层:SkillInjectionHook

KOTLIN
class SkillInjectionHook(
    private val store: SkillStore
) {
    fun registerTo(manager: HookManager) {
        val handler: HookHandler = handler@{ event ->
            if (event.type != HookEvent.Type.PRE_LLM_REQUEST) {
                return@handler HookResult.Continue
            }

            val skills = store.getAll()
            if (skills.isEmpty()) {
                return@handler HookResult.Continue  // 空 store 不浪费 token
            }

            val text = buildSkillsMetadataText(skills)
            HookResult.AppendMessages(listOf(Message(Role.SYSTEM, text)))
        }
        manager.on(HookEvent.Type.PRE_LLM_REQUEST, handler)
    }

    internal fun buildSkillsMetadataText(skills: List<SkillManifest>): String = buildString {
        append("# Available Skills\n\n")
        append("The following skills are available. Call the `load_skill` tool with the skill's name to retrieve its full instructions, then follow them.\n\n")
        for (skill in skills) {
            append("## ${skill.name}\n")
            append("${skill.description}\n\n")
        }
    }.trimEnd()
}

s07 行为:始终注入所有 skill 的 name + description(不注入 body)。LLM 根据这些 metadata 判断是否调用某 skill,触发时调用 load_skill 工具按需加载 body(见下一节)。

注入位置:为什么放在请求末尾?

注入位置在 messagesHistory 之后,LLM 会作为最近上下文优先注意。这是有意的——skill metadata 是"提示触发"信息,应该比系统提示更靠后、更敏感。

为什么放在 skill 包而非 hooks/builtins?

依赖方向:skill → hooks → llm 单向合法(skill 是消费方)。若放在 hooks/builtins,则 hooks 包需要反向依赖 skill 包,破坏单向依赖。这与 TodoDisplayHook 放在 todo/ 包而非 hooks/builtins 的取舍完全一致。


第六层:SkillLoaderTool(load_skill 工具)

为什么需要这个工具?

只注入 metadata 不够——LLM 看到 "code-review skill 能审查代码" 后,要怎么审查?审查步骤是什么?这些都在 body 里。如果直接把所有 skill body 全注入,几十个 skill 会撑爆 context;如果完全不注入,metadata 就是空头支票。

SkillLoaderTool 是最简形式的"渐进式加载":

  • metadata 始终注入(轻量,所有 skill 的 name+description)
  • body 按需加载(LLM 显式调用 load_skill(name) 才拿到某 skill 的完整指令)
KOTLIN
class SkillLoaderTool(
    private val store: SkillStore
) : Tool {
    override val name = "load_skill"
    override val description = "Load the full content (body) of a skill by name. " +
        "Use this when you've decided to apply a skill from the available skills list."
    override val isReadOnly = true  // 只读 SkillStore,无副作用

    override suspend fun execute(input: JsonObject): ToolResult {
        val skillName = input["name"]?.jsonPrimitive?.content
            ?: return ToolResult("", "Error: name is required", isError = true)

        val skill = store.get(skillName)
            ?: return ToolResult("", "Error: skill not found: $skillName", isError = true)

        val content = buildString {
            append("# Skill: ${skill.name}\n\n")
            append("Description: ${skill.description}\n\n")
            append("## Instructions\n\n")
            append(skill.body)
        }
        return ToolResult("", content)
    }
}

设计要点

  1. isReadOnly = true:只读 SkillStore 无副作用,可与读工具并发
  2. 包路径 skill/:与 SkillInjectionHook 一致,保持 skill → tool 单向依赖(若放 tool.tools 会引入反向依赖)
  3. 空 body 友好处理:返回明确提示"has no body content",不当作错误
  4. store reload 后立即生效:/skill reload 后下一轮 LLM 调用就能看到新 skill,load_skill 也能加载——SkillStore 是 CopyOnWriteArrayList,并发读安全
  5. JSON Schema 明确描述何时使用:description 字段写"Use this when you've decided to apply a skill",让 LLM 知道调用时机

完整触发链路

TEXT
用户: "帮我 review SkillStore.kt"
   ↓
LLM 收到请求(末尾带 # Available Skills SYSTEM 消息)
   ↓
LLM 看到 "## code-review / 审查代码变更..."
   ↓
LLM 调用 load_skill(name="code-review")
   ↓
SkillLoaderTool 返回完整 body(4 步审查流程)
   ↓
LLM 按 body 中的步骤执行:git diff → 检查 5 个维度 → 输出报告

未使用的 skill 的 body 不会占用任何 context。


第七层:ReplCommand 注册表

为什么重构 ReplLoop?

s06 的 ReplLoop 用硬编码 when 块处理 /exit /help /clear:

KOTLIN
private fun handleCommand(input: String): Boolean {
    val cmd = input.split(" ", limit = 2)[0]
    return when (cmd) {
        CMD_EXIT, CMD_QUIT, CMD_Q -> { println("Exiting..."); false }
        CMD_HELP, CMD_H -> { printHelp(); true }
        CMD_CLEAR, CMD_C -> { /* clear 4 stores */ true }
        else -> { println("Unknown command: $cmd"); true }
    }
}

这违背了"扩展不修改"原则——每加一个命令都要改 ReplLoop。s07 要加 /skill,正好顺手重构。

ReplCommand 接口

KOTLIN
interface ReplCommand {
    val name: String          // "/exit"、"/skill"
    val description: String  // 用于 /help
    suspend fun execute(args: String, ctx: ReplContext): ReplCommandResult
}

sealed class ReplCommandResult {
    object Continue : ReplCommandResult()
    object Exit : ReplCommandResult()
}

data class ReplContext(
    val agentLoop: AgentLoop,
    val approvalStore: ApprovalStore,
    val todoStore: TodoStore,
    val subagentStore: SubagentStore,
    val skillStore: SkillStore,
    val skillLoader: SkillLoader
)

关键设计:

  1. 子命令解析在命令内部完成——/skill list 由 SkillCommand 自己解析 args = "list",保持 ReplLoop.handleCommand 简单
  2. ReplContext 聚合所有可变状态——避免每个命令都通过 ReplLoop 引用,也便于测试时构造 mock context
  3. suspend execute——为未来异步命令预留(虽然 s07 的命令都是同步的)

ReplCommandRegistry——主名 + 别名

KOTLIN
class ReplCommandRegistry {
    private val commands: MutableMap<String, ReplCommand> = mutableMapOf()
    private val primaryNames: MutableSet<String> = mutableSetOf()

    fun register(command: ReplCommand, vararg aliases: String) {
        commands[command.name] = command
        primaryNames.add(command.name)
        for (alias in aliases) {
            commands[alias] = command
        }
    }

    fun find(name: String): ReplCommand? = commands[name]

    fun all(): List<ReplCommand> =
        primaryNames.map { commands[it]!! }
}

all() 按主名去重——别名不重复出现,避免 /help 列出 /exit /quit /q 三个条目。

ReplLoop 委派

KOTLIN
private fun handleCommand(input: String): Boolean = runBlocking {
    val parts = input.split(" ", limit = 2)
    val cmdName = parts[0]
    val args = parts.getOrNull(1)?.trim() ?: ""

    val command = commandRegistry.find(cmdName)
    if (command == null) {
        println("Unknown command: $cmdName. Type /help for available commands.")
        return@runBlocking true
    }

    val ctx = ReplContext(
        agentLoop = agentLoop,
        approvalStore = approvalStore,
        todoStore = todoStore,
        subagentStore = subagentStore,
        skillStore = skillStore,
        skillLoader = skillLoader
    )
    when (command.execute(args, ctx)) {
        ReplCommandResult.Continue -> true
        ReplCommandResult.Exit -> false
    }
}

沿用 handleChat 的 runBlocking 模式(REPL 主线程本来就阻塞读 stdin,无死锁风险)。

四个 ReplCommand 实现

KOTLIN
// ExitCommand: /exit /quit /q
class ExitCommand : ReplCommand {
    override val name = "/exit"
    override val description = "Exit Cat-Code"
    override suspend fun execute(args: String, ctx: ReplContext) = run {
        println("Exiting...")
        ReplCommandResult.Exit
    }
}

// HelpCommand: /help /h
class HelpCommand(private val registry: ReplCommandRegistry) : ReplCommand {
    override val name = "/help"
    override val description = "Show available commands"
    override suspend fun execute(args: String, ctx: ReplContext) = run {
        // 从 registry.all() 聚合命令列表,按 name 排序
        println(buildHelpText(registry.all()))
        ReplCommandResult.Continue
    }
}

// ClearCommand: /clear /c
class ClearCommand : ReplCommand {
    override val name = "/clear"
    override val description = "Clear conversation history and stores"
    override suspend fun execute(args: String, ctx: ReplContext) = run {
        ctx.agentLoop.messagesHistory.clear()
        ctx.approvalStore.clear()
        ctx.todoStore.clear()
        ctx.subagentStore.clear()
        // 不清空 ctx.skillStore —— skill 是配置,不是会话状态
        ReplCommandResult.Continue
    }
}

// SkillCommand: /skill list|reload|show <name>
class SkillCommand : ReplCommand {
    override val name = "/skill"
    override val description = "Manage skills (list, reload, show)"
    override suspend fun execute(args: String, ctx: ReplContext): ReplCommandResult {
        // 子命令解析:list / reload / show <name>
    }
}

HelpCommand 持有 registry 引用——这是运行时注入,注册时构造:register(HelpCommand(this), "/h")。


第八层:ReplLoop 集成

KOTLIN
fun start() {
    subagentScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    todoStore = TodoStore()
    subagentStore = SubagentStore()
    // ... 工具注册 ...

    val hookManager = HookManager()
    LoggingHook().registerTo(hookManager)
    TimingHook().registerTo(hookManager)
    TodoDisplayHook().registerTo(hookManager)

    // s07: 加载 skills 并注册注入 hook
    skillStore = SkillStore()
    skillLoader = SkillLoader(config.skillPaths)
    val loadResult = skillLoader.load()
    skillStore.reload(loadResult.skills)
    logger.info { "Skills loaded: ${loadResult.successCount} ok, ${loadResult.failureCount} failed" }
    SkillInjectionHook(skillStore).registerTo(hookManager)
    // s07: 让 LLM 能通过 load_skill(name) 主动加载 skill body
    toolRegistry.register(SkillLoaderTool(skillStore))

    agentLoop = AgentLoop(
        llmProvider = llmProvider,
        systemPrompt = systemPrompt,
        config = config,
        hooks = AgentLoopHooks(
            onBeforeToolExecute = { tc -> pipeline.approve(tc) },
            onPreToolUse = { tc -> hookManager.firePreToolUse(tc) },
            onPostToolUse = { tc, result -> hookManager.firePostToolUse(tc, result) },
            onPreLlmRequest = { msgs -> hookManager.firePreLlmRequest(msgs) }  // 新增
        ),
        toolRegistry = toolRegistry
    )

    // s07: 命令注册表
    commandRegistry = ReplCommandRegistry().apply {
        val exitCmd = ExitCommand()
        register(exitCmd, "/quit", "/q")
        register(HelpCommand(this), "/h")
        register(ClearCommand(), "/c")
        register(SkillCommand())
    }

    printWelcome()
    // ... REPL 主循环 ...
}

private fun printWelcome() {
    val skillCount = skillStore.size()
    val skillNote = if (skillCount > 0) " ($skillCount skills loaded)" else ""
    println("🐱 Cat-Code — Index 07: Skill Loading$skillNote")
    println("Type /help for commands, /exit to quit.")
    println()
}

欢迎语会动态显示已加载的 skill 数:

TEXT
🐱 Cat-Code — Index 07: Skill Loading (3 skills loaded)

关键设计取舍汇总

决策点选择理由
YAML 解析依赖零依赖手写Claude Code spec 只有 name/description 两字段;参考 EnvFile 先例
注入策略MVP 始终注入所有 metadatas07 不做触发关键词过滤,s08/s10 再优化
body 注入方式load_skill 工具按需加载metadata 始终注入(轻量);LLM 显式调用工具才拿 body;token 经济
子智能体加载 skill不加载子智能体任务 prompt 已显式给出,无需 skill metadata
SkillStore 在 /clear 中不清空skill 是项目/用户级配置,不是会话状态
HookEvent 字段扩展加默认值旧代码 HookEvent(Type.PRE_TOOL_USE, toolCall) 仍可编译
onPreLlmRequest 类型nullable lambdanull 时跳过调用,零开销
注入位置请求末尾LLM 优先注意最近上下文,skill 触发判断更敏感
SkillInjectionHook 包路径skill/ 包避免 hooks → skill 反向依赖,保持 skill → hooks → llm 单向
ReplLoop 重构命令注册表扩展不修改;新增命令只 register,不改 ReplLoop
子命令解析命令内部完成保持 ReplLoop.handleCommand 简单
ReplContext聚合所有 store便于测试构造 mock;避免每个命令引用 ReplLoop
别名支持register 时变长参数/exit /quit /q 一次注册,find 时任一别名都能命中
all() 去重按主名避免 /help 列出别名重复
多路径优先级后加载覆盖先加载用户级覆盖项目级(与 EnvFile 一致)
SkillLoader 错误处理聚合不抛单个 skill 失败不阻塞其他;失败信息供 /skill reload 展示
~ 展开System.getProperty("user.home")跨平台兼容,与 EnvFile 一致

测试策略

测试隔离原则

  • 所有 skill 测试用 Files.createTempDirectory 创建临时目录(参考 EnvFileTest 模式)
  • SkillStore / SkillLoader 测试每用例 new 局部实例
  • 不修改全局状态

关键测试用例

SkillFrontmatterTest(8 用例):

  • 正常解析 name + description + body
  • 缺 name 抛 SkillLoadException
  • 缺 frontmatter 抛 SkillLoadException
  • 带引号 description(含冒号)
  • 裸字符串 description
  • 空 body(只有 frontmatter)
  • 未知字段被忽略(向前兼容)
  • frontmatter 后无空行

SkillLoaderTest(8 用例,临时目录):

  • 单路径加载单个 skill
  • 多路径加载,后加载覆盖先加载(同名 skill)
  • 单个 SKILL.md 解析失败不阻塞其他
  • 空目录返回空列表
  • 路径不存在返回空列表(不抛异常)
  • 展开 ~/ 路径
  • SKILL.md 必须在子目录下(不递归扫描)

SkillStoreTest(6 用例):

  • reload 整体替换
  • getAll 返回快照副本
  • get 按 name 查找
  • clear 清空
  • size 返回当前数量
  • 并发场景下 CopyOnWriteArrayList 线程安全

SkillInjectionHookTest(5 用例):

  • 空 skillStore 返回空 list
  • 有 skill 时返回 1 条 SYSTEM 消息
  • 消息内容包含 name 和 description
  • store reload 后下次 fire 反映新状态
  • 非 PRE_LLM_REQUEST 事件忽略

ReplCommandRegistryTest(4 用例):

  • register + find 按主名查找
  • 别名查找(/quit /q 找到 ExitCommand)
  • all() 去重(别名不重复出现)
  • 未注册命令 find 返回 null

SkillCommandTest(8 用例):

  • list 空时打印 "No skills loaded"
  • list 非空时打印所有 skill name + description
  • reload 调用 skillLoader.load 并更新 skillStore
  • show 找到 skill 打印 body
  • show 未找到打印 "Skill not found"
  • show 无参数打印 usage
  • 无子命令打印 usage
  • 未知子命令打印 usage with warning

HookManagerTest(+4 用例):

  • firePreLlmRequest 无 handler 返回空 list
  • 聚合多个 AppendMessages 按注册顺序
  • Block/Replace 被忽略,只保留 AppendMessages
  • 把 messages 快照传给 handler

AgentLoopTest(+3 用例):

  • onPreLlmRequest 返回的消息出现在 fakeLLM.lastMessages 末尾
  • 无 onPreLlmRequest 时向后兼容(消息数不变)
  • onPreLlmRequest 返回空 list 时不修改请求

文件清单

TEXT
src/main/kotlin/com/sepcai/code/
├── skill/                              # [新建] 技能领域包
│   ├── SkillManifest.kt               # data class(name/description/body/sourcePath)
│   ├── SkillFrontmatter.kt            # 零依赖 frontmatter 解析器 + SkillLoadException
│   ├── SkillLoader.kt                 # 多路径扫描 + LoadResult 聚合
│   ├── SkillStore.kt                  # CopyOnWriteArrayList 内存存储
│   ├── SkillInjectionHook.kt          # PRE_LLM_REQUEST handler(注入 metadata)
│   └── SkillLoaderTool.kt             # load_skill 工具(按需返回 body 给 LLM)
├── hooks/
│   ├── HookEvent.kt                   # [修改] +PRE_LLM_REQUEST +messages 字段
│   ├── HookResult.kt                  # [修改] +AppendMessages 子类
│   └── HookManager.kt                # [修改] +firePreLlmRequest +两处 when 补分支
├── agent/
│   ├── AgentConfig.kt                 # [修改] +skillPaths 字段 + DEFAULT_SKILL_PATHS
│   ├── AgentLoop.kt                   # [修改] buildRequestMessages 后注入 hook
│   └── AgentLoopHooks.kt             # [修改] +onPreLlmRequest 字段
├── repl/
│   ├── ReplCommand.kt                 # [新建] 接口 + ReplCommandResult + ReplContext
│   ├── ReplCommandRegistry.kt         # [新建] Map + 别名支持
│   ├── ReplLoop.kt                    # [修改] 集成 skill + commandRegistry
│   └── commands/                      # [新建]
│       ├── ExitCommand.kt             # /exit /quit /q
│       ├── HelpCommand.kt             # /help /h(从 registry 聚合)
│       ├── ClearCommand.kt            # /clear /c
│       └── SkillCommand.kt            # /skill list|reload|show
└── tool/tools/                        # 不变

src/test/kotlin/com/sepcai/code/
├── skill/                             # [新建]
│   ├── SkillFrontmatterTest.kt        # 8 用例
│   ├── SkillLoaderTest.kt             # 8 用例
│   ├── SkillStoreTest.kt              # 6 用例
│   ├── SkillInjectionHookTest.kt      # 5 用例
│   └── SkillLoaderToolTest.kt         # 7 用例
├── repl/
│   ├── ReplCommandRegistryTest.kt     # [新建] 4 用例
│   └── commands/                      # [新建]
│       └── SkillCommandTest.kt        # 8 用例
├── hooks/
│   └── HookManagerTest.kt            # [修改] +4 个 firePreLlmRequest 用例
└── agent/
    └── AgentLoopTest.kt              # [修改] +3 个 PRE_LLM_REQUEST 集成用例

12 个新源文件 + 7 个修改源文件 · 7 个新测试文件 + 2 个测试修改 · 全部通过。


下一站

s07 完成了阶段三的第一个能力——技能加载。智能体现在能:

  • 启动时扫描项目级和用户级目录下的 SKILL.md
  • 每次向 LLM 发请求时自动注入所有 skill 的 name + description
  • 通过 load_skill 工具按需获取 skill body,未使用的 skill 不占 context
  • 通过 /skill list|reload|show 命令管理技能

但 s07 的 metadata 注入是"全部注入"——不管用户问什么,每次都把所有 skill 的 name+description 塞进请求。当 skill 数量增加到几十上百个时,token 占用会成为问题。s08/s10 会引入:

  • 按上下文相关性选择性注入——根据用户输入关键词匹配 skill description,只注入相关的
  • ContextCompact——历史消息超过 token 上限时替换为压缩摘要(通过 PRE_LLM_REQUEST hook 实现,与 SkillInjectionHook 共存)

下一站 s08 将优先解决上下文压缩——因为当对话变长时,没有压缩就无法测试大量 skill 注入的效果。

目录

当前章节:目标

  • 1. 目标
  • 2. Figma to Code Workflow
  • 3. Available Skills
  • 4. figma-to-code
  • 5. 架构:按需注入式技能系统
  • 6. 第一层:PRELLMREQUEST hook 扩展点
  • 7. 为什么需要新事件类型?
  • 8. HookEvent 扩展(向后兼容)
  • 9. HookResult 新增 AppendMessages
  • 10. HookManager.firePreLlmRequest 聚合规则
  • 11. AgentLoop 注入点
  • 12. 第二层:SkillManifest 与零依赖 frontmatter 解析器
  • 13. SkillManifest——不可变值对象
  • 14. 为什么不引入 snakeyaml?
  • 15. Markdown body
  • 16. SkillLoadException——明确的错误语义
  • 17. 第三层:SkillLoader 多层级解析
  • 18. 镜像 EnvFile 的多层级模式
  • 19. LoadResult——成功与失败分离
  • 20. 第四层:SkillStore
  • 21. SkillStore 在 /clear 中不清空
  • 22. 第五层:SkillInjectionHook
  • 23. 注入位置:为什么放在请求末尾?
  • 24. 为什么放在 skill 包而非 hooks/builtins?
  • 25. 第六层:SkillLoaderTool(loadskill 工具)
  • 26. 为什么需要这个工具?
  • 27. 设计要点
  • 28. 完整触发链路
  • 29. 第七层:ReplCommand 注册表
  • 30. 为什么重构 ReplLoop?
  • 31. ReplCommand 接口
  • 32. ReplCommandRegistry——主名 + 别名
  • 33. ReplLoop 委派
  • 34. 四个 ReplCommand 实现
  • 35. 第八层:ReplLoop 集成
  • 36. 关键设计取舍汇总
  • 37. 测试策略
  • 38. 测试隔离原则
  • 39. 关键测试用例
  • 40. 文件清单
  • 41. 下一站
回到顶部

相关推荐

查看全部文章
Index 21: Terminal UX —— Claude Code 风格终端交互

Index 21: Terminal UX —— Claude Code 风格终端交互

2026年8月21日

Index 21 将 cat-code 终端交互升级为 Claude Code 风格,解决旧 REPL 黑箱、无中断及输入体验差的问题。通过 JLine3 与 Mordant 实现历史补全、实时工具可见性、Spinner 状态及 Esc 中断。核心采用 UI 与 AgentLoop 解耦的事件流架构,支持内联权限菜单与优雅降级。同时配置 logback 收敛控制台日志,确保 TUI 清爽且功能无损,显著提升可用性。

Index 20: Comprehensive Agent —— 全机制集成(收口)

Index 20: Comprehensive Agent —— 全机制集成(收口)

2026年8月13日

作为收官之作,把 s01-s19 的二十个核心机制整合为一个全面智能体:统一的状态查询与运行周期、自治认领与工作区隔离的贯通,以及 /status 命令的全局可视化。全机制协同运转,标志着 Cat-Code 从零完整复刻 Claude Code 核心能力的收官。

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

2026年8月13日

引入 MCP(Model Context Protocol)插件机制,通过多传输适配与通道路由,把外部 MCP 服务器的工具动态接入智能体的工具池。工具注册从静态编译期扩展为运行时动态组装,让智能体能力随外部服务即插即用。