12 Commits

Author SHA1 Message Date
2c9147b437 fix(packaging): SHA256SUMS 只列本批产物,且用平铺名(此前会带上历史版本、且与附件名不符)
v1.2.2 出包时发现:dist/ 跨多次构建累积,而清单用 `find $DIST_DIR` 全目录扫,
于是 SHA256SUMS 里混进了 1.2.0/1.2.1 的包名——用户从发布页下载这份清单后
`sha256sum -c` 必然报「文件缺失」(那些包并不在本页)。

两处一起修:
- 按本批 `$PKG_VERSION` 过滤(只列这次真正打出来的产物);
- 名字用 basename(平铺名),与发布页附件名一致;哈希取真实路径(此前若直接
  对 basename 求哈希会找不到文件——就在 `deb/`、`tar/` 子目录里)。

验证:对含 1.2.0/1.2.1/1.2.2 的 dist/ 跑新逻辑 → 4 条(旧逻辑 12 条);
并拿发布页真下载的 SHA256SUMS 逐条核对四个产物的实际哈希 → 全部一致。
2026-09-12 19:18:24 +08:00
e803e77bfe chore(version): release/v1.2.x 路牌推到 1.2.2
本版内容:知识库检索改为「稠密 + 词法」两路融合(修复检索排序基本是噪声与
查询词全为停用词时搜不出任何东西),并把真实插件产物测试的平台校验补上
(此前错误平台会报误导性的 exec format error)。公开接口与插件协议均未变。
2026-09-12 18:50:19 +08:00
8b57691edf test(plugins): 真实插件产物先校验平台再 exec,错误平台给可读提示而不是 exec format error
排查知识库改动是否引入回归时,internal/plugins 6 个测试全红、报
"fork/exec .../plugin.bin: exec format error"。查了半小时才发现与代码无关:
早前验证跨平台示例构建时,最后一次构建(darwin/arm64)把
`example/*/build/plugin.bin` 覆盖成了 Mach-O arm64,而 realPluginBinary
只按文件名找候选、**不校验平台**,于是拿 macOS 产物在本机 exec。

两个陷阱一起堵:
- 校验魔数(ELF / Mach-O / PE),平台不符则 **SKIP 并给出可直接粘贴的重建命令**
  (`hmapdev build --target <host> --no-bundle`),不再用误导性的 exec format error;
- 明确写出「产物缺失即 skip」的语义,避免"全绿其实什么都没验"
  (本次对照实验里,改动前的 worktree 因无产物而全绿,看着像通过)。

反向验证:把 weather 产物换成 PE 假头 → 相关测试转为 SKIP 且提示平台与重建命令 ✓。
2026-09-12 18:38:22 +08:00
fd8276e4aa fix(knowledge): 知识库检索改为「稠密 + 词法」两路融合(真实 KB 自检索 MRR 0.271→0.376)
追「实例看起来没更新」时发现知识库检索本身也不可信,先把病因查清再动手:

- **两段式召回不是瓶颈**:Store 的结果与全量暴力 cosine 完全一致;
- **真因是向量没有区分度**:词向量取平均后各向异性明显,真实 KB(33 条)上自检索
  top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声;
- 且全为停用词的查询会得到**空向量**("最近更新"),直接搜不出任何东西。

先在真实数据上把候选方案量了一遍(用自检索 top-1 / MRR)再动手:IDF 维度加权零收益、
去均值反而更差,**都不做**;唯一有收益的是与词法路(TF-IDF)融合。

改动:
- `Store` 增设词法路索引,`Search` 融合两路:各自按**查询内最大值**归一化后加权。
  权重 0.5 由权重扫描定:1.0(旧行为)MRR 0.271 / 0.8→0.354 / 0.7→0.358 / **0.5→0.376** /
  0.3→0.336 / 0.0→0.307;语义查询也从"全是 openharmony 噪声"变成命中正确条目
  (「首启人格门禁」→changelog_v1.2.1、「插件怎么开发和部署」→plugin_dev_build);
- `vector.Store` 的候选中选阈值改为**可设**(默认 0.05 保持既有行为):TF-IDF 余弦量级
  只有 0.0~0.2,沿用 0.05 会把词法路有效候选**静默砍掉**——这一条正是 0.376→0.197 的
  差距来源,且当时没有任何报错;
- Add/Remove/scanAll/ReindexWithVectorizer 同步维护两路;分数相同时按名字定序(结果可重复)。

**顺带修一个真实毛病**:Add/Remove 原先用**无追踪的 goroutine** 写索引(因为
writeIndex→BuildTree 会 RLock,而调用方持写锁,同步调用会死锁)→ 失败只打日志,
且与调用方竞态(测试的临时目录清理就撞上了)。改为持锁就地 flush
(buildTreeLocked / writeIndexLocked)。

判据(不依赖人工标注问答对):新增 `internal/knowledge/rankdiag_test.go`,用**自检索
top-1 / MRR** 量区分度,`KB_DIAG=1` 跑、`KB_DIAG_ASSERT=1` 断言(MRR ≥ 0.34)。
另有不依赖真实数据的单测 6 条(空稠密向量靠词法路救回、稠密并列时词法路定序、
词法路阈值接线、Add/Remove 双路一致、并列时确定性、空库不 panic)。

**反向验证**(证明判据真能发现缺陷):权重退回 1.0、词法路阈值改回 0.05、
把阈值写死回 0.05 —— 对应测试逐条变红。另:我第一版夹具余弦 0.365/0.273 远高于阈值,
注入缺陷也不报错(等于没验),故加了「夹具前提」断言并改成两层判据
(语义层由 vector 包测试证明、接线层由知识库测试钉住)。

顺带纳入上一轮漏提交的 `TestAddOverwriteReplacesVector`(同名覆盖必须摘掉旧向量,
生产改动当时已提交,测试一直未入库)。
2026-09-12 18:14:22 +08:00
4737aa10e2 chore(version): release/v1.2.x 路牌推到 1.2.1(本版内容:人格门禁跨通道化 + healthcheck 状态报告)
v1.2.0 已从 f550bb2 发出且 tag 不可改写;本分支今起产出 1.2.1。
主干 main 的路牌仍是下一个未发布中版本 1.3.0,不随本 patch 变动。
2026-09-12 14:55:53 +08:00
d759e74cca feat(healthcheck): 内核状态快照报出内核版本号与 ONNX 模型启用状态
healthcheck_kernel 此前没有任何「ONNX 模型是否在用」的信息,只报「向量可用/不可用」,
分不清「统一多模态空间已加载」与「退回到词嵌入/TF-IDF 路径」;人格卡要求
「版本以运行时快照为准」,也缺一个可查字段(build.version 早就在,但没人知道)。

- KernelStatus 新增 onnx 段:enabled / provider / dim / fingerprint / modalities / reason。
  判据取 Loaded()(provider 真正打开且元数据合法),**不是**「配置里写了 provider」
  —— 后者在模型缺失 / 运行时缺失时也为真,报出去就是假绿。
- 未启用时 reason 给**具体原因**:未配置(说明会走回退路径)/ 打开失败的具体错误。
  homed 把「配置的 provider 名」与「打开失败原因」透传给 Agent,仅供状态报告。
- ProviderAdapter 新增 Modalities()(可选能力,按接口断言取用,不改公开契约)。
- healthcheck_kernel 的工具描述同步说明它回答这两件事。

**顺带修一个真实 panic**:collectKernelStatus 的 knowledge 是**接口**参数,
(*knowledge.Store)(nil) 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic,
而 healthcheck_kernel 正是走这条路径(panic 发生在工具 goroutine 里)。
GetKernelStatus 改为先按具体指针判空、再赋给接口;并加刻画测试钉住这个成因
(一旦不再 panic 说明参数形状已变,守卫与该测试应同步删除)。

验证:单测 4 例(已启用 / 打开失败 / 未配置 / 未加载)+ 刻画测试;
隔离实例 E2E 7/7:正例 provider=chineseclip → enabled=true、dim=512、模态 2;
反例 provider=nonexistent → enabled=false 且 reason 含具体错误与 provider 名,
真实对话仍通。
2026-09-12 14:55:09 +08:00
010a081d56 feat(persona): 首启人格门禁跨通道化 + 内核 persona_set 工具
WebUI 首启向导只覆盖 WebUI 这一条通道,而「人格该问一次」是所有通道的事:
走 QQ / CLI / ACP / 邮件来的人永远见不到那个向导,人格就永远是没确认过。

- 门禁移到 buildSystemPrompt(每轮重建 → WebUI/QQ/CLI/ACP/邮件全覆盖),
  以 core.internal.persona_initialized 为准:未确认时要求模型主动询问用户
  (默认 / 自定义 / 以后再说),确认后该段消失;personaStore 为 nil 时静默关闭。
- 新增内核内置工具 persona_set(mode=default|custom|later[, content]),
  落库逻辑与 WebUI 向导**共用 internal/config**(一个实现 + 两个薄入口:
  ConfigRegistry 直连 / 插件侧 SettingsAPI),避免两套语义各自漂移。
- AgentConfig 增加 PersonaStore 接口,cmd/homed 用 RegistryPersonaStore 实现。
- 非法输入(未知 mode / custom 空内容)在打标记**之前**拒绝:否则标记置位、
  向导被跳过,用户再没机会设。

E2E(隔离实例 + 真实 LLM 往返走 /v1/chat/completions,/var/tmp/persona/e2e.sh)9/9 PASS:
未确认时模型主动询问 → 用户答「用默认的」→ 模型调用 persona_set 落库并置位标记
→ 之后不再追问;反向对照(清标记 + 清会话上下文 + 重启)重新开始询问,
排除了「同一段对话里已问过」这一混淆。
2026-09-12 13:55:29 +08:00
f550bb2cca docs(branching): 明确开发者文档的发布归属——以 rel 分支的形态为准,再合入 main
用户裁定:开发者文档应当在每个 rel 分支被修正为对应 rel 的形式,随后合入 main。

新增 §二.7,写清:
- 规则与做法(release 上按本版口径改 → cherry-pick 到 main,遵守 §三 只 pick 不 merge)
- 为什么不能直接改 main:main 语义是「下一个未发布版本」;assets/docs 会随发行包
  分发并在 WebUI 被阅读,服务的是「这一版」;版本号/工具名/机制有无都随版变动
- main 上描述「下一版才有」的行为必须显式标注(如「(下一版)」)
- 反例表(本仓真实踩过):人格卡写死 v0.9.0 + 已删除的 C ABI、架构文档把已移除的
  描述式索引/引用计数写成现行、README 停在旧版本
- 配套硬约束:任何会被当作事实的文本不得写死版本号,须插值或读运行时快照并加测试
2026-09-12 13:06:48 +08:00
551a423322 feat(webui): 首启人格向导(默认 / 自定义 / 稍后)+ 一次性标记
接续人格配置项化(597f07c):现在人格是 core.agent.personal_prompt,
本次加上「首启问一次」的界面,之后不再打扰。

后端(GET/POST /api/v1/persona):
- GET  → {initialized, current_prompt, file_override}
        未设置时 current_prompt 回落到内置默认模板;存在 personal/personal.md
        时报告 file_override(它会覆盖配置项,向导据此提示用户)
- POST → {"mode":"default"|"custom"|"later","content":"…"}
        写配置 + 打一次性标记 core.internal.persona_initialized;
        custom 返回 restart_required=true(人格在启动时载入);
        「稍后」= 保留当前默认 + 打标记,**绝不阻塞任何流程**
- 空内容的 custom 与未知 mode 一律 400,且**不打标记**(否则向导会被跳过)

前端(dashboard.html):
- 首启拉一次 /api/v1/persona,未初始化则弹向导(复用一直没人用的 .confirm-* 样式)
- 「自定义…」第一次点击展开文本域并预填当前人格,再次点击才提交(避免误提交)
- 中英双语走既有 __() 机制;保存失败/空内容用 toast 提示

测试:TestPersonaWizardFlow(首启状态、later 打标记不改人格、custom 写入 + 需重启、
空内容与未知 mode 被拒且不打标记)、TestPersonaWizardReportsFileOverride。

E2E(真实实例):首启 initialized=false → POST later → initialized=true,
config 中标记=1、人格键为默认模板;前端页面含向导函数。
2026-09-12 13:02:18 +08:00
6b9a7f36fd docs(architecture): 记忆流转图对齐统一多模态空间
流程图里 Context/Prune/DocStore 三行仍只写 StaticEmbedder 与 TF-IDF,
读起来像"向量化只有词嵌入一条路",与 1.2.0 实际(多模态统一空间为主,
带 fingerprint;词嵌入/TF-IDF 是降级层)不符。

- Context Append:补三层向量层级说明
- Context Prune:改为 DenseCosine(仅同指纹比较)→ StaticEmbedder 回退
- DocStore:改为稠密向量 + dense_fp 同指纹要求(不符即重算)
- 中英双版同步
2026-09-12 12:46:21 +08:00
70f03354a2 feat(persona): 人格设定配置项化 + 默认模板契约测试 + 腐坏告警
起因(v1.2.0 压测):线上实例内核日志/接口都报 1.2.0,agent 被问版本时却按人格卡
自述 v0.9.0 + C ABI v2(该机制 v1.0.0 已删除)。根因是人格只有「文件」一个来源且无人
维护——写死的版本号必然随发版腐坏。

改动:
1. 新增配置项 core.agent.personal_prompt(多行文本),默认值为内置模板
   config.DefaultPersonaPrompt,随其它默认值同批播种(老安装不注入,语义不变)
2. 默认模板**不含任何版本号字面量**,并显式要求「被问到版本/构建信息时以运行时快照
   (healthcheck_kernel)为准」——从根上消掉这类腐坏
3. 人格来源优先级:personal/personal.md(存在且非空)> 配置项 > 无
   启动日志明确打印来源;文件含腐坏内容(版本号字面量 / 已删除机制的说法)时告警并
   建议迁移到配置项
4. internal/agent.PersonaStaleHints:腐坏检测(版本号正则 + 已删除机制词表)

契约测试(防复发):
- TestDefaultPersonaPromptHasNoVersionLiterals:默认模板不得含 v?\d+\.\d+\.\d+,
  且必须含「运行时快照」要求
- TestPersonaPromptRegisteredWithDefault:注册存在、默认值一致、播种真的写入
- TestPersonaStaleHints:线上人格卡原文必须被识别(v0.9.0 / C ABI v2),干净文本不误报

验证:go build ./cmd/homed ok;go vet 三个包 ok;go test ./internal/config ./internal/agent ok;
端到端两场景(无文件→来源=配置项 1307 字节;有旧文件→来源=文件 + 告警列出 v0.9.0 与 C ABI v2)。
2026-09-12 12:42:09 +08:00
4f9370ae7a refactor(plugin)!: 重编提示与模板路径改用 hmapdev;文档全面对齐 1.2.0
工具链在 SDK 1.2.0 更名为 hmapdev(原 plugindev)。核心侧三处功能耦合同步:

1. 用户可见报错:旧 C ABI 产物 / 协议版本不匹配 / 共享段版本不匹配
   三处「请用配套 plugindev 重编」→ hmapdev(对应两条测试断言同步)
2. e2e_template_test 的模板路径改为 tools/hmapdev/templates,
   并保留旧路径回退(旧 SDK 检出仍能跑测试)
3. 注释与文档同步

文档更新(用户可见面):
- assets/docs/{zh,en}/PLUGIN_DEV.md:工具链章节整体改为 hmapdev,
  补改名说明与 SDK 存储目录迁移;命令示例全部更新
- assets/docs/{zh,en}/ARCHITECTURE.md:**流程图与章节对齐 v1.2.0** ——
  · 向量化章节改为三层降级:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退),
    写明「同指纹且同维度才参与融合」
  · 媒体记忆章节重写:媒体是一等记忆块(无独立 GC / 无引用计数 / 无描述式索引 /
    正文不再写 media marker),并写明 reembedStaleMedia 的跨空间迁移与写回
- README{,_EN}.md、docs/zh/plugin-interface-matrix.md:工具名与模板路径同步
  (历史条目标注「当时名为 plugindev」)

验证:go test ./internal/plugin/ ./internal/plugin/proc/ ok,
含 4 条真实模板 E2E(模板路径切换后仍通过)。
2026-09-12 12:42:09 +08:00
45 changed files with 2015 additions and 257 deletions

View File

@ -190,7 +190,7 @@ internal/
├── config/ SQLite 配置中心
├── events/ 事件总线
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
```
## 项目状态
@ -209,7 +209,7 @@ internal/
是块的**迁移**,不是复制、也不靠引用保活。
- **数据面全部走共享内存**(工具调用帧 / Cleaner / 输入输出通道 / 媒体块 / 文档与知识正文),
RPC 只传偏移描述符;**RPC 协议升到 2**fd3 布局改变,**不支持滚动升级**——
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `plugindev` 重编。
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `hmapdev` 重编。
- 注入可声明 `InjectOptions{NoMemory, ContextPolicy}`**默认仍记入记忆、默认不裁剪**
裁剪必须显式声明,且先经插件注册的 `Cleaner`。SDK 1.2.0 相对 1.1.0 **纯追加**
- **发行包默认启用** ONNX 向量空间并把模型754MB与 ONNX Runtime24MB
@ -226,7 +226,7 @@ internal/
**v1.1.0** — 记忆系统支持**二进制多媒体节点**。内容寻址媒体存储CAS + SQLite 元数据 + 磁盘 blob`Get` always 重校 digest贯通 L0上下文事件/L2文档/L3图谱句子三层引用计数式 GC有引用者绝不删。视觉模型生成的描述文本是持久语义记忆blob 只是可被容量 GC 淘汰的缓存。
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE``dlclose` 成 no-op、崩溃隔离缺失插件 panic 带崩 homed、stage lost update副本模型丢失 35.8~36.8%、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功模型收到「已发送」而消息未送达、Windows 能力断层(只见 3 个 stage 字段且无法写回。三面通信stdio JSON-RPC控制+ 共享内存段(数据)+ 事件环通知权限梯度显式化为三道闸。RPC 往返 p50 24.1µs崩溃到恢复 <1s
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版工具链重编;该工具链当时名为 `plugindev`**现名 `hmapdev`**)。外部插件需重编为 `plugin.bin`**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE``dlclose` 成 no-op、崩溃隔离缺失插件 panic 带崩 homed、stage lost update副本模型丢失 35.8~36.8%、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功模型收到「已发送」而消息未送达、Windows 能力断层(只见 3 个 stage 字段且无法写回。三面通信stdio JSON-RPC控制+ 共享内存段(数据)+ 事件环通知权限梯度显式化为三道闸。RPC 往返 p50 24.1µs崩溃到恢复 <1s
**v0.9.0** C ABI v2外部插件 Stage 回调支持写回`invoke_stage` 增加 result 输出插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核ABI 版本随内核 minor 对齐v0.9.x ABIVersion=2`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题配套 SDK 提供增强版 sanitizer 示例 UTF-8/U+FFFD/ANSI 转义全链路清洗)。** ABI 已随 v1.0.0 退场。**

