29 Commits

Author SHA1 Message Date
7ef9bc2ad3 docs(example): 为每个插件补 README
此前 example/ 下 21 个插件里,13 个完全没有 README,另 4 个是
`hmapdev init` 生成的脚手架样板(`# <name>` + `plugin build` + `Install` 三行,
等于从没被写过)。只有 deepsearch / vikunja / plugindev / luademo 是真实文档。

本次为 **17 个**插件写了真文档(13 个缺失 + 4 个样板),现在 21 个全部有内容。

## 写法

每个 README 覆盖:能力一句话 → 为什么需要 → 工具表 → 配置项表 →
通道与钩子(有才写)→ 构建 → 已知边界。

**事实全部从源码读出来,不推测**:
- 工具名核对到注册点(含 `tp+"x"` / `p.name+"_x"` 前缀拼接,展开成最终名)
- 配置键与默认值取自 `RegisterDef` / getStr 默认值
- 通道名、钩子名、依赖命令逐条 grep 确认
- 版本号与已部署实例交叉核对,17 个里 16 个一致

## 几处按源码写、与直觉不同的点

- **rss**:订阅时会把抓到的历史条目一次性标为 seen,所以订阅一个源
  **不会**把历史文章全推一遍 —— 这是避免刷屏的关键,写进了文档。
- **files**:路径校验是**两道**(规范化后判断 + 解析符号链接后再判断),
  只做前者的话沙箱里的软链接就能逃逸。两种情况报错文案不同。
- **qq**:身份必须**绑帧**而非存插件全局,源码注释记录了由此产生的两个真实故障
  (中断抢占恢复后权限门整体失效、运行中到达的消息改写正在跑那一轮的身份)。
  多来源合并时权限取**交集**。硬私有工具按前缀一律拒绝。这些是安全关键,
  单独成节写清楚。
- **memo**:待办与备忘录**刻意分两类**(一提醒一不提醒),提醒注入带 NoMemory。
- **sanitizer**:不注册任何工具,只挂三个阶段钩子;依赖 ABI v2 的 stage 写回能力。
- **editdoc**:本目录是 v1.0.0(单工具),而线上跑 v2.0.0(全能版,源码未公开)——
  在文档开头显式标注,**不按 v2 描述**,避免读者以为这里就是线上那份。

## 验证

- 21/21 文件非空且非样板(最小 913B,最大 6845B)
- 逐个核对 README 中出现的工具名能在源码找到依据;5 处报警经复核**全是误报**
  (`ai_image_generate`/`music_*` 前缀来自 metadata 的 name,`on_input` 等是钩子不是工具)
- README 版本号 vs 线上 plugin.json:16/17 一致,editdoc 的差异已显式说明

注:本仓既有未提交改动(example/qq/plugin.go、sdk/plugin.go)**未纳入本次提交**。
2026-09-20 00:43:06 +08:00
cfa72df3e9 fix(qq): 权限身份改为绑帧,修中断抢占/运行中到达导致的串权与失效
问题(都是插件全局 p.auth 一份状态引起):
- 中断抢占当前轮并把现场压栈,中断轮收尾 afterOutput 清空全局身份;外层
  恢复(resumeTask 复用同帧、不重跑 StageOnInput)后 auth.active=false,
  beforeToolcall 在 !active 处直接返回 —— 该轮剩余工具调用**完全不受门**。
- 运行中到达的新消息会调 activateAuthContext 改写全局身份,把正在跑的那一轮
  换成另一方的身份:换高即越权,换低即误拒。

改法:身份在 StageOnInput 绑定到本帧的 StageContext.Extra 上,beforeToolcall
以帧上身份为准(无绑定时才回退插件全局,兼容单测)。帧随中断栈一起压栈/恢复,
身份自然跟着走。

顺带:合并中断正文里的整批 message_id 现在全部消费(原来只清第一个,其余要等
generation 回收),新增 qqMessageIDsRe 支持 message_id=100,101,102 连写。
新增 4 条测试覆盖:中断恢复、运行中到达、整批 id 消费、非 QQ 轮不受门。
2026-09-14 16:45:09 +08:00
fe1c4cdb09 feat(qq): Bot 所有者消息升到 L2;普通消息保持 L1
jianf:所有者的话不该被普通人的消息打断/挤到队尾。

