nanobot源码学习笔记1

为什么学这个

最近我在系统地学习AI底层,transformer学完了之后我想往更”应用层”靠近一点,理解AI Agent框架是怎么构建的。现有的框架太多了,LangChain、AutoGPT之类的太重,代码太杂,读起来头皮发麻。nanobot这个项目吸引我的地方在于它足够轻量——整个项目就几千行Python,没有乱七八糟的抽象层,却把一个完整的AI agent所需要的东西都实现了:多渠道接入、工具调用、记忆系统、定时任务……

所以我打算系统地走读一遍它的源码。这是第一篇,先把整体结构搞清楚,然后重点把记忆系统看透。

几个有用的链接:

项目结构

先看整体目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
nanobot/
├── agent/ 核心处理逻辑(重点)
│ ├── loop.py AgentLoop:消息调度总控
│ ├── runner.py AgentRunner:单次LLM调用循环
│ ├── memory.py 记忆系统:MemoryStore / Consolidator / Dream
│ ├── context.py 上下文构建:System Prompt组装
│ └── skills.py skill加载
├── cli/
│ └── commands.py CLI命令入口
├── providers/ LLM provider抽象层(Anthropic / OpenAI / …)
├── config/
│ └── schema.py Pydantic配置定义
├── session/ 会话持久化
├── channels/ 多渠道接入(Telegram / 钉钉 / Slack / …)
└── templates/ 各种prompt模板(.md文件)

依赖管理在pyproject.toml里,CLI入口是:

1
2
[project.scripts]
nanobot = "nanobot.cli.commands:app"

也就是说安装完之后,nanobot这个命令就指向commands.py里的app(typer应用)。

配置文件

nanobot用JSON格式的配置文件。默认路径是~/.nanobot/config.json,结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5",
"contextWindowTokens": 65536,
"maxToolIterations": 200,
"timezone": "Asia/Shanghai",
"dream": {
"intervalH": 2,
"maxBatchSize": 20,
"maxIterations": 10
}
}
},
"providers": {
"anthropic": {
"apiKey": "sk-ant-xxx"
}
}
}

配置的Python定义在nanobot/config/schema.py,用的是Pydantic v2。有几个值得关注的参数:

  • contextWindowTokens:context window大小,默认65536 tokens,用于触发记忆压缩
  • maxToolIterations:一次对话最多允许agent循环调用工具多少轮,默认200
  • dream.intervalH:Dream任务的执行间隔(小时),默认2小时
  • timezone:时区,这个会注入到每次请求的system prompt里

CLI启动过程:nanobot agent -m

最常用的启动方式是nanobot agent -m "你好",加-m表示单条消息模式,不加就是交互式终端。

下图是完整的调用链:

CLI启动流程

入口:def agent()

代码在nanobot/cli/commands.pyagent()函数,大概做了这几件事:

① 加载配置

1
2
config = _load_runtime_config(config, workspace)
sync_workspace_templates(config.workspace_path)

sync_workspace_templates会把一些初始模板文件(SOUL.mdUSER.mdAGENTS.md等)同步到workspace目录。如果用户还没有这些文件,就复制过去;如果已经有了,就保留用户的版本。

② 初始化AgentLoop

1
2
3
4
5
6
7
agent_loop = AgentLoop(
bus=bus,
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
...
)

AgentLoop是整个系统的核心。它的__init__里会创建ContextBuilder(负责组装prompt)、Consolidator(负责压缩历史)、Dream(负责更新长期记忆),以及注册默认工具(读写文件、执行命令、网络搜索等)。

③ 单条消息模式

1
2
3
4
5
response = await agent_loop.process_direct(
message, session_id,
on_progress=_cli_progress,
on_stream=renderer.on_delta,
)

process_direct_process_message

process_direct把字符串包装成InboundMessage对象,然后调用_process_message_process_message才是真正的处理核心:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 1. 加载或新建session
session = self.sessions.get_or_create(key)