View File

@ -179,7 +179,7 @@ internal/
├── config/ SQLite config center
├── events/ Event bus
└── internal/lua/adapters/ 8 LLM protocol adapter scripts
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `plugindev` toolchain, refer to Go and Lua examples in `example/`
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/`
```
## Project Status
@ -240,7 +240,7 @@ by `-race`; in production this showed up as sporadic nil-dereference crashes dur
items are never deleted). The description text produced by the vision model is the durable
semantic memory; the blob is only a cache that capacity GC may evict.
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new toolchain — called `plugindev` back then, **now `hmapdev`** though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**

View File

@ -90,8 +90,8 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
RelevanceContext — In-memory events[] + JSON persistence
Append: Each input, CleanTemplateText → three-branch vector(textForVector)
agent→Response, user→Input, cold_storage→Input+Response
StaticEmbedder pretrained word embedding / TF-IDF fallback
Prune: StaticEmbedder CosineSimilarity, keep topK + last 10
Vector layers: unified multimodal space (primary, with fingerprint) → StaticEmbedder word embedding TF-IDF (fallback)
Prune: DenseCosine (compared only within the same fingerprint) → StaticEmbedder CosineSimilarity fallback; keep topK + last 10
├── Keep → timeline → chronologically sorted → system prompt
└── Low score → Document layer archive (original timestamp)
Save: 5s debounce write to disk
@ -99,7 +99,7 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
↓ Prune archive ↑ LLM active recall
② Document (File Memory)
DocStore — JSON files + shared StaticEmbedder vector space with Context (fallback: TF-IDF InvertedIndex)
DocStore — JSON files + dense vectors (unified multimodal space; dense_fp must match the current space fingerprint or the doc is recomputed; fallback: StaticEmbedder / TF-IDF InvertedIndex)
Write: Prune archive / doc_commit / Graph snapshot (syncGraphToDocs)
Read:
├── Auto-inject: Query(input, top3) → similarity summary under same vector space → [Related Memory Docs] → system prompt (read-only)
@ -132,11 +132,22 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
→ triples → GraphDB.Commit
```
### Vectorization: Pretrained Word Embedding + TF-IDF Fallback
### Vectorization: Unified Multimodal Space (primary) → Word Embedding TF-IDF (fallback)
All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedder.go`):
Vectorization degrades through three layers by availability; **each missing layer reports an explicit
error and never pretends to succeed**:
**Primary Strategy — Pretrained Word Embedding (aligned 300d)**
**① Unified multimodal space (primary path, since v1.2.0)**
Text and images share **one model, one dimension, one fingerprint** (default `chineseclip`: 512d,
Apache-2.0, Chinese-native; `qwen3vl` or an external `http` provider are alternatives).
Providers register through the public `pkg/embedding` SPI — **the kernel hardcodes no model**.
Vectors persist together with their fingerprint (`dense_fp` / `vec_model`); any mismatch with the
current fingerprint triggers recomputation, and only blocks with the **same fingerprint and the same
dimension** participate in fusion (mixing coordinate systems yields a direction resembling neither).
**② Word embedding (text fallback)** — `StaticEmbedder` (`internal/memory/static_embedder.go`):
**Model sources** (aligned 300d)
- Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English
- Configured via `core.agent.embedding_model_path` (comma-separated multi-model)
- Path containing `numberbatch` → auto-download ConceptNet; `cc.zh.` → fastText Chinese; `cc.en.` → fastText English
@ -196,28 +207,32 @@ single typo silently breaks reference binding with no error anywhere in the chai
also gained `sentence_text`: media references hang off a sentence, so with no sentence there is
nowhere to attach them.
### Media Memory (since v1.1.0)
### Media Memory (since v1.2.0: first-class memory blocks)
`internal/memory/media/``Store`, content-addressed (CAS)
Media is not attached content but a **first-class memory node**: `internal/memory/media/` is a
content-addressed store (CAS), and graph `block` nodes carry its digest plus its own vector, while
structural edges (e.g. `sentence --contains--> block`) express ownership.
| Concern | Approach | Why |
|---|---|---|
| Addressing | sha256 digest; metadata in SQLite, blobs on disk | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
| Addressing | sha256 digest; metadata in SQLite, blobs on disk (`blobs/<first2>/<rest>`, two-level fanout) | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
| Integrity | Every `Get` re-verifies the digest | Silently returning corrupt data on disk damage is far worse than an error |
| Write atomicity | `.tmp` + rename | A half-written file taken as complete content would permanently poison that digest |
| References | `owner_kind/owner_id/digest` composite primary key, `AddRef` idempotent | Three owner kinds: `context` (context events), `document`, `graph_sentence` |
| GC | Two-stage with `minAge`, **referenced items are never deleted** | Description text stays in the text layers while blobs may be evicted — semantic memory and byte cache are decoupled |
| Retrieval | Blocks carry **their own multimodal vector and fingerprint** and are searched directly | No description text is needed as an intermediary |
| Lifecycle | **No separate GC, no refcounts, no keep-set**; deleting the block deletes the content | Media is a memory node, not a cache that needs keeping alive |
**How media is represented in plain-text memory** is the marker `[<mime> <short digest>] <description>`:
**Description-based indexing is gone**: the old implementation embedded a
`[<mime> <short digest>] <description>` marker in the body and treated the description as the
semantic memory (retrieval used it). That path was removed wholesale in v1.2.0: a description is
second-hand model output, and retrieving "someone else's paraphrase of an image" is strictly worse
than retrieving the image's own vector. Images are now retrieved only by their own vector in the
unified space, and no media marker is written into the body.
```
[image/png a1b2c3d4e5f6] a purple-blue-red three-band chart
```
Why it must ride on text: `Doc.Content`, `sentences.text` and text memory's `Input` are all strings
— there is no field to carry structured data. **The description text is the durable semantic
memory** (retrieval uses it); the digest is the key back to the bytes (reverse lookup uses it).
After capacity GC evicts a blob, the description remains in the L0/L2/L3 text.
**Cross-space vector migration**: media rows store their vector together with `vec_model` (the space
fingerprint). At startup `reembedStaleMedia()` recomputes and **writes back** every row whose
`vec_model` is empty (never embedded) or differs from the current space (model/dimension switched).
Modalities outside the space return `ErrModalityUnsupported` — the kernel **never substitutes
another model's vector**.
The media store is **optional throughout**: with `core.memory.media.enabled=false` or no
configuration, the whole chain silently degrades to plain-text behaviour — no errors, no panics.
@ -292,7 +307,7 @@ VM built-ins: `json.encode` / `json.decode` / `log` / `http_get` / `http_post`.
| Method | Registration Mechanism | Compilation | Usage |
|--------|----------------------|-------------|-------|
| Built-in | `init()``RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. |
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `plugindev build``plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `hmapdev build``plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
| Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. |
| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
@ -311,7 +326,7 @@ Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter exe
| Dimension | Built-in Plugin | External Plugin |
|-----------|----------------|-----------------|
| Registration | `init()` calls `plugin.RegisterFactory(name, factory)` | Implements `NewPluginFactory(name, config) (sdk.Plugin, error)` entry function |
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `plugindev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `hmapdev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
| Distribution | Bundled with kernel, not independently installable | `.hmap` package (ZIP archive), installed via WebUI or pluginmgr API |
| Metadata | `plugin.RegisterPluginMeta()` for display name | `plugin.json` manifest file (name, version, entry, platforms, capabilities, etc.) |
| Plugin directory | No separate directory, compiled into binary | `plugins/<name>/` independent directory with `plugin.json` + `plugin.bin` |

View File

@ -29,75 +29,80 @@ type Plugin interface {
| Method | Use Case | Complexity |
|--------|----------|------------|
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `hmapdev` toolchain |
| **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo |
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `plugindev init --lua` |
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `hmapdev init --lua` |
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 1. Quick Start: Using the plugindev Toolchain
## 1. Quick Start: Using the hmapdev Toolchain
`plugindev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua plugin types.
`hmapdev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua
plugin types, and producing `.hmap` plugin bundles (the tool is named after that package format).
> Rename note: as of 1.2.0 the toolchain was renamed from `plugindev` to `hmapdev`; the SDK store moved from
> `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk` (the old directory keeps working automatically).
### Installation
```bash
cd homeagent-sdk/tools/plugindev
go build -o plugindev
# Add plugindev to PATH or use directly
cd homeagent-sdk/tools/hmapdev
go build -o hmapdev
# Add hmapdev to PATH or use directly
# Prebuilt binaries also ship as release assets (hmapdev_linux_amd64, ...)
```
### SDK Version Management
`plugindev sdk` manages local SDK versions:
`hmapdev sdk` manages local SDK versions:
```bash
plugindev sdk list # list installed SDK versions
plugindev sdk current # show current SDK version
plugindev sdk latest # show latest available version
plugindev sdk install v0.8.0 # install a specific version
plugindev sdk use v0.8.0 # switch to a version
plugindev sdk path # show current SDK path
hmapdev sdk list # list installed SDK versions
hmapdev sdk current # show current SDK version
hmapdev sdk latest # show latest available version
hmapdev sdk install v1.2.0 # install a specific version
hmapdev sdk use v1.2.0 # switch to a version
hmapdev sdk path # show current SDK path
```
SDK is stored at `~/.homeagent/plugindev/sdk/<version>/`; `plugindev init` reads the current SDK version for `go.mod`.
SDK is stored at `~/.homeagent/hmapdev/sdk/<version>/`; `hmapdev init` reads the current SDK version for `go.mod`.
### Source Debugging
`plugindev debug` interprets plugin source and prints a call trace, no compilation environment needed:
`hmapdev debug` interprets plugin source and prints a call trace, no compilation environment needed:
```bash
plugindev debug [dir] # dir defaults to the current directory
hmapdev debug [dir] # dir defaults to the current directory
```
### Creating a Go Plugin
```bash
plugindev init myplugin
hmapdev init myplugin
cd myplugin
# Edit plugin code
vim plugin.go
# Build and package (default is a multi-platform bundle, see below)
plugindev build
hmapdev build
# Output: dist/myplugin_bundle.hmap
# Single-platform build:
plugindev build --no-bundle
hmapdev build --no-bundle
# Output: dist/myplugin_linux_amd64.hmap (or windows_amd64)
```
### Creating a Lua Plugin
```bash
plugindev init myluaplugin --lua
hmapdev init myluaplugin --lua
cd myluaplugin
# Edit plugin code
vim main.lua
# Local test
lua main.lua
# Build and package
plugindev build
hmapdev build
# Output: dist/myluaplugin_lua.hmap
```
@ -128,16 +133,16 @@ myluaplugin/
### Build & Package
`plugindev build` automatically handles compilation and packaging:
`hmapdev build` automatically handles compilation and packaging:
```bash
cd myplugin
plugindev build # default bundle mode (multi-platform)
plugindev build --no-bundle # single-target build (per plg.json targets)
plugindev build --target linux/amd64 # append a target on top of plg.json targets
plugindev build --outdir dist # output directory (default: dist)
plugindev build --sdk-path <path> # SDK path override (go.mod replace)
plugindev build --replace <mod@path> # append a go.mod replace directive (repeatable)
hmapdev build # default bundle mode (multi-platform)
hmapdev build --no-bundle # single-target build (per plg.json targets)
hmapdev build --target linux/amd64 # append a target on top of plg.json targets
hmapdev build --outdir dist # output directory (default: dist)
hmapdev build --sdk-path <path> # SDK path override (go.mod replace)
hmapdev build --replace <mod@path> # append a go.mod replace directive (repeatable)
```
Execution process:
@ -171,7 +176,7 @@ the kernel picks the one matching the current platform and renames it to `plugin
> - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel
> skips legacy artifacts with an actionable error instead of crashing.
> - **Business code needs no changes** — the public SDK interface is unchanged; just
> rebuild with the new `plugindev`.
> rebuild with the new `hmapdev` (formerly `plugindev`).
> - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving
> `plugin.so` there is harmless); it only distinguishes Lua plugins.
> - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain.
@ -180,12 +185,12 @@ the kernel picks the one matching the current platform and renames it to `plugin
### Build Targets & Multi-platform Bundle
**`plugindev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
**`hmapdev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
```bash
plugindev build # default bundle, outputs dist/myplugin_bundle.hmap
plugindev build --bundle # explicitly enable bundle (same as above)
plugindev build --no-bundle # disable bundle, build per plg.json targets
hmapdev build # default bundle, outputs dist/myplugin_bundle.hmap
hmapdev build --bundle # explicitly enable bundle (same as above)
hmapdev build --no-bundle # disable bundle, build per plg.json targets
```
Notes:
@ -275,7 +280,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
### Entry Point
`plugindev init` generates `plugin.go` with the `NewPlugin` export function directly,
`hmapdev init` generates `plugin.go` with the `NewPlugin` export function directly,
which is the entry point when the kernel loads the plugin:
```go
@ -284,7 +289,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
}
```
At build time, `plugindev build` auto-generates subprocess runtime code
At build time, `hmapdev build` auto-generates subprocess runtime code
(`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` /
`z_proc_shm_windows.go`). All three platforms share the same entry point and the same
RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on

View File

@ -90,8 +90,8 @@ eventLoop() → processTextInput()
RelevanceContext — 内存 events[] + JSON持久化
Append: 每次输入, CleanText → 三分支向量(textForVector)
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
StaticEmbedder 预训练词嵌入 / TF-IDF 回退
Prune: StaticEmbedder CosineSimilarity, 保留 topK + 最近10条
向量层级:统一多模态空间(主,带 fingerprint StaticEmbedder 词嵌入 TF-IDF回退
Prune: DenseCosine仅同指纹才比较→ 退化 StaticEmbedder CosineSimilarity保留 topK + 最近10条
├── 保留 → timeline → 按时间排序 → system prompt
└── 低分 → Document 层归档 (原始时间戳)
Save: 5s debounce 写盘
@ -99,7 +99,7 @@ eventLoop() → processTextInput()
↓ Prune 归档 ↑ LLM 主动召回
② Document (文件记忆)
DocStore — JSON文件 + 与 Context 共享的 StaticEmbedder 向量空间(兜底: TF-IDF InvertedIndex
DocStore — JSON文件 + 稠密向量统一多模态空间dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
读取:
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
@ -132,11 +132,20 @@ eventLoop() → processTextInput()
→ 三元组 → GraphDB.Commit
```
### 向量化:预训练词嵌入 + TF-IDF 回退
### 向量化:统一多模态空间(主)→ 词嵌入 TF-IDF回退
所有向量化统一使用 `StaticEmbedder``internal/memory/static_embedder.go`
向量化按可用性分三层降级,**每一层缺位都明确报错,不静默假装成功**
**主策略 — 预训练词嵌入(词对齐 300 **
**① 统一多模态空间主路径v1.2.0 **
文本与图像共用**同一模型、同一维度、同一指纹**(默认 `chineseclip`512 维、Apache-2.0、中文原生;
亦可选 `qwen3vl` 或外部 `http` provider。provider 经 `pkg/embedding` 公共 SPI 注册,
**内核不硬编码任何模型**。向量与指纹一起持久化(`dense_fp` / `vec_model`
与当前指纹不一致即触发重算;融合时只接受**同指纹且同维度**的块向量
(跨坐标系的向量混进去会算出两边都不像的方向)。
**② 词嵌入(文本兜底)** — `StaticEmbedder``internal/memory/static_embedder.go`
**模型来源**(词对齐 300 维)
- 模型来源ConceptNet Numberbatch77 语对齐)/ fastText 中文 / fastText 英文
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
- 路径名含 `numberbatch` → 自动下载 ConceptNet`cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
@ -194,30 +203,31 @@ eventLoop() → processTextInput()
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。
`memory_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
### 媒体记忆v1.1.0 起)
### 媒体记忆v1.2.0 起:一等记忆块
`internal/memory/media/` `Store`内容寻址CAS
媒体不是外挂内容,而是**记忆的一等节点**`internal/memory/media/` 内容寻址仓储CAS
图数据库里的 block 节点携带它的 digest 与向量,结构边(如 `sentence --contains--> block`)表达归属。
| 关注点 | 做法 | 为何 |
|---|---|---|
| 寻址 | sha256 digest元数据在 SQLiteblob 在磁盘 | 相同字节只存一份元数据要可查询blob 不该进数据库 |
| 寻址 | sha256 digest元数据在 SQLiteblob 在磁盘`blobs/<前2位>/<其余>` 两级分桶) | 相同字节只存一份元数据要可查询blob 不该进数据库 |
| 完整性 | 每次 `Get` 重校 digest | 磁盘损坏时静默返回脏数据比报错危险得多 |
| 写入原子性 | `.tmp` + rename | 半个文件被当成完整内容会永久污染那个 digest |
| 引用 | `owner_kind/owner_id/digest` 三元组主键,`AddRef` 幂等 | 三个 owner 类型:`context`(上下文事件)、`document`(文档)、`graph_sentence`(图谱句子) |
| GC | 两阶段 + `minAge`**有引用者绝不删** | 描述文本留在文本层blob 可淘汰——语义记忆与字节缓存分离 |
| 检索 | 块携带**自己的多模态向量与指纹**,直接参与向量检索 | 不需要描述文本做中介 |
| 生命周期 | **无独立 GC、无引用计数、无 keep-set**;删除块即删内容 | 媒体是记忆节点,不是需要保活的缓存 |
**媒体在纯文本记忆里的表示**是标记 `[<mime> <短digest>] <描述>`
**不再有描述式索引**:旧实现在正文里写 `[<mime> <短digest>] <描述>` 标记,并把描述文本当作语义记忆
(检索靠描述)。该机制已在 v1.2.0 整体拆除:描述是模型生成的二手信息,
检索“别人转述的图片”不如检索图片自己的向量。现在图片只按自己的统一空间向量被检索,
正文里不再有 media marker。
```
[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
```
**跨空间向量迁移**:媒体行的向量带 `vec_model`(空间指纹)。启动时
`reembedStaleMedia()``vec_model` 为空(从未嵌入)或与当前空间不一致(换过模型/维度)的行
批量重算并**写回库**;模态不在本空间覆盖范围时返回 `ErrModalityUnsupported`
**绝不拿别的模型的向量顶替**
之所以必须借文本承载:`Doc.Content``sentences.text`、文本记忆的 `Input` 全是字符串
没有字段能挂结构化数据。**描述文本才是持久的语义记忆**检索靠它digest 是回到字节的
钥匙反查靠它。blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里。
媒体存储**全程可选**`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为
纯文本行为,不报错不 panic。
媒体存储全程可选:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为纯文本行为
不报错不 panic。
### 其他记忆层
@ -287,7 +297,7 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
| 方式 | 注册机制 | 编译 | 用途 |
|------|----------|------|------|
| 内置插件 | `init()``RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `plugindev build``plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build``plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
@ -306,7 +316,7 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
| 维度 | 内置插件 | 外部插件 |
|------|----------|----------|
| 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 |
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `plugindev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo内核 spawn 为子进程 |
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `hmapdev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo内核 spawn 为子进程 |
| 分发方式 | 随内核分发,不可独立安装/卸载 | `.hmap`ZIP 归档),通过 WebUI 或 pluginmgr API 安装 |
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件name, version, entry, platforms, capabilities 等) |
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + `plugin.bin` |

View File

@ -30,75 +30,80 @@ type Plugin interface {
| 方式 | 适用场景 | 复杂度 |
|------|---------|--------|
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `hmapdev` 工具链生成 |
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 |
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `hmapdev init --lua` 生成 |
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 一、快速开始:使用 plugindev 工具链
## 一、快速开始:使用 hmapdev 工具链
`plugindev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型
`hmapdev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型
最终产出 `.hmap` 插件包(工具名即来自这个包格式)。
> 改名说明1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`SDK 存储目录同时由
> `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`(旧目录会自动继续沿用)。
### 安装
```bash
cd homeagent-sdk/tools/plugindev
go build -o plugindev
# 将 plugindev 加入 PATH 或直接使用
cd homeagent-sdk/tools/hmapdev
go build -o hmapdev
# 将 hmapdev 加入 PATH 或直接使用
# 也可从 SDK 的 release 附件下载预编译二进制hmapdev_linux_amd64 等)
```
### SDK 版本管理
`plugindev sdk` 子命令管理本地 SDK 版本:
`hmapdev sdk` 子命令管理本地 SDK 版本:
```bash
plugindev sdk list # 列出已安装的 SDK 版本
plugindev sdk current # 显示当前使用的 SDK 版本
plugindev sdk latest # 显示最新可用版本
plugindev sdk install v0.8.0 # 安装指定版本
plugindev sdk use v0.8.0 # 切换使用版本
plugindev sdk path # 显示当前 SDK 路径
hmapdev sdk list # 列出已安装的 SDK 版本
hmapdev sdk current # 显示当前使用的 SDK 版本
hmapdev sdk latest # 显示最新可用版本
hmapdev sdk install v1.2.0 # 安装指定版本
hmapdev sdk use v1.2.0 # 切换使用版本
hmapdev sdk path # 显示当前 SDK 路径
```
SDK 存储在 `~/.homeagent/plugindev/sdk/<version>/``plugindev init` 自动读取当前 SDK 版本填充 `go.mod`
SDK 存储在 `~/.homeagent/hmapdev/sdk/<version>/``hmapdev init` 自动读取当前 SDK 版本填充 `go.mod`
### 源码调试
`plugindev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
`hmapdev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
```bash
plugindev debug [dir] # dir 默认当前目录
hmapdev debug [dir] # dir 默认当前目录
```
### 创建 Go 插件
```bash
plugindev init myplugin
hmapdev init myplugin
cd myplugin
# 编辑插件代码
vim plugin.go
# 编译打包
plugindev build # 默认多平台 bundle见下节
hmapdev build # 默认多平台 bundle见下节
# 输出: dist/myplugin_bundle.hmap
# 单平台构建:
plugindev build --no-bundle
hmapdev build --no-bundle
# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64)
```
### 创建 Lua 插件
```bash
plugindev init myluaplugin --lua
hmapdev init myluaplugin --lua
cd myluaplugin
# 编辑插件代码
vim main.lua
# 本地测试
lua main.lua
# 编译打包
plugindev build
hmapdev build
# 输出: dist/myluaplugin_lua.hmap
```
@ -129,16 +134,16 @@ myluaplugin/
### 编译打包
`plugindev build` 会自动完成编译和打包:
`hmapdev build` 会自动完成编译和打包:
```bash
cd myplugin
plugindev build # 默认 bundle 模式(多平台合集)
plugindev build --no-bundle # 单平台构建(仅当前 plg.json targets
plugindev build --target linux/amd64 # 在 targets 基础上追加一个目标
plugindev build --outdir dist # 指定输出目录(默认 dist
plugindev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace
plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
hmapdev build # 默认 bundle 模式(多平台合集)
hmapdev build --no-bundle # 单平台构建(仅当前 plg.json targets
hmapdev build --target linux/amd64 # 在 targets 基础上追加一个目标
hmapdev build --outdir dist # 指定输出目录(默认 dist
hmapdev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace
hmapdev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
```
执行过程:
@ -154,7 +159,7 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
| 文件 | 用途 | 关键字段 |
|------|------|---------|
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"``bundle` — 多平台合集开关(默认 `true`|
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
| `plugin.json` | 构建产物清单,`hmapdev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**
@ -169,7 +174,7 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
>
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
> 会跳过并报可操作错误,不崩溃。
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `plugindev` 重编。
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `hmapdev`(原 `plugindev`重编。
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
> 它现在只用于区分 Lua 插件。
> - 产物不再需要 cgo交叉编译无需目标平台 C 工具链。
@ -178,12 +183,12 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
### 构建目标与多平台打包bundle
**`plugindev build` 默认就是 bundle 模式**`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
**`hmapdev build` 默认就是 bundle 模式**`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
```bash
plugindev build # 默认 bundle输出 dist/myplugin_bundle.hmap
plugindev build --bundle # 显式开启 bundle同上
plugindev build --no-bundle # 关闭 bundle按 plg.json 的 targets 逐平台构建
hmapdev build # 默认 bundle输出 dist/myplugin_bundle.hmap
hmapdev build --bundle # 显式开启 bundle同上
hmapdev build --no-bundle # 关闭 bundle按 plg.json 的 targets 逐平台构建
```
注意:
@ -273,7 +278,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
### 入口点
`plugindev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
`hmapdev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
```go
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -281,7 +286,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
}
```
编译时 `plugindev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑仅跨进程资源传递机制不同Unix 继承 fdWindows 命名内核对象)。
编译时 `hmapdev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑仅跨进程资源传递机制不同Unix 继承 fdWindows 命名内核对象)。
### PluginSDK 核心 API

View File

@ -367,7 +367,11 @@ func main() {
// 布局、预处理、解码、运行时全部属于 provider 内部实现。
// provider 名为空时禁用多模态向量检索,退回纯 fastText 文本路径。
var multimodalSpace vector.MultimodalEmbedder
// 这两个值只用于状态报告healthcheck_kernel 的 onnx 段):
// 「配了哪个 provider」与「为什么没启用」避免只能看到 false 却不知原因。
var mmProviderName, mmErr string
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
mmProviderName = mmProvider
opts := map[string]string{}
const optPrefix = "core.memory.multimodal_space.options."
for _, key := range cfgReg.List("core.memory.multimodal_space.options.") {
@ -375,10 +379,12 @@ func main() {
}
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
if err != nil {
mmErr = err.Error()
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v多模态向量检索已禁用已注册: %s",
mmProvider, err, strings.Join(embedding.Names(), ", "))
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
provider.Close()
mmErr = err.Error()
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v多模态向量检索已禁用", mmProvider, err)
} else {
multimodalSpace = adapted
@ -405,13 +411,29 @@ func main() {
// 人格设定
// ========================================================================
// 人格来源优先级personal/personal.md高级覆盖存在且非空才生效
// > 配置项 core.agent.personal_prompt默认模板 = config.DefaultPersonaPrompt
//
// 曾经只有「文件」一个来源且无人维护,导致人格卡写死旧版本号与已删除的 C ABI、
// 反过来让实例自称旧版本v1.2.0 压测发现)。故:
// - 配置项化 + 内置默认模板(不含版本号字面量)
// - 文件仍在时生效,但扫到腐坏内容就在启动日志里明确告警
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
personality, err := agentPkg.LoadPersonality(personalPath)
if err != nil {
log.Printf("[homed] warning: load personality: %v", err)
}
if personality != nil && personality.Content != "" {
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
log.Printf("[homed] 人格来源=文件 %s优先于配置项%d 字节", personalPath, len(personality.Content))
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+
"(默认模板不含版本号,被问版本时以运行时快照为准)", hints)
}
} else if pv := cfgReg.GetString("core.agent.personal_prompt", internalConfig.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt%d 字节", len(pv))
} else {
log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)")
}
// ========================================================================
@ -490,20 +512,23 @@ func main() {
}
agent := agentCore.New(agentCore.AgentConfig{
ID: "main",
SystemPrompt: sysPrompt,
Provider: provider,
ProviderManager: providerMgr,
IO: iom,
Memory: memDB,
Indexer: memIdx,
Tracker: trk,
DocStore: docStore,
Knowledge: ks,
SocialStore: socialStore,
TextMemory: textMem,
MediaStore: mediaStore,
Personality: personality,
ID: "main",
SystemPrompt: sysPrompt,
Provider: provider,
ProviderManager: providerMgr,
IO: iom,
Memory: memDB,
Indexer: memIdx,
Tracker: trk,
DocStore: docStore,
Knowledge: ks,
SocialStore: socialStore,
TextMemory: textMem,
MediaStore: mediaStore,
Personality: personality,
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
PluginReg: pluginReg,
PluginDir: cfg.Plugin.Dir,
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
@ -514,6 +539,8 @@ func main() {
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
Embedder: embedder,
MultimodalSpace: multimodalSpace,
EmbeddingProvider: mmProviderName,
EmbeddingError: mmErr,
StageHost: stageHost,
EventBus: evBus,
ThinkingEnabled: cfg.LLM.ThinkingEnabled,

View File

@ -24,7 +24,7 @@ import (
// 所以选择:**原生 Windows 不提供 homed**。Windows 用户跑 WSL2——
// WSL2 里就是普通 linux/amd64走与我们测试矩阵完全相同的那条路径。
//
// 注意范围:只有 homed 如此。plugindev 工具链仍可在 Windows 上运行
// 注意范围:只有 homed 如此。hmapdev 工具链仍可在 Windows 上运行
// (在 Windows 上开发、为 WSL 构建 linux 插件是合理工作流)。
func requireSupportedPlatform() {
fmt.Fprintln(os.Stderr, "homed 不支持 Windows 原生运行。")

View File

@ -710,18 +710,25 @@ main() {
echo "=== Done! Packages in: $DIST_DIR ==="
echo ""
echo "Summary:"
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.rpm" \) 2>/dev/null | sort)
# 只列**本批**产物dist/ 会跨多次构建累积,用 find 全目录会让清单SHA256SUMS
# 带上历史版本的文件名——用户下载那种清单后 `sha256sum -c` 必然报缺失。
# v1.2.2 构建时就出现过:清单里混进了 1.2.0/1.2.1 的包名。)按本批版本号过滤。
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*${PKG_VERSION}*.deb" -o -name "homeagent_${PKG_VERSION}_*.tar.gz" -o -name "*${PKG_VERSION}*.rpm" \) 2>/dev/null | sort)
for f in "${release_files[@]}"; do
echo " $(du -h "$f" | cut -f1) $f"
done
# 全部包生成之后一次计算,避免边打边算漏掉后生成的产物。
# 名字用**平铺名**basename下载页的附件名就是平铺的
# 清单里若写 ./deb/xxx.deb用户下载后 `sha256sum -c` 会找不到文件。
if [ ${#release_files[@]} -gt 0 ]; then
(
cd "$DIST_DIR"
find . -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.rpm" \) \
-print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
# 哈希取**真实路径**,标签用**平铺名**:两者不能混(直接对 basename 求哈希会找不到文件)。
for f in "${release_files[@]}"; do
printf '%s ./%s\n' "$(sha256sum "$f" | awk '{print $1}')" "$(basename "$f")"
done | sort -k2 > SHA256SUMS
)
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS"
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS (仅本批 ${#release_files[@]} 个产物,平铺名)"
fi
}

View File

@ -130,6 +130,45 @@ main ──────────────── E ────────
---
### 7. 开发者文档的发布归属(以 rel 分支的形态为准)
**规则:面向使用者的开发者文档,先在对应的 `release/vX.Y.x` 上修正成「这一版的实际行为」,
再 cherry-pick 合入 `main`。**(文档属 §二.3 所列的发布分支允许事项之一)
为什么不能直接改 main
- `main` 的语义是**下一个未发布版本**(§二.1)。在那儿写的文档要么描述尚未发布的行为,
要么与当前 rel 的实际行为**相反**,而文档的读者(包括模型自身)会把它当事实。
- `assets/docs/**` 会**随发行包分发并在 WebUI 里被阅读**——它服务的是“这一版”,不是“下一版”。
- 版本号、工具名、机制的有无都是**随版变动的**:同一个文件在两个分支上就应该是两种口径。
做法:
```bash
git switch release/v1.2.x
# 按这一版口径修改版本号、当前工具名hmapdev、已移除机制不再写成现行
# ... 编辑 assets/docs/**、README{,_EN}.md、docs/zh/** ...
git commit -m "docs: 按 v1.2.x 口径修正 …"
git switch main && git cherry-pick <sha> # 遵守 §三:只 pick不 merge
```
`main` 上若需要描述“下一版才有的行为”,必须显式标注(如「(下一版)」或附版本号),
不得让读者以为它已发布。
**反例(本仓真实踩过,均为“文档当成事实后反向误导”)**
| 现象 | 后果 |
|---|---|
| 人格卡写死 `v0.9.0C ABI v2` | 内核接口/日志报 1.2.0agent 却向用户自述旧版本(且该机制 v1.0.0 已删除) |
| 架构文档在 1.2.0 后仍把“描述式索引 + 引用计数 GC”写成现行机制 | 读者按已删除的设计理解现行行为 |
| README 停在 v1.1.1 并描述已被删除的机制 | 同上 |
配套硬约束:**任何“模型或用户会当作事实”的文本,都不得写死版本号**——
要么用 `meta.Version` 插值,要么要求读运行时快照,并用测试钉住
(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
---
## 三、当前分支对齐2026-09-12 更新)
### 主仓TrueAgent

View File

@ -2,7 +2,7 @@
> 状态:**完成 v3**2026-09-06——v2 的迁移已上生产(内核 v1.0.0v3 记录 v1.1.1 的公开接口**扩展**。
> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致;
> 所有改造落在**核心homed 侧)+ 工具链plugindev**,外部插件业务代码零改动,只需用新 plugindev 重编。
> 所有改造落在**核心homed 侧)+ 工具链(hmapdev当时名为 plugindev**,外部插件业务代码零改动,只需用新工具链重编。
>
> **结果(已验证)**`git diff third_party/homeagent-sdk/sdk/` 全程为空17 个 `example/*/plugin.go` 逐字节未改
> `git status example/` 无输出);生产 17 插件全部经子进程通道运行。
@ -11,7 +11,7 @@
> 它要保的是「换运行模型不动业务代码」。迁移完成后SDK 需要能随功能演进而扩展,
> 否则多模态这类能力永远到不了插件手上。解除的边界见 §九:**只增不减,签名不改**。
>
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/plugindev/templates/` 后,
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/hmapdev/templates/` 后,
> 必须同步更新本矩阵。
>
> 权威编号plan.md 第 11 节11.1~11.9)。本文档只做接口面盘点,不做实现。
@ -21,9 +21,9 @@
## 一、迁移的形状(一句话)
```
今天: 外部插件 = example/*/plugin.go纯 Go ──plugindev c-shared──> plugin.so
今天: 外部插件 = example/*/plugin.go纯 Go ──hmapdev c-shared──> plugin.so
homed ──dlopen──> plugin.soC ABI bridge51 个整数 method id
之后: 外部插件 = example/*/plugin.go纯 Go一行不改 ──plugindev go build──> plugin.bin
之后: 外部插件 = example/*/plugin.go纯 Go一行不改 ──hmapdev go build──> plugin.bin
homed ──spawn──> plugin.binstdio JSON-RPC + shm + eventfd
```
@ -33,8 +33,8 @@
|---|---|---|
| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) |
| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go只 import 公开 SDK | **不动**(只重编) |
| bridge 模板 `tools/plugindev/templates.go``tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
| `plugindev` 构建命令 | c-shared | 改普通 `go build` |
| bridge 模板 `tools/hmapdev/templates.go``tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
| `hmapdev` 构建命令 | c-shared | 改普通 `go build` |
| homed `internal/plugin/cabi/`1096 行) | cgo | 删(已归入 plan 迁移收尾 5.2 |
| homed `internal/plugin/registry.go` 加载分派 | — | 改:按 `entry` 分派 `.so`/`.bin` |
@ -300,7 +300,7 @@ C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 c
## 七、接口冻结检查点(全部已通过)
1.**阶段 2子进程通道原型**`plugindev` 重编 weather → `plugin.bin` → 端到端跑通。
1.**阶段 2子进程通道原型**`hmapdev` 重编 weather → `plugin.bin` → 端到端跑通。
验收weather 业务代码逐字节未改(`git status example/` 无输出)。
2.**阶段 3共享内存**:子进程并发改写 StageContext 丢失率 = 0%
`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate`
@ -349,7 +349,7 @@ tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
公开接口加方法时,`tools/plugindev/templates/proc_main.go.tmpl` 里的 `procIO` /
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的 `procIO` /
`procDocMemory` 若不实现新方法,就不满足接口——**每个外部插件都编不过**,是硬失败
不是软降级。v1.1.1 这一层是被 `go test` 抓出来的(`internal/plugin/proc` 的两个
E2E 用例编译失败),不是靠人工检查发现的。
@ -366,7 +366,7 @@ E2E 用例编译失败),不是靠人工检查发现的。
|---|---|---|
| 存量插件源码零改动 | `cd example/<n> && go vet ./...`17 个) | ✅ 17/17 通过 |
| 旧产物仍能建链 | 用 SDK 0.9.2 编的 `plugin.bin``TestRealPlugin_*` | ✅ 4/4 通过(握手校验 `ProtocolVersion=1`,不是 SDK 版本) |
| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
| 模板已接线 | `cd tools/hmapdev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
| 并发安全 | `go test ./sdk/ -race -count=5` | ✅ 零 DATA RACE13 例压测) |
### v1.2.x 的接口扩展2026-09-12
@ -426,5 +426,5 @@ data URL 本身已是 base64 文本,包进二进制传输省不了空间,还
- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板)
- `internal/plugin/proc/shm.go` — 合同面 C 的代码实现(共享段布局与 18 字段枚举)
- `internal/plugin/proc/capability.go` — 权限梯度capability 组 + `withheldCapabilities`
- `third_party/homeagent-sdk/tools/plugindev/templates/` — 子进程运行时模板(三文件)
- `third_party/homeagent-sdk/tools/hmapdev/templates/` — 子进程运行时模板(三文件)
- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具