- 新增 interruptLevel(owner):owner → PriorityL2(一般提醒),其余 → PriorityL1(后台)。
  一批里只要有一条来自 owner,整批按 L2 投递。
- 为什么不是 L3:L3 是时钟/终端那类"实时",QQ 是异步消息,抬到 L3 会打断真正实时的工作。
- 新增测试 TestOwnerMessagesGetHigherInterruptLevel 钉住 owner=L2 / 普通人=L1。
2026-09-14 16:31:27 +08:00
a01fe21ab1 feat(qq): 同一会话连续消息合并为一次中断 + 示例 SDK 指回仓库源码
需求(jianf):同一个人连发的数条消息应打包成一次中断,别逐条唤醒 Agent。

- debounce 合并:同一会话 + 同一发送者(群聊按 群号+QQ、私聊按 QQ)在
  batch_window_ms(默认 1500)内的连续消息合成一批,每来一条重置计时;
  整批不超过 batch_max_ms(默认 30000),避免对方持续刷屏时一直不投。
- n>1 时中断说明「短时间连续发来 N 条」并列出 message_id,建议一次
  get_history 拿全上下文;n==1 沿用原文,行为与合并前逐字一致。
- 可配置 batch_window_ms / batch_max_ms,0 = 关闭合并(逐条投递)。
- Stop 时 flush 未到点批次,别把对方消息吞掉。
- 新增 4 条测试(同发送者合并 / 不同发送者不合并 / 窗口 0 逐条 / 单条沿用原文)。

顺带:deepsearch / vikunja 的 go.mod 与 plg.json 此前指向本地安装的 SDK 1.2.0,
导致无法用当前 SDK 重编(缺 InjectOptions.Priority)。改回 ../../ 仓库源码,
与其余示例一致。
2026-09-14 16:09:45 +08:00
4852d70d77 fix(qq): 输出工具不再受"当前会话身份"限制(修「可信 QQ 会话身份不完整」误拒)
现场(用户线上,驻留子联调回执原文):
  被**子的中断**唤醒的一轮里,父 agent 调用 output_send__qq(meta 带齐 user_id)被拒:
  「QQ 权限策略拒绝工具 output_send__qq:可信 QQ 会话身份不完整;请不要改用其他会话 ID 重试」

根因:`sessionToolArgsAllowed` 对**所有**工具都先要求"本轮能精确匹配可信 OneBot 事件"。
`onInputAuthContext` 在来源是 QQ 但匹配不到可信事件时会降权成
`auth = qqAuthContext{active: true}`(无 peer、非 owner)⇒ `currentPeer == 0`
⇒ 连**输出**也一并被拒 ✗。

但输出是 agent 的**主动调用**:发到哪个会话由它自己给的 meta(group_id / user_id)决定,
`handleChannelOutput` 已经强制要求该字段存在(缺了给明确报错)。再要求"当前会话身份"
是多余的门,而且会把合法发送一起挡掉 —— 设计上收到输入后可以往任意(已授权)通道
发任意多次。

改法:`output_send__qq` 在身份判据**之前**直接放行;「只能访问当前会话」这类限制
保留给**读取类**工具(get_history / mark_read / get_message)—— 那才真的不能跨会话读。

判据 `TestDowngradedAuthStillAllowsQQOutput`:
降权态下输出放行、读取类仍被当前会话限制挡住。
扰动验证:去掉放行分支 ⇒ 该判据报出与现场**一字不差**的那句拒绝。
线上实测:CLI 发起的轮次里 output_send__qq 返回 ok,插件日志 handleChannelOutput 确认送达。
2026-09-13 16:03:24 +08:00
4f4a03d368 feat(sdk): InjectOptions.Priority —— 插件声明自己中断的级别(L1-L3)
内核的输入调度器区分两类别:中断输入(可抢占)与排队输入(可被任何中断打断)。
中断的级别是“这项工作有多不能等”的声明,由插件在注入时给出:

    p.sdk.InjectInterruptTextOpts(src, ch, text, sdk.InjectOptions{
        NoMemory: true,
        Priority: sdk.PriorityL2,   // L1 完全可等 / L2 一般提醒 / L3 需及时
    })