# 2. 恢复中断的任务(进程被杀时的容错)
self._restore_runtime_checkpoint(session)

# 3. 检查是否需要压缩上下文(核心记忆操作之一)
await self.consolidator.maybe_consolidate_by_tokens(session)

# 4. 组装消息列表
initial_messages = self.context.build_messages(
history=history,
current_message=msg.content,
...
)

# 5. 提前持久化用户消息(防止进程crash导致丢失)
session.add_message("user", msg.content)
self.sessions.save(session)

# 6. 进入agent loop
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(...)

# 7. 保存本轮对话 + 触发后台压缩
self._save_turn(session, all_msgs, save_skip)
self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session))

第5步我觉得设计很细心——它在进入LLM循环之前就把用户消息写入文件。这是为了防止进程在中途被OOM-kill或者agent自重启时,用户的那句话彻底丢失。第7步的_schedule_background是把压缩操作扔到后台异步执行,不阻塞响应。

workspace中间文件

运行过一段时间后,~/.nanobot/workspace目录里会出现很多文件,下图列出了它们的结构、用途以及读写关系:

workspace文件结构

挨个看一下:

AGENTS.md — 给agent的工具使用规范,比如”用cron工具创建定时提醒,不要直接写到MEMORY.md里”。这个文件会被直接注入到每次请求的system prompt里,属于”行为约束”文件。

SOUL.md — agent的”人格设定”,默认内容就是:

1
2
3
I am nanobot 🐈, a personal AI assistant.
I solve problems by doing, not by describing what I would do.
I keep responses short unless depth is asked for.

可以自由修改。Dream进程会根据对话内容逐渐更新这个文件。

USER.md — 关于用户的稳定知识,初始是个模板,时区、名字、偏好等。Dream会往里面填信息。

TOOLS.md — 工具使用的补充说明,比如exec命令的超时限制、glob的使用建议等。

memory/history.jsonl — 中期记忆的核心,每行是一条JSON:

1
{"cursor": 42, "timestamp": "2026-04-03 00:02", "content": "- User asks about nanobot memory design\n- Discussed Consolidator and Dream flow"}

注意是cursor + timestamp + content的结构,不是原始对话,而是LLM摘要

memory/.cursor — 纯文本文件,存一个整数,是history.jsonl的写游标。Consolidator每次追加新条目时递增这个值,用于生成下一条的cursor ID。

memory/.dream_cursor — 同样是纯文本整数,是Dream的读游标。Dream每次处理完一批history.jsonl条目后,把最后一条的cursor写进来,下次从这里往后读,避免重复处理。这个值也被build_system_prompt拿来判断”哪些历史条目还没被Dream消化,需要注入Recent History”。

memory/MEMORY.md — 长期记忆,存放项目相关的持久化事实,由Dream写入和更新。

memory/.git/ — Dream每次修改SOUL.mdUSER.mdMEMORY.md后,会用GitStore做一次自动commit,这三个文件的变更历史都在这里。支持/dream-log查看、/dream-restore回滚。

session文件详解

sessions/cli_direct.jsonl是短期记忆,但值得单独讲一下它的格式,因为不只是消息列表那么简单。

第一行是metadata行,不是消息:

1
2
3
4
5
6
7
8
{
"_type": "metadata",
"key": "cli:direct",
"created_at": "2026-04-14T20:49:51.526861",
"updated_at": "2026-04-15T15:48:39.037309",
"metadata": {},
"last_consolidated": 0
}

这里有几个重要字段:

  • key:session的唯一标识,格式是channel:chat_id,多渠道下各自独立(cli:directtelegram:12345discord:67890各有各的文件)
  • last_consolidated:这个整数很关键——它记录的是messages数组里有多少条已经被Consolidator摘要走了get_history()只返回messages[last_consolidated:],确保已压缩的历史不会重复喂给LLM
  • metadata:临时运行时状态,比如进程中途被杀时,这里会存runtime_checkpoint(记录当前工具调用进度)和pending_user_turn(标记用户消息已写入但LLM还没回复),重启后用来恢复中断的任务

