Files
homeagent-sdk/tools/hmapdev/skills.go
JianFeeeee 74e2c654ed feat(sdk): 22 个示例插件配齐 skill + 三态启动声明 + 打包期校验
## 用户的三条要求

1. 「所有插件都默认自启会占启动时间,还在 llms 上下文塞大量工具」
   ⇒ 纯工具型改为按需加载,靠 skill 让 agent 知道要先拉起。
2. 「会向 agent 注入输入的部分插件不能这样做」
   ⇒ 9 个必须常驻,其余 13 个按需。
3. 「sdk 是不是没有暴露插件声明自身行为的方案」
   ⇒ 对:auto_start 只在 internal/plugin/manifest.go 有,SDK 零命中。

## 定级依据(代码证据,不是猜描述)

注入 / 钩子 ⇒ 必须常驻(9 个):

	qq         HTTP 监听 NapCat 推送,消息到达要立刻注入
	rss        轮询订阅源,新文章主动推送
	calendar   30s ticker 检查提醒
	memo       5min ticker 检查待办到期
	a2a        TCP 监听外部 Agent 请求
	acp        TCP 监听 ACP 客户端
	browser    ticker + 长命令完成回调
	sanitizer  注册 stage 钩子(无工具,要每轮生效)
	luademo    注册 stage 钩子做示范

纯工具型 ⇒ 按需(13 个):
	weather ocr music bili ai_image files editdoc deepsearch
	plugindev recoverydiag vanblog vikunja commondev

★ commondev 值得单独说:它虽然有 6 处 Inject 调用,但**注入源是自己的
sidecar 完成回投**,而 sidecar 只在 commondev_agent_up 时才拉起
(Start() 里不启 sidecar,通道也在 agent_up 时才注册)。所以没有 agent
调用它时,它不需要做任何事 —— 可以按需。44 个工具 ≈726 tokens 省下来可观。

## 改动

 · 22 个插件各配一个 skill,写明:何时用、先 plugin_start、
   工具清单、**用 plugin_stop 而不是 plugin_disable**
 · 13 个 plg.json 加 `auto_start: false`
 · `sdk/autostart.go`:把三态语义与"按需插件必须有 skill"的理由写进公开 SDK
 · hmapdev 打包期校验:`auto_start=false` 但无 skill/ 目录 ⇒ 直接 exit 1
   (理由:缺 skill 的按需插件**等价于没装**,比没装更糟 —— 占着磁盘让人以为可用)
 · 修 Lua 打包路径:它**完全不走** rawfile/skill 收集,luademo 的 skill 永远打不进去
 · 修 build-examples.sh:原来 `[ ! -x ]` 判"二进制在不在",改了 hmapdev 源码后
   重新打包**仍用旧工具链**(实测:luademo 的 skill 一直是 0)
 · 补 mocksdk.ToolDef.Timeout(上一轮加 ToolDef.Timeout 时漏了镜像)

## 判据

 · TestHasSkillDirRequiresRealSkillMd(空目录/.gitkeep/无 SKILL.md 都不算)
 · TestBuildRejectsOnDemandWithoutSkill(打包期必须真拦住 + exit 1)
 · TestLuaPackagingCollectsSkillAndRawfiles(**验代码行**,不验注释 —— 第一版
   只查符号存在,把 createHmapWithRaw 换回 createHmap 判据照样通过)

## 踩过的坑

 · Lua 分支的结束边界原是 `// Resolve build config`,但那个注释在文件里
   **出现多次**(Go 路径里也有),用它截取会把 Go 分支一起包进来 ⇒
   变异测不出。改成截到 Lua 分支自己的 `return` + `}`。
2026-10-18 13:21:47 +08:00

