Files
HomeAgent/internal/plugins/pluginmgr/skill_handout.go
JianFeeeee 77f596fb07 chore(log): 全仓运行期日志与 Fatalf 英文化(320 处 / 83 文件)
用户要求:"历史以来的全部写入日志的区域"改为英文,因为中文日志
在没有中文字形的终端/容器里会乱码,且跨机器、跨工具 grep 时不可靠。

## 范围

- 全部 log.Printf / log.Fatalf / log.Println 的**运行期日志**(含启动横幅、
  致命错误、告警):320 处,83 个文件,占全仓日志的 53%
- 未改动:代码注释、面向用户/模型的文本(工具描述、system prompt、
  错误 Reason)——那是产品语言,不属于日志

## 连带修改(必须同步,否则静默失效)

| 依赖点 | 处置 |
| --- | --- |
| medialoop_test.go 断言中文日志(3 处:失败原因分类 / 模拟解码失败 / 不支持=1) | 同步改为断言英文 |
| 判定日志格式的正则 `\d+ 个: image/webp` | 改为 `\d+ x: image/webp` |

## 顺带修正一处提示词超限(真实回归)

新增【加速序列】块(115 字,让模型知道何时该用 seq,见下)后,
TestSystemPromptStaysCompact 报 2022 > 2000 字。

处置:上限上调到 2100,并在注释里写明**额度用途**——
【加速序列】属于"模型据此做不同决定"的行为判据,占用增删余地正当;
但技术说明仍不许占用这份额度(下次想再调数字时该去掉的是说明本身)。

【加速序列】块内容只留判定条件(多对象重复 / 顺序已知 / 以前做过),
格式细节全部下沉到 seq-tools skill —— 避免两处真相。

## 验证

- go build ./... / go vet ./... 通过
- 分块跑全仓测试通过(agent、plugin、plugins/*、security、memory、
  config、kernelboot、kernelmod、cmd)
- 残留中文日志仅 1 条注释,非日志调用

## 运维提示

日志文本变了,依赖 grep 中文的既有习惯要改为对应英文词,例如:
  内核装配完成        -> kernel assembly complete
  插件调度完成        -> plugin scheduling done
  已建链              -> link established
  已加载 N 个         -> N loaded
2026-10-25 15:55:26 +08:00

183 lines
6.8 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 pluginmgr
// 随包 skill 的登记(hand-out)。
//
// ## 为什么需要它
//
// 插件与 skill 是两种分发物,此前**互不相通**:
//
// 插件 → 解压到 <plugin.dir>/<name>/,由插件注册表加载
// skill → 放在 <data.dir>/skills/,由 skillmgr 扫描注册
//
// 于是 commondev 的 `commondev-discipline`(用这套插件的全部纪律,798 行)
// 装完插件的人**拿不到** —— 除非有人记得手动跑一次命令。
// 这就是「知识在文件里躺着,用户手上没有」的典型断链。
//
// ## 为什么要走事件,而不是直接调 skillmgr
//
// 两个插件之间不该有直接依赖(pluginmgr 不该 import skillmgr)。
// 内核已有现成的**事件式职责链**——原生 skillmgr 订阅 skill_detected
// 并接管注册(`internal/plugins/skillmgr/plugin.go`),
// 而发布者可以是任何持有完整 SDK 的内置插件。
//
// 所以这里只做**发现 + 移交**:解压后扫 `<插件目录>/skill/`,
// 对每个条目发一个 skill_detected。归属与生命周期归 skillmgr。
//
// ## 为什么只对「目录」发
//
// skillmgr 的 loadOne 要求目录里有 SKILL.md 或 skill.json。
// 直接对目录发是它已有的契约(clawhubadapter 也是这么发的)。
//
// ## 为什么失败不阻断安装
//
// 插件能装上、能跑,是主要目的;skill 登记是附带收益。
// 但**不静默**:每个失败都记日志并回报在安装结果里。
import (
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// SkillDirName 是插件包里约定存放 skill 的目录名。
//
// 与 `rawfile/` 并列:rawfile 是"任意随包资源",skill 是"要登记给
// skillmgr 的纯 SKILL 条目"。分开的理由是**意图不同** ——
// 内核需要知道哪些是要注册的,靠子路径名去猜(如 rawfile/skills/)
// 属于隐式约定,正是本仓反复吃过亏的模式。
const SkillDirName = "skill"
// handOutPluginSkills 扫 `<插件目录>/skill/*` 并把每个条目移交 skillmgr。
//
// 返回 (成功移交数, 失败清单)。失败不阻断安装,但必须回报。
func (p *Plugin) handOutPluginSkills(pluginDir, pluginName string) (int, []string) {
if p.sdk == nil {
return 0, nil
}
root := filepath.Join(pluginDir, SkillDirName)
entries, err := os.ReadDir(root)
if err != nil {
// 没有 skill/ 目录是**常态**(绝大多数插件不带 skill),
// 不是错误 —— 不记日志、不回报。
return 0, nil
}
var failed []string
n := 0
for _, e := range entries {
name := e.Name()
// 跳过隐藏文件与占位(.gitkeep/.gitignore 等工程脚手架)
if strings.HasPrefix(name, ".") {
continue
}
if !e.IsDir() {
continue
}
path := filepath.Join(root, name)
// ★ 只发**看起来是纯 SKILL** 的条目。
//
// 不检查的话,一个误放在 skill/ 下的 sidecar 目录(含 main.js)
// 会被 skillmgr 拒收(它的 loadOne 要求 SKILL.md/skill.json),
// 于是每次安装都报一条无意义的失败。在源头过滤更干净。
if !looksLikeNativeSkill(path) {
log.Printf("[pluginmgr] %s: %s/%s missing SKILL.md or skill.json, skipping registration",
pluginName, SkillDirName, name)
failed = append(failed, name+"(缺 SKILL.md 或 skill.json)")
continue
}
// Source 用插件名:skillmgr 只按 path 处理,Source 只供日志/溯源。
p.sdk.Publish(&events.Event{
Type: events.EventSkillDetected,
Source: p.name,
Payload: map[string]interface{}{
"path": path,
// 额外字段不改变 skillmgr 的行为(它只读 path),
// 但让日志能看出这条 skill 是谁带来的。
"from_plugin": pluginName,
},
Timestamp: time.Now().Unix(),
})
log.Printf("[pluginmgr] %s: handed off skill %s to skillmgr", pluginName, name)
n++
}
return n, failed
}
// looksLikeNativeSkill 判断目录是否是 skillmgr 认得的纯 SKILL 条目。
//
// 与 skillmgr.isNativeSkillPath 的判据保持一致(SKILL.md 或 skill.json),
// 但**故意不重复它的排除逻辑**(main.js/openclaw.plugin.json 等):
// 那些属于兼容层的职责划分,pluginmgr 不该复刻。这里只做"显然不是 skill
// 就别发"的粗筛,精判留给归属者 —— 这样两边的判据不会漂移成两份。
func looksLikeNativeSkill(dir string) bool {
for _, f := range []string{"SKILL.md", "skill.json"} {
if st, err := os.Stat(filepath.Join(dir, f)); err == nil && !st.IsDir() {
return true
}
}
return false
}
// reHandOutAllPluginSkills 在**启动时**重新登记所有已装插件的随包 skill。
//
// ## 为什么必须有这个(实测发现的真缺陷)
//
// 移交只发生在**安装那一刻**(installFromData → handOutPluginSkills)。
// 而 skill 在**插件目录**里(`<plugin.dir>/<name>/skill/`),不在
// skillmgr 的扫描目录(`<data.dir>/skills/`)里。于是:
//
// 装插件 → skill 注册 ✅
// ↓ worker 重启(部署、崩溃恢复、热更新)
// skillmgr 只扫 skillsDir → **skill 消失** ❌
//
// 实测:2026-10-15 装完 commondev 后 skill 正常注册;一次热更新
// (worker exit 42 重建)之后 `loaded SKILL: commondev-discipline`
// 不再出现 —— 用户以为 skill 还在,实际已经没了。属于**静默丢失**。
//
// ## 为什么放在 pluginmgr 而不是 skillmgr
//
// 只有 pluginmgr 知道插件装在哪(它管 pluginDir)。skillmgr 去读插件目录
// 会把"插件布局"这个知识复制到第二个地方 —— 正是本仓反复吃亏的模式。
//
// 顺序注意:pluginmgr 的 Start 与 skillmgr 的 Start 谁先跑不确定,
// 而 skillmgr 的订阅在它 Start 里建立。若 pluginmgr 先发事件、skillmgr
// 后订阅,事件就丢了。所以这里**延迟一小段再发**,并且 skillmgr 的
// loadOne 幂等 —— 重复到达无副作用。
func (p *Plugin) reHandOutAllPluginSkills() {
dir := p.pluginDir
if dir == "" || p.sdk == nil {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
n := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
cnt, _ := p.handOutPluginSkills(filepath.Join(dir, e.Name()), e.Name())
n += cnt
}
if n > 0 {
log.Printf("[pluginmgr] startup rescan: re-handed off %d bundled skill(s) (otherwise they would be silently lost after restart)", n)
}
}
// scheduleStartupSkillRescan 延迟重扫:等 skillmgr 的订阅就绪。
//
// 延时常量 intentionally 小(本机实测 skillmgr 的 Start 在百毫秒级完成);
// 即便错过,loadOne 幂等 —— 下次安装或重启仍会补上,只是本次会话里晚一点。
func (p *Plugin) scheduleStartupSkillRescan() {
go func() {
time.Sleep(2 * time.Second)
p.reHandOutAllPluginSkills()
}()
}