后续每行是一条消息,完整保留所有字段:

1
2
3
{"role": "user", "content": "你用的大模型是哪家的?", "timestamp": "2026-04-15T14:58:32.117159"}
{"role": "assistant", "content": "...", "tool_calls": [...], "timestamp": "..."}
{"role": "tool", "tool_call_id": "call_xxx", "name": "read_file", "content": "...", "timestamp": "..."}

重启时,SessionManager._load()会读第一行恢复last_consolidated和时间戳,后续行恢复完整的消息列表,进程重启后可以无缝续接之前的对话上下文。

记忆系统设计

记忆系统是nanobot最有意思的部分,文档里把它分成三层:

记忆系统架构

短记忆:当前session的完整消息列表,存在sessions/目录下。每次对话时,这些历史消息会直接放进LLM的messages数组里参与推理,上下文越长越消耗token。

中记忆memory/history.jsonl,append-only的历史摘要文件。当短记忆太长(超过context window阈值)时,Consolidator会把最老的一段对话用LLM做摘要,然后追加到这里。

长记忆SOUL.mdUSER.mdmemory/MEMORY.md,这三个文件存放的是真正持久化的知识。由Dream定期(默认每2小时)从history.jsonl里提炼,用外科手术式的方式更新这些文件(不是整体重写,而是精确修改)。

build_system_prompt — system prompt怎么组装的

每次请求前,ContextBuilder.build_system_prompt()会把以下内容拼成一个system prompt,用---分隔:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
parts = [self._get_identity(channel=channel)]  # runtime信息

bootstrap = self._load_bootstrap_files() # AGENTS.md + SOUL.md + USER.md + TOOLS.md
# 每个文件用 ## {filename} 包裹后直接拼接文件内容
# 文件内部如果有自己的 # 一级标题(如 TOOLS.md 里的 "# Tool Usage Notes"),
# 就会出现二级包裹 + 内部一级标题的"层级倒置"现象,但这对LLM无影响
if bootstrap:
parts.append(bootstrap)

memory = self.memory.get_memory_context() # MEMORY.md(若非空且非模板)
if memory:
parts.append(f"# Memory\n\n{memory}")

always_skills = self.skills.get_always_skills() # 总是激活的skill
if always_skills:
always_content = self.skills.load_skills_for_context(always_skills)
parts.append(f"# Active Skills\n\n{always_content}")

skills_summary = self.skills.build_skills_summary(exclude=set(always_skills))
if skills_summary:
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
# ↑ skills_section.md 模板内容:
# "The following skills extend your capabilities. To use a skill, read its SKILL.md file."
# {{ skills_summary }}

# Recent History:history.jsonl中未被Dream处理的条目
entries = self.memory.read_unprocessed_history(since_cursor=self.memory.get_last_dream_cursor())
if entries:
capped = entries[-50:] # 最多50条
parts.append("# Recent History\n\n" + "\n".join(
f"- [{e['timestamp']}] {e['content']}" for e in capped
))

return "\n\n---\n\n".join(parts)

这里有个细节:Recent History注入的是.dream_cursor之后的history.jsonl条目——那些已经被Consolidator摘要但还没被Dream”消化”进长记忆的中期记忆内容。这样可以保证中期记忆的内容不丢,哪怕Dream还没来得及跑。

Active Skills是怎么来的

system prompt里的Active Skills部分,来自frontmatter里标了always: true的skill。以内置的memory skill为例,它的SKILL.md开头长这样:

1
2
3
4
5
---
name: memory
description: Two-layer memory system with Dream-managed knowledge files.
always: true
---

get_always_skills()就是扫描所有skill的frontmatter,把有always: true且依赖满足的挑出来,内容完整加载进system prompt。对比一下weather skill的frontmatter:

1
2
3
4
5
---
name: weather
description: Get current weather and forecasts (no API key required).
metadata: {"nanobot":{"requires":{"bins":["curl"]}}}
---

