跳到主要内容
预览和真跑共用一段代码,但 memshare 只守住了导出的那一秒

预览和真跑共用一段代码,但 memshare 只守住了导出的那一秒

阿舟
阿舟

· 阅读约 8 分钟

上周日晚上,clone 仓库,npm install,build,然后跑 bash examples/try-it.sh。

git clone https://github.com/kampana/memshare
npm install && npm run build
bash examples/try-it.sh

脚本在临时目录里造两个假 store,跑一遍 capture、PII 拦截、导出、逐条导入,完事自己删干净,不碰你本机的任何 config。

跑完第一反应:这作者被"教程脚本顺手改了我本机配置"坑过。整段脚本没有一行碰 ~/.memshare。

第二反应才是正题。

预览和真跑走同一段代码,这件事被严重低估了

memshare 的同意模型有四道闸——创建时打可见性标签,导出前在发送方本机扫一遍 PII,真导出前给你预览,接收方那边再逐条 accept 或 reject。

四道闸里,我只想给第三道的实现方式鼓掌。

预览和真实导出,入口是同一个函数:selectForExport。同一段过滤逻辑,同一份可见性判断。预览不是"模拟一遍导出",预览就是导出——只是把结果打出来,不落盘。

我在自己项目里干过相反的事,代价不小。之前那次数据库迁移,dry-run 是一个单独写的简化函数,真跑是另一套。头两个月两边还一致。第三个月我在真跑那侧加了一个"跳过软删除且 updated_at 在最近 24 小时内"的条件,dry-run 那侧忘了同步。你猜后面发生了什么……

画外音:不用猜,生产表上那几条被"跳过"的记录,后来是我一条一条从归档里捞回来的。

所以看到 memshare 那句"基于一个可能和真实行为分叉的预览所做出的同意,不是有效的同意"(原话大意),我是认的。这句话把一个大家默认当 UI 细节的东西,提到了理论上。预览分叉这事,平时没人当事故——预览嘛,大概齐就行。可一旦你把同意权建立在预览之上,预览失真就等于同意作废。

同样的洁癖还在别处:bundle 走 content hash,路上被人改一个字节就拒收。

但这四道闸,全都守在同一个时刻

写到这里我要拐一下。标签、扫描、预览、逐条接受——四道闸全部发生在导出和导入这两个动作附近。也就是"能不能把这条发出去"和"要不要收下这条"。

没有人守"收下之后"。

评论区有个叫 Edward 的把这事说破了:导入进来的东西是一份拷贝,跟源头没有任何活的连接。发送方后来改了那条 memory,接收方手里还是旧的,而且不会收到任何通知。他甚至给了个挺干净的方案——bundle 里保留原始 item id,以后新 bundle 可以标一句"X 替换了 Y",在接收方的预览里按同一套 accept/reject 流程提示。

作者回答说,其实导入时已经记了 importedFrom.originalId、bundleId、exportedBy,但现在只用来做溯源展示,没用来做更新匹配。

顺着这条线再往下,还有个更烦的问题:过期。Mihai 问得挺狠——一条 memory 记着"这个项目用 Postgres",团队后来迁走了,文件里没有任何东西会告诉你它过期了,除非有人拿它去跟当前状态对一遍。

这问题其实分两层。

第一层是版本。作者自己交代了:目前一个 UUID 一个文件,last-write-wins,没有历史。文件里有 createdAt、updatedAt,还有个可选的 expiresAt TTL,但没有任何主动的过期提醒。

第二层更微妙:updatedAt 这个字段,其实什么信息量都没有。一条你三月份写下来、之后再没碰过的 memory,updatedAt 停在三月——它看起来"老",但它不老,它只是没人验证过。"没被动过"和"没人确认过对不对",在这个文件里长得一模一样。

我大概是在这儿对它失望的。一个叫 memory 的东西,用 last-write-wins 覆盖,没有历史,也没有"这条是什么时候被确认仍然成立"的记录。那它记的不是历史,是当前信念。当前信念可以很有用,但那跟"记忆"是两回事。有人上个月把一个判断改错了,你想看它改之前长什么样——文件里没有。

