From 433d274a260b2afdffca8833916984485c3a29c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 06:49:35 +0000 Subject: [PATCH 1/2] chore: bump manifest version [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 2fff695..f34fb62 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "7.1.5", + "version": "7.1.6", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } From ea1e515694dbcd5fde1b176b355639132a5a495f Mon Sep 17 00:00:00 2001 From: youzini Date: Mon, 1 Jun 2026 06:56:40 +0000 Subject: [PATCH 2/2] test(recall): lock inject-decoupling reroll invariant Add controller-level coverage for no-new-user reroll behavior: AFTER_COMMANDS defers recall, BEFORE_COMBINE deterministically reapplies stored per-floor recall, and misses fall back to legacy compute. Update docs to describe compute/injection decoupling and recall cards as the editable per-floor bme_recall source. --- docs/algorithms/retrieval.md | 7 +- docs/architecture/control-plane.md | 11 +- docs/features/recall-cards.md | 10 +- package.json | 1 + tests/recall-inject-decoupling.mjs | 209 +++++++++++++++++++++++++++++ 5 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 tests/recall-inject-decoupling.mjs diff --git a/docs/algorithms/retrieval.md b/docs/algorithms/retrieval.md index 7db0d03..2a85f12 100644 --- a/docs/algorithms/retrieval.md +++ b/docs/algorithms/retrieval.md @@ -28,7 +28,12 @@ 召回输入按优先级解析(`resolveRecallInputController`):override → 待发送意图(send intent)→ 聊天尾部用户楼层 → 已发送用户 → 最新用户楼层。 -**持久召回复用**(`resolveReusablePersistedRecallRecord`):如果当前输入匹配某条已持久化的用户楼层召回记录,可直接复用已存的注入内容,**跳过全部新检索**,返回 `llm.status="persisted"`。reroll / regenerate / continue 场景由宿主 `type` 判定为 no-new-user 生成后,会绑定父 user 楼层的持久召回,而不是根据 textarea / send-intent 等输入源猜测(见 [`../architecture/control-plane.md`](../architecture/control-plane.md) 的 reroll 不变量)。 +**持久召回复用有两条路径:** + +1. **no-new-user 主路径**(`reapplyPersistedRecallBlock`):reroll / swipe / regenerate / continue 由宿主 `type` 判定为 no-new-user 后,`GENERATION_AFTER_COMMANDS` 不计算召回;`GENERATE_BEFORE_COMBINE_PROMPTS` 直接读取父 user 楼层的 `message.extra.bme_recall`,校验绑定文本未过期后确定性重放注入块。命中后不会进入 transaction / `runRecall` / 新检索。 +2. **compute fallback 内部复用**(`resolveReusablePersistedRecallRecord`):当主路径没有可用记录(例如无记录或陈旧)而落回 `runRecallController()` 时,如果当前输入匹配某条已持久化的用户楼层召回记录,可在控制器内复用已存注入内容,跳过新检索,返回 `llm.status="persisted"`。 + +fresh `normal` 发送仍走正常输入选择与召回计算路径;no-new-user 的父楼层绑定来自宿主生成上下文,而不是根据 textarea / send-intent 等输入源猜测(见 [`../architecture/control-plane.md`](../architecture/control-plane.md) 的 reroll 不变量)。 ## 5. 向量预筛 diff --git a/docs/architecture/control-plane.md b/docs/architecture/control-plane.md index 48ffc22..715e05f 100644 --- a/docs/architecture/control-plane.md +++ b/docs/architecture/control-plane.md @@ -83,11 +83,18 @@ **reroll 不变量:** -> reroll 助手楼层时,若上方用户楼层未变且存在可复用的持久召回记录,则跳过预召回历史恢复和新向量检索;但被 reroll 的助手楼层的**图谱回滚必须保留**(走既有 `onReroll` 路径)。 +> reroll 助手楼层时,若上方用户楼层未变且存在可复用的持久召回记录,则复用父 user 楼层 `message.extra.bme_recall` 中的注入块;但被 reroll 的助手楼层的**图谱回滚必须保留**(走既有 `onReroll` 路径)。 换句话说:召回注入可以复用,但图谱状态该回滚还得回滚。两者不能混为一谈——这是"reroll 乱召回"修复的核心。 -设计纪律:**信任宿主生成类型,不用输入源猜 reroll**。`GENERATION_STARTED` / `GENERATION_AFTER_COMMANDS` 传入的 `type` 是权威信号:`swipe`、`regenerate`、`continue` 属于 no-new-user 生成,优先绑定上方可见 user 楼层的持久召回;`normal` 才代表新输入,需要 fresh recall。`MESSAGE_DELETED` 在 regenerate 代际中只作为预期删除处理,不会擦掉本轮召回事务。 +设计纪律:**计算与注入解耦,信任宿主生成类型,不用输入源猜 reroll**。`GENERATION_STARTED` / `GENERATION_AFTER_COMMANDS` 传入的 `type` 是权威信号:`swipe`、`regenerate`、`continue` 属于 no-new-user 生成,优先绑定上方可见 user 楼层的持久召回;`normal` 才代表新输入,需要 fresh recall。`MESSAGE_DELETED` 在 regenerate 代际中只作为预期删除处理,不会擦掉本轮召回事务。 + +no-new-user 的稳定路径分两段: + +1. `GENERATION_AFTER_COMMANDS` 不做召回计算,直接跳过并把工作推迟到 before-combine。 +2. `GENERATE_BEFORE_COMBINE_PROMPTS` 先调用 `reapplyPersistedRecallBlock`,从父 user 楼层的 `message.extra.bme_recall` 确定性重放召回块;命中后立即返回,不进入 transaction / `runRecall`。若没有记录或记录已陈旧,再落回既有 transaction + compute 兼容路径。 + +旧的召回事务机制仍保留为 fresh normal 和 fallback compute 的基础设施;它不再是 reroll 已存召回注入的唯一门闸。 ## 副本一致性模型 diff --git a/docs/features/recall-cards.md b/docs/features/recall-cards.md index c6f6140..8935959 100644 --- a/docs/features/recall-cards.md +++ b/docs/features/recall-cards.md @@ -6,14 +6,20 @@ ## 做什么 -每次生成前召回发生时,召回结果(选中的节点、注入文本、输入指纹)被持久化并绑定到对应的用户楼层。卡片把这个记录渲染在消息旁,用户可以看到、展开。 +每次生成前召回发生时,召回结果(选中的节点、注入文本、输入指纹)被持久化并绑定到对应的用户楼层,存放在该消息的 `message.extra.bme_recall`。卡片把这个记录渲染在消息旁,用户可以看到、展开、编辑。 ## 为什么持久化 两个目的: 1. **透明度**:用户能看到记忆系统在每轮"想起了什么"。 -2. **reroll 复用**:reroll 助手楼层时,如果上方用户楼层没变,已持久化的召回记录可以直接复用,跳过新检索。详见 [`../architecture/control-plane.md`](../architecture/control-plane.md) 的 reroll 不变量和 [`../algorithms/retrieval.md`](../algorithms/retrieval.md) 的持久复用。 +2. **reroll 复用**:reroll 助手楼层时,如果上方用户楼层没变,before-combine 会把父 user 楼层 `message.extra.bme_recall` 中的召回块确定性重放,跳过新检索。详见 [`../architecture/control-plane.md`](../architecture/control-plane.md) 的 reroll 不变量和 [`../algorithms/retrieval.md`](../algorithms/retrieval.md) 的持久复用。 + +## 存储边界 + +召回卡片是**每个用户楼层可编辑的召回存储**,不是永久世界书条目。reroll 复用读取的是 `message.extra.bme_recall` 里的独立召回块,不会把内容写成长期 worldbook,也不会破坏性覆盖用户原文。 + +用户输入文本和召回注入块始终是两件事:用户楼层 `mes` 保留原始输入;召回内容作为单独 block 注入提示词。用户编辑召回卡片只改变该楼层的召回 artifact,不等同于改写用户说过的话。 ## 控制器封装 diff --git a/package.json b/package.json index 90c99f2..d234a04 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version:bump-manifest": "node scripts/bump-manifest-version.mjs", "build:native:wasm": "node scripts/build-native-wasm.mjs", "test:p0": "node tests/p0-regressions.mjs", + "test:recall-inject-decoupling": "node tests/recall-inject-decoupling.mjs", "test:recall-reapply-block": "node tests/recall-reapply-block.mjs", "test:triviumdb-poc": "node tests/triviumdb-poc.mjs", "test:runtime-history": "node tests/runtime-history.mjs", diff --git a/tests/recall-inject-decoupling.mjs b/tests/recall-inject-decoupling.mjs new file mode 100644 index 0000000..69b25da --- /dev/null +++ b/tests/recall-inject-decoupling.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { + onBeforeCombinePromptsController, + onGenerationAfterCommandsController, +} from "../host/event-binding.js"; + +function createRuntime(overrides = {}) { + const calls = { + applyFinalRecallInjectionForGeneration: 0, + buildGenerationAfterCommandsRecallInput: 0, + buildHistoryGenerationRecallInput: 0, + buildNormalGenerationRecallInput: 0, + createGenerationRecallContext: 0, + reapplyPersistedRecallBlock: 0, + runRecall: 0, + }; + const runtime = { + calls, + applyFinalRecallInjectionForGeneration: () => { + calls.applyFinalRecallInjectionForGeneration += 1; + return { source: "default-final" }; + }, + buildGenerationAfterCommandsRecallInput: () => { + calls.buildGenerationAfterCommandsRecallInput += 1; + return { overrideUserMessage: "user floor" }; + }, + buildHistoryGenerationRecallInput: () => { + calls.buildHistoryGenerationRecallInput += 1; + return null; + }, + buildNormalGenerationRecallInput: () => { + calls.buildNormalGenerationRecallInput += 1; + return { userMessage: "fresh normal" }; + }, + clearLiveRecallInjectionPromptForRewrite: () => {}, + clearPendingHostGenerationInputSnapshot: () => {}, + clearPendingRecallSendIntent: () => {}, + consumeDryRunPromptPreview: () => false, + consumeHostGenerationInputSnapshot: () => null, + createGenerationRecallContext: () => { + calls.createGenerationRecallContext += 1; + return { + shouldRun: true, + transaction: { id: "tx-default" }, + recallOptions: { userMessage: "default recall" }, + generationType: "normal", + hookName: "GENERATE_BEFORE_COMBINE_PROMPTS", + recallKey: "recall-key-default", + }; + }, + getContext: () => ({ + chat: [{ is_user: true, mes: "fresh normal" }], + chatId: "chat-inject-decoupling", + }), + getCurrentChatId: () => "chat-inject-decoupling", + getGenerationContext: () => null, + getGenerationRecallHookStateFromResult: () => "completed", + getGenerationRecallTransactionResult: () => null, + getPendingHostGenerationInputSnapshot: () => null, + isMvuExtraAnalysisGuardActive: () => false, + isTavernHelperPromptViewerRefreshActive: () => false, + markCurrentGenerationTrivialSkip: () => {}, + markGenerationRecallTransactionHookState: () => {}, + reapplyPersistedRecallBlock: () => { + calls.reapplyPersistedRecallBlock += 1; + return { applied: false, reason: "default-miss" }; + }, + resolveGenerationRecallDeliveryMode: () => "deferred", + runRecall: async () => { + calls.runRecall += 1; + return { + status: "completed", + didRecall: true, + injectionText: "fresh injection", + }; + }, + storeGenerationRecallTransactionResult: () => {}, + ...overrides, + }; + return runtime; +} + +{ + const runtime = createRuntime({ + getGenerationContext: () => ({ kind: "no-new-user", type: "regenerate" }), + }); + + const result = await onGenerationAfterCommandsController( + runtime, + "regenerate", + {}, + false, + ); + + assert.deepEqual(result, { + skipped: true, + reason: "no-new-user-deferred-to-before-combine", + }); + assert.equal(runtime.calls.createGenerationRecallContext, 0); + assert.equal(runtime.calls.runRecall, 0); + assert.equal(runtime.calls.applyFinalRecallInjectionForGeneration, 0); +} + +{ + const reapplied = { + applied: true, + source: "persisted", + reason: "deterministic-reapply", + }; + const runtime = createRuntime({ + getGenerationContext: () => ({ kind: "no-new-user", type: "regenerate" }), + reapplyPersistedRecallBlock: () => { + runtime.calls.reapplyPersistedRecallBlock += 1; + return reapplied; + }, + }); + + const result = await onBeforeCombinePromptsController(runtime, { + combinedPrompt: "prompt", + }); + + assert.equal(result, reapplied); + assert.equal(runtime.calls.reapplyPersistedRecallBlock, 1); + assert.equal(runtime.calls.createGenerationRecallContext, 0); + assert.equal(runtime.calls.runRecall, 0); +} + +{ + const finalSentinel = { source: "fallback-final", applied: true }; + const transaction = { id: "tx-fallback" }; + const runtime = createRuntime({ + applyFinalRecallInjectionForGeneration: (payload) => { + runtime.calls.applyFinalRecallInjectionForGeneration += 1; + assert.equal(payload.transaction, transaction); + assert.equal(payload.hookName, "GENERATE_BEFORE_COMBINE_PROMPTS"); + return finalSentinel; + }, + createGenerationRecallContext: () => { + runtime.calls.createGenerationRecallContext += 1; + return { + shouldRun: true, + transaction, + recallOptions: { userMessage: "fallback user" }, + generationType: "regenerate", + hookName: "GENERATE_BEFORE_COMBINE_PROMPTS", + recallKey: "recall-key-fallback", + }; + }, + getGenerationContext: () => ({ kind: "no-new-user", type: "regenerate" }), + reapplyPersistedRecallBlock: () => { + runtime.calls.reapplyPersistedRecallBlock += 1; + return { applied: false, reason: "no-record" }; + }, + runRecall: async (options) => { + runtime.calls.runRecall += 1; + assert.equal(options.hookName, "GENERATE_BEFORE_COMBINE_PROMPTS"); + return { status: "completed", didRecall: true, injectionText: "computed" }; + }, + }); + + const result = await onBeforeCombinePromptsController(runtime, { + combinedPrompt: "prompt", + }); + + assert.equal(result, finalSentinel); + assert.equal(runtime.calls.reapplyPersistedRecallBlock, 1); + assert.equal(runtime.calls.createGenerationRecallContext, 1); + assert.equal(runtime.calls.runRecall, 1); + assert.equal(runtime.calls.applyFinalRecallInjectionForGeneration, 1); +} + +{ + const normalSentinel = { source: "normal-final", applied: true }; + const runtime = createRuntime({ + applyFinalRecallInjectionForGeneration: () => { + runtime.calls.applyFinalRecallInjectionForGeneration += 1; + return normalSentinel; + }, + createGenerationRecallContext: () => { + runtime.calls.createGenerationRecallContext += 1; + return { + shouldRun: true, + transaction: { id: "tx-normal" }, + recallOptions: { userMessage: "fresh normal" }, + generationType: "normal", + hookName: "GENERATE_BEFORE_COMBINE_PROMPTS", + recallKey: "recall-key-normal", + }; + }, + getGenerationContext: () => ({ kind: "fresh", type: "normal" }), + reapplyPersistedRecallBlock: () => { + runtime.calls.reapplyPersistedRecallBlock += 1; + return { applied: true, source: "should-not-run" }; + }, + }); + + const result = await onBeforeCombinePromptsController(runtime, { + combinedPrompt: "prompt", + }); + + assert.equal(result, normalSentinel); + assert.equal(runtime.calls.reapplyPersistedRecallBlock, 0); + assert.equal(runtime.calls.buildNormalGenerationRecallInput, 1); + assert.equal(runtime.calls.createGenerationRecallContext, 1); + assert.equal(runtime.calls.runRecall, 1); + assert.equal(runtime.calls.applyFinalRecallInjectionForGeneration, 1); +} + +console.log("recall-inject-decoupling tests passed");