weather没有always: true,而且依赖curl命令,所以它不会被自动加载全文,只会出现在Skills目录列表里,等模型需要时自己去读。

这就是渐进式加载(Progressive Loading)的核心设计:

1
2
3
4
5
system prompt里的 # Skills 节:
- **memory** — Two-layer memory system... [全文已加载,在 # Active Skills]
- **cron** — Schedule reminders... `/path/to/cron/SKILL.md` ← 只有路径,没有内容
- **weather** — Get current weather... `/path/to/weather/SKILL.md` ← 只有路径,没有内容
- **github** — GitHub CLI... (unavailable: CLI: gh) ← 依赖缺失

模型看到cron skill的路径后,如果判断当前任务需要用到定时功能,就会自己调read_file去读那个路径的内容,拿到完整的使用说明。这样不需要把所有skill的全文都塞进system prompt,节省大量token。

最终prompt长什么样

光看代码可能不直观,来看一个接近真实的完整例子。假设是CLI渠道,用户名Corgis,已有部分会话历史被压缩到history.jsonl,则实际发给LLM的请求由三部分组成:

① system消息(build_system_prompt组装)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# nanobot 🐈

You are nanobot, a helpful AI assistant.

## Runtime
macOS arm64, Python 3.12.3

## Workspace
Your workspace is at: /Users/corgis/.nanobot/workspace
- Long-term memory: /Users/corgis/.nanobot/workspace/memory/MEMORY.md
- History log: /Users/corgis/.nanobot/workspace/memory/history.jsonl

## Platform Policy (POSIX)
- You are running on a POSIX system. Prefer UTF-8 and standard shell tools.

## Format Hint
Output is rendered in a terminal. Avoid markdown headings and tables.

## Execution Rules
- Act, don't narrate. If you can do it with a tool, do it now...

---

## AGENTS.md

# Agent Instructions
## Scheduled Reminders
Use the built-in `cron` tool to create/list/remove jobs...

## SOUL.md

# Soul
I am nanobot 🐈, a personal AI assistant.
I solve problems by doing, not by describing what I would do.
I keep responses short unless depth is asked for.

## USER.md

# User Profile
- **Name**: Corgis
- **Timezone**: Asia/Shanghai (UTC+08:00)
- **Language**: 简体中文

## TOOLS.md

# Tool Usage Notes
Tool signatures are provided automatically via function calling.
This file documents non-obvious constraints and usage patterns.

## exec — Safety Limits
- Commands have a configurable timeout (default 60s)
- Dangerous commands are blocked (rm -rf, format, dd, etc.)
- Output is truncated at 10,000 characters

## grep — Content Search
- Default behavior returns only matching file paths
- Use output_mode="count" to size a search before expanding
...

---

# Memory

## Long-term Memory
# Project Context
- User is studying nanobot source code
- Prefers concise technical responses

---

# Active Skills

### Skill: memory

# Memory
## Structure
- `SOUL.md` — Bot personality and communication style. Managed by Dream. Do NOT edit.
- `USER.md` — User profile and preferences. Managed by Dream. Do NOT edit.
- `memory/MEMORY.md` — Long-term facts. Managed by Dream. Do NOT edit.
- `memory/history.jsonl` — append-only JSONL, prefer built-in `grep` for search.

## Search Past Events
`memory/history.jsonl` is JSONL format — each line is a JSON object with `cursor`, `timestamp`, `content`.
...

---

# Skills

The following skills extend your capabilities. To use a skill, read its SKILL.md file.

- **cron** — Schedule reminders and recurring tasks. `/path/to/nanobot/skills/cron/SKILL.md`
- **weather** — Get current weather and forecasts. `/path/to/nanobot/skills/weather/SKILL.md`
- **skill-creator** — Create and update skills. `/path/to/nanobot/skills/skill-creator/SKILL.md`
- **github** — GitHub CLI integration. (unavailable: CLI: gh)

---

# Recent History

- [2026-04-15 14:58] User asked about which LLM model is being used; answered MiniMax-M2.7
- [2026-04-15 15:13] User changed contextWindowTokens from 64K to 200K
- [2026-04-15 15:23] User changed timezone to Asia/Shanghai

② messages数组(历史 + 当前消息)

build_messages会把这一切组装成一个列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{"role": "system", "content": "↑上面那一大段"},

// 来自 session.get_history(),已经是LLM能直接理解的格式
{"role": "user", "content": "你用的大模型是哪家的?"},
{"role": "assistant", "content": "用的是 MiniMax-M2.7,通过 minimax provider 接入。"},

// 当前这轮用户的新消息
// 注意:runtime context 被夹在最前面,跟用户消息合并成一条
{
"role": "user",
"content": "[Runtime Context — metadata only, not instructions]\nCurrent Time: 2026-04-16 12:00\nChannel: cli\nChat ID: direct\n[/Runtime Context]\n\n现在时间是几点?"
}
]