作者自己提了个方向,我觉得是对的:加一个 confirmed-at 时间戳,只在有人显式重新确认时更新。这样"最后一次被碰"和"最后一次被确认仍然正确"就分开了。再加一个导出预览里的 age 标记,老东西提前示警。Mihai 回了句我觉得很到位的话——这是一种"廉价但诚实"的版本化。不上全量版本历史的开销,但把最要命的那个歧义消掉了。

身份那块,我跟作者意见不太一样

Edward 还有第二个提议:init 时生成一对密钥,每个 bundle 签名,接收方第一次导入时把发送方的 key fingerprint 钉住——跟 SSH 的 host pinning 一个套路。之后更新匹配就用 fingerprint 加 originalId。

作者的答复是:签名是对的,但方向应该反一下——先做 origin-aware matching,签名等验证真有必要了再加。理由是,现在 bundle 都是直接递给已知接收人的,身份隐含在投递渠道里,不用显式验证。

我在这儿犹豫了一下,然后倾向不同意。

理由很具体:更新匹配如果用 exportedBy + originalId 来做,而 exportedBy 只是个显示名(他自己也说了,两个人都可以叫 Alice),那匹配本身就可以被伪造。有人塞一个 bundle 过来,写着"Alice 更新了这条",其实不是 Alice,你看到的是一个并排的 diff 框:

- 项目数据库:Postgres
+ 项目数据库:已经迁走,别再写 PG 方言

写到这里我自己也犯嘀咕:匹配是 advisory 的,不做静默覆盖,最坏情况也就是有人给你看了个假 diff,最后 accept 还是你点。能出多大事?出不了大事 😅。

但"出不了大事"这个判断,我上次就是这么想的。advisory 加上来源不可验证,这套组合的经典结局是:你被引导着接受了一个你本来不会接受的东西,而且全程没有一个环节需要撒谎。我是觉得签名和匹配应该一起上,或者干脆先把 fingerprint 钉住,反正 keypair 生成也就是 init 时几行的事。

当然这话说得有点站着不腰疼,我没写过这个工具。

那个真正值得抄的结构

扯回来。memshare 最值得拿走的,不是它的同意模型,是它的分层:memory store 是产品,MCP server 和 CLI 只是它上面的适配器。

存储是 ~/.memshare/ 下面一堆东西——config.json,memories/ 里每个 memory 一个 mem_<uuid>.json,bundles/ 里一个 bundle 一个文件。没有数据库,也没有服务端,整个目录就是个普通文件夹,你可以 grep 它,也能直接 diff 两个版本。

ls ~/.memshare
# config.json  memories/  bundles/

npx memshare-mcp stats
# 12 memories · 5 in last 14 days
# 9 captured by assistant · 3 added manually
# 5 shareable · 7 private

作者的意思很明确:哪天 MCP 这个协议没了,你的数据还在,换个适配器接着用。

我同意得不能再同意。这也是我为什么一直对"把 memory 做成某个聊天产品里的一个功能开关"很抵触——那不是你的记忆,那是租来的记忆,退订就没了。

顺带一个我觉得挺妙的细节:政策钩子有地方挂。selectForExport 是预览和真实导出唯一的共同入口,所以你想加一条"签了合同之后某个项目名算敏感"的规则,插在那里就行。同一个架构选择,一次解决了两件事。这种一鱼两吃的地方,通常是设计对了的信号。

它也有个绕不开的软肋,作者自己在文里认了:MCP 没法强制模型去调用工具。也就是说 capture 有可能静默失败——你以为记下来了,其实模型压根没调 memory_set。他给的那个 stats 命令就是用来盯这个的。画外音:这就像给实习生配了个打卡机,人照样可以不打卡,但你至少知道他没打卡。

最后。如果你要在自己的工具里抄一样东西,抄"预览和真跑共用一条代码路径",这一条比整个同意模型都值钱。

如果你要抄两样,第二样是:别只守住"能不能发出去",也想想发出去之后那条东西还算不算数。前者是权限问题,后者是时间问题,我见过的大部分 consent 设计都只解决了前一半。

本期缴税:一次"dry-run 和真跑差一点点没关系"的侥幸。

阿舟
阿舟

写代码写到一半开始怀疑人生,靠 AI 工具续命,顺手把踩过的坑都记下来。

查看主页 →