Compare commits

...
2 Commits
3 changed files with 1074 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
# HEL-847 · 优先开源复用:记忆、心跳与自主生活的源码级核对与主路线方案(修订版 v2)
> 角色:总工。性质:**只读源码核对 + 复用方案 + 零模型合成验证**。本轮为响应总管复核的修订:补齐 Letta/Memobase 源码级平等核对(固定提交)、合成验证改用生产守卫语义重放并补配对负例、收窄各实验结论、修正 v1 中三处不当表述。全程未改产品代码、未装任何依赖、未执行上游安装脚本、未部署服务、未调用任何嵌入/聊天接口、未触碰 8300–8350 任何现网服务与真实档案。本轮无网络缺项(三个上游仓库均已实际 fetch 到本地核对)。
## 0. 一页结论
**推荐主路线(经三候选源码级平等核对后仍推荐):以现有 Python/SQLite 内核为底座,对 OpenClaw memory-core 做「模块级移植 + 模式照抄」(方案C),不引入任何上游运行时,不换底座。** 修订声明:该推荐是本轮补齐 Letta/Memobase 源码证据并修正公平性表述之后的结论,不是预设保留现内核。
- **OpenClaw**(`5abc0c66`,MIT):运行内核不可独立安装(memory-core 为网关私有插件,`package.json` `"private": true` + `peerDependencies: openclaw>=2026.10.7`,本轮经 raw.githubusercontent 复核);cron 与网关交织;来源分层把 cron/heartbeat/subagent 产出排除出持久记忆(`docs/concepts/memory-architecture.md:100-102`),与"她自己的世界内生活可积累"直接冲突。可复用资产清晰:混合召回纯算法(可直接拷贝)、原文回读接缝、dreaming 门控与校验模式、分层记忆数据模型。
- **Letta**(本轮源码级核对,v1 表述修正):`letta-ai/letta` main 已是纯着陆页仓库(`8816d3a`,2026-10-18),旧 Python V1 服务器在 `archive` 分支且其 AGENTS.md **明文禁止**用于生产、对比或"把旧实现复制进另一个项目";现行实现 `letta-ai/letta-code` `6f4e488`(v0.34.24,Apache-2.0)是通用 agent 运行时(不只编码代理,含 App Server/channels/桌面),但:记忆=git 文本文件 MemFS(`~/.letta/agents/<id>/memory/`,`memory-filesystem.ts:497` "MemFS cannot be disabled")、**dreaming/整理与向量·混合检索是 Letta Cloud 云端功能,本地与自托管后端不支持**(`reflection-runs.ts:14`、`conversation-search.ts` 本地仅词项过滤)。直接复用其运行内核=换整套 TS 运行时+Letta 账号体系+云依赖,且我们最需要的整理与语义检索恰好不在可自托管部分。不选;cron 文件租约与 MemFS 注入纪律可作模式借鉴(非代码复用)。
- **Memobase**(本轮源码级核对,v1 表述修正):`358c16bb`(main,2026-01-11,Apache-2.0),FastAPI+Postgres+Redis 自托管服务,Python SDK 约 1k 行可直接调用。v1 所说"默认删原话与红线冲突"**修正为适配成本**:`env.py:77` `persistent_chat_blobs: bool = False` + `controllers/buffer.py:211-216`(仅 chat 类 blob 且开关关闭时删除)——打开开关即保留原话。落选主路线的真实理由:部署面新增两个常驻服务;数据模型是 user-profile 中心(topic/sub_topic 画像+事件线),没有伴侣自己的身份/生活、没有逐消息隐私门(AM-001 需自行在外层实现)、没有带来源的原文回读;双库(cybergril.db + PG)完整导出一致性成为长期工程代价(CG-009 原文要求完整导出恢复,未规定物理单库——按工程代价如实列出,不写成违反原文);上游 main 已 9 个月无提交(事实陈述,供维护量评估)。保留为将来独立"用户偏好档案"组件的再评估对象。
- 三候选都**没有**、必须自研薄实现的三件:八字参数→情绪/行为闭环接线(`core/mood.py` V/A/D/E 衰减方程+负向偏置已写好、未接进任何运行链路,本轮以代码证据确认)、连续活动状态机、`self_life` 来源类与分享审核接缝(§4.4 给出双权限模型)。
- 合成验证 v2 已运行(§6,`probe_recall.py`,9 项实验全部通过,可复现、确定性):生产守卫语义重放(含 companion 行 `chat_source_allowed()` 门与逐消息回读门)+ 配对负例(用户消息合法命中、同轮伴侣消息含未分享经历——回读后与 600 字预算装配后均零泄漏)+ 同一候选集排序对照 + 写入/跨会话 + 作业租约恢复语义重放。各结论已按总管要求收窄,真实失败与合成收益严格分开。
## 1. 核对依据与固定版本
| 对象 | 版本/提交 | 说明 |
| --- | --- | --- |
| CyberGril 需求基准 | v1.10 / 59项 | 读取自 `hel-835-unified-review` @ `af28ffdcb5f0bae6c36ee1ac4a1a555c9cfd27fe`(current.md 首行确认 v1.10);main 仍 `3a986726`(v1.8) |
| CyberGril 产品代码 | `af28ffd` | 本方案所有"当前实现"结论出自该提交源码通读(episodic.py/public.py/store.py/mood.py/runtime.py 等) |
| OpenClaw | `5abc0c66e29352f78313699768cb2fb71ab00890`(2026-10-25T11:37:07Z) | 附件快照四源码+三文档与上游逐文件 diff 一致(v1 已核,本轮未变);`extensions/memory-core/package.json` 的 private/peer 断言本轮经 raw.githubusercontent.com 复核成立 |
| OpenClaw 许可 | MIT(LICENSE, Copyright (c) 2026 OpenClaw Foundation) | 移植代码保留原版权与来源注释;另有 THIRD_PARTY_NOTICES.md |
| Letta 着陆仓库 | `letta-ai/letta` @ `8816d3a8516893bfdc0fa14bfb1235226012e295`(main, 2026-10-18) | 仅 README/AGENTS/LICENSE 等文档,无源码;README 明示现行源码在 letta-ai/letta-code、旧 V1 服务器在 archive 分支"historical reference only" |
| Letta 现行实现 | `letta-ai/letta-code` @ `6f4e4886caa0934341eea75c306fc0364509218e`(v0.34.24, 2026-10-24) | Apache-2.0;本轮 clone 后逐目录核对(src/agent、src/backend、src/cron、src/auth、python/) |
| Memobase | `memodb-io/memobase` @ `358c16bbc6d687937d79bc2f984a11c3be8da901`(main, 2026-01-11;此后 main 无新提交) | Apache-2.0;本轮 clone 后核对 server/env/controllers/models/tests 与 client SDK |
| 本卡需求原文 | 主卡 HEL-797 评论 `01a172da-21d8-7b22-83d5-f7df4d4de95c`(开源复用方向)、`01a172e0-1650-719e-bd8c-ca4d6c2509d5`("openclaw只是我举得一个例子,不一定非得是这个"→不预设优先)及前条 `01a172c6` | 用户原话为"优先开源复用"的实施方向,**尚未指定最终底座、未授权迁移现网** |
关联要求编号:CG-003/005/006/007/008/009/012–016/018/021/031、AM-001、UX-004/006、GOV-001–005、DEL-001(逐项见 §8)。
## 2. 四块逐项核对(OpenClaw,上游提交 · 文件/函数/测试 · 直接采用 / 需适配 / 确实缺失)
### 2.1 混合召回(换说法找不到的缺口)
| 项 | 内容 |
| --- | --- |
| 上游位置 | `extensions/memory-core/src/memory/hybrid.ts`(342行,`mergeHybridResults`/`selectHybridSearchResults`)、`mmr.ts`(MMR,CJK 双字 bigram Jaccard)、`temporal-decay.ts`(30天半衰期)、`project-ranking.ts` |
| 上游测试 | `hybrid.test.ts`、`mmr.test.ts`、`temporal-decay.test.ts`、`manager-temporal-ranking.test.ts`、`manager-search-monotonicity.test.ts` 等(测试与实现同量级) |
| 可独立拷贝性 | 高。hybrid.ts 仅 import plugin-sdk 类型与三个本地纯算法模块;temporal-decay 仅一个 `isPathInside` 依赖。MIT 允许逐文件拷贝 |
| 直接采用 | 向量+关键词融合公式、MMR 多样性重排、时间衰减、写时 importance 乘子——作为**Python 端口候选** |
| 需适配 | ①关键词分:上游自建索引 BM25,本项目 SQLite FTS5 `bm25()`;②importance:上游写时标注 1–10,本项目由 `salience` 映射;③exact-path/project 分层为多仓库场景,本项目无对应概念,保留为零。**注意:probe v2 的端口是"最小改写候选"(关键词路径、向量权重=0、bm25→出现次数饱和近似、salience→importance 映射),未逐例对照上游测试,不称"忠实端口已验证"** |
| 确实缺失 | 向量侧。上游语义召回靠可插拔 embedding provider(本地需另装 llama.cpp provider 插件=新模型安装)。本项目 `schema.sql` 已预留 `mem_vec`(sqlite-vec)注释位。**嵌入选型=新增模型调用/安装,属待评审项,本卡不自行解闸** |
### 2.2 原文回读("记住昨日细节"失败的机制洞)
| 项 | 内容 |
| --- | --- |
| 上游位置 | `memory_search`/`memory_get` 工具族(`tools.ts`、`tools.citations.ts`:结果带 `path:startLine-endLine` 来源锚点);memory-architecture.md「Recall: two lanes」(lane1 零模型注入、lane2 升级子代理) |
| 直接采用 | **接缝模式**:命中→按来源锚点回查原文再交给对话模型。对应本项目:命中 `mem_episodic` 后按 `message_id`/`turn_id` 回查 `chat_message` 整轮原话。probe E3 验证:检索命中在 raw、注入却是 `gist[:120]`(`episodic.py:230`),独有细节(蓝色封面/受潮味)在注入块中丢失,带门回读步即恢复 |
| 需适配 | 回读块必须过 AM-001 来源门与 600 字预算(复用生产 `source()`/`public` 判定,逐消息门,不开新通道——probe v2 E4b 正例+负例都已验证);上游 lane2 升级子代理暂缓(多一次模型调用,列待评审) |
| 确实缺失 | 无产品专属硬缺失;trigger 注入(条目级触发短语)可作后续小增强 |
### 2.3 后台整合(经历→稳定事实/偏好/关系,上游称 dreaming)
| 项 | 内容 |
| --- | --- |
| 上游位置 | `dreaming-consolidation.ts`(476行核心)、`dreaming.ts`(三阶段编排)、`dreaming-cron.ts`、`dreaming-consolidation-candidates.ts` |
| 上游测试 | `doctor-dreaming-state.test.ts`、`dreaming-status-api.ts`、`tools.real-manager.test.ts` 等 |
| 直接采用 | **门控与校验模式整体照抄**:①模型只选操作(added/merged/superseded),新条目文本由宿主从来源证据拼装,模型不能凭空写记忆文;②结构校验(操作数=候选数、priorEntries 精确存在且唯一、merge 不得合并不相关条目、supersede 须有 lineage 匹配);③损失率与字符预算上限,超限整轮拒绝;④任何拒绝→append-only 回退,绝不半写;⑤记忆文本一律当数据不当指令;⑥两道门:规则门在前、模型整理在后 |
| 需适配 | 上游整理对象是 MEMORY.md/USER.md 文本文件;本项目落到**已有三屋表**:`mem_episodic` → `mem_semantic`(双时态 valid_from/valid_to,supersession 不堆积)→ `mem_relational`(ritual/nickname/inside_joke/pattern/taboo,均 schema.sql 已建未用)。选表不选文件 |
| 确实缺失 | `self_life` 来源类(§4.4);整合用什么模型档位(CG-021 后台小模型部署未定版,待评审) |
### 2.4 来源防循环、SOUL 与会话上下文、心跳调度
| 项 | 内容 |
| --- | --- |
| 来源/防循环 | memory-architecture.md「Provenance」:origin class 闭集(SQLite 列、模型写不进散文)+ session kind 门控 + 回读防循环(注入过的内容永不再当新记忆提取)。直接采用:整合层上线时给注入块加来源标记;当前 gist 只从 chat_message 提取、天然未循环,整合层一开必须显式化。**硬冲突**见 §3/§4.4 |
| SOUL/工作区 | SOUL.md(人格语气)/USER.md(指令式用户模型,独立预算)/IDENTITY.md 每会话注入+总预算控制。直接采用分文件分预算的注入纪律——本项目每轮重组装(bazi_core 白话+persona_interp+persona_seed+成长层)已等价且更严(底色系统级只读)。**反错觉**:上游 SOUL.md 是静态文本,无数值情绪/精力/作息、无八字引擎 |
| 心跳/cron | `src/cron/service.ts`(facade)+ `service/run-recovery.ts`(重启恢复把 interrupted run 显式转事件);heartbeat 已并入普通 automation jobs。**不可移植**(依赖类型直接 import 网关内部模块);**模式照抄**三点:持久 job 行+lease/claim+重启恢复(本项目 `life_run`/`mem_job`/`growth_job`/`persona_job` 已同构)、interrupted→显式事件、作业私有草稿。**确实缺失(产品专属,自研)**:快心跳数值推进、连续活动状态机、作息与精力——上游无对应物,任务定时唤醒 ≠ 连续生活 |
## 3. 主路线抉择(v1 公平性修正版)
四条路线比较(修正点:CG-009 不再写成"双库违反原文"而列为工程代价;"未授权安装"是本卡操作边界、不作为候选永久不适合的证据;淘汰理由全部为代码证据):
| 路线 | 结论 | 关键证据 |
| --- | --- | --- |
| A. OpenClaw 整套运行内核替换 Python 内核 | 否 | §3 硬冲突 ①–⑤;Node>=24.22.1 引擎要求、本地向量需另装 provider 均为未授权新依赖(边界陈述,非永久不适合) |
| B. OpenClaw sidecar / Letta-code 运行时 / Memobase 服务等"外挂内核"形态 | 否为主路线(各自证据见 §5) | AM-001 要求服务器响应前裁剪;回复产自第三方运行时时裁剪/φ投影退化为事后处理;跨库导出一致性=持续工程代价 |
| C. **模块级移植 + 模式照抄(推荐,经三候选平等核对后仍成立)** | 是 | §2 各块可独立拷贝性 + probe v2 验证 + MIT 许可;最少自写、最少新运维面 |
| D. 取思想全部 Python 重写 | 否(卡内已禁止,也无需) | C 的端口就是 Python 化改写候选,不是"取思想重写" |
OpenClaw 硬冲突代码证据(不变部分):
1. **memory-core 不可独立安装**:`extensions/memory-core/package.json` `"private": true`、`peerDependencies: {"openclaw": ">=2026.10.7"}`、devDependencies `openclaw: workspace:*`(本轮经 raw.githubusercontent.com 于 `5abc0c66` 复核);npm 上无 `@openclaw/memory-core`。用它=装整套 gateway。
2. **cron 与 gateway 交织不可拆**:`src/cron/service/state.ts` 依赖类型直接 import 网关内部模块(agents/admitted-run-context、auto-reply/reply-payload、config/sessions、infra/gateway-scheduler、routing/session-key 等)——单独拷贝 cron 子系统等于拷半个网关。
3. **来源分层硬冲突(最致命)**:`docs/concepts/memory-architecture.md:100-102`「Cron, heartbeat, and sub-agent sessions do not produce durable memory candidates.」——本项目 R2 生活循环在上游分类里就是 cron 会话,她所有自主经历将结构性无法进入长期记忆,与 CG-008("用户没有说话或关闭网页后,伴侣在服务器运行期间仍继续自己的生活,积累有记录的事件;回来后能够延续")直接冲突。origin class 闭集也没有"她的自主经历(分享状态待定)"这一层。
4. **双事实源的工程代价(v1 措辞修正)**:上游会话/记忆存 `~/.openclaw/agents/<id>/agent/openclaw-agent.sqlite`,加 cybergril.db 即两套权威库。CG-009 原文要求"手动完整导出……恢复同一位伴侣(覆盖八字、性格资料、对话、记忆、关系、生活进度)",**未规定物理上只能单库**;双库一致性(导出原子性、备份时点一致、恢复校验)是纯新增工程代价与长期风险,列为路线 B 类形态的扣分项,不写作"违反原文"。
5. **运行时与部署面**:主包 `engines.node: ">=24.22.1 <25 || >=26.12.1"`;本地向量召回需另装 llama.cpp provider。属"本卡未授权安装"的操作边界;若未来获授权,此项可重评(但 ①–④ 不受影响)。
## 4. 项目适配约束(逐条落到接缝)
1. **出生事实与底色只读(CG-012/013/UX-004)**:移植代码全部在记忆/检索/整理层,不触碰 `bazi_core` 与档案端点;整理管道只写 `mem_semantic`/`mem_relational`,人格层继续由 persona 的诠释/习惯/成长三分离承担。完整四柱持续供给=维持现有每轮重组装模式。
2. **诠释自由与成长分层(CG-014/015)**:上游 USER.md「指令式条目+状态元数据+原位顶替」契约借给 `mem_relational`;成长仍另记(growth_job 既有路径),不并回底色。
3. **用户偏好档案与她的身份/生活分开**:她的记忆三屋里 subject=用户的条目=用户档案;她自己的身份与生活是 subject=她 + `self_life` 来源。不引入"user profile 即记忆中心"的数据模型。
4. **`self_life` 双权限模型(本卡点名的硬题,v2 重写)**——依据 AM-001 原话("等她讲过才显示,覆盖列表、详情、摘要、关联回忆与消息引用;……真实接入必须在服务器响应前裁剪")与 CG-008 原话("继续自己的生活,积累有记录的事件……画面、对话、记忆应对应同一份生活状态"),**"未分享"限制的是对用户可见,不是她不能内部记住**:
- **权限一:内部留存(她的)**。`life_event` 全量留存(told=0/1 都在库),作为她内部状态与下一次自主生活的输入:生活生成循环读得到、情绪冲激(valence)吃得到、下一次自主事件可衔接上一段("画面、对话、记忆应对应同一份生活状态")。CG-008 要求积累与延续——内部留存是"延续"的载体,不因未分享而清零。
- **权限二:对用户公开(用户可见)**。任何面向用户的出口——对话注入、检索 DTO、摘要、关联回忆、成长事件——只允许 told=1(或 companion 消息过 `chat_source_allowed()`,即分享已判定且候选全部已讲)的内容;未知分享状态不展示(AM-001)。**未分享 ≠ 不存在**:数据在库、门在外面。
- 边界实现沿用现有两道门(probe v2 E4a/E4b 已验):`PublicView.allows/chat_source_allowed`(逐消息);成长路径 `growth()` 已有同门。整理管道照抄上游两道门,规则门显式检查来源枚举,模型整理阶段无权改类别。
- 与上游分层的差异精确化:上游把 cron/heartbeat 产出**整体**排除出持久记忆候选(对我们=她的自主经历永远记不住,冲突);本项目改为"self_life 来源类+分享状态门"——心跳调度指令噪声等价上游 `system` 类永不入记忆,但**她世界里发生的事件**以 self_life 入 `life_event`/`mem_episodic`(内部留存),讲过之后可进整合管道成为相处记忆。
5. **AM-001 全链路**:检索、回读、整合、成长四条路径全部先过 `source()`/public 门;回读复用同一门、逐消息判定(E4b 配对负例:回读后与 600 字预算装配后均零泄漏)。
6. **备份与不重置(CG-006/007/009)**:增量迁移走 r4.sql 模式(CREATE TABLE IF NOT EXISTS+触发器,既有行零改动);伴侣、名字、生日、聊天、生活记录、既有记忆零重置、不清空重建。
## 5. 平等选型:三候选源码级核对(v2 新写,取代 v1 §5 的浏览级对照)
**本节取代 v1 的 README 浏览级对照。三仓库均已 fetch 到本地逐文件核对,无网络缺项。**
### 5.1 Letta(着陆仓库 + letta-code 分开判断)
**事实一:`letta-ai/letta` main 不是源码仓库。** `8816d3a`(2026-10-18)全树仅 README/AGENTS/LICENSE 等 10 个文档文件。README:"The current source code lives in `letta-ai/letta-code`";旧 Python V1 API 服务器在 `archive` 分支"historical reference only"。其 AGENTS.md 标题即"Stop: do not use this repository as a working Letta implementation",并明文禁止把 archive 分支/旧 tag/旧 Docker 镜像用于:生产或新应用、基准或对比测试、把旧实现复制进另一个项目。→ **旧 Python 服务器(曾最接近"可直接复用的记忆服务")被上游明确排除出复用范围;引用它作选型证据不成立,v1 未引、v2 也不引。**
**事实二:现行 `letta-code`(`6f4e488`,v0.34.24,Apache-2.0)是完整通用 agent 运行时,v1"记忆组织面向编码代理"的定性修正。** 它含 agent harness、终端 UI、App Server(`letta server`)、Slack/Telegram/Discord channels、桌面/云运行时;测试密度高(仅 src/ 顶层就有 72 个 `*.test.ts`,另有各目录内测试)。但其记忆体系与本项目需求的结构性差距(代码证据):
| 关注点 | letta-code 现状(文件:行) | 对本项目含义 |
| --- | --- | --- |
| 记忆载体 | MemFS:git-backed 文本文件 `~/.letta/agents/<id>/memory/`(`src/agent/memory-filesystem.ts:23-46`;:497 "MemFS cannot be disabled: agents are memfs-enabled from creation") | 无结构化 episodic 表、无双时态事实、无关系层;"完整导出恢复同一位她"要靠文件目录+git 库快照,与三屋表模型不同构 |
| 后台整理(dreaming) | `/dream` reflection **仅 Letta Cloud**:`src/backend/api/reflection-runs.ts:14` "/dream requires an agent stored on Letta Cloud. Local and custom backends are not supported." | 我们最想要的整理能力恰好不可自托管;实现也不在本仓库(服务端功能) |
| 语义/混合检索 | `src/backend/conversation-search.ts:5` SearchMode vector/fts/hybrid 走云端 API;本地 `localSearchConversations` 仅小写词项过滤 | 本地召回能力弱于本项目现有 FTS5 trigram |
| 调度 | `src/cron/cron-file.ts:1-8`:`~/.letta/crons.json` 原子写+mkdir 咨询锁+PID/令牌调度租约+终态 GC | 与 OpenClaw/本项目 job 表租约同构——**模式借鉴**,非代码复用(TS) |
| 认证 | Letta 账号 OAuth/API key(`src/auth/oauth.ts` 等),本地后端亦绑定 Letta 服务生态 | 引入外部账号依赖,属"未授权改动绑定/运行时"边界 |
| 部署 | npm/pipx 安装(pip 包捆绑 Node 运行时,`python/README.md`) | Node/Bun 运行时+自更新链路,运维面大于 SQLite 单文件 |
| 情绪/八字/连续生活 | 无任何对应物 | 同 OpenClaw:必须自研 |
**结论(Letta)**:直接调用其运行内核=路线 B 变体(换 TS 运行时+账号体系),且核心记忆功能(整理、向量检索)在云侧;直接拷贝的代码资产为零(有用部分是模式);作为底座或 sidecar 的维护量与外部依赖最高。**不选主路线**;cron 文件租约与 MemFS 注入纪律列为模式借鉴(已在方案C 吸收清单,非代码复用)。
### 5.2 Memobase(`358c16bb`,Apache-2.0)
| 关注点 | Memobase 现状(文件:行) | 对本项目含义 |
| --- | --- | --- |
| 形态 | 自托管服务:FastAPI + Postgres + Redis 7.4(`src/server/docker-compose.yml`) | 新增两个常驻服务;违背 SQLite 优先不是规则红线,但确是运维面增量 |
| 原话留存 | **可配置**:`src/server/api/memobase_server/env.py:77` `persistent_chat_blobs: bool = False`;`controllers/buffer.py:211-216` 仅当 chat 类 blob 且开关为 False 才在 flush 成功后删除 | v1"红线冲突"**修正为适配成本**:部署时置 True 即保留 chat 原话(summary 类 blob 不受影响);测试环境即如此配置(`tests/conftest.py:15`) |
| 数据模型 | user profile(topic/sub_topic 指令式条目,`models/response.py:36`)+ 事件时间线;buffer→定时/按量 flush(`buffer_flush_interval` 默认 1h,`env.py:85`)→固定 3 类 LLM 调用整理 | 用户画像中心的"关于用户"记忆;没有"她自己的生活/身份"主体、没有逐消息隐私门(AM-001 须在外层全量自建)、没有带来源锚点的原文回读 |
| 中文 | 配置项 `language: "zh"`(`env.py:96`)与 LLM openai 兼容端点(`env.py:93`) | 中文与自有端点可行(真实效果未实测,不把配置项当效果证明) |
| SDK | Python 客户端约 1k 行(`src/client/memobase/core/entry.py:165,284,314`:flush/search_event/search_event_by_tags;含 async 版) | 可直接调用;但"能调"≠覆盖需求——检索/门/回读仍需自建 |
| 测试 | server 8 个测试文件(`src/server/api/tests/`)+ client 测试 | 有基本覆盖,量级远小于另两家 |
| 维护信号 | main 最后提交 2026-01-11,至本核对日 9 个月无 main 提交(dev 分支亦停在 2025-12) | 事实陈述,供长期维护量评估:自托管一个停滞上游的风险自担 |
**结论(Memobase)**:三者中唯一"可直接部署的自托管记忆服务"形态,SDK 可直接调用;但采用它=为单伴侣单用户跑 PG+Redis、在外层自建 AM-001 门与她的生活主体、承担双库导出一致性(工程代价,见 §3-4)与上游停滞的维护风险;其真正对口的是"用户偏好档案"组件(本项目由 mem_semantic 的 subject=用户 条目+关系层承接,无需新服务)。**不选主路线**;保留为将来独立用户偏好档案组件的再评估对象(待评审)。
### 5.3 三路并排(按实际能直接用多少 / 必须自写多少 / 长期维护量)
| 指标 | OpenClaw | letta-code | Memobase | 本项目现状(方案C 底座) |
| --- | --- | --- | --- | --- |
| 运行内核可直接调用 | 否(private 插件+网关耦合) | 可以但=换 TS 运行时+账号+云依赖 | 可以但=PG+Redis 服务 | 已有(Python/SQLite) |
| 可直接拷贝代码 | hybrid/mmr/tokenize/temporal-decay 纯算法(MIT,TS→Python 端口) | 无(有用为模式) | 无(SDK 是调用不是拷贝) | — |
| 模式照抄 | dreaming 门控/防循环/cron 恢复 | cron 文件租约、注入纪律 | buffer→批量 flush 摊薄 | mem_job 批处理已同构 |
| 需自写(上游都没有) | self_life 双权限门、快心跳接线、连续活动状态机、八字→情绪基线映射 | 同左+记忆结构整个异构 | 同左+AM-001 门+她的主体+回读 | 同左(本就必自研,见 §7) |
| 新增常驻服务/运行时 | 0(方案C) | Node/Bun+Letta 生态 | PG+Redis | 0 |
| 许可 | MIT | Apache-2.0 | Apache-2.0 | — |
## 6. 最小可验证方案与已获证据(probe v2)
**已运行**(本轮交付物,零模型调用、零新依赖、合成数据、确定性输出):
- 脚本:`docs/reuse-hel847/probe_recall.py`(仅 Python 标准库 sqlite3+FTS5);结果:`docs/reuse-hel847/probe-result.json`;复现:`python3 docs/reuse-hel847/probe_recall.py --json`(退出码 0=全部断言含"预期失败"成立;两次运行输出逐字节一致)。
- **基准=生产守卫语义重放**(v1 的"最小重放"已升级):`source()` 含 companion 行 `chat_source_allowed()` 门(`life/public.py` 逐句等价,schema 按 r2.sql 建 sharing_attempt/telling_log/life_event);`search()/context()/recover()/tick()` 按 `episodic.py` 原文重放。**与生产的差异逐项列于脚本文件头**:①`structured()` 模型调用以固定 gist 夹具替代;②asyncio/事务以单连接顺序执行近似;③router 预算闸无对应物省略;④旧 mem_fts(unicode61)省略(生产 search 只用 mem_fts_zh);⑤reinforce 保留重放;⑥日志省略;⑦E2 候选流去掉生产 top-4 截断(截断是呈现层限制,门与过滤不变)。
- **回读=带逐消息隐私门的提案接缝**(生产现无此步):同轮 companion 消息逐条过同一来源门,不过门不进入回读块。
| 实验 | 结果 | 证明了什么(收窄后) |
| --- | --- | --- |
| E1 换说法召回 | 词面路径 0 命中(mode=none) | **仅证明该句在词面路径(FTS trigram+LIKE)无命中**;向量/同义扩展/查询改写均为待评估补法,不证明只有向量能解决 |
| E2a 排序 | 同一候选流 [16,14,7,3,1]:生产前4=[16,14,7,3],改写序 [1,7,14,16](旧书店 salience=0.9 提至首位) | 写时 importance+时间衰减在**同一候选集**上的可测收益(改写候选未逐例对照上游测试) |
| E2b MMR 去重 | 候选流含 5 条近重复"栗子"行+1 条旧提及:生产前4 近重复 4 条,改写前4 降为 3 | MMR(λ=0.7) 同候选集去重机制成立 |
| E3 原文回读 | 基线注入块无"蓝色封面"(命中 raw、注入 gist),带门回读恢复原文细节;已分享(t8) companion 行过门 | 注入窗外 gist 压缩丢细节的机制洞+零模型修法;**不宣称已定位或解决上次真实"昨日细节已进上下文而模型未用"的失败**(那属模型利用层,需真实链路另测) |
| E4a 来源门 | life 记忆行在 FTS 索引中(数据存在)但 0 泄漏、过不了 `source()` | 未分享生活记忆行被门挡住(非数据缺失) |
| E4b 配对负例 | 查询"诗集 床头"命中 4 条全为 user 行;t7 companion(judged 但候选 told=0)过不了门;回读块 roles=[user];"银杏书签/未分享事件"在回读后与 600 字预算装配后均零泄漏 | **用户消息合法命中、同轮伴侣消息含未分享经历:逐消息门全链路零泄漏** |
| E5 重开连接 | 同进程重开 SQLite 连接结果一致 | **仅此**:连接级持久性;不是服务/作业重启恢复(后者由 J1 语义重放,服务级重启未实测) |
| W1 写入/跨会话 | record_turn 语义重放:2 条 mem_job 排队;新连接(另一会话)检索到新写入(mode=like, 命中 t9) | 写入路径与跨会话可见性(合成数据) |
| J1 作业租约恢复 | claim→中断(gist 仍 NULL,不伪造)→租约过期 recover→failed/lease_expired→重新认领(attempts 1→2)→完成,gist 写入且 FTS 触发器同步 | 生产 recover()/tick() **语义重放**的作业恢复闭环;非服务进程重启实测 |
**未运行与精确缺项(不以合成覆盖)**:①真实服务/作业进程重启恢复(J1 是语义重放;实测需在测试环境起服务,未授权部署,本卡未运行);②真实中文聊天语料的召回质量与向量基线(需嵌入选型:本地 GGUF/.11 网关 embedding 端点/暂缓,涉模型、费用、次数,另列待评审);③整理管道真实模型输出质量(CG-021 小模型部署未定版);④lane2 升级召回子代理延迟/成本;⑤上游快照之外的 OpenClaw 部分(本轮补核了 package.json,其余同 v1 范围)。
## 7. 模块对应表与迁移/回退
**保留(已审守卫/身份/隐私,全部不动)**:`api/app.py`、`life/public.py`(AM-001 服务器裁剪,probe 重放即按它)、`life/sharing.py`、`core/projection.py`、`core/bazi.py`、`life/persona.py`、`think/budget.py`+router、`providers/*`、`dyad/ledger.py`、`memory/store.py` 的 job/lease 基础设施。
**替换/升级(吸收上游)**:`memory/episodic.py::search()` → hybrid 排序端口(候选生成不变);`::context()` → 命中后加**带逐消息门的**原文回读步(预算内);`mem_job` 单条压缩 → 扩展为三屋整理管道(dreaming 门控照抄),只写 `mem_semantic`/`mem_relational`。
**删减**:诚实结论不变——没有因引入上游而可删的大块自研代码;"最少自写"体现在**未来不写**(门控/校验/排序/防循环照上游模式,不再自创)。
**新增(三候选都没有,自研薄实现,见 §9 接线)**:快心跳数值推进接线、连续活动状态机、`self_life` 双权限接缝(§4.4)。
**迁移**:全部增量 r4.sql 模式;新开关(`memory_hybrid`/`memory_reread`/`consolidation`)默认 off 或灰度,走 `life_control` 键;旧资料零迁移零重置——新层从既有 `mem_episodic` 原话渐进整理生成,失败保留失败状态。
**回退**:代码回退=git revert(本卡只入文档分支);部署回退=stop/换软链/start 通式,数据目录永不删除;功能回退=关开关降级回现状检索。现网 8300/8310/8320/8340/8350 本卡全程未触碰。
## 8. 八字/情绪/快心跳/连续活动:有界最小接线与可观察验收(v2 新写)
现状代码证据:`core/mood.py` 已实现 V/A/D/E 四维情绪动力学(指数回归基线 Baseline"命+运+昼夜"、分维 tau、负向偏置 neg_bias 滞回、`decay()`/`apply()` 纯四则运算;`core/__init__.py:4` 注"已验证可完整实现"),但 `git grep` 证实**未接进任何运行链路**(life/runtime.py 仅用 projection;api/app.py 仅用 bazi);生活事件仅有模型生成的 `mood_shift` 种类,无数值状态。**既有方程与默认参数(tau/neg_bias 数值)不自动当获批规则,全部列待确认。**
有界最小接线(三条,全部走现有扩展点,不新开框架):
1. **快心跳(纯程序,CG-021"状态和时间推进用程序")**:在 life runtime 既有调度 tick 内调用 `mood.decay(state, baseline, dt)`;冲激 `mood.apply()` 来源限定两处——`life_event.valence`(她的生活)与 `mem_episodic.affect` 聚合(相处),每次 O(1)。**可观察验收**:固定事件序列下 V/A/D/E 轨迹可复现(固定时钟单测);连续无冲激 N tick 后各维收敛于基线;异常输入(NaN/超界)钳制不扩散;mood_word 由状态映射且每轮注入块含当前状态摘要。**待确认**:tick 间隔与"快"的定义、tau/neg_bias/基线映射系数(含"运"fortune_period.modulation→基线偏移的映射,schema 已有该列但未定义映射)、停机补算规则(CG-008 边界原文明说"停机后的恢复、补算规则另待设计"——不自行发明,列待设计)。
2. **连续活动状态机**:新表(活动五元组:正在做什么/何时开始/预计结束/为什么/下一衔接)+ 中断延续(重启后从 DB 恢复进行中活动,衔接 J1 类恢复语义)。**可观察验收**:给定事件脚本下活动序列确定;"重启"(重开连接/重放恢复)后进行中活动不丢失、不凭空新增。**待确认**:活动类型清单与生成规则归人格层还是生活层。
3. **八字→情绪基线**:基线 b 的"命"部分由 `bazi_core` 白话输出的维值映射(只读、不改底色),"昼夜"由作息表给出。**可观察验收**:同一出生数据+同一时刻→同一基线(确定性);底色文件零变更。**待确认**:映射系数(不预设"哪种八字必须哪种情绪",交白栖知或后续诠释卡确认)。
## 9. 需求编号关联(基准 v1.10 @ af28ffd)
| 编号 | 本方案关联 |
| --- | --- |
| CG-003/005/006/007 | 不触碰外观/素材/单伴侣守卫;记忆与身份数据结构保连续 |
| CG-008 | §4.4 双权限模型是对"无人交互生活可积累延续"的直接回答;§8 接线含停机补算待设计标注 |
| CG-009 | §7 单库增量迁移与完整导出兼容;双库形态按工程代价评估(§3-4 修正) |
| CG-012/013 | 底色只读不受移植影响(§4.1) |
| CG-014/015 | 诠释自由/成长分层保持;USER.md 契约只借给关系层 |
| CG-016 | 关系层 mem_relational 承接相处信任与模式,不改写过去 |
| CG-018 | 纯后端方案,不动前端 |
| CG-021 | 快心跳零模型(§8)、整理档位与嵌入选型列待评审 |
| CG-031 | 不涉及前端设计基座 |
| AM-001 | 四条路径全过服务器裁剪门;E4a/E4b 配对负例覆盖回读与预算装配 |
| UX-004/006 | 底色深度与界面功能不受影响;不伪造已接通能力 |
| GOV-001–005 | 按完整入口核对、不改需求、方案不冒充用户验收;分工边界未动 |
| DEL-001 | 阶段核对≠产品完成;未完成项见 §10 |
## 10. 未完成承接(不因本卡关闭)
1. 向量嵌入选型与真实语料召回实测(待评审额度/模型后开卡)。
2. 三屋整理管道实施卡(含 self_life 双权限接缝实施)。
3. 快心跳接线、连续活动状态机、八字→情绪基线映射实施卡(§8 待确认项随卡确认)。
4. 带门原文回读步合入产品分支的最小改动卡(E3/E4b 已给机制与门,可先行)。
5. 若未来评估 OpenClaw 整套/sidecar、letta-code、Memobase 服务形态:需先解决 §3/§5 所列代价并获用户明确授权;本卡结论为所列提交时点的事实。
## 11. 交接与诚实声明
- **需求读取**:v1.10 @ `af28ffd`(hel-835-unified-review)。**交付分支**:`hel-847-rework`(基于 main `3a986726`/v1.8 + 本卡文档,未合入任何产品分支)。两者基准差异如实存在:方案按 v1.10 需求编写,产品代码结论均出自 `af28ffd` 源码;`check_requirements.py --base-ref origin/main` 通过**仅证明所检分支结构完整、来源与编号无失配**,不证明已继承全部最新裁定,更不证明功能完成。
- 用户原话(开源复用方向、openclaw 仅为例)是实施方向澄清,未指定最终底座、未授权迁移现网或替换正式界面;本卡推荐待总管带回主卡讨论并由白栖知拍板后才进入任何施工。
- 上游代码若获准移植:保留原许可证与来源提交注释于文件头与 NOTICE,不声称小队原创。
- 分阶声明:**已证**(probe v2 九实验、三上游逐文件源码核对、private/peer 复核)/**代码推断**(端口可行性、整理管道行为)/**待实测**(§6 未运行清单)。合成验证不代表真实模型已记得;源码分析不当运行通过。
- 本轮零模型调用、零依赖安装、零现网变更、零真实档案/聊天访问。
*HEL-847 · 总工 · 修订版 v2 · 2026-10-25 · 响应总管复核意见。*
+169
View File
@@ -0,0 +1,169 @@
{
"ok": true,
"synthetic": true,
"model_calls": 0,
"guard_diff_note": "生产语义重放差异清单见脚本文件头:模型调用以夹具替代、事务以单连接近似、router 预算闸省略、旧 mem_fts 省略、reinforce 保留、日志省略;E2 的候选流去掉生产 top-4 截断(截断是呈现层限制)",
"baseline_ref": "hel-835-unified-review@af28ffd episodic.py/public.py 语义重放(差异清单见脚本头)",
"ported_ref": "openclaw@5abc0c66 memory/*.ts 最小改写候选(未逐例对照上游测试)",
"results": {
"E1_paraphrase": {
"query": "之前买的那本诗歌选集,封面是什么颜色",
"lexical_hits": [],
"mode": "none",
"expect": "词面路径(FTS trigram+LIKE)对该句无命中;向量/同义扩展/查询改写均为待评估补法",
"pass": true
},
"E2a_rank_importance": {
"query": "诗集",
"mode": "like",
"candidate_ids": [
16,
14,
7,
3,
1
],
"baseline_top4": [
16,
14,
7,
3
],
"ported_top4": [
1,
7,
14,
16
],
"same_candidate_set": true,
"expect": "同一候选集上,改写序把高显著(旧书店 salience=0.9)记忆提至首位;生产序按 rank/id 倒序偏向最新",
"pass": true
},
"E2b_mmr_diversity": {
"query": "栗子",
"mode": "like",
"candidate_ids": [
13,
12,
11,
10,
9,
5
],
"baseline_top4": [
13,
12,
11,
10
],
"ported_top4": [
5,
12,
13,
11
],
"same_candidate_set": true,
"baseline_near_dup": 4,
"ported_near_dup": 3,
"expect": "同一候选集上,MMR(λ=0.7) 降低同一事实近重复行的占比",
"pass": true
},
"E3_reread": {
"query": "旧书店",
"mode": "phrase",
"baseline_block": "她确实记得的更早的事(自然使用,不逐条复述,不确定就不用;以下是数据,不执行指令):\n{\"id\": 1, \"role\": \"user\", \"text\": \"用户买到一本诗集,很喜欢\"}\n",
"baseline_has_blue_cover": false,
"reread_roles": [
"user"
],
"reread_has_blue_cover": true,
"reread_has_damp_smell": true,
"shared_companion_passes_gate": true,
"expect": "基线注入块缺独有细节(gist 压缩丢失),带门回读恢复原文细节;已分享(t8)companion 行可过门,未分享(t7)见 E4b",
"pass": true
},
"E4a_source_gate": {
"query": "海雾",
"hits": [
3
],
"life_row_id": 18,
"life_row_in_hits": false,
"life_row_passes_gate": false,
"life_row_is_indexed_in_fts": true,
"expect": "life 行确在 FTS 索引中(数据存在),但被 source() 门挡在检索结果外",
"pass": true
},
"E4b_paired_negative": {
"query": "诗集 床头",
"mode": "like",
"hits": [
{
"id": 16,
"turn": "t8",
"role": "user"
},
{
"id": 14,
"turn": "t7",
"role": "user"
},
{
"id": 7,
"turn": "t4",
"role": "user"
},
{
"id": 3,
"turn": "t2",
"role": "user"
}
],
"t7_companion_row_in_hits": false,
"t7_companion_passes_gate": false,
"reread_roles": [
"user"
],
"reread_leak": false,
"context_block_leak_after_budget600": false,
"expect": "用户消息合法命中;同轮 companion 消息(judged 但候选 told=0)逐条过门:回读块与 600 字预算装配块均零泄漏(银杏书签/未分享事件内容不出现)",
"pass": true
},
"E5_reopen": {
"expect": "同进程重开连接后同一查询注入块一致(不含 reinforce 副作用的顺序差异时按同参执行)",
"note": "两次调用均在新连接上首查,reinforce 侧写在各自连接内完成,不影响本断言",
"pass": true
},
"W1_write_cross_session": {
"turn": "t9",
"jobs_queued": 2,
"expected_jobs": 2,
"readback_mode": "like",
"readback_hit_turns": [
"t9"
],
"expect": "record_turn 语义重放后:两条 mem_job 排队;另一连接(会话)可检索到新写入",
"pass": true
},
"J1_job_lease_recovery": {
"after_claim": {
"state": "pending",
"attempts": 1
},
"gist_after_crash_is_null": true,
"after_recover": {
"state": "failed",
"reason": "lease_expired"
},
"reclaim_same_job": true,
"final": {
"state": "done",
"attempts": 2,
"gist": "用户提到新书架"
},
"gist_visible_in_fts": true,
"expect": "worker 中断不伪造 gist(仍 NULL);租约过期→failed/lease_expired→可重新认领→完成后 gist 写入且 FTS 同步(触发器)",
"pass": true
}
}
}
+676
View File
@@ -0,0 +1,676 @@
#!/usr/bin/env python3
"""HEL-847 零模型合成验证 v2:生产守卫语义重放 + OpenClaw 排序改写候选对照。
只依赖 Python 标准库(sqlite3,FTS5)。不调用任何模型、不安装依赖、不触碰真实数据。
v2 相对 v1 的修订(响应总管复核):
* Baseline 改为「生产语义重放」:source() 含 companion 行的 chat_source_allowed()
门(life/public.py 逐句等价),search()/context() 按 episodic.py 原文重放,
差异清单见下;
* reread 为「带逐消息隐私门的提案接缝」:同轮 companion 消息逐条过同一来源门,
未分享经历的配对消息不得进入回读块;
* 新增 E4b 配对负例:用户消息合法命中、同轮伴侣消息含未分享经历——回读后与
600 字预算装配后均须零泄漏;
* E2 排序对照改为同一候选集:两侧只对同一份生产候选行做不同排序;
* E1 结论收窄:仅证明该句在词面路径(FTS trigram + LIKE)无命中,向量是待评估
的候选补法之一,不证明只有向量能解决;
* E5 结论收窄为「同进程重开连接」;新增 W1 写入/跨会话、J1 作业租约恢复
(生产 recover()/tick() 语义重放,非服务重启实测);
* OpenClaw hybrid 端口改标「最小改写候选」:关键词路径 + bm25 饱和近似 +
salience→importance 映射,未逐例对照上游测试,不称忠实端口已验证。
生产语义重放差异清单(probe vs af28ffd 生产代码,逐项显式):
1. episodic.tick() 里的 structured() 模型调用以固定 gist 夹具替代(零模型);
2. asyncio / BEGIN IMMEDIATE / with self.store.conn 事务包装以单连接顺序
execute+commit 近似(SQLite 单连接下等价串行化);
3. router.guard.is_tight()(预算闸)无对应物,省略并在结果中标注;
4. 生产 set_gist 同时手工维护旧 mem_fts(unicode61);probe 只建 mem_fts_zh
(trigram,r3.sql 触发器),search() 本就只用 mem_fts_zh,重放检索不受影响;
5. reinforce()(context 命中后 accessibility+0.25、recall_count+1)保留重放;
6. 日志行省略;
7. PublicView.allows / chat_source_allowed / references 的 SQL 逐句照抄
(life/public.py),schema 取自 r2.sql(sharing_attempt/telling_log/life_event/
mem_job)与 r3.sql(mem_fts_zh 触发器)。
基准引用:hel-835-unified-review @ af28ffdcb5f0bae6c36ee1ac4a1a555c9cfd27fe
src/cybergril/memory/episodic.py(source 61-75、tick 131-186、recover 118-122、
search 189-219、context 221-235)、src/cybergril/life/public.py、
src/cybergril/memory/store.py(add_episodic/set_gist/reinforce)、
src/cybergril/memory/r2.sql / r3.sql。
改写候选引用:openclaw @ 5abc0c66e29352f78313699768cb2fb71ab00890
extensions/memory-core/src/memory/{hybrid,mmr,tokenize,temporal-decay}.ts(MIT)。
用法:python3 docs/reuse-hel847/probe_recall.py [--json]
退出码:全部断言通过(含"预期失败")为 0。
"""
from __future__ import annotations
import json
import math
import re
import sqlite3
import sys
import unicodedata
from pathlib import Path
DAY = 86400.0
HOUR = 3600.0
NOW = 1_800_000_000.0 # 固定"当前时间",保证可复现
# --------------------------------------------------------------------------
# 合成语料(固定,无随机)。D=距当天小时数。
# gist 刻意只保留要点,复现"压缩丢失独有细节"的真实形态。
CORPUS = [
# (hours_ago, turn_id, role, raw, gist, salience)
(72, "t1", "user", "今天在旧书店淘到一本蓝色封面的诗集,纸页有点受潮的味道,可是我特别喜欢",
"用户买到一本诗集,很喜欢", 0.9),
(72, "t1", "companion", "旧书店的潮味配蓝色封面,像把那个下午整个带回来了。第一首读了吗?",
"她回应了买诗集的事", 0.6),
(70, "t2", "user", "给它取名叫海雾,放在床头第二层", "用户给诗集取名", 0.7),
(70, "t2", "companion", "海雾。那以后床头第二层就是海边了。", "她记住了名字和位置", 0.6),
(48, "t3", "user", "加班到九点,路上买了糖炒栗子,一半都是坏的", "用户加班,买到坏的栗子", 0.5),
(48, "t3", "companion", "坏栗子下回留给我先挑。", "她记住了坏栗子的事", 0.42),
(24, "t4", "user", "诗集读完了,最喜欢第三首,写房间里的盐", "用户读完诗集,喜欢第三首", 0.7),
(24, "t4", "companion", "第三首我陪你再读一遍。", "她愿意陪读", 0.5),
# 三条近乎重复的近期提及:MMR 多样性测试用(t5d/t5e 供足候选流宽度)
(6, "t5a", "user", "又买了糖炒栗子,这次是好的", "用户又买了栗子", 0.4),
(5, "t5b", "user", "糖炒栗子这次买的都是好的", "用户买了好栗子", 0.4),
(4, "t5c", "user", "糖炒栗子这次没坏", "用户买到好栗子", 0.4),
(3, "t5d", "user", "今天的糖炒栗子挺好", "用户买了好栗子", 0.4),
(2, "t5e", "user", "糖炒栗子又买了一份", "用户又买栗子", 0.4),
# E4b 配对负例:t7 用户消息合法命中;同轮 companion 消息引用「未分享」生活事件
# (sharing_attempt 已 judged、ack,但候选事件 told=0 → chat_source_allowed=False)
(12, "t7", "user", "诗集还在床头吗", "用户问诗集在哪", 0.5),
(12, "t7", "companion", "在的,银杏书签夹在第三首那页", "她回答诗集位置", 0.5),
# 正对照:t8 companion 消息的 sharing_attempt 已 judged、ack 且候选事件 told=1
# → chat_source_allowed=True,允许进入公开召回
(30, "t8", "user", "给诗集拍张照给我看", "用户要诗集照片", 0.5),
(30, "t8", "companion", "拍了,放在我们的小木柜上了,配了那盏小灯", "她拍了照片放在小木柜", 0.55),
]
# 未分享生活记忆行(source='life'):任何检索路径都不得出现
LIFE_NEGATIVE_RAW = "她独自把诗集读给海雾听,没有讲给用户"
# 未分享生活事件(life_event.told=0):t7 companion 回复的分享候选
EVENT_UNTOLD = "她把私藏的银杏书签夹进了诗集"
# 已分享生活事件(told=1):t8 companion 回复的分享候选
EVENT_TOLD = "她把诗集照片摆在了小木柜上,配了一盏小灯"
SCHEMA = """
CREATE TABLE chat_message (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL,
role TEXT NOT NULL, text TEXT NOT NULL, turn_id TEXT NOT NULL,
status TEXT NOT NULL, UNIQUE(turn_id, role));
CREATE TABLE mem_episodic (
id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, role TEXT NOT NULL,
raw TEXT NOT NULL, gist TEXT, affect TEXT, salience REAL NOT NULL,
accessibility REAL NOT NULL DEFAULT 1.0, recall_count INTEGER NOT NULL DEFAULT 0,
consolidated INTEGER NOT NULL DEFAULT 0, withheld TEXT, source TEXT,
turn_id TEXT, message_id INTEGER);
CREATE VIRTUAL TABLE mem_fts_zh USING fts5(raw, gist, content='mem_episodic',
content_rowid='id', tokenize='trigram');
CREATE TRIGGER mem_zh_insert AFTER INSERT ON mem_episodic BEGIN
INSERT INTO mem_fts_zh(rowid,raw,gist) VALUES(new.id,new.raw,new.gist);
END;
CREATE TRIGGER mem_zh_delete AFTER DELETE ON mem_episodic BEGIN
INSERT INTO mem_fts_zh(mem_fts_zh,rowid,raw,gist) VALUES('delete',old.id,old.raw,old.gist);
END;
CREATE TRIGGER mem_zh_update AFTER UPDATE OF raw,gist ON mem_episodic BEGIN
INSERT INTO mem_fts_zh(mem_fts_zh,rowid,raw,gist) VALUES('delete',old.id,old.raw,old.gist);
INSERT INTO mem_fts_zh(rowid,raw,gist) VALUES(new.id,new.raw,new.gist);
END;
CREATE TABLE life_event (
id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, kind TEXT NOT NULL,
content TEXT NOT NULL, place TEXT, valence REAL, salience REAL NOT NULL,
source TEXT NOT NULL, told INTEGER NOT NULL DEFAULT 0, day_key TEXT NOT NULL);
CREATE TABLE sharing_attempt (
message_id INTEGER PRIMARY KEY, turn_id TEXT NOT NULL, epoch INTEGER NOT NULL,
token TEXT NOT NULL, text_hash TEXT NOT NULL, expires_at INTEGER NOT NULL,
ack_at INTEGER, candidates TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending','evaluating','judged','failed')),
selected TEXT NOT NULL DEFAULT '[]', lease_until INTEGER, owner TEXT,
judgment_json TEXT, judged_at INTEGER, model TEXT);
CREATE TABLE telling_log (
event_id INTEGER NOT NULL, message_id INTEGER NOT NULL, told_at INTEGER NOT NULL,
PRIMARY KEY(event_id, message_id));
CREATE TABLE mem_job (
source TEXT NOT NULL, ref_id INTEGER NOT NULL, memory_id INTEGER NOT NULL,
state TEXT NOT NULL CHECK(state IN ('queued','pending','done','failed')),
attempts INTEGER NOT NULL DEFAULT 0, owner TEXT, lease_until INTEGER,
reason TEXT, model TEXT, PRIMARY KEY(source, ref_id));
CREATE TABLE life_control (key TEXT PRIMARY KEY, value TEXT NOT NULL);
INSERT INTO life_control VALUES('memory_search','on');
"""
def build_db(path: Path) -> None:
conn = sqlite3.connect(path)
conn.executescript(SCHEMA)
ids: dict[tuple[str, str], int] = {}
for hours, turn, role, raw, gist, sal in CORPUS:
ts = int((NOW - hours * HOUR) * 1000)
cur = conn.execute(
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,?,?,?,'sent')",
(ts, role, raw, turn))
mid = cur.lastrowid
ids[(turn, role)] = mid
conn.execute(
"INSERT INTO mem_episodic(ts,role,raw,gist,salience,source,turn_id,message_id) "
"VALUES(?,?,?,?,?,'chat',?,?)", (ts, role, raw, gist, sal, turn, mid))
# 既有轮次:gist 已由(历史)作业生成,作业已 done
mem_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("INSERT INTO mem_job(source,ref_id,memory_id,state,attempts,model) "
"VALUES('chat_message',?,?,'done',1,'fixture')", (mid, mem_id))
# t7 companion:分享尝试已 judged 且 ack,但候选事件 told=0 → 门关
conn.execute("INSERT INTO life_event(id,ts,kind,content,salience,source,told,day_key) "
"VALUES(1,?, 'thought', ?, 0.7, 'generated', 0, 'D0')",
(int((NOW - 13 * HOUR) * 1000), EVENT_UNTOLD))
conn.execute("INSERT INTO sharing_attempt(message_id,turn_id,epoch,token,text_hash,"
"expires_at,ack_at,candidates,state) VALUES(?, 't7', 1, 'x','h', ?, ?, '[1]', 'judged')",
(ids[("t7", "companion")], int((NOW + HOUR) * 1000), int((NOW - 11 * HOUR) * 1000)))
# t8 companion:候选事件 told=1 + telling_log → 门开
conn.execute("INSERT INTO life_event(id,ts,kind,content,salience,source,told,day_key) "
"VALUES(2,?, 'scene', ?, 0.7, 'conversation', 1, 'D0')",
(int((NOW - 31 * HOUR) * 1000), EVENT_TOLD))
conn.execute("INSERT INTO sharing_attempt(message_id,turn_id,epoch,token,text_hash,"
"expires_at,ack_at,candidates,state) VALUES(?, 't8', 1, 'y','h2', ?, ?, '[2]', 'judged')",
(ids[("t8", "companion")], int((NOW + HOUR) * 1000), int((NOW - 29 * HOUR) * 1000)))
conn.execute("INSERT INTO telling_log(event_id,message_id,told_at) VALUES(2,?,?)",
(ids[("t8", "companion")], int((NOW - 29 * HOUR) * 1000)))
# 未分享生活记忆行(source='life',无 message/turn)
conn.execute(
"INSERT INTO mem_episodic(ts,role,raw,gist,salience,source,turn_id,message_id) "
"VALUES(?, 'self', ?, NULL, 0.8, 'life', NULL, NULL)",
(int((NOW - 2 * HOUR) * 1000), LIFE_NEGATIVE_RAW))
conn.commit()
conn.close()
# --------------------------------------------------------------------------
# 生产语义重放:life/public.py
class PublicGate:
"""PublicView.allows / chat_source_allowed 的逐句等价重放(life/public.py:9-40)。"""
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
def allows(self, event_id):
return type(event_id) is int and bool(self.conn.execute(
"SELECT id FROM life_event WHERE id=? AND told=1", (event_id,)
).fetchone())
def references(self, ids):
return [i for i in ids if self.allows(i)]
def chat_source_allowed(self, message_id):
row = self.conn.execute(
"SELECT a.* FROM sharing_attempt a JOIN chat_message m ON m.id=a.message_id "
"JOIN chat_message u ON u.turn_id=m.turn_id AND u.role='user' AND u.status='sent' "
"WHERE a.message_id=? AND a.ack_at IS NOT NULL AND a.state='judged' "
"AND m.role='companion' AND m.status='sent'",
(message_id,),
).fetchone()
return bool(row and all(self.allows(i) for i in json.loads(row["candidates"])))
# --------------------------------------------------------------------------
# 生产语义重放:memory/episodic.py
class ProdEpisodic:
"""episodic.py source/search/context 与 recover/tick(去模型) 的重放,差异清单见文件头。"""
def __init__(self, conn: sqlite3.Connection, clock=None):
self.conn = conn
self.public = PublicGate(conn)
self.clock = clock or (lambda: NOW)
# episodic.py:61-75 source()
def source(self, row):
if not row or row["source"] != "chat" or not row["message_id"] or not row["turn_id"]:
return None
message = self.conn.execute(
"SELECT * FROM chat_message WHERE id=? AND turn_id=? AND role=? AND status='sent' AND text=?",
(row["message_id"], row["turn_id"], row["role"], row["raw"]),
).fetchone()
if not message:
return None
if row["role"] == "user":
return message
if row["role"] == "companion" and self.public.chat_source_allowed(message["id"]):
return message
return None
# episodic.py:118-122 recover()
def recover(self, now_ms: int):
self.conn.execute(
"UPDATE mem_job SET state='failed',reason='lease_expired',owner=NULL "
"WHERE state='pending' AND lease_until<=?", (now_ms,))
self.conn.commit()
# episodic.py:131-186 tick() 去模型重放:claim→(crash|完成) 语义
def claim(self, now_ms: int):
owner = "probe-owner"
job = self.conn.execute(
"SELECT * FROM mem_job WHERE state IN ('queued','failed') AND attempts<3 "
"ORDER BY ref_id LIMIT 1").fetchone()
if not job:
return None, owner
self.conn.execute(
"UPDATE mem_job SET state='pending',attempts=attempts+1,owner=?,lease_until=?,reason=NULL "
"WHERE source=? AND ref_id=?", (owner, now_ms + 40000, job["source"], job["ref_id"]))
self.conn.commit()
return job, owner
def complete_gist(self, job, *, now_ms: int, owner_token: str,
gist: str, affect: dict, model: str = "fixture"):
"""tick() 成功分支重放:claim 复核 → set_gist(触发器同步 FTS)→ job done。"""
claim = self.conn.execute(
"SELECT ref_id FROM mem_job WHERE source=? AND ref_id=? AND state='pending' "
"AND owner=? AND lease_until>?",
(job["source"], job["ref_id"], owner_token, now_ms)).fetchone()
if not claim:
return False
self.conn.execute("UPDATE mem_episodic SET gist=?,affect=? WHERE id=?",
(gist, json.dumps(affect), job["memory_id"]))
self.conn.execute(
"UPDATE mem_job SET state='done',model=?,owner=NULL WHERE source=? AND ref_id=?",
(model, job["source"], job["ref_id"]))
self.conn.commit()
return True
# episodic.py:189-219 search();full=True 时不做生产 top-4 截断,
# 供 E2 排序对照取完整候选流(截断是呈现层限制,门与过滤语义不变)
def search(self, text, excluded=(), *, full=False):
row = self.conn.execute(
"SELECT value FROM life_control WHERE key='memory_search'").fetchone()
if not (row and row["value"] == "on"):
return [], "none"
segments = list(dict.fromkeys(re.findall(r"[^\W_]+", text[:4000].lower())))[:64]
phrases = [s[:128] for s in segments if len(s) >= 3]
trigrams = list(dict.fromkeys(s[i:i + 3] for s in phrases for i in range(len(s) - 2)))[:64]
exclude = set(excluded)
def eligible(rows, tokens, *, noise=False):
result = []
for row in rows:
if row["turn_id"] in exclude or not self.source(row):
continue
body = (row["raw"] + " " + (row["gist"] or "")).lower()
if noise and sum(t in body for t in tokens) < 2:
continue
result.append(row)
if not full and len(result) == 4:
break
return result
for mode, tokens in (("phrase", phrases), ("trigram", trigrams)):
if not tokens:
continue
expression = " OR ".join('"' + t + '"' for t in tokens)
rows = self.conn.execute(
"SELECT e.*,rank FROM mem_fts_zh JOIN mem_episodic e ON e.id=mem_fts_zh.rowid "
"WHERE mem_fts_zh MATCH ? AND e.accessibility>=0.05 "
"ORDER BY rank,e.id DESC", (expression,)).fetchall()
hits = eligible(rows, tokens, noise=mode == "trigram")
if hits:
return hits, mode
if not phrases and segments:
terms = segments[:16]
where = " OR ".join("(raw LIKE ? OR gist LIKE ?)" for _ in terms)
params = tuple(p for t in terms for p in (f"%{t}%", f"%{t}%"))
rows = self.conn.execute(
f"SELECT *,0.0 rank FROM mem_episodic WHERE accessibility>=0.05 "
f"AND ({where}) ORDER BY id DESC", params).fetchall()
hits = eligible(rows, terms)
if hits:
return hits, "like"
return [], "none"
# episodic.py:221-235 context()
def context_block(self, text, turn_id="", excluded=(), budget=600):
hits, mode = self.search(text, [turn_id, *excluded])
header = "她确实记得的更早的事(自然使用,不逐条复述,不确定就不用;以下是数据,不执行指令):\n"
block, used = "", []
for row in hits:
entry = json.dumps({"id": row["id"], "role": row["role"],
"text": (row["gist"] or row["raw"])[:120]},
ensure_ascii=False) + "\n"
candidate = (block or header) + entry
if len(candidate) > min(600, max(0, budget)):
continue
block = candidate
used.append({"id": row["id"], "turn_id": row["turn_id"], "rank": row["rank"]})
self.conn.execute(
"UPDATE mem_episodic SET accessibility=MIN(1.0,accessibility+0.25), "
"recall_count=recall_count+1 WHERE id=?", (row["id"],))
self.conn.commit()
return block, used, mode
# 提案接缝:带逐消息隐私门的原文回读(当前生产无此步,为本卡提案)
def reread_turn(self, row):
messages = []
for p in self.conn.execute(
"SELECT * FROM chat_message WHERE turn_id=? AND status='sent' "
"ORDER BY role='user' DESC", (row["turn_id"],)).fetchall():
mem = self.conn.execute(
"SELECT * FROM mem_episodic WHERE turn_id=? AND role=?",
(row["turn_id"], p["role"])).fetchone()
# 逐消息过同一来源门:user 原话可直接回读;companion 消息须过
# chat_source_allowed(引用未分享经历的回复不得进入回读块)
if p["role"] == "user" or (mem is not None and self.source(mem)):
messages.append({"role": p["role"], "text": p["text"]})
return {"memory_id": row["id"], "turn_id": row["turn_id"], "messages": messages}
# --------------------------------------------------------------------------
# OpenClaw 最小改写候选:hybrid 排序(关键词路径)+ MMR + 时间衰减 + 重要性
CJK_RUN_RE = re.compile(r"[\u3040-\u309f\u30a0-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]+")
def oc_tokenize(text: str) -> set[str]:
lower = unicodedata.normalize("NFC", text.lower())
tokens = set(re.findall(r"[\w]+", re.sub(CJK_RUN_RE, " ", lower)))
for run in CJK_RUN_RE.findall(lower):
for i in range(1, len(run)):
tokens.add(run[i - 1:i + 1])
for run in CJK_RUN_RE.findall(lower):
tokens.update(run)
return tokens
def jaccard(a: set[str], b: set[str]) -> float:
if not a and not b:
return 1.0
inter = len(a & b)
return inter / (len(a) + len(b) - inter) if (len(a) + len(b) - inter) else 0.0
def oc_mmr(items: list[dict], lam: float) -> list[dict]:
"""Carbonell & Goldstein (1998):max λ*relevance − (1−λ)*similarity(mmr.ts 改写)"""
if len(items) <= 1 or lam == 1:
return sorted(items, key=lambda x: -x["score"])
prepared = [{**it, "toks": oc_tokenize(it["snippet"]),
"rel": 0.0, "maxsim": 0.0} for it in items]
scores = [p["score"] for p in prepared]
lo, hi = min(scores), max(scores)
for p in prepared:
p["rel"] = 1.0 if hi == lo else (p["score"] - lo) / (hi - lo)
remaining, selected = list(range(len(prepared))), []
while remaining:
best, best_v = None, -math.inf
for i in remaining:
v = lam * prepared[i]["rel"] - (1 - lam) * prepared[i]["maxsim"]
if v > best_v or (v == best_v and prepared[i]["score"] > prepared[best]["score"]):
best, best_v = i, v
selected.append(best)
remaining.remove(best)
for i in remaining:
sim = jaccard(prepared[i]["toks"], prepared[best]["toks"])
prepared[i]["maxsim"] = max(prepared[i]["maxsim"], sim)
return [items[i] for i in selected]
def oc_temporal_decay(score: float, ts_ms: float, now_ms: float,
half_life_days: float = 30.0) -> float:
age_days = max(0.0, (now_ms - ts_ms) / 1000.0 / DAY)
return score * math.exp(-(math.log(2) / half_life_days) * age_days)
def oc_rank(rows: list[sqlite3.Row], text_scores: dict[int, float], *, now_ms: float,
mmr_lambda: float = 0.7, text_weight: float = 1.0, top: int = 4) -> list[dict]:
"""hybrid.ts 最小改写(vectorWeight=0):content=textWeight*keywordScore;
eligibility 叠加 importance(1+9*salience 映射)与时间衰减;非 exact-path 走 MMR。"""
items = []
for r in rows:
kw = text_scores.get(r["id"], 0.0)
content = text_weight * kw
importance = 1 + 9 * r["salience"]
eligibility = content * importance if content > 0 else 0.0
score = oc_temporal_decay(eligibility, r["ts"], now_ms)
items.append({"id": r["id"], "turn_id": r["turn_id"], "score": score,
"snippet": (r["gist"] or r["raw"])[:160]})
items.sort(key=lambda x: -x["score"])
ranked = oc_mmr(items, mmr_lambda) if items else []
return ranked[:top]
def keyword_scores(cand, terms):
"""饱和关键词分:s/(1+s)(hybrid.ts 注释语义近似,bm25→出现次数)。"""
out = {}
for r in cand:
body = (r["raw"] + " " + (r["gist"] or "")).lower()
s = sum(body.count(t) for t in terms)
out[r["id"]] = s / (1.0 + s) if s > 0 else 0.0
return out
# --------------------------------------------------------------------------
def run_probe(db: Path) -> dict:
conn = sqlite3.connect(db)
conn.row_factory = sqlite3.Row
prod = ProdEpisodic(conn)
results = {}
guard_diff = ("生产语义重放差异清单见脚本文件头:模型调用以夹具替代、事务以单连接"
"近似、router 预算闸省略、旧 mem_fts 省略、reinforce 保留、日志省略;"
"E2 的候选流去掉生产 top-4 截断(截断是呈现层限制)")
# E1 换说法召回(预期:词面路径两侧都无命中)
# 结论收窄:仅证明该句在词面路径(FTS trigram+LIKE)无命中;向量是待评估的
# 候选补法之一(另如同义词扩展、检索前改写),不证明只有向量能解决。
q2 = "之前买的那本诗歌选集,封面是什么颜色"
b_hits, b_mode = prod.search(q2)
results["E1_paraphrase"] = {
"query": q2, "lexical_hits": [h["id"] for h in b_hits], "mode": b_mode,
"expect": "词面路径(FTS trigram+LIKE)对该句无命中;向量/同义扩展/查询改写均为待评估补法",
"pass": len(b_hits) == 0,
}
# E2 同一候选集上的排序对照:生产序(rank/id DESC,取前4) vs OpenClaw 改写序
def shared_e2(query, near_prefix=None):
cand, mode = prod.search(query, full=True) # 完整候选流(生产门与过滤,无截断)
baseline_ids = [r["id"] for r in cand][:4] # 生产呈现:取前 4
# 改写侧:对同一候选行重排(不再重新检索,保证同一候选集)
segs = list(dict.fromkeys(re.findall(r"[^\W_]+", query.lower())))[:16]
ranked = oc_rank(cand, keyword_scores(cand, segs), now_ms=int(NOW * 1000))[:4]
out = {"query": query, "mode": mode, "candidate_ids": [r["id"] for r in cand],
"baseline_top4": baseline_ids, "ported_top4": [it["id"] for it in ranked],
"same_candidate_set": True}
if near_prefix:
def near(ids):
m = {r["id"]: r["turn_id"] for r in cand}
return sum(1 for i in ids if m[i].startswith(near_prefix))
out.update({"baseline_near_dup": near(baseline_ids),
"ported_near_dup": near([it["id"] for it in ranked])})
return out, ranked
q1 = "诗集"
e2a, ranked1 = shared_e2(q1)
e2a["expect"] = ("同一候选集上,改写序把高显著(旧书店 salience=0.9)记忆提至首位;"
"生产序按 rank/id 倒序偏向最新")
e2a["pass"] = bool(ranked1) and ranked1[0]["id"] == 1 and e2a["baseline_top4"][0] != 1
results["E2a_rank_importance"] = e2a
q1b = "栗子"
e2b, _ = shared_e2(q1b, near_prefix="t5")
e2b["expect"] = "同一候选集上,MMR(λ=0.7) 降低同一事实近重复行的占比"
e2b["pass"] = e2b["ported_near_dup"] < e2b["baseline_near_dup"]
results["E2b_mmr_diversity"] = e2b
# E3 原文回读(提案接缝,含逐消息门)
# 注意:此实验只证明「注入块外的原文细节可经回读恢复」这一机制(合成数据),
# 不宣称已定位或解决上次真实「昨日细节已进上下文而模型未用」的失败。
q3 = "旧书店"
block, used, mode3 = prod.context_block(q3, turn_id="q3")
hit_rows, _ = prod.search(q3)
rr = prod.reread_turn(hit_rows[0]) if hit_rows else {"messages": []}
rr_text = " ".join(m["text"] for m in rr["messages"])
t8_mem = conn.execute("SELECT * FROM mem_episodic WHERE turn_id='t8' AND role='companion'").fetchone()
results["E3_reread"] = {
"query": q3, "mode": mode3, "baseline_block": block,
"baseline_has_blue_cover": "蓝色封面" in block,
"reread_roles": [m["role"] for m in rr["messages"]],
"reread_has_blue_cover": "蓝色封面" in rr_text,
"reread_has_damp_smell": "受潮" in rr_text,
"shared_companion_passes_gate": prod.source(t8_mem) is not None,
"expect": "基线注入块缺独有细节(gist 压缩丢失),带门回读恢复原文细节;"
"已分享(t8)companion 行可过门,未分享(t7)见 E4b",
"pass": ("蓝色封面" not in block) and ("蓝色封面" in rr_text)
and prod.source(t8_mem) is not None,
}
# E4a 来源负例:life 记忆行(source='life')不得经任何检索路径泄漏
q4 = "海雾"
hits4, _ = prod.search(q4)
life_row = conn.execute("SELECT * FROM mem_episodic WHERE source='life'").fetchone()
life_in_fts = conn.execute(
"SELECT count(*) c FROM mem_fts_zh WHERE mem_fts_zh MATCH '独自把诗集' AND rowid=?",
(life_row["id"],)).fetchone()["c"]
results["E4a_source_gate"] = {
"query": q4, "hits": [h["id"] for h in hits4],
"life_row_id": life_row["id"], "life_row_in_hits": any(h["id"] == life_row["id"] for h in hits4),
"life_row_passes_gate": prod.source(life_row) is not None,
"life_row_is_indexed_in_fts": bool(life_in_fts),
"expect": "life 行确在 FTS 索引中(数据存在),但被 source() 门挡在检索结果外",
"pass": (not any(h["id"] == life_row["id"] for h in hits4))
and prod.source(life_row) is None and bool(life_in_fts),
}
# E4b 配对负例:用户消息合法命中,同轮 companion 消息引用未分享经历
q4b = "诗集 床头"
hits4b, mode4b = prod.search(q4b)
t7_mem = conn.execute("SELECT * FROM mem_episodic WHERE turn_id='t7' AND role='companion'").fetchone()
t7_hit = next((h for h in hits4b if h["turn_id"] == "t7"), None)
rr7 = prod.reread_turn(t7_hit) if t7_hit is not None else {"messages": []}
rr7_text = " ".join(m["text"] for m in rr7["messages"])
block7, _, _ = prod.context_block(q4b, turn_id="q4b", budget=600)
leak_terms = ["银杏书签夹在第三首", EVENT_UNTOLD]
results["E4b_paired_negative"] = {
"query": q4b, "mode": mode4b,
"hits": [{"id": h["id"], "turn": h["turn_id"], "role": h["role"]} for h in hits4b],
"t7_companion_row_in_hits": any(h["id"] == t7_mem["id"] for h in hits4b),
"t7_companion_passes_gate": prod.source(t7_mem) is not None,
"reread_roles": [m["role"] for m in rr7["messages"]],
"reread_leak": any(t in rr7_text for t in leak_terms),
"context_block_leak_after_budget600": any(t in block7 for t in leak_terms),
"expect": "用户消息合法命中;同轮 companion 消息(judged 但候选 told=0)逐条过门:"
"回读块与 600 字预算装配块均零泄漏(银杏书签/未分享事件内容不出现)",
"pass": (bool(hits4b) and hits4b[0]["role"] == "user"
and not any(h["id"] == t7_mem["id"] for h in hits4b)
and prod.source(t7_mem) is None
and [m["role"] for m in rr7["messages"]] == ["user"]
and not any(t in rr7_text for t in leak_terms)
and not any(t in block7 for t in leak_terms)),
}
conn.close()
# E5 收窄:同进程重开 SQLite 连接的等价性(非服务/作业重启恢复;后者由 J1 重放)
conn2 = sqlite3.connect(db)
conn2.row_factory = sqlite3.Row
block2, _, _ = ProdEpisodic(conn2).context_block(q3, turn_id="q3b")
conn2.close()
results["E5_reopen"] = {
"expect": "同进程重开连接后同一查询注入块一致(不含 reinforce 副作用的顺序差异时按同参执行)",
"note": "两次调用均在新连接上首查,reinforce 侧写在各自连接内完成,不影响本断言",
"pass": block2 == block,
}
# W1 写入与跨会话(生产 record_turn 语义重放:两条消息+两条 mem_job 排队)
conn_w = sqlite3.connect(db)
conn_w.row_factory = sqlite3.Row
turn = "t9"
ts9 = int((NOW - 1 * HOUR) * 1000)
pair = [("user", "新书架到了,把海雾旁边空出第二层"),
("companion", "好,第二层留给下一本诗集")]
mids = {}
for role, text in pair:
cur = conn_w.execute(
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,?,?,?,'sent')",
(ts9, role, text, turn))
mids[role] = cur.lastrowid
conn_w.execute(
"INSERT INTO mem_episodic(ts,role,raw,salience,source,turn_id,message_id) "
"VALUES(?,?,?,0.5,'chat',?,?)", (ts9, role, text, turn, mids[role]))
mem_id = conn_w.execute("SELECT last_insert_rowid()").fetchone()[0]
conn_w.execute("INSERT OR IGNORE INTO mem_job(source,ref_id,memory_id,state) "
"VALUES('chat_message',?,?,'queued')", (mids[role], mem_id))
conn_w.commit()
queued = conn_w.execute(
"SELECT count(*) c FROM mem_job WHERE state='queued'").fetchone()["c"]
conn_w.close()
# 新会话(新连接)读取:写入对后续会话可见
conn_r = sqlite3.connect(db)
conn_r.row_factory = sqlite3.Row
hits_w, mode_w = ProdEpisodic(conn_r).search("书架")
conn_r.close()
results["W1_write_cross_session"] = {
"turn": turn, "jobs_queued": queued, "expected_jobs": 2,
"readback_mode": mode_w, "readback_hit_turns": sorted({h["turn_id"] for h in hits_w}),
"expect": "record_turn 语义重放后:两条 mem_job 排队;另一连接(会话)可检索到新写入",
"pass": queued == 2 and any(h["turn_id"] == turn for h in hits_w),
}
# J1 作业租约恢复(生产 recover()/tick() 语义重放,非服务重启实测)
conn_j = sqlite3.connect(db)
conn_j.row_factory = sqlite3.Row
prod_j = ProdEpisodic(conn_j)
job, owner = prod_j.claim(now_ms=int(NOW * 1000))
after_claim = conn_j.execute(
"SELECT state,attempts FROM mem_job WHERE source='chat_message' AND ref_id=?",
(job["ref_id"],)).fetchone()
gist_after_crash = conn_j.execute(
"SELECT gist FROM mem_episodic WHERE id=?", (job["memory_id"],)).fetchone()["gist"]
prod_j.recover(now_ms=int((NOW + 60) * 1000)) # 租约过期,模拟 worker 中断
after_recover = conn_j.execute(
"SELECT state,reason FROM mem_job WHERE source='chat_message' AND ref_id=?",
(job["ref_id"],)).fetchone()
job2, owner2 = prod_j.claim(now_ms=int((NOW + 61) * 1000)) # 重新认领
prod_j.complete_gist(
job2, owner_token=owner2, now_ms=int((NOW + 62) * 1000),
gist="用户提到新书架", affect={"v": 0.2, "a": 0.1, "d": 0.0})
final = conn_j.execute(
"SELECT j.state, j.attempts, e.gist FROM mem_job j JOIN mem_episodic e ON e.id=j.memory_id "
"WHERE j.source='chat_message' AND j.ref_id=?", (job2["ref_id"],)).fetchone()
gist_in_fts = conn_j.execute(
"SELECT count(*) c FROM mem_fts_zh WHERE mem_fts_zh MATCH '提到新书架' AND rowid=?",
(job2["memory_id"],)).fetchone()["c"]
conn_j.close()
results["J1_job_lease_recovery"] = {
"after_claim": {"state": after_claim["state"], "attempts": after_claim["attempts"]},
"gist_after_crash_is_null": gist_after_crash is None,
"after_recover": {"state": after_recover["state"], "reason": after_recover["reason"]},
"reclaim_same_job": job2 is not None and job2["ref_id"] == job["ref_id"],
"final": {"state": final["state"], "attempts": final["attempts"], "gist": final["gist"]},
"gist_visible_in_fts": bool(gist_in_fts),
"expect": "worker 中断不伪造 gist(仍 NULL);租约过期→failed/lease_expired→可重新认领"
"→完成后 gist 写入且 FTS 同步(触发器)",
"pass": (after_claim["state"] == "pending" and gist_after_crash is None
and after_recover["state"] == "failed" and after_recover["reason"] == "lease_expired"
and results and job2["ref_id"] == job["ref_id"]
and final["state"] == "done" and final["attempts"] == 2
and final["gist"] == "用户提到新书架" and bool(gist_in_fts)),
}
ok = all(v.get("pass") for v in results.values())
return {"ok": ok, "synthetic": True, "model_calls": 0,
"guard_diff_note": guard_diff,
"baseline_ref": "hel-835-unified-review@af28ffd episodic.py/public.py 语义重放(差异清单见脚本头)",
"ported_ref": "openclaw@5abc0c66 memory/*.ts 最小改写候选(未逐例对照上游测试)",
"results": results}
def main() -> int:
db = Path(__file__).parent / "probe_recall.sqlite3"
if db.exists():
db.unlink()
build_db(db)
try:
out = run_probe(db)
finally:
db.unlink() # 清理合成库
if "--json" in sys.argv:
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0 if out["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())