View File

@ -57,9 +57,13 @@ type Agent struct {
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
mediaStore *media.Store
// 人格设定
// 人格设定(内容来自启动时载入的人格文件/配置项)
personality *agentPkg.Personality
// 人格落库面:首启门禁与 persona_set 工具使用(见 persona.go
// 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。
personaStore PersonaStore
// 插件注册表(用于 plgreload
pluginReg *plugin.Registry
pluginDir string
@ -150,6 +154,12 @@ type Agent struct {
// 也可以是外部 API 客户端;两者共享同一套 L0/L2/L3 向量缓存与检索基础设施。
multimodalSpace vector.MultimodalEmbedder
// embeddingProvider 是配置里指定的统一向量空间 provider 名;
// embeddingError 是打开/适配失败的原因(成功时为空)。
// 二者只用于状态报告:区分「没配」「配了但打不开」「已启用」。
embeddingProvider string
embeddingError string
// fusionCfg 控制文本路与视觉路的跨模态融合权重,可按模型实测结果配置。
fusionCfg CrossModalFusionConfig
@ -175,14 +185,19 @@ type AgentConfig struct {
Indexer *memory.Indexer
Tracker *tracker.Tracker
DocStore *document.Store
Knowledge *knowledge.Store
SocialStore *social.SocialStore
TextMemory *text.Memory
MediaStore *media.Store
MultimodalSpace vector.MultimodalEmbedder
DocStore *document.Store
Knowledge *knowledge.Store
SocialStore *social.SocialStore
TextMemory *text.Memory
MediaStore *media.Store
MultimodalSpace vector.MultimodalEmbedder
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
// 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。
EmbeddingProvider string
EmbeddingError string
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
Personality *agentPkg.Personality
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
PluginReg *plugin.Registry
PluginDir string
DistillInterval time.Duration
@ -251,45 +266,48 @@ func New(cfg AgentConfig) *Agent {
}
return &Agent{
id: cfg.ID,
startTime: time.Now(),
provider: cfg.Provider,
providerManager: cfg.ProviderManager,
io: cfg.IO,
memory: cfg.Memory,
indexer: cfg.Indexer,
tracker: cfg.Tracker,
context: rc,
systemPrompt: cfg.SystemPrompt,
ctx: ctx,
cancel: cancel,
docStore: cfg.DocStore,
knowledge: cfg.Knowledge,
social: cfg.SocialStore,
textMem: cfg.TextMemory,
mediaStore: cfg.MediaStore,
personality: cfg.Personality,
pluginReg: cfg.PluginReg,
pluginDir: cfg.PluginDir,
distillInterval: cfg.DistillInterval,
archiveInterval: cfg.ArchiveInterval,
reviewInterval: cfg.ReviewInterval,
mergeInterval: cfg.MergeInterval,
maxContextSize: cfg.MaxContextSize,
stageHost: cfg.StageHost,
skillIndex: cfg.SkillIndexProvider,
eventBus: cfg.EventBus,
selfInputCh: make(chan selfInputMsg, 64),
childTasks: make(map[string]*childTaskState),
interceptCh: make(chan *agentIO.InputEvent, 64),
pluginHealth: newPluginHealthTracker(),
thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing,
embedder: embedder,
multimodalSpace: cfg.MultimodalSpace,
fusionCfg: cfg.FusionCfg,
noMergeMarkers: make(map[string]int),
lastInput: make(map[string]time.Time),
id: cfg.ID,
startTime: time.Now(),
provider: cfg.Provider,
providerManager: cfg.ProviderManager,
io: cfg.IO,
memory: cfg.Memory,
indexer: cfg.Indexer,
tracker: cfg.Tracker,
context: rc,
systemPrompt: cfg.SystemPrompt,
ctx: ctx,
cancel: cancel,
docStore: cfg.DocStore,
knowledge: cfg.Knowledge,
social: cfg.SocialStore,
textMem: cfg.TextMemory,
mediaStore: cfg.MediaStore,
personality: cfg.Personality,
personaStore: cfg.PersonaStore,
pluginReg: cfg.PluginReg,
pluginDir: cfg.PluginDir,
distillInterval: cfg.DistillInterval,
archiveInterval: cfg.ArchiveInterval,
reviewInterval: cfg.ReviewInterval,
mergeInterval: cfg.MergeInterval,
maxContextSize: cfg.MaxContextSize,
stageHost: cfg.StageHost,
skillIndex: cfg.SkillIndexProvider,
eventBus: cfg.EventBus,
selfInputCh: make(chan selfInputMsg, 64),
childTasks: make(map[string]*childTaskState),
interceptCh: make(chan *agentIO.InputEvent, 64),
pluginHealth: newPluginHealthTracker(),
thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing,
embedder: embedder,
multimodalSpace: cfg.MultimodalSpace,
embeddingProvider: cfg.EmbeddingProvider,
embeddingError: cfg.EmbeddingError,
fusionCfg: cfg.FusionCfg,
noMergeMarkers: make(map[string]int),
lastInput: make(map[string]time.Time),
}
}

View File

@ -0,0 +1,54 @@
package core
import (
"fmt"
"strings"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// PersonaStore 是人格设定的读写面。
//
// 首启门禁buildSystemPrompt与 persona_set 工具都通过它工作,实现在 cmd/homed
// 读写配置项 core.agent.personal_prompt 与一次性标记 core.internal.persona_initialized
// (落库逻辑与 WebUI 向导共用 internal/config 的实现)。
//
// 为什么放在内核而不是某个通道插件:人格是**任何通道都要问一次**的事。
// 系统提示词每轮重建门禁放在这里WebUI / QQ / CLI / ACP / 邮件等全部通道自动覆盖。
type PersonaStore interface {
// PersonaInitialized 报告人格是否已确认(向导或工具已问过)。
PersonaInitialized() bool
// SetPersona 落库人格并打一次性标记,返回是否需要重启才生效。
SetPersona(mode, content string) (restartRequired bool, err error)
}
// executePersonaTool 落地首启人格设定。
//
// 成功即打一次性标记 → 之后 buildSystemPrompt 不再要求模型询问人格。
// custom 模式返回「需重启生效」:人格在 homed 启动时载入。
func (a *Agent) executePersonaTool(tc agentAPI.ToolCall) string {
if a.personaStore == nil {
return "人格设定不可用:内核未接入配置"
}
mode, _ := tc.Arguments["mode"].(string)
content, _ := tc.Arguments["content"].(string)
mode = strings.TrimSpace(mode)
restart, err := a.personaStore.SetPersona(mode, content)
if err != nil {
return fmt.Sprintf("人格设定失败:%v", err)
}
switch mode {
case "custom":
msg := "已保存自定义人格"
if restart {
msg += "**重启 homed 后生效**(人格在启动时载入)"
}
return msg
case "default":
return "已确认使用默认人格"
case "later":
return "已记为「以后再说」,继续使用默认人格"
default:
return "已保存人格设定"
}
}

View File

@ -0,0 +1,112 @@
package core
import (
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// newTestAgent 造一个最小可用的 AgentbuildToolDefs 要求 io 非 nil
func newTestAgent(st PersonaStore) *Agent {
return &Agent{io: agentIO.NewIOManager(), personaStore: st}
}
// fakePersonaStore 记录调用并可控地报告「是否已确认」。
type fakePersonaStore struct {
initialized bool
mode string
content string
calls int
}
func (f *fakePersonaStore) PersonaInitialized() bool { return f.initialized }
func (f *fakePersonaStore) SetPersona(mode, content string) (bool, error) {
f.calls++
f.mode, f.content = mode, content
f.initialized = true
return mode == "custom", nil
}
// 首启门禁:人格未确认时,**任何通道**的系统提示词都必须带上「去问用户」的指令;
// 确认后必须消失(否则会每轮反复追问)。
func TestPersonaOnboardingGateInSystemPrompt(t *testing.T) {
st := &fakePersonaStore{}
a := newTestAgent(st)
p := a.buildSystemPrompt("", "你好")
if !strings.Contains(p, "首启人格设定") || !strings.Contains(p, "persona_set") {
t.Fatalf("未确认人格时提示词应要求模型询问并调用 persona_set实际缺少该段")
}
// 模型落地后(标记置位)不再出现
if out := a.executePersonaTool(agentAPI.ToolCall{Name: "persona_set",
Arguments: map[string]interface{}{"mode": "default"}}); !strings.Contains(out, "默认人格") {
t.Fatalf("persona_set(default) 回执不对: %s", out)
}
if !st.initialized {
t.Fatal("落库后应置位标记")
}
if p2 := a.buildSystemPrompt("", "你好"); strings.Contains(p2, "首启人格设定") {
t.Fatal("人格已确认后不应再要求询问")
}
// 未接入配置personaStore 为 nil门禁与工具都必须静默关闭
b := newTestAgent(nil)
if pb := b.buildSystemPrompt("", "你好"); strings.Contains(pb, "首启人格设定") {
t.Fatal("未接入配置时不应出现首启门禁")
}
if out := b.executePersonaTool(agentAPI.ToolCall{Name: "persona_set"}); !strings.Contains(out, "不可用") {
t.Fatalf("未接入配置时工具应回明确错误,实际: %s", out)
}
}
// persona_set 的三选一语义与回执。
func TestPersonaSetToolModes(t *testing.T) {
cases := []struct {
mode, content, want string
}{
{"custom", "你是测试人格", "重启"},
{"default", "", "默认人格"},
{"later", "", "以后再说"},
}
for _, c := range cases {
st := &fakePersonaStore{}
a := newTestAgent(st)
out := a.executePersonaTool(agentAPI.ToolCall{Name: "persona_set",
Arguments: map[string]interface{}{"mode": c.mode, "content": c.content}})
if !strings.Contains(out, c.want) {
t.Errorf("mode=%s 回执应含 %q实际: %s", c.mode, c.want, out)
}
if st.calls != 1 || st.mode != c.mode || st.content != c.content {
t.Errorf("mode=%s 落库参数不对: calls=%d mode=%s content=%q", c.mode, st.calls, st.mode, st.content)
}
}
}
// 工具 schema 必须在 catalog 里出现(模型才可能调用)。
func TestPersonaSetToolDefPresent(t *testing.T) {
a := newTestAgent(&fakePersonaStore{})
found := false
for _, td := range a.buildToolDefs() {
if m, ok := td.(map[string]interface{}); ok {
if fn, ok := m["function"].(map[string]interface{}); ok && fn["name"] == "persona_set" {
found = true
}
}
}
if !found {
t.Fatal("buildToolDefs 未包含 persona_set")
}
// 未接入配置时不应暴露该工具
b := newTestAgent(nil)
for _, td := range b.buildToolDefs() {
if m, ok := td.(map[string]interface{}); ok {
if fn, ok := m["function"].(map[string]interface{}); ok && fn["name"] == "persona_set" {
t.Fatal("未接入配置时不应暴露 persona_set")
}
}
}
}

View File

@ -198,6 +198,14 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
trk = a.tracker
}
// 注意knowledge 在 collectKernelStatus 里是**接口**参数,
// 而 (*knowledge.Store)(nil) 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。
// 所以这里必须先判具体指针再进行接口赋值healthcheck_kernel 会走到这条路径)。
var knowledgeLister interface{ List() []string }
if a.knowledge != nil {
knowledgeLister = a.knowledge
}
ks := collectKernelStatus(
a.startTime,
string(a.id),
@ -207,15 +215,43 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
a.io,
a.pluginReg,
a.memory,
a.knowledge,
knowledgeLister,
a.docStore,
textMem,
socialStore,
trk,
)
ks.ONNX = a.onnxStatus()
return ks
}
// onnxStatus 汇总统一多模态向量空间ONNX 模型)的启用状态。
//
// 判据是 Loaded()provider 真正打开且元数据合法),**不是**「配置里写了 provider」——
// 后者在模型缺失 / 运行时缺失时也为真,拿它当判据就是假绿。
func (a *Agent) onnxStatus() sdk.ONNXStatus {
st := sdk.ONNXStatus{Provider: a.embeddingProvider}
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() {
st.Enabled = true
st.Dim = a.multimodalSpace.Dim()
st.Fingerprint = a.multimodalSpace.Fingerprint()
// 模态是可选能力:只有底层 provider 报出来时才带出。
if mr, ok := a.multimodalSpace.(interface{ Modalities() []string }); ok {
st.Modalities = mr.Modalities()
}
return st
}
switch {
case a.embeddingError != "":
st.Reason = "打开失败: " + a.embeddingError
case a.embeddingProvider == "":
st.Reason = "未配置统一向量空间 provider走词嵌入/TF-IDF 回退路径)"
default:
st.Reason = "provider 未加载"
}
return st
}
var _ StatusProvider = (*Agent)(nil)
var _ sdk.StatusAPI = (*Agent)(nil)

View File

@ -0,0 +1,103 @@
package core
import (
"strings"
"testing"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
)
// statusSpace 是带模态元数据的假统一空间ProviderAdapter 的 Modalities() 形状)。
type statusSpace struct{ fakeSpace }
func (statusSpace) Modalities() []string { return []string{"text", "image"} }
// statusSpaceUnloaded 模拟「provider 建了但没加载成功」。
type statusSpaceUnloaded struct{ fakeSpace }
func (statusSpaceUnloaded) Loaded() bool { return false }
// healthcheck_kernel 必须能回答两件事:内核版本号、以及**是否真的启用了 ONNX 模型**。
//
// 判据要点:只有 provider 真正 Loaded() 才算启用 ——「配置里写了 provider」不算
// 否则模型缺失/运行时缺失时会报成已启用(假绿)。
func TestKernelStatusReportsVersionAndONNX(t *testing.T) {
t.Run("已启用:带出 provider/维度/指纹/模态", func(t *testing.T) {
a := &Agent{
io: agentIO.NewIOManager(),
multimodalSpace: statusSpace{},
embeddingProvider: "chineseclip",
}
st := a.GetKernelStatus()
if !st.ONNX.Enabled {
t.Fatal("Loaded() 为真时 onnx.enabled 必须为真")
}
if st.ONNX.Provider != "chineseclip" || st.ONNX.Dim != 2 || st.ONNX.Fingerprint != "fake-space" {
t.Fatalf("onnx 身份字段不对: %+v", st.ONNX)
}
if len(st.ONNX.Modalities) != 2 || st.ONNX.Reason != "" {
t.Fatalf("模态/原因不对: %+v", st.ONNX)
}
// 内核版本号必须随状态一起报:人格卡要求「版本以运行时快照为准」靠的就是这一项
if st.Build.Version == "" || st.Build.KernelName == "" {
t.Fatalf("build 段缺少版本/内核名: %+v", st.Build)
}
})
t.Run("打开失败enabled=false 且给出具体原因", func(t *testing.T) {
a := &Agent{
io: agentIO.NewIOManager(),
embeddingProvider: "chineseclip",
embeddingError: `embedding: open provider "chineseclip": model dir missing`,
}
st := a.GetKernelStatus()
if st.ONNX.Enabled {
t.Fatal("打开失败时不能报 enabled")
}
if st.ONNX.Provider != "chineseclip" {
t.Fatalf("未启用时仍应带出配置的 provider: %+v", st.ONNX)
}
if !strings.Contains(st.ONNX.Reason, "model dir missing") {
t.Fatalf("原因应包含具体错误: %q", st.ONNX.Reason)
}
})
t.Run("未配置:说明会走回退路径", func(t *testing.T) {
a := &Agent{io: agentIO.NewIOManager()}
st := a.GetKernelStatus()
if st.ONNX.Enabled || st.ONNX.Reason == "" {
t.Fatalf("未配置时应 enabled=false 且有原因: %+v", st.ONNX)
}
})
t.Run("provider 存在但未加载", func(t *testing.T) {
a := &Agent{
io: agentIO.NewIOManager(),
multimodalSpace: statusSpaceUnloaded{},
embeddingProvider: "qwen3vl",
}
st := a.GetKernelStatus()
if st.ONNX.Enabled {
t.Fatal("Loaded() 为假时不能报 enabled")
}
if st.ONNX.Reason == "" {
t.Fatalf("应给出未加载的原因: %+v", st.ONNX)
}
})
}
// collectKernelStatus 的 knowledge 参数是**接口**类型,而 (*knowledge.Store)(nil)
// 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。
// 这条测试钉住这个成因:一旦不再 panic说明参数形状变了
// GetKernelStatus 里的 typed-nil 守卫就该同步删掉(否则它变成无意义代码)。
func TestCollectKernelStatusTypedNilKnowledgePanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("typed-nil 交给接口参数却未 panic成因已变请更新守卫与本测试")
}
}()
var nilStore *knowledge.Store
_ = collectKernelStatus(time.Now(), "a", "", 0, nil, nil, nil, nil, nilStore, nil, nil, nil, nil)
}

