Cat Blog
HomeBlogTools
Search
Language
Choose site style
Choose accent color
Click Effect
Theme

Cat Blog · Updated regularly. Source code is available on GitHub.

Index 09: Memory — 跨会话记忆管线

August 13th, 2026
AI智能体Kotlin后端

Series

使用Kotlin从0开发一个ClaudeCode

Series

使用Kotlin从0开发一个ClaudeCode

Progress 9 / 21

使用Kotlin从0开发一个ClaudeCode

Previous in series

Index 08: Context Compact — 四层上下文压缩

Next in series

Index 10: System Prompt — 运行时分段拼接

目标

s01 到 s08 让智能体在单个会话内变得强大:能调用工具、审批权限、扩展 Hook、规划 Todo、派发子智能体、按需加载技能、自动压缩上下文。但每次启动 cat-code,智能体对项目一无所知——上次会话的结论、用户的偏好、踩过的坑,全部随进程退出而蒸发。更糟的是,s08 的上下文压缩会把旧消息折叠成 [Conversation Summary] 系统消息,而这条摘要也随会话结束一起丢失——压缩保存的上下文只在当次会话内有效。

Index 09 引入跨会话记忆管线,让智能体把"学到的经验"沉淀到磁盘,在下一次会话中"想起来"。对应 docs/main.md 里定义的三阶段:

TEXT
selection(选择)    →  从对话中选出值得长期记住的事实
extraction(提取)   →  把候选格式化为结构化记忆条目
consolidation(整合)→ 与已有记忆合并(新增/更新/跳过)

完整能力闭环:

  1. 持久化存储(MemoryStore)—— 记忆以文件形式落在磁盘 ~/.cat-code/memory/,重启不丢失
  2. 三阶段提取(MemoryExtractor + MemoryConsolidator)—— 会话结束时从对话中提炼并整合
  3. 会话开始注入(MemoryInjectionHook)—— 下次启动时把记忆索引喂给 LLM,让它"想起来"
  4. 主动读写工具(memory_read / memory_write)—— 会话中 LLM 可随时保存或查询记忆
  5. 手动控制(/memory 命令)—— 随时触发提取管线、查看记忆库

完成后的效果——第二次会话的智能体站在第一次会话的肩膀上:

TEXT
$ 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

核心设计与实现

架构全景

TEXT
                    ┌─────────────────────────────────────────────┐
                    │          ~/.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 也是文件名:

KOTLIN
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 存元数据,正文存事实:

MARKDOWN
---
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 维护内存缓存(构造时从磁盘加载),所有写操作同时更新缓存与磁盘文件,并重建索引:

KOTLIN
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 前进:

KOTLIN
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 是整合管线的落点,三种动作各有语义:

KOTLIN
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 入口先校验:

KOTLIN
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 链接:

MARKDOWN
# Memory Index

- [project-api-style](project-api-style.md) — 项目 API 风格遵循 Kotlin 官方惯例
- [user-prefers-vim](user-prefers-vim.md) — 用户偏好 vim 编辑器

提取层 MemoryExtractor(selection + extraction)

"什么值得记住"是语义判断——提取器把一次 LLM 调用同时完成选择和格式化:

KOTLIN
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)——指令 + 已知记忆 + 消息窗口三部分:

MARKDOWN
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 设计值得注意:

  1. ## Already Known 防重复 —— 把已有记忆的 name+description 喂给 LLM,让它不要重复提取已知事实
  2. 与 s08 联动 —— 提示 LLM 若历史含 [Conversation Summary] 系统消息则优先从中提炼。s08 压缩的摘要成为 s09 提取的素材,两个 Index 在这里衔接

JSON 解析(parseCandidates)——三层容错,全部 fail-open:

KOTLIN
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 调用用于决策:

KOTLIN
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 结构 —— 指令 + 现有记忆全文 + 候选清单:

MARKDOWN
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(已处理到的消息索引):

KOTLIN
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 消息追加到请求末尾:

KOTLIN
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)
    }
}

注入格式:

TEXT
# 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:

KOTLIN
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 枚举校验:

KOTLIN
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")
}

两个设计点:

  1. memory_write 走既有 PermissionPipeline——isReadOnly = false 触发 ToolCategoryRule,LLM 写记忆需用户批准,符合安全预期
  2. 经 MemoryManager.writeEntry 单一写路径——工具与整合管线共用 upsert 语义,不出现两条语义不一致的写路径

/memory 命令

KOTLIN
class MemoryCommand : ReplCommand {
    // /memory            → 立即运行提取+整合管线,输出报告
    // /memory list       → 列出所有记忆(name - description (type))
    // /memory show <name> → 显示一条记忆完整内容
    // /memory clear      → 清空记忆库
}

runPipeline 是唯一需要小心的地方——fail-open:

KOTLIN
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 压缩器之后组装记忆系统:

KOTLIN
// 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 的"触发器":

KOTLIN
} 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

KOTLIN
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(),已是绝对路径时幂等)。


端到端流程

一次完整的跨会话记忆生命周期:

第一次会话:

TEXT
用户启动 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

第二次会话:

TEXT
用户启动 REPL
  → MemoryInjectionHook: PRE_LLM_REQUEST → 追加 "# Persistent Memory / ## user-prefers-vim / 用户偏好 vim 编辑器"
用户:"继续重构 UserService"
  → LLM 看到索引,想起用户偏好,主动遵守
  →(若需要详情)调用 memory_read(user-prefers-vim) 读全文

会话中主动写入:

TEXT
智能体发现一个 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
MemoryStoreTestCRUD、索引重建、排序、损坏文件跳过、磁盘重载、apply 三动作计数、路径安全校验、~ 展开、相对路径解析
MemoryExtractorTestprompt 构造(含已知记忆)、候选 JSON 解析、代码围栏剥离、非法 JSON/type 丢弃、空候选、空窗口短路
MemoryConsolidatorTestadd/update/skip 决策解析、非法 action 丢弃、ADD/UPDATE 缺 entry 丢弃、空候选短路
MemoryManagerTestwatermark 三语义、完整管线编排、报告生成、writeEntry upsert 保留 createdAt、deleteMemory
MemoryInjectionHookTest索引注入格式、只注入索引不注入正文、空库 Continue、maxInjected 截断
MemoryToolsTestread 三行为、write 三行为、非法参数错误
MemoryCommandTest子命令分发、管线报告输出、LLM 异常 fail-open 不崩溃

测试设计上踩过的两个坑(也是 s09 开发过程的真实记录):

  1. watermark 断言用消息标记而非单字符 —— 最初断言 extractPrompt shouldContain "e",但英文指令文本里全是 e,断言平凡通过;shouldNotContain "a" 更是必败。改用消息标记 "USER: e" / "USER: a" 后,才真正验证"第二次只处理新消息"
  2. 哨兵值用唯一标记 —— 验证"只注入索引不注入正文"时,最初用 "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 —— 运行时分段拼接,把记忆、技能、上下文统一进一个可组合的系统提示。

Table of Contents

Current section:目标

  • 1. 目标
  • 2. 为什么需要
  • 3. 现实问题
  • 4. 设计原则
  • 5. 核心设计与实现
  • 6. 架构全景
  • 7. 领域模型 MemoryModels
  • 8. 存储层 MemoryStore
  • 9. Memory Index
  • 10. 提取层 MemoryExtractor(selection + extraction)
  • 11. Already Known
  • 12. Conversation
  • 13. 整合层 MemoryConsolidator(consolidation)
  • 14. Existing Memories
  • 15. user-prefers-vim (user)
  • 16. Candidates
  • 17. 编排层 MemoryManager
  • 18. 读路径 MemoryInjectionHook
  • 19. Persistent Memory
  • 20. user-prefers-vim
  • 21. project-api-style
  • 22. 主动读写工具
  • 23. /memory 命令
  • 24. ReplLoop 集成
  • 25. 配置 MemoryConfig
  • 26. 端到端流程
  • 27. 测试策略
  • 28. 开发过程:设计取舍与踩坑记录
  • 29. 下一站
Back to top

Related Posts

View all posts
Index 21: Terminal UX —— Claude Code 风格终端交互

Index 21: Terminal UX —— Claude Code 风格终端交互

August 21st, 2026

Index 21 将 cat-code 终端交互升级为 Claude Code 风格,解决旧 REPL 黑箱、无中断及输入体验差的问题。通过 JLine3 与 Mordant 实现历史补全、实时工具可见性、Spinner 状态及 Esc 中断。核心采用 UI 与 AgentLoop 解耦的事件流架构,支持内联权限菜单与优雅降级。同时配置 logback 收敛控制台日志,确保 TUI 清爽且功能无损,显著提升可用性。

Index 20: Comprehensive Agent —— 全机制集成(收口)

Index 20: Comprehensive Agent —— 全机制集成(收口)

August 13th, 2026

作为收官之作,把 s01-s19 的二十个核心机制整合为一个全面智能体:统一的状态查询与运行周期、自治认领与工作区隔离的贯通,以及 /status 命令的全局可视化。全机制协同运转,标志着 Cat-Code 从零完整复刻 Claude Code 核心能力的收官。

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

August 13th, 2026

引入 MCP(Model Context Protocol)插件机制,通过多传输适配与通道路由,把外部 MCP 服务器的工具动态接入智能体的工具池。工具注册从静态编译期扩展为运行时动态组装,让智能体能力随外部服务即插即用。