feat(memo): 新增跨会话持久记忆系统——模型工具与自动提取#228
Conversation
新增 internal/memo 包,实现对标 Claude Code 的文件级持久记忆系统: - 领域类型:Entry/Index/Store/Extractor 四类记忆(user/feedback/project/reference) - FileStore:基于 SHA1 工作区隔离的文件存储,原子写入 MEMO.md 索引 + topic 文件 - Service:Add/Remove/List/Search/Recall 编排,支持索引行数上限自动淘汰 - ContextSource:实现 promptSectionSource 接口,将记忆索引注入 system prompt 同步改动: - context 包导出 SectionSource/PromptSection 接口,新增 NewBuilderWithMemo 构造函数 - config 包新增 MemoConfig(enabled/auto_extract/max_index_lines)及持久化支持 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
将 MemoService 注入 TUI 完整链路: - bootstrap:Options/Container 新增 MemoSvc 字段,app/bootstrap 按配置创建并注入 - tui 包:新增 NewWithMemo 入口,appServices 持有 memoSvc - 命令处理: - /memo:显示所有持久记忆条目 - /remember <text>:保存用户手动记忆(user 类型) - /forget <keyword>:按关键词搜索并删除匹配记忆 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 导出 session 包的 HashWorkspaceRoot/WorkspacePathKey/NormalizeWorkspaceRoot, 消除 memo/store.go 中对 workspace 哈希逻辑的重复实现 - 重命名 newEntryID 中 rand 变量为 randHex,避免遮蔽 crypto/rand 包 - 提取 loadIndexLocked 辅助方法,统一 Remove/List/Search/Recall 的锁内索引加载 - 移除空的 ExtractAndStore 方法和 matchesKeyword 中冗余的 lowerKeyword 别名 - gofmt 格式化 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| if c == nil { | ||
| return | ||
| } | ||
| if !c.Enabled { |
There was a problem hiding this comment.
这里把 false 当成“未设置”处理了,所以用户在 YAML 里显式写 memo.enabled: false 或 memo.auto_extract: false 时,加载阶段会被默认值重新改回 true。结合 persistedMemoConfig 也是 plain bool,当前实现实际上无法关闭这两个开关。这里需要用 *bool/presence 标记区分“未设置”和“显式 false`,否则配置语义是错的。
| memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir) | ||
| memoSource := memo.NewContextSource(memoStore) | ||
| contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource) | ||
| memoSvc = memo.NewService(memoStore, nil, cfg.Memo, nil) |
There was a problem hiding this comment.
memoSource 在上面通过 NewContextSource 启用了 5 秒缓存,但这里创建 memoSvc 时把缓存失效回调传成了 nil。结果 /remember 和 /forget 成功后不会立即刷新注入到 prompt 的 memo,后续一段时间内 runtime 仍然看到旧索引。service_test 已经把 cache invalidation 当成预期行为,这里需要把 context source 的失效钩子真正接进来。
| a.rebuildTranscript() | ||
| return nil | ||
| } | ||
| entry := memo.Entry{ |
There was a problem hiding this comment.
这里把用户输入原样保存为 Title/Content,而 memo 索引随后会作为 system prompt 的一部分跨会话注入。这样用户只要执行一次 /remember Ignore previous instructions and ...,就能把任意指令持久写入系统上下文,形成稳定的 prompt-injection 面。至少要把 memo 作为数据块而不是可执行指令文本注入,并对换行/markdown/heading 等做约束或转义。
| if c == nil { | ||
| return | ||
| } | ||
| if !c.Enabled { |
There was a problem hiding this comment.
MemoConfig.ApplyDefaults treats false as “unset”, so persisted values like memo.enabled: false or memo.auto_extract: false are overwritten back to the defaults (true). Combined with omitempty on the YAML fields, users currently have no way to disable memo or auto-extract once this lands. This needs presence-aware decoding or pointer/bool-wrapper fields.
| index.Entries = index.Entries[excess:] | ||
| } | ||
|
|
||
| if err := s.store.SaveIndex(ctx, index); err != nil { |
There was a problem hiding this comment.
Add saves the index before writing the topic file. If SaveTopic fails, MEMO.md now references a topic file that does not exist, leaving recall/context injection in a broken state. Please make the write atomic from the service perspective (write topic first, or roll back the index on topic-write failure).
| memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir) | ||
| memoSource := memo.NewContextSource(memoStore) | ||
| contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource) | ||
| memoSvc = memo.NewService(memoStore, nil, cfg.Memo, nil) |
There was a problem hiding this comment.
The new memo.Service is constructed with a nil cache-invalidation callback, even though memoContextSource has an explicit InvalidateCache hook and the service already supports calling it. After /remember or /forget, the system prompt can keep serving stale memo content until the TTL expires, so the agent will not reliably see newly added/removed memory in the next turn.
| if c == nil { | ||
| return | ||
| } | ||
| if !c.Enabled { |
There was a problem hiding this comment.
MemoConfig.ApplyDefaults 这里会把显式配置成 false 的值重新覆盖成默认值 true。结果是用户无法通过配置关闭 memo.enabled 或 memo.auto_extract:YAML 读出 false 后在 Load() 里又被这里改回去了,保存时还会把 false 因 omitempty 省略掉。这个回归会让 memo 功能实际上变成“无法禁用”。建议像其他布尔配置一样区分“未设置”和“显式 false”,例如改成指针字段或单独的 persisted 层 presence 标记。
| index.Entries = index.Entries[excess:] | ||
| } | ||
|
|
||
| if err := s.store.SaveIndex(ctx, index); err != nil { |
There was a problem hiding this comment.
这里先 SaveIndex、再 SaveTopic,如果第二步失败,MEMO.md 已经持久化了一个指向不存在 topic 文件的条目,仓库状态会永久不一致。之后 /memo 能看到该条目,但 Recall/删除细节文件时会表现异常。建议把 topic 先写入临时文件并一起提交,或者在 SaveTopic 失败时回滚刚写入的索引。
| memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir) | ||
| memoSource := memo.NewContextSource(memoStore) | ||
| contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource) | ||
| memoSvc = memo.NewService(memoStore, nil, cfg.Memo, nil) |
There was a problem hiding this comment.
memoSource 带了 5 秒 TTL 缓存,但这里创建 memo.Service 时没有把缓存失效回调接进去,所以 /remember 或 /forget 之后,后续几轮 prompt 里仍可能继续注入旧的 MEMO 索引,直到 TTL 过期。对“刚记住马上提问”的场景会直接读到陈旧上下文。这里需要把 InvalidateCache 连接到 service,或者让 context source 在每次变更后立即失效。
| if c == nil { | ||
| return | ||
| } | ||
| if !c.Enabled { |
There was a problem hiding this comment.
MemoConfig.ApplyDefaults treats false as "unset". Because the defaults for both Enabled and AutoExtract are true, a persisted config like memo.enabled: false or memo.auto_extract: false is rewritten back to true on load. That makes the feature impossible to disable persistently. This needs a tri-state/pointer representation or a different defaulting strategy that can distinguish "unset" from an explicit false.
| var memoSvc *memo.Service | ||
| if cfg.Memo.Enabled { | ||
| memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir) | ||
| memoSource := memo.NewContextSource(memoStore) |
There was a problem hiding this comment.
memoContextSource exposes InvalidateCache, but this wiring drops that hook and constructs memo.Service with sourceInvl=nil. After /remember or /forget, the next agent turn can still use the stale memo index until the 5s TTL expires, so the new memo is not reliably available immediately. Please pass the source invalidator into NewService (or otherwise share the cache) so writes invalidate the injected prompt cache synchronously.
| return nil | ||
| } | ||
| entry := memo.Entry{ | ||
| Type: memo.TypeUser, |
There was a problem hiding this comment.
/remember stores raw user input as Entry.Title, and RenderIndex later writes that title straight into MEMO.md and the system prompt. A title containing newlines or markdown/list syntax can break the one-line index format and inject arbitrary extra prompt text on the next run. The command should normalize titles to a single safe line, or the renderer/parser needs escaping.
| index.Entries = index.Entries[excess:] | ||
| } | ||
|
|
||
| if err := s.store.SaveIndex(ctx, index); err != nil { |
There was a problem hiding this comment.
Add saves the index before saving the topic file. If SaveTopic fails, MEMO.md is already committed and now points at a missing topic file; Remove has the inverse ordering problem because it deletes topic files before saving the updated index. Ordinary filesystem errors can therefore leave the store in a permanently inconsistent state. This needs rollback handling or a write order that keeps the index authoritative only after the topic mutations succeed.
| if c == nil { | ||
| return | ||
| } | ||
| if !c.Enabled { |
There was a problem hiding this comment.
ApplyDefaults 这里把 false 当成“未设置”处理了。defaultMemoConfig() 的两个布尔默认值都是 true,因此用户显式配置 memo.enabled: false 或 memo.auto_extract: false,在 load/save 一轮后都会被覆盖回 true。这会让这两个开关实际上无法关闭。
| memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir) | ||
| memoSource := memo.NewContextSource(memoStore) | ||
| contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource) | ||
| memoSvc = memo.NewService(memoStore, nil, cfg.Memo, nil) |
There was a problem hiding this comment.
这里创建 memo.Service 时把缓存失效回调传成了 nil,但上面已经创建了带 TTL 缓存的 memoSource。结果是 /remember / /forget 成功后,下一轮 prompt 里注入的 memo 仍可能是旧值,直到缓存过期。这个和 context_source_test 里验证的失效路径没有真正接上。
| index.Entries = index.Entries[excess:] | ||
| } | ||
|
|
||
| if err := s.store.SaveIndex(ctx, index); err != nil { |
There was a problem hiding this comment.
Add 先落 MEMO.md,再落 topic 文件。只要第二步失败,索引就会永久指向一个不存在的 topic,Recall 和后续上下文读取都会看到不一致状态。Remove 也有同类问题:先删 topic、后写索引,失败时会留下仍被索引引用但已经被删除的 topic。这里需要原子化这两个文件更新,或者至少做回滚。
| a.rebuildTranscript() | ||
| return nil | ||
| } | ||
| entry := memo.Entry{ |
There was a problem hiding this comment.
/remember 直接把原始输入同时写进 Title 和 Content。后续 Title 会进入 MEMO.md 单行索引,再被重新解析并注入 system prompt;如果输入包含换行或伪造的 (...) 后缀,当前 RenderIndex/ParseIndex 格式会被破坏,最轻也会导致索引 round-trip 丢数据,严重时会把用户构造文本原样带进系统提示。这里至少需要对 Title 做单行化/转义。
合并 1024XEngineer/neo-code main (496d350),解决 3 处冲突: - loader.go: 保留 Memo 配置字段 + Legacy 工作目录字段 - commands.go: 保留 /cwd /memo /remember /forget 命令定义 - update.go: 保留 memo 命令处理函数 + setCurrentWorkdir 函数 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
/simplify |
|
已完成本次 完成内容
验证
代码提交
Fork PR |
- preserve explicit false for memo config flags - wire memo cache invalidation callback in bootstrap - make memo service index/topic updates consistency-safe - sanitize memo titles and inject memo index as data block - add regression tests and simplification cleanups Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com>
fix: address memo review findings and simplify memo flow
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
- index_test.go: 新增 parseIndexLine/typeDisplayName/NormalizeTitle/topicNameFromEntry 测试 - config_test.go: 新增 MemoConfig Clone/ApplyDefaults/Validate 测试 - builder_test.go: 新增 NewBuilderWithMemo 测试(注入/nil 两种场景) - update_test.go: 新增 handleMemoCommand/handleRememberCommand/handleForgetCommand 测试 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
冲突 |
|
/simplify |
处理结果已按本次 Review 评论处理结论已读取历史评论日志并逐条核对“未解决项”。之前指出的核心问题(memo 布尔配置持久化、memo 缓存失效回调接线、Add/Remove 持久化顺序、 本次简化改动
验证
提交与推送
|
Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com>
|
/code 解决冲突 |
处理结果已完成冲突处理,并已直接更新 PR 头分支
验证
提交与推送
|
Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com>
Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com>
概述
为 NeoCode 新增完整的跨会话持久记忆系统(
internal/memo),对标 Claude Code 的记忆架构,使 Agent 能在会话间积累和利用知识。主要变更
核心模块 (
internal/memo/)store.go): 基于文件系统的FileStore,复用 session 的 SHA1 工作区隔离,原子写入index.go): MEMO.md 索引渲染与解析,Topic 文件 frontmatter 格式,NormalizeTitle清洗service.go):Service编排 Add/Remove/List/Search/Recall,互斥锁保护 + 缓存失效回调context_source.go): 实现SectionSource接口,将 MEMO.md 索引注入 system prompt,TTL 缓存types.go): 4 类记忆 (user/feedback/project/reference),Store/Extractor接口extractor.go): 检测用户消息中的显式信号词(记住/偏好/avoid 等),自动提取记忆模型工具 (
internal/tools/memo/)tool_initiatedTUI 命令
/memo— 查看记忆索引/remember <text>— 手动保存记忆/forget <keyword>— 删除匹配记忆Runtime 集成
Service新增MemoExtractor接口与SetMemoExtractor方法EventAgentDone后异步调用ExtractAndStore,失败静默处理AutoExtract启用时注入配置
MemoConfig:enabled/auto_extract/max_index_lines,支持 YAML 持久化enabled=true,auto_extract=true,max_index_lines=200存储布局
架构
测试覆盖
internal/memo/全部单元测试(types/store/index/service/context_source/extractor)internal/tools/memo/全部单元测试(remember/recall 正常+边界+异常)internal/config/MemoConfig Clone/Validate/ApplyDefaults 测试internal/context/NewBuilderWithMemo 测试internal/tui/core/app/memo 命令处理函数测试go build ./...&&go test ./...全量通过测试计划
/remember 我喜欢中文注释,重启后/memo仍可见/forget删除后确认不再出现在上下文🤖 Generated with Claude Code