跳到主要内容

数据截至 (上游 commit 4ac938ddecce)

自我进化闭环 —— 技能、记忆、策展与跨会话回忆

30 秒导读: Hermes 的招牌不是"能调工具",而是它会把每一轮对话的收获写回磁盘。 一轮聊完,它 fork 一个只准调 memory / skill 工具的自己去复盘;复盘结果落到三处存储 (记忆文件、技能目录、使用元数据);下一次会话启动时,系统提示把这些读回来。 再加一个定期跑的策展器给技能库做减法,和一套 FTS5 全文索引让它翻旧账。

本章只讲这个闭环。一轮对话本身怎么跑完见 01-turn-lifecycle; 系统提示怎么拼、缓存怎么分层见 02-context-engineering; SessionDB 的存储与修复细节见 05-runs-anywhere


1. 这一章讲什么(零基础版)

1.1 先问一个问题

普通聊天机器人和 Hermes 的差别,一句话:普通机器人下次见你还是一张白纸,Hermes 不是。

要做到"下次不是白纸",一个 agent 必须回答三个问题:

问题Hermes 的答案存到哪
我该记住这个人是谁?记忆(memory)~/.hermes/memories/MEMORY.mdUSER.md
我该记住这类活怎么干?技能(skill)~/.hermes/skills/<name>/SKILL.md
我上次到底聊了啥?会话历史 + 全文索引~/.hermes/state.db 的 FTS5 表

再加一个运维问题:技能越攒越多、越攒越碎怎么办?答案是策展器(curator)——一个定期 给技能库做减法的后台任务。

1.2 一个直觉类比

把这四件事当成一个人的四种笔记:

  • 记忆 = 通讯录上写的一行备注("老王喜欢直说,别绕")。空间很小,必须精挑。
  • 技能 = 你写给自己的操作手册("发版流程:1. …2. …")。可以很厚,按需翻。
  • 策展 = 每季度整理一次手册:把五篇碎片合并成一篇总纲,过期的塞进档案盒。
  • 会话回忆 = 你的聊天记录全文搜索。不占脑容量,想不起来了再去翻。

关键在于谁来写这些笔记:不是人,是 agent 自己,在每轮对话结束、用户已经拿到回复 之后,悄悄跑一遍。

1.3 用起来什么样

用户什么都不用做。一轮结束后,终端上可能多出这么一行:

💾 Self-improvement review: Skill 'pr-triage-salvage' patched: "…" → "…" · Memory updated

这行的生成逻辑在 agent/background_review.py:1428-1441(_run_review_in_thread 尾部), 内容来自 summarize_background_review_actions(agent/background_review.py:640)。

想手动触发学习,还有一个 /learn 命令,把"刚才这套流程"直接固化成一篇 SKILL.md。

1.4 本章承重术语(一词一义)

后面几节反复出现四个词,含义固定,不换说法:

术语指什么不指什么
后台复盘整条机制:触发判定 → fork → 写盘 → 一行汇总不指某个具体进程
复盘 fork被 fork 出来的那个 AIAgent 实例(工具只剩 memory/skills)不是子 agent(delegate_task 那条路见 04)
策展 forkcurator 合并阶段另起的 AIAgent 实例(跑辅助模型)与复盘 fork 是两条独立路径
记忆 / 技能陈述性知识(这个人是谁) / 程序性知识(这类活怎么干)两者不互相替代

2. 顶层全景:一张闭环图

怎么读这张图: 从上往下是时间顺序。左边是主对话(用户看得见),右边是后台复盘 (用户看不见,只在最后冒一行汇总)。

主对话(前台) 后台复盘(daemon 线程)
────────────────────────────────────────────────────────────────
用户消息 → 模型 → 工具 → 回复

│ 回复已交付给用户

① 查计数器
记忆:满 10 个用户轮?
技能:本轮工具迭代 ≥10?

│ 都没到 → 结束
↓ 到了
└──────────────────────→ ② fork 一个 AIAgent(复盘 fork)
· 继承父的 provider / model / 凭据
· 继承父的已缓存系统提示
· 工具白名单 = {memory, skills}
· 不压缩、不关会话、不碰外部记忆插件


③ 写回三处磁盘存储
├─ memories/MEMORY.md · USER.md
├─ skills/<name>/SKILL.md (+references/)
└─ skills/.usage.json(provenance+计数)

④ 一行汇总打回终端 ←──────────────────────┘

(与此并行)每条消息自动落 state.db,触发器同步写两张 FTS5 索引

⑤ 下一次会话启动:系统提示读回
· MEMORY/USER 冻结快照 · 技能索引(名字+≤60 字描述)
· 需要翻旧账时调 session_search

(另有一条慢线)每 7 天一次 curator:给技能库做减法

各部件一句话职责:

部件干什么主文件
后台复盘每轮后 fork 一个自己,决定要不要写记忆/技能agent/background_review.py
技能读发现、过滤、渐进披露地加载 SKILL.mdtools/skills_tool.py
技能写创建/编辑/补丁/删除,带四道保护闸tools/skill_manager_tool.py
技能来源从 GitHub / skills.sh / ClawHub 等装外部技能tools/skills_hub.py + tools/skills_guard.py
使用元数据计数、provenance、生命周期状态tools/skill_usage.py
策展定期淘汰陈旧技能、合并同类项、出报告agent/curator.py
记忆双 store、字符预算、冻结快照tools/memory_tool.py
记忆编排外部记忆 provider 的注册与围栏agent/memory_manager.pyagent/memory_provider.py
跨会话回忆FTS5 检索 + 锚点滚动tools/session_search_tool.pyhermes_state.py

3. 通道一:每轮之后的后台复盘

这是整个闭环的发动机。没有它,记忆和技能就只能靠用户主动喊"记一下"。

3.1 它要解决的小问题

模型在一轮对话里刚被用户纠正过("别这么啰嗦""这个步骤你漏了"),此刻它知道自己 错在哪。但这轮一结束,上下文一清,教训就没了。

最笨的做法是在主对话里插一句"顺便,你要不要记点什么"——代价是污染主对话、多烧一次 用户等待时间、把复盘的思考混进正式回复。

Hermes 的做法:回复先交付,然后另开一条线程,让一个受限的复盘 fork 去复盘。

3.2 什么时候触发

两个计数器,互相独立,默认都是 10:

触发器计什么默认阈值判定位置
记忆复盘用户轮数(_turns_since_memory)10agent/turn_context.py:714-721
技能复盘本轮的工具迭代次数(_iters_since_skill)10agent/turn_finalizer.py:772-776

默认值在 agent/agent_init.py:1827(_memory_nudge_interval)和 :1296 (_skill_nudge_interval),都可以由 config 覆盖。

两个计数口径不一样是有讲究的:记忆看"聊了多久",技能看"这轮干了多少活"。一轮里 连着调了 10 次工具,通常意味着刚趟过一条非平凡的路径——正是值得沉淀成技能的时刻。

真正的 fork 在回复交付之后才发生:

# agent/turn_finalizer.py:453-461,已省略异常处理
if final_response and not interrupted and (_should_review_memory or _should_review_skills):
agent._spawn_background_review(
messages_snapshot=list(messages),
review_memory=_should_review_memory,
review_skills=_should_review_skills,
)

_spawn_background_review(run_agent.py:1859)只是薄薄一层,真正构造线程目标的是 spawn_background_review_thread(agent/background_review.py:1478),它按触发组合挑 三段 prompt 之一:只记忆(_MEMORY_REVIEW_PROMPT,同文件 :159)、只技能 (_SKILL_REVIEW_PROMPT,:170)、或合并版(_COMBINED_REVIEW_PROMPT,:275)。

3.3 复盘 fork 继承什么、不继承什么

这是全章最值得抄的一段工程。_run_review_in_thread(agent/background_review.py:1013) 构造 fork 时做了十几处显式设置,每一处都对应一个踩过的坑:

设置为什么
provider / model / api_key / base_url继承父的活运行时重新从环境变量解析会对 OAuth-only、会话级凭据、凭据池全部失败
_cached_system_prompt继承父的让 fork 的请求命中父刚焐热的前缀缓存
enabled_toolsets / disabled_toolsets继承父的tools[] 必须逐字节相同——Anthropic 的缓存键包含它
_skip_mcp_refreshTrue轮间的 MCP 刷新会给 fork 加上晚连的工具,破坏 tools[] 一致性
skip_memoryTrue否则 fork 会重建自己的外部记忆 provider,把复盘 prompt 灌进用户真实记忆空间
_memory_store直接指父的对象上一条关掉了外部 provider,但内建 MEMORY.md/USER.md 的写入必须照常落盘
compression_enabledFalsefork 与父共享 session_id,一旦它赢了压缩竞态,会把父轮换成一个网关永远不会认领的子会话
_end_session_on_closeFalsefork 用完即关,不能顺手把父那条还活着的 session 行给收尾了
max_iterations16复盘不是干活,给个小预算
suppress_status_outputTrue中途的限流重试、预算耗尽等状态消息不该冒到用户屏幕上