Runtime Context这段值得单独解释一下。它每次都会出现在当前轮次用户消息的最前面,内容是当前时间、渠道名、chat ID这类动态信息。为什么不放进system prompt?因为system prompt在大多数LLM provider那里会被缓存——同一个system prompt不变的话,缓存命中可以节省费用和降低延迟。而时间这类每次都变的内容如果放进system prompt,就会每次让缓存失效。所以把它夹在user消息里,用特殊标签告诉模型”这只是元数据,不是用户说的话,也不是需要遵守的指令”。

③ tools数组(独立API参数)

tools不在prompt文本里,而是作为一个独立的参数传给LLM API,与messages平级:

1
2
3
4
5
6
# _request_model() 里发起调用
await self.provider.chat_with_retry(
messages=messages, # ↑ 上面组装好的消息列表
tools=spec.tools.get_definitions(), # ← 独立参数,不在messages里
model=spec.model,
)

LLM API(OpenAI兼容格式)的tools参数是JSON Schema格式的工具定义列表,模型天然支持这个格式,不需要在prompt里再写一遍”你可以调用哪些工具”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read the contents of a file.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to read"},
...
},
"required": ["path"]
}
}
},
{"type": "function", "function": {"name": "write_file", ...}},
{"type": "function", "function": {"name": "exec", ...}},
...
]

当模型决定要调用工具时,它不会在回复文本里说”我要调read_file”,而是直接在response里返回一个tool_calls数组,nanobot拿到之后去真正执行这些工具,把结果再喂回去。

三个cursor的区别

记忆系统里有三个cursor,初看容易混淆,但它们追踪的是完全不同维度的东西:

cursor 存放位置 追踪对象 谁维护
memory/.cursor memory/目录下的独立文件 history.jsonl里下一条要写入的全局ID Consolidator(追加时递增)
memory/.dream_cursor memory/目录下的独立文件 history.jsonl里Dream已消化到的位置 Dream(每次run后推进)
last_consolidated session文件第一行metadata session.messages数组里已被摘走的消息数 Consolidator(压缩后更新)

前两个是全局级别的,跟history.jsonl这个文件绑定,所有session共用一份。第三个是per-session的——系统可能同时有cli:directtelegram:12345discord:67890多个会话,每个会话消息被压缩的进度各不相同,所以last_consolidated必须跟各自的session文件存在一起。

Consolidator — 什么时候做,怎么做

Consolidator的入口是maybe_consolidate_by_tokens,在_process_message里被调用两次:一次是处理消息之前,一次是处理完之后(异步后台执行)。

触发逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
budget = context_window_tokens - max_completion_tokens - SAFETY_BUFFER
target = budget // 2

estimated, _ = self.estimate_session_prompt_tokens(session)

if estimated < budget:
return # 还有空间,不压缩

for round_num in range(5): # 最多5轮
if estimated <= target:
return

boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
# 找到一个"user轮次"的边界,划出需要压缩的消息块
chunk = session.messages[session.last_consolidated:end_idx]

