OpenWork 开源桌面应用的跨会话记忆:一层你能读能改的明文记忆
本文基于 openwork 仓库 commit 3b41381(2026-08-03)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/different-ai/openwork 最新代码与文档为准。
OpenWork 这个开源桌面应用在跨会话记忆上做的最关键一件事,是拒绝把记忆藏进一个你看不见的向量库:它存的是一条条明文句子,存在一张普通的 MySQL 表里,检索走的是全文索引的词面匹配,桌面端还给了你一个能逐条列出来、逐条删掉的面板。 代价是它不懂”意思相近”,好处是当它记错了一件事,你能当场看见那句话长什么样,然后按删除键。
这篇只讲 OpenWork 的记忆实现细节。Agent 记忆这件事本身该怎么想,站内另有三篇从不同角度切:Agent 记忆机制总览 讲记忆有哪几类、各解决什么;Agent 记忆分层设计 讲短期、中期、长期怎么分层不打架;记忆污染与主动遗忘 讲记错之后怎么收拾。那三篇是方法层,这篇是把方法落到一个你能当场 clone 下来读的具体实现上,看它在每个岔路口选了哪条、放弃了什么。
顺带说明一下命名:OpenWork 是这个开源桌面应用的项目名,跟同名的职场点评网站、跟”开放工作”这类泛指没有关系。仓库 README 这样定位自己:一个免费的开源桌面应用,用于分享 AI 工作流,是 Claude Cowork 和 Codex 的开源替代,支持 macOS、Windows 和 Linux。这是项目自己的说法,本文不替它背书,只看代码怎么写。
一、先分清两层:查历史会话,和存记忆库
很多人一说”跨会话记忆”就默认是一个长期记忆数据库。OpenWork 里其实是两套东西,解决的问题不一样,别混着用。
第一层是基于已保存会话历史的现场查找。仓库文档 packages/docs/start-here/do-work-with-it/cross-chat-memory.mdx 开头就把话说死了:这不是一个独立的长期记忆数据库,也不会把每一段旧对话自动塞进每一次新的提示。它的做法是让运行在应用里的智能体去调 OpenWork 的界面动作,把过去的会话当成一个可查的对象:session.list_sessions 列出应用已知工作区里的近期会话,session.open 按 ID 打开一个会话,session.read_transcript 读当前打开会话的近期消息,session.latest_message 读最新一条。
于是你可以直接问”客户迁移那个会话里我说了什么”,它的动作序列是:列会话,按会话 ID、标题、工作区或你给的主题词去匹配;只有一个明确命中就打开它;读记录并从返回的消息里回答;命中多个就反问你指的是哪个。
这一层的实现细节值得看一眼,因为它决定了你能问到多深。列会话的动作按更新时间倒序,只返回前 30 条;读记录的动作接受一个 count 参数,取值被夹在 1 到 30 之间,不给就默认 10 条,返回的是最近的这几条加上会话 ID、总消息数和实际返回条数。也就是说,一次读记录拿到的是一个有界的近期消息窗口。文档在”当前限制”里对应写了这一点,并要求智能体在老消息没被返回时如实说明,而不是猜。这不是性能优化的边角,是你提问方式的约束:越长的会话,越要靠精确的线索去定位,而不是指望它通读全篇。
还有一个使用上的副作用,文档写得很坦白:因为这一层走的是 OpenWork 的界面,智能体可能会短暂地把你从当前会话导航走,去看另一个会话,你需要让它看完再回来。
第二层才是记忆库,也就是本文的重点。它不是”翻旧账”,是把一条结论性的事实单独存下来。这两层在仓库里的位置也是分开的:面向用户的入门文档 57 份 mdx 里只有跨会话查找那一篇,记忆库的设计写在架构文档目录 docs/ 的 20 份 md 之一 docs/memory-bank-architecture.md 里,而代码已经落到了服务端与桌面端。
二、记忆库存的是什么:两张表,全是明文
记忆库的数据模型定义在 ee/packages/den-db/src/schema/memory.ts,两张表,读一遍就明白它的野心边界在哪。
主表 memory 的字段:主键 id、所有者 user_id、org_id、一个 scope 枚举(取值 user 和 org)、content 文本、source 字符串、tags JSON,加上创建和更新时间,另有一个建在 user_id 上的索引。content 就是那句人话,纯文本,没有向量,没有嵌入。
副表 memory_context 存出处:主键 id、指回主表的 memory_id、citation JSON、snippet 文本、origin 枚举(取值 active_conversation 和 searched_conversation)、创建时间。这张表是”我为什么会记住这件事”的凭证——一段原文摘录,加上可选的会话 ID 与消息 ID。架构文档把所有引用字段都设成可选,底线是一条没有任何 ID 的 snippet。
schema 文件里有一段注释交代了一个容易踩的工程现实:副表没有数据库层的外键约束,因为这套 den-db 遵循的托管 MySQL 惯例是不用外键,主表到副表的级联删除是在删除处理器里显式做的,靠 memory_context_memory_id 这个索引支撑。
scope 这个字段是留给未来的双库模型的,但当前不给客户端做主。路由文件 ee/apps/den-api/src/routes/memory/core.ts 的保存处理器里,写库的那一行是硬编码 scope: "user",source 也由服务端设成 "chat",注释明确写了:客户端就算 POST 一个 scope:'org' 过来也会被覆盖。所有读路径除了按 user_id 过滤,还额外加一条 scope='user' 的防御性条件——这样将来真的启用组织共享库,历史上的组织行也不会倒灌进个人视图。
保存是一个事务:主表一行、若干副表行一起进,避免出现没有主记录的孤儿出处行。整次保存共用一个时间戳,注释解释了原因——MySQL 没有 INSERT ... RETURNING,所以插入用的那个对象同时也是 201 响应体的来源,持久化的行和返回给你的内容不会漂移。
三、检索为什么是词面的:MySQL 全文索引的取舍
召回这块,架构文档的措辞值得整段抄给任何做记忆功能的人看。它把当前版本定义为显式的、词面的搜索:必须你开口问,它才查;不声称”理解语义”;不做自动回忆。文档里给的诚实价值线是”存下来的事实能在跨会话时用一句自然语言查回来”,而不是”智能体再也不用你重新解释一遍”。
实现上,ee/apps/den-api/src/routes/memory/shared.ts 里那个相关度函数是这样的:
/** Bound MATCH … AGAINST in NATURAL LANGUAGE MODE — never sql.raw (B/§3). */
export function memoryRelevance(query: string): SQL<number> {
return sql<number>`MATCH(${MemoryTable.content}) AGAINST (${query} IN NATURAL LANGUAGE MODE)`
}
搜索路由把这个表达式同时当作 SELECT 出来的 score、WHERE 里的一个条件、以及 ORDER BY 的排序键,再和 user_id、scope='user' 一起放进同一个 and(...)。查询串是绑定参数,不是拼接。没有命中时返回空结果集加 HTTP 200,不是错误——这样智能体能自然地说一句”没找到”,而不是把一次正常的落空报成故障。
这里有一个真正的工程坑,值得单独说,因为它是”设计对了但会静默失效”的典型。Drizzle 的 mysql-core 没有表达全文索引的 DSL,所以这个索引没法写在 schema 里。而全新安装的数据库是从构建期的当前 schema 快照引导出来的,看不见一个原始 SQL 建的全文索引;基线步骤又会把迁移标记成”已应用但不执行”。两头一夹,只写在迁移里的索引在生产环境上会悄无声息地不存在,而在逐步迁移过来的开发库上一切正常。
修法在 ee/packages/den-db/src/fulltext.ts:把建索引做成一个幂等函数 ensureMemoryFulltextIndex,先查 information_schema.STATISTICS 看索引在不在,不在才 CREATE FULLTEXT INDEX;并发创建撞车时再查一次而不是直接抛。引导路径和迁移后的钩子都调同一个入口 ensureFulltextIndexes,两条路不会分叉。旁边还有一个 assertMemoryFulltextIndexExists,索引缺失就抛错,让配置错误的库大声失败,而不是把搜索静默退化成永远查不到东西。
顺带一提,这个文件里还有一段把两张表的 created_at 默认值规整成可移植写法的逻辑,同样是幂等的先查再改。这类”两条部署路径必须收敛到同一个函数”的写法,比记忆功能本身更值得抄。
四、组成部分对照表
下面这张表列的仓库位置都是本文实际读过的文件。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 会话查找界面动作 | 列会话、按 ID 打开、读近期记录 | apps/app/src/react-app/domains/session/control/session-control-actions.ts、apps/app/src/react-app/domains/session/surface/session-surface.tsx | 你问”上次那个会话里我说了什么” |
| 记忆与出处两张表 | 明文事实 + 引用摘录 | ee/packages/den-db/src/schema/memory.ts | 想知道到底存了哪些字段时 |
| 四条 REST 路由 | 保存、搜索、列出、删除 | ee/apps/den-api/src/routes/memory/core.ts | 排查权限、返回码、事务问题 |
| 入参上限与相关度表达式 | 校验、绑定参数、词面排序 | ee/apps/den-api/src/routes/memory/shared.ts | 内容被截断或搜不到时 |
| 全文索引幂等创建与断言 | 让索引在两条部署路径上都存在 | ee/packages/den-db/src/fulltext.ts | 自建部署、搜索恒空时 |
| 记忆管理面板 | 列出、删除、撤销、复制提示词 | apps/app/src/react-app/domains/settings/pages/memory-view.tsx | 你要亲眼看它记了什么 |
| 客户端开关 | 控制面板可见性 | apps/app/src/react-app/domains/settings/state/feature-flags-preferences.ts | 找不到入口时 |
| 智能体提示词段落 | 教它怎么发现并调用能力 | apps/server/src/openwork-runtime-config.ts | 想改它的存取行为时 |
| 端到端评测流程 | 证明存进去能跨会话查回来 | evals/flows/memory-save-recall.flow.mjs | 改动后想验证没退化 |
| 架构文档 | 决策与取舍的原始记录 | docs/memory-bank-architecture.md | 想搞清楚”为什么这么做” |
需要提醒的一点:上表里 ee/ 开头的两项——服务端 API 与数据库包——落在仓库的分层许可里。仓库根目录的 LICENSE 写明,/ee 目录下的全部内容按 ee/LICENSE 定义的 Fair Source 许可证;这个目录之外的部分才是 MIT(Copyright 2026 Different AI)。ee/LICENSE 的抬头是 Functional Source License, Version 1.1, MIT Future License,署名 Copyright 2026 Different AI Inc。也就是说,记忆库的服务端实现不是笼统的”MIT 开源”。你能不能商用、能不能改,一律以许可证原文为准,本文不提供法律意见。
五、你怎么看见它记错了什么
这才是标题里那句话的落点。桌面端有一个记忆面板,代码在 apps/app/src/react-app/domains/settings/pages/memory-view.tsx,它做的事很朴素:调列出接口,把每条记忆的 content 直接渲染成一行标题,有出处就把 snippet 拼在下面当描述,有标签就再列一行,右边一个删除按钮。文件里有一句注释说明了为什么这样渲染是安全的——React 会转义文本子节点,所以存进去的内容原样显示不会变成脚本。
删除这段的处理很有意思,可以直接拿去当”可撤销的破坏性操作”的范本。它没有走”先删再重建”的假撤销,而是把服务端删除推迟执行:确认后先把这条 ID 放进一个待删集合,界面上通过一层过滤把它藏起来;真正的删除请求排在一个定时器上,撤销提示条的存活时间比这个定时器更短,这样你永远不会看见一个已经撤销不了的撤销按钮。注释写明了推迟的理由:这是真正的反悔,原来的 ID 和时间戳都还在。
细节还不止于此。待删集合是一层”面纱”,查询缓存里保留的仍是服务端返回的原始列表,所以后台自动刷新不会让一条正在删除的记录复活。发起删除时会把当时的组织 ID 和客户端捕获下来,哪怕你在撤销窗口里切换了组织,那次删除仍然打在你按下按钮时所在的组织上。组件卸载时会遍历所有待删项,清掉定时器并立刻提交——也就是说,在撤销窗口内关掉页面,删除照样生效。删除完成后焦点会被移回工具栏,因为列表可能整体切换成空状态,键盘用户不至于被扔在一个已经消失的行上。
面板的入口由一个客户端开关控制。feature-flags-preferences.ts 里那个 featureFlags.memory 是存在本地偏好里的,逐台设备生效,不同步。架构文档把它定性为软开关:只控制界面可见性,路由本身照样可调用,依然由所有者作用域和鉴权把守。所以关掉开关不等于关掉功能,只是你看不见那个面板。
至于智能体怎么知道去存、去查,答案在 apps/server/src/openwork-runtime-config.ts 里那段追加的 ## Memory Bank 提示词。它交代了三件事:记忆库是走元 MCP 通道的每用户存储,不是本地文件,绝对不要把记忆写进 .opencode/ 或任何文件;没有专门的记忆工具,要先用 search_capabilities 发现能力,再用 execute_capability 执行;保存流程是先起草一句自洽的 content,连同可选的引用片段一起给人看,人确认或修改后才真正落库,永远不持久化未经确认的原始输出。取回流程则被写成”只在被问到时搜,绝不自动回忆,也不要声称理解语义”。最后一段是硬约束:不要把密钥、凭据、API key、令牌和敏感个人信息写进记忆,正文和引用片段都算。
六、边界与代价:它明确不管的事
这一节按仓库里写下来的口径讲,别自行加戏。
不做语义召回。 向量库、嵌入、语义回忆,架构文档在”不在本版范围”一节里第一条就列了。演进路线表把当前的全文检索标为可换成向量或混合排序,但那是未来,而且明确说不需要改契约。
不做自动回忆。 会话开始时的被动回忆是考虑过之后砍掉的。这意味着记忆不会自动进上下文,你不问它就不查——好处是上下文预算可控,代价是它照样可能让你重说一遍。
落库不加密。 content 和 snippet 在当前版本是明文。文档写明仓库里其实有加密文本列的能力,但这版没用,唯一的缓解手段就是上面那段提示词里”别存密钥”的约束,并把这条记为一个已知并接受的风险,把落库加密列为正式发布前的必要项。你要不要把公司信息交给它,这个前提得先想清楚。
词面匹配有它够不到的地方。 文档点名了托管数据库上最小分词长度与停用词不可调这一现实(并把具体影响标记为待在实现时验证),结论是:太短或太常见的词可能悄悄匹配不上。它给的应对不是”去调参数”,而是把功能的价值主张说小一点。
删除账号不等于删除记忆。 文档在待办清单里写着:没有数据库外键、也没有离职下线钩子,所以删掉一个用户或组织不会连带清掉他们的记忆行,清理钩子和落库加密一样被列为正式发布前的要求,当前版本只能靠那条显式删除接口。这是文档里的原话记录,不是我的推断,但对要不要在团队里铺开这功能,它是个硬信息。
它依赖登录与组织上下文。 面板在未登录时只显示一段登录提示,没有活跃组织时也只显示一句提示。保存路由走的是组织成员校验:没有活跃组织、或者不是该组织成员的调用者会被 404 挡掉。保存时会记下 org_id,但当前版本的读取只按用户过滤、不按组织过滤,注释解释了这个选择——让你在自己所有组织之间都能查回自己的记忆,org_id 是给将来的组织共享库留的。这也意味着:你的记忆是存在服务端的,不是本地文件。凭据集中保管、桌面应用代管模型访问、记忆内容托管在远端,这三件事叠在一起的暴露面,是你在决定用不用它之前就该算清楚的账,而不是出事之后再算。
它不管编辑。 当前版本只有存、查、列、删,改一条已存记忆的能力被明确推到了未来。记错了只能删掉重存。
输入有硬上限。 shared.ts 里那组常量是廉价的滥用防护:单条内容长度、标签数量与长度、引用条数与片段长度、查询串长度、搜索与列出的返回上限,都有上界,搜索默认返回条数也有上限收口。完整的按用户配额与限流被明确推迟了。
七、上手与避坑清单
别去找一个叫 memory_save 的工具。 会踩是因为绝大多数平台都会给记忆配一个专用工具,你的直觉会去搜这个名字。这个项目走的是另一条路:整条云端通道只暴露两个工具,发现能力和执行能力,记忆是被”发现出来”的一个能力而不是一个内建工具。架构文档甚至专门写了一句,不要在提示或文档里提那两个不存在的名字。避法:先用发现能力搜”保存一条记忆”这类说法,拿到真实名称再执行。
别指望短词能搜到。 会踩是因为你按平时用搜索引擎的习惯扔两个字进去。词面检索加上不可调的最小分词长度与停用词表,短词和常见词可能一个都不命中,而返回空集是 200 不是报错,你不会收到任何警告。避法:存的时候就把 content 写成一句自洽的完整陈述,尽量带上专有名词、项目名、人名这类可检索的锚点;查的时候用当初存进去的词,而不是同义改写。
别把撤销当后悔药用。 会踩是因为界面给了撤销按钮,你会默认”关掉窗口这次删除就不算数”。实际相反:组件卸载时会把待删项立刻提交。避法:要撤销就在提示条还在的时候点,别切页面、别关窗口。
找不到面板先查开关,再查登录。 会踩是因为这个开关是本机本地的、不跨设备同步,你在另一台机器上打开过不算数;而且开关只管界面可见性,不管接口。避法:在偏好设置里确认那个记忆开关是开的,再确认已登录且有活跃组织。
自建部署一定要确认全文索引真的建出来了。 会踩是因为这个失败是无声的:索引不存在时搜索不会报错,只会永远返回空,你会以为是”没存进去”而去查保存链路。避法:走仓库提供的引导流程,让那个幂等建索引的函数跑到;有条件就把那个断言函数挂在启动或持续集成里,缺索引直接失败。
别把密钥写进记忆。 会踩是因为起草那句话的是模型,它可能顺手把上下文里的令牌抄进 content 或 snippet。当前版本落库是明文,唯一的防线就是提示词加你自己的确认这一步。避法:确认环节逐字读一遍再点确定;引用片段里的敏感串在保存前手动抹掉。
查旧会话时留意它会跑掉。 会踩是因为跨会话查找走的是界面动作,智能体可能真的把你导航到另一个会话去读。避法:给足线索让它一次命中——会话 ID、标题、人名、项目名、工作区都行;看完让它回到原来的会话。
收束:先读哪三个文件
如果你要评估这套设计能不能抄进自己的产品,按这个顺序读:docs/memory-bank-architecture.md 看它在每个岔路口选了什么、放弃了什么,尤其是那份”不在本版范围”的清单;ee/apps/den-api/src/routes/memory/core.ts 看所有者作用域、服务端覆写客户端字段、单事务保存和非泄漏的 404 是怎么落到几十行里的;apps/app/src/react-app/domains/settings/pages/memory-view.tsx 看一个可撤销的破坏性操作在真实工程里要处理多少非顺利路径。
要用它,先问自己三句话:我打算存进去的东西,明文躺在别人服务器上我能接受吗?我记的这句话,半年后用当时的词还查得到吗?如果它记错了,我知道去哪里看、按哪个键删吗?第三个问题在这个项目里有明确答案,这就是它相比黑盒记忆最实在的地方。前两个问题的答案,得你自己给。
想把这套判断放回更一般的框架里,回头再看 Agent 记忆机制总览 和 记忆污染与主动遗忘;如果你关心的是”能力被发现而不是被内建”这条通道本身,可以顺着 MCP 协议是什么 往下看。
本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 OpenWork 开源桌面应用怎么接模型:三条路径的机制与代价 和 开源桌面应用 OpenWork 让 Agent 开浏览器干活的两条路:内置面板与 macOS 屏幕操作。