目标
s01 到 s08 让智能体在单个会话内变得强大:能调用工具、审批权限、扩展 Hook、规划 Todo、派发子智能体、按需加载技能、自动压缩上下文。但每次启动 cat-code,智能体对项目一无所知——上次会话的结论、用户的偏好、踩过的坑,全部随进程退出而蒸发。更糟的是,s08 的上下文压缩会把旧消息折叠成 [Conversation Summary] 系统消息,而这条摘要也随会话结束一起丢失——压缩保存的上下文只在当次会话内有效。
Index 09 引入跨会话记忆管线,让智能体把"学到的经验"沉淀到磁盘,在下一次会话中"想起来"。对应 docs/main.md 里定义的三阶段:
selection(选择) → 从对话中选出值得长期记住的事实
extraction(提取) → 把候选格式化为结构化记忆条目
consolidation(整合)→ 与已有记忆合并(新增/更新/跳过)完整能力闭环:
- 持久化存储(MemoryStore)—— 记忆以文件形式落在磁盘
~/.cat-code/memory/,重启不丢失 - 三阶段提取(MemoryExtractor + MemoryConsolidator)—— 会话结束时从对话中提炼并整合
- 会话开始注入(MemoryInjectionHook)—— 下次启动时把记忆索引喂给 LLM,让它"想起来"
- 主动读写工具(memory_read / memory_write)—— 会话中 LLM 可随时保存或查询记忆
- 手动控制(/memory 命令)—— 随时触发提取管线、查看记忆库
完成后的效果——第二次会话的智能体站在第一次会话的肩膀上:
$ cat-code
🐱 Cat-Code — Index 07: Skill Loading · s08: Context Compact · s09: Memory
> 继续昨天的重构,把 UserService 也改成 Kotlin 风格
[Agent] 好的,我记得昨天我们定了两个约束:项目遵循 Kotlin 官方惯例(project-api-style),
以及你偏好写操作先过权限审批(user-prefers-safe-writes)。让我先看一下 UserService...为什么需要
现实问题
每次启动 REPL,智能体是一张白纸。用户被迫反复解释同样的事情:
用户偏好类:
- "用 vim 编辑"、"不要动 tests 目录"、"写操作需要我确认"——每个新会话都要重新说一遍
- 智能体每次都可能踩同样的坑(比如在用户明确拒绝过的目录里乱写)
项目决策类:
- "采用 Kotlin DSL"、"测试用 Kotest"、"API 风格遵循官方惯例"——上次讨论定下的结论,下次智能体不知道
- 同一个决策被反复重新讨论,浪费用户时间
技术经验类:
- "Anthropic API 的 system 字段是顶层参数"、"s07 的手写 YAML 解析不支持嵌套"——踩过的坑沉淀不下来
- 长会话被 s08 压缩后,这些经验即便提取成了摘要,也随会话结束丢失
本质上,对话是易失的,但经验是应该累积的。Claude Code 靠 CLAUDE.md 文件和记忆目录实现跨会话连续性;s09 为 cat-code 复刻这套机制。
设计原则
- 镜像 Claude Code 真实记忆机制——记忆目录 + MEMORY.md 索引 + 每记忆一个 markdown 文件,人类可读可编辑(用户自己就能改)
- 读取路径渐进式加载——只注入 name+description 索引,LLM 需要详情时调
memory_read(与 s07 skill 的"索引注入 + 工具取详情"完全一致,省 token) - 写入路径 fail-open——提取失败不阻断会话退出,解析失败丢弃该条,损坏文件跳过
- LLM 驱动语义判断——"什么值得记住"是语义判断,交给 LLM(selection + extraction 一次调用);"候选如何合并"同样是语义判断,也交给 LLM(consolidation 一次调用)
- 严格迭代——不做记忆过期、重要性排名、双向链接、按项目隔离;这些留到后续 Index
核心设计与实现
架构全景
┌─────────────────────────────────────────────┐
│ ~/.cat-code/memory/ │
│ MEMORY.md(索引,每行一个链接) │
│ user-prefers-vim.md(每记忆一文件) │
│ project-api-style.md │
└───────────────┬─────────────────────────────┘
│
┌───────────────────────────┼──────────────────────────────┐
│ │ │
会话开始(读路径) 会话中(工具) 会话结束(写路径)
│ │ │
MemoryInjectionHook memory_read / memory_write MemoryManager
(PRE_LLM_REQUEST) (ToolRegistry) extractAndConsolidate()
│ │ │
注入 name+description 按 name 读详情 / 写记忆 ├─ MemoryExtractor
索引 SYSTEM 消息 (写经 writeEntry 单一写路径) │ (selection+extraction,
│ │ │ 一次 LLM 调用 → 候选)
│ │ ├─ MemoryConsolidator
│ │ │ (consolidation,
│ │ │ 一次 LLM 调用 → 操作)
│ │ └─ MemoryStore.apply()模块依赖(memory 包,单向):memory → llm(提取/整合)、memory → tool(工具)、memory → hooks(注入 Hook)、repl → memory(组装)。agent 包不依赖 memory,保持可插拔。
领域模型 MemoryModels
一条记忆的核心是 MemoryEntry——name 是 kebab-case slug,既是唯一 key 也是文件名:
enum class MemoryType { USER, FEEDBACK, PROJECT, REFERENCE }
data class MemoryEntry(
val name: String, // kebab-case slug,唯一 key,文件名 = "$name.md"
val description: String, // 一句话摘要(用于索引注入,省 token)
val type: MemoryType, // 对齐 Claude Code 的 type 分类
val content: String, // 记忆正文
val createdAt: LocalDate = LocalDate.now(),
val updatedAt: LocalDate = LocalDate.now()
)
data class MemorySummary(val name: String, val description: String) // 提取器参考用,不含正文
data class CandidateMemory(val type: MemoryType, val name: String, val description: String, val content: String)
enum class MemoryAction { ADD, UPDATE, SKIP }
data class MemoryOperation(
val action: MemoryAction,
val targetName: String? = null, // UPDATE 时的目标条目名
val entry: CandidateMemory? = null
)
data class ConsolidationReport(
val candidatesFound: Int,
val added: List<String>, updated: List<String>, skipped: List<String>,
val skippedReason: String = ""
)三个模型贯穿整条管线,每一层只消费/产出自己需要的形状:
- 提取层输入
Message,产出CandidateMemory - 整合层输入
CandidateMemory + MemoryEntry,产出MemoryOperation - 存储层消费
MemoryOperation,产出AppliedCounts
存储层 MemoryStore
记忆的物理格式镜像 Claude Code 真实机制——每个记忆一个 .md 文件,frontmatter 存元数据,正文存事实:
---
name: user-prefers-vim
description: 用户偏好 vim 编辑器
metadata:
type: user
createdAt: 2026-08-06
updatedAt: 2026-08-06
---
用户偏好 vim 作为编辑器,配置在 ~/.vimrc。MemoryFrontmatter 手写解析(与 s07 SkillFrontmatter 同模式,零依赖)——支持顶层 key: value 加 metadata: 缩进子键。文件必须以 --- 开头、以第二个 --- 收尾,缺失 name / 非法 type / 非法日期抛 MemoryParseException。
MemoryStore 维护内存缓存(构造时从磁盘加载),所有写操作同时更新缓存与磁盘文件,并重建索引:
class MemoryStore(private val dir: Path) {
init { Files.createDirectories(dir); reloadFromDisk() }
fun loadAll(): List<MemoryEntry> // 快照副本,按 name 排序
fun save(entry: MemoryEntry) // 新增(同名替换)
fun update(entry: MemoryEntry) // 按 name 更新,保留 createdAt
fun delete(name: String): Boolean // 删除
fun apply(operations: List<MemoryOperation>): AppliedCounts // 整合产物批量应用
}update 保留 createdAt 是关键细节——一条记忆被反复更新,创建时间不丢失,只有 updatedAt 前进:
fun update(entry: MemoryEntry) {
val old = entries.firstOrNull { it.name == entry.name }
val merged = if (old != null) entry.copy(createdAt = old.createdAt) else entry
entries.removeAll { it.name == merged.name }
entries.add(merged)
writeEntryFile(merged)
rebuildIndex()
}apply 是整合管线的落点,三种动作各有语义:
fun apply(operations: List<MemoryOperation>): AppliedCounts {
var added = 0; var updated = 0; var skipped = 0
for (op in operations) {
when (op.action) {
MemoryAction.ADD -> {
if (op.entry != null) { save(op.entry.toEntry()); added++ }
else skipped++ // ADD 但无内容 → 计为跳过,不撒谎
}
MemoryAction.UPDATE -> {
val target = op.targetName; val candidate = op.entry
if (target != null && candidate != null) { update(candidate.toEntry(nameOverride = target)); updated++ }
else skipped++
}
MemoryAction.SKIP -> skipped++
}
}
return AppliedCounts(added, updated, skipped)
}安全细节:
validateName——entry.name来自 LLM 提取,不可完全信任。最终审查发现含/、\、../的名字可写出记忆目录(如../../.ssh/authorized_keys)。writeEntryFile与delete入口先校验:
private fun validateName(name: String) {
require(name.isNotBlank()) { "Memory name must not be blank" }
require('/' !in name && '\\' !in name) { "Memory name must not contain path separators" }
require(name != ".." && !name.startsWith("../") && !name.startsWith("..\\")) { "Memory name must not traverse directories" }
}索引 MEMORY.md 每次变更后全量重写(简单可靠),格式是人类可读的 markdown 链接:
# Memory Index
- [project-api-style](project-api-style.md) — 项目 API 风格遵循 Kotlin 官方惯例
- [user-prefers-vim](user-prefers-vim.md) — 用户偏好 vim 编辑器提取层 MemoryExtractor(selection + extraction)
"什么值得记住"是语义判断——提取器把一次 LLM 调用同时完成选择和格式化:
class MemoryExtractor(
private val llmProvider: LLMProvider,
private val config: MemoryConfig = MemoryConfig()
) {
suspend fun extract(
messages: List<Message>, // watermark 之后的新消息窗口
existingSummaries: List<MemorySummary> // 已有记忆 name+description,防重复提取
): List<CandidateMemory> {
if (messages.isEmpty()) return emptyList() // 空窗口短路,省一次 LLM 调用
val prompt = buildExtractPrompt(messages, existingSummaries)
val response = llmProvider.chat(
messages = listOf(Message(Role.USER, prompt)),
options = ChatOptions(maxTokens = config.extractMaxTokens, temperature = 0.3),
tools = emptyList()
)
return parseCandidates(response.content)
}
}Prompt 结构(buildExtractPrompt)——指令 + 已知记忆 + 消息窗口三部分:
You are a memory extractor for an AI coding agent. Review the conversation below and
identify facts worth remembering across sessions. Remember:
- User preferences and habits
- Project decisions and constraints
- Key technical facts learned (APIs, file layout, gotchas)
- Anything the user explicitly asked to remember
Ignore: one-off instructions, temporary details, small talk.
If the conversation contains a "[Conversation Summary]" system message, extract from it too.
Do NOT re-extract facts already listed under "## Already Known", and ignore the "# Persistent Memory" index block.
Respond with ONLY a JSON object, no markdown:
{"candidates": [{"type": "user|feedback|project|reference", "name": "kebab-case-slug",
"description": "one-line summary", "content": "the fact to remember"}]}
If nothing is worth remembering, respond with {"candidates": []}
## Already Known
- project-api-style: 项目 API 风格遵循 Kotlin 官方惯例
## Conversation
USER: 继续昨天的重构,把 UserService 也改成 Kotlin 风格
ASSISTANT: 好的,让我先看一下...
[Tool Calls: read_file]
[Tool Results: (文件内容预览...)]两个 prompt 设计值得注意:
## Already Known防重复 —— 把已有记忆的 name+description 喂给 LLM,让它不要重复提取已知事实- 与 s08 联动 —— 提示 LLM 若历史含
[Conversation Summary]系统消息则优先从中提炼。s08 压缩的摘要成为 s09 提取的素材,两个 Index 在这里衔接
JSON 解析(parseCandidates)——三层容错,全部 fail-open:
internal fun parseCandidates(content: String): List<CandidateMemory> {
val dto = try {
json.decodeFromString<CandidatesResponse>(MemoryJson.stripCodeFence(content))
} catch (e: SerializationException) {
logger.warn(e) { "Failed to parse extractor output, dropping batch" }
return emptyList()
} catch (e: IllegalArgumentException) {
logger.warn(e) { "Failed to parse extractor output, dropping batch" }
return emptyList()
}
return dto.candidates.mapNotNull { c ->
val candidate = c.toCandidate()
if (candidate == null) {
logger.warn { "Dropping invalid candidate: type=${c.type}, name=${c.name}" }
null
} else candidate
}
}MemoryJson.stripCodeFence—— LLM 经常用 markdown 围栏包 JSON(json ...),先剥离- 整批丢弃 —— 非法 JSON 直接返回空列表(warn),不让单条坏数据污染整批
- 按条丢弃 —— 单条 type 非法 / name 或 content 为空时丢弃该条,保留其余
整合层 MemoryConsolidator(consolidation)
候选 vs 现有记忆的合并决策同样是语义判断——"这条候选和哪条现有记忆主题相关?该新增还是更新?"。整合器把一次 LLM 调用用于决策:
class MemoryConsolidator(
private val llmProvider: LLMProvider,
private val config: MemoryConfig = MemoryConfig()
) {
suspend fun consolidate(
candidates: List<CandidateMemory>,
existing: List<MemoryEntry> // 现有全部记忆(含正文,供 LLM 对比)
): List<MemoryOperation> {
if (candidates.isEmpty()) return emptyList() // 空候选短路
val prompt = buildConsolidatePrompt(candidates, existing)
val response = llmProvider.chat(
messages = listOf(Message(Role.USER, prompt)),
options = ChatOptions(maxTokens = config.extractMaxTokens, temperature = 0.3),
tools = emptyList()
)
return parseOperations(response.content)
}
}Prompt 结构 —— 指令 + 现有记忆全文 + 候选清单:
You are a memory consolidator for an AI coding agent. Decide how to merge the candidate
memories into the existing memory store.
For each candidate decide:
- "add": the fact is new and worth storing
- "update": the fact updates an existing memory with the same or closely related topic;
set targetName to the existing memory's name
- "skip": the fact duplicates an existing memory, is trivial, or is already known
Existing memories are listed under "## Existing Memories" (name, type, content).
Respond with ONLY a JSON object, no markdown:
{"operations": [{"action": "add|update|skip", "targetName": "<existing name for update, else null>",
"entry": {"type": "user|feedback|project|reference", "name": "kebab-case-slug",
"description": "one-line summary", "content": "the fact"}}]}
Every candidate must appear in exactly one operation. For skip, entry may be null.
## Existing Memories
### user-prefers-vim (user)
用户偏好 vim 编辑器
用户偏好 vim 作为编辑器,配置在 ~/.vimrc。
## Candidates
1. [project] project-api-style: 项目 API 风格遵循 Kotlin 官方惯例
API 调用、命名、注释都遵循 Kotlin 官方风格指南解析逻辑与提取器对称:非法 JSON 整批丢弃;非法 action / ADD/UPDATE 缺 entry 的条目标记丢弃;SKIP 允许 entry 为 null(prompt 明确允许)。
consolidate 是唯一的整合决策点——语义相似的记忆被更新而非重复堆叠,重复内容被跳过。这让记忆库随时间保持整洁,不会无限膨胀成事实的垃圾桶。
编排层 MemoryManager
串联整条管线,维护 watermark(已处理到的消息索引):
class MemoryManager(
private val store: MemoryStore,
private val extractor: MemoryExtractor,
private val consolidator: MemoryConsolidator,
private val config: MemoryConfig = MemoryConfig()
) {
private var lastProcessedIndex: Int = 0 // watermark:会话内只增不减
suspend fun extractAndConsolidate(messages: List<Message>): ConsolidationReport {
val start = lastProcessedIndex.coerceAtMost(messages.size)
val newMessages = messages.subList(start, messages.size)
if (newMessages.size < config.extractMinMessages) {
return ConsolidationReport(candidatesFound = 0, skippedReason = "消息不足 ...")
}
val existing = store.loadAll()
val candidates = extractor.extract(newMessages, existing.map { MemorySummary(it.name, it.description) })
if (candidates.isEmpty()) {
lastProcessedIndex = messages.size // 已审查过,推进
return ConsolidationReport(candidatesFound = 0, skippedReason = "无可记内容")
}
val operations = consolidator.consolidate(candidates, existing)
val counts = store.apply(operations)
lastProcessedIndex = messages.size // 整合完成,推进
return buildReport(candidates.size, operations)
}
}watermark 三种情况:
| 情况 | watermark 推进? | 原因 |
|---|---|---|
| 新消息 < extractMinMessages | 否 | 太少没意义,留待下次提取(消息不丢失) |
| 提取无候选 | 是 | 已审查过,标记处理完,避免下次重复审查 |
| 提取有候选并整合 | 是 | 正常推进 |
watermark 的意义:/memory 命令中途触发提取后,会话结束的自动提取不会重复处理已提取的消息——只处理增量。
MemoryManager 还暴露了单一写路径 writeEntry(工具与管线共用 upsert 语义)和 deleteMemory(供 /memory clear),以及 allEntries / getEntry。
读路径 MemoryInjectionHook
镜像 SkillInjectionHook,监听 PRE_LLM_REQUEST,把记忆库的 name+description 索引拼成 SYSTEM 消息追加到请求末尾:
class MemoryInjectionHook(
private val store: MemoryStore,
private val maxInjected: Int = MemoryConfig.DEFAULT_MAX_INJECTED
) {
fun registerTo(manager: HookManager) {
val handler: HookHandler = handler@{ event ->
if (event.type != HookEvent.Type.PRE_LLM_REQUEST) return@handler HookResult.Continue
val entries = store.loadAll().take(maxInjected)
if (entries.isEmpty()) return@handler HookResult.Continue
HookResult.AppendMessages(listOf(Message(Role.SYSTEM, buildMemoryIndexText(entries))))
}
manager.on(HookEvent.Type.PRE_LLM_REQUEST, handler)
}
}注入格式:
# Persistent Memory
The following memories were saved from previous sessions. Call `memory_read`
with a memory's name to retrieve its full content.
## user-prefers-vim
用户偏好 vim 编辑器
## project-api-style
项目 API 风格遵循 Kotlin 官方惯例只注入索引,不注入正文——与 skill 的渐进式加载同思路:LLM 在索引里看到相关记忆 → 需要详情时调 memory_read。三条保险:空库返回 Continue(不浪费 token)、maxInjected 截断(防 context 膨胀)、记忆条目过多时 take(maxInjected) 取前 N 条。
主动读写工具
会话中 LLM 能主动管理记忆。工具放在 memory/ 包(依赖方向 memory → tool 单向,同 SkillLoaderTool 的定位):
memory_read(只读,isReadOnly = true)—— 缺省列出全部,按 name 读单条详情,未知 name 返回 isError:
override suspend fun execute(input: JsonObject): ToolResult {
val name = input["name"]?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotEmpty() }
val entries = store.loadAll()
return if (name == null) {
val text = entries.joinToString("\n\n") { formatEntry(it) }
ToolResult("", if (text.isBlank()) "No memories stored." else text)
} else {
val entry = entries.firstOrNull { it.name == name }
?: return ToolResult("", "Memory not found: $name. Use memory_read without name to list all memories.", isError = true)
ToolResult("", formatEntry(entry))
}
}memory_write(写,isReadOnly = false)—— 新增或按 name 更新,type 枚举校验:
override suspend fun execute(input: JsonObject): ToolResult {
val name = input["name"]?.jsonPrimitive?.content?.trim().orEmpty()
if (name.isBlank()) return ToolResult("", "Error: name is required", isError = true)
val type = parseType(input["type"]?.jsonPrimitive?.content)
?: return ToolResult("", "Error: type must be one of user|feedback|project|reference", isError = true)
val description = input["description"]?.jsonPrimitive?.content ?: ""
val content = input["content"]?.jsonPrimitive?.content ?: ""
manager.writeEntry(MemoryEntry(name, description, type, content)) // 单一写路径
return ToolResult("", "Memory saved: $name")
}两个设计点:
memory_write走既有 PermissionPipeline——isReadOnly = false触发 ToolCategoryRule,LLM 写记忆需用户批准,符合安全预期- 经
MemoryManager.writeEntry单一写路径——工具与整合管线共用 upsert 语义,不出现两条语义不一致的写路径
/memory 命令
class MemoryCommand : ReplCommand {
// /memory → 立即运行提取+整合管线,输出报告
// /memory list → 列出所有记忆(name - description (type))
// /memory show <name> → 显示一条记忆完整内容
// /memory clear → 清空记忆库
}runPipeline 是唯一需要小心的地方——fail-open:
private suspend fun runPipeline(ctx: ReplContext): ReplCommandResult {
val report = try {
ctx.memoryManager.extractAndConsolidate(ctx.agentLoop.messagesHistory)
} catch (e: Exception) {
// fail-open:提取/整合的 LLM 调用异常不应中断 REPL 会话
println("Memory pipeline failed: ${e.message}")
return ReplCommandResult.Continue
}
println("Memory pipeline complete:")
println(" candidates: ${report.candidatesFound}")
if (report.skippedReason.isNotEmpty()) println(" note: ${report.skippedReason}")
if (report.added.isNotEmpty()) println(" added: ${report.added.joinToString()}")
if (report.updated.isNotEmpty()) println(" updated: ${report.updated.joinToString()}")
if (report.skipped.isNotEmpty()) println(" skipped: ${report.skipped.joinToString()}")
return ReplCommandResult.Continue
}最终审查发现这里最初没有 try-catch——LLM 调用异常会传播出 REPL 循环导致整个会话崩溃,违反设计规范 §12 的 fail-open 要求。修复后 /memory 与 ReplLoop 的 chat 路径保持一致的行为哲学。
ReplLoop 集成
ReplLoop.start() 中 s08 压缩器之后组装记忆系统:
// s09: 构建记忆系统(文件存储 + 提取/整合管线 + 注入 hook + 工具)
val memoryConfig = MemoryConfig()
val memoryStore = MemoryStore(memoryConfig.resolvedMemoryDir())
val memoryExtractor = MemoryExtractor(llmProvider, memoryConfig)
val memoryConsolidator = MemoryConsolidator(llmProvider, memoryConfig)
memoryManager = MemoryManager(memoryStore, memoryExtractor, memoryConsolidator, memoryConfig)
MemoryInjectionHook(memoryStore, memoryConfig.maxInjectedSummaries).registerTo(hookManager)
toolRegistry.register(MemoryReadTool(memoryStore))
toolRegistry.register(MemoryWriteTool(memoryManager))命令注册表加一行 register(MemoryCommand(), "/memory");ReplContext 增加 memoryManager 字段。
会话结束自动提取放在 finally 块——这是整个 s09 的"触发器":
} finally {
subagentScope.cancel()
logger.info { "Subagent scope cancelled" }
// s09: 会话结束自动提取记忆(fail-open,失败不阻断退出)
try {
val report = runBlocking { memoryManager.extractAndConsolidate(agentLoop.messagesHistory) }
logger.info { "Session-end memory: ${report.added.size} added, ${report.updated.size} updated" }
} catch (e: Exception) {
logger.warn(e) { "Session-end memory extraction failed" }
}
}用户退出 REPL → finally → 提取本次会话的全部新消息 → 整合进磁盘记忆库 → 下一次会话的注入 Hook 立刻能看到。
配置 MemoryConfig
data class MemoryConfig(
val memoryDir: String = DEFAULT_MEMORY_DIR, // "~/.cat-code/memory"
val extractMinMessages: Int = 4, // 至少 4 条新消息才触发提取
val extractMaxTokens: Int = 2048, // 提取/整合 LLM 输出 token 上限
val maxInjectedSummaries: Int = 30 // 注入索引条数上限
)resolvedMemoryDir() 展开 ~ 为 home 目录;相对路径基于当前工作目录解析为绝对路径(Path.of(expanded).toAbsolutePath(),已是绝对路径时幂等)。
端到端流程
一次完整的跨会话记忆生命周期:
第一次会话:
用户启动 REPL(记忆库为空,注入 Hook 返回 Continue,不浪费 token)
用户:"用 vim 编辑,别动 tests 目录"
智能体:"好的,记住了。"
→(用户 Ctrl+D 退出)
→ finally: extractAndConsolidate(messagesHistory)
→ 提取器:LLM 审阅对话,产出候选 [{type:user, name:user-prefers-vim, ...}]
→ 整合器:现有记忆为空,决策 ADD
→ MemoryStore.apply → 写入 user-prefers-vim.md + 重建 MEMORY.md
[INFO] Session-end memory: 1 added, 0 updated第二次会话:
用户启动 REPL
→ MemoryInjectionHook: PRE_LLM_REQUEST → 追加 "# Persistent Memory / ## user-prefers-vim / 用户偏好 vim 编辑器"
用户:"继续重构 UserService"
→ LLM 看到索引,想起用户偏好,主动遵守
→(若需要详情)调用 memory_read(user-prefers-vim) 读全文会话中主动写入:
智能体发现一个 API 坑 → 想记住 → 调用 memory_write{name: api-gotcha, type: reference, ...}
→ 用户批准(权限管线)→ writeEntry → 立即持久化测试策略
7 个测试类,全部 Kotest StringSpec + 手写 Fake LLM Provider(实现 LLMProvider 返回固定 ChatResponse,不用 mock 框架):
| 测试类 | 覆盖点 |
|---|---|
MemoryFrontmatterTest | 解析/序列化、metadata 缺省值、缺失 name 抛错、非法 type 抛错、无 frontmatter 抛错、round-trip |
MemoryStoreTest | CRUD、索引重建、排序、损坏文件跳过、磁盘重载、apply 三动作计数、路径安全校验、~ 展开、相对路径解析 |
MemoryExtractorTest | prompt 构造(含已知记忆)、候选 JSON 解析、代码围栏剥离、非法 JSON/type 丢弃、空候选、空窗口短路 |
MemoryConsolidatorTest | add/update/skip 决策解析、非法 action 丢弃、ADD/UPDATE 缺 entry 丢弃、空候选短路 |
MemoryManagerTest | watermark 三语义、完整管线编排、报告生成、writeEntry upsert 保留 createdAt、deleteMemory |
MemoryInjectionHookTest | 索引注入格式、只注入索引不注入正文、空库 Continue、maxInjected 截断 |
MemoryToolsTest | read 三行为、write 三行为、非法参数错误 |
MemoryCommandTest | 子命令分发、管线报告输出、LLM 异常 fail-open 不崩溃 |
测试设计上踩过的两个坑(也是 s09 开发过程的真实记录):
- watermark 断言用消息标记而非单字符 —— 最初断言
extractPrompt shouldContain "e",但英文指令文本里全是e,断言平凡通过;shouldNotContain "a"更是必败。改用消息标记"USER: e"/"USER: a"后,才真正验证"第二次只处理新消息" - 哨兵值用唯一标记 —— 验证"只注入索引不注入正文"时,最初用
"content"作哨兵,但注入 prompt 文本里就有its full content,断言必败。改用BODY-MARKER-ABC作记忆正文,断言其不出现
开发过程:设计取舍与踩坑记录
s09 经历了完整的 spec → plan → subagent-driven execution 流程,真实踩过的坑值得记录:
计划阶段发现的矛盾(3 处,均在实现中被发现并修正):
- watermark 测试语义矛盾 —— 计划里"第二次调用只处理 2 条新消息"的测试,与实现"第一次调用后 watermark 推进到全部消息"矛盾,导致第二次窗口只剩 1 条 < extractMinMessages 被跳过。修正测试窗口(4 条 → 6 条)
- 注入测试哨兵冲突 ——
shouldNotContain "content"与 prompt 文本冲突,改用唯一哨兵 - 任务审查的删除能力缺口 ——
/memory clear需要删除入口,deleteMemory提前到 MemoryManager 实现
最终全分支审查发现并修复的问题(3 Critical + 5 Important):
- C1 路径穿越(安全) ——
entry.name未校验可写出记忆目录,加validateName - C2 apply 计数撒谎(正确性) —— ADD 分支 entry 为 null 时仍
added++,改为计 skipped - C3 /memory 不 fail-open(可靠性) —— LLM 异常崩溃 REPL,包 try-catch
- I4-I8 ——
.md魔法值提取常量、相对路径解析为绝对、catch 收窄到具体异常、Json 实例共享、空窗口短路
几个被采纳的关键设计决策:
- 只注入索引不注入正文 —— 与 s07 skill 渐进式加载一致,LLM 要详情调
memory_read - 单一写路径 —— 工具与管线都经
MemoryManager.writeEntry,避免两条语义不一致的写路径 - 复用主 LLMProvider —— 提取/整合不引入第二个模型,与 s08 摘要同模式
- 全部 fail-open —— 解析失败、文件损坏、LLM 异常,都不阻断主流程
下一站
s09 让智能体拥有了长期记忆——这是"智能体能力"(阶段二)的收官之作。它为后续铺平了道路:
- s10 System Prompt——记忆注入与 skill 注入同属 PRE_LLM_REQUEST 扩展,s10 将统一管理运行时上下文组装,把"该注入什么"从各 Hook 的各自为政收拢为系统级分段拼接
- s12 Task System——
MemoryStore的"目录 + 索引 + 每项一文件"持久化模式可被 Task 持久化复用;s12 引入后记忆目录可升级为按项目作用域隔离(当前所有项目共享一个记忆库,是已知的 MVP 取舍) - s14 Cron Scheduler——定时触发记忆整合(如每天结束时自动 consolidate)是自然的结合点
s09 留给后续最大的礼物是一个可复用的文件持久化范式:手写 frontmatter、目录 + 索引布局、fail-open 容错——这套模式在 s12 Task System 里会再次出现。
下一篇:Index 10: System Prompt —— 运行时分段拼接,把记忆、技能、上下文统一进一个可组合的系统提示。


