Merge branch 'dev'

This commit is contained in:
youzini
2026-06-01 06:56:47 +00:00
5 changed files with 233 additions and 5 deletions

View File

@@ -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. 向量预筛

View File

@@ -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 已存召回注入的唯一门闸。
## 副本一致性模型

View File

@@ -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不等同于改写用户说过的话。
## 控制器封装

View File

@@ -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",

View File

@@ -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");