另外还有一层保险:整段执行被 thread_scoped_silence() 包住(agent/background_review.py:1089-1097)—— 只把本工作线程的 stdout/stderr 写入引到 devnull,进程级的 redirect_stdout 会连 别的线程(比如网关的 Telegram 长轮询事件循环)的输出一起吞掉(#55769/#55925);并且给 这条线程装了一个自动拒绝的危险命令回调 _bg_review_auto_deny(:881-888)——否则 一旦复盘 fork 踩到需要人工确认的命令,input() 会和父进程的 prompt_toolkit TUI 死锁(注释点了 issue #15216)。

3.4 只准碰两类工具

fork 的工具白名单是运行时强制的,不是靠 prompt 自觉:

# agent/background_review.py:1150-1167,已简化
review_toolsets = ["skills"]
if review_agent._memory_enabled or review_agent._user_profile_enabled:
review_toolsets.insert(0, "memory") # profile 关掉 memory 时连白名单都不给(#54937)
review_whitelist = {
t["function"]["name"]
for t in get_tool_definitions(enabled_toolsets=review_toolsets, quiet_mode=True)
}
set_thread_tool_whitelist(
review_whitelist,
deny_msg_fmt="Background review denied non-whitelisted tool: {tool_name}. ...",
)

注意这里的双层设计:tools[] 数组仍然是父的完整工具集(为了缓存一致),但派发层 只放行 skills(以及 profile 开了 memory_enabled 才有的 memory 工具)。模型看得见所有 工具,调别的会在运行时被拒。finally 里的 clear_thread_tool_whitelist()(:1193)保证线程复用时不留残留。

3.5 缓存经济学:同模型全量重放 vs 换模型只放 digest

这是文件头 30-42 行那段策略注释讲的事,也是本章最"值钱"的一个设计。

先讲直觉。复盘要看完整对话才能判断"这轮学到了什么",所以它要把整段历史再喂给模型一次。 问题是——这一次重放到底贵不贵?

答案取决于跑在哪个模型上:

场景缓存状态策略代价
默认(不路由):跟主对话同一个模型父刚跑完,前缀缓存是热的全量重放整段历史便宜的缓存读
用户把复盘路由到别的(更便宜的)模型缓存键不同,必然是冷的只重放一份 digest尽量少的冷写 token

关键推论是反直觉的:换个便宜模型不代表要少喂,而是因为反正缓存冷了,才值得少喂。 同模型下如果为了"省"而喂摘要,反而会把一份新的冷前缀写进缓存,得不偿失。

判定在 _resolve_review_runtime(agent/background_review.py:280):只有当 auxiliary.background_review.{provider,model} 指定了一个与父不同的具体模型时, 才返回 routed=True。同名同 provider 直接短路回父运行时(:163-164)。

digest 的构造在 _digest_history(agent/background_review.py:353),规则很朴素:

# 示意,非源码 —— 演示 digest 的形状
tail = 24 # 最近 24 条原样保留
old = messages[:-tail] # 更早的压成一条
lines = []
for m in old:
if m["role"] == "user":
lines.append("USER: " + m["text"][:300]) # 用户话截 300 字
elif m["role"] == "assistant":
lines.append("ASSISTANT[tools: read_file, patch]") # 助手只留工具名
lines.append("ASSISTANT: " + m["text"][:200]) # 正文截 200 字
digest = {"role": "user", "content": "[Earlier conversation digest …]\n" + "\n".join(lines)}
return [digest] + messages[-tail:]

重点看两处真源码里的细节:一是保留角色交替——摘要被塞成 user 角色,避免破坏 消息序列(agent/background_review.py:386-393);二是尾部不能以 tool 消息开头, 否则会出现孤儿 tool 结果,所以 :123-127 有个循环往前多留几条直到不是 tool 为止。

对应地,系统提示的继承也只在非路由时才做(:692-701)——路由到别的模型时,父的缓存 提示是给错模型的键,继承过去只会白搭。

3.6 复盘 prompt 教它抓什么、更教它别抓什么

三段 prompt 常量都在 agent/background_review.py:401-636。它们不是"看看有啥可存的" 这种泛泛指令,而是一份相当具体的编辑方针。

正面信号(任一条命中就该动手,_SKILL_REVIEW_PROMPT :180-194):

  • 用户纠正了你的风格/语气/格式/啰嗦程度——"别这么写""直接给答案"算一级技能信号,不只是记忆信号
  • 用户纠正了你的工作流或步骤顺序
  • 冒出了非平凡的技巧、修复、绕过、调试路径
  • 本轮加载过的某个技能被证明是错的/缺步骤的——立刻打补丁

动作优先级(挑最靠前那个能用的,:195-230):

  1. 补丁本轮加载过的技能——它才是当时在场的那一个
  2. 补丁一个已有的伞形技能(class-level umbrella)
  3. 在伞形技能下加支持文件:references/(会话细节与知识库)、templates/(拿去改的样板)、scripts/(可直接重跑的脚本)
  4. 实在没有才新建一个 class-level 伞形技能

明令禁止捕获的四类(:249-264),这一段是本章我最想让读者带走的东西:

别记什么为什么
环境依赖型故障(缺二进制、路径不对、command not found)用户能修好;记下来就变成一条永久的自我限制
对工具能力的负面断言("浏览器工具不能用""X 坏了")会固化成拒答理由,在问题修好几个月后还被自己引用
会话内已经自愈的瞬时错误重试成功了,教训是重试模式,不是那次失败
一次性任务叙事("总结今天行情")那不是一类活

最后一条兜底规则(:265-268):如果某个工具因为环境没配好而失败,要记的是修法 (装什么、配哪个 key),而绝不是"这个工具不能用"。

还有一条元规则也值得注意:prompt 明确说 'Nothing to save.'真选项但不该是默认 (:269-272)——设计者认为"一次什么都没学到的复盘是浪费,不是中性结果"。

3.7 汇总怎么打回给用户

summarize_background_review_actions(agent/background_review.py:640)扫复盘 fork 的 消息列表,把成功的 memory / skill_manage 调用翻译成人话。两个不显然的细节:

  • 跳过继承来的旧工具结果。 fork 继承了父的全部历史,里面本来就有上一轮的 "Entry added",不去重就会被当成"刚刚发生的后台工作"重新播报一遍。去重靠 prior_snapshot 里的 tool_call_id / content 集合(:384-395,issue #14944)。
  • 只认白名单里的两个工具名(notify_tools,:401)。否则任何辅助工具成功了都会 被算成一次记忆写入。

显示详略由 notification_mode 控制:off 不显示、on 只说"Memory updated"、 verbose 带内容预览(如 📝 Skill 'x' patched: "旧" → "新")。


4. 通道二:技能体系

技能是 Hermes 的程序性记忆——记忆回答"这个人是谁",技能回答"这类活怎么干"。 这个区分在 prompt 里被反复强调(agent/background_review.py:476-482)。

4.1 磁盘长什么样

~/.hermes/skills/
├── my-skill/
│ ├── SKILL.md ← 必需,YAML frontmatter + 正文
│ ├── references/ ← 会话细节、外部文档摘录
│ ├── templates/ ← 拿去复制修改的样板
│ ├── scripts/ ← 可直接重跑的脚本
│ └── assets/
├── category/ ← 分类目录
│ └── another-skill/SKILL.md
├── .usage.json ← 使用计数 + provenance(sidecar)
├── .bundled_manifest ← 哪些是随 Hermes 出厂的
├── .curator_state ← 策展调度状态
├── .archive/ ← 策展归档区(可恢复)
└── .hub/ ← 外部源的锁文件、隔离区、审计日志

结构说明见 tools/skills_tool.py:14-46 的模块文档。注意所有技能都住在 ~/.hermes/skills/,包括出厂自带的——安装时从仓库的 skills/ 播种过来,这样 "agent 改的、hub 装的、出厂的"三类共存而不污染 git 仓库(tools/skills_tool.py:140-142 的注释)。

4.2 三层渐进披露

这是从 Anthropic 的 Claude Skills 借来的思路(tools/skills_tool.py:9-12),核心是 别把整个技能库塞进上下文:

给模型看什么入口
0系统提示里的技能索引:名字 + ≤60 字描述build_skills_system_prompt(agent/prompt_builder.py:1828)
1全部技能的 name + description(JSON)skills_list(tools/skills_tool.py:804)
2某个技能的完整 SKILL.mdskill_view(name)(tools/skills_tool.py:1072)
3技能下的某个支持文件skill_view(name, "references/api.md")

那个 60 字上限是硬的:extract_skill_description(agent/skill_utils.py:1173-1180, 常量 SKILL_PROMPT_DESC_LIMIT = 60 定义在 :1164)在超过 60 时截成 desc[:57] + "..."。而 frontmatter 校验的上限是 1024 (MAX_DESCRIPTION_LENGTH,tools/skills_tool.py:164)——两者不一致,所以写长了不会报错, 只会被静默截断,永远路由不到。这正是 /learn 的作者标准把"数一遍字符数"写进 prompt 的原因(见 §4.7)。

4.3 发现与过滤:哪些技能会被列出来

_find_all_skills(tools/skills_tool.py:673)扫本地目录 + 配置的 external_dirs, 每个候选过五道筛:

SKILL.md 文件

├─ 路径含排除目录?(.git/node_modules/references/…) → 丢
├─ 只读前 4000 字节,解析 frontmatter
├─ skill_matches_platform() 不匹配当前 OS? → 丢
├─ skill_matches_environment() 不匹配运行环境? → 丢
├─ 名字已见过?(本地目录先扫,本地优先) → 丢
└─ 名字在 disabled 集合里? → 丢

进列表

两个筛子性质不同,别搞混:

  • skill_matches_platform(tools/skills_tool.py:253,实现委托给 agent/skill_utils.py) 是硬兼容门——macOS-only 的技能在 Linux 上根本不该出现。
  • skill_matches_environment(tools/skills_tool.py:263)是offer 时的相关性门, 注释里写得很清楚:显式加载会绕过它。

性能上一个小细节:content = skill_md.read_text(encoding="utf-8")[:4000](:637)—— 列表阶段只读文件头,不读全文。

4.4 加载:skill_view 的三件不显然的事

skill_view(tools/skills_tool.py:1072)看起来只是"读个文件",实际上干了三件事。

第一件:名字解析拒绝猜。 它会用四种策略在所有搜索目录里找候选(直接路径、 分类路径、按目录名递归、按 frontmatter name 递归、legacy 扁平 <name>.md),然后—— 如果候选多于一个,直接报错,列出所有匹配路径让用户显式指定 (tools/skills_tool.py:1355-1377)。理由写在注释里:本地技能被同名外部技能静默遮蔽 是一类真实 bug(/skills 显示的是这个,agent 加载的是那个),宁可吵也不要猜。

安全上,名字先过 _skill_lookup_path_error(:114),挡掉 ../ 遍历、绝对路径和 Windows 盘符(盘符里的 : 会被误读成插件命名空间前缀)。

第二件:缺环境变量时按需现场索要。 这是我觉得最巧的一块。技能可以在 frontmatter 里 声明 required_environment_variables;加载时发现缺,不是直接失败,而是走 _capture_required_environment_variables(tools/skills_tool.py:409):

skill_view("arxiv-search")

├─ 解析 required_environment_variables → 哪些没配?
│ (合并三个来源:新字段、setup.collect_secrets、legacy prerequisites.env_vars)


├─ 是网关面(Slack/Telegram 之类)且非交互? → 返回"去 CLI 里加载"的提示,不追问
├─ 没注册 secret 捕获回调? → 只报告缺什么
└─ 有回调 → 逐个弹安全输入框,存进 ~/.hermes/.env

重新读一遍 env 快照,还缺的进 remaining

readiness_status = setup_needed / available

"是网关面且非交互"这一判定写在 :358(_is_gateway_surface() and not env_var_enabled("HERMES_INTERACTIVE")),上面 :352-357 的注释说明了为什么要用 HERMES_INTERACTIVE 而不是"是不是网关"——桌面端 / TUI 也算网关面,但它们能弹安全输入, 所以要靠这个标志把两类网关面分开;tools/approval.py 用的是同一个标志。 _is_gateway_surface 本身定义在 :418。远程后端(docker/ssh/modal 等, _REMOTE_ENV_BACKENDS,tools/skills_tool.py:174-176)还会额外提示:这些变量在远端 也得有(提示拼装在 :1498)。

第三件:加载即计数。 注册的 handler 不是 skill_view 本身,而是包了一层的 _skill_view_with_bump(tools/skills_tool.py:2122):成功后同时 bump_viewbump_use。注释解释了为什么 view 也算 use——"agent 调 skill_view 是在主动加载技能 去干活,那就是使用,不是浏览"(:1624-1626)。策展器的陈旧计时器正是看 last_used_at

4.5 写:skill_manage 与它的四道闸

写入口是 skill_manage(tools/skill_manager_tool.py:1543),六个动作在 schema 的 enum 里列全(:1342):create / patch / edit / delete / write_file / remove_file

真正有意思的是谁在写决定了能写什么。四道闸按严格程度排:

挡谁挡什么位置
_pinned_guard前台 agent只挡 delete,pin 过的技能仍可改内容tools/skill_manager_tool.py:274
_background_review_write_guard复盘 forkpinned / external_dirs / hub 装的 / 出厂的,一律拒写:238
_curator_consolidation_delete_guard策展 fork删除必须带 absorbed_into=<伞形技能> 且该伞确实存在,否则拒:332
_security_scan_skillagent 自建技能可选开关 skills.guard_agent_created,过 skills_guard 扫描:78

第二道闸的注释把设计意图说透了(:243-249):复盘 fork 比前台更严,恰恰是因为 "这里没有用户在回路里为这次编辑背书"。前台用户明说要改出厂技能,那是用户的自由; 自主维护要改,不行。

4.6 provenance:一个 ContextVar 决定技能归谁

怎么区分"用户让我写的技能"和"我自己攒的技能"?答案朴素得令人愉快:一个 ContextVar。

tools/skill_provenance.py 全文只有 78 行,核心是:

# tools/skill_provenance.py:37-45
_write_origin: contextvars.ContextVar[str] = contextvars.ContextVar(
"skill_write_origin", default="foreground",
)
BACKGROUND_REVIEW = "background_review"

run_agent.py 在每次工具循环前设置它,工具处理器用 is_background_review() 查。 落地效果在 tools/skill_manager_tool.py:1669-1675:

if action == "create":
record_created(
name,
agent_created=is_background_review(),
task_id=task_id,
session_id=session_id,
)

只有复盘 fork 创建的技能才被标记为 agent-created,而策展器只碰 agent-created 的技能。 一条链就把"用户的东西自动化程序永远不许动"这个不变量钉死了。

4.7 /learn:把作者标准写成一段 prompt

/learn 的实现方式很值得单说一句,因为它故意不做一件事。

agent/learn_prompt.py 的模块文档写得很直白(:18-22):

没有独立的蒸馏引擎,也没有 model-tool 足迹:agent 用它现成的工具集干活,所以在 本地、Docker、远程终端后端上表现完全一致。

build_learn_prompt(agent/learn_prompt.py:165)做的事就是拼一段指令:去把用户说的 那些源(目录/URL/"刚才那套流程"/粘贴的文本)用现有工具收集起来,然后按 _AUTHORING_STANDARDS(:30)写一篇 SKILL.md 存进去。

_AUTHORING_STANDARDS 本身是一份相当硬核的 house style:

  • description ≤60 字符,并且明确要求"写完数一遍字符数,超了就砍,别写完祈祷"—— 上游原因就是 §4.2 说的静默截断
  • author 永远是字面量 Hermes,禁止从宿主环境(OS 登录名、git config)填—— 技能会被分享出去,环境推导出的名字是用户没同意过的隐私泄漏
  • 正文八节固定顺序:标题+简介 → When to Use → Prerequisites → How to Run → Quick Reference → Procedure → Pitfalls → Verification
  • 用 Hermes 工具名叙述,不用 shell 工具名:说 read_file 不说 cat,说 search_files 不说 grep,说 patch 不说 sed
  • 绝不编造 flag / 路径 / API;大脚本放 scripts/,不要内联

4.8 外部技能源与安装校验

技能不只能自己写,也能从外面装。tools/skills_hub.py 是一个 3996 行的源适配器集合:

源类拉什么
OptionalSkillSource随仓库出厂但默认不激活的官方可选技能(优先级最高):2983
HermesIndexSource集中式索引(搜索 + 已解析的安装路径):3640
SkillsShSourceskills.sh 注册表:1423
GitHubSource任意 GitHub 仓库,走 Contents API:446
ClawHubSourceClawHub:1999
ClaudeMarketplaceSourceClaude 插件市场:2545
LobeHubSource / BrowseShSource / UrlSource / WellKnownSkillSource其余若干:2649 / :2809 / :1257 / :1030

装配顺序即优先级,见 create_source_router(tools/skills_hub.py:4505); 搜索是并行的(parallel_search_sources,:3867)。

安装路径是先隔离、后扫描、再落地:

远端 bundle
↓ quarantine_bundle() skills_hub.py:3360
~/.hermes/skills/.hub/quarantine/<name>/
↓ scan_skill() skills_guard.py:627
ScanResult{verdict: safe|caution|dangerous}
↓ should_allow_install() skills_guard.py:686
↓ install_from_quarantine() skills_hub.py:3385
~/.hermes/skills/<category>/<name>/
↓ append_audit_log() skills_hub.py:3327

放行策略是信任等级 × 扫描结论的二维表(INSTALL_POLICY, tools/skills_guard.py:55-65):

信任等级safecautiondangerous
builtin(出厂)放行放行放行(根本不扫)
trusted放行放行
community放行
agent-created放行放行ask(报错给 agent,让它去掉可疑内容重试)

trusted 是硬编码的白名单,TRUSTED_REPOS(tools/skills_guard.py:44-53)里逐条数下来 就是 openai/skillsanthropics/skillshuggingface/skillsNVIDIA/skills。 除此之外一切都是 community——任何 finding 都直接拦,除非 --force

扫描器本身是正则静态分析(数据外泄、提示注入、破坏性命令、持久化),另加结构检查 _check_structure(:797)和 Unicode 同形字检测(_unicode_char_name,:926)。

4.9 bundle 与 slash 命令

最后一小块拼图:技能怎么被用户主动唤起。

  • 每个技能自动获得一个 /<slug> 命令,扫描在 scan_skill_commands (agent/skill_commands.py:424),消息构造在 build_skill_invocation_message(:517)。
  • Bundle 是一个 YAML 别名,一条命令加载 N 个技能(agent/skill_bundles.py, get_skill_bundles:195、build_bundle_invocation_message:253)。同名时 bundle 赢——用户给 bundle 起名 research,他要的就是自己那个(:25-31)。
  • CLI 可以用 --skill 在会话启动时预载,见 build_preloaded_skills_prompt (agent/skill_commands.py:839),同样会 bump_use

一个容易忽略的坑:/skill 展开后的用户消息里塞着整篇技能正文,如果原样喂给外部 记忆 provider,记下来的就是技能全文而不是用户真正问了什么。所以有 extract_user_instruction_from_skill_message(agent/skill_commands.py:97)专门把用户 那句话抠出来;它依赖的标记常量(_SKILL_INVOCATION_PREFIX 等,:47-55)必须与两个 消息构造器逐字节一致,这一点有测试守着(:41-45 的注释)。


5. 通道三:策展 —— 给技能库做减法

5.1 它要解决的问题

后台复盘被明确要求"要主动、别偷懒",跑几个月后必然的结果是:技能库长成一堆碎片hermes-config-ahermes-config-bhermes-config-c……每个都记着某一次的具体 bug。

策展 prompt 对此的判词很不客气(agent/curator.py:436-444):

一个几百条窄技能、每条只捕获了一次会话具体 bug 的集合,是这个库的失败,不是特性。

理由是检索机制决定的:agent 是按 description 匹配,不是按精确名字。一个带小节标题的 宽伞形技能,比五个窄兄弟更容易被命中。

5.2 两段式:确定性淘汰 + 可选的 LLM 合并

run_curator_review(agent/curator.py:1511)分两段,性质完全不同:

用不用模型默认干什么
一、自动状态迁移不用开(只要 curator 启用)按活跃时间戳 active↔stale→archived
二、伞形合并用(策展 fork,跑辅助模型)(DEFAULT_CONSOLIDATE = False,:64)找前缀簇、建伞、吸收兄弟

默认关掉第二段是个务实决定:确定性剪枝零成本,而 LLM 合并要跑 50-100 次 API 调用 (max_iterations=9999 与那段解释注释在 agent/curator.py:1942-1947),得用户显式 opt-in。

5.3 触发闸门

should_run_now(agent/curator.py:233)只管静态闸,空闲判定留给调用点:

should_run_now()
├─ curator.enabled 为假? → False
├─ 被 pause 了? → False
├─ last_run_at 不存在?
│ → 把 last_run_at 播种为"现在",返回 False ← 首次不跑
└─ now - last_run_at >= interval_hours? → True

maybe_run_curator(idle_for_seconds=…) curator.py:1958
└─ 空闲不足 min_idle_hours? → 不跑

"首次不跑"这个设计值得注意:全新安装或刚 hermes update 完的机器,last_run_at 是空, 此时立刻开跑,而是播种一个时间戳、推迟一整个间隔(:247-262)。想马上看效果的 用户走 hermes curator run --dry-run,那条路径绕过这道闸。

默认参数(agent/curator.py:70-78):

参数默认值
interval_hours168(7 天)
min_idle_hours2
stale_after_days30
archive_after_days90
consolidateFalse

两个调用点都是"顺手一问"式的:CLI 启动时(cli.py:12683,把空闲时间当成无穷大), 以及网关的 housekeeping 循环(gateway/run.py:30083)——注释说得好(:18241), CURATOR_EVERY 只是轮询频率,真正的节流在 should_run_now 里。

5.4 状态机:什么时候变陈旧、什么时候归档

apply_automatic_transitions(agent/curator.py:305)是纯函数,不碰模型:

(每个 agent-created 技能)

┌──────────┴──────────┐
│ pinned? cron 引用? │ → 是,整个跳过
└──────────┬──────────┘
│ 否
┌──────────┴──────────────────┐
│ 首次见到、无记录? │ → seed 一条基线,本轮不动
└──────────┬──────────────────┘

anchor = last_activity_at 或 created_at

┌──────────┴───────────────────────────┐
│ use_count == 0 且 anchor 比 30 天新? │ → 完全不动(可能只是触发条件还没来)
└──────────┬───────────────────────────┘

anchor ≤ 90 天前 ──→ ARCHIVED(目录移进 .archive/)
anchor ≤ 30 天前 ──→ STALE
anchor 比 30 天新且当前是 STALE ──→ 回到 ACTIVE

几条保护规则是踩出来的:

  • cron 引用的技能等同 pinned。 _cron_referenced_skills(:276)读定时任务里 提到的技能名,包括暂停的和一次性的远期任务。理由:调度器只在任务真的触发时才 bump 使用计数,所以触发周期长于 archive_after_days 的任务,技能会在它脚下被归档掉。
  • 从不删除,只归档。 archive_skill(tools/skill_usage.py:1071)把目录 rename 进 .archive/,跨设备时回退到 shutil.move,并保留一条 STATE_ARCHIVED 记录供 hermes curator restore
  • 出厂技能默认动不了,除非打开 curator.prune_builtins;而且有一份永不可动的 硬名单 PROTECTED_BUILTIN_SKILLS = {"plan"}(tools/skill_usage.py:66)——plan 背着 /plan 这条对外承诺的 UX 路径,悄悄归档它会让斜杠命令变成 "Unknown command"。

活跃时间戳的口径也讲究:latest_activity_at(tools/skill_usage.py:146)只看 last_used_at / last_viewed_at / last_patched_at 三者最大,故意排除 created_at, 这样调用方还能区分"从没活动过"的技能。

5.5 合并之后:consolidated 还是 pruned?

第二段跑完后有个真问题:某个技能从库里消失了——它是被吸收进伞了(内容还在), 还是被当成陈旧剪掉了(内容没了)?这两件事对用户的意义完全不同,老版本报告把 它们混在一起,误导用户以为合并掉的技能被剪了。

Hermes 用三路信号对账解决:

信号来源权威性
absorbed_into 声明每次 delete 调用自带的参数最高——模型在删除那一刻直说
YAML 结构化总结模型最终回复里的块中——有意图和理由,但可能幻觉
工具调用启发式扫这一轮的 skill_manage 参数低——但能当 ground truth 审计

启发式在 _classify_removed_skills(agent/curator.py:632):扫所有 skill_manage 调用,找那些 name 不是被删技能、但 file_path/content/new_string提到了 被删技能名的调用,那就是"吸收进伞"的教科书信号。

匹配策略按字段类型分开,这个细节很好:

  • file_path 字段:needle 必须是完整路径分量,所以 api 不会误配 references/api-design.md
  • 内容字段:词边界正则,所以 test 不会误配 latesttesting

三路结果交给 _reconcile_classification(:858)对账:模型对意图和理由有权威, 启发式负责抓幻觉(声称吸收进一个不存在的伞)和遗漏(模型忘了列)。

5.6 报告落盘

_write_run_report(agent/curator.py:1109)把每次运行写成 logs/curator/{YYYYMMDD-HHMMSS}/ 下的 run.json + REPORT.md,内容包括前后快照 diff、 状态迁移、工具调用统计、consolidated/pruned 分类。同秒重跑会加后缀去重(:1104-1108)。

还有两处让人安心的设计:

  • 真跑(非 dry-run)前会先 curator_backup.snapshot_skills(reason="pre-curator-run") 快照整个技能目录(:1536-1538)。
  • 合并会改写 cron 任务里的技能引用——技能 X 被并进伞 Y 之后,引用 X 的定时任务 就加载不到了,调度器会静默跳过,于是任务在没有它该遵循的指令的情况下跑 (agent/curator.py:1206-1212 的注释)。

6. 通道四:记忆

6.1 两个 store,分工明确

MemoryStore(tools/memory_tool.py:159)管两个文件:

文件装什么默认字符预算
MEMORY.mdagent 自己的笔记:环境事实、项目约定、工具怪癖2200
USER.md关于用户:偏好、沟通风格、工作习惯1375

预算是字符数不是 token 数,理由写在模块头(tools/memory_tool.py:17):字符数 与模型无关。条目分隔符是 §(ENTRY_DELIMITER = "\n§\n",:59),所以条目可以多行。

工具是单一入口 memory,靠 action 参数分 add / replace / remove / 批量; replaceremove短的唯一子串定位,不用 ID 也不用全文。

预算满了会怎样?add(tools/memory_tool.py:414)会拒绝并把现有条目全列出来, 指示模型"在这一轮里先合并/删除再重试"(:331-344)。反过来,成功响应是终止性的—— 故意不回显条目列表,注释说观察到过模型看到列表就想"再找点能修的",把同一批操作重发 五遍(_success_response,:586-600)。

6.2 冻结快照:整章最重要的一个语义

这条规则值得单独记住:

记忆写盘立刻生效(持久),但本会话的系统提示不变——快照只在下次会话启动时刷新。

代码是这样落的:load_from_disk(tools/memory_tool.py:227)在会话启动时算一次 _system_prompt_snapshot;format_for_system_prompt(:571)永远返回那份快照,不返回 实时状态。

为什么?为了前缀缓存。 系统提示里任何一个字节变了,整个前缀缓存就作废,后续每一轮 都要重新冷写。用一次"记忆延迟一个会话生效"换整个会话的缓存命中,是划算的。 (系统提示的分层见 02-context-engineering;记忆块被放在 volatile 层,agent/system_prompt.py:820-829。)

# 示意,非源码 —— 冻结快照的两份状态
class MemoryStore:
def load_from_disk(self):
self.entries = read_and_dedupe("MEMORY.md") # 活状态,工具改它
self._snapshot = render(sanitize(self.entries)) # 冻结,系统提示只读它

def add(self, content):
self.entries.append(content)
write_to_disk(self.entries) # 磁盘立刻变
# 注意:这里绝不碰 self._snapshot

6.3 快照阶段的净化

记忆进的是系统提示,而且是跨会话持久的——一条被投毒的记忆会活到用户手动删掉 为止。所以有两道扫描,都用 tools/threat_patterns.pystrict 尺度:

  1. 写入时:_scan_memory_content(tools/memory_tool.py:97)命中就拒绝。
  2. 加载时:_sanitize_entries_for_snapshot(:172)——命中的条目在快照里被替换成 [BLOCKED: … ] 占位符,但活状态保留原文

第二道的取舍很讲究(:142-147 的 docstring,以及 :159-161 的实现注释):活状态保留 原文,是为了让用户能看见被投毒的条目并删掉它;静默丢弃等于对用户隐瞒了这次攻击。 而扫描是从磁盘字节确定性推导的,所以快照在整个会话里仍然稳定——前缀缓存不变量不破。

具体的威胁模型见 06-trust-boundaries

6.4 外部记忆 provider:为什么只允许一个

除了内建的两个文件,Hermes 支持挂一个外部记忆后端(Honcho、Mem0、Hindsight、 supermemory 等,都在 plugins/memory/ 下),抽象基类是 MemoryProvider (agent/memory_provider.py:104),生命周期钩子:initialize / system_prompt_block / prefetch / sync_turn / get_tool_schemas / handle_tool_call / shutdown, 外加一批可选钩子(on_pre_compresson_memory_writeon_delegation 等)。

MemoryManager(agent/memory_manager.py:364)负责编排,而且硬性只允许一个外部 provider——第二个直接被 add_provider(:374)拒掉并打警告。理由在模块头 (:6-8):防止工具 schema 膨胀和记忆后端互相打架。

还有一条更细的门禁:provider 注册的工具名如果撞了 Hermes 核心工具名 (clarifydelegate_task 之类),在门口就被拒(agent/memory_manager.py:430-454)。 注释解释了为什么必须在门口拒而不是事后覆盖——核心工具本来就会赢,但那个坏名字会留在 _tool_to_provider 路由表里劫持派发(issue #40466)。

工具注入本身在 inject_memory_provider_tools(agent/memory_manager.py:110),用 normalize_tool_schema(:49)吸收 provider 返回 schema 的两种形状差异——有的返回 裸函数 schema,有的已经是 OpenAI 工具形状,二次包装会产出 function.function 这种 没有顶层 name 的畸形体。

6.5 上下文围栏与流式擦除

外部 provider 召回的记忆会被塞进对话,用一个围栏包起来 (build_memory_context_block,agent/memory_manager.py:347):

<memory-context>
[System note: The following is recalled memory context, NOT new user input. …]
…召回内容…
</memory-context>

问题来了:这段东西不该被用户看见。一次性正则(sanitize_context,:163)在非流式 下够用,但在流式下会漏——开标签在一个 delta 里,闭标签在后面的 delta 里,非贪婪 正则要求两个标签在同一个字符串里。

于是有了 StreamingContextScrubber(agent/memory_manager.py:182,设计理由写在 :174-179):一个跨 delta 的小状态机,把可能是标签开头的尾部片段扣在缓冲里, 等下一个 delta 来了再判断;span 内部的一切(包括那行 system note)全部丢弃。 每轮开头由 turn_context 调用 reset()(agent/turn_context.py:701-703)。


7. 通道五:跨会话回忆

前四条通道都是。这一条是:让 agent 翻自己的旧账。

7.1 一个工具,三种形状

session_search(tools/session_search_tool.py:1065)没有 mode 参数,模式从传了 哪些参数推断:

形状传什么返回实现
DISCOVERYquery命中会话 + 片段 + ±5 窗口 + 首尾书签_discover(:499)
SCROLLsession_id + around_message_id锚点两侧 ±window 条(clamp 到 1..20)_scroll(:303)
READ只有 session_id整段会话_read_session(:211)
BROWSE什么都不传最近会话列表_list_recent_sessions(:260)

优先级:锚点赢——显式给了 around_message_id 就走 scroll(:679)。

全程零 LLM 调用(tools/session_search_tool.py:27)。这一点在设计上很关键: 回忆是纯检索,不是二次总结。历史上有过一条 summary LLM 路径,合并时被砍掉了(:25-29)。

书签(bookend)的设计也值得学:命中点周围 ±5 条给你局部,会话首 3 条 + 尾 3 条给你 这段对话是干嘛的——一次调用同时回答"匹配在哪"和"这是什么会话"(结果字段 bookend_start / messages / bookend_end,:599-601)。

滚动怎么用:想往前翻,就拿这次返回窗口的第一条 id 重新锚定;往后翻拿最后一条

7.2 底层:两张 FTS5 索引

存储层的建表 DDL 拆到了 hermes_state_common.py(检索逻辑在拆出的 hermes_state_search.py)。 索引不是一张,是三张:

tokenizer干什么建表
messages_fts默认 unicode61拉丁语系的正常全文检索FTS_SQL,hermes_state_common.py:569
messages_fts_trigramtrigramCJK / 泰文等的子串匹配FTS_TRIGRAM_SQL,hermes_state_common.py:633
messages_fts_cjkcjk_unicode61(bigram)CJK 主力索引,可用时包揽各种 CJK 查询形状(PR #65544)FTS_CJK_TABLE_SQL,hermes_state.py:2679

三张都是挂在 messages(或其视图)上的 external-content 表,content / tool_name / tool_calls 三列各自独立索引,由带重建水位门控的 insert / delete / update 触发器 同步(update 触发器只对内容列变更生效,hermes_state_common.py:578-616)。trigram 与 CJK 索引都通过排除 role='tool' 行的视图取数(messages_fts_trigram_src, hermes_state_common.py:634-637)——tool 行约占消息字节九成且几乎全是机器噪声, 不值得给它们做子串索引。

为什么要第二张?因为 unicode61 会把 CJK 逐字切成 token,"大别山项目"变成 "大 AND 别 AND 山 AND 项 AND 目"——既误配又配不上精确短语(注释在 :533-536)。 trigram 用重叠三字节序列,子串查询对任何文字都天然可用。

路由逻辑在 search_messages(hermes_state_search.py:1414)的 CJK 分支(:1843-1900), 四档:

查询含 CJK?
├─ 否 → messages_fts(unicode61)
└─ 是 → bigram 索引 messages_fts_cjk 可用且非 tool 过滤、无孤立单字 → messages_fts_cjk
└─ 旧路:数 CJK 字数
├─ 总数 ≥3 且每个非操作符 token 都 ≥3 字 → messages_fts_trigram
└─ 否则 → LIKE 回退

那条 per-token 检查(:1858-1870,issue #20494)很典型:"广西 OR 桂林 OR 漓江" 总字数 6 满足 ≥3,但每个 token 只有 2 字,trigram(需要 ≥9 UTF-8 字节 = 3 个 CJK 字) 返回 0 条。所以要逐 token 检查,任一 token 太短就整体走 LIKE。

另有一条数据可见性规则(hermes_state_search.py:1744-1749):被用户 rewind 撤回的行 (active=0, compacted=0)默认不可见;被压缩归档的行(active=0, compacted=1) 默认可见——它们只是从活上下文里被摘要掉了,但仍是这段对话的记录。

7.3 排序纪律:两道去噪

原始 BM25 结果直接返回是不能用的,_discover 上了两道处理。

第一道:cron 降权。 _order_for_recall(tools/session_search_tool.py:281)做一次 稳定排序,把 source 属于 _DEMOTED_SESSION_SOURCES(目前只有 cron,:50)的行 整体沉底,类内保持原 BM25 顺序。

原因写在常量注释里(:42-49,issue #19434):定时任务按计划反复跑,积累大量重复词汇 (项目名、日期、"session"、各种总结),裸 BM25 下它们会霸占 top-N,把用户自己的会话 饿死——作者管这叫 "recall blindness"。注意是降权不是排除:cron 内容在它是唯一 匹配时仍然捞得到。

配套的一个数字:_DISCOVER_SCAN_LIMIT = 300(:56)。扫描量必须远大于最终返回条数, 否则埋在一堵 cron 墙下面的交互会话根本进不了手里,降权也就无从谈起。

第二道:按世系去重。 一个逻辑对话经过压缩会分裂成多条 session 行(父→子)。 _resolve_to_parent(:84)沿 parent_session_id 链一路走到根,同一世系只保留 一条命中(:555-570)。当前会话所在的世系直接跳过——那些消息已经在上下文里了。

同样的道理,scroll 也拒绝滚到当前世系里(:333-342)。

默认排序是纯 BM25 相关度,时间中立;传 sort="newest"/"oldest" 才让时间戳当主键、 rank 当 tiebreaker(hermes_state_search.py:1788-1793)。

_HIDDEN_SESSION_SOURCES(:40)则是硬排除:subagenttool 标记的会话不属于 用户的历史,永远不出现。


8. 巧妙之处(可以直接抄走的)

  1. fork 一个受限的自己去复盘,而不是在主对话里插话。 主对话与 prompt cache 零污染, 用户等待时间零增加。代价是十几处显式的"别继承这个"设置——但每一处都对应一个真实事故 (agent/background_review.py:1187-1282)。

  2. 缓存经济学的反直觉结论。 同模型全量重放摘要重放便宜,因为前者是缓存读、 后者是冷写。只有当路由到别的模型(缓存必冷)时,摘要才是纯赚 (agent/background_review.py:186-198_digest_history:111)。

  3. schema 里给全,派发时限死。 tools[] 保持与父逐字节相同以命中缓存,同时用 线程级白名单在派发层拒绝(agent/background_review.py:1319-1339)。缓存和权限 两个目标本来冲突,这个双层做法把它们解耦了。

  4. 一个 ContextVar 定义所有权。 is_background_review() 一个布尔量,就把 "自动化程序永远不许碰用户的东西"这条不变量贯穿到技能创建、编辑、删除、策展全链 (tools/skill_provenance.py:75)。

  5. 自主行为比用户在场时更严。 前台 pin 只挡删除,复盘 fork 连改都不许——因为 "没有用户在回路里为这次编辑背书"(tools/skill_manager_tool.py:306-312)。

  6. use=0 既不是价值证据,也不是无价值证据。 策展 prompt 明写这一条 (agent/curator.py:467-474),并在确定性剪枝里落地成"从没用过的技能至少活满 stale 窗口"的宽限地板(:359-363)。新技能可能只是触发条件还没来。

  7. 名字冲突时报错,不猜。 skill_view 发现多个候选就列出全部路径拒绝执行 (tools/skills_tool.py:1355-1377)。静默遮蔽是最难查的一类 bug。

  8. 删除时当场声明去向。 absorbed_into=<伞名> 参数让"合并"和"剪枝"在删除那一刻 就被区分开,比事后从工具调用里猜可靠得多(_extract_absorbed_into_declarations, agent/curator.py:835)。

  9. 冻结快照换缓存命中。 记忆写盘即持久,但系统提示到下个会话才更新 (tools/memory_tool.py:706)。用一个会话的延迟换整段会话的前缀缓存。

  10. 投毒条目在快照里屏蔽,在活状态里保留。 用户能看见并删掉它,而不是被静默隐瞒 (tools/memory_tool.py:237-242)。

  11. 降权而非排除。 cron 会话沉底但不消失,唯一匹配时仍能捞到 (tools/session_search_tool.py:281)。同一个思路也用在技能索引的 compact_categories 上——只丢描述,名字永远可见 (agent/prompt_builder.py:1848-1853)。

  12. 把"作者标准"写成 prompt,而不是写成引擎。 /learn 没有独立的蒸馏器,agent 用 自己现成的工具照着规范写(agent/learn_prompt.py:22-26)——于是它在本地、Docker、 远程后端上行为完全一致。


9. 边界与局限

诚实地列一下这个闭环做不到什么:

局限说明依据
学习是尽力而为fork 失败、模型出错、白名单拒绝,全部吞掉只记 warning;这一轮的教训就丢了agent/turn_finalizer.py:803-804background_review.py:1442-1446
记忆容量很小2200 + 1375 字符,满了必须先合并再写tools/memory_tool.py:176-179:331-344
记忆延迟一个会话生效冻结快照的直接代价tools/memory_tool.py:706
合并质量取决于模型consolidation 是 LLM 判断,所以默认关闭,并配了 dry-run、快照、三路对账兜底agent/curator.py:78:1536
只能挂一个外部记忆 provider硬限制,不是配置项agent/memory_manager.py:413-425
短 CJK 查询退化孤立单字 CJK 词项(以及 trigram 不可用时的 1-2 字查询)走 LIKE,没有排名也没有片段高亮hermes_state_search.py:1848-1853:1873-1878
技能安全是正则静态分析不是沙箱、不是符号执行;community 源采取"任何 finding 都拦"的保守姿态兜底tools/skills_guard.py:55-65
策展只碰 agent-created用户手写的技能、hub 装的、外部目录的,永远不自动整理——好处是安全,代价是这些得手动管agent/curator.py:15-19

还有一条不算局限但值得警觉:复盘 prompt 明确说"什么都不存不该是默认" (agent/background_review.py:531-534)。这是一个偏向多写的设定,配合上面那份 "禁止捕获清单"才安全。如果去掉那份清单,agent 很容易把一次性的环境故障固化成永久的 自我限制。


10. 代码地图(导航索引)

主题文件关键符号
复盘线程与 fork 构造agent/background_review.pyspawn_background_review_thread_run_review_in_thread
复盘模型路由与摘要agent/background_review.py_resolve_review_runtime_digest_history
复盘 prompt 三件套agent/background_review.py_MEMORY_REVIEW_PROMPT_SKILL_REVIEW_PROMPT_COMBINED_REVIEW_PROMPT
复盘结果汇总agent/background_review.pysummarize_background_review_actionsbuild_memory_write_metadata
复盘触发判定agent/turn_context.pyagent/turn_finalizer.py_turns_since_memory_iters_since_skill
技能发现与列表tools/skills_tool.py_find_all_skillsskills_list_sort_skills
技能加载与就绪tools/skills_tool.pyskill_viewSkillReadinessStatus_skill_view_with_bump
平台/环境匹配tools/skills_tool.pyagent/skill_utils.pyskill_matches_platformskill_matches_environment
缺失环境变量捕获tools/skills_tool.py_capture_required_environment_variables_is_gateway_surfaceset_secret_capture_callback
技能写入与保护闸tools/skill_manager_tool.pyskill_manage_pinned_guard_background_review_write_guard_curator_consolidation_delete_guard
写入来源 provenancetools/skill_provenance.pyis_background_reviewset_current_write_origin
使用计数与生命周期tools/skill_usage.pybump_uselatest_activity_atarchive_skillis_curation_eligiblePROTECTED_BUILTIN_SKILLS
外部技能源tools/skills_hub.pycreate_source_routerGitHubSourceSkillsShSourceClawHubSourceClaudeMarketplaceSourcequarantine_bundleinstall_from_quarantine
技能安全扫描tools/skills_guard.pyscan_skillshould_allow_installTRUSTED_REPOSINSTALL_POLICY
/learn 作者标准agent/learn_prompt.pybuild_learn_prompt_AUTHORING_STANDARDS
斜杠命令与 bundleagent/skill_commands.pyagent/skill_bundles.pybuild_skill_invocation_messagebuild_preloaded_skills_promptget_skill_bundlesbuild_bundle_invocation_message
技能索引进系统提示agent/prompt_builder.pyagent/skill_utils.pybuild_skills_system_promptextract_skill_description
策展调度与状态机agent/curator.pyshould_run_nowmaybe_run_curatorapply_automatic_transitions
策展主流程与 forkagent/curator.pyrun_curator_review_run_llm_reviewCURATOR_REVIEW_PROMPT
合并/剪枝分类与报告agent/curator.py_classify_removed_skills_reconcile_classification_write_run_report
记忆存储tools/memory_tool.pyMemoryStoreload_from_diskformat_for_system_promptENTRY_DELIMITER
记忆编排与围栏agent/memory_manager.pyMemoryManagerinject_memory_provider_toolsStreamingContextScrubberbuild_memory_context_block
记忆 provider 抽象agent/memory_provider.pyplugins/memory/MemoryProvider
跨会话检索tools/session_search_tool.pysession_search_discover_scroll_order_for_recall_resolve_to_parent
FTS5 索引与查询hermes_state.pyFTS_SQLFTS_TRIGRAM_SQLSessionDB.search_messagesget_anchored_view

11. 接着读哪一章