await self.archive(chunk) # 用LLM摘要,写入history.jsonl
session.last_consolidated = end_idx # 更新per-session游标

estimated = self.estimate_session_prompt_tokens(session) # 重新估算

几个关键点:

  1. token估算:调用estimate_session_prompt_tokens,它会真的调用build_messages组装一遍prompt,然后用tiktoken估算token数(不实际请求LLM)。
  2. 边界选择pick_consolidation_boundary会找一个user消息开头的位置作为切割点,确保不在assistant或tool消息中间切断,保持语义完整性。
  3. 单次cap_cap_consolidation_boundary限制每轮最多压缩60条消息,防止一次LLM请求带太多内容。
  4. 摘要LLM调用archive()方法会调一次LLM,让它把这段对话摘要成几条要点,然后append_history写入history.jsonl,同时把memory/.cursor递增。

如果LLM摘要调用失败,会退化到直接把原始消息dump进history.jsonlraw_archive),加上[RAW]标记,确保数据不丢。

Dream — 长记忆如何更新

Dream是个两阶段的流程,在Dream.run()里实现。完整流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
history.jsonl (未处理条目)
+
MEMORY.md / SOUL.md / USER.md (当前内容)

Phase 1:分析(纯LLM调用,无工具)

分析结论(结构化标注行)
+
当前文件内容(再次传入)
+
现有 skill 列表(去重用)

Phase 2:执行(AgentRunner + edit_file / write_file)

精确修改 SOUL.md / USER.md / MEMORY.md
或新建 skills/<name>/SKILL.md

更新 .dream_cursor + GitStore auto-commit

Phase 1:分析

先读出.dream_cursor之后未处理的history.jsonl条目(最多max_batch_size条,默认20),加上三个长记忆文件的当前内容,一起构造user消息:

1
2
3
4
5
6
7
8
9
10
history_text = "\n".join(f"[{e['timestamp']}] {e['content']}" for e in batch)

file_context = (
f"## Current Date\n{current_date}\n\n"
f"## Current MEMORY.md ({len(current_memory)} chars)\n{current_memory}\n\n"
f"## Current SOUL.md ({len(current_soul)} chars)\n{current_soul}\n\n"
f"## Current USER.md ({len(current_user)} chars)\n{current_user}"
)

phase1_prompt = f"## Conversation History\n{history_text}\n\n{file_context}"

system消息来自templates/agent/dream_phase1.md,内容是一段简明指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Compare conversation history against current memory files.
Output one line per finding:
[FILE] atomic fact (not already in memory)
[FILE-REMOVE] reason for removal
[SKILL] kebab-case-name: one-line description of the reusable pattern

Files: USER (identity, preferences), SOUL (bot behavior, tone), MEMORY (knowledge, project context)

Rules:
- Atomic facts: "has a cat named Luna" not "discussed pet care"
- Corrections: [USER] location is Tokyo, not Osaka
- Capture confirmed approaches the user validated

Staleness — flag for [FILE-REMOVE]:
- Time-sensitive data older than 14 days: weather, daily status, one-time meetings
- Completed one-time tasks, resolved tracking, superseded approaches

Skill discovery — flag [SKILL] when ALL of these are true:
- A specific, repeatable workflow appeared 2+ times in the conversation history
- It involves clear steps (not vague preferences)
- Substantial enough to warrant its own instruction set

[SKIP] if nothing needs updating.

这次调用不给工具(tools=None),只让模型输出结构化的分析文本。Phase 1的产物是一堆标注行,比如:

1
2
3
4
5
[USER] prefers responses in simplified Chinese
[MEMORY] nanobot project uses MiniMax-M2.7 via minimax provider
[SOUL] user prefers very concise answers, avoid markdown in CLI output
[MEMORY-REMOVE] outdated API endpoint entry from 2026-03-01
[SKILL] query-history: search history.jsonl using grep tool for past conversation context

Phase 2:执行

把Phase 1的分析结论再加上文件当前内容,构造Phase 2的user消息。这次还额外附上已有skill列表用于去重:

