mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
## 为什么
文档里混着三种时态:当时实测、历史推演、描述现状但已失真。三者外观相同,
读者无从分辨。本次逐条用实测核对,把失真的改成事实,把「已做」从「未做」
里摘出来,把「真没做」集中登记到一处。
## 新文件:docs/zh/future-plan.md
此前「未做」散落在各设计文档的「待办/剩余/遗留」小节,时间一长就与代码
脱节(已完成却仍写未做,未做却被当成已完成)。新建本文作为**唯一登记处**,
收录规则明确:只收「代码里确实没有」与「有机制但零生产调用方」两类。
它同时把本仓一个反复出现的缺陷形态显式化 —— **机制 ≠ 接线**。已攒够 5 例:
| 能力 | 曾经/现在的状态 |
|---|---|
| `Policy.CheckTool` | 曾 `Agent.security` 恒 nil → 2026-10-30 修 |
| `CheckToolFor` | 曾 0 生产调用方 → 2026-10-30 修 |
| `ActivateProfile` | **现在仍 0 调用方** |
| `ScanContent` | **现在仍 0 调用方** |
| `CheckPath` / `CheckNetwork` | **现在仍 0 调用方** |
## 已失真的表述(逐条实测后更正)
**kernel-modularization-design.md**
- §2.7.4 原标题是「未做(Step 4b 的剩余)」,写着「main.go 仍走原来的串行代码,
下一步是把 Run(plan) 真接进 main」。**实际 `main.go:229` 早已调用
`RunKernelModules` → `kernelboot.Run`**,插件调度也已归位 plugin-host。
改为「已完成」并给出两处代码位置 + 生产日志证据。
- §2.7 分层数字「6 层 / 17 模块,最宽层 9」→ 实测 **20 模块 / 6 层 / 最宽 10**。
同时给出取数命令,并要求「数字一律现场取,不凭文档记忆」。
- §2.3.5 那段标着「当前已产出的分层结果(实测)」的 4 层 14 模块表,是
2026-10-16 的推演。改为明确标注「已过时,仅作设计推演保留」。
- §2.7.3 判据表 7 条**全部不存在**(随 3864f36 删死包迁包时丢失)。改为
左右栏对照:左边当时名字、右边现仓库真实判据,找不到对应物的 4 条明确标 ❌。
- 文件头补状态声明、与 future-plan 的分工、数字取数方式。
**kernel-secret-firewall-design.md**
- 状态从「已实现」改为「部分实现」。原来那句会让读者以为整套安全能力都在跑。
- 新增接线状态表:逐个写**实测的生产调用点**(不写 ✅/❌ 定性)。
事实是:凭据三件套与工具闸生效;**敏感词/注入、路径沙箱、网络闸三条
机制完整但零生产调用方**;档位声明侧通、激活侧零调用方。
- §5 T1–T10:T2/T3/T4 早已落地却仍挂在「待办」表里。改为现状列,
并把真未做的 T5–T10 迁入 future-plan §1.5。
**对外文档的工具名**(外部读者正在看错的名字)
- `ARCHITECTURE.md` 6 处、`OVERVIEW.md` 1 处、`README.md` / `README_EN.md` 各 3 处、
`resident-subagent-design.md` 2 处:`memory_*` / `doc_query` → `stablemem_*` / `coldcontext_*`
- README 的 v1.1.1 发布说明保留原名(那是历史事实),加一句现名注记
**input-scheduler-design.md / resident-subagent-design.md**
- 补状态头。input-scheduler 记入 rtchild 不走四级阶梯(单槽覆盖)这一下游差异;
resident-subagent 记入「继承安全运行时实例但**不继承权限**」这一下沉事实。
## 未动的(有意为之)
历史归档文档(`legacy-*`、`*-plan.md`、`live-run-evidence-*`、
`production-recall-validation.md`、`context-overflow-l4-design.md`、
`entities-retirement-plan.md`、`memory-*-plan.md`)**一律不改** ——
它们是历史记录,改了就篡改证据。其中的待办以索引形式登记在
future-plan §六,原文档保留。
## 核对结论
- 文档现在只描述两种内容:**已落地的实现**(附实测证据/取数命令)与
**尚未实现的事项**(统一在 future-plan)
- 数字类断言全部给出取数命令
- 跨文档的「未做」引用统一指向 future-plan,避免再次分叉
53 KiB
53 KiB
English | 中文
HologramAgent 架构
内核装配(模块化内核)
内核不是一个"从上到下 init 一遍"的单体启动序列,而是声明式依赖图:
internal/kernelmod/ 模块注册表(根目录只有 core.go)
core.go Register / Registered / IsRegistered
facilities.go 设施名常量(模块间协议;与公开 SDK 逐字一致)
config/ kernelapi/ lua/ llm/ memory/ docstore/ knowledge/ multimodal/
onnx/ textmem/ stagehost/ supervisor/ tracker/ migration/ agent/
pluginhost/ device/ security/ update/ ← 每个模块一个独立文件夹
internal/kernelmod/all/ 唯一的空白 import 清单(加模块 = 加一行)
```text
**模块只声明依赖,不声明顺序。** `Requires()` 的语义是"我需要谁**已就绪**",
不是"我要排在谁之后"——顺序由依赖图推导,人肉排序正是要消灭的东西。
```go
type Module interface {
Name() string // 依赖图节点名
Requires() []string // 需要的设施(别人 Provides 的名字)
Provides() []string // 自己产出的设施
Init(ks *KernelState) error
}
```text
装配由 `internal/kernelboot` 完成,分两步:
1. **`Build()` 解析依赖并分层** —— 缺设施(拼错名字)、提供者重名、循环依赖三种
结构性错误在这里报死,且报错带"已被声明的设施清单",便于发现拼写差异。
2. **`Run()` 逐层执行** —— 同层模块**并行** `Init`;每层执行前再核一次
"实际已产出的设施是否涵盖本层依赖",防止计划对、执行顺序被改坏。
**`KernelState` 是内核唯一的公共数据结构**:所有包级共享状态收敛成它的类型化字段,
模块从里面读依赖、只写自己那一个字段。字段拼错/取错类型是**编译错误**,
而不是运行期静默拿到 nil。同层模块各写各字段 ⇒ 无数据竞争,因此字段上不加锁。
**当前真实分层**(启动日志逐行可验,20 个模块 6 层):
```text
layer 0 config, kernel-api, update
layer 1 doc-store, knowledge-migration, lua, memory, migration,
multimodal, onnx-parser, security, supervisor, tracker
layer 2 knowledge-store, llm, text-memory
layer 3 stage-host
layer 4 agent, plugin-host
layer 5 device
```text
几个刻意的位置选择:
- **`update` 在第 0 层**:启动自检、版本比对、升级失败回滚的判定必须早于
memory/LLM/pluginhost —— 回滚晚于别人读配置,读到的就是"已回滚版本"的假象。
- **`security` 在第 1 层**:它要 `config` 就绪才能读密钥与变量库,同时必须早于
一切会读 `core.llm.*` 密文的模块(否则解不开密文 ⇒ 全 provider 401)。
- **`device` 在第 5 层**:它要 `agent` 已存在才谈得上"把工具数据面挂上去"。
**`Group 0` 不在图里,但必须最先跑完**:平台门、心跳首写、日志、配置库打开、
安全块装配、配置快照 —— 这些是**副作用顺序**("此动作必须先于那个动作生效"),
依赖图表达不了(图表达的是"我产出数据给他")。因此它们由 main 串行执行,
之后的模块装配才交给调度器。
**`depgraph.bin`**:依赖图在**编译期**生成并内嵌进二进制(同时注入
`EmbeddedGraphHash` 摘要)。运行期先比对摘要再按图装配 —— 装配顺序是构建产物,
不是运行期猜出来的。文件损坏或摘要不符会响亮失败,不会"能起就先用着"。
**设施名是插件契约**:图里的设施名(`config` / `kernel-io` / `memory` / `llm` /
`docmem` / `knowledge` / `multimodal` / `onnx` / `stagehost` / `agent` / `security` /
`device` …)与公开 SDK 的 `sdk.Facility*` **逐字一致**(有判据守着)。插件在
`plugin.json` 的 `requires_facilities` 里声明它要等什么就绪;写错名字的后果是
**永不启动且不报错** —— 这正是把名字放进 SDK 常量、让拼错变成编译错误的原因。
## 认知层(Agent 运行时)
下面几节描述的是 **agent 运行时**(事件循环、上下文窗口、阶段管道)——
它们是内核装配完成后的执行层,不是内核的启动机制。
## 体系结构原则
HologramAgent 的认知架构由三个核心子系统构成:事件循环(eventLoop)、上下文窗口(RelevanceContext)与阶段管道(StageHost)。三者共同组成编排框架。LLM 在其中作为可调度的推理执行单元运行;认知连续性由事件循环、上下文窗口与阶段管道维持。
**事件循环(eventLoop)** 是一个三路 select 循环:`a.io.InputChan()` 接收外部用户输入并分发至 `processTextInput` / `processMediaInput`;`a.selfInputCh` 接收内部系统任务(如记忆合并、蒸馏回调),以 `_consolidation_` 输出通道标识区分,走 `processConsolidation` 路径;`a.ctx.Done()` 接受关闭信号。与之并行运行的 `interceptLoop` 协程独立监听 `a.io.InputInterruptChan()`,收到高优先级中断时先取消当前 LLM HTTP 请求(`a.cancelLLM()`),再将事件写入 `a.interceptCh`——该通道在 `process()` 每次 LLM 调用前由 `drainInterrupts()` 非阻塞排空,以 `[打断消息]` 格式注入消息历史。三条中断投递路径各具语义:`cancelLLM` 终结当前 HTTP 请求,`interceptCh` 在下一轮 LLM 调用前注入文本,`InjectInput` 在 eventLoop 空闲时触发新一轮处理。
**阶段管道(StageHost)** 管理两类注册:工具定义(ToolDef)与阶段处理器(StageHandler)。ToolDef 包含 `NoMemory bool` 和 `Cleaner func(string) string` 两个可选的记忆控制字段:`NoMemory=true` 时工具输出不参与向量化/jieba/蒸馏计算(原文保留);`Cleaner` 在输出进入计算层前执行过滤(如提取 JSON 的 `content` 字段)。两者均不修改原文,只影响计算层输入。`RegisterTool` 拒绝同名注册,推断工具所属插件名,并维护工具到插件的映射表 `toolPlugins`。`RegisterStage` 将处理器追加至对应阶段的处理器列表。触发阶段执行时(`RunStage`),**所有已注册处理器通过 goroutine 并行执行**,共享同一 `*StageContext` 实例(通过 `sync.RWMutex` 保护并发访问)。单个处理器的 panic 被独立恢复,不影响其他处理器。短路语义通过检查 `ctx.Response != nil` 实现——任一阶段处理器可设置此值提前终止当前链路。工具执行 `ExecuteTool` 内置 panic 恢复与栈追踪记录。`UnregisterPluginTools` 在插件热重载时移除对应工具集。
**上下文窗口(RelevanceContext)** 维护一个按时间排序的事件列表。`Append` 在录入前经 `textForVector` 聚合工具输出:按 `NoMemory` 跳过、`Cleaner` 过滤(插件为各自工具注册的清洗函数),最后经 `CleanText` 做基本空白规范化,再通过三分支向量策略(agent 事件用 Response,用户事件用 Input,cold_storage 用 Input+Response)计算嵌入向量。`Prune` 在事件数超过 `topK` 时触发,**无条件保护最近 10 条事件不被裁剪**(recency bias),对剩余候选事件计算与当前输入的 CosineSimilarity,按评分降序保留 `topK - 10` 条(下限为 0),之后按时间戳重排序。裁剪出的事件通过 `docStore.ContextToDoc` **迁移**至 Document 层:事件连同它持有的块一起下沉,保留原始时间戳与完整正文。持久化采用 5 秒防抖写入磁盘 JSON 文件。
> ★ **哪些事件不该进 L2,由插件自己声明,内核不认插件名**。
>
> 旧版这里写的是「过滤掉 `agentcli` 和 `terminal` 来源后归档」——那是在内核里
> 硬编码插件行为,已废除。正确姿势是插件在注册时就声明:
>
> - `ToolDef.NoMemory` / `ToolDef.Cleaner`:工具输出不进计算层
> - `ChannelDef.NoMemory` / `ChannelDef.Cleaner`:该通道的输入不进计算层
> - `InjectOptions.NoMemory`:本次注入不参与记忆
>
> 内核只消费这些**声明**(已由 `textForVector` 实现),不认识任何具体插件。
**工具定义聚合自五个来源**:IOManager 注册的插件工具;StageHost 注册的 SDK 插件工具;Indexer 提供的记忆索引工具;内置条件工具(依据 memory / knowledge / docStore / social / pluginReg / providerManager 等模块的非空状态选择性添加,包括记忆操作、知识检索、文档查询、社交网络、插件重载、子代理生成、输出通道工具、LLM 源切换等);以及按 `pendingMedia` 状态添加的媒体处理工具。`buildToolDefs()` 在每次 process 周期中重新聚合所有这些来源。
**Provider 调用采用有序降级策略**:`ProviderManager.OrderedProviders()` 返回按注册顺序排列的 provider 列表。`process()` 内循环遍历该列表依次尝试 `Chat()` 调用。401/403 状态码将对应 provider 标记为永久不可用;其他错误类型同样标记不可用但容忍度更高。全部 provider 失败时返回错误返回调用方。若调用因 context 取消而中断且 Agent 仍在运行,则重试(仅当处理非 consolidation 路径时)。
**记忆体系采用三级存储层级结构(Context → Document → Graph),按访问局部性与持久化需求进行数据分置**:Context 层为高速易失工作窗口,使用 StaticEmbedder(预训练词嵌入)进行语义相关性评分,以 TF-IDF 为回退策略;Document 层与 Context **共享同一 StaticEmbedder 向量空间**(agent 启动时将 embedder 注入 Document Store),使 Context 裁剪时的事件相关性评分与 Document 查询时的语义检索处于同一向量空间中,确保冷热数据之间的相似度可比——TF-IDF 仅在 embedder 未加载时作为兜底方案;Graph 层以 SQLite 为持久化载体,entities 表与 relations 表分别存储节点与有向边,支持 BFS 遍历召回。三级之间定义数据迁移策略:低分事件从 Context 下沉至 Document(以归档时相同的 embedder 向量化写入),冷文档经 72 小时未访问阈值判定后通过 `docToTriples` 蒸馏为三元组写入 Graph。Indexer 通过双路召回(实体向量相似度搜索 + jieba 关键词提取)构建 Graph 查询种子,结合 `MarkRecalled` 机制避免已被工具调用取回的实体重复注入系统提示。
**核心域与应用域的职责域分离**是一项系统级架构决策:内核的责任边界限定在 LLM 编排、记忆管理与知识检索三个维度内,不直接承载任何 IO 操作;所有外部交互通过插件域接入。该分离将核心域的复杂度控制在可验证范围内,同时赋予插件域独立的演化自由度——后者可独立开发、独立发布、热加载,且不对核心域的稳定性构成直接影响。
## 消息处理流程
### 完整链路
```text
外部输入(通过插件 InjectInput)
│
▼
eventLoop() → processTextInput()
│
├── on_input stage 插件可拦截/改写/短路
├── Context.Append 记录到上下文窗口
├── Context.Prune 低相关性事件归档到 Document
├── buildMemoryContext() Indexer 召回 → GraphDB BFS 遍历
│
├── pre_action stage 插件可注入 system 消息
│
├── [工具循环] process()
│ ├── buildSystemPrompt 人格 + 记忆 + 知识 + 上下文
│ ├── buildToolDefs 内置工具 + 插件工具
│ ├── provider.Chat() LLM 调用
│ ├── post_action stage 插件可见 LLM 输出 + 工具列表
│ ├── 有工具?
│ │ ├── before_toolcall 插件可拒绝/改参
│ │ ├── executeToolCall 路由到插件/内置
│ │ ├── after_toolcall 插件可改结果
│ │ └── → 回到 post_action
│ └── 无工具 → 退出循环
│
├── Context.Append(response)
├── before_output stage 插件可改最终文本
├── emitResponse() 通过 output_send 发送
└── after_output stage 插件只读,收尾
```text
代码:`internal/agent/core/process.go` — `process()` 是工具循环主体
### 7 个阶段钩子
| 阶段 | 触发时机 | 插件可做 |
| ------ | ---------- | ---------- |
| `on_input` | 消息到 Agent,零处理 | 黑名单/限流/短路回复 |
| `pre_action` | 上下文就绪,LLM 调用前 | 注入外部数据到 context |
| `post_action` | LLM 返回文本+工具列表 | 敏感词过滤/强制 redirect |
| `before_toolcall` | 单个工具执行前 | 审计/拒绝/改参 |
| `after_toolcall` | 单个工具执行完 | 脱敏/排序结果 |
| `before_output` | 最终文本就绪,发送前 | 格式适配 |
| `after_output` | 已发送 | 统计日志 |
代码:`internal/agent/core/stages.go` — `StageHost` 编排
### 循环规则
`post_action → [before_toolcall → 执行 → after_toolcall] → post_action` 构成内循环。
退出条件:LLM 无工具调用 / 全部被拒绝 / 超上限。
### 短路规则
任意阶段设 `ctx.Response` 即跳到 `after_output`。
## 三层记忆
### 记忆块是唯一载体,层是持有关系
三层不是三种数据结构,而是**同一批记忆块的三层持有关系**:
```text
记忆块 MemoryBlock(union)
├─ 文本块:负载在 Text
└─ 媒体块:负载在 Data(配 MIME/宽高/尺寸)
↑ 同一个块 ID 在 context → doc → graph 全程不变
↑ 块自带 vector + fingerprint:向量只算一次,跨层复用
```text
| 层 | 承载单元 | 与模型的关系 | 容量 | 流动性 |
| --- | --- | --- | --- | --- |
| **L1 Context** | 事件(内联正文 + 块) | **直接投喂**(进 system prompt 时序) | 最近 n 条 / 上限 m 条 | 超限时按相似度迁出 |
| **L2 Document** | 事件(内联正文 + 块) | **不直接投喂**(需召回才进 L1) | 无上限 | 冷到阈值后蒸馏进 L3 |
| **L3 Stable** | 三元组(节点 + 关系边),节点即记忆块 | 不投喂(经召回进入 L1) | 持久 | 不流出 |
★ **L1 与 L2 结构完全同构**,唯一区别是「是否被直接投喂给模型」。
L2 不是 L1 的摘要层,也不是另一种数据形态——它就是同样的事件 + 同样的块,只是不直接进模型视野。
★ **正文(含多媒体正文)内联在块里**:文本块的 `Text` 就是正文,媒体块的 `Data` 就是字节。
不存在「块 + 外部正文」的分离形态,也不存在靠渲染字符串承载原文的路径。
事件是块的容器,不是块的替代品。
★ **任何一层都不压缩**。视图(检索摘要、给模型看的渲染)可以是压缩的,但视图不是记忆。
### 记忆流转
```text
① Context (工作窗口 — 直接投喂)
RelevanceContext — 内存 events[] + JSON持久化
每条事件:Timestamp / Source / Input / Response(完整) / ToolsUsed / ToolResults / Blocks
Append: 每次输入, CleanText → 三分支向量(textForVector)
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
向量层级:统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退)
Prune: DenseCosine(仅同指纹才比较)→ 退化 StaticEmbedder CosineSimilarity;保留 topK + 最近10条
├── 保留 → timeline → 按时间排序 → system prompt
└── 低分 → Document 层归档(迁移:事件连同块一起下沉,时间戳与正文不变)
Save: 5s debounce 写盘
↓ Prune 归档(迁移) ↑ 召回(迁回)
② Document (文件记忆 — 不直接投喂,结构与 L1 同构)
DocStore — 每篇文档 = 检索元数据 + 事件列表(内联正文 + 块)
写入: Prune归档 / coldcontext_commit / stablemem 快照(syncGraphToDocs)
读取:
├── 自动预填充: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读,不动层)
└── LLM主动: coldcontext_query → Consume(取走)
→ 逐条按各自原始时间戳把事件插回 L1 时序
→ 块随事件迁回(同一块 ID,向量不重算),L2 不再持有
冷化: FindColdDocs(72h, ≤2次访问) → docToTriples → Graph
↓ 冷文档蒸馏 ↑ 自动召回
③ Graph (图数据库 — 顶层仍是三元组,节点即记忆块)
SQLite — memory_blocks(节点) + memory_block_edges(边)
三元组 = 【节点】【关系边】【节点】;putTripleBlocksTx 落为 src --edge_type--> dst
写入: stablemem_commit / 冷文档蒸馏 / Pipeline 规则蒸馏 / stablemem_merge
读取:
├── 自动召回: Indexer.BuildContext(input)
│ → CleanText → 向量实体搜索 + jieba关键词 → SQLite LIKE + BFS depth=2
│ → 【记忆索引】→ system prompt
└── LLM主动: stablemem_recall / stablemem_merge / stablemem_purge /
stablemem_edit / stablemem_delete_entity
Social: person_query / set_trait / relate (包装 GraphDB)
④ 四个独立心跳循环(各自独立的 ticker 和配置间隔)
distillLoop (distillInterval, 默认30m): 上下文裁剪 — Context.Prune → Document
archiveLoop (archiveInterval, 默认60m): 冷文档归档 — docToTriples → GraphDB
mergeLoop (mergeInterval, 默认120m): 实体合并检测 — 相似度 → LLM 裁决
reviewLoop (reviewInterval, 默认120m): 关系复审 — SentenceRef 回溯 → LLM 修正
⑤ Pipeline 规则蒸馏器 (每10min心跳)
distillOnce → 正则匹配个人信息:
我叫X / 我住在X / 我喜欢X / 我X岁 / 我的工作是X
→ 三元组 → GraphDB.Commit
```text
### 向量化:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)
向量化按可用性分三层降级,**每一层缺位都明确报错,不静默假装成功**:
**① 统一多模态空间(主路径,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 Numberbatch(77 语对齐)/ fastText 中文 / fastText 英文
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
- 路径名含 `numberbatch` → 自动下载 ConceptNet,含 `cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
- 不匹配则默认 ConceptNet
- **前处理**:`textForVector` 聚合工具输出时逐一应用各工具的 `Cleaner`(由插件注册),最后经 `CleanText` 做基本空白规范化
- **三分支向量来源**:agent→Response,用户→Input,cold_storage→Input+Response
- **TF-IDF 回退**:模型下载失败或未配置时自动回退词袋 TF-IDF,服务不中断
| 位置 | 文件 | 用途 | 算法 |
|------|------|------|------|
> 下表按**函数名**定位,不绑定行号:行号随每次改动漂移,函数名才是稳定锚点。
> (旧版这里写的是 `context.go:155` / `document.go:206` / `indexer.go:96+111`,
> 但 memory 重组后 `context.go` 已迁到 `agent/core/`,那些行号早已失效。)
| 位置 | 文件(函数) | 用途 | 算法 |
| ------ | ------ | ------ | ------ |
| Context Prune | `internal/agent/core/context.go` — `RelevanceContext.Prune` / `PruneWithProtected` | 裁剪低相关性上下文事件 | VectorizeClean → CosineSimilarity(queryVec, evt.Vector) |
| DocStore Query | `internal/memory/document/document.go` — `Store.Query` / `QueryScored` | 文档记忆召回 | StaticEmbedder.Vectorize(首选)/ TF-IDF(兜底)→ vec.Search |
| Indexer 实体搜索 | `internal/memory/indexer.go` — `Indexer.vectorSearchEntities` | Graph实体召回 | 向量实体搜索 + jieba关键词 → SQLite LIKE + BFS |
| 实体相似度检测 | `internal/agent/core/distill.go` | Graph中相似实体 | 加权 Jaccard(阈值 `sim > 0.75` → consolidation) |
### Context 层
`internal/agent/core/context.go` — `RelevanceContext`
- 维护最近事件列表,每次 Append/Prune 写入 JSON 防丢
- 向量化前统一经 `CleanText` 去模版噪声
- 三分支 `textForVector`:agent 事件用 Response、用户事件用 Input、cold_storage 用 Input+Response
- 预训练词嵌入 `StaticEmbedder` → CosineSimilarity,模型不可用时自动回退 TF-IDF
- 保护最近 10 条记录免于淘汰,超出部分按相关性排序**迁移**到文档记忆
- 迁移是事件**连同它持有的块**一起下沉,时间戳与正文不变;源事件清空块(同一块不同时留两层)
### Document 层
`internal/memory/document/document.go` — `Store`
**与 Context 同构,只是不直接投喂给模型**:
- 每篇文档 = 检索元数据(`ID`/`Summary`/`Vector`/`DenseVec`/`DenseFP`/`AccessCount`)
- **事件列表**(内联正文 + 块)
- 正文内联在块里:文本块的 `Text` 是正文,媒体块的 `Data` 是字节
- `Doc.Content` 只是**渲染视图**(供摘要/人工阅读/SDK 兼容),不是原文载体
- 自动预填充:与 Context 共享的向量空间做相似度检索 → 摘要进 system prompt(**只读**,不动层)
- 主动召回:`coldcontext_query` 取走后,**逐条按各自原始时间戳**把事件插回 L1 时序,块随事件迁回
- 冷化:`FindColdDocs(72h, ≤2次访问)` → `docToTriples` → Graph
## 安全防火墙与设备数据面(内核模块)
### security 内核模块(`internal/kernelmod/security`)
安全不是"某项能力",而是**横在 Core 与全部外部模块之间的一堵墙**:
```text
Core ──出向审查──▶ 外部(device / plugin / channel)
Core ◀─入向审查── 外部
```text
- **凭据闸门**:出向把明文换成 `@{别名}`、入向清洗返回值、执行前才回填真实值
- **工具黑白名单 + 沙箱**:路径读写根、网络开关、`..` 逃逸拦截(`deny` 永远优先)
- **内容审查**:敏感词过滤 + 提示词注入预警(Finding **不带原文**,避免日志泄漏)
- **按主体授权**:hard-private 工具对任何非内部主体永久拒绝
- **插件命名限式**:插件声明 `qq.owner` / `qq.guest` 等档位,inputch 触发时用
`InjectOptions.SecurityProfile` 选择;**未声明的档位名会报错**,不静默回落
落地形态是**显式代理**(不是 monkey patch):装配时把 `KernelState` 的公共挂载点
换成安全对象,业务模块仍认为自己直接与对端交互。
### device 内核模块(`internal/kernelmod/device`)
统一拥有:工具注册与路由、inputch、outputch、设备登记。内置工具的路由表在
`internal/toolroute`(避免 core ↔ kernelboot 成环),device 保留对外入口;
未挂载 device 时 agent 退化为本地 switch。
---
### Graph 层
`internal/memory/graph.go` — `GraphDB`
**顶层结构仍是三元组,变的是节点的物理表示**:
- SQLite WAL 模式(驱动:mattn/go-sqlite3,CGo)
- `memory_blocks`(节点,带 vector + fingerprint + modality)+ `memory_block_edges`(边)
- 三元组 = 【节点】【关系边】【节点】:`putTripleBlocksTx` 落为 `src --edge_type--> dst`
- `entities` / `relations` 旧表是**存量过渡路径**,逐步退场(退场的是节点存储,不是三元组概念)
- `Commit(triples)` — 写入时直接块化为节点 + 边
- `Recall(keywords, depth)` — 关键词检索 + BFS 遍历
### 记忆工具(LLM 可直接调用)
| 工具 | 作用 |
| ------ | ------ |
| `stablemem_recall` | 从 Graph 召回 |
| `stablemem_commit` | 写入 Graph 三元组 |
| `stablemem_merge` | 合并两个实体节点 |
| `stablemem_block_merge` | 合并两个记忆块 |
| `stablemem_purge` | 删除指定实体 |
| `stablemem_edit` | 编辑已有实体/关系 |
| `stablemem_delete_entity` | 删除实体及其所有关系 |
| `stablemem_introspect` | 查看记忆统计 |
| `coldcontext_query` | 从冷上下文(cold context)召回 |
| `coldcontext_commit` | 写入冷上下文 |
> ★ 工具名以 `stablemem_` / `coldcontext_` 为前缀(v1.2 起,旧的
> `memory_*` / `doc_*` 已废弃)。权威定义在
> `internal/agent/core/tooldefs.go`;核对命令:
> `grep -rhoE '"(stablemem|coldcontext)_[a-z_]+"' --include=*.go internal | sort -u`
`stablemem_commit` 与 `coldcontext_commit` 自 v1.1.1 起接受 `media_digests`,并由内核把
`[<mime> <短digest>] <描述>` 标记补进句子/正文——**标记由内核拼,模型只给 digest**。
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。
`stablemem_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
### 媒体记忆(块 union:媒体即块)
媒体不是外挂内容,也不是另建的存储层,而是**块自身的一种取值**。
`MemoryBlock` 是一个 union:文本块的负载在 `Text`,媒体块的负载在 `Data`(配 MIME/宽高/大小)。
字节随块同生同灭——同一个 row、同一个块值,没有独立的寻址层。
> ★ 2026-10-11 CAS 淘汰:此前媒体字节存在独立的**内容寻址仓储**(`internal/memory/media/`,
> 带 `media.db` + `blobs/`、`ref_count` 引用账本、第二套向量与第二套召回 `QueryMediaScored`),
> 块只留一个 digest 指向它。两套存储必然要打补丁(`crossmodal.go` 曾靠 `heldMediaDigests()`
> 防召回“无处可归的媒体”),且 digest 与块 ID 双身份让跨层引用容易错位。
> 该层已整体拆除:媒体跟着块走,块 ID 就是媒体的身份。
| 关注点 | 做法 | 为何 |
| --- | --- | --- |
| 负载归属 | 字节在 `memory_blocks.data`(BLOB),随块同生共灭 | 一个块要么带文本、要么带字节;不存在块外的字节存储层 |
| 块身份 | **内容派生** `blk_media_<sha256(trim(mime)+\0+data)[:12]>` | 同内容⇒同 ID⇒`ON CONFLICT` 天然去重(内容寻址当年的好处,现在由块 ID 直接提供) |
| 检索 | 块携带**自己的多模态向量与指纹**,直接参与 `RecallBlocks` 向量召回 | 纯向量检索,全程不碰负载;负载只在块被选中后按需 `BlockData` 取用 |
| 取字节 | `BlockData(blockID)` 单块按需加载;插件经 `Memory().MediaBytes(ref)` 按块引用取回 | `blockColumns` 刻意不含 `data`:召回带上负载会让每次全表读入几十 MB |
| 生命周期 | **无独立 GC、无引用计数、无 keep-set**;删除块即删内容 | 媒体是记忆块,不是需要保活的缓存 |
| 知识库媒体 | 字节作为条目自带数据落在 entry 目录(与 `content.md`、`.media.json` 并列) | `Remove` 的 `RemoveAll` 天然清掉,不留孤儿 |
**不再有描述式索引**:旧实现在正文里写 `[<mime> <短digest>] <描述>` 标记,并把描述文本当作语义记忆
(检索靠描述)。该机制已在 v1.2.0 整体拆除:描述是模型生成的二手信息,
检索“别人转述的图片”不如检索图片自己的向量。现在图片只按自己的统一空间向量被检索,
正文里不再有 media marker。
**跨空间向量迁移**:块的向量带 `fingerprint`(空间指纹)。启动时
`reembedStaleMedia()` 把 `vector` 为空/`'null'` 或 fingerprint 与当前空间不一致(换过模型/维度)的块
批量重算并**写回库**(字节经 `BlockData` 按需取回);模态不在本空间覆盖范围时返回 `ErrModalityUnsupported`,
**绝不拿别的模型的向量顶替**。
### 其他记忆层
- **Social** (`internal/memory/social/social.go`) — 人格特质和关系网,包装 GraphDB 实体类型
- **Text Memory** (`internal/memory/text/text.go`) — 原始对话 JSONL 日志,轮转策略
- **Memory Indexer** (`internal/memory/indexer.go`) — 实体向量化 + jieba 关键词提取,自动注入 system prompt
### 蒸馏管道
`internal/memory/pipeline/pipeline.go`
- 10 分钟 tick,7 天保留
- 规则提取三元组(name / location / likes / age / job 模式)
- 写入 GraphDB
### 上下文剪枝
```text
四个独立心跳循环(各自可配置间隔):
├── distillLoop (distillInterval, 默认30m)
│ └── distillContext() — Context.Prune → Document
├── archiveLoop (archiveInterval, 默认60m)
│ └── archiveColdDocs() — 冷文档 → docToTriples → GraphDB
├── mergeLoop (mergeInterval, 默认120m)
│ └── detectEntityMerge() — 实体相似度检测 → LLM 裁决
└── reviewLoop (reviewInterval, 默认120m)
└── reviewRelations() — 关系复审 → SentenceRef 回溯 → LLM 修正
```text
实体冲突检测启发式(bigram Jaccard > 0.75),走 `selfInputCh` 内部通道,LLM 最终判断是否合并。
## 知识库
`internal/knowledge/knowledge.go`
- 文件目录 `knowledge/<name>/content.md`
- 独立 TF-IDF 索引,与记忆系统不冲突
- `knowledge_search` / `knowledge_create` / `knowledge_list`
## Provider 与 Lua 适配层
```text
Agent
│
▼
Provider 接口 (Name / Chat / ChatStream)
│
└── LuaAdaptedProvider (唯一实现)
├── 序列化 CompletionRequest → JSON
├── adapter.transform_request() → API 格式
├── HTTP 请求 + adapter.headers
├── adapter.transform_response() → 统一格式
└── 反序列化
```text
代码:`internal/agent/api/provider.go`
ProviderManager 管理多个源,按注册顺序 fallback。Lua 适配器位于 `internal/lua/adapters/`,每个 `.lua` 脚本定义 `transform_request` / `transform_response` / `transform_stream_chunk`。
VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
## 插件系统
### 四种加载方式
| 方式 | 注册机制 | 编译 | 用途 |
| ------ | ---------- | ------ | ------ |
| 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/agentcli 等 13 个(判定标准见下) |
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
内置插件注册:`internal/plugins/all.go` 空白导入 → 各插件 `init()` → `Registry.Load()` 扫描目录匹配工厂。
外部插件加载(v1.0.0 起):`internal/plugin/dynamic_proc.go` → `exec.Command(plugin.bin)`
→ 继承共享段 fd → 握手(比对 protocol 版本)→ `plugin.init` → `plugin.start`
(插件在此期间反向注册工具/阶段/通道)。
**C ABI 通道(`-buildmode=c-shared` + bridge)已在 v1.0.0 整体删除**——
旧的 `plugin.Open` 路径缓存绕行、SHA256 临时路径复制等手法随之退场。
Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main.lua`(加载期 `sdk.register_*` 仅暂存 handler),`Start()` 时替换为真实 SDK 实现并批量注册。脚本只在加载时读取一次,运行期通过回调执行。
### 内置插件的判定标准(2026-10-19 定型)
**只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件,才留在编译期内置清单。**
内部 SDK(`internal/sdk`)比公开 SDK(third_party/homeagent-sdk)多出一批
**不对外授予**的能力面:`TrackerAPI` / `StatusAPI` /
`SupervisorAPI` / `AdapterAPI` / `SelftestAPI` / `ToolAPI`,以及 **`Publish`
(事件发布)**。外部插件走 proc 桥的收窄能力集,这些接口在类型层就不存在
(权威清单见 `internal/plugin/proc/capability.go` 的 `withheldCapabilities`)。
由此得出的二分法:
- **插件需要 withheld 能力 ⇒ 必须内置**。外置后功能**静默消失**——不报错、
不崩进程,只是数据链路断在插件与内核之间。这类故障最难排查。
- **插件只用公开 SDK 能力 ⇒ 应当外置**(.hmap 安装)。独立编译、独立发版,
改一行不用重编 87MB 的内核。
正例(留内置,各有非它不可的理由):
| 插件 | 依赖的内部能力 |
| --- | --- |
| cli / webui | TrackerAPI、StatusAPI、SupervisorAPI、AdapterAPI |
| agentcli | **EventPublish**:靠 `Publish(EventTerminalOutput)` 上报终端输出与生命周期,公开 SDK 无 Publish |
| healthcheck | SelftestAPI(能构造虚拟内核实例做自检) |
| pluginmgr | failback 恢复链必需(`core.agent.failback_plugins` 默认 webui/pluginmgr/recoverydiag) |
| seq | ToolAPI(工具表直接操作,组内并行需按名调工具) |
| remotedevice | RegisterChannel(内核 IO 通道注册面) |
| clawhubadapter | InjectInputSync(OC 插件主动注入) |
反例(已外置,因为它们只要公开能力):
| 插件 | 实际依赖 |
| --- | --- |
| timer | RegisterTool + Settings + InjectText(公开) |
| ai_image / files / cmd / localuse / mcp | RegisterTool + Settings(+ 公开 RegisterStage) |
> ★ 终审翻案(2026-10-19,逐方法对照公开 SDK 后加回两个):
> **kbtree** 依赖 `KnowledgeAPI.Subtree/CategoryCounts/SearchIn/Categories`
> ——公开 `KnowledgeAPI` 只有 `Search/Add/List`,且公开 SDK 没有
> `KnowledgeTreeView`/`KnowledgeCategoryCount` 类型;
> **multimodal** 依赖 `LLMAPI.Chat` + `LLMMessage`/`LLMContentBlock`
> ——公开 `LLMAPI` 只有 `ListSources/SetSource/CurrentSource`。
> 两者最初被误判为"只用公开能力",因为 `s.Knowledge()`/`s.LLM()` 两边
> **同名不同面**。教训:判外置与否必须逐方法对照公开接口,不能只看方法名。
> ⚠️ 两份并存是禁止的:`loadOne` 先查内置工厂表,命中就不再看外部 `plugin.bin`
> ⇒ 内置版永远赢,外部版**装了也不生效**。外置一个插件时,必须同时把它从
> `internal/plugins/all.go` 移除(该文件是唯一权威清单)。
### 终端归属:agentcli 全权,内核不碰(2026-10-20 调整)
终端逻辑只归 `agentcli`(PTY、会话生命周期、输出缓冲、历史)。内核**不再**维护
终端语义:曾经的 `internal/agent/core/terminal_registry.go` 已删除 —— 那是在内核里
编码「什么是终端、怎么从工具调用里归并会话」,属于职责错位。
插件侧(WebUI / CLI)需要终端面板时,**订阅事件总线自己攒视图**:
| 事件 | 发布者 | 用途 |
| --- | --- | --- |
| `EventTerminalOutput` | agentcli | 会话输出、创建/关闭/超时(`running` 翻转) |
| `EventToolCall` | 内核 | `cmd_run` 历史、`terminal_create/close` 归并 |
WebUI 与 CLI 共用 `internal/pluginview` 的实现(同一解析口径,避免历史上两份
推导各踩一次「工具 result 是 Go map 文本」的坑)。视图不是权威:状态真值在
agentcli,插件晚于会话启动时会缺早期会话。
agentcli 因此需要 `Publish`(外部插件无此能力),按判定标准保持内置;
将来若要外置,需要先给外部插件开一条**受控的上行事件**通道(当前刻意不开)。
### 内置插件 vs 外部插件
| 维度 | 内置插件 | 外部插件 |
| ------ | ---------- | ---------- |
| 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 |
| 编译方式 | 编译进 `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` |
| SDK 权限 | 完整 PluginSDK(SocialAPI 读写、Publish 事件) | 收窄的 `procCore` 能力面 + manifest capabilities 声明 + RPC 边界拒绝 |
| 生命周期 | 随内核启动/停止,不可单独热重载 | 独立进程,换 `plugin.bin` 即生效的真热重载(ReloadOne)和禁用/启用 |
| 崩溃恢复 | 无独立恢复机制 | 进程级隔离:崩溃不影响内核,内核摘除其注册面后按退避自动重启(`SetAutoRestart(false)` 可关) |
两者的联系:
- 内置插件的工厂函数 `RegisterFactory` 与外部插件的 `NewPluginFactory` 共用同一个 `NativeFactory` 类型签名
- `Registry.Load()` 统一处理两者的加载:先查工厂表(内置),无工厂则按 manifest 的 `entry` 分派到 proc / lua / skill 通道
- 两者使用相同的 `Plugin` 接口和公开 SDK API,工具注册、阶段钩子、输出通道等完全一致
- 两者共享同一个工具注册表(`StageHost`),LLM 调用时无差别
### 子进程插件的三个通信面(v1.0.0)
| 面 | 机制 | 为何这么选 |
| --- | --- | --- |
| 控制面 | stdio JSON-RPC(NDJSON 帧),当前 82 个 method(权威清单见 `internal/plugin/proc/protocol.go`) | 进程边界即 ABI 边界,无需维护三套平台特定的动态库加载代码 |
| 数据面 | 共享内存段,**全部子进程共用一块** | 每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型,lost update 原样复现 |
| 通知面 | 事件环 + 平台通知(Linux eventfd / macOS pipe / Windows Event) | 内核发事件绕不等消费者,流式输出逐 token 发布时任何等待都会造成卡顿 |
v1.1.1 新增 4 个 method(51 → 55):`doc.insertWithMedia`、`io.injectMedia`、
`io.injectMediaSync`、`io.injectInterruptMedia`。**媒体块走 JSON 而非共享段二进制通道**——
data URL 本身已是 base64 文本,包进二进制传输省不了空间,还要跟其余 51 个 method 分道。
> 上表的「82 个」是**当前**值;51/55 是 v1.1.1 当时的数字,此后各条发布线继续追加
> (settings / secrets / social / knowledge 等面),故不再回填到历史叙述里。
**子进程生命周期管理**:
- 每子进程一根专职 `waitLoop`(`cmd.Wait()` 唯一调用点)——不依赖 stdout EOF,
因为插件 fork 的孙子进程(browser 拉 chromium、editdoc 拉 python)继承同一 stdout,
插件本体死后 EOF 永不到来
- 集中台账 `proc.Supervisor`:握手成功即登记,退出即注销;`Host.Close()` 先 StopAll 再拆段
(顺序反了插件还持有映射而段已 unmap,下次访问就是 SIGBUS)
- 崩溃自愈:摘注册面(工具 + stage handler + IO 通道)→ 移出注册表 → 退避重启
- Linux `Pdeathsig` 兜底 homed 被强杀时子进程不滞留为孤儿
### PluginSDK 四通道
```text
插件 ──→ 核心
RegisterTool(name, fn) ──→ buildToolDefs() / executeToolCall()
RegisterStage(stage, fn, scope...) ──→ runStage() 在对应阶段调用(scope 控制全局/仅自己工具)
Subscribe(event, fn) ──→ Publish() 通知所有订阅者
RegisterOutputChannel(name, caps, desc, handler) ──→ output_send__{name} 工具生成
```text
`internal/sdk/` 桥接外部 SDK 接口到内核,定义完整 PluginSDK:
```go
sdk.RegisterTool(name, def, handler)
sdk.RegisterStage(stage, handler, scope...)
sdk.Publish(event)
sdk.InjectInput(source, channel, payload)
sdk.InjectInterrupt(source, channel, payload)
sdk.Memory().Recall/Commit
sdk.Knowledge().Search/Create
sdk.Settings().Get/Set/List
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ消息通道,详见 output_send__qq_help", handler)
// v1.1.1 媒体接口(全部新增,无签名变更)
sdk.DocMemory().InsertWithMedia(doc, attachments) // 带 Data 的落进 CAS,只给 Digest 的引用已有内容
sdk.InjectInputMedia(source, channel, text, blocks) // 媒体在「本轮」就发给模型
sdk.InjectInputMediaSync(...) // 同上并同步等回复
sdk.InjectInterruptMedia(...) // 带媒体的中断,可抢占当前处理
```text
媒体注入与 `SetToolBlocks` 的区别:后者只能在工具处理函数内部调用,且媒体要等**下一条**
tool message 才到模型手上;前三个是插件**主动发起一轮带媒体的对话**,媒体随本轮消息发出,
并自动落进 CAS、挂上媒体记忆引用。`Triple` 与 `Doc` 相应新增 `MediaDigests`、`Attachments`。
`internal/sdk/` 是这层的桥接实现,不受公开接口冻结约束(见 `docs/git-branching.md` §六)。
### Plugin 接口
```go
type Plugin interface {
Name() string
Start(sdk *PluginSDK) error
Stop() error
}
```text
## 输出通道系统
每个输出通道生成两个工具:
| 工具 | 类型 | 作用 |
|------|------|------|
| `output_send__{name}` | function | 接受 `payload`(消息载荷)、`meta`(JSON 路由元数据)、`type`(枚举) 三个参,路由到插件 handler |
| `output_send__{name}_help` | function | 返回通道的 meta 格式和 type 枚举说明 |
能力标志位:
| 标志 | 值 | 含义 |
| ------ | ----- | ------ |
| CapText | 1 | 纯文本 |
| CapFile | 2 | 文件 |
| CapImage | 4 | 图片 |
| CapAudio | 8 | 音频 |
| CapStructured | 16 | 结构化数据 |
系统提示注入:输出门控规则、多调用支持、长消息拆分。
子代理权限:`output_send__` 前缀工具允许使用。
## LLM 链事件
- 事件类型 `agent_llm_chain`,每次 LLM 轮次后发射
- 包含完整 LLM 响应(文本 + 工具调用 + 推理)
- WebUI 通过 SSE 订阅此事件实现实时显示
- 插件可通过 EventSubscriber 订阅(外部插件只读)
## 受限外部插件 API
分层架构:内部插件获得完整 PluginSDK,外部插件获得受限 SDK。
| API | 内部插件 | 外部插件 |
|-----|----------|----------|
| SocialAPI | 完整读写 | 只读(GetPerson / GetTrait / GetRelations / GetNetwork / ListPersons) |
| EventSubscriber | 订阅 + 发布 | 仅订阅(无 Publish 能力) |
扩展字段:
- Triple 扩展:Confidence、SubjectType、ObjectType
- Relation 扩展:Confidence
## 输入调度器与中断机制
输入不直接进 LLM —— 它们先进**输入调度器**(`internal/agent/core/scheduler.go`)。
设计全文见 [`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md)。
### 两类任务
| 类别 | 级别 | 语义 |
|------|------|------|
| `TaskQueued` 排队输入 | 无级别(恒 0) | 待办工作。任何中断(≥ L1)都能抢它 |
| `TaskInterrupt` 中断 | L1~L4 | "这件事有多不能等",由来源在 `InjectOptions.Priority` 声明 |
### 四级中断
| 级别 | 含义 | 典型来源 |
| ------ | ------ | ---------- |
| L1 背景 | 完全可等 | QQ/微信消息、批量通知 |
| L2 消息 | 一般提醒 | 插件希望尽快看到但不紧急的提示 |
| L3 交互 | 需及时处理 | 定时器到达、终端输出、子 agent 汇报 |
| L4 关键 | **内核独占** | panic、内核事件、内核级插件的终止按钮 |
未声明级别时取 **L1**(`DefaultLevel`)——「显式才是特权」,新插件不会默认拿到抢占权。
外部插件声明 L4 会被**夹到 L3**(`clampPluginLevel`)。
### 抢占与挂起
- **同级不能抢占同级**(`canPreempt` 要求严格大于)——这是日常"消息排队等前面跑完"的成因。
- 被抢占的任务压入**中断栈**(LIFO),用 `suspendStack` 保存现场,稍后恢复;
栈内不做优先级重排("后被打断的先恢复"才是栈语义)。
- **饥饿防护**:被抢占次数会提升有效级别(`effectiveLevel = Level + min(PreemptCount, 2)`,
封顶 L4),确保低级别流不会被困。
- **抢占冷却**:刚被抢占过的任务在 `preemptCooldown`(2s)内不再被抢,
避免高优先级流把同一个任务反复打断到永不完结。
- 中断栈帧数有**结构上界**(链条 = 排队 ← L1 ← L2 ← L3 ← L4,最多挂起 4 帧)。
### 停止(用户按停止按钮 / `/stop`)
停止 ≠ 空中断。它做两件事:① 立即结束当前 LLM 推理;② 对**停止那一刻已排队**
的 x 条消息依次在 pre-action 阶段短路(`cancelBudget` 快照配额),而不是把它们
当新输入再跑一遍。停止之后**新到**的输入不受影响。
`PendingInputs()` 必须把"还停在 `io.inputCh`、没被 `pumpInbox` 搬进队列"的那一段
算进来 —— 停止时调度器多半正忙于当前任务,只数 `sched.queue` 会得到 0。
### 驻留式子 agent 与及时反馈
设计见 [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md)。
- **驻留子**是轻量内核的独立 agent:自己的调度器、自己的 temp 稳定记忆、共享的通道登记表。
- 父对子的控制面:`resident_agents`(list / create / send / inspect / compress / reclaim / destroy)。
- **积压及时反馈**:主 agent 长时间忙时(默认 > 5m,可配),内核把排队输入交给
一个临时**分诊助手**(`offload_*` 配置):简单的直接处理并回复,需要主 agent 的
立刻回「忙碌中,请稍候」。这样用户不会干等十几分钟。
- 分诊助手**不配 inputch**(不接收插件用户输入)、**持有全部输出通道**(结果要能发回原通道)。
- 回收/销毁时它手头的**残余任务由父显式决定**:`residual=keep`(转回父队列,默认)
或 `drop`(明确丢弃,逐条记日志)。
### 旧版三路径(仍存在,但已是调度器之下的一层)
```text
interceptLoop (goroutine)
├── InputInterruptChan() ← 定时器/消息通知
├── (a) cancelLLM() → 取消 Provider HTTP 请求
├── (b) interceptCh → process() 轮前读 [打断消息]
└── (c) InjectInput() → 空闲时触发新处理
```text
代码:`internal/agent/core/scheduler.go`(调度器)、`eventloop.go`(拦截循环)。
## 上下文预算
`internal/agent/core/tokenbudget.go` — `ComputeTokenBudget`:
```text
maxCtx = provider.MaxContextTokens() // 声明窗口(per-source context_window 优先)
targetUsage = min(maxCtx × 0.8, 600000) // 工作面:封顶 600K
├── 记忆召回预算 = (targetUsage - 固定开销) / 3
└── 上下文事件预算 = 其余 2/3
```text
★ **窗口 ≠ 工作面**:源的真实窗口可能到 1M,但接近满窗口时注意力涣散、
成本与延迟线性上升,因此 `maxTargetTokens=600000` 把工作面单独封顶。
若模型名(如 `AUTO`)推断不出窗口,`ModelContextWindow` 会**打日志提醒**并回退保守值,
部署方应用 `core.llm.sources.<name>.context_window` 显式声明。
**预算都是上限而非填充目标**:记忆按相关度召回(没相关就停),时间线按预算从新到旧取。
实测:预算 400K 时实际注入仍只有几百字符。
## 提示词分层(2026-10-20 定型)
系统提示词**不是**放工具用法的位置。判断一段话该放哪里,看它的**生命周期与可见性**:
| 内容 | 归属 | 为什么 |
| --- | --- | --- |
| 身份、对用户的称呼、语气风格、形象 | 人格(`personal/personal.md` 或 `core.agent.personal_prompt`) | 与人设同生共死,跨插件、跨通道稳定 |
| 单工具:何时用、参数含义、副作用、坑 | 工具 `description` | **跟着工具走**:插件没装/没启动,描述就不在清单里,模型不会误用 |
| 多步流程、策略、领域知识(如「怎么查 QQ 历史」「怎么改文件」) | skill(`skill/<name>/SKILL.md`) | 渐进披露:索引常驻、全文按需 `skill_info` 取,不占常驻预算 |
| 输出投递规则、同轮并行语义、对话时序语义 | 内核运行时块(`buildSystemPrompt` / 时间线头) | 由内核按**真实**执行语义生成;写成静态文本会与实现漂移 |
**反面教材(实测 2026-10-20)**:默认系统提示词里写了一大段
「cmd_run 经完整 bash 执行…」「写文件优先 files_write…」「并行优先 spawn_child…」。
这些插件外置并按需加载后,`cmd_run` 根本不在工具清单里,而提示词还在教模型用它 ——
模型第一轮就调用不存在的工具,拿到
「工具 cmd_run 不存在或未注册」后才发现。
**判据**:提示词里凡是出现**具体工具名 + 用法**,几乎都是放错了位置。
例外只有两类:① 工具名是**运行时注入**的(如 `output_send__{通道名}`,通道集合每轮变);
② 那句话讲的是**内核行为**而不是工具行为(如「同轮调用默认并行」)。
同理,插件的 skill 必须与插件**同包分发**:`auto_start=false` 的插件若没有 skill,
它的工具不在初始清单、模型不知道它存在、也永远不会拉起它 —— 等价于没装。
hmapdev 在打包期强制校验这条(`autostart_skill_guard_test.go`)。
## 配置系统
`internal/config/registry.go` — ConfigRegistry
- SQLite 存储,`config` 表 + `config_<plugin>` 独立表
- 命名空间:`core.*` / `plugin.<name>.*`
- `RegisterDefault` 插入 ~80 个默认键(含 1 个 LLM 源 seed,其余源由部署时添加)
- WebUI 设置页 `/api/v1/settings` 读写
## 代码结构
```text
cmd/homed/main.go — 入口:组装所有子系统
cmd/waiter/main.go — CLI 客户端 (Unix socket)
internal/
├── agent/
│ ├── core/ — Agent 核心 (eventLoop/process/stages/context)
│ │ └── plugin_health.go — 插件健康监控与自动重启
│ ├── api/ — Provider 接口 + LuaAdaptedProvider
│ ├── io/ — IOManager (排队/中断/输出)
│ └── personal.go — 人格加载
├── plugin/
│ ├── registry.go — 注册表 + 生命周期
│ ├── dynamic.go — 加载通道分派(proc / lua / skill);.so 仅剩给明确报错
│ └── manifest.go — plugin.json 元数据
├── plugins/ — 内置插件实现(13 个,判定标准见「内置插件的判定标准」)
│ ├── all.go — 空白导入(**唯一权威清单**,增删内置插件只改这里)
│ ├── webui/ — HTTP 服务器 + 嵌入式 SPA
│ ├── cli/ — Unix socket CLI
│ ├── clawhubadapter/ — ClawHub 适配器(OC 插件/SKILL/JS/Python sidecar)
│ ├── agentcli/ — PTY 终端
│ ├── healthcheck/ — 健康检查
│ ├── pluginmgr/ — 插件管理器
│ ├── skillmgr/ — Skill 插件管理
│ ├── cfgmgr/ — 配置管理
│ ├── remotedevice/ — 远程设备桥
│ ├── seemedia/ — 直看媒体
│ ├── seq/ — 序列编排
│ ├── data/ plugin_data/ — 插件静态资源(非 Go,随内核打包)
│ ├── cmd/ localuse/ mcp/ ai_image/ files/
│ │ — 源码仍在但**未列入 all.go**:2026-10-19 起外置,
│ │ 以 .hmap 形式安装(SDK 仓 example/ 提供工程);
│ │ 目录待外置工程稳定后删除
├── sdk/ — PluginSDK 定义
│ ├── plugin.go — Plugin 接口 + PluginSDK
│ ├── memory.go — MemoryAPI
│ ├── knowledge.go — KnowledgeAPI
│ ├── settings.go — SettingsAPI
│ └── llm.go — LLMAPI
├── memory/
│ ├── graph.go — SQLite 图数据库
│ ├── indexer.go — 图→向量索引
│ ├── vector/store.go — TF-IDF 向量引擎
│ ├── document/document.go — 文档记忆
│ ├── text/text.go — 文本日志
│ └── pipeline/ — 蒸馏器
├── knowledge/knowledge.go — 知识库
├── lua/
│ ├── vm.go — Lua VM (json/log/http)
│ └── adapters/ — 10 个 LLM 协议适配器脚本
├── config/registry.go — SQLite 配置中心
├── events/bus.go — 事件总线
├── tracker/ — OverlayFS 变更追踪
├── supervisor/ — 守护进程管理
└── meta/ — 元信息
└── meta.go — Agent 元数据
```text