记忆与同步
记忆是 oHarness 内置的一个架构插件(memory),不是 MCP 服务器。它注册记忆工具、系统提示词中的索引、逐条消息的召回、可选的提取、Memory 页面和一个后台同步器,全部通过与模型、工具、审计插件相同的 PluginHost 完成。页面扩展保持精简:只有注册的描述符,加上宿主自带的渲染器(目前是 memory),而不是任意的前端 JavaScript 或 iframe SDK。
笔记存在哪里
每条笔记一个 Markdown 文件,带 JSON frontmatter:
---
{"id":"pricing-borderless-3fa","summary":"Pricing cards use fills, not borders","type":"feedback","tags":["design"],"createdAt":1789000000000,"updatedAt":1789000000000}
---
Cards on the pricing page are separated by fill and whitespace.
**Why:** the outlines were the loudest thing on a page of numbers.
**How to apply:** keep the border declared but transparent. See [[pricing-layout-a91]].
| 作用域 | 目录 | 存放内容 |
|---|---|---|
project | <project>/MEMORY/ | 随项目移动和导出的决策、约束与参考信息 |
user | ~/.oharness/memory/user/ | 用户是谁、喜欢怎样工作,适用于所有项目 |
每个已注册的项目都带有 .oharness/project.json。其中稳定的 id 让 Home 侧的会话、同步状态和召回统计在文件夹被重命名或导出后仍然关联在一起。Git 仓库最初用它的根 commit 作为这个 id;其他目录会获得一个随机的项目 id。OHARNESS_HOME 会移动用户笔记和本机状态,但永远不会移动项目的 MEMORY/ 目录。
配置中的 memory.dir(或旧的 memoryDir)可以覆盖项目目录,路径相对于项目根目录。用户目录无法创建时,项目笔记仍然可以在 <project>/MEMORY 中使用;只有用户级笔记和 Home 侧的记忆状态不可用,并会给出警告。
类型
| 类型 | 默认作用域 | 记录什么 |
|---|---|---|
user | user | 用户的角色、专长和工作偏好 |
feedback | project | 一次纠正,或用户认可的一种做法 |
project | project | 一个决策及其理由、一条约束、无法从代码推导出的进行中工作 |
reference | project | 外部信息在哪里 |
只有 user 笔记默认放在 user 作用域。反馈默认放在项目中,因为大多数纠正针对的是某个代码库的工作方式;明显到处适用的反馈,会被有意放进 user 作用域。
在有类型之前写下的笔记没有类型,照常可用。只有存在 type 时,它才计入笔记的内容哈希,所以已有笔记保持同步基线中已记录的哈希不变。
写笔记
模型用 remember 写入,用 memory 读取,用 forget 删除。来自记忆插件的 remember 在除 plan 之外的所有权限模式下都无需询问直接执行;在 plan 模式下,它会像其他写入一样拒绝它。点名 remember 的 ask 或 deny 规则仍然生效,forget 仍然会询问,其他插件里叫 remember 的工具也享受不到这种豁免。保存笔记前,标签会去除首尾空白、按逗号和空格拆分并去重。系统提示词的 Memory 部分说明了什么时候值得写笔记、什么时候不值得。remember 会返回与刚写内容最相关的已有笔记,标出可能的重复,并列出指向尚不存在的笔记的链接。任何看起来像凭据的内容都会被拒绝。笔记被要求为别人可能用到的其他说法打上标签:同义词,以及正在使用的另一种语言中的对应术语,因为召回匹配的是词,而不是意思。
笔记在正文中用 [[note-id]] 相互链接。链接在每次加载笔记时从正文推导出来;不会单独存储任何链接信息,同步协议也不知道它们的存在。链接先在项目作用域中解析,再到 user 作用域。指向不存在笔记的链接会被保留:它通常指的是某人本打算写的一条笔记。
自动提取
默认关闭,因为每一次都是用用户自己的额度进行的模型调用。启用 memory.autoExtract.onStop 后,每次运行结束时,只要自上次提取以来积累了至少 minUserTurns 条真实的用户消息,就把这段对话交给小模型。启用 onCompaction(或 onStop)时,压缩即将丢弃、而还没有任何一次提取读过的那部分,会先交出去,不论多短,因为这是读取它的最后机会。恢复的会话从恢复处开始计数,而不是把整个历史重新提取一遍。子 agent 从不参与提取。
提取器会看到与对话最相关的已有笔记。最相关的几条附带完整正文(在大小预算内),并且只有这些可以被更新;其余的只列出摘要,供建立链接。更新会替换正文,所以被替换的版本会先备份到 ~/.oharness/memory-backups/。它的回复被视为不可信:对没给它看过的 id 的更新会被丢弃,指向没给它看过的 id 的链接会去掉方括号,凭据会被涂掉,同一批次中的新笔记在获得 id 之前用 [[new:N]] 相互引用。仓库的配置可以关闭提取;只有用户自己的配置能开启它。
提取在触发它的那次运行结束后才完成,所以它保存的笔记会在下一条消息开始时公布:「从这段对话保存了 N 条记忆」,每个前端都一样。
召回
有两条路径把笔记呈现给模型:
- 索引。每次运行开始时,每条笔记一行,最近更新的排在前面,最多
memory.indexChars个字符。超出的部分会被计数,而不是丢失;memory工具会搜索所有笔记。 - 逐条消息召回。每条用户消息都会与所有笔记打分,最佳匹配附加在该消息的
<recalled-memory>中,它们链接到的笔记以摘要形式列出。前端会显示「召回了 N 条记忆」。没有任何笔记过阈值时,什么都不附加——这是常见情况。完全没有笔记时,索引会被替换为两行说明何时该写笔记的文字,而不是完整的指引。
打分基于词法,在本地运行:单词会做词干化,CJK 文本切分成重叠的双字组,摘要和标签中的匹配权重高于正文中的匹配。笔记必须在摘要或标签中匹配,才会被附加。一条笔记被附加过一次后,只要携带它的消息还在会话记录中,就不会再次附加,包括会话恢复或应用重启之后;这些 id 会从消息的元数据中读回。压缩会释放那些所在消息被总结掉的笔记。附加的笔记位于用户消息中单独的块里,记录在消息的元数据中,所以会话记录、会话标题和 Web 历史显示的都是用户自己的话。子 agent 不做召回。
召回失败永远不会阻止消息发送。
限制
| 设置 | 默认值 | 含义 |
|---|---|---|
memory.maxNotes | 5000 | 每个作用域。超过后,最久未修改的文件不会加载,并有警告说明数量;在删除一些之前,新笔记会被拒绝 |
memory.maxNoteBytes | 131072 | 正文大小;同步服务器也有自己的限制 |
memory.indexChars | 4000 | 系统提示词中摘要的字符数 |
memory.recall.enabled | true | 逐条消息召回 |
memory.recall.perTurn | 3 | 附加到一条消息上的笔记数 |
memory.recall.budgetChars | 2000 | 这些笔记可占用的字符数 |
memory.recall.minScore | 1.2 | 附加的阈值 |
memory.autoExtract.onCompaction / onStop | false | 自动提取 |
memory.autoExtract.minUserTurns | 3 | 更短的对话会被跳过 |
每条笔记被召回的次数按机器保存在 ~/.oharness/memory-usage/ 中,不在笔记里,所以召回永远不会触发同步上传。它会微调排序,并且从不同步。
云同步
- 笔记在本地始终可用,未登录、离线也一样。
- 登录后默认开启自动同步。同步是双向的:本地改动上传,其他设备上的改动也会自动下载。
- Memory 页面提供 关闭云同步 和 立即同步。这个开关同时覆盖两个作用域。关闭状态在重启和重新登录后仍然保留,并且在明确重新开启之前,会阻止自动和手动的云端请求。本地工具和编辑照常可用。
- 关闭同步会取消正在进行的请求。已被 Cloud 接收的数据无法撤回;关闭同步不会删除云端副本。
- 每个逻辑上的项目 / 用户作用域都固定绑定到第一个为它同步的账号和 Cloud origin。更换账号或 origin 会暂停同步。页面不提供一键式的不安全重新分配。
页面维护 ~/.oharness/plugins.json(可用 OHARNESS_HOME 覆盖),位于 runtimePlugins[id=memory].config.syncTargets[sha256(stableScopeKey)] 下:
{ "userId": "verified-account-label", "origin": "https://your-cloud.example", "enabled": false }
不要把 R2 key 或 Supabase 配置放进这个文件。Cloud 的 URL 仍然放在 cloud.json 中;R2 凭据只存在于同步部署的环境里。要禁用整个插件(页面、工具、提示词、召回和后台工作),在 runtimePlugins 中添加 { "id": "memory", "enabled": false };这会在下次创建 Harness 时生效。项目注册仍然会创建标准的空 MEMORY/ 和 ARTIFACTS/ 目录。对于嵌入方,harness.memory 是项目存储,harness.memoryUserService 是用户存储;插件禁用时两者都不存在。AgentOptions.memory 仍然是一个更底层的嵌入 API,无需插件宿主也能获得召回。
什么时候同步
每个进程有一个协调器,调度所有笔记目录,不管本地 Web 服务器或桌面应用打开了多少个项目。每个目录在进程中只有一个同步服务,所以用户自己的笔记只同步一次,而不是每个打开的项目同步一次。
- 本地写入大约三秒后上传;一连串写入合并为一次。
- 否则每分钟检查一次目录,两边都没有变化时逐渐退避到每五分钟一次。
- 你自己的笔记始终会检查。项目的笔记只在项目使用期间(在其中发送了消息,或 Memory 页面处于打开状态)以及之后三十分钟内检查。
- 进程把自己限制在每分钟约二十个请求,为其他设备在同步服务的每账号限制之下留出余量。
- 遇到
429时,所有目录暂停到其Retry-After指定的时间,页面会说明同步过于频繁、稍后重试,而不是归咎于配置。
立即同步 总是立刻执行。
页面显示什么
开关、「上次同步」和错误都覆盖两个作用域:只有两个作用域都开启时,同步才算开启;显示的时间取两者中较早的那个;每个错误都会说明它针对的是项目笔记还是你自己的笔记。页面还会显示上次拉取带下来的内容(新增、修改、删除),免费方案下还会显示方案的限制。
整理笔记
页面的 待整理 列表是在本机上运行的一次检查,不使用模型:
| 发现 | 含义 | 你可以做什么 |
|---|---|---|
| 主题相同 | 同一作用域中两到六条笔记,摘要和标签大部分重叠 | 合并,或忽略 |
| 失效链接 | 一个指向不存在笔记的 [[id]] | 打开笔记,或忽略 |
| 文件缺失 | 项目笔记提到了一个项目中不存在的相对路径 | 打开,或忽略 |
| 未使用 | 在 memory.consolidate.unusedDays(90)天内既没有被召回也没有被编辑 | 打开,或忽略 |
| 过长 | 正文超过 8,000 个字符 | 打开,或忽略 |
忽略记录会针对笔记的当前版本保存在本地,所以编辑其中任何一条,这个发现都会重新出现。
合并会打开一个带草稿的编辑器:草稿来自小模型(生成合并建议),或者把所有原笔记拼在一起(手动合并)。应用合并会保留最早那条笔记的 id,删除其他笔记,把该作用域中所有 [[deleted]] 链接指向保留的笔记,并备份它改动的每条笔记。如果计划合并的笔记在此之后发生了变化,合并会被拒绝。
开启云同步时,会先同步该作用域,并在写入本机之前先把合并提交到云端(commitFirst),这样如果期间另一台设备做了编辑,合并会被拒绝,而不是变成冲突。合并后的正文必须符合同步服务的单条笔记限制。关闭同步时,合并只是一次普通的本地编辑。
后台合并默认只对付费方案开启("auto": "subscribers"):当同步服务最近一次回复表明该账号或其所属的任何团队处于付费方案时运行;对免费账号以及方案未知(云同步关闭、未登录)的情况保持关闭。设为 true 或 false 可以覆盖这一行为:
{ "memory": { "consolidate": { "auto": false, "cooldownHours": 24, "maxMerges": 3 } } }
开启后,启动两分钟后开始一次检查,每个冷却周期最多一次,用小模型合并最多 maxMerges 组主题相同的笔记,走的是与页面相同的路径。它从不处理其他类型的发现。项目配置可以关闭它,但不能开启它。
备份与恢复
在任何笔记被替换或删除、而没有人明确选择那个版本之前,都会先写一份副本到 ~/.oharness/memory-backups/:在页面上删除、forget、在冲突中采用云端版本、自动拉取修改或删除了笔记、提取器的改写,以及恢复操作替换了较新的版本。Memory 页面列出最近的五十份备份,注明时间和原因,并可以把其中任何一份恢复到它原来所在的目录;之后这条笔记会像其他本地改动一样同步。在开始记录原因之前写入的备份同样会列出,也可以恢复。
存储与协调
同步服务器提供需认证的 /v1/memory/identity、/snapshot 和 /commit。它不存储任何数据库行。R2 保存不可变的 Markdown 修订版本,外加一个以原子方式替换的 JSON 清单:
users/<server-verified-user-id>/projects/<project-id>/memory/
users/<server-verified-user-id>/memory/ user scope
manifest.json
objects/<note-id>/<revision-id>.md
实现可以使用其他身份或存储系统;确切的请求结构、哈希算法、CAS 行为和错误码见同步协议。
本地基线(~/.oharness/memory-sync/)支持三方协调:未变化的笔记会跳过,单边的改动直接复制。客户端还在旁边保留每条笔记上次同步时的副本,所以两边都改过的笔记会被合并,而不只是标记出来:
- 摘要和类型取自改动了它们的那一边;
- 标签按集合合并,保留任意一边新增的,去掉任意一边删除的;
- 正文按行合并,不相邻的编辑会合并在一起;
- 编辑与删除冲突时保留编辑,因为再删一次只需点一下,而丢失的编辑就找不回来了。
合并前两个版本都会备份。无法合并的情况(两个不同的摘要或类型、对同一行或相邻行的编辑、超出方案限制的合并,或者某条笔记没有上次同步版本的副本)会成为冲突。冲突只会阻塞它自己的那条笔记:其他所有内容仍然正常上传和下载。Memory 页面会显示上次同步的版本、本机的版本和云端的版本,并提供以下选项:保留本机版本、采用云端版本、两者都保留(云端版本成为旁边的一条新笔记),或手动合并;无论被放弃的是哪一边,都会备份。不存在「最后写入者胜出」的覆盖。删除以墓碑记录表示,清单写入使用 ETag 比较并交换(compare-and-swap)。
没人逐条审查的批量改动(整理合并)会先写到云端:涉及的每条笔记都必须完全同步,提交带上检查时的清单版本,只有服务接受后,才把这批改动写入本地。期间来自另一台设备的改动会被服务拒绝,而不是变成冲突。
请求会列出本机持有的笔记的哈希(have);服务器既不读取也不返回这些正文,所以变化不大的同步只传输很少的数据。提交会按服务器公布的改动配额拆分。超过服务器保留窗口的墓碑记录会从清单中删除;一台在那之后才回来的机器,如果发现一条它上次看到时处于同步状态、之后未改动的笔记不再列出,就会删除它,而它在此期间编辑过的笔记会成为冲突。仅大小写不同的两个笔记 id 会被拒绝,因为 macOS 和 Windows 的文件系统无法同时容纳两者。
同步在暂时性失败后会冷却 60 秒再重试;手动同步可以立即重试。冲突会等待用户处理。页面会把被删除的笔记以及冲突中被放弃的本地版本备份到 ~/.oharness/memory-backups/ 下;forget 也一样。直接在文件系统中编辑仍然受支持,但合并期间并发的外部写入不受进程内写锁保护;每个目录请只保持一个运行中的进程。
R2 是私有的并使用 TLS,但这不是端到端加密:Cloud 可以读取笔记内容。只有笔记会同步,会话日志、文件和凭据都不会。
配额与方案
配额由服务器掌握,并在每个响应中公布;客户端不保留副本。默认值:每个项目和每个用户 5000 条有效笔记,每次提交 500 处改动,每条正文 128 KiB,每个响应 16 MiB 正文,每条墓碑记录保留 90 天。降低配额只会阻止增长,所以超出配额的用户始终可以删除。相关变量见运行你自己的同步服务器。
免费账号在此之上还有更严格的限制:每个项目和每个用户的笔记数量,以及每条笔记的大小,定义在同步服务的 src/memory-entitlements.json 中。付费账号没有方案限制,只受上述配额约束。方案从 Cloud 写入 access token 的 ent 声明中读取:用户自己的付费方案,或其所在任何团队的付费方案,都算付费。只有云同步受限;本机上的笔记不受限,超出限制的免费账号会保留所有本地笔记,只是停止上传新笔记,并有消息说明原因。
删除账号
在网站上删除账号会清除它同步过的所有内容:两个作用域中的会话流和记忆。网关会在删除账号本身之前调用同步服务的 POST /v1/admin/erase,使用一个只有它持有的 key(两边都是 OHARNESS_SYNC_ADMIN_KEY),并在删除之后再调用一次。同步服务会先为该账号写入一个标记,此后拒绝它的会话流和记忆请求,这样仍然登录着的设备就无法再次上传数据。你自己机器上的笔记和会话不受影响。
删除团队时,团队共享的会话历史也会这样处理:网关在取消团队订阅之后、删除其数据行之前,清除 feed/team-<id>/(同步服务的 {team} 清除),之后再清除一次。此后同步服务会拒绝该团队的会话流,即使某个 token 仍然列着这个团队。团队不持有记忆。
升级
先升级同步服务器,再升级客户端。新客户端面对旧服务器时,对于没有类型的项目笔记会退回到旧协议。它无法把用户级笔记或带类型的笔记同步到旧服务器。旧客户端从云端收到带类型的笔记后,会因校验和错误停止同步,直到升级为止。
验证与部署
运行 npm run test:memory、npm run test:sync-memory、npm run test:plugins、npm run typecheck 和 npm run build。script/memory-ui-demo.ts 会在 4319 端口启动一个隔离的、未登录的 UI,使用临时主目录;它从不读取用户的凭据。缺少 R2 配置会显示一个可见的同步错误,但不会破坏本地记忆。修改源码后请重启本地开发服务器。