1
2
3
4
5
6
existing_skills = self._list_existing_skills()
# → ["cron — Schedule reminders...", "memory — Two-layer memory system...", ...]

skills_section = "\n\n## Existing Skills\n" + "\n".join(f"- {s}" for s in existing_skills)

phase2_prompt = f"## Analysis Result\n{analysis}\n\n{file_context}{skills_section}"

system消息来自templates/agent/dream_phase2.md,核心规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Update memory files based on the analysis below.
- [FILE] entries: add the described content to the appropriate file
- [FILE-REMOVE] entries: delete the corresponding content from memory files
- [SKILL] entries: create a new skill under skills/<name>/SKILL.md using write_file

## Editing rules
- Edit directly — file contents provided below, no read_file needed
- Use exact text as old_text, include surrounding blank lines for unique match
- Batch changes to the same file into one edit_file call
- Surgical edits only — never rewrite entire files
- If nothing to update, stop without calling tools

## Skill creation rules
- Before writing, read skill-creator/SKILL.md for format reference
- Dedup check: read existing skills listed below, skip if already covered
- Include YAML frontmatter with name and description fields

这次用AgentRunner(而不是AgentLoop)驱动,给它三个工具:

  • read_file:读文件(允许读workspace目录 + 内置skills目录,用于skill格式参考)
  • edit_file:精确替换文件中的某段文本(只允许操作workspace目录)
  • write_file:写文件(只允许写入skills/子目录,防止模型乱覆盖主记忆文件)

最多循环10轮工具调用(max_iterations=10)。模型会基于Phase 1的分析,决定调哪些工具、怎么调。典型的执行序列大概是:

1
2
3
4
5
6
→ edit_file(SOUL.md, old_text="...", new_text="...")        # 更新性格设定
→ edit_file(USER.md, old_text="...", new_text="...") # 更新用户偏好
→ edit_file(memory/MEMORY.md, old_text="...", new_text="...") # 更新项目记忆
→ read_file(skills/skill-creator/SKILL.md) # 读格式参考
→ read_file(skills/cron/SKILL.md) # 去重检查
→ write_file(skills/query-history/SKILL.md, content="...") # 新建skill

edit_file做的是精确文本替换——传入old_textnew_text,在文件里找到精确匹配的那段文字然后替换掉,而不是重写整个文件。这就是所谓”外科手术式”更新,用户手动写入的内容完全不受影响。

收尾:cursor推进与git提交

Phase 2结束后无论成功与否,都推进游标(防止Phase 1失败导致同一批history条目被无限重试):

1
2
3
new_cursor = batch[-1]["cursor"]
self.store.set_last_dream_cursor(new_cursor) # 写入 memory/.dream_cursor
self.store.compact_history() # 清理history.jsonl里过旧的条目

如果有实际文件改动(result.tool_events里有status=ok的事件),就用GitStore做一次自动commit:

1
2
3
if changelog and self.store.git.is_initialized():
ts = batch[-1]["timestamp"]
sha = self.store.git.auto_commit(f"dream: {ts}, {len(changelog)} change(s)")

commit message格式是dream: 2026-04-15 15:23, 3 change(s)/dream-log命令就是列这些commit,/dream-restore就是git revert到某个commit。

总结

这篇主要把整体结构和记忆系统弄清楚了。源码层面有几个设计值得学:

  1. 三层记忆分离:短/中/长期记忆各司其职,互相解耦。短记忆用于实时推理,中记忆做LLM摘要缓冲,长记忆存稳定知识。
  2. token-based触发:Consolidator不是按消息数量触发,而是估算真实token数,比简单的”超过N条就压缩”精确很多。
  3. 三个独立cursor:写游标、Dream读游标、per-session压缩位置各自独立,Consolidator和Dream可以完全解耦地运行,互不干扰。
  4. 容错设计:提前持久化用户消息 + runtime_checkpoint机制,防止进程crash丢数据;session文件第一行的metadata确保重启后可以完整恢复对话状态。