- 新增 `InjectOptions.Priority string` 与 `PriorityL1/L2/L3` 常量(纯追加)。
- 空/非法值一律降级为 L1(默认级)——拼写错误不会被静默当成别的级别。
- **L4 由内核独占**(panic 中断、内核事件中断 selfip),插件声明 L4 会被内核
  夹到 L3,远端常量的取值域里也不提供 L4。
- 排队注入(InjectText*/InjectInputSync*)没有级别:它们本就是“不需及时处理”
  的那一类,可被任何中断打断;传了 Priority 也不会生效。
- 贯通链路:sdk.InjectOptions -> proc RPC 参数(priority)-> 内核 payload;
  tools/hmapdev 模板同步透传(三个注入的 6 个 Opts 变体共用 applyInjectOpts)。
- example/qq 显式声明 L1:QQ 消息既不是时钟那样的实时工作,也不是紧急工作。

兼容性:零值等价于旧行为(L1),既有插件无需改动。
2026-09-13 07:00:59 +08:00
ba49dfda44 feat(sdk): 注入行为的记忆/裁剪标志位(纯追加)+ plugindev 退出码修复 + 示例 hmap 随发版
## 1. 注入标志位(公开 API 纯追加,无签名变更)

给注入行为补上工具早已有的两类声明位,并让通道定义也带上:
- `InjectOptions{NoMemory, ContextPolicy, CleanerName}`
- `IOInjector` 新增六个 `*Opts` 变体(排队/中断/同步 × 纯文本/带媒体)
- `ChannelDef.ContextPolicy`,并**补上 JSON tag**(Cleaner 标 `json:"-"`)

零值 InjectOptions 与旧的三参数方法完全等价(记入记忆 + 不裁剪),
存量插件不需要改一行、也不需要重编;旧方法保留为转发到零值 opts 的语法糖。

三条设计要点:
- **默认不裁剪**:裁剪会归档丢弃低相关事件,必须显式声明(ContextPolicy=prune)。
- **中断也允许声明 prune**(已确认):中断同样携带内容进上下文。
- `CleanerName`:注入的 source 未必是注册过的输入通道名,而注入内容常带
  ANSI/JSON 包装;允许显式指定用哪个已注册 cleaner 清洗。

顺带修掉一个易静默丢字段的坑:`ChannelDef` 原来没有 JSON tag,只能手写字段
白名单跨进程传(`{"NoMemory": ...}`),新增字段会被丢掉。现在模板整体传 `def`。

## 2. 示例调用点统一写明意图
rss/memo/calendar/qq 的中断注入显式 `NoMemory: true`(行为等价,写清语义)。

## 3. plugindev 出错却 exit 0(真缺陷)
`buildBundle`/`buildTarget` 遇错只 Printf 后 return,`cmdBuild` 返回 void,
于是**构建失败也退 0**。实测中一个示例的 windows 目标编译失败,批量脚本却报
「17/17 全绿」,并因此少产出 16 个 .hmap。现在累计 `buildFailed` 并以非零退出。

## 4. 平台策略:插件目标去掉 windows
homed 已放弃 Windows 原生(见核心仓 cmd/homed/platform_windows.go:插件体系依赖
fd 继承 + 统一共享内存区的段内偏移解引用,Windows 句柄模型无法表达),
插件只运行在 homed 能跑的平台上,故 `allBundleTargets` 去掉 windows,
并对 windows 目标给出**可执行的报错**(指引 WSL2),而不是让它死在一句
`undefined: attachUnifiedShm` 上。