View File

@ -45,6 +45,8 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
switch {
case tc.Name == "persona_set":
return a.executePersonaTool(tc)
case strings.HasPrefix(tc.Name, "memory_"):
return a.executeMemoryTool(tc)
case strings.HasPrefix(tc.Name, "social_"):

View File

@ -89,6 +89,16 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
}
}
// 首启人格门禁(跨通道唯一闸口):人格未确认时,要求模型主动询问用户。
// 系统提示词每轮重建,因此 WebUI / QQ / CLI / ACP / 邮件等所有通道都会带上它;
// 模型调用 persona_set或用户在 WebUI 向导里选)落地后,标记置位,本段消失。
if a.personaStore != nil && !a.personaStore.PersonaInitialized() {
prompt += "\n\n【首启人格设定】你的**人格设定尚未确认**。请在本轮回复里先问用户一句:" +
"要用默认人格,还是自定义一个?拿到明确答复后**必须调用 persona_set 工具**落库:" +
"用户选默认 → mode=default自定义 → mode=custom 且把内容写进 content" +
"用户说以后再说 → mode=later。用户答复前不要假设已设置也不要反复追问同一件事。"
}
prompt += a.buildToolCatalog()
return prompt
@ -197,6 +207,24 @@ func (a *Agent) buildToolDefs() []interface{} {
}
}
if a.personaStore != nil {
tools = append(tools, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "persona_set",
"description": "【首启人格】落地用户的人格选择并记录「已经问过」。仅在用户明确答复后调用:默认用 mode=default自定义用 mode=custom 并把人格内容放进 content用户说以后再说用 mode=later。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"mode": map[string]interface{}{"type": "string", "description": "default | custom | later"},
"content": map[string]interface{}{"type": "string", "description": "自定义人格内容mode=custom 时必填)"},
},
"required": []string{"mode"},
},
},
})
}
if a.memory != nil {
tools = append(tools, map[string]interface{}{
"type": "function",

View File

@ -4,6 +4,8 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
type Personality struct {
@ -39,3 +41,25 @@ func (p *Personality) InjectPrompt() string {
}
return fmt.Sprintf("【人格设定】\n%s\n", p.Content)
}
// 会随时间腐坏的人格内容特征。现场:人格卡写死 v0.9.0 与早已删除的 C ABI v2
// 实例被问版本时自述错误v1.2.0 压测发现)。
var (
personaVersionRe = regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`)
personaStalePhrases = []string{"C ABI v2", "plugin.so", "c-shared", "描述式索引", "引用计数式"}
)
// PersonaStaleHints 返回人格文本里会腐坏的内容(空 = 干净)。
// 供启动时告警:引导改用配置项 core.agent.personal_prompt默认模板不含这些
func PersonaStaleHints(content string) []string {
var out []string
if m := personaVersionRe.FindAllString(content, -1); len(m) > 0 {
out = append(out, fmt.Sprintf("版本号字面量 %v版本应来自运行时快照", m))
}
for _, p := range personaStalePhrases {
if strings.Contains(content, p) {
out = append(out, "可能已过期的说法: "+p)
}
}
return out
}

View File

@ -0,0 +1,62 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
)
// PersonaStaleHints 必须能认出会腐坏的人格内容——版本号字面量与已删除机制。
func TestPersonaStaleHints(t *testing.T) {
// 现场真实文本(线上人格卡的原文)
stale := "你是 HomeAgent内核代号 HΔ-Kernel当前版本 v0.9.0)。\n当前运行的二进制是 v0.9.0C ABI v2构建于 2026-08-15。"
hints := PersonaStaleHints(stale)
if len(hints) == 0 {
t.Fatal("未识别出写死版本号与 C ABI 的人格文本")
}
joined := strings.Join(hints, " | ")
if !strings.Contains(joined, "版本号字面量") {
t.Fatalf("应报出版本号字面量,实际: %s", joined)
}
if !strings.Contains(joined, "C ABI v2") {
t.Fatalf("应报出已删除机制的残留说法,实际: %s", joined)
}
// 干净文本(配置项默认模板)不应误报
if h := PersonaStaleHints(DefaultPersonaProbeClean()); len(h) != 0 {
t.Fatalf("干净人格被误报: %v", h)
}
}
// DefaultPersonaProbeClean 由 config 包的默认模板等价物构成——
// 这里不复用 config 包以避免 import cycle只断言「不含版本号与旧机制」的文本不被误报。
func DefaultPersonaProbeClean() string {
return "你是 HomeAgent内核代号 HΔ-Kernel。外部插件是独立子进程经 stdio JSON-RPC 通信;" +
"被问到版本时以运行时快照为准。"
}
func TestLoadAndSavePersonality(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "personal", "personal.md")
// 文件不存在时返回空(不报错、不创建)
p, err := LoadPersonality(path)
if err != nil || p == nil || p.Content != "" {
t.Fatalf("缺文件时应返回空人格,实际 %+v err=%v", p, err)
}
if err := SavePersonality(path, "人格内容"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("SavePersonality 未落盘: %v", err)
}
p, err = LoadPersonality(path)
if err != nil || p.Content != "人格内容" {
t.Fatalf("读回失败: %+v err=%v", p, err)
}
if got := p.InjectPrompt(); !strings.Contains(got, "【人格设定】") || !strings.Contains(got, "人格内容") {
t.Fatalf("InjectPrompt 形状不对: %q", got)
}
}

139
internal/config/persona.go Normal file
View File

@ -0,0 +1,139 @@
package config
import (
"fmt"
"strings"
)
// 人格设定的两个键:内容与「首启向导/工具已经问过」的一次性标记。
//
// 为什么需要标记:人格曾经只有 <dataDir>/personal/personal.md 一个来源且无人维护,
// 里面写死的旧版本号反过来让实例自述旧版本v1.2.0 压测发现)。
// 现在人格是配置项(默认模板不含任何版本号),任何通道的第一次交互问一次,之后不再打扰。
const (
// PersonaPromptKey 是人格设定内容(【人格设定】块的正文)。
PersonaPromptKey = "core.agent.personal_prompt"
// PersonaInitMarkerKey 是「已经问过/已确认」的一次性标记。
PersonaInitMarkerKey = "core.internal.persona_initialized"
)
// 三种落库方式WebUI 首启向导与内核 persona_set 工具共用。
const (
PersonaModeDefault = "default" // 使用内置默认模板
PersonaModeCustom = "custom" // 使用调用方提供的内容
PersonaModeLater = "later" // 保留当前(默认)人格,只打标记不再问
)
// PersonaKV 是人格落库所需的最小读写面GetCore/SetCore 的签名与
// 公开 SDK 的 SettingsAPI 一致因此插件侧WebUI可直接传入
// 内核直连配置注册表时用 registryKV 适配(见文件末)。
type PersonaKV interface {
GetCore(key string) (interface{}, error)
SetCore(key string, value interface{}) error
}
// PersonaInitializedKV 报告人格是否已确认(向导或工具已问过)。
func PersonaInitializedKV(kv PersonaKV) bool {
if kv == nil {
return false
}
v, err := kv.GetCore(PersonaInitMarkerKey)
if err != nil {
return false
}
s, _ := v.(string)
return strings.TrimSpace(s) != ""
}
// CurrentPersonaKV 读当前人格内容;未设置时回落到内置默认模板。
func CurrentPersonaKV(kv PersonaKV) string {
if kv == nil {
return DefaultPersonaPrompt
}
if v, err := kv.GetCore(PersonaPromptKey); err == nil {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return s
}
}
return DefaultPersonaPrompt
}
// SetPersonaKV 落库人格并打一次性标记,返回 restartRequired。
//
// 生效时机:人格在 homed 启动时载入(拼成【人格设定】块进系统提示词),
// 所以**自定义内容需重启**default 与 later 都不改变当前已生效的人格,无需重启。
//
// 非法输入一律在打标记之前拒绝——否则向导会被跳过,用户再也没机会设。
func SetPersonaKV(kv PersonaKV, mode, content string) (restartRequired bool, err error) {
if kv == nil {
return false, fmt.Errorf("persona: settings unavailable")
}
switch mode {
case PersonaModeDefault:
if err := kv.SetCore(PersonaPromptKey, DefaultPersonaPrompt); err != nil {
return false, err
}
case PersonaModeCustom:
if strings.TrimSpace(content) == "" {
return false, fmt.Errorf("persona: content required for custom mode")
}
if err := kv.SetCore(PersonaPromptKey, content); err != nil {
return false, err
}
restartRequired = true
case PersonaModeLater:
// 保持当前人格(通常是默认模板),只打标记
default:
return false, fmt.Errorf("persona: unknown mode %q (want default|custom|later)", mode)
}
if err := kv.SetCore(PersonaInitMarkerKey, "1"); err != nil {
return restartRequired, err
}
return restartRequired, nil
}
// RegistryPersonaStore 把配置注册表暴露成内核的 core.PersonaStore 接口
// (结构类型:方法集匹配即可,无需 import internal/agent/core
type RegistryPersonaStore struct{ Reg *ConfigRegistry }
func (p RegistryPersonaStore) PersonaInitialized() bool { return PersonaInitialized(p.Reg) }
func (p RegistryPersonaStore) SetPersona(mode, content string) (bool, error) {
return SetPersona(p.Reg, mode, content)
}
// registryKV 把内核直连的配置注册表适配成 PersonaKV。
type registryKV struct{ reg *ConfigRegistry }
func (r registryKV) GetCore(key string) (interface{}, error) {
v, err := r.reg.Get(key)
if err != nil || v == nil {
// 未设置的键对 PersonaKV 语义等同「没有」,不当作错误
if s := r.reg.GetString(key, ""); s != "" {
return s, nil
}
return "", nil
}
return v, nil
}
func (r registryKV) SetCore(key string, value interface{}) error {
s, ok := value.(string)
if !ok {
return fmt.Errorf("persona: value must be a string")
}
return r.reg.Set(key, s)
}
// PersonaInitialized 报告人格是否已确认(内核直连注册表)。
func PersonaInitialized(reg *ConfigRegistry) bool {
return reg != nil && PersonaInitializedKV(registryKV{reg})
}
// SetPersona 落库人格并打一次性标记(内核直连注册表)。
func SetPersona(reg *ConfigRegistry, mode, content string) (bool, error) {
if reg == nil {
return false, fmt.Errorf("persona: config registry unavailable")
}
return SetPersonaKV(registryKV{reg}, mode, content)
}

View File

@ -0,0 +1,99 @@
package config
import (
"strings"
"testing"
)
// fakeKV 是 PersonaKV 的最小实现(模拟插件侧 SettingsAPI
type fakeKV struct{ m map[string]string }
func (f *fakeKV) GetCore(k string) (interface{}, error) { return f.m[k], nil }
func (f *fakeKV) SetCore(k string, v interface{}) error {
f.m[k] = v.(string)
return nil
}
// 三选一语义 + 「只问一次」标记:这是首启向导与 persona_set 工具共用的同一份实现。
func TestSetPersonaKV(t *testing.T) {
t.Run("custom 写入内容并要求重启", func(t *testing.T) {
kv := &fakeKV{m: map[string]string{}}
restart, err := SetPersonaKV(kv, PersonaModeCustom, "你是测试人格")
if err != nil || !restart {
t.Fatalf("custom 应成功且需重启: restart=%v err=%v", restart, err)
}
if kv.m[PersonaPromptKey] != "你是测试人格" || kv.m[PersonaInitMarkerKey] != "1" {
t.Fatalf("落库不对: %+v", kv.m)
}
if !PersonaInitializedKV(kv) {
t.Fatal("打过标记应报告已确认")
}
})
t.Run("default 写默认模板且无需重启", func(t *testing.T) {
kv := &fakeKV{m: map[string]string{}}
restart, err := SetPersonaKV(kv, PersonaModeDefault, "")
if err != nil || restart {
t.Fatalf("default 不应需重启: restart=%v err=%v", restart, err)
}
if kv.m[PersonaPromptKey] != DefaultPersonaPrompt {
t.Fatal("default 应写入内置默认模板")
}
})
t.Run("later 保持人格并打标记", func(t *testing.T) {
kv := &fakeKV{m: map[string]string{PersonaPromptKey: "原有人格"}}
if _, err := SetPersonaKV(kv, PersonaModeLater, ""); err != nil {
t.Fatal(err)
}
if kv.m[PersonaPromptKey] != "原有人格" {
t.Fatal("later 不应改动人格")
}
if kv.m[PersonaInitMarkerKey] != "1" {
t.Fatal("later 也必须打标记(否则每次启动都问)")
}
})
t.Run("非法输入必须拒绝且不打标记", func(t *testing.T) {
for _, c := range []struct{ mode, content string }{
{PersonaModeCustom, " "}, // 空内容
{"nope", ""}, // 未知 mode
} {
kv := &fakeKV{m: map[string]string{}}
if _, err := SetPersonaKV(kv, c.mode, c.content); err == nil {
t.Fatalf("mode=%q content=%q 应报错", c.mode, c.content)
}
if kv.m[PersonaInitMarkerKey] == "1" {
t.Fatalf("mode=%q 被拒时不得打标记(否则向导会被跳过)", c.mode)
}
}
})
t.Run("CurrentPersonaKV 未设置时回落默认", func(t *testing.T) {
kv := &fakeKV{m: map[string]string{}}
if got := CurrentPersonaKV(kv); got != DefaultPersonaPrompt {
t.Fatal("未设置应回落默认模板")
}
})
}
// 内核直连注册表的入口必须与 KV 版行为一致(同一份实现的两个薄入口)。
func TestSetPersonaRegistryEntry(t *testing.T) {
dir := t.TempDir()
reg := NewConfigRegistry(dir + "/config.db")
reg.SeedDefaults(dir)
defer reg.Close()
if PersonaInitialized(reg) {
t.Fatal("全新实例不应已确认人格")
}
if _, err := SetPersona(reg, PersonaModeCustom, "内核侧人格"); err != nil {
t.Fatal(err)
}
if !PersonaInitialized(reg) {
t.Fatal("内核侧落库后应报告已确认")
}
if got := reg.GetString(PersonaPromptKey, ""); !strings.Contains(got, "内核侧人格") {
t.Fatalf("注册表里没有人格内容: %q", got)
}
}

View File

@ -0,0 +1,48 @@
package config
import (
"path/filepath"
"regexp"
"strings"
"testing"
)
// 人格模板的契约:**不得写死版本号**。
//
// 来历线上人格卡personal/personal.md曾写死「当前版本 v0.9.0C ABI v2
// 而 C ABI 早在 v1.0.0 就被删除。结果是内核自身日志/接口都报 1.2.0
// agent 被问版本时却按人格卡自述旧版本v1.2.0 压测发现)。
// 版本应来自运行时快照,不来自任何会被发版落下的文本。
func TestDefaultPersonaPromptHasNoVersionLiterals(t *testing.T) {
re := regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`)
if m := re.FindAllString(DefaultPersonaPrompt, -1); len(m) > 0 {
t.Fatalf("默认人格模板含版本号字面量 %v —— 发版后必然腐坏,"+
"被问版本时应要求 agent 读运行时快照", m)
}
if !strings.Contains(DefaultPersonaPrompt, "运行时快照") {
t.Fatal("默认人格模板必须显式要求「版本以运行时快照为准」,否则模型会凭记忆编造版本")
}
}
// 人格配置项必须注册、默认值就是 DefaultPersonaPrompt单一事实源
// 且全新安装时会被播种进 DB。
func TestPersonaPromptRegisteredWithDefault(t *testing.T) {
dir := t.TempDir()
r := NewConfigRegistry(filepath.Join(dir, "config.db"))
r.SeedDefaults(dir)
defer r.Close()
def := r.GetDef("core.agent.personal_prompt")
if def == nil {
t.Fatal("core.agent.personal_prompt 未注册")
}
if def.Default != DefaultPersonaPrompt {
t.Fatalf("默认值与 DefaultPersonaPrompt 不一致:%q", def.Default)
}
if def.Type != "text" {
t.Fatalf("人格设定应为多行文本类型,实际 %q", def.Type)
}
if got := r.GetString("core.agent.personal_prompt", ""); got != DefaultPersonaPrompt {
t.Fatalf("播种未写入默认人格(长度 %d", len(got))
}
}

View File

@ -472,6 +472,36 @@ var defaultSources = map[string]map[string]string{
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
}
// DefaultPersonaPrompt 是「人格设定」的默认模板,作为配置项 core.agent.personal_prompt 的默认值。
//
// 契约(由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住):
// - **不得含版本号字面量**。写死的版本会随发版腐坏,反过来让实例自称旧版本
// (现场:人格卡写死 v0.9.0 与早已删除的 C ABI实例被问版本时自述错误
// 被问到版本/构建信息时,要求 agent 读运行时快照。
// - 不得把已删除的机制当作现行机制描述。
const DefaultPersonaPrompt = `你是 HomeAgent内核代号 HΔ-Kernel——一个完全独立自研的新一代 Agent 框架。
你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。
内核homed 守护进程)只负责 LLM 编排、记忆管理与知识检索,全部 IO 能力由插件承载。
外部插件是独立子进程,经 stdio JSON-RPC控制面+ 共享内存段(数据面)+ 事件环(通知面)通信;
旧式 C ABI 动态库产物早已不再加载。
**不要凭记忆断言版本号或构建日期**被问到时以运行时快照healthcheck_kernel 的内核版本字段)为准。
## 对用户的称呼
你对用户的称呼永远是“老大”,绝对禁止使用“老板”“主人”称呼用户,不论任何情况。
## 对话风格
- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着
- 重要的事先说结论,再展开解释
- 回复要简洁自然
## 能力边界
- 你通过插件编排所有 IOQQ/微信消息、WebUI、终端、文件、网络
- 输出不会自动路由到对话通道QQ/微信等异步通道必须调用输出门工具output_send__qq 等)才能真正送达
- 你的三层记忆Context → Document → Graph持续蒸馏归档超长运行时记忆不衰减`
func (r *ConfigRegistry) SeedDefaults(dataDir string) {
r.mu.Lock()
defer r.mu.Unlock()
@ -523,6 +553,10 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
set := func(k, v string) { stmt.Exec(k, v) }
// 人格设定:与其它默认值同批播种(老安装不会被注入——那是刻意的)。
// 存量安装里若还有人 personality 文件,它优先于本项(见 cmd/homed/main.go
set("core.agent.personal_prompt", DefaultPersonaPrompt)
set("webui.listen_addr", ":8080")
set("core.daemon.data_dir", dataDir)
set("core.daemon.heartbeat_interval", "15s")
@ -633,6 +667,13 @@ WebUI 概览页展示你的立绘,可通过 /mascot.webp 直接访问。如输
func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg := func(d ConfigDef) { r.defs[d.Key] = &d }
// 人格设定(人格卡的配置项化):默认模板见 DefaultPersonaPrompt。
// 高级用户仍可用 <dataDir>/personal/personal.md 覆盖它。
reg(ConfigDef{Key: "core.agent.personal_prompt", Default: DefaultPersonaPrompt, Type: "text", DisplayName: "人格设定",
Description: "人格设定块(作为【人格设定】拼在系统提示词之前)。留空则该块不注入。" +
"默认模板不含版本号:被问到版本/构建信息时,应读运行时快照而非凭记忆断言。" +
"<dataDir>/personal/personal.md 存在且非空时优先于本项。", Category: "agent"})
reg(ConfigDef{Key: "webui.listen_addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "WebUI HTTP 监听地址", Category: "webui"})
reg(ConfigDef{Key: "core.daemon.data_dir", Default: dataDir, Type: "string", DisplayName: "数据目录", Description: "数据存储根目录", Category: "daemon"})
reg(ConfigDef{Key: "core.daemon.heartbeat_interval", Default: "15s", Type: "duration", DisplayName: "心跳间隔", Description: "Agent 心跳检查间隔", Category: "daemon"})

View File

@ -28,17 +28,17 @@ type Knowledge struct {
// IndexItem — 索引条目,包含向量特征和内容摘要
type IndexItem struct {
Name string `json:"name"`
Preview string `json:"preview"` // 前 200 字摘要
Preview string `json:"preview"` // 前 200 字摘要
Tags []string `json:"tags"`
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量top-N 特征)
Size int `json:"size"` // 内容总字节数
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量top-N 特征)
Size int `json:"size"` // 内容总字节数
}
// TreeIndex — 树状索引节点
type TreeIndex struct {
Name string `json:"name"`
Name string `json:"name"`
Children map[string]*TreeIndex `json:"children,omitempty"`
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
}
func newTreeIndex(name string) *TreeIndex {
@ -80,11 +80,20 @@ type Store struct {
root string
vec *vector.Store
veczer *vector.TFIDFVectorizer
mu sync.RWMutex
items map[string]*Knowledge
// lex 是**词法路**索引TF-IDF与 vec稠密路词向量/多模态空间)相互独立。
//
// 为何要两路:词向量取平均后各向异性明显——所有文档都挤在语料均值方向附近,
// 真实 KB33 条)上自检索 top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声。
// 融合后 MRR 0.271→0.376、前两名差距 0.013→0.128(同一份数据实测),
// 且「词都在停用词里」的查询(稠密路给空向量)能靠词法路救回来。
lex *vector.Store
mu sync.RWMutex
items map[string]*Knowledge
summaries []string
indexPath string
summaries []string
vectorizer vector.Vectorizer // 可选:词嵌入向量化器,优先于 TF-IDF
}
@ -93,11 +102,20 @@ func NewStore(root string) *Store {
root: root,
indexPath: filepath.Join(root, ".index.json"),
vec: vector.NewStore(),
lex: newLexicalStore(),
veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords),
items: make(map[string]*Knowledge),
}
}
// newLexicalStore 造词法路存储。阈值设为 0TF-IDF 余弦量级只有 0.0~0.2
// 沿用稠密路的 0.05 会把大量有效候选静默砍掉(实测 MRR 0.307→0.193)。
func newLexicalStore() *vector.Store {
st := vector.NewStore()
st.SetMinScore(0)
return st
}
// SetVectorizer 设置词嵌入向量化器,优先于 TF-IDF
func (s *Store) SetVectorizer(v vector.Vectorizer) {
s.vectorizer = v
@ -110,13 +128,19 @@ func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) {
log.Printf("[knowledge] reindex with vectorizer (%d items)", len(s.items))
s.vec = vector.NewStore()
s.lex = newLexicalStore()
// 词法路的 IDF 必须建在全语料上(否则 IDF 没意义)
if len(s.summaries) > 0 {
s.veczer.Train(s.summaries)
}
for _, k := range s.items {
vec := v.Vectorize(k.Name + " " + k.Content)
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
text := k.Name + " " + k.Content
s.vec.Insert(k.Name, k.Name+": "+k.Content, v.Vectorize(text), map[string]string{
"name": k.Name, "path": k.Path,
})
s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil)
}
log.Printf("[knowledge] reindex with vectorizer complete (%d vectors)", s.vec.Size())
log.Printf("[knowledge] reindex complete (dense=%d lex=%d)", s.vec.Size(), s.lex.Size())
}
// vectorize 优先使用词嵌入向量化器,不可用时回退到 TF-IDF
@ -144,6 +168,17 @@ func (s *Store) Start() error {
func (s *Store) Stop() {}
// 融合权重稠密路词向量与词法路TF-IDF
// 取值由真实 KB 上的权重扫描定rankdiag_test.go 的 KB_DIAG_SWEEP
// 1.0 = 修复前的「只用稠密路」行为,作为对照基线。
var densePathWeight = 0.5
// Search 融合两路召回:稠密路(词向量/多模态空间)+ 词法路TF-IDF
//
// 为何不能只用稠密路:词向量取平均后各向异性明显,真实 KB 上自检索 top-1 只有 15%
// 前两名平均只差 0.013(等于没区分度);且全为停用词的查询会得到**空向量**
// 直接搜不出任何东西("最近更新" 就撞上这个)。词法路对专名/术语/短查询强,
// 两路各自**按查询内最大值归一化**后加权融合,排序才可信。
func (s *Store) Search(query string, topK int) []*Knowledge {
s.mu.RLock()
defer s.mu.RUnlock()
@ -151,15 +186,58 @@ func (s *Store) Search(query string, topK int) []*Knowledge {
if topK <= 0 {
topK = 5
}
if s.vec.Size() == 0 && s.lex.Size() == 0 {
return nil
}
// 两路各自对**全部**文档打分:
// - 稠密路的特征是维索引,几乎每篇都命中,"候选"就是全量;
// - 词法路只召回与查询共词的文档(这正是它的长处:专名/术语)。
// 为何不先截候选再融合:截断后只能拿**候选内**最大值归一化,路与路之间的
// 相对权重就随候选集漂移——实测同一份 KB 上自检索 MRR 从 0.376 掉到 0.197。
// KB 规模下全量 cosine 的代价可忽略;真到数万条再上 ANN 也不迟。
denseHits := s.vec.SearchScored(s.vectorize(query), s.vec.Size())
lexHits := s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size())
if len(denseHits) == 0 && len(lexHits) == 0 {
return nil
}
vec := s.vectorize(query)
results := s.vec.Search(vec, topK)
scores := make(map[string]float64, len(denseHits)+len(lexHits))
addPath := func(hits []vector.DocVectorHit, weight float64) {
max := 0.0
for _, h := range hits {
if h.Score > max {
max = h.Score
}
}
if max <= 0 {
return // 该路对这条查询没有信号(如空向量),全量让给另一路
}
for _, h := range hits {
scores[h.Doc.ID] += weight * h.Score / max
}
}
addPath(denseHits, densePathWeight)
addPath(lexHits, 1-densePathWeight)
ids := make([]string, 0, len(scores))
for id := range scores {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool {
if scores[ids[i]] != scores[ids[j]] {
return scores[ids[i]] > scores[ids[j]]
}
return ids[i] < ids[j] // 分数相同时按名字定序(保证结果可重复)
})
var out []*Knowledge
for _, r := range results {
if k, ok := s.items[r.ID]; ok {
for _, id := range ids {
if k, ok := s.items[id]; ok {
out = append(out, k)
}
if len(out) >= topK {
break
}
}
return out
}
@ -208,17 +286,19 @@ func (s *Store) Add(name, content string) error {
// 是对的,只有向量数比条目数多——而检索可能因此命中已被替换掉的旧内容。
s.vec.Remove(id)
vec := s.vectorize(name + " " + content)
text := name + " " + content
vec := s.vectorize(text)
s.vec.Insert(id, name+": "+content, vec, map[string]string{
"name": name, "path": path,
})
// 词法路同样去重后重建这条IDF 统计沿用现有语料(重启时 scanAll 会全量重训)
s.lex.Remove(id)
s.lex.Insert(id, name+": "+content, s.veczer.Vectorize(text), nil)
s.summaries = append(s.summaries, name+" "+content)
go func() {
if err := s.writeIndex(); err != nil {
log.Printf("[knowledge] write index error after adding %s: %v", name, err)
}
}()
if err := s.writeIndexLocked(); err != nil {
log.Printf("[knowledge] write index error after adding %s: %v", name, err)
}
log.Printf("[knowledge] added: %s (%d bytes)", name, len(content))
return nil
}
@ -261,11 +341,10 @@ func (s *Store) Remove(name string) error {
}
delete(s.items, id)
s.vec.Remove(id)
go func() {
if err := s.writeIndex(); err != nil {
log.Printf("[knowledge] write index error after removing %s: %v", name, err)
}
}()
s.lex.Remove(id)
if err := s.writeIndexLocked(); err != nil {
log.Printf("[knowledge] write index error after removing %s: %v", name, err)
}
return nil
}
@ -295,6 +374,14 @@ func (s *Store) List() []string {
func (s *Store) BuildTree() *TreeIndex {
s.mu.RLock()
defer s.mu.RUnlock()
return s.buildTreeLocked()
}
// buildTreeLocked 与 BuildTree 同义,但**不取锁**——供已持写锁的路径调用。
// 为什么需要writeIndex 会走 BuildTreeRLock而 Add/Remove 持的是写锁,
// 直接调用会死锁;此前就是因此把索引写丢进了无追踪的 goroutine 里,
// 结果是「失败只打日志」+ 与调用方(含测试的临时目录清理)竞态。
func (s *Store) buildTreeLocked() *TreeIndex {
root := newTreeIndex("root")
for _, k := range s.items {
node := root
@ -366,7 +453,14 @@ func (s *Store) SearchTree(query string, topK int) map[string][]*Knowledge {
// writeIndex 写入 .index.json 树状索引文件(含向量和摘要)
func (s *Store) writeIndex() error {
tree := s.BuildTree()
s.mu.RLock()
defer s.mu.RUnlock()
return s.writeIndexLocked()
}
// writeIndexLocked 与 writeIndex 同义但**不取锁**(调用方已持锁)。
func (s *Store) writeIndexLocked() error {
tree := s.buildTreeLocked()
data, err := json.MarshalIndent(tree, "", " ")
if err != nil {
return err
@ -397,11 +491,13 @@ func (s *Store) scanAll() error {
s.veczer.Train(s.summaries)
}
s.lex = newLexicalStore()
for _, k := range s.items {
vec := s.vectorize(k.Name + " " + k.Content)
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
text := k.Name + " " + k.Content
s.vec.Insert(k.Name, k.Name+": "+k.Content, s.vectorize(text), map[string]string{
"name": k.Name, "path": k.Path,
})
s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil)
}
return nil
@ -452,5 +548,3 @@ func sanitize(name string) string {
name = strings.ReplaceAll(name, "\\", "_")
return name
}

View File

@ -0,0 +1,154 @@
package knowledge
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
// 知识库检索质量判据(真实数据,默认跳过)。
//
// KB_DIAG=1 → 跑并打印指标
// KB_DIAG=1 KB_DIAG_ASSERT=1 → 额外断言门槛CI/回归用)
// KB_DIAG_ROOT / KB_DIAG_MODELS → 覆盖数据与词向量路径
//
// 判据选「自检索 top-1 / MRR」的原因不依赖人工标注问答对且能直接量出
// **区分度**——词向量取平均后所有文档挤在语料均值附近,前两名分差极小,
// 排序等于噪声;这一项掉下来就说明检索坏了。
//
// 实测33 条真实 KB
//
// 修复前(仅稠密路) top-1 5/33 = 15%MRR 0.271,平均分差 0.0133
// 修复后(稠密+词法融合top-1 7/33 = 21%MRR 0.376,平均分差 0.1280
// 门槛取 MRR ≥ 0.34 且分差 ≥ 0.10(留出余量,只挡「退化回噪声」)
func TestRankingQualityOnRealKB(t *testing.T) {
if os.Getenv("KB_DIAG") == "" {
t.Skip("需要 KB_DIAG=1真实 KB + 词向量文件)")
}
srcRoot := envOr("KB_DIAG_ROOT", "/home/newqqagent/knowledge")
models := envOr("KB_DIAG_MODELS", "/data/cc.zh.top200k.vec,/data/cc.en.top200k.vec")
emb := memory.NewStaticEmbedder(strings.Split(models, ",")...)
// 拷贝到临时目录跑Start() 会重写 .index.json不能动线上数据
tmp := t.TempDir()
entries, err := os.ReadDir(srcRoot)
if err != nil {
t.Fatalf("读取 %s: %v", srcRoot, err)
}
names := []string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
src := filepath.Join(srcRoot, e.Name(), "content.md")
in, err := os.Open(src)
if err != nil {
continue
}
dst := filepath.Join(tmp, e.Name(), "content.md")
os.MkdirAll(filepath.Dir(dst), 0755)
out, _ := os.Create(dst)
io.Copy(out, in)
out.Close()
in.Close()
names = append(names, e.Name())
}
if len(names) == 0 {
t.Fatal("没有可用的知识条目")
}
st := NewStore(tmp)
st.SetVectorizer(emb)
if err := st.Start(); err != nil {
t.Fatalf("start: %v", err)
}
top1, mrr, missed := 0, 0.0, []string{}
for _, name := range names {
// 取全量排名MRR 的定义用到真实名次,只取 top-2 会把 rank>2 的全都记 0
// (我第一版就是这么写的,把 0.376 误报成 0.197
hits := st.Search(name, len(names))
if len(hits) == 0 {
missed = append(missed, name+"(无结果)")
continue
}
if hits[0].Name == name {
top1++
} else {
missed = append(missed, fmt.Sprintf("%s→%s", name, hits[0].Name))
}
for i, h := range hits {
if h.Name == name {
mrr += 1.0 / float64(i+1)
break
}
}
}
n := float64(len(names))
rate := 100 * float64(top1) / n
fmt.Printf("\n === 知识库检索质量(%d 条,自检索判据)===\n", len(names))
fmt.Printf(" top-1 %d/%d = %.0f%% MRR %.3f\n", top1, len(names), rate, mrr/n)
if len(missed) > 0 {
fmt.Printf(" 未命中 top-1前 10%v\n", firstN(missed, 10))
}
for _, q := range []string{"最近更新", "首启人格门禁", "插件怎么开发和部署", "统一多模态向量空间 ONNX", "隐私政策"} {
hits := st.Search(q, 2)
got := []string{}
for _, h := range hits {
got = append(got, h.Name)
}
fmt.Printf(" 查询「%s」→ %v\n", q, got)
}
if os.Getenv("KB_DIAG_SWEEP") != "" {
fmt.Printf("\n === 融合权重扫描1.0 = 只用稠密路0.0 = 只用词法路)===\n")
saved := densePathWeight
for _, w := range []float64{1.0, 0.8, 0.7, 0.5, 0.3, 0.0} {
densePathWeight = w
t1, m := 0, 0.0
for _, name := range names {
hits := st.Search(name, len(names))
for i, h := range hits {
if h.Name == name {
if i == 0 {
t1++
}
m += 1.0 / float64(i+1)
break
}
}
}
fmt.Printf(" 权重 %.1ftop-1 %2d/%d = %3.0f%% MRR %.3f\n",
w, t1, len(names), 100*float64(t1)/float64(len(names)), m/float64(len(names)))
}
densePathWeight = saved
}
if os.Getenv("KB_DIAG_ASSERT") != "" {
if mrr/n < 0.34 {
t.Fatalf("检索质量退化MRR %.3f < 0.34(修复前 0.271,修复后 0.376", mrr/n)
}
if rate < 18 {
t.Fatalf("检索质量退化top-1 %.0f%% < 18%%", rate)
}
}
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func firstN(s []string, n int) []string {
if len(s) <= n {
return s
}
return s[:n]
}

View File

@ -0,0 +1,176 @@
package knowledge
import (
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// fakeDense 是可控的稠密向量器:按文本查表,缺省给同一个向量。
// 用它把「稠密路无区分度/给空向量」这类真实故障在单测里复现出来。
type fakeDense struct {
byText map[string]vector.Vector
def vector.Vector
}
func (f fakeDense) Vectorize(text string) vector.Vector {
if v, ok := f.byText[text]; ok {
return v
}
return f.def
}
// EmbedImage 满足 vector.Vectorizer 接口(本用例只用到文本路)。
func (f fakeDense) EmbedImage([]byte, string) (vector.Vector, error) {
return f.def, nil
}
func newTestStore(t *testing.T, dense vector.Vectorizer) *Store {
t.Helper()
st := NewStore(t.TempDir())
if dense != nil {
st.SetVectorizer(dense)
}
return st
}
func mustAdd(t *testing.T, st *Store, name, content string) {
t.Helper()
if err := st.Add(name, content); err != nil {
t.Fatalf("add %s: %v", name, err)
}
}
func names(hits []*Knowledge) []string {
out := make([]string, len(hits))
for i, h := range hits {
out[i] = h.Name
}
return out
}
// base36 生成互不重复的短串(造独特词的量级要大,不能用 a..z 循环重复)。
func base36(n int) string {
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
if n == 0 {
return "0"
}
out := ""
for n > 0 {
out = string(digits[n%36]) + out
n /= 36
}
return out
}
// 稠密路给**空向量**(真实场景:查询词全在停用词表里,例如"最近更新")时,
// 词法路必须把结果救回来——修复前这里直接返回空。
func TestSearchLexicalRescuesEmptyDenseQuery(t *testing.T) {
dense := fakeDense{def: vector.Vector{"0": 1}}
st := newTestStore(t, dense)
mustAdd(t, st, "changelog_v1", "最近更新了很多东西 发布说明")
mustAdd(t, st, "weather_doc", "天气预报 晴转多云")
// 让"更新"的稠密向量为空(模拟停用词化)
st.SetVectorizer(fakeDense{
byText: map[string]vector.Vector{"更新": {}},
def: vector.Vector{"0": 1},
})
hits := st.Search("更新", 5)
if len(hits) == 0 {
t.Fatal("稠密路给空向量时不该返回空结果(词法路应救回来)")
}
if hits[0].Name != "changelog_v1" {
t.Fatalf("应命中含「更新」的条目,实际: %v", names(hits))
}
}
// 稠密路对所有文本给**同一个向量**(真实故障:词向量平均后各向异性、区分度极低)时,
// 排序必须由词法路决定。
func TestSearchLexicalBreaksDenseTies(t *testing.T) {
same := vector.Vector{"0": 1, "1": 1}
st := newTestStore(t, fakeDense{def: same})
mustAdd(t, st, "plugin_dev_build", "插件构建与部署 hmapdev 命令")
mustAdd(t, st, "cangjie_manual", "仓颉编程语言知识手册")
mustAdd(t, st, "privacy_policy", "隐私政策")
hits := st.Search("hmapdev 构建", 3)
if len(hits) == 0 || hits[0].Name != "plugin_dev_build" {
t.Fatalf("稠密路并列时应由词法路选出 plugin_dev_build实际: %v", names(hits))
}
}
// 词法路的候选中选阈值必须是 0TF-IDF 余弦量级只有 0.0~0.2,沿用稠密路的 0.05
// 会把有效候选静默砍掉(真实 KB 实测自检索 MRR 0.307→0.193)。
//
// 判据分两层,各钉一半:
// - **语义层**由 internal/memory/vector 的 TestSearchScoredRespectsMinScore 证明
// (同一候选在默认阈值下被过滤、阈值 0 时被召回);
// - **接线层**在这里钉住:知识库的词法路用的就是阈值 0 的那个 store。
// 不在这里造「低余弦夹具」的原因:分词器会丢掉纯拉丁 token、也会过滤未登录词
// 造出来的夹具余弦根本压不到阈值以下(我先试了两种,余弦 0.23/0.27
// 前提断言直接把这两版夹具否掉了)。
func TestLexicalStoreUsesZeroMinScore(t *testing.T) {
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
if got := st.lex.MinScore(); got != 0 {
t.Fatalf("词法路阈值必须为 0实际 %v沿用稠密路阈值会静默丢候选", got)
}
if got := st.vec.MinScore(); got != vector.DefaultMinScore {
t.Fatalf("稠密路阈值应保持默认 %v实际 %v", vector.DefaultMinScore, got)
}
}
// Add / Remove 必须同时维护两路索引:只维护一路会让被删条目继续被检索命中
// (或新条目只在其中一路可见)。
func TestAddRemoveKeepsBothPaths(t *testing.T) {
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
mustAdd(t, st, "alpha", "alpha 独有词 alphaonly")
mustAdd(t, st, "beta", "beta 独有词 betaonly")
has := func(q, want string) bool {
for _, h := range st.Search(q, 5) {
if h.Name == want {
return true
}
}
return false
}
if !has("alphaonly", "alpha") {
t.Fatal("新增条目应可被检索到")
}
if err := st.Remove("alpha"); err != nil {
t.Fatalf("remove: %v", err)
}
if has("alphaonly", "alpha") {
t.Fatal("已删除条目仍被检索命中(两路索引有一路没清)")
}
if !has("betaonly", "beta") {
t.Fatal("删除其它条目不应影响 beta")
}
}
// 分数相同时必须按名字定序,保证结果可重复(否则同一查询两次结果可能不同)。
func TestSearchDeterministicOnTies(t *testing.T) {
same := vector.Vector{"0": 1}
st := newTestStore(t, fakeDense{def: same})
for _, n := range []string{"ccc", "aaa", "bbb"} {
mustAdd(t, st, n, "完全一样的内容")
}
first := names(st.Search("完全一样的内容", 3))
for i := 0; i < 5; i++ {
got := names(st.Search("完全一样的内容", 3))
for j := range first {
if got[j] != first[j] {
t.Fatalf("结果不确定:第 %d 次 %v != 首次 %v", i, got, first)
}
}
}
}
// 两路都空时不能 panic且应返回空。
func TestSearchEmptyStore(t *testing.T) {
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
if hits := st.Search("随便", 5); len(hits) != 0 {
t.Fatalf("空库应返回空,实际 %v", names(hits))
}
}

View File

@ -65,6 +65,18 @@ func (a *ProviderAdapter) embed(input embedding.Input) ([]float64, error) {
func (a *ProviderAdapter) Fingerprint() string { return a.info.Fingerprint }
func (a *ProviderAdapter) Dim() int { return a.info.Dimension }
// Modalities 报告该空间支持的输入模态text/image/...)。
//
// 模态是**可选能力**MultimodalEmbedder 契约里没有它,状态查询按接口断言取用,
// 所以这里既不改公开接口,也不影响其它实现(核心也不硬编码任何模型名)。
func (a *ProviderAdapter) Modalities() []string {
out := make([]string, 0, len(a.info.Modalities))
for _, m := range a.info.Modalities {
out = append(out, string(m))
}
return out
}
func (a *ProviderAdapter) Loaded() bool {
a.mu.RLock()
defer a.mu.RUnlock()

View File

@ -77,6 +77,12 @@ type Store struct {
docs []DocVector
dim int
index *InvertedIndex
// minScore 是候选分数下限。**必须按向量空间标定**
// 词向量/多模态余弦通常在 0.3~0.9,而 TF-IDF 余弦只有 0.0~0.2 ——
// 用同一个阈值会把词法路的大量有效候选静默砍掉
// (实测:知识库自检索 MRR 0.307 → 0.193 就是这么掉的)。
minScore float64
}
type DocVector struct {
@ -86,9 +92,27 @@ type DocVector struct {
Meta map[string]string
}
// DefaultMinScore 是默认候选中选阈值(沿用历史行为)。
const DefaultMinScore = 0.05
// MinScore 返回当前候选中选阈值(供接线处自证用的是哪个阈值)。
func (s *Store) MinScore() float64 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.minScore
}
// SetMinScore 调整候选中选阈值(按向量空间标定,见 minScore 字段注释)。
func (s *Store) SetMinScore(v float64) {
s.mu.Lock()
defer s.mu.Unlock()
s.minScore = v
}
func NewStore() *Store {
return &Store{
index: NewInvertedIndex(),
index: NewInvertedIndex(),
minScore: DefaultMinScore,
}
}
@ -160,7 +184,7 @@ func (s *Store) SearchScored(query Vector, topK int) []DocVectorHit {
for _, d := range s.docs {
if d.ID == id {
score := CosineSimilarity(query, d.Vector)
if score > 0.05 {
if score > s.minScore {
results = append(results, scored{d, score})
}
break

View File

@ -210,3 +210,29 @@ func BenchmarkExtractNGrams(b *testing.B) {
extractNGrams(text, 2)
}
}
// 候选中选阈值必须**按向量空间标定**:词向量/多模态余弦通常在 0.3~0.9
// 而 TF-IDF 余弦只有 0.0~0.2。用同一个阈值会把词法路的有效候选静默砍掉
// (知识库自检索 MRR 0.307→0.193 就是这么掉的,且当时看不出任何报错)。
func TestSearchScoredRespectsMinScore(t *testing.T) {
// 构造一个低余弦候选:共享特征 "a",但两个向量几乎正交 → cosine ≈ 0.02
st := NewStore()
st.Insert("doc", "", Vector{"a": 1, "b": 1}, nil) // |doc| = √2
query := Vector{"a": 0.02, "c": 100} // 与 doc 的点积 0.02
hits := st.SearchScored(query, 10)
for _, h := range hits {
if h.Score < DefaultMinScore {
t.Fatalf("默认阈值 %.2f 不该返回 %.5f 的候选", DefaultMinScore, h.Score)
}
}
if len(hits) != 0 {
t.Fatalf("该查询在默认阈值下应被过滤,实际返回 %d 条", len(hits))
}
st.SetMinScore(0)
hits = st.SearchScored(query, 10)
if len(hits) != 1 || hits[0].Doc.ID != "doc" {
t.Fatalf("阈值设为 0 后应召回低余弦候选,实际 %+v", hits)
}
}

View File

@ -9,7 +9,7 @@ var (
//
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
// 这是首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须
// 用新版 plugindev 重编),故跃到主版本号。
// 用新版 hmapdev 重编),故跃到主版本号。
// 1.1.0记忆系统支持二进制多媒体节点——CAS 媒体存储 + L0/L2/L3 贯通。
// 1.1.1:多模态贯通**插件边界**。内核实现公开 SDK 1.1.0 新增的媒体接口
// doc.insertWithMedia、io.injectMedia / injectMediaSync /
@ -24,7 +24,7 @@ var (
//
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
Version = "1.2.0"
Version = "1.2.2"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"

View File

@ -17,7 +17,7 @@ const (
//
// 保留这张表只为**给出明确错误**:插件目录里躺着 plugin.so 而内核不再认它时,
// 静默跳过会让「目录在但插件没加载」看起来像配置问题,而实际原因是需要用
// 新版 plugindev 重编。
// 新版 hmapdev 重编。
var legacyCABIEntries = []string{"plugin.so", "plugin.dll", "plugin.dylib"}
// entryKind 描述插件入口归属的加载通道。

View File

@ -115,7 +115,7 @@ func TestHasLegacyCABIEntry(t *testing.T) {
})
}
// 旧 .so 插件必须报「用新 plugindev 重编」而非静默跳过。
// 旧 .so 插件必须报「用新 hmapdev 重编」而非静默跳过。
func TestTryDynamic_LegacyCABIGivesActionableError(t *testing.T) {
r := NewRegistry()
defer r.closeProcHost()
@ -129,7 +129,7 @@ func TestTryDynamic_LegacyCABIGivesActionableError(t *testing.T) {
}
// 错误消息须指向解决办法,且明确业务代码无需改
msg := err.Error()
for _, want := range []string{"plugindev", "plugin.bin", "业务代码"} {
for _, want := range []string{"hmapdev", "plugin.bin", "业务代码"} {
if !strings.Contains(msg, want) {
t.Errorf("错误消息应含 %q实际: %v", want, err)
}

View File

@ -10,11 +10,11 @@ import (
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 端到端:用**真实 plugindev 模板**编译的插件,经内核 proc 通道加载运行。
// 端到端:用**真实 hmapdev 模板**编译的插件,经内核 proc 通道加载运行。
//
// 与 plugin_test.go 中 testdata/*.go 假插件的区别:
// 那些是手写的最简 RPC 实现,只验证内核侧逻辑;
// 这里用的是 tools/plugindev/templates/proc_main.go.tmpl —— 外部插件作者
// 这里用的是 tools/hmapdev/templates/proc_main.go.tmpl —— 外部插件作者
// 真正会拿到的那份运行时。它验证的是「模板 ↔ 内核」两侧协议/布局真的对齐,
// 而不只是内核自己跟自己对齐。
//
@ -101,9 +101,9 @@ func (p *e2ePlugin) Start(s *sdk.PluginSDK) error {
func (p *e2ePlugin) Stop() error { return nil }
`
// procRuntimeTemplates 列出 plugindev 会生成到插件目录的运行时文件。
// procRuntimeTemplates 列出 hmapdev 会生成到插件目录的运行时文件。
//
// 必须与 SDK 仓 tools/plugindev/proc_runtime.go 的 procRuntimeFiles 一致:
// 必须与 SDK 仓 tools/hmapdev/proc_runtime.go 的 procRuntimeFiles 一致:
// 共享段与事件通知的传递机制按平台不同Unix 继承 fdWindows 命名
// 内核对象),故拆成带 build tag 的文件;只写主模板会编译失败。
var procRuntimeTemplates = []struct {
@ -115,15 +115,21 @@ var procRuntimeTemplates = []struct {
{"proc_shm_windows.go.tmpl", "z_proc_shm_windows.go"},
}
// buildPluginWithRealTemplate 用 plugindev 的真实模板编译一个插件二进制。
// buildPluginWithRealTemplate 用 hmapdev 的真实模板编译一个插件二进制。
func buildPluginWithRealTemplate(t *testing.T, businessCode string) string {
t.Helper()
if _, err := exec.LookPath("go"); err != nil {
t.Skip("环境无 go 工具链,跳过端到端测试")
}
// 工具链在 SDK 1.2.0 起改名 hmapdev原 plugindev。两个目录都接受
// 旧检出(软链或旧版 SDK 仓)仍能跑本测试,新检出走新路径。
tmplDir := filepath.Join("..", "..", "..",
"third_party", "homeagent-sdk", "tools", "plugindev", "templates")
"third_party", "homeagent-sdk", "tools", "hmapdev", "templates")
if _, err := os.Stat(tmplDir); err != nil {
tmplDir = filepath.Join("..", "..", "..",
"third_party", "homeagent-sdk", "tools", "plugindev", "templates")
}
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "plugin.go"), businessCode)
@ -131,7 +137,7 @@ func buildPluginWithRealTemplate(t *testing.T, businessCode string) string {
for _, rt := range procRuntimeTemplates {
data, err := os.ReadFile(filepath.Join(tmplDir, rt.tmpl))
if err != nil {
t.Skipf("plugindev 模板 %s 不可读SDK 仓可能未就位): %v", rt.tmpl, err)
t.Skipf("hmapdev 模板 %s 不可读SDK 仓可能未就位): %v", rt.tmpl, err)
}
mustWriteFile(t, filepath.Join(dir, rt.out), string(data))
}

View File

@ -260,7 +260,7 @@ func (p *Process) handshake(timeout time.Duration) error {
return fmt.Errorf("proc: %s 握手应答解析失败: %w", p.name, err)
}
if res.Protocol != ProtocolVersion {
return fmt.Errorf("proc: %s 协议版本不匹配(插件 %d内核 %d——请用配套 plugindev 重编",
return fmt.Errorf("proc: %s 协议版本不匹配(插件 %d内核 %d——请用配套 hmapdev 重编",
p.name, res.Protocol, ProtocolVersion)
}
log.Printf("[proc] %s 已建链pid=%d protocol=%d sdk=%s",

View File

@ -331,7 +331,7 @@ func TestProcess_ProtocolMismatchRejected(t *testing.T) {
}
// 运维可读性:光报“不匹配”不能定位到行动。生产上碰到它的现场是
// “只更新了内核没重编插件”,所以错误里必须带出这条修复指令。
if !strings.Contains(err.Error(), "plugindev") || !strings.Contains(err.Error(), "重编") {
if !strings.Contains(err.Error(), "hmapdev") || !strings.Contains(err.Error(), "重编") {
t.Errorf("错误应给出重编插件的修复指令,实际: %v", err)
}
}

View File

@ -143,7 +143,7 @@ func AttachSegment(data []byte) (*Segment, error) {
return nil, fmt.Errorf("proc: 共享段魔数不匹配0x%x期望 0x%x", got, shmMagic)
}
if got := binary.LittleEndian.Uint32(data[offVersion:]); got != shmVersion {
return nil, fmt.Errorf("proc: 共享段版本不匹配(%d本内核 %d——插件需用配套 plugindev 重编",
return nil, fmt.Errorf("proc: 共享段版本不匹配(%d本内核 %d——插件需用配套 hmapdev 重编",
got, shmVersion)
}
return &Segment{data: data}, nil

View File

@ -4,7 +4,7 @@
// stage 处理经共享内存读改写(模拟 sanitizer 的清洗行为)。
//
// 它手写 RPC 与共享段访问,不依赖公开 SDK——因为 SDK 侧的 proc 支持
// 属于 Part 3plugindev 工具链)的内容。这里只验证内核侧机制。
// 属于 Part 3hmapdev 工具链)的内容。这里只验证内核侧机制。
package main
import (

View File

@ -1175,11 +1175,11 @@ func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}
// 旧 .so/.dll 插件给明确错误,不静默跳过。
// 静默跳过会让「插件目录在但没加载」看起来像配置问题,
// 而实际原因是需要用新 plugindev 重编。
// 而实际原因是需要用新 hmapdev 重编。
if hasLegacyCABIEntry(plgDir) {
return nil, fmt.Errorf(
"plugin %s: 检测到旧 C ABI 产物plugin.so/.dll/.dylib。"+
"外部插件已改为子进程模式,请用新版 plugindev 重编产出 %s"+
"外部插件已改为子进程模式,请用新版 hmapdev 重编产出 %s"+
"(业务代码无需修改)", name, binEntry)
}

View File

@ -240,7 +240,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.selfToolNames["healthcheck_kernel"] = true
s.RegisterTool("healthcheck_kernel", sdk.ToolDef{
Name: "healthcheck_kernel",
Description: "查询 Agent 内核运行状态快照,包括插件/工具/记忆/知识库/LLM Provider/运行时等各子系统信息。Agent 可通过此工具自主监测内核健康。",
Description: "查询 Agent 内核运行状态快照,包括**内核版本号与构建身份**build.version / commit / build_time、**统一多模态向量空间ONNX 模型)是否启用**onnx.enabled未启用时给出原因插件/工具/记忆/知识库/LLM Provider/运行时等各子系统信息。Agent 可通过此工具自主监测内核健康。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},

View File

@ -3,6 +3,7 @@
package plugins
import (
"bytes"
"fmt"
"os"
"os/exec"
@ -23,9 +24,50 @@ import (
// 这里用 **example/ 里真实的 17 个插件产物**,验证「业务代码零改动 + 重编即可」
// 这一迁移承诺在完整内核装配下成立。
//
// 前置:插件需已用新版 plugindev 重编scripts/rebuild-plugins.sh
// 前置:插件需已用新版 hmapdev 重编scripts/rebuild-plugins.sh
// 未重编时测试 skip 而非 fail——CI 上不强制要求先跑重编脚本。
// hostExecutableKind 按魔数判断产物是不是**本机**可执行文件,返回可读格式名。
//
// 为何需要以前只按文件名找候选plugin.bin_<os>_<arch> → plugin.bin拿到
// darwin/windows 产物就直接 exec报的是 "exec format error"——看起来像插件坏了,
// 实际上只是**开发环境里的产物平台不对**。本轮就踩了:跨平台示例构建把
// build/plugin.bin 覆盖成 Mach-O arm64internal/plugins 6 个测试全红,排查半小时。
func hostExecutableKind(path string) (string, bool) {
f, err := os.Open(path)
if err != nil {
return "无法读取", false
}
defer f.Close()
head := make([]byte, 4)
n, _ := f.Read(head)
if n < 2 {
return "文件过短", false
}
switch runtime.GOOS {
case "linux":
if n >= 4 && head[0] == 0x7f && head[1] == 'E' && head[2] == 'L' && head[3] == 'F' {
return "ELF本机", true
}
if n >= 4 && head[0] == 0xcf && head[1] == 0xfa && head[2] == 0xed && head[3] == 0xfe {
return "Mach-O 64 little-endianmacOS不是本机", false
}
if n >= 2 && head[0] == 'M' && head[1] == 'Z' {
return "PEWindows不是本机", false
}
return "未知格式(不是本机可执行文件)", false
case "darwin":
for _, m := range [][]byte{{0xcf, 0xfa, 0xed, 0xfe}, {0xca, 0xfe, 0xba, 0xbe}} {
if n >= 4 && bytes.Equal(head, m) {
return "Mach-O本机", true
}
}
return "不是本机可执行文件", false
default:
return "未知平台(默认放行)", true
}
}
// realPluginDir 返回某个 example 插件的 linux 产物路径。
func realPluginBinary(t *testing.T, name string) string {
t.Helper()
@ -39,9 +81,14 @@ func realPluginBinary(t *testing.T, name string) string {
filepath.Join(root, "build", "plugin.bin"),
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
if st, err := os.Stat(c); err != nil || st.IsDir() {
continue
}
if kind, ok := hostExecutableKind(c); !ok {
t.Skipf("插件 %s 的产物 %s 不是本机可执行格式(%s重建cd %s && hmapdev build --target %s/%s --no-bundle",
name, c, kind, root, runtime.GOOS, runtime.GOARCH)
}
return c
}
t.Skipf("插件 %s 未重编(先跑 scripts/rebuild-plugins.sh", name)
return ""

View File

@ -1124,6 +1124,33 @@
justify-content: flex-end;
gap: 8px;
}
/* 首启人格向导(复用 confirm-* 弹窗)*/
.persona-ta {
width: 100%;
display: none;
margin-bottom: 12px;
font: 12px/1.5 var(--font-mono, monospace);
padding: 8px;
border-radius: var(--radius-md, 8px);
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
box-sizing: border-box;
}
.persona-warn {
display: none;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
margin-bottom: 10px;
}
.persona-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.empty-state {
text-align: center;
padding: 48px 24px;
@ -2521,8 +2548,87 @@
if (card) card.style.transform = "";
});
// ===== API =====
async function api(p, o) {
// ===== 首启人格向导 =====
// 人格是配置项core.agent.personal_prompt默认模板不含任何版本号
// 首次启动问一次「默认 / 自定义 / 稍后」,之后不再打扰;
// 不回答 = 稍后 = 保留默认人格,绝不阻塞启动。
async function maybeShowPersonaWizard() {
var st;
try {
st = await api("/persona");
} catch (e) {
return; // 拿不到状态就不打扰用户
}
if (!st || st.initialized) return;
var ov = document.createElement("div");
ov.className = "confirm-overlay";
ov.style.display = "flex";
ov.innerHTML =
'<div class="confirm-box">' +
"<h3>" + escHtml(__("人格设定", "Persona")) + "</h3>" +
"<p>" + escHtml(__(
"首次启动:选一下助手的人格。选「使用默认」即可(之后可在设置里修改);自定义内容在下次重启后生效。",
"First run: pick your assistant's persona. \"Use default\" is fine (change it later in Settings); custom content takes effect after the next restart."
)) + "</p>" +
'<div class="persona-warn"></div>' +
'<textarea class="persona-ta" rows="6"></textarea>' +
'<div class="persona-actions">' +
'<button class="btn btn-ghost btn-sm" data-mode="later">' + escHtml(__("稍后再说", "Later")) + "</button>" +
'<button class="btn btn-ghost btn-sm" data-mode="custom">' + escHtml(__("自定义…", "Custom…")) + "</button>" +
'<button class="btn btn-sm" data-mode="default">' + escHtml(__("使用默认", "Use default")) + "</button>" +
"</div></div>";
document.body.appendChild(ov);
var ta = ov.querySelector(".persona-ta");
var warn = ov.querySelector(".persona-warn");
var customOpen = false;
if (st.file_override) {
warn.style.display = "block";
warn.textContent = __(
"注意:检测到 personal/personal.md它优先于这里的设置。",
"Note: personal/personal.md exists and takes precedence over this choice."
);
}
function close() {
ov.remove();
}
async function submit(mode, content) {
try {
var r = await api("/persona", {
method: "POST",
body: JSON.stringify({ mode: mode, content: content || "" }),
});
if (r && r.restart_required) toast(__("已保存,重启后生效", "Saved; takes effect after restart"));
else toast(__("已保存", "Saved"));
} catch (e) {
toast(__("保存失败:", "Save failed: ") + e, true);
}
close();
}
ov.querySelectorAll("button[data-mode]").forEach(function (b) {
b.onclick = function () {
var mode = b.getAttribute("data-mode");
if (mode !== "custom") {
submit(mode);
return;
}
if (!customOpen) { // 第一次点:展开文本域并预填当前人格
customOpen = true;
ta.style.display = "block";
ta.value = st.current_prompt || "";
ta.focus();
return;
}
if (!ta.value.trim()) {
toast(__("内容不能为空", "Content cannot be empty"), true);
return;
}
submit("custom", ta.value);
};
});
}
// ===== API =====
async function api(p, o) {
var opts = {
credentials: "include",
headers: { "Content-Type": "application/json", ...o?.headers },
@ -6516,6 +6622,7 @@
renderAll();
connectSSE();
startUptimeTicker();
maybeShowPersonaWizard();
})();
setInterval(renderAll, 15000);
// 消息同步轮询兜底每30秒增量同步 chatHistory补偿 SSE 断连窗口期

View File

@ -23,6 +23,7 @@ import (
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
@ -865,6 +866,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/terminals", h.requireAPI(h.handleTerminals))
mux.HandleFunc("/api/v1/cmd/history", h.requireAPI(h.handleCmdHistory))
mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel))
mux.HandleFunc("/api/v1/persona", h.requireAPI(h.handlePersona))
mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins))
mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID))
// 设备网关(可配置反代到 remotedevice默认禁用未启用时返回 404
@ -974,6 +976,77 @@ func (h *Handler) handleKernel(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.status.GetKernelStatus())
}
// 人格设定:配置项键,以及「首启向导已经问过」的一次性标记。
//
// 为什么需要向导:人格曾经只有 <dataDir>/personal/personal.md 一个来源且无人维护,
// 里面写死的旧版本号反过来让实例自述旧版本v1.2.0 压测发现)。
// 现在人格是配置项(默认模板不含任何版本号),首启问一次,之后不再打扰。
// handlePersona 是首启人格向导的后端(与内核 persona_set 工具共用 internal/config 的实现)。
//
// GET → {initialized, current_prompt, file_override}
// POST → {"mode":"default"|"custom"|"later","content":"..."}
// 写入 core.agent.personal_prompt 并打一次性标记,返回 restart_required
//
// 生效时机:人格在 homed 启动时载入(以【人格设定】块拼进系统提示词),
// 所以**自定义内容需重启生效**;选「默认」或「稍后」(保持当前默认)无需重启。
// 不回答就是「稍后」:保留默认并打标记,不阻塞任何流程。
//
// 跨通道:这里只是 WebUI 侧的入口;任何通道的消息到来时,内核都会检查同一枚标记,
// 未确认则在提示词里要求模型主动询问(见 internal/agent/core 的首启门禁)。
func (h *Handler) handlePersona(w http.ResponseWriter, r *http.Request) {
if h.settings == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "settings not available"})
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{
"initialized": internalConfig.PersonaInitializedKV(h.settings),
"current_prompt": internalConfig.CurrentPersonaKV(h.settings),
"file_override": h.personaFileExists(),
})
case http.MethodPost:
var req struct {
Mode string `json:"mode"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
restart, err := internalConfig.SetPersonaKV(h.settings, req.Mode, req.Content)
if err != nil {
// 非法 mode / 空内容 → 400落库失败 → 500。两者都不打标记。
code := http.StatusInternalServerError
if strings.Contains(err.Error(), "unknown mode") || strings.Contains(err.Error(), "content required") {
code = http.StatusBadRequest
}
writeJSON(w, code, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok", "mode": req.Mode, "restart_required": restart,
})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// personaFileExists 报告是否存在会覆盖配置项的人格文件(存在时它优先)。
// 数据目录取自 core.daemon.data_dir由播种写入
func (h *Handler) personaFileExists() bool {
v, err := h.settings.GetCore("core.daemon.data_dir")
if err != nil {
return false
}
dir, _ := v.(string)
if dir == "" {
return false
}
_, err = os.Stat(filepath.Join(dir, "personal", "personal.md"))
return err == nil
}
func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:

View File

@ -0,0 +1,145 @@
package webui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func newPersonaHandler(t *testing.T) (*Handler, *internalConfig.ConfigRegistry) {
t.Helper()
dir := t.TempDir()
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
cfgReg.SeedDefaults(dir)
t.Cleanup(func() { cfgReg.Close() })
h := NewHandler(testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", cfgReg)}))
return h, cfgReg
}
func doPersona(t *testing.T, h *Handler, method, body string) *httptest.ResponseRecorder {
t.Helper()
var rd *strings.Reader
if body == "" {
rd = strings.NewReader("")
} else {
rd = strings.NewReader(body)
}
req := httptest.NewRequest(method, "/api/v1/persona", rd)
w := httptest.NewRecorder()
h.handlePersona(w, req)
return w
}
// 首启向导的后端契约GET 报告状态、POST 三选一、并且**只问一次**。
func TestPersonaWizardFlow(t *testing.T) {
h, cfgReg := newPersonaHandler(t)
// 1. 全新安装未初始化current_prompt 回落到内置默认模板
w := doPersona(t, h, http.MethodGet, "")
if w.Code != http.StatusOK {
t.Fatalf("GET 状态码 %d", w.Code)
}
var got struct {
Initialized bool `json:"initialized"`
CurrentPrompt string `json:"current_prompt"`
FileOverride bool `json:"file_override"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Initialized {
t.Fatal("全新安装不应已初始化")
}
if got.CurrentPrompt != internalConfig.DefaultPersonaPrompt {
t.Fatal("未设置时应回落到内置默认模板")
}
if got.FileOverride {
t.Fatal("没有人格文件时不应报告 file_override")
}
// 2. 「稍后再说」= 保留默认、打标记、不再问
w = doPersona(t, h, http.MethodPost, `{"mode":"later"}`)
if w.Code != http.StatusOK {
t.Fatalf("later 状态码 %d: %s", w.Code, w.Body.String())
}
if v := cfgReg.GetString(internalConfig.PersonaInitMarkerKey, ""); v == "" {
t.Fatal("later 也必须打一次性标记(否则每次启动都问)")
}
if v := cfgReg.GetString(internalConfig.PersonaPromptKey, ""); v != internalConfig.DefaultPersonaPrompt {
t.Fatalf("later 不应改动人格,实际 %q", v)
}
// 3. 已初始化后 GET 应报 true
w = doPersona(t, h, http.MethodGet, "")
got.Initialized = false
_ = json.Unmarshal(w.Body.Bytes(), &got)
if !got.Initialized {
t.Fatal("打过标记后应报告已初始化")
}
// 4. 自定义:写入内容 + 需要重启(人格在启动时载入)
h2, cfgReg2 := newPersonaHandler(t)
w = doPersona(t, h2, http.MethodPost, `{"mode":"custom","content":"你是测试人格"}`)
if w.Code != http.StatusOK {
t.Fatalf("custom 状态码 %d: %s", w.Code, w.Body.String())
}
var pr struct {
RestartRequired bool `json:"restart_required"`
}
_ = json.Unmarshal(w.Body.Bytes(), &pr)
if !pr.RestartRequired {
t.Fatal("自定义人格应提示需要重启才生效")
}
if v := cfgReg2.GetString(internalConfig.PersonaPromptKey, ""); v != "你是测试人格" {
t.Fatalf("自定义内容未写库: %q", v)
}
// 5. 空内容的 custom 必须被拒(否则等于静默清空人格)
h3, _ := newPersonaHandler(t)
if w = doPersona(t, h3, http.MethodPost, `{"mode":"custom","content":" "}`); w.Code != http.StatusBadRequest {
t.Fatalf("空内容应 400实际 %d", w.Code)
}
// 6. 未知 mode 必须被拒
if w = doPersona(t, h3, http.MethodPost, `{"mode":"nope"}`); w.Code != http.StatusBadRequest {
t.Fatalf("未知 mode 应 400实际 %d", w.Code)
}
// 7. 被拒的请求不得打标记(否则向导会被跳过)
if v := cfgReg2.GetString(internalConfig.PersonaInitMarkerKey, ""); v == "" {
t.Fatal("前置条件:第 4 步已打标记")
}
h4, cfgReg4 := newPersonaHandler(t)
_ = doPersona(t, h4, http.MethodPost, `{"mode":"nope"}`)
if v := cfgReg4.GetString(internalConfig.PersonaInitMarkerKey, ""); v != "" {
t.Fatal("被拒的请求不应打标记")
}
}
// 存在人格文件时 GET 要报告 file_override它会覆盖配置项向导应提示用户
func TestPersonaWizardReportsFileOverride(t *testing.T) {
h, cfgReg := newPersonaHandler(t)
dir := cfgReg.GetString("core.daemon.data_dir", "")
if dir == "" {
t.Fatal("播种应写入 core.daemon.data_dir")
}
if err := os.MkdirAll(filepath.Join(dir, "personal"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "personal", "personal.md"), []byte("旧人格"), 0o644); err != nil {
t.Fatal(err)
}
w := doPersona(t, h, http.MethodGet, "")
var got struct {
FileOverride bool `json:"file_override"`
}
_ = json.Unmarshal(w.Body.Bytes(), &got)
if !got.FileOverride {
t.Fatal("存在 personal.md 时必须报告 file_override")
}
}

View File

@ -38,9 +38,29 @@ type KernelStatus struct {
Runtime RuntimeStatus `json:"runtime"`
// ONNX 报告统一多模态向量空间ONNX 模型)是否**真的在用**。
//
// 为何单列:内核的向量能力是三层降级(统一多模态空间 → 词嵌入 → TF-IDF
// 只报「向量可用/不可用」分不清「ONNX 模型已加载」与「退回了纯文本路径」。
// 模型缺失 / 运行时缺失 / provider 打开失败时这里是 enabled=false + reason。
ONNX ONNXStatus `json:"onnx"`
Tracker TrackerStatus `json:"tracker"`
}
// ONNXStatus 是统一多模态向量空间ONNX 模型)的启用状态与身份。
type ONNXStatus struct {
// Enabled 是 provider 真正打开且元数据合法(不是「配置里写了 provider」
Enabled bool `json:"enabled"`
// Provider 是配置指定的 provider 名(如 chineseclip / qwen3vl / http
Provider string `json:"provider,omitempty"`
Dim int `json:"dim,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Modalities []string `json:"modalities,omitempty"`
// Reason 是未启用时的原因(未配置 / 打开失败的具体错误 / 其它)。
Reason string `json:"reason,omitempty"`
}
type PluginInfo struct {
Name string `json:"name"`
Loaded bool `json:"loaded"`