mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
Fix processed message hash migration
This commit is contained in:
363
.claude/plans/hide-hash-extraction-fix.md
Normal file
363
.claude/plans/hide-hash-extraction-fix.md
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
# ST-BME: 隐藏旧楼层 × 自动提取 × 历史恢复 问题修复计划(收紧版)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把当前现象拆成两类问题分别处理,避免把“已证实的根因”和“待确认的假设”混在一起:
|
||||||
|
|
||||||
|
1. **已证实问题**:隐藏旧楼层会改动 `is_system`,而历史完整性哈希把 `is_system` 算进去,导致误判历史被改动,进而触发错误恢复,甚至形成恢复循环。
|
||||||
|
2. **待确认问题**:新聊天里自动提取不启动,当前最可疑的是图谱持久化就绪时序,但还没有足够证据证明它与上面的哈希误判属于同一条因果链。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前观察到的症状
|
||||||
|
|
||||||
|
### 症状 A:新聊天中自动提取似乎不启动
|
||||||
|
|
||||||
|
- 新开聊天,聊了多层后,“历史状态”仍显示“干净,已处理到楼层 -1”
|
||||||
|
- 手动点击“手动提取”后,`lastProcessedAssistantFloor` 才推进
|
||||||
|
|
||||||
|
### 症状 B:手动提取成功后,再聊一层会误触发历史恢复
|
||||||
|
|
||||||
|
- 提示类似:
|
||||||
|
|
||||||
|
> ⚠ 楼层 4 内容或 swipe 已变化
|
||||||
|
> 检测到楼层历史变化,将从楼层 4 之后自动恢复图谱
|
||||||
|
|
||||||
|
### 症状 C:触发恢复后界面表现为“提取卡住”
|
||||||
|
|
||||||
|
- 面板显示“ST-BME 提取 - AI 生成中...”
|
||||||
|
- Console 没有明显新的推进日志
|
||||||
|
- 更可能是恢复与后续自动提取反复触发,表现为“看起来挂住”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已确认的代码事实
|
||||||
|
|
||||||
|
### 1. 自动提取入口确实存在
|
||||||
|
|
||||||
|
- [`event-binding.js`](C:\Users\brian\OneDrive\Desktop\ST-Bionic-Memory-Ecology-past\event-binding.js) 的 `onMessageReceivedController`
|
||||||
|
- 收到 assistant 消息后,会排一个 microtask 调用 `runExtraction()`
|
||||||
|
|
||||||
|
### 2. 自动提取确实会被图谱未就绪挡住
|
||||||
|
|
||||||
|
- [`extraction-controller.js`](C:\Users\brian\OneDrive\Desktop\ST-Bionic-Memory-Ecology-past\extraction-controller.js) 的 `runExtractionController`
|
||||||
|
- Guard 3:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (!runtime.ensureGraphMutationReady("自动提取", { notify: false })) {
|
||||||
|
runtime.deferAutoExtraction?.("graph-not-ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [`index.js`](C:\Users\brian\OneDrive\Desktop\ST-Bionic-Memory-Ecology-past\index.js) 的 `deferAutoExtraction` / `maybeResumePendingAutoExtraction` 也确实存在持续重试逻辑
|
||||||
|
|
||||||
|
### 3. 隐藏旧楼层会直接改 `message.is_system`
|
||||||
|
|
||||||
|
- [`hide-engine.js`](C:\Users\brian\OneDrive\Desktop\ST-Bionic-Memory-Ecology-past\hide-engine.js) 的 `markManagedSystemRange`
|
||||||
|
- 会把消息对象直接写成 `message.is_system = true`
|
||||||
|
- 同时写入 `message.extra.__st_bme_hide_managed = true`
|
||||||
|
- [`restoreManagedSystemFlags`] 会把这些消息重新改回 `is_system = false`
|
||||||
|
|
||||||
|
### 4. 历史完整性哈希当前包含 `isSystem`
|
||||||
|
|
||||||
|
- [`runtime-state.js`](C:\Users\brian\OneDrive\Desktop\ST-Bionic-Memory-Ecology-past\runtime-state.js) 的 `buildMessageHash`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
isUser: Boolean(message?.is_user),
|
||||||
|
isSystem: managedHideMarker ? false : Boolean(message?.is_system),
|
||||||
|
text: String(message?.mes || ""),
|
||||||
|
swipeId,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 历史恢复就是基于这个哈希差异触发的
|
||||||
|
|
||||||
|
- [`runtime-state.js`](C:\Users\brian\OneDrive\Desktop\ST-Bionic-Memory-Ecology-past\runtime-state.js) 的 `detectHistoryMutation`
|
||||||
|
- [`index.js`](C:\Users\brian\OneDrive\Desktop\ST-Bionic-Memory-Ecology-past\index.js) 的 `inspectHistoryMutation` / `recoverHistoryIfNeeded`
|
||||||
|
|
||||||
|
因此,**“隐藏逻辑改了 `is_system`,而历史哈希又把 `is_system` 当成内容真相的一部分”这一矛盾是已坐实的。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 根因划分
|
||||||
|
|
||||||
|
### 根因 1(已确认):`is_system` 被错误纳入历史完整性判断
|
||||||
|
|
||||||
|
这是症状 B、C 的主根因。
|
||||||
|
|
||||||
|
#### 问题链条
|
||||||
|
|
||||||
|
1. 某次提取完成后,系统对已处理楼层拍快照
|
||||||
|
2. 快照中的哈希包含 `isSystem`
|
||||||
|
3. 旧楼层隐藏逻辑随后执行,会改动部分消息的 `is_system`
|
||||||
|
4. 后续完整性检查重新计算哈希时,发现同一楼层 hash 不一致
|
||||||
|
5. `detectHistoryMutation` 误以为消息内容或 swipe 发生变化
|
||||||
|
6. 触发 `recoverHistoryIfNeeded`
|
||||||
|
7. 若恢复后又再次遇到同类误判,就可能出现“恢复 -> 再检查 -> 再恢复”的循环
|
||||||
|
|
||||||
|
#### 关键结论
|
||||||
|
|
||||||
|
- `is_system` 在这里更像一种**展示/隐藏副作用**
|
||||||
|
- 它不应作为“消息历史真实性”的核心判据
|
||||||
|
- 否则隐藏功能会污染历史恢复机制
|
||||||
|
|
||||||
|
### 根因 2(待确认):自动提取在新聊天中的启动/恢复时序不稳
|
||||||
|
|
||||||
|
这是症状 A 的最可疑原因,但**目前还不能写死为已确认根因**。
|
||||||
|
|
||||||
|
#### 当前证据
|
||||||
|
|
||||||
|
- 自动提取入口存在
|
||||||
|
- Guard 3 会在 DB 未就绪时阻断
|
||||||
|
- 阻断后会走 `deferAutoExtraction`
|
||||||
|
- resume 逻辑理论上会持续重试,不是一次失败就永久放弃
|
||||||
|
|
||||||
|
#### 当前仍不确定的点
|
||||||
|
|
||||||
|
- 新聊天里究竟是否一直卡在 `ensureGraphMutationReady`
|
||||||
|
- 还是 `MESSAGE_RECEIVED` 实际没有按预期命中 assistant 消息
|
||||||
|
- 还是 defer/resume 被别的状态反复打断
|
||||||
|
- 还是 load state 在某些聊天中长期停在非 ready 状态
|
||||||
|
|
||||||
|
所以,症状 A 目前应视为一个**独立待诊断问题**,而不是直接并入“隐藏哈希误判”这条链。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复方案
|
||||||
|
|
||||||
|
### 修复 A(核心,直接做):从 `buildMessageHash` 中移除 `isSystem`
|
||||||
|
|
||||||
|
**目标文件**:[`runtime-state.js`](C:\Users\brian\OneDrive\Desktop\ST-Bionic-Memory-Ecology-past\runtime-state.js)
|
||||||
|
|
||||||
|
#### 当前实现
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function buildMessageHash(message) {
|
||||||
|
const managedHideMarker = Boolean(
|
||||||
|
message?.extra &&
|
||||||
|
typeof message.extra === "object" &&
|
||||||
|
message.extra.__st_bme_hide_managed === true,
|
||||||
|
);
|
||||||
|
const swipeId = Number.isFinite(message?.swipe_id) ? message.swipe_id : null;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
isUser: Boolean(message?.is_user),
|
||||||
|
isSystem: managedHideMarker ? false : Boolean(message?.is_system),
|
||||||
|
text: String(message?.mes || ""),
|
||||||
|
swipeId,
|
||||||
|
});
|
||||||
|
return String(stableHashString(payload));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 建议修改
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function buildMessageHash(message) {
|
||||||
|
const swipeId = Number.isFinite(message?.swipe_id) ? message.swipe_id : null;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
isUser: Boolean(message?.is_user),
|
||||||
|
text: String(message?.mes || ""),
|
||||||
|
swipeId,
|
||||||
|
});
|
||||||
|
return String(stableHashString(payload));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 理由
|
||||||
|
|
||||||
|
- `is_system` 的变化不等于消息内容被编辑
|
||||||
|
- `text + swipeId + isUser` 已经足够覆盖绝大多数真正影响提取语义的变化
|
||||||
|
- 这能直接切断“隐藏副作用 -> hash 变化 -> 误恢复”的链路
|
||||||
|
|
||||||
|
#### 风险
|
||||||
|
|
||||||
|
- 如果用户真的手动把一条消息从普通消息改成系统消息,这个变化将不再被历史恢复逻辑视为“内容变更”
|
||||||
|
- 但这种操作相对罕见,而且比起当前误恢复问题,这个代价是可以接受的
|
||||||
|
|
||||||
|
### 修复 A-1(必须配套):加入快照哈希版本迁移
|
||||||
|
|
||||||
|
**这不是可选备注,而应作为正式方案的一部分。**
|
||||||
|
|
||||||
|
如果只改 `buildMessageHash` 而不做迁移:
|
||||||
|
|
||||||
|
- 旧快照是“含 `isSystem`”算法算出的
|
||||||
|
- 新代码会用“不含 `isSystem`”算法重新计算
|
||||||
|
- 第一次完整性检查几乎必然把所有已处理楼层判成 dirty
|
||||||
|
- 这会触发一次高代价恢复/重建
|
||||||
|
|
||||||
|
#### 推荐做法
|
||||||
|
|
||||||
|
给历史快照引入一个明确的 hash schema version,例如:
|
||||||
|
|
||||||
|
- `historyState.processedMessageHashVersion = 2`
|
||||||
|
|
||||||
|
加载图状态时:
|
||||||
|
|
||||||
|
1. 若版本缺失或旧于当前版本
|
||||||
|
2. 不走“历史被篡改”的判断
|
||||||
|
3. 直接清空旧 `processedMessageHashes`
|
||||||
|
4. 基于当前聊天内容重新拍一份新快照
|
||||||
|
5. 更新版本号
|
||||||
|
|
||||||
|
#### 目标
|
||||||
|
|
||||||
|
- 避免升级后第一次运行就误触发一次全量恢复
|
||||||
|
- 把这次变化当成“哈希算法升级”,而不是“聊天历史损坏”
|
||||||
|
|
||||||
|
### 修复 B(设计收敛,次优先):减少或移除 BME 对 `is_system` 的双写
|
||||||
|
|
||||||
|
**目标文件**:[`hide-engine.js`](C:\Users\brian\OneDrive\Desktop\ST-Bionic-Memory-Ecology-past\hide-engine.js)
|
||||||
|
|
||||||
|
当前隐藏引擎已经调用宿主的 `/hide` 和 `/unhide`,但仍然自己改:
|
||||||
|
|
||||||
|
- `message.is_system`
|
||||||
|
- DOM 上的 `is_system` attribute
|
||||||
|
- `__st_bme_hide_managed`
|
||||||
|
|
||||||
|
这说明现在是“宿主隐藏 + BME 本地 system 标记”双轨并存,副作用偏大。
|
||||||
|
|
||||||
|
#### 建议
|
||||||
|
|
||||||
|
先确认宿主 `/hide` 的真实语义:
|
||||||
|
|
||||||
|
1. `/hide` 是否已经足以让主 AI 和 UI 正常隐藏旧消息
|
||||||
|
2. `/hide` 是否会自行管理 `is_system`
|
||||||
|
3. BME 是否还有任何逻辑依赖 `message.is_system` 来跳过消息
|
||||||
|
|
||||||
|
#### 若确认不再需要本地双写
|
||||||
|
|
||||||
|
则逐步去掉:
|
||||||
|
|
||||||
|
- `markManagedSystemRange`
|
||||||
|
- `restoreManagedSystemFlags`
|
||||||
|
- `syncSystemAttribute`
|
||||||
|
- `__st_bme_hide_managed` 相关用途
|
||||||
|
|
||||||
|
#### 注意
|
||||||
|
|
||||||
|
这一步是“降低副作用、收敛设计”的改进,不是修复症状 B/C 的前提。
|
||||||
|
**真正阻断误恢复的是修复 A。**
|
||||||
|
|
||||||
|
### 修复 C(诊断,尽快做):给自动提取 guard 和 resume 点加日志
|
||||||
|
|
||||||
|
**目标文件**:
|
||||||
|
|
||||||
|
- [`extraction-controller.js`](C:\Users\brian\OneDrive\Desktop\ST-Bionic-Memory-Ecology-past\extraction-controller.js)
|
||||||
|
- [`index.js`](C:\Users\brian\OneDrive\Desktop\ST-Bionic-Memory-Ecology-past\index.js)
|
||||||
|
|
||||||
|
#### 建议打点位置
|
||||||
|
|
||||||
|
1. `runExtractionController` 每个 return 前
|
||||||
|
2. `ensureGraphMutationReady` 返回 false 的分支
|
||||||
|
3. `deferAutoExtraction`
|
||||||
|
4. `maybeResumePendingAutoExtraction`
|
||||||
|
5. `onMessageReceivedController` 命中 assistant 消息时
|
||||||
|
|
||||||
|
#### 目标不是长期保留大量日志
|
||||||
|
|
||||||
|
而是回答这几个问题:
|
||||||
|
|
||||||
|
- 新聊天时是否真的进入了 `runExtraction`
|
||||||
|
- 是否总卡在 `graph-not-ready`
|
||||||
|
- defer 是否持续排队
|
||||||
|
- resume 是否真的被触发
|
||||||
|
- resume 后是否又被 `extracting` / `history-recovering` / `graph-not-ready` 打回
|
||||||
|
|
||||||
|
只有拿到这些证据,才能决定症状 A 下一步是:
|
||||||
|
|
||||||
|
- 调整 load-ready 时机
|
||||||
|
- 在 chat loaded / chat changed 后主动 resume 一次
|
||||||
|
- 还是修正 assistant message 识别逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不建议在文档里写死的说法
|
||||||
|
|
||||||
|
以下表述建议从“结论”降级为“待验证假设”:
|
||||||
|
|
||||||
|
### 1. “三个症状是同一条因果链”
|
||||||
|
|
||||||
|
建议改为:
|
||||||
|
|
||||||
|
- 症状 B/C 已有统一根因
|
||||||
|
- 症状 A 暂时独立排查
|
||||||
|
|
||||||
|
### 2. “自动提取比隐藏重算更早执行,因此会读到隐藏中间态”
|
||||||
|
|
||||||
|
从当前代码时序看,这个说法不够稳。
|
||||||
|
|
||||||
|
- 自动提取:`MESSAGE_RECEIVED` microtask
|
||||||
|
- 隐藏重算:`GENERATION_ENDED` 后 180ms 调度
|
||||||
|
|
||||||
|
更稳妥的写法是:
|
||||||
|
|
||||||
|
- 隐藏逻辑会改动消息对象的 `is_system`
|
||||||
|
- 历史哈希又把 `is_system` 算进去了
|
||||||
|
- 因此在后续任一完整性检查时都可能误判 dirty
|
||||||
|
|
||||||
|
不必把主要解释建立在“正好撞上中间态”之上。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
1. **先做修复 A**
|
||||||
|
从历史哈希中移除 `isSystem`
|
||||||
|
2. **立刻配套修复 A-1**
|
||||||
|
加入快照哈希版本迁移,避免升级后误触发全量恢复
|
||||||
|
3. **并行做修复 C**
|
||||||
|
加最小必要日志,单独定位症状 A
|
||||||
|
4. **最后评估修复 B**
|
||||||
|
收敛隐藏引擎,减少 `is_system` 相关副作用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
### 针对症状 B/C
|
||||||
|
|
||||||
|
1. 手动提取成功后,再聊一层
|
||||||
|
2. 不再出现“楼层 X 内容或 swipe 已变化”的误恢复提示
|
||||||
|
3. `recoverHistoryIfNeeded` 不会因为纯隐藏操作反复触发
|
||||||
|
4. 面板不会再卡在“AI 生成中...”但无实际推进
|
||||||
|
|
||||||
|
### 针对升级迁移
|
||||||
|
|
||||||
|
1. 更新到新版本后
|
||||||
|
2. 不因 hash 算法变化直接触发一次全量恢复
|
||||||
|
3. `processedMessageHashes` 能平滑迁移到新版本
|
||||||
|
|
||||||
|
### 针对症状 A
|
||||||
|
|
||||||
|
1. 新聊天中收到 assistant 消息后能看到明确日志链路
|
||||||
|
2. 能判断问题究竟发生在:
|
||||||
|
- 事件未命中
|
||||||
|
- graph-not-ready
|
||||||
|
- defer/resume 丢失
|
||||||
|
- history-recovering 打断
|
||||||
|
- 其他 guard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键文件索引
|
||||||
|
|
||||||
|
| 文件 | 关键函数 | 用途 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `runtime-state.js` | `buildMessageHash` | 历史完整性哈希计算 |
|
||||||
|
| `runtime-state.js` | `snapshotProcessedMessageHashes` | 拍快照 |
|
||||||
|
| `runtime-state.js` | `detectHistoryMutation` | hash 对比与 dirty 判断 |
|
||||||
|
| `hide-engine.js` | `markManagedSystemRange` | 本地写 `is_system` |
|
||||||
|
| `hide-engine.js` | `restoreManagedSystemFlags` | 撤销本地写 `is_system` |
|
||||||
|
| `hide-engine.js` | `syncSystemAttribute` | 同步 DOM `is_system` attribute |
|
||||||
|
| `hide-engine.js` | `runHideApply` | 隐藏主流程 |
|
||||||
|
| `extraction-controller.js` | `runExtractionController` | 自动提取主流程 |
|
||||||
|
| `event-binding.js` | `onMessageReceivedController` | 自动提取事件入口 |
|
||||||
|
| `index.js` | `ensureGraphMutationReady` | 图谱写入前置就绪判断 |
|
||||||
|
| `index.js` | `deferAutoExtraction` | 自动提取延迟重试 |
|
||||||
|
| `index.js` | `maybeResumePendingAutoExtraction` | 自动提取恢复 |
|
||||||
|
| `index.js` | `updateProcessedHistorySnapshot` | 更新处理后快照 |
|
||||||
|
| `index.js` | `inspectHistoryMutation` | 历史检查入口 |
|
||||||
|
| `index.js` | `recoverHistoryIfNeeded` | 历史恢复主流程 |
|
||||||
|
| `index.js` | `scheduleMessageHideApply` | 隐藏调度 |
|
||||||
20
bme-sync.js
20
bme-sync.js
@@ -1,3 +1,5 @@
|
|||||||
|
import { PROCESSED_MESSAGE_HASH_VERSION } from "./runtime-state.js";
|
||||||
|
|
||||||
const BME_SYNC_FILE_PREFIX = "ST-BME_sync_";
|
const BME_SYNC_FILE_PREFIX = "ST-BME_sync_";
|
||||||
const BME_SYNC_FILE_SUFFIX = ".json";
|
const BME_SYNC_FILE_SUFFIX = ".json";
|
||||||
const BME_SYNC_FILENAME_MAX_LENGTH = 180;
|
const BME_SYNC_FILENAME_MAX_LENGTH = 180;
|
||||||
@@ -469,6 +471,14 @@ function normalizeRuntimeHistoryMeta(value = {}, fallbackChatId = "") {
|
|||||||
value && typeof value === "object" && !Array.isArray(value)
|
value && typeof value === "object" && !Array.isArray(value)
|
||||||
? toSerializableData(value, {})
|
? toSerializableData(value, {})
|
||||||
: {};
|
: {};
|
||||||
|
const processedMessageHashVersion = Number.isFinite(
|
||||||
|
Number(input.processedMessageHashVersion),
|
||||||
|
)
|
||||||
|
? Math.max(1, Math.floor(Number(input.processedMessageHashVersion)))
|
||||||
|
: PROCESSED_MESSAGE_HASH_VERSION;
|
||||||
|
const processedMessageHashesNeedRefresh =
|
||||||
|
input.processedMessageHashesNeedRefresh === true ||
|
||||||
|
processedMessageHashVersion !== PROCESSED_MESSAGE_HASH_VERSION;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
@@ -476,8 +486,12 @@ function normalizeRuntimeHistoryMeta(value = {}, fallbackChatId = "") {
|
|||||||
lastProcessedAssistantFloor: Number.isFinite(Number(input.lastProcessedAssistantFloor))
|
lastProcessedAssistantFloor: Number.isFinite(Number(input.lastProcessedAssistantFloor))
|
||||||
? Math.floor(Number(input.lastProcessedAssistantFloor))
|
? Math.floor(Number(input.lastProcessedAssistantFloor))
|
||||||
: -1,
|
: -1,
|
||||||
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
||||||
extractionCount: normalizeNonNegativeInteger(input.extractionCount, 0),
|
extractionCount: normalizeNonNegativeInteger(input.extractionCount, 0),
|
||||||
processedMessageHashes: normalizeProcessedMessageHashes(input.processedMessageHashes),
|
processedMessageHashes: processedMessageHashesNeedRefresh
|
||||||
|
? {}
|
||||||
|
: normalizeProcessedMessageHashes(input.processedMessageHashes),
|
||||||
|
processedMessageHashesNeedRefresh,
|
||||||
historyDirtyFrom: normalizeOptionalFloor(input.historyDirtyFrom),
|
historyDirtyFrom: normalizeOptionalFloor(input.historyDirtyFrom),
|
||||||
lastMutationReason:
|
lastMutationReason:
|
||||||
typeof input.lastMutationReason === "string" ? input.lastMutationReason : "",
|
typeof input.lastMutationReason === "string" ? input.lastMutationReason : "",
|
||||||
@@ -557,12 +571,16 @@ function mergeRuntimeHistoryMeta(localMeta = {}, remoteMeta = {}, options = {})
|
|||||||
...remoteHistory,
|
...remoteHistory,
|
||||||
chatId: normalizeChatId(remoteHistory.chatId || localHistory.chatId || options.chatId),
|
chatId: normalizeChatId(remoteHistory.chatId || localHistory.chatId || options.chatId),
|
||||||
lastProcessedAssistantFloor: safeLastProcessedFloor,
|
lastProcessedAssistantFloor: safeLastProcessedFloor,
|
||||||
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
||||||
extractionCount: Math.max(
|
extractionCount: Math.max(
|
||||||
localHistory.extractionCount,
|
localHistory.extractionCount,
|
||||||
remoteHistory.extractionCount,
|
remoteHistory.extractionCount,
|
||||||
fallbackExtractionCount,
|
fallbackExtractionCount,
|
||||||
),
|
),
|
||||||
processedMessageHashes: sortProcessedMessageHashes(mergedHashes),
|
processedMessageHashes: sortProcessedMessageHashes(mergedHashes),
|
||||||
|
processedMessageHashesNeedRefresh:
|
||||||
|
localHistory.processedMessageHashesNeedRefresh === true ||
|
||||||
|
remoteHistory.processedMessageHashesNeedRefresh === true,
|
||||||
historyDirtyFrom,
|
historyDirtyFrom,
|
||||||
lastMutationReason: hasIntegrityConflict
|
lastMutationReason: hasIntegrityConflict
|
||||||
? `sync-merge:processed-hash-conflict@${firstConflictFloor}`
|
? `sync-merge:processed-hash-conflict@${firstConflictFloor}`
|
||||||
|
|||||||
@@ -558,6 +558,15 @@ export function onMessageReceivedController(
|
|||||||
: lastMessage;
|
: lastMessage;
|
||||||
|
|
||||||
if (runtime.isAssistantChatMessage(targetMessage)) {
|
if (runtime.isAssistantChatMessage(targetMessage)) {
|
||||||
|
runtime.console?.debug?.(
|
||||||
|
"[ST-BME] assistant message received, queueing auto extraction",
|
||||||
|
{
|
||||||
|
messageId: Number.isFinite(Number(messageId)) ? Number(messageId) : null,
|
||||||
|
chatLength: Array.isArray(chat) ? chat.length : 0,
|
||||||
|
loadState,
|
||||||
|
dbReady,
|
||||||
|
},
|
||||||
|
);
|
||||||
enqueueMicrotask(() => {
|
enqueueMicrotask(() => {
|
||||||
void runtime.runExtraction().catch((error) => {
|
void runtime.runExtraction().catch((error) => {
|
||||||
runtime.console.error("[ST-BME] 异步自动提取失败:", error);
|
runtime.console.error("[ST-BME] 异步自动提取失败:", error);
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export async function executeExtractionBatchController(
|
|||||||
|
|
||||||
export async function runExtractionController(runtime) {
|
export async function runExtractionController(runtime) {
|
||||||
if (runtime.getIsExtracting()) {
|
if (runtime.getIsExtracting()) {
|
||||||
|
runtime.console?.debug?.("[ST-BME] auto extraction deferred: extraction already in progress");
|
||||||
runtime.deferAutoExtraction?.("extracting");
|
runtime.deferAutoExtraction?.("extracting");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,9 @@ export async function runExtractionController(runtime) {
|
|||||||
const settings = runtime.getSettings();
|
const settings = runtime.getSettings();
|
||||||
if (!settings.enabled) return;
|
if (!settings.enabled) return;
|
||||||
if (!runtime.ensureGraphMutationReady("自动提取", { notify: false })) {
|
if (!runtime.ensureGraphMutationReady("自动提取", { notify: false })) {
|
||||||
|
runtime.console?.debug?.("[ST-BME] auto extraction blocked: graph-not-ready", {
|
||||||
|
loadState: runtime.getGraphPersistenceState?.()?.loadState || "",
|
||||||
|
});
|
||||||
runtime.deferAutoExtraction?.("graph-not-ready");
|
runtime.deferAutoExtraction?.("graph-not-ready");
|
||||||
runtime.setLastExtractionStatus(
|
runtime.setLastExtractionStatus(
|
||||||
"等待图谱加载",
|
"等待图谱加载",
|
||||||
@@ -148,6 +152,9 @@ export async function runExtractionController(runtime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(await runtime.recoverHistoryIfNeeded("auto-extract"))) {
|
if (!(await runtime.recoverHistoryIfNeeded("auto-extract"))) {
|
||||||
|
runtime.console?.debug?.("[ST-BME] auto extraction paused during history recovery", {
|
||||||
|
recovering: runtime.getIsRecoveringHistory?.() === true,
|
||||||
|
});
|
||||||
if (runtime.getIsRecoveringHistory?.()) {
|
if (runtime.getIsRecoveringHistory?.()) {
|
||||||
runtime.deferAutoExtraction?.("history-recovering");
|
runtime.deferAutoExtraction?.("history-recovering");
|
||||||
}
|
}
|
||||||
|
|||||||
47
index.js
47
index.js
@@ -155,6 +155,7 @@ import {
|
|||||||
findJournalRecoveryPoint,
|
findJournalRecoveryPoint,
|
||||||
markHistoryDirty,
|
markHistoryDirty,
|
||||||
normalizeGraphRuntimeState,
|
normalizeGraphRuntimeState,
|
||||||
|
PROCESSED_MESSAGE_HASH_VERSION,
|
||||||
snapshotProcessedMessageHashes,
|
snapshotProcessedMessageHashes,
|
||||||
undoLatestMaintenance,
|
undoLatestMaintenance,
|
||||||
} from "./runtime-state.js";
|
} from "./runtime-state.js";
|
||||||
@@ -3691,6 +3692,13 @@ function deferAutoExtraction(
|
|||||||
`retry:${pendingAutoExtraction.reason || "auto-extraction-deferred"}`,
|
`retry:${pendingAutoExtraction.reason || "auto-extraction-deferred"}`,
|
||||||
);
|
);
|
||||||
}, resolvedDelayMs);
|
}, resolvedDelayMs);
|
||||||
|
console.debug?.("[ST-BME] auto extraction deferred", {
|
||||||
|
reason: pendingAutoExtraction.reason,
|
||||||
|
chatId: normalizedChatId,
|
||||||
|
messageId: pendingAutoExtraction.messageId,
|
||||||
|
attempts: nextAttempts,
|
||||||
|
delayMs: resolvedDelayMs,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scheduled: true,
|
scheduled: true,
|
||||||
@@ -3737,6 +3745,15 @@ function maybeResumePendingAutoExtraction(source = "auto-extraction-resume") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ensureGraphMutationReady("自动提取", { notify: false })) {
|
if (!ensureGraphMutationReady("自动提取", { notify: false })) {
|
||||||
|
console.debug?.(
|
||||||
|
"[ST-BME] pending auto extraction resume blocked: graph-not-ready",
|
||||||
|
{
|
||||||
|
source,
|
||||||
|
chatId: pendingChatId,
|
||||||
|
attempts: pendingAutoExtraction.attempts || 0,
|
||||||
|
loadState: graphPersistenceState.loadState || "",
|
||||||
|
},
|
||||||
|
);
|
||||||
return deferAutoExtraction("graph-not-ready", {
|
return deferAutoExtraction("graph-not-ready", {
|
||||||
chatId: pendingChatId,
|
chatId: pendingChatId,
|
||||||
messageId: pendingAutoExtraction.messageId,
|
messageId: pendingAutoExtraction.messageId,
|
||||||
@@ -3745,6 +3762,12 @@ function maybeResumePendingAutoExtraction(source = "auto-extraction-resume") {
|
|||||||
|
|
||||||
const pendingRequest = { ...pendingAutoExtraction };
|
const pendingRequest = { ...pendingAutoExtraction };
|
||||||
clearPendingAutoExtraction();
|
clearPendingAutoExtraction();
|
||||||
|
console.debug?.("[ST-BME] resuming pending auto extraction", {
|
||||||
|
source,
|
||||||
|
chatId: pendingRequest.chatId,
|
||||||
|
messageId: pendingRequest.messageId,
|
||||||
|
attempts: pendingRequest.attempts || 0,
|
||||||
|
});
|
||||||
const enqueueMicrotask =
|
const enqueueMicrotask =
|
||||||
typeof globalThis.queueMicrotask === "function"
|
typeof globalThis.queueMicrotask === "function"
|
||||||
? globalThis.queueMicrotask.bind(globalThis)
|
? globalThis.queueMicrotask.bind(globalThis)
|
||||||
@@ -4604,8 +4627,11 @@ function updateProcessedHistorySnapshot(chat, lastProcessedAssistantFloor) {
|
|||||||
ensureCurrentGraphRuntimeState();
|
ensureCurrentGraphRuntimeState();
|
||||||
currentGraph.historyState.lastProcessedAssistantFloor =
|
currentGraph.historyState.lastProcessedAssistantFloor =
|
||||||
lastProcessedAssistantFloor;
|
lastProcessedAssistantFloor;
|
||||||
|
currentGraph.historyState.processedMessageHashVersion =
|
||||||
|
PROCESSED_MESSAGE_HASH_VERSION;
|
||||||
currentGraph.historyState.processedMessageHashes =
|
currentGraph.historyState.processedMessageHashes =
|
||||||
snapshotProcessedMessageHashes(chat, lastProcessedAssistantFloor);
|
snapshotProcessedMessageHashes(chat, lastProcessedAssistantFloor);
|
||||||
|
currentGraph.historyState.processedMessageHashesNeedRefresh = false;
|
||||||
currentGraph.lastProcessedSeq = lastProcessedAssistantFloor;
|
currentGraph.lastProcessedSeq = lastProcessedAssistantFloor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7078,6 +7104,27 @@ function inspectHistoryMutation(
|
|||||||
ensureCurrentGraphRuntimeState();
|
ensureCurrentGraphRuntimeState();
|
||||||
const context = getContext();
|
const context = getContext();
|
||||||
const chat = context?.chat;
|
const chat = context?.chat;
|
||||||
|
if (
|
||||||
|
Array.isArray(chat) &&
|
||||||
|
currentGraph.historyState?.processedMessageHashesNeedRefresh === true
|
||||||
|
) {
|
||||||
|
updateProcessedHistorySnapshot(
|
||||||
|
chat,
|
||||||
|
currentGraph.historyState.lastProcessedAssistantFloor ?? -1,
|
||||||
|
);
|
||||||
|
console.debug?.(
|
||||||
|
"[ST-BME] refreshed processed message hashes after hash-version migration",
|
||||||
|
{
|
||||||
|
trigger,
|
||||||
|
lastProcessedAssistantFloor:
|
||||||
|
currentGraph.historyState.lastProcessedAssistantFloor ?? -1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (isGraphMetadataWriteAllowed()) {
|
||||||
|
saveGraphToChat({ reason: "processed-hash-version-migrated" });
|
||||||
|
}
|
||||||
|
return { dirty: false, earliestAffectedFloor: null, reason: "" };
|
||||||
|
}
|
||||||
const metaDetection = resolveDirtyFloorFromMutationMeta(
|
const metaDetection = resolveDirtyFloorFromMutationMeta(
|
||||||
trigger,
|
trigger,
|
||||||
primaryArg,
|
primaryArg,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
const BATCH_JOURNAL_LIMIT = 96;
|
const BATCH_JOURNAL_LIMIT = 96;
|
||||||
const MAINTENANCE_JOURNAL_LIMIT = 20;
|
const MAINTENANCE_JOURNAL_LIMIT = 20;
|
||||||
export const BATCH_JOURNAL_VERSION = 2;
|
export const BATCH_JOURNAL_VERSION = 2;
|
||||||
|
export const PROCESSED_MESSAGE_HASH_VERSION = 2;
|
||||||
|
|
||||||
export function buildVectorCollectionId(chatId) {
|
export function buildVectorCollectionId(chatId) {
|
||||||
return `st-bme::${chatId || "unknown-chat"}`;
|
return `st-bme::${chatId || "unknown-chat"}`;
|
||||||
@@ -12,7 +13,9 @@ export function createDefaultHistoryState(chatId = "") {
|
|||||||
return {
|
return {
|
||||||
chatId,
|
chatId,
|
||||||
lastProcessedAssistantFloor: -1,
|
lastProcessedAssistantFloor: -1,
|
||||||
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
||||||
processedMessageHashes: {},
|
processedMessageHashes: {},
|
||||||
|
processedMessageHashesNeedRefresh: false,
|
||||||
historyDirtyFrom: null,
|
historyDirtyFrom: null,
|
||||||
lastMutationReason: "",
|
lastMutationReason: "",
|
||||||
lastMutationSource: "",
|
lastMutationSource: "",
|
||||||
@@ -104,6 +107,22 @@ export function normalizeGraphRuntimeState(graph, chatId = "") {
|
|||||||
) {
|
) {
|
||||||
historyState.processedMessageHashes = {};
|
historyState.processedMessageHashes = {};
|
||||||
}
|
}
|
||||||
|
if (!Number.isFinite(historyState.processedMessageHashVersion)) {
|
||||||
|
historyState.processedMessageHashVersion = 1;
|
||||||
|
}
|
||||||
|
historyState.processedMessageHashVersion = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(historyState.processedMessageHashVersion),
|
||||||
|
);
|
||||||
|
historyState.processedMessageHashesNeedRefresh =
|
||||||
|
historyState.processedMessageHashesNeedRefresh === true;
|
||||||
|
if (
|
||||||
|
historyState.processedMessageHashVersion !== PROCESSED_MESSAGE_HASH_VERSION
|
||||||
|
) {
|
||||||
|
historyState.processedMessageHashes = {};
|
||||||
|
historyState.processedMessageHashVersion = PROCESSED_MESSAGE_HASH_VERSION;
|
||||||
|
historyState.processedMessageHashesNeedRefresh = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!vectorIndexState.hashToNodeId ||
|
!vectorIndexState.hashToNodeId ||
|
||||||
@@ -208,15 +227,9 @@ export function stableHashString(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildMessageHash(message) {
|
export function buildMessageHash(message) {
|
||||||
const managedHideMarker = Boolean(
|
|
||||||
message?.extra &&
|
|
||||||
typeof message.extra === "object" &&
|
|
||||||
message.extra.__st_bme_hide_managed === true,
|
|
||||||
);
|
|
||||||
const swipeId = Number.isFinite(message?.swipe_id) ? message.swipe_id : null;
|
const swipeId = Number.isFinite(message?.swipe_id) ? message.swipe_id : null;
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
isUser: Boolean(message?.is_user),
|
isUser: Boolean(message?.is_user),
|
||||||
isSystem: managedHideMarker ? false : Boolean(message?.is_system),
|
|
||||||
text: String(message?.mes || ""),
|
text: String(message?.mes || ""),
|
||||||
swipeId,
|
swipeId,
|
||||||
});
|
});
|
||||||
@@ -242,6 +255,13 @@ export function snapshotProcessedMessageHashes(
|
|||||||
export function detectHistoryMutation(chat, historyState) {
|
export function detectHistoryMutation(chat, historyState) {
|
||||||
const lastProcessedAssistantFloor =
|
const lastProcessedAssistantFloor =
|
||||||
historyState?.lastProcessedAssistantFloor ?? -1;
|
historyState?.lastProcessedAssistantFloor ?? -1;
|
||||||
|
const processedMessageHashVersion = Number.isFinite(
|
||||||
|
historyState?.processedMessageHashVersion,
|
||||||
|
)
|
||||||
|
? Math.max(1, Math.floor(historyState.processedMessageHashVersion))
|
||||||
|
: 1;
|
||||||
|
const processedMessageHashesNeedRefresh =
|
||||||
|
historyState?.processedMessageHashesNeedRefresh === true;
|
||||||
|
|
||||||
const processedMessageHashes =
|
const processedMessageHashes =
|
||||||
historyState?.processedMessageHashes &&
|
historyState?.processedMessageHashes &&
|
||||||
@@ -253,6 +273,12 @@ export function detectHistoryMutation(chat, historyState) {
|
|||||||
if (!Array.isArray(chat) || lastProcessedAssistantFloor < 0) {
|
if (!Array.isArray(chat) || lastProcessedAssistantFloor < 0) {
|
||||||
return { dirty: false, earliestAffectedFloor: null, reason: "" };
|
return { dirty: false, earliestAffectedFloor: null, reason: "" };
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
processedMessageHashesNeedRefresh ||
|
||||||
|
processedMessageHashVersion !== PROCESSED_MESSAGE_HASH_VERSION
|
||||||
|
) {
|
||||||
|
return { dirty: false, earliestAffectedFloor: null, reason: "" };
|
||||||
|
}
|
||||||
|
|
||||||
if (lastProcessedAssistantFloor >= chat.length) {
|
if (lastProcessedAssistantFloor >= chat.length) {
|
||||||
return {
|
return {
|
||||||
@@ -339,7 +365,10 @@ export function clearHistoryDirty(graph, result = null) {
|
|||||||
graph.historyState.historyDirtyFrom = null;
|
graph.historyState.historyDirtyFrom = null;
|
||||||
graph.historyState.lastMutationReason = "";
|
graph.historyState.lastMutationReason = "";
|
||||||
graph.historyState.lastMutationSource = "";
|
graph.historyState.lastMutationSource = "";
|
||||||
|
graph.historyState.processedMessageHashVersion =
|
||||||
|
PROCESSED_MESSAGE_HASH_VERSION;
|
||||||
graph.historyState.processedMessageHashes = {};
|
graph.historyState.processedMessageHashes = {};
|
||||||
|
graph.historyState.processedMessageHashesNeedRefresh = false;
|
||||||
if (result) {
|
if (result) {
|
||||||
graph.historyState.lastRecoveryResult = result;
|
graph.historyState.lastRecoveryResult = result;
|
||||||
}
|
}
|
||||||
@@ -478,9 +507,19 @@ function buildJournalStateBefore(snapshotBefore, meta = {}) {
|
|||||||
snapshotBefore?.historyState?.lastProcessedAssistantFloor ??
|
snapshotBefore?.historyState?.lastProcessedAssistantFloor ??
|
||||||
snapshotBefore?.lastProcessedSeq ??
|
snapshotBefore?.lastProcessedSeq ??
|
||||||
-1,
|
-1,
|
||||||
|
processedMessageHashVersion: Number.isFinite(
|
||||||
|
snapshotBefore?.historyState?.processedMessageHashVersion,
|
||||||
|
)
|
||||||
|
? Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(snapshotBefore.historyState.processedMessageHashVersion),
|
||||||
|
)
|
||||||
|
: PROCESSED_MESSAGE_HASH_VERSION,
|
||||||
processedMessageHashes: clonePlain(
|
processedMessageHashes: clonePlain(
|
||||||
snapshotBefore?.historyState?.processedMessageHashes || {},
|
snapshotBefore?.historyState?.processedMessageHashes || {},
|
||||||
),
|
),
|
||||||
|
processedMessageHashesNeedRefresh:
|
||||||
|
snapshotBefore?.historyState?.processedMessageHashesNeedRefresh === true,
|
||||||
historyDirtyFrom: Number.isFinite(
|
historyDirtyFrom: Number.isFinite(
|
||||||
snapshotBefore?.historyState?.historyDirtyFrom,
|
snapshotBefore?.historyState?.historyDirtyFrom,
|
||||||
)
|
)
|
||||||
@@ -805,9 +844,16 @@ function applyJournalStateBefore(graph, stateBefore = {}) {
|
|||||||
)
|
)
|
||||||
? stateBefore.lastProcessedAssistantFloor
|
? stateBefore.lastProcessedAssistantFloor
|
||||||
: historyState.lastProcessedAssistantFloor;
|
: historyState.lastProcessedAssistantFloor;
|
||||||
|
historyState.processedMessageHashVersion = Number.isFinite(
|
||||||
|
stateBefore.processedMessageHashVersion,
|
||||||
|
)
|
||||||
|
? Math.max(1, Math.floor(stateBefore.processedMessageHashVersion))
|
||||||
|
: historyState.processedMessageHashVersion;
|
||||||
historyState.processedMessageHashes = clonePlain(
|
historyState.processedMessageHashes = clonePlain(
|
||||||
stateBefore.processedMessageHashes || {},
|
stateBefore.processedMessageHashes || {},
|
||||||
);
|
);
|
||||||
|
historyState.processedMessageHashesNeedRefresh =
|
||||||
|
stateBefore.processedMessageHashesNeedRefresh === true;
|
||||||
historyState.historyDirtyFrom = Number.isFinite(stateBefore.historyDirtyFrom)
|
historyState.historyDirtyFrom = Number.isFinite(stateBefore.historyDirtyFrom)
|
||||||
? stateBefore.historyDirtyFrom
|
? stateBefore.historyDirtyFrom
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
createBatchJournalEntry,
|
createBatchJournalEntry,
|
||||||
detectHistoryMutation,
|
detectHistoryMutation,
|
||||||
findJournalRecoveryPoint,
|
findJournalRecoveryPoint,
|
||||||
|
normalizeGraphRuntimeState,
|
||||||
|
PROCESSED_MESSAGE_HASH_VERSION,
|
||||||
rollbackBatch,
|
rollbackBatch,
|
||||||
snapshotProcessedMessageHashes,
|
snapshotProcessedMessageHashes,
|
||||||
} from "../runtime-state.js";
|
} from "../runtime-state.js";
|
||||||
@@ -66,8 +68,25 @@ const realSystemFlipDetection = detectHistoryMutation(realSystemFlipChat, {
|
|||||||
lastProcessedAssistantFloor: 3,
|
lastProcessedAssistantFloor: 3,
|
||||||
processedMessageHashes: hashes,
|
processedMessageHashes: hashes,
|
||||||
});
|
});
|
||||||
assert.equal(realSystemFlipDetection.dirty, true);
|
assert.equal(realSystemFlipDetection.dirty, false);
|
||||||
assert.equal(realSystemFlipDetection.earliestAffectedFloor, 1);
|
|
||||||
|
const migratedGraph = normalizeGraphRuntimeState({
|
||||||
|
historyState: {
|
||||||
|
chatId: "chat-history-test",
|
||||||
|
lastProcessedAssistantFloor: 3,
|
||||||
|
processedMessageHashVersion: 1,
|
||||||
|
processedMessageHashes: hashes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
migratedGraph.historyState.processedMessageHashVersion,
|
||||||
|
PROCESSED_MESSAGE_HASH_VERSION,
|
||||||
|
);
|
||||||
|
assert.deepEqual(migratedGraph.historyState.processedMessageHashes, {});
|
||||||
|
assert.equal(migratedGraph.historyState.processedMessageHashesNeedRefresh, true);
|
||||||
|
|
||||||
|
const migratedDetection = detectHistoryMutation(chat, migratedGraph.historyState);
|
||||||
|
assert.equal(migratedDetection.dirty, false);
|
||||||
|
|
||||||
const truncatedChat = chat.slice(0, 2);
|
const truncatedChat = chat.slice(0, 2);
|
||||||
const truncatedDetection = detectHistoryMutation(truncatedChat, {
|
const truncatedDetection = detectHistoryMutation(truncatedChat, {
|
||||||
|
|||||||
Reference in New Issue
Block a user