151 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// 随包 skill 的打包(`skill/` 目录)。
//
// ## 为什么与 `rawfile/` 分开,而不是放进 rawfile/
//
// 两者**意图不同**:
//
// rawfile/ 任意随包资源(node 二进制、pi 依赖、配置模板…)——内核只是解开
// skill/ 要**登记给 skillmgr** 的纯 SKILL 条目 —— 内核安装后会移交注册
//
// 若把 skill 放在 rawfile/ 下(如 `rawfile/skills/x/SKILL.md`),内核就必须
// 靠**子路径名**去猜哪些是要注册的 —— 那是隐式约定,正是本仓反复吃过亏的
// 模式(例:config.db 因"默认 0644"这个隐式假设而长期明文)。
// 显式目录让意图在**结构**里可见。
//
// ## 解包形态
//
// 与 rawfile 一样**保留路径**(不拍平):
//
// <插件目录>/skill/<name>/SKILL.md
//
// 内核侧(pluginmgr)扫 `<插件目录>/skill/*` 并把每个目录发
// skill_detected 事件,由 skillmgr 接管注册(见
// internal/plugins/pluginmgr/skill_handout.go)。
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// SkillDir 是工程根下存放随包 skill 的目录(与 rawfile/ 并列)。
const SkillDir = "skill"
// SkillZipPrefix 是解包后这些条目的统一前缀。
//
// 与 SkillDir 同值 —— 保留这个独立常量是为了让"打包目录名"与
// "包内前缀"可以分别演进(rawfile 那边是两个同名常量,同样的理由)。
const SkillZipPrefix = "skill"
// collectSkillFiles 收集 `skill/` 目录下的条目。
//
// 与 collectRawFiles 的差别:
// - **只收一层**:skill 是「每个子目录一个 skill」,嵌套不再是 skill。
// 递归收会让 `skill/a/.git/SKILL.md` 这种被当成 skill 发出去。
// - **只收目录**:单文件 skill 不在本约定内(skillmgr 支持,
// 但随包分发统一用目录形态,避免两种形态都要判)。
// - 仍然排除脚手架(.gitkeep/.gitignore)。
func collectSkillFiles(root string) ([]rawEntry, error) {
dir := filepath.Join(root, SkillDir)
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
// 没有 skill/ 是**常态**(绝大多数插件不带),不是错误
return nil, nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("读取 %s/ 失败: %w", SkillDir, err)
}
var out []rawEntry
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue // 脚手架/隐藏项
}
if !e.IsDir() {
// 明确拒绝而不是静默跳过:放在 skill/ 下的文件几乎肯定是放错了
//(要么该是 skill/<name>/SKILL.md,要么该放 rawfile/)。
return nil, fmt.Errorf(
"%s/%s 是文件;%s/ 下只接受**目录**(每个目录一个 skill,形如 %s/<name>/SKILL.md)"+
";普通资源请放 rawfile/",
SkillDir, name, SkillDir, SkillDir)
}
sub := filepath.Join(dir, name)
// 每个 skill 目录下的文件**递归保留结构**
// (有的 skill 带 references/、assets/ 等子目录)。
err := filepath.Walk(sub, func(p string, fi os.FileInfo, werr error) error {
if werr != nil {
return werr
}
if fi.IsDir() {
return nil
}
rel, err := filepath.Rel(dir, p)
if err != nil {
return err
}
if IsRawFileScaffold(rel) {
return nil
}
out = append(out, rawEntry{
src: p,
zip: filepath.ToSlash(filepath.Join(SkillZipPrefix, rel)),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("展开 %s/%s 失败: %w", SkillDir, name, err)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].zip < out[j].zip })
return out, nil
}
// skillFilesSummary 给构建输出一行摘要(0 个时不打印)。
func skillFilesSummary(ents []rawEntry) string {
if len(ents) == 0 {
return ""
}
// 数 skill 个数(一层目录名去重)而不只是文件数 —— 后者对用户无意义
names := map[string]bool{}
for _, e := range ents {
rest := strings.TrimPrefix(e.zip, SkillZipPrefix+"/")
if i := strings.Index(rest, "/"); i > 0 {
names[rest[:i]] = true
}
}
var list []string
for n := range names {
list = append(list, n)
}
sort.Strings(list)
return fmt.Sprintf("%d 个 skill(%s,共 %d 个文件)",
len(list), strings.Join(list, ", "), len(ents))
}
// hasSkillDir 判断工程根是否有**至少一个真实 skill**(供打包期校验用)。
//
// 为什么不用简单的 os.Stat(SkillDir):空目录、只有 .gitkeep 的脚手架目录
// 都会让它返回 true,而那种情况下 agent 一样拿不到任何说明 ⇒ 校验形同虚设。
// 这里复用 collectSkillFiles 的实际收集结果,与打包时的判定**同源**,
// 避免"校验说有、打包说没有"的分叉。
func hasSkillDir(root string) bool {
files, err := collectSkillFiles(root)
if err != nil {
return false
}
// 收集到的条目里必须真的有 SKILL.md 才算一个可用 skill:
// 只有目录没有说明文件的条目,注册到 skillmgr 也是空壳。
for _, f := range files {
if strings.HasSuffix(f.zip, "SKILL.md") {
return true
}
}
return false
}