## 5. 发版附带各示例插件的 .hmap
新增 `package/build-examples.sh` 并接入 `package/build.sh`(组件 all|plugindev|examples):
- 用**刚构建出来的**那把工具链构建示例,保证与本次发版同源
- 逐平台 `--no-bundle --target <os/arch>`(bundle 会连 windows 一起编)
- 判成功同时看**退出码 + 产物存在**
- 全部产物齐了才 `sha256sum`(边打边算会漏掉后生成的包)
- 有任一失败即整体失败,不生成 SHA256SUMS

## 6. 版本
SDK 仍为 1.2.0(main 是下一个未发布中版本);1.2.0 条目补记本次新增接口,
并注明新标志位需核心 1.2.0+(旧核心会忽略这些字段,不报错但不生效)。
2026-09-11 20:29:30 +08:00
0a164fe4b9 feat(qq): qq_get_message 声明 ContextPolicy=prune(§13.8 验证项)
消息正文只在当轮需要(决定怎么回复),用完即裁剪。不裁的后果是每条 QQ
消息的完整正文都留在 L0 上下文里,长会话下持续挤占 token 预算。

内核侧 §13.8 早已就位:StageAfterToolcall 之后按 ToolDef.ContextPolicy
调 RelevanceContext.Prune(772a494),但 QQ 侧一直没声明,等于功能空转。
2026-09-10 21:18:16 +08:00
fd5a291df1 feat(qq): 权限门 + 单轮循环保险 + 极简发送回执 (v1.4.0)
回应 problem.md:核心把「发送成功」的富回执喂给模型,模型读成
「这步成功,继续下一步」而重复调用 output_send__qq。

- handleChannelOutput 成功只返回 "ok",不回传 NapCat 原始响应(含 message_id)
- 全局权限门 on_input/before_toolcall/post_action:工具白名单、私人资源边界、
  高风险命令 confirm 校验;before_toolcall 的 Response 只用于拒绝单个工具,
  post_action 负责清除以免被内核误当作结束推理的最终响应
- 单轮循环保险:max_qq_tool_calls=200 / max_qq_output_calls=20 /
  max_duplicate_qq_send=1,只拦参数完全相同的重复调用,不误杀必需的多次调用
- plg.json 1.2.0 → 1.4.0
2026-09-10 20:36:43 +08:00
61f307be1a feat(qq): msg_id→get_history 7天兜底 + list_chats/mark_read 会话列表 v1.2.0
## 问题

1. NapCat get_msg 的 message_id 是 QQ 服务端临时短号,约 3 天后失效。
   实测 1306 条真实 webhook 消息,141 条(10.8%)现在查回报「消息不存在」,
   全是 3 天前的旧消息;1 小时内的消息可正常查回。NapCat 侧无保留时长配置项
   (napcat.json / onebot11_*.json / webui.json 均无),是 QQ 协议硬限制。
   模型收到 not_found 后曾编造正文(虚构 message_id + 虚构需求),
   已在核心 prompt 加事实性约束,此处从插件层根治取不到正文的问题。

2. 插件与真人客户端差距大:没有会话列表、消息不按到达先后排序、无未读提醒,
   模型只能靠单条中断消息被动响应,导致消息处理不及时。

## 改动

### msg_id → get_history 兜底(不缓存正文)
- 新增 msgRef{peerID,isGroup,time}:只记 msg_id → (peer, 时间) 映射,7 天 TTL
  (qqMsgTTL),超 2000 条时惰性清理过期项。不缓存消息正文。
- handleGetMessage 改为包装层:命中映射且 <7 天 → getMsgFromHistoryByTime()
  按 peer 拉 get_history(count=50),取时间最接近的一条,
  经 msgToGetMsgResult() 包装为与 get_msg 同构的结果(附 resolved_via:history);
  未命中或超 7 天 → 回退原 NapCat get_msg(重命名为 getMsgFromNapcat)。
- 超 7 天的消息由 get_history 自行处理,插件不做长期缓存。
- not_found 文案改为明确引导改用 qq_get_history / qq_list_chats。

### 会话列表(对齐真人客户端)
- 新增 chatMeta:会话名、未读数、最新一条 ≤60 字摘要(qqLastSumLen)、最新时间。
  只维护最新一条摘要,不存历史。
