Skip to content

feat(memo): 新增跨会话持久记忆系统——模型工具与自动提取#228

Merged
phantom5099 merged 10 commits into
1024XEngineer:mainfrom
wynxing:main
Apr 12, 2026
Merged

feat(memo): 新增跨会话持久记忆系统——模型工具与自动提取#228
phantom5099 merged 10 commits into
1024XEngineer:mainfrom
wynxing:main

Conversation

@wynxing

@wynxing wynxing commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator

概述

为 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/)

  • memo_remember: Agent 主动保存记忆条目(type/title/content/keywords),Source=tool_initiated
  • memo_recall: Agent 按关键词搜索并加载记忆详情,返回 topic 文件完整内容

TUI 命令

  • /memo — 查看记忆索引
  • /remember <text> — 手动保存记忆
  • /forget <keyword> — 删除匹配记忆

Runtime 集成

  • Service 新增 MemoExtractor 接口与 SetMemoExtractor 方法
  • ReAct 循环 EventAgentDone 后异步调用 ExtractAndStore,失败静默处理
  • 仅当 AutoExtract 启用时注入

配置

  • MemoConfigenabled/auto_extract/max_index_lines,支持 YAML 持久化
  • 默认 enabled=true, auto_extract=true, max_index_lines=200

存储布局

~/.neocode/projects/<workspace_hash>/memo/
  MEMO.md          # 索引(~200 行上限,始终注入上下文)
  topics/          # 详细内容文件,按需加载

架构

用户对话 → Runtime ReAct 循环
              ├─ EventAgentDone → RuleExtractor → Service.Add(异步提取)
              ├─ 每轮开始 → ContextBuilder.Build → MemoSource.Sections(注入索引)
              └─ 模型调用 → memo_remember / memo_recall 工具

用户手动 → /memo /remember /forget → MemoService

测试覆盖

  • 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 仍可见
  • Agent 回复中引用记忆内容
  • 用户说"记住以后都用 tab"后,下次会话自动提取生效
  • /forget 删除后确认不再出现在上下文

🤖 Generated with Claude Code

wynxing and others added 3 commits April 11, 2026 17:16
新增 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>

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

发现 3 个需要处理的问题,主要集中在配置语义、memo 上下文一致性和持久化 prompt 注入风险。