- 新增 qq_list_chats:按最新消息时间降序返回会话列表,每项含
  peer_id/type/name/unread/last_text/last_nick/last_time。
- 新增 qq_mark_read:按 group_id/user_id 清零未读;get_history 拉取某会话后
  自动标已读(看过=已读,与真人客户端一致)。
- webhook 记录时机前移:策略允许的消息(群/私聊、是否 @bot 均记)都进入映射与
  会话状态,@bot 只决定是否发中断——与真人客户端一致能看到全部会话。

### 中断模板
- 补 fallback 路径与私聊 user_id(原模板只给 message_id,取不到正文时
  模型没有 peer 信息可用于 get_history):
  「先用 get_message 取正文;若取不到(已过期),改用 get_history(...) 按会话拉取,
   或用 list_chats 查看未读会话。用 output_send__qq 回复」

## 验证

用重建的 plugindev build --target linux/amd64 产出 dist/qq_linux_amd64.hmap,
经内核 plugin_install(overwrite=true) 安装(action=reinstalled, config_kept=true),
重启 homed 后内核注册 20 个 qq_* 工具(原 18 + list_chats + mark_read)。
实测 qq_list_chats(count=8) 返回按时间排序的会话,unread=6 正确累积。

注意:cgo c-shared 插件带完整 Go runtime,dlclose 后引用计数不归零,
同路径 dlopen 复用旧映像,plgreload 无法热替换 .so,换 .so 必须重启 homed。
2026-08-30 17:11:57 +08:00
d57c5eaf3e fix(examples): 全插件安全审查修复(qq/a2a/memo/calendar/rss/browser/bili/recoverydiag)
审查发现并修复 7 项问题:
- P1 qq: downloadURL 裸 http.Get 无超时 → 120s client
- P2 a2a: inbound http.Server 零超时 → Read 30s/Write 120s/Idle 60s
- P3 bili: output_dir 配置项零校验 → 系统目录黑名单(/、/etc、/usr、/var 等)
- P4 recoverydiag: db_path LLM 可控任意 sqlite → 强制限制 data 目录内
- P5 memo/calendar/rss: os.WriteFile 直写 → atomicWriteJSON (temp+rename)
- P6 qq: 3 处后台 goroutine(已读/rcon转发/下载)加 panic recover
- P7 browser: dump-dom failback Kill 后补 wait 回收僵尸进程

recoverydiag 此前被 .gitignore 排除,但其 db_path 安全修复
属生产代码,故取消忽略并入库。

全部经 plugindev 重打包升版安装验证 config_kept=true。
2026-08-26 17:04:41 +08:00
c91739d670 fix(qq): parseIDList 兼容科学计数法存库的历史坏值
配置里 QQ 号被 WebUI 以科学计数法(2.198972886e+09)存库时,
ParseInt 解析失败导致 adminIDs 为空、老大消息不被标记。
ParseInt 失败后回退 ParseFloat, 整数值转为 int64。
2026-08-15 17:24:21 +08:00
cca9fdce9c example: 新增 acp(ACP 代理通信)、vanblog(VanBlog 博客管理) 插件; 多插件工具调用检测与清理改进; weather 移除 onRemove/文本记忆; plugindev 精简 2026-08-15 09:03:05 +08:00
84bf100a12 example/qq: add typing indicator on private messages
- setInputStatus helper wrapping NapCat set_input_status API
- startTyping/stopTyping with goroutine-based typing manager
  (5s interval, 30s auto-timeout)
- start typing on webhook private message receipt
- stop typing when output channel sends a response
- fix plg.json UTF-8 BOM that broke plugindev parse
2026-07-30 09:24:20 +08:00
4d01e75282 example/qq: use relative replace path, disable bundle mode 2026-07-29 15:24:43 +08:00
c5bcae9404 sdk: add RegisterInputChannel + NoMemory/Cleaner support
- ChannelDef struct: NoMemory bool + Cleaner func(string) string
- InputChannelRegistrar interface + RegisterInputChannel method
- RegisterOutputChannel now takes ChannelDef parameter
- QQ example registers input channel with NoMemory:true + Cleaner
2026-07-29 14:46:15 +08:00
04837f237d plugindev: 全面 plg.json 持久化 + --replace 支持 + 文档 2026-07-25 15:47:49 +08:00
cb7999ca71 feat: sdk NoMemory/Cleaner + example build fixes
- sdk/plugin.go: ToolDef adds NoMemory/Cleaner fields
- sdk/plugin_test.go: unit tests for NoMemory/Cleaner
- all example plugins: NoMemory/Cleaner annotated for each tool
- plugindev/templates.go: template shows NoMemory/Cleaner pattern
- plugindev/cmd_build.go: fix ensureGoMod, buildBundle/buildTarget sdkPath param, NewPluginFactory
- example/go.mod: add external dependency declarations (chromedp, gofeed)
- add main.go stubs for all example plugins
2026-07-25 11:17:21 +08:00
5e0c8d5a40 feat(qq): download task tracking with qq_get_download_tasks, sync re-added 2026-07-23 10:41:08 +08:00
e182b89983 feat(qq): async file download, get_private_file_url, mark-as-read, get_recent_contacts 2026-07-23 10:36:49 +08:00
e030a9b9f9 refactor(qq): remove local message cache, direct NapCat query, URL-based file download 2026-07-23 10:27:55 +08:00
ab3c0bfd2d fix(qq): use file_id instead of file field for group file download
NapCat file messages have file_id in data["file_id"], not data["file"].
data["file"] is the filename, causing get_file API to return empty.
Also pass url field to downloadFile for URL-direct fallback.
2026-07-21 15:33:41 +08:00
1a3e72e899 fix: use short config keys in RegisterDef, remove generics helpers
- qq, a2a, bili, files, rss: RegisterDef keys changed from
  namespaced (e.g. "plugin.qq.listen") to short flat keys ("listen")
- a2a, bili: Get() calls updated to match short keys
- rss: replaced readCfg generics with getSetting, added SetAutoRestart(true)
- files: already uses short key Get, only RegisterDef needed fixing
2026-07-21 13:20:13 +08:00
52dc22f86f feat: add example plugins (ai_image, calendar, music, rss, weather), fix .gitignore, move gengskill to tools/ 2026-07-21 12:45:17 +08:00
75ae2b4692 fix: napcat returns json.RawMessage to avoid double-quoting in C ABI bridge
go_invoke_tool does json.Marshal(r) on handler results. When r is a string,
the raw JSON gets wrapped in quotes and escapes, causing the core's
json.Unmarshal into map[string]interface{} to fail (displayed as map[]).

Changed return type to json.RawMessage (implements json.Marshaler, outputs
raw bytes inline). Added rawString() helper for callers that need the
string representation.
2026-07-20 17:51:36 +08:00
bf08764c29 refactor: qq output channel to payload/meta/type interface
- handleChannelOutput: read payload/meta/type instead of content JSON
- Route by type: text/voice/image/file
- buildOutputHelp: show new parameter format
- Remove fixJSON (no longer needed without JSON-within-JSON)
2026-07-19 10:29:22 +08:00
02997a0dca fix: QQ interrupt text explicitly requires output_send__ reply 2026-07-18 21:29:00 +08:00
0856a9b2c3 feat: merge web/webfetch/bili into single browser plugin (search/fetch/render/video) 2026-07-18 21:06:56 +08:00
1762f0c34b docs: 修正全部文档使其与源码实现一致
- Plugin.Start(sdk *PluginSDK) 接口签名改为指针
- 方法表重写: 移除 CallLLM/QueryKnowledge/SetMemory 等不存在方法
- IOInjector 参数顺序修正为 (source, channel, text)
- 删除虚构 SDKConfig, 替换为实际 New() 构造函数签名
- .hmap 内容描述一致化 (plugin.so + plugin.dll + main.lua)
- 添加 meta/ 包元数据文件
2026-07-18 20:47:17 +08:00