Comment thread internal/config/model.go Outdated
if c == nil {
return
}
if !c.Enabled {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里把 false 当成“未设置”处理了,所以用户在 YAML 里显式写 memo.enabled: falsememo.auto_extract: false 时,加载阶段会被默认值重新改回 true。结合 persistedMemoConfig 也是 plain bool,当前实现实际上无法关闭这两个开关。这里需要用 *bool/presence 标记区分“未设置”和“显式 false`,否则配置语义是错的。

Comment thread internal/app/bootstrap.go Outdated
memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir)
memoSource := memo.NewContextSource(memoStore)
contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource)
memoSvc = memo.NewService(memoStore, nil, cfg.Memo, nil)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

memoSource 在上面通过 NewContextSource 启用了 5 秒缓存,但这里创建 memoSvc 时把缓存失效回调传成了 nil。结果 /remember/forget 成功后不会立即刷新注入到 prompt 的 memo,后续一段时间内 runtime 仍然看到旧索引。service_test 已经把 cache invalidation 当成预期行为,这里需要把 context source 的失效钩子真正接进来。

a.rebuildTranscript()
return nil
}
entry := memo.Entry{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里把用户输入原样保存为 Title/Content,而 memo 索引随后会作为 system prompt 的一部分跨会话注入。这样用户只要执行一次 /remember Ignore previous instructions and ...,就能把任意指令持久写入系统上下文,形成稳定的 prompt-injection 面。至少要把 memo 作为数据块而不是可执行指令文本注入,并对换行/markdown/heading 等做约束或转义。

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found three correctness regressions in the new memo integration: config flags cannot be turned off, memo writes can leave the store inconsistent on failure, and runtime wiring never invalidates the memo prompt cache after writes.

Comment thread internal/config/model.go Outdated
if c == nil {
return
}
if !c.Enabled {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/memo/service.go Outdated
index.Entries = index.Entries[excess:]
}

if err := s.store.SaveIndex(ctx, index); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread internal/app/bootstrap.go Outdated
memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir)
memoSource := memo.NewContextSource(memoStore)
contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource)
memoSvc = memo.NewService(memoStore, nil, cfg.Memo, nil)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

发现 3 个需要处理的问题,分别涉及配置回归、memo 写入一致性,以及上下文缓存失效。

Comment thread internal/config/model.go Outdated
if c == nil {
return
}
if !c.Enabled {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MemoConfig.ApplyDefaults 这里会把显式配置成 false 的值重新覆盖成默认值 true。结果是用户无法通过配置关闭 memo.enabledmemo.auto_extract:YAML 读出 false 后在 Load() 里又被这里改回去了,保存时还会把 falseomitempty 省略掉。这个回归会让 memo 功能实际上变成“无法禁用”。建议像其他布尔配置一样区分“未设置”和“显式 false”,例如改成指针字段或单独的 persisted 层 presence 标记。

Comment thread internal/memo/service.go Outdated
index.Entries = index.Entries[excess:]
}

if err := s.store.SaveIndex(ctx, index); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里先 SaveIndex、再 SaveTopic,如果第二步失败,MEMO.md 已经持久化了一个指向不存在 topic 文件的条目,仓库状态会永久不一致。之后 /memo 能看到该条目,但 Recall/删除细节文件时会表现异常。建议把 topic 先写入临时文件并一起提交,或者在 SaveTopic 失败时回滚刚写入的索引。

Comment thread internal/app/bootstrap.go Outdated
memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir)
memoSource := memo.NewContextSource(memoStore)
contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource)
memoSvc = memo.NewService(memoStore, nil, cfg.Memo, nil)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

memoSource 带了 5 秒 TTL 缓存,但这里创建 memo.Service 时没有把缓存失效回调接进去,所以 /remember/forget 之后,后续几轮 prompt 里仍可能继续注入旧的 MEMO 索引,直到 TTL 过期。对“刚记住马上提问”的场景会直接读到陈旧上下文。这里需要把 InvalidateCache 连接到 service,或者让 context source 在每次变更后立即失效。

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found a few correctness issues in the new memo integration that are worth fixing before merge.

Comment thread internal/config/model.go Outdated
if c == nil {
return
}
if !c.Enabled {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/app/bootstrap.go
var memoSvc *memo.Service
if cfg.Memo.Enabled {
memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir)
memoSource := memo.NewContextSource(memoStore)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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.

Comment thread internal/memo/service.go Outdated
index.Entries = index.Entries[excess:]
}

if err := s.store.SaveIndex(ctx, index); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

发现 4 个值得在合并前处理的问题,主要集中在配置可关闭性、memo 一致性,以及记忆注入链路的即时性/输入稳健性。

Comment thread internal/config/model.go Outdated
if c == nil {
return
}
if !c.Enabled {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApplyDefaults 这里把 false 当成“未设置”处理了。defaultMemoConfig() 的两个布尔默认值都是 true,因此用户显式配置 memo.enabled: falsememo.auto_extract: false,在 load/save 一轮后都会被覆盖回 true。这会让这两个开关实际上无法关闭。

Comment thread internal/app/bootstrap.go Outdated
memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir)
memoSource := memo.NewContextSource(memoStore)
contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource)
memoSvc = memo.NewService(memoStore, nil, cfg.Memo, nil)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里创建 memo.Service 时把缓存失效回调传成了 nil,但上面已经创建了带 TTL 缓存的 memoSource。结果是 /remember / /forget 成功后,下一轮 prompt 里注入的 memo 仍可能是旧值,直到缓存过期。这个和 context_source_test 里验证的失效路径没有真正接上。

Comment thread internal/memo/service.go Outdated
index.Entries = index.Entries[excess:]
}

if err := s.store.SaveIndex(ctx, index); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add 先落 MEMO.md,再落 topic 文件。只要第二步失败,索引就会永久指向一个不存在的 topic,Recall 和后续上下文读取都会看到不一致状态。Remove 也有同类问题:先删 topic、后写索引,失败时会留下仍被索引引用但已经被删除的 topic。这里需要原子化这两个文件更新,或者至少做回滚。

a.rebuildTranscript()
return nil
}
entry := memo.Entry{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/remember 直接把原始输入同时写进 TitleContent。后续 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>
@wynxing

wynxing commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator Author

/simplify

@fennoai

fennoai Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

已完成本次 /simplify 请求,并已提交与推送到当前 PR 分支。

完成内容

  • 修复 memo.enabled / memo.auto_extract 显式 false 被默认值覆盖的问题
    • 持久化层改为 presence-aware(*bool)并补测试
  • 修复 memo 缓存失效回调未接线
    • BuildRuntime 中将 ContextSource.InvalidateCache 注入 memo.NewService
  • 修复 memo.Service 的索引与 topic 持久化一致性问题
    • Add 改为先写 topic 后写 index,失败时回滚
    • Remove 改为先写 index 再删 topic
    • 增加索引 clone,避免失败路径污染原引用
  • 修复 /remember 可破坏索引格式/提示结构的问题
    • 统一标题归一化(单行化 + () 转义)
    • memo 注入改为显式“数据块”格式,降低 prompt 注入风险
  • 按 code-simplifier 建议做了低风险简化
    • 统一标题归一化实现(memo.NormalizeTitle
    • 更正命名 handleForcreateCommand -> handleForgetCommand
    • context_source 抽取缓存有效性判断并去掉未用参数赋值

验证

  • go test ./internal/config ./internal/memo ./internal/tui/core/app ./internal/app
  • go test ./...

代码提交

  • Commit: 2b344a5
  • 已推送分支: fork-pr-228-1775900312

Fork PR

View job run

xgopilot and others added 2 commits April 11, 2026 10:49
- 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

codecov Bot commented Apr 11, 2026

Copy link
Copy Markdown

- 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>
@phantom5099

Copy link
Copy Markdown
Collaborator

冲突

@wynxing

wynxing commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator Author

/simplify

@fennoai

fennoai Bot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

处理结果

已按本次 /simplify 要求完成检查、简化、提交与推送。

Review 评论处理结论

已读取历史评论日志并逐条核对“未解决项”。之前指出的核心问题(memo 布尔配置持久化、memo 缓存失效回调接线、Add/Remove 持久化顺序、/remember 标题规范化)在当前分支已修复,本轮未发现需要继续修复的同类缺陷。

本次简化改动

  • internal/memo/context_source.go
    • 将缓存命中判断从 cachedText != "" 改为显式 cacheReady,避免空索引场景重复加载。
  • internal/memo/context_source_test.go
    • 新增 TestContextSourceCacheCachesEmptyIndex,验证空索引也能命中 TTL 缓存。
  • internal/memo/service.go
    • 清理文件尾部遗留无效注释。

验证

  • go test ./internal/memo
  • go test ./...

提交与推送

  • Commit: 3a02b84
  • 已推送到 PR 头分支:Yumiue:main

View job run

Generated with [codeagent](https://github.com/qbox/codeagent)
Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com>
@wynxing

wynxing commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator Author

/code 解决冲突

@fennoai

fennoai Bot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

处理结果

已完成冲突处理,并已直接更新 PR 头分支 Yumiue:main

  • 关键动作:将 origin/main 合入当前工作分支,逐个解决冲突文件(含 app/config/context/tui 相关冲突点)
  • 保留内容:本 PR 的 memo 能力(memo 注入、TUI memo 命令相关路径)
  • 对齐内容:主干的 config/state 选择服务接口与相关测试
  • 额外兼容:parseConfigWithContextDefaults 调整为兼容两种调用方式(含 memo 默认值)

验证

  • go test ./... 通过
  • gh pr view 228 --json mergeStateStatus 返回 CLEAN

提交与推送

  • 已推送到:Yumiue:main
  • 最新提交:1ad9d37

View job run

xgopilot and others added 2 commits April 12, 2026 08:40
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>
@phantom5099 phantom5099 merged commit 90045d0 into 1024XEngineer:main Apr 12, 2026
2 checks passed
@wynxing wynxing changed the title feat: 新增跨会话持久记忆系统 (memo) 及多项基础设施改进 feat(memo): 新增跨会话持久记忆系统——模型工具与自动提取 Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants