Merge branch 'dev'

# Conflicts:
#	manifest.json
This commit is contained in:
youzini
2026-06-09 17:10:56 +00:00
14 changed files with 836 additions and 121 deletions

View File

@@ -28,11 +28,15 @@
召回输入按优先级解析(`resolveRecallInputController`override → 待发送意图send intent→ 聊天尾部用户楼层 → 已发送用户 → 最新用户楼层。
控制器里的来源/类型判定保持为小型纯 helperactive input source、no-new-user generation type、可信 user-floor source、持久复用输入构造分别独立测试。它们只做字符串规范化和布尔判定不调用 `retrieve()`、不写消息、也不触碰生成事务。
**持久召回复用有两条路径:**
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"`
内部复用命中后,控制器只重写本次 effective recall input 的来源为 `persisted-user-floor`,并保留原 delivery mode / hook / source candidates 等上下文字段真正注入、generation count bump、metadata save 仍由原路径执行。
fresh `normal` 发送仍走正常输入选择与召回计算路径no-new-user 的父楼层绑定来自宿主生成上下文,而不是根据 textarea / send-intent 等输入源猜测(见 [`../architecture/control-plane.md`](../architecture/control-plane.md) 的 reroll 不变量)。
## 5. 向量预筛

View File

@@ -79,7 +79,8 @@
- `vector/vector-gate.js` — 向量准备/修复前置门禁,决定 skip / repair / blocked / sync。
- `runtime/generation-context.js` — 记录宿主本轮生成的 `type``normal` / `swipe` / `regenerate` / `continue` 等),并解析本轮应绑定的父 user 楼层。
- `runtime/reroll-recall-input.js` — 基于代际上下文构造召回输入;不再用一次性 marker 猜测 reroll。
- `runtime/reroll-recall-input.js` — 基于代际上下文构造召回输入,并保存 planner recall handoff / plot record handoff;不再用一次性 marker 猜测 reroll。
- `retrieval/recall-controller.js` — 召回控制器;来源/类型/持久复用输入构造是纯 helper检索执行和注入副作用仍留在控制器热路径里。
**reroll 不变量:**
@@ -96,6 +97,8 @@ no-new-user 的稳定路径分两段:
旧的召回事务机制仍保留为 fresh normal 和 fallback compute 的基础设施;它不再是 reroll 已存召回注入的唯一门闸。
ENA Planner 另有一条 plot record handoff它只负责把 planner 产出的剧情推进记录绑定到新 user 楼层的 `message.extra.st_bme_plot`,不参与召回决策。这样剧情历史持久化不依赖 planner recall 是否成功。
## 副本一致性模型
Authority 场景下有三处存储,它们**不是平级的版本副本**

View File

@@ -9,7 +9,7 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs``npm run test:stable`
| 控制平面 | `identity-resolver` / `persistence-reducer` | 身份解析、持久化状态机不变量 |
| 数据格式 | `graph-snapshot-schema` / `graph-snapshot-upgrade` / `snapshot-forward-compat` | 快照契约、宽容解析、向前兼容往返 |
| 持久化 | `graph-persistence` / `indexeddb-*` | 图谱持久化、IndexedDB 快照/增量/hydrate |
| 检索/召回 | `p0-regressions` 内相关、`trivial-user-input` | 召回、reroll 复用、注入 |
| 检索/召回 | `p0-regressions` 内相关、`recall-controller-helpers``recall-reroll-reuse``trivial-user-input` | 召回来源判定、reroll 复用、注入 |
| 向量 | `vector-gate` / `vector-connection-probe` / `vector-sync-coalescer` | 向量门禁、连接探测、后台同步合并 |
| Native | `native-layout-parity` / `native-rollout-matrix` | native/JS 一致性、灰度门控 |
| 防线 | `index-slicing-ratchet` / `runtime-deps-completeness` / `i18n-user-visible-ratchet` | 见下 |
@@ -53,6 +53,7 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs``npm run test:stable`
## 重要测试文件
- **`tests/p0-regressions.mjs`** — 主回归集合覆盖提取、召回、恢复、UI 关键路径。
- **`tests/recall-controller-helpers.mjs`** — 召回控制器的纯来源/类型/持久复用输入 helper。
- **`tests/runtime-history.mjs`** — 消息 hash、历史 dirty、恢复状态。
- **`tests/message-render-limit.mjs`** — 聊天区渲染限制和渲染切片历史保护。
- **`tests/graph-persistence.mjs`** — 图谱持久化基础行为。

View File

@@ -20,7 +20,7 @@ ENA Planner 是一个**可选的、发送前剧情规划**子系统。它独立
```
拦截发送(点击发送/回车)
→ 构建规划消息buildPlannerMessages
→ 收集上下文:角色卡 + BME 记忆召回 + 近期 AI 对话 + 历史 <plot> + 世界书 + 用户输入
→ 收集上下文:角色卡 + BME 记忆召回 + 近期 AI 对话 + 结构化/旧式 plot 历史 + 世界书 + 用户输入
→ 渲染模板/宏EJS、ST 宏)
→ 组装提示词块(优先用 planner 任务预设,回退遗留块)
→ 调用规划师 LLMcallPlanner可流式
@@ -34,10 +34,11 @@ ENA Planner 是一个**可选的、发送前剧情规划**子系统。它独立
## 与 ST-BME 的集成
ENA Planner 集成的是**召回**,不是提取:
ENA Planner 集成的是**召回**和**剧情历史记录**,不是提取:
- 它调用 BME 召回获取记忆块作为规划上下文(`runPlannerRecallForEna`)。
- 规划输出注入用户文本后,主生成会把规划标签当作用户消息的一部分看到。
- 规划输出会以结构化记录写入用户楼层 `message.extra.st_bme_plot`;后续规划优先读取这个记录,读不到时再回退扫描历史文本中的 `<plot>`
- 它**不**直接运行提取,也**不**把规划结果写进记忆图谱。后续提取走正常聊天/提取路径。
### 召回交接handoff
@@ -48,6 +49,32 @@ ENA Planner 集成的是**召回**,不是提取:
这套机制的实现见 `runtime/planner-recall-controller.js``runtime/reroll-recall-input.js``runtime/generation-recall-transactions.js`
### 结构化剧情历史
历史 plot 不再只依赖“从聊天文本里扫描 `<plot>`”。Planner 发送时会准备一条独立的 plot record handoff`MESSAGE_SENT` 绑定到新 user 楼层后写入:
```js
message.extra.st_bme_plot = {
version: 1,
rawUserInput,
plannerAugmentedMessage,
plotText,
plotBlocks,
inputHash,
createdAt,
recallHandoffId,
taskResults: []
}
```
读取顺序:
1. 优先读取 `message.extra.st_bme_plot` 中的结构化 `<plot>` 块。
2. 若结构化记录不足 `plotCount`,用历史消息文本里的旧式 `<plot>` 补足。
3. 只把 `<plot>` 内容喂回 planner`<note>` / `<state>` 等标签不会因为结构化记录而混入历史 plot 区。
plot record handoff 和 recall handoff 是两条独立通道:即使 planner 召回失败或被禁用,只要 planner 产出了 `<plot>`,剧情历史仍可持久化。这避免了“剧情推进记录依赖召回成功”的隐式耦合。
## 规划召回 vs 正常召回
| 维度 | 规划召回 | 正常召回 |

View File

@@ -0,0 +1,102 @@
export function extractLastNPlots(chat, n) {
if (!Array.isArray(chat) || chat.length === 0) return [];
const want = Math.max(0, Number(n) || 0);
if (!want) return [];
const plots = [];
const plotRe = /<plot\b[^>]*>[\s\S]*?<\/plot>/gi;
for (let i = chat.length - 1; i >= 0; i--) {
const text = chat[i]?.mes ?? '';
if (!text) continue;
const matches = [...text.matchAll(plotRe)];
for (let j = matches.length - 1; j >= 0; j--) {
plots.push(matches[j][0]);
if (plots.length >= want) return plots;
}
}
return plots;
}
export function formatPlotsBlock(plotList) {
if (!Array.isArray(plotList) || plotList.length === 0) return '';
const chrono = [...plotList].reverse();
const lines = [];
chrono.forEach((p, idx) => {
lines.push(`【plot -${chrono.length - idx}\n${p}`);
});
return `<previous_plots>\n${lines.join('\n\n')}\n</previous_plots>`;
}
export function applyPlannerResultAndSend({
textarea,
button,
rawUserInput = '',
filtered = '',
plannerRecall = null,
plannerPlotRecord = null,
runtime = null,
plannerState = null,
} = {}) {
if (!textarea || !button) return { applied: false, reason: 'missing-target' };
const raw = String(rawUserInput ?? '').trim();
const merged = `${raw}\n\n${String(filtered ?? '')}`.trim();
textarea.value = merged;
if (plannerState && typeof plannerState === 'object') {
plannerState.lastInjectedText = merged;
}
const plotRecordPayload = plannerPlotRecord && typeof plannerPlotRecord === 'object'
? {
...plannerPlotRecord,
rawUserInput: raw,
plannerAugmentedMessage: merged,
}
: null;
let plotHandoffPrepared = false;
if (runtime?.preparePlannerPlotRecordHandoff && plotRecordPayload) {
runtime.preparePlannerPlotRecordHandoff(plotRecordPayload);
plotHandoffPrepared = true;
}
let handoffPrepared = false;
if (runtime?.preparePlannerRecallHandoff && plannerRecall?.result) {
runtime.preparePlannerRecallHandoff({
rawUserInput: raw,
plannerAugmentedMessage: merged,
plannerRecall,
plannerPlotRecord: plotRecordPayload,
});
handoffPrepared = true;
}
if (plannerState && typeof plannerState === 'object') {
plannerState.bypassNextSend = true;
}
button.click();
return { applied: true, merged, handoffPrepared, plotHandoffPrepared };
}
export function shouldInterceptPlannerSend({
enabled = false,
isPlanning = false,
hasTextarea = false,
textareaValue = '',
isTrivial = false,
bypassNextSend = false,
skipIfPlotPresent = false,
} = {}) {
if (!enabled) return { shouldIntercept: false, reason: 'disabled' };
if (isPlanning) return { shouldIntercept: false, reason: 'planning' };
if (!hasTextarea) return { shouldIntercept: false, reason: 'missing-textarea' };
const text = String(textareaValue ?? '').trim();
if (!text) return { shouldIntercept: false, reason: 'empty-input' };
if (isTrivial) return { shouldIntercept: false, reason: 'trivial' };
if (bypassNextSend) return { shouldIntercept: false, reason: 'bypass' };
if (skipIfPlotPresent && /<plot\b/i.test(text)) {
return { shouldIntercept: false, reason: 'plot-present' };
}
return { shouldIntercept: true, reason: 'ok' };
}

View File

@@ -1,6 +1,11 @@
import { extension_settings } from '../../../../extensions.js';
import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../script.js';
import { EnaPlannerStorage, migrateFromLWBIfNeeded } from './ena-planner-storage.js';
import {
applyPlannerResultAndSend,
shouldInterceptPlannerSend,
} from './ena-planner-runtime-utils.js';
import { readPlannerPlotHistory } from './planner-plot-history.js';
import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js';
import {
createBuiltinPromptBlock,
@@ -794,38 +799,6 @@ function collectRecentChatSnippet(chat, maxMessages) {
* Plot extraction
* --------------------------
*/
function extractLastNPlots(chat, n) {
if (!Array.isArray(chat) || chat.length === 0) return [];
const want = Math.max(0, Number(n) || 0);
if (!want) return [];
const plots = [];
const plotRe = /<plot\b[^>]*>[\s\S]*?<\/plot>/gi;
for (let i = chat.length - 1; i >= 0; i--) {
const text = chat[i]?.mes ?? '';
if (!text) continue;
const matches = [...text.matchAll(plotRe)];
for (let j = matches.length - 1; j >= 0; j--) {
plots.push(matches[j][0]);
if (plots.length >= want) return plots;
}
}
return plots;
}
function formatPlotsBlock(plotList) {
if (!Array.isArray(plotList) || plotList.length === 0) return '';
// plotList is [newest, ..., oldest] from extractLastNPlots
// Reverse to chronological: oldest first, newest last
const chrono = [...plotList].reverse();
const lines = [];
chrono.forEach((p, idx) => {
lines.push(`【plot -${chrono.length - idx}\n${p}`);
});
return `<previous_plots>\n${lines.join('\n\n')}\n</previous_plots>`;
}
/**
* -------------------------
* Worldbook — read via ST API (like idle-watcher)
@@ -1770,7 +1743,7 @@ async function buildPlannerMessages(rawUserInput) {
// a little continuity even when memory recall returns empty.
const recentChatRaw = collectRecentChatSnippet(chat, 2);
const plotsRaw = formatPlotsBlock(extractLastNPlots(chat, s.plotCount));
const plotsRaw = readPlannerPlotHistory(chat, { count: s.plotCount }).block;
// Build scanText for worldbook keyword activation
const scanText = [charBlockRaw, recentChatRaw, plotsRaw, rawUserInput].join('\n\n');
@@ -1908,15 +1881,17 @@ function isTrivialPlannerInput(text) {
function shouldInterceptNow() {
const s = ensureSettings();
if (!s.enabled || state.isPlanning) return false;
const ta = getSendTextarea();
if (!ta) return false;
const txt = String(ta.value ?? '').trim();
if (!txt) return false;
if (isTrivialPlannerInput(txt)) return false;
if (state.bypassNextSend) return false;
if (s.skipIfPlotPresent && /<plot\b/i.test(txt)) return false;
return true;
const txt = String(ta?.value ?? '').trim();
return shouldInterceptPlannerSend({
enabled: Boolean(s.enabled),
isPlanning: Boolean(state.isPlanning),
hasTextarea: Boolean(ta),
textareaValue: txt,
isTrivial: Boolean(txt && isTrivialPlannerInput(txt)),
bypassNextSend: Boolean(state.bypassNextSend),
skipIfPlotPresent: Boolean(s.skipIfPlotPresent),
}).shouldIntercept;
}
async function doInterceptAndPlanThenSend() {
@@ -1941,22 +1916,22 @@ async function doInterceptAndPlanThenSend() {
ta.value = `${raw}\n\n${preview}`.trim();
}
});
const merged = `${raw}\n\n${filtered}`.trim();
ta.value = merged;
state.lastInjectedText = merged;
// Ordering requirement: register the one-shot planner recall handoff
// synchronously before btn.click(), with no await/timer hop in between.
if (_bmeRuntime?.preparePlannerRecallHandoff && plannerRecall?.result) {
_bmeRuntime.preparePlannerRecallHandoff({
// Ordering requirement: write the merged textarea, register the
// one-shot planner recall handoff synchronously, then click send with
// no await/timer hop in between.
applyPlannerResultAndSend({
textarea: ta,
button: btn,
rawUserInput: raw,
filtered,
plannerRecall,
plannerPlotRecord: {
rawUserInput: raw,
plannerAugmentedMessage: merged,
plannerRecall,
});
}
state.bypassNextSend = true;
btn.click();
plotText: filtered,
},
runtime: _bmeRuntime,
plannerState: state,
});
} catch (err) {
ta.value = raw;
state.lastInjectedText = '';

View File

@@ -0,0 +1,154 @@
import {
extractLastNPlots,
formatPlotsBlock,
} from './ena-planner-runtime-utils.js';
export const ST_BME_PLOT_HISTORY_KEY = 'st_bme_plot';
export const ST_BME_PLOT_HISTORY_VERSION = 1;
export function hashPlannerPlotInput(text = '') {
let hash = 2166136261;
for (const char of String(text || '')) {
hash ^= char.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return String(Math.abs(hash >>> 0));
}
export function createStructuredPlotRecord({
rawUserInput = '',
plannerAugmentedMessage = '',
plotText = '',
plotBlocks = null,
promptProfileId = '',
recallHandoffId = '',
taskResults = [],
createdAt = Date.now(),
inputHash = '',
} = {}) {
const normalizedRaw = String(rawUserInput || '').trim();
const normalizedPlot = String(plotText || '').trim();
const blocks = Array.isArray(plotBlocks)
? plotBlocks.map((item) => String(item || '').trim()).filter(Boolean)
: extractLastNPlots([{ mes: normalizedPlot }], 99);
return {
version: ST_BME_PLOT_HISTORY_VERSION,
inputHash: String(inputHash || hashPlannerPlotInput(normalizedRaw)),
rawUserInput: normalizedRaw,
plannerAugmentedMessage: String(plannerAugmentedMessage || '').trim(),
plotText: normalizedPlot,
plotBlocks: blocks,
promptProfileId: String(promptProfileId || ''),
recallHandoffId: String(recallHandoffId || ''),
taskResults: Array.isArray(taskResults) ? taskResults : [],
createdAt: Number.isFinite(Number(createdAt)) ? Number(createdAt) : Date.now(),
};
}
export function normalizeStructuredPlotRecord(value) {
if (!value || typeof value !== 'object') return null;
if (Number(value.version) !== ST_BME_PLOT_HISTORY_VERSION) return null;
const plotText = String(value.plotText || '').trim();
const plotBlocks = Array.isArray(value.plotBlocks)
? value.plotBlocks.map((item) => String(item || '').trim()).filter(Boolean)
: [];
if (!plotText && plotBlocks.length === 0) return null;
return createStructuredPlotRecord({
...value,
plotText,
plotBlocks,
createdAt: value.createdAt,
});
}
export function readStructuredPlotRecordFromMessage(message) {
return normalizeStructuredPlotRecord(message?.extra?.[ST_BME_PLOT_HISTORY_KEY]);
}
export function collectStructuredPlotRecords(chat, count = 2) {
if (!Array.isArray(chat) || chat.length === 0) return [];
const want = Math.max(0, Number(count) || 0);
if (!want) return [];
const records = [];
for (let index = chat.length - 1; index >= 0; index--) {
const record = readStructuredPlotRecordFromMessage(chat[index]);
if (!record) continue;
records.push(record);
if (records.length >= want) break;
}
return records;
}
export function readPlannerPlotHistory(chat, { count = 2 } = {}) {
const want = Math.max(0, Number(count) || 0);
if (!want) {
return { source: 'empty', records: [], plots: [], block: '' };
}
const structuredRecords = collectStructuredPlotRecords(chat, count);
const seen = new Set();
const plots = [];
let usedLegacy = false;
if (structuredRecords.length > 0) {
for (const record of structuredRecords) {
const recordBlocks = record.plotBlocks.length > 0
? record.plotBlocks
: extractLastNPlots([{ mes: record.plotText || '' }], want);
const plot = recordBlocks.join('\n').trim();
if (!plot || seen.has(plot)) continue;
plots.push(plot);
seen.add(plot);
if (plots.length >= want) break;
}
}
if (plots.length < want) {
for (const legacyPlot of extractLastNPlots(chat, want)) {
if (!legacyPlot || seen.has(legacyPlot)) continue;
plots.push(legacyPlot);
seen.add(legacyPlot);
usedLegacy = true;
if (plots.length >= want) break;
}
}
const source = structuredRecords.length > 0
? (usedLegacy ? 'structured+legacy' : 'structured')
: (plots.length > 0 ? 'legacy' : 'empty');
return {
source,
records: structuredRecords,
plots,
block: formatPlotsBlock(plots),
};
}
export function writeStructuredPlotRecordToMessage(message, recordInput) {
if (!message || typeof message !== 'object' || !message.is_user) return false;
const record = normalizeStructuredPlotRecord(
recordInput?.version ? recordInput : createStructuredPlotRecord(recordInput),
);
if (!record) return false;
message.extra = message.extra && typeof message.extra === 'object'
? message.extra
: {};
message.extra[ST_BME_PLOT_HISTORY_KEY] = record;
return true;
}
export function writeStructuredPlotRecordToMatchingUserMessage(chat, recordInput) {
if (!Array.isArray(chat)) return null;
const record = normalizeStructuredPlotRecord(
recordInput?.version ? recordInput : createStructuredPlotRecord(recordInput),
);
if (!record) return null;
const inputHash = String(record.inputHash || hashPlannerPlotInput(record.rawUserInput));
for (let index = chat.length - 1; index >= 0; index--) {
const message = chat[index];
if (!message?.is_user) continue;
if (hashPlannerPlotInput(message.mes || '') !== inputHash) continue;
if (writeStructuredPlotRecordToMessage(message, record)) {
return { index, record };
}
}
return null;
}

View File

@@ -327,6 +327,7 @@ export function onMessageSentController(runtime, messageId) {
resolvedMessageId,
message.mes || "",
);
runtime.persistPlannerPlotRecordToUserMessage?.(resolvedMessageId);
// GENERATION_AFTER_COMMANDS 在 sendMessageAsUser 之前触发,此时新用户消息
// 尚未进入 chatrecall 记录会被写到上一条 user 上。这里用户消息刚入场,
// transaction 仍在桥接窗口内,立即把记录重新绑定到正确的楼层。

View File

@@ -111,6 +111,7 @@ import {
registerGenerationAfterCommandsController,
scheduleSendIntentHookRetryController,
} from "./host/event-binding.js";
import { writeStructuredPlotRecordToMessage } from "./ena-planner/planner-plot-history.js";
import {
BME_HOST_PROFILE_LUKER,
getBmeHostAdapter,
@@ -14734,16 +14735,56 @@ function preparePlannerRecallHandoff({
rawUserInput = "",
plannerAugmentedMessage = "",
plannerRecall = null,
plannerPlotRecord = null,
chatId = getCurrentChatId(),
} = {}) {
return rerollRecallInput.preparePlannerRecallHandoff({
rawUserInput,
plannerAugmentedMessage,
plannerRecall,
plannerPlotRecord,
chatId,
});
}
function preparePlannerPlotRecordHandoff(plannerPlotRecord = null) {
if (!plannerPlotRecord || typeof plannerPlotRecord !== "object") {
return null;
}
return rerollRecallInput.preparePlannerPlotRecordHandoff({
...plannerPlotRecord,
chatId: getCurrentChatId(),
});
}
function persistPlannerPlotRecordToUserMessage(newUserMessageIndex) {
const context = getContext();
const chat = context?.chat;
if (
!Array.isArray(chat) ||
!Number.isFinite(newUserMessageIndex) ||
!chat[newUserMessageIndex]?.is_user
) {
return false;
}
const chatId = context?.chatId || getCurrentChatId();
const plotHandoff = rerollRecallInput.peekPlannerPlotRecordHandoff?.(chatId);
const handoff = peekPlannerRecallHandoff(chatId);
const plannerPlotRecord = plotHandoff || handoff?.plannerPlotRecord;
if (!plannerPlotRecord || typeof plannerPlotRecord !== "object") {
return false;
}
const wrote = writeStructuredPlotRecordToMessage(chat[newUserMessageIndex], {
...plannerPlotRecord,
recallHandoffId: handoff?.id || plannerPlotRecord.recallHandoffId || "",
});
if (wrote) {
rerollRecallInput.consumePlannerPlotRecordHandoff?.(chatId);
triggerChatMetadataSave(context, { immediate: false });
}
return wrote;
}
function buildPreGenerationRecallKey(type, options = {}) {
return generationRecallTransactionRuntime.buildPreGenerationRecallKey(
type,
@@ -16051,6 +16092,7 @@ function onMessageSent(messageId) {
getContext,
isTrivialUserInput,
markCurrentGenerationTrivialSkip,
persistPlannerPlotRecordToUserMessage,
recordRecallSentUserMessage,
rebindRecallRecordToNewUserMessage,
refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh,
@@ -17889,6 +17931,7 @@ async function onCompactLukerSidecar() {
getExtensionPath: () => `scripts/extensions/third-party/${MODULE_NAME}`,
getPlannerRecallTimeoutMs,
isTrivialUserInput,
preparePlannerPlotRecordHandoff,
preparePlannerRecallHandoff,
runPlannerRecallForEna,
});

View File

@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
"version": "7.6.4",
"version": "7.6.9",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}

View File

@@ -91,40 +91,81 @@ function buildPersistedRecallReuseResult(record = {}) {
};
}
function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) {
const generationType = String(recallInput?.generationType || "normal").trim() || "normal";
export function normalizeRecallGenerationType(value = "normal") {
return String(value || "normal").trim() || "normal";
}
let targetUserMessageIndex = Number.isFinite(recallInput?.targetUserMessageIndex)
? Math.floor(Number(recallInput.targetUserMessageIndex))
: null;
export function normalizeRecallTargetUserMessageIndex(value) {
return Number.isFinite(value) ? Math.floor(Number(value)) : null;
}
const readPersistedRecallFromUserMessage = runtime.readPersistedRecallFromUserMessage;
if (typeof readPersistedRecallFromUserMessage !== "function") return null;
export function normalizeRecallTextForRuntime(runtime, value = "") {
return typeof runtime?.normalizeRecallInputText === "function"
? runtime.normalizeRecallInputText(value)
: String(value ?? "")
.replace(/\r\n/g, "\n")
.trim();
}
const normalizeText = (value = "") =>
typeof runtime.normalizeRecallInputText === "function"
? runtime.normalizeRecallInputText(value)
: String(value ?? "")
.replace(/\r\n/g, "\n")
.trim();
const currentRecallInputText = normalizeText(recallInput?.userMessage || "");
const recallSource = String(recallInput?.source || "").trim();
const activeInputSources = new Set([
export function isActiveRecallInputSource(source = "") {
return new Set([
"send-intent",
"generation-started-send-intent",
"generation-started-textarea",
"host-generation-lifecycle",
"textarea-live",
"planner-handoff",
]);
const isActiveInputSource = activeInputSources.has(recallSource);
const noNewUserGenerationTypes = new Set([
"swipe",
"regenerate",
"continue",
"history",
]);
const isNoNewUserGeneration = noNewUserGenerationTypes.has(generationType);
]).has(String(source || "").trim());
}
export function isNoNewUserGenerationType(generationType = "normal") {
return new Set(["swipe", "regenerate", "continue", "history"]).has(
normalizeRecallGenerationType(generationType),
);
}
export function isTrustedUserFloorRecallSource(source = "") {
return new Set([
"chat-last-user",
"chat-latest-user",
"chat-tail-user",
"message-sent",
"persisted-user-floor",
]).has(String(source || "").trim());
}
export function buildPersistedReuseRecallInput(recallInput = {}, record = {}, runtime) {
const boundUserFloorText = normalizeRecallTextForRuntime(
runtime,
record.boundUserFloorText || recallInput.boundUserFloorText || "",
);
return {
...recallInput,
source: "persisted-user-floor",
sourceLabel: "复用用户楼层召回",
reason: "persisted-user-floor-reuse",
authoritativeInputUsed: Boolean(
record.authoritativeInputUsed || recallInput.authoritativeInputUsed,
),
boundUserFloorText,
};
}
function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) {
const generationType = normalizeRecallGenerationType(recallInput?.generationType);
let targetUserMessageIndex = normalizeRecallTargetUserMessageIndex(
recallInput?.targetUserMessageIndex,
);
const readPersistedRecallFromUserMessage = runtime.readPersistedRecallFromUserMessage;
if (typeof readPersistedRecallFromUserMessage !== "function") return null;
const normalizeText = (value = "") => normalizeRecallTextForRuntime(runtime, value);
const currentRecallInputText = normalizeText(recallInput?.userMessage || "");
const recallSource = String(recallInput?.source || "").trim();
const isActiveInputSource = isActiveRecallInputSource(recallSource);
const isNoNewUserGeneration = isNoNewUserGenerationType(generationType);
if (isActiveInputSource && !isNoNewUserGeneration) {
return null;
}
@@ -213,18 +254,11 @@ function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) {
!isActiveInputSource &&
String(record?.injectionText || "").trim(),
);
const userFloorSources = new Set([
"chat-last-user",
"chat-latest-user",
"chat-tail-user",
"message-sent",
"persisted-user-floor",
]);
const canTrustUserFloorRecord = Boolean(
(!isActiveInputSource || isNoNewUserGeneration) &&
!boundUserFloorText &&
!recordRecallInputMismatch &&
(generationType !== "normal" || userFloorSources.has(recallSource)),
(generationType !== "normal" || isTrustedUserFloorRecallSource(recallSource)),
);
if (
@@ -618,31 +652,11 @@ export async function runRecallController(runtime, options = {}) {
runtime,
);
if (earlyPersistedReuse) {
const normalizedBoundUserFloorText =
typeof runtime.normalizeRecallInputText === "function"
? runtime.normalizeRecallInputText(
earlyPersistedReuse.record.boundUserFloorText ||
recallInput.boundUserFloorText ||
"",
)
: String(
earlyPersistedReuse.record.boundUserFloorText ||
recallInput.boundUserFloorText ||
"",
)
.replace(/\r\n/g, "\n")
.trim();
const effectiveRecallInput = {
...recallInput,
source: "persisted-user-floor",
sourceLabel: "复用用户楼层召回",
reason: "persisted-user-floor-reuse",
authoritativeInputUsed: Boolean(
earlyPersistedReuse.record.authoritativeInputUsed ||
recallInput.authoritativeInputUsed,
),
boundUserFloorText: normalizedBoundUserFloorText,
};
const effectiveRecallInput = buildPersistedReuseRecallInput(
recallInput,
earlyPersistedReuse.record,
runtime,
);
const reusedResult = buildPersistedRecallReuseResult(earlyPersistedReuse.record);
const applied = runtime.applyRecallInjection(
settings,

View File

@@ -1,5 +1,6 @@
export function createRerollRecallInput(deps = {}) {
const plannerRecallHandoffs = new Map();
const plannerPlotRecordHandoffs = new Map();
const getCurrentChatId = (...args) => deps.getCurrentChatId?.(...args);
const normalizeChatIdCandidate = (value = "") =>
@@ -250,6 +251,16 @@ export function createRerollRecallInput(deps = {}) {
plannerRecallHandoffs.delete(chatId);
}
}
for (const [chatId, handoff] of plannerPlotRecordHandoffs.entries()) {
if (
!handoff ||
String(handoff.chatId || "") !== String(chatId || "") ||
now - Number(handoff.updatedAt || handoff.createdAt || 0) >
getPlannerRecallHandoffTtlMs()
) {
plannerPlotRecordHandoffs.delete(chatId);
}
}
}
function peekPlannerRecallHandoff(
@@ -278,14 +289,37 @@ export function createRerollRecallInput(deps = {}) {
) {
cleanupPlannerRecallHandoffs();
if (clearAll) {
const removed = plannerRecallHandoffs.size;
const removed = plannerRecallHandoffs.size + plannerPlotRecordHandoffs.size;
plannerRecallHandoffs.clear();
plannerPlotRecordHandoffs.clear();
return removed;
}
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId) return 0;
return plannerRecallHandoffs.delete(normalizedChatId) ? 1 : 0;
let removed = 0;
if (plannerRecallHandoffs.delete(normalizedChatId)) removed += 1;
if (plannerPlotRecordHandoffs.delete(normalizedChatId)) removed += 1;
return removed;
}
function peekPlannerPlotRecordHandoff(
chatId = getCurrentChatId(),
now = Date.now(),
) {
cleanupPlannerRecallHandoffs(now);
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId) return null;
return plannerPlotRecordHandoffs.get(normalizedChatId) || null;
}
function consumePlannerPlotRecordHandoff(chatId = getCurrentChatId()) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId) return null;
const handoff = peekPlannerPlotRecordHandoff(normalizedChatId);
if (!handoff) return null;
plannerPlotRecordHandoffs.delete(normalizedChatId);
return handoff;
}
function consumePlannerRecallHandoff(
@@ -309,6 +343,7 @@ export function createRerollRecallInput(deps = {}) {
rawUserInput = "",
plannerAugmentedMessage = "",
plannerRecall = null,
plannerPlotRecord = null,
chatId = getCurrentChatId(),
} = {}) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
@@ -340,6 +375,10 @@ export function createRerollRecallInput(deps = {}) {
? plannerRecall.recentMessages.map((item) => String(item || ""))
: [],
injectionText,
plannerPlotRecord:
plannerPlotRecord && typeof plannerPlotRecord === "object"
? { ...plannerPlotRecord }
: null,
source: "planner-handoff",
sourceLabel: "Planner handoff",
createdAt,
@@ -349,12 +388,56 @@ export function createRerollRecallInput(deps = {}) {
return handoff;
}
function preparePlannerPlotRecordHandoff({
rawUserInput = "",
plannerAugmentedMessage = "",
plotText = "",
plotBlocks = null,
promptProfileId = "",
taskResults = [],
chatId = getCurrentChatId(),
} = {}) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
const normalizedRawUserInput = normalizeRecallInputText(rawUserInput);
const normalizedPlannerAugmentedMessage = normalizeRecallInputText(
plannerAugmentedMessage,
);
const normalizedPlotText = normalizeRecallInputText(plotText);
if (!normalizedChatId || !normalizedRawUserInput || !normalizedPlotText) {
return null;
}
cleanupPlannerRecallHandoffs();
const createdAt = Date.now();
const handoff = {
id: [
normalizedChatId,
hashRecallInput(normalizedRawUserInput),
"plot",
createdAt,
].join(":"),
chatId: normalizedChatId,
rawUserInput: normalizedRawUserInput,
plannerAugmentedMessage: normalizedPlannerAugmentedMessage,
plotText: normalizedPlotText,
plotBlocks: Array.isArray(plotBlocks) ? [...plotBlocks] : null,
promptProfileId: String(promptProfileId || ""),
taskResults: Array.isArray(taskResults) ? taskResults : [],
createdAt,
updatedAt: createdAt,
};
plannerPlotRecordHandoffs.set(normalizedChatId, handoff);
return handoff;
}
return {
clearPendingRerollRecallReuse,
buildNormalGenerationRecallInput,
buildHistoryGenerationRecallInput,
buildGenerationAfterCommandsRecallInput,
preparePlannerRecallHandoff,
preparePlannerPlotRecordHandoff,
peekPlannerPlotRecordHandoff,
consumePlannerPlotRecordHandoff,
cleanupPlannerRecallHandoffs,
peekPlannerRecallHandoff,
clearPlannerRecallHandoffsForChat,

219
tests/ena-planner-plots.mjs Normal file
View File

@@ -0,0 +1,219 @@
import assert from 'node:assert/strict';
import {
applyPlannerResultAndSend,
extractLastNPlots,
formatPlotsBlock,
shouldInterceptPlannerSend,
} from '../ena-planner/ena-planner-runtime-utils.js';
import { createRerollRecallInput } from '../runtime/reroll-recall-input.js';
import {
createStructuredPlotRecord,
readPlannerPlotHistory,
writeStructuredPlotRecordToMatchingUserMessage,
writeStructuredPlotRecordToMessage,
} from '../ena-planner/planner-plot-history.js';
{
const chat = [
{ mes: 'no plot here' },
{ mes: '<plot>old one</plot>\n<plot>old two</plot>' },
{ mes: 'assistant says <plot>new one</plot>' },
];
assert.deepEqual(extractLastNPlots(chat, 2), [
'<plot>new one</plot>',
'<plot>old two</plot>',
]);
assert.deepEqual(extractLastNPlots(chat, 0), []);
assert.deepEqual(extractLastNPlots(null, 3), []);
}
{
const block = formatPlotsBlock([
'<plot>newest</plot>',
'<plot>older</plot>',
]);
assert.equal(
block,
'<previous_plots>\n【plot -2】\n<plot>older</plot>\n\n【plot -1】\n<plot>newest</plot>\n</previous_plots>',
);
assert.equal(formatPlotsBlock([]), '');
}
{
const order = [];
const textarea = { value: 'raw' };
const button = { click: () => order.push('click') };
const plannerState = { bypassNextSend: false, lastInjectedText: '' };
const plannerRecall = { result: { selected: ['memory-a'] } };
const runtime = {
preparePlannerRecallHandoff(payload) {
order.push('handoff');
assert.equal(payload.rawUserInput, 'raw input');
assert.equal(payload.plannerAugmentedMessage, 'raw input\n\n<plot>next</plot>');
assert.equal(payload.plannerRecall, plannerRecall);
assert.deepEqual(payload.plannerPlotRecord, {
rawUserInput: 'raw input',
plannerAugmentedMessage: 'raw input\n\n<plot>next</plot>',
plotText: '<plot>next</plot>',
});
},
};
const result = applyPlannerResultAndSend({
textarea,
button,
rawUserInput: 'raw input',
filtered: '<plot>next</plot>',
plannerRecall,
plannerPlotRecord: { plotText: '<plot>next</plot>' },
runtime,
plannerState,
});
assert.deepEqual(order, ['handoff', 'click']);
assert.equal(result.applied, true);
assert.equal(result.handoffPrepared, true);
assert.equal(textarea.value, 'raw input\n\n<plot>next</plot>');
assert.equal(plannerState.lastInjectedText, textarea.value);
assert.equal(plannerState.bypassNextSend, true);
}
{
const order = [];
const textarea = { value: 'raw' };
const button = { click: () => order.push('click') };
const plannerState = { bypassNextSend: false, lastInjectedText: '' };
const runtime = {
preparePlannerPlotRecordHandoff(payload) {
order.push('plot-handoff');
assert.deepEqual(payload, {
rawUserInput: 'raw input',
plannerAugmentedMessage: 'raw input\n\n<plot>next</plot>',
plotText: '<plot>next</plot>',
});
},
preparePlannerRecallHandoff() {
order.push('recall-handoff');
},
};
const result = applyPlannerResultAndSend({
textarea,
button,
rawUserInput: 'raw input',
filtered: '<plot>next</plot>',
plannerRecall: null,
plannerPlotRecord: { plotText: '<plot>next</plot>' },
runtime,
plannerState,
});
assert.deepEqual(order, ['plot-handoff', 'click']);
assert.equal(result.applied, true);
assert.equal(result.plotHandoffPrepared, true);
assert.equal(result.handoffPrepared, false);
assert.equal(textarea.value, 'raw input\n\n<plot>next</plot>');
assert.equal(plannerState.lastInjectedText, textarea.value);
assert.equal(plannerState.bypassNextSend, true);
}
{
const cases = [
[{ enabled: false, hasTextarea: true, textareaValue: 'go' }, false, 'disabled'],
[{ enabled: true, isPlanning: true, hasTextarea: true, textareaValue: 'go' }, false, 'planning'],
[{ enabled: true, hasTextarea: false, textareaValue: 'go' }, false, 'missing-textarea'],
[{ enabled: true, hasTextarea: true, textareaValue: ' ' }, false, 'empty-input'],
[{ enabled: true, hasTextarea: true, textareaValue: 'go', isTrivial: true }, false, 'trivial'],
[{ enabled: true, hasTextarea: true, textareaValue: 'go', bypassNextSend: true }, false, 'bypass'],
[{ enabled: true, hasTextarea: true, textareaValue: '<plot>done</plot>', skipIfPlotPresent: true }, false, 'plot-present'],
[{ enabled: true, hasTextarea: true, textareaValue: '<plotter>not a plot tag</plotter>', skipIfPlotPresent: true }, true, 'ok'],
[{ enabled: true, hasTextarea: true, textareaValue: '<plot id="x">done</plot>', skipIfPlotPresent: false }, true, 'ok'],
[{ enabled: true, hasTextarea: true, textareaValue: 'continue the scene' }, true, 'ok'],
];
for (const [input, expectedShouldIntercept, expectedReason] of cases) {
const result = shouldInterceptPlannerSend(input);
assert.equal(result.shouldIntercept, expectedShouldIntercept, expectedReason);
assert.equal(result.reason, expectedReason);
}
}
{
const chat = [
{ is_user: true, mes: 'raw old', extra: {} },
{ is_user: false, mes: '<plot>legacy stale</plot>' },
{ is_user: true, mes: 'raw latest', extra: {} },
];
writeStructuredPlotRecordToMessage(chat[2], createStructuredPlotRecord({
rawUserInput: 'raw latest',
plannerAugmentedMessage: 'raw latest\n\n<note>private</note>\n<plot>structured</plot>\n<state>hidden</state>',
plotText: '<note>private</note>\n<plot>structured</plot>\n<state>hidden</state>',
}));
const history = readPlannerPlotHistory(chat, { count: 2 });
assert.equal(history.source, 'structured+legacy');
assert.deepEqual(history.plots, ['<plot>structured</plot>', '<plot>legacy stale</plot>']);
assert.ok(history.block.includes('<plot>structured</plot>'));
assert.ok(history.block.includes('legacy stale'));
assert.ok(!history.block.includes('<note>private</note>'));
assert.ok(!history.block.includes('<state>hidden</state>'));
}
{
const chat = [
{ is_user: true, mes: 'raw old', extra: {} },
{ is_user: false, mes: '<plot>legacy old</plot>' },
];
chat[0].extra.st_bme_plot = { version: 999, plotText: '<plot>bad</plot>' };
const history = readPlannerPlotHistory(chat, { count: 1 });
assert.equal(history.source, 'legacy');
assert.deepEqual(history.plots, ['<plot>legacy old</plot>']);
}
{
const chat = [
{ is_user: true, mes: 'first input', extra: {} },
{ is_user: false, mes: 'assistant' },
{ is_user: true, mes: 'second input', extra: {} },
];
const result = writeStructuredPlotRecordToMatchingUserMessage(chat, {
rawUserInput: 'first input',
plannerAugmentedMessage: 'first input\n\n<plot>first plan</plot>',
plotText: '<plot>first plan</plot>',
});
assert.equal(result.index, 0);
assert.equal(chat[0].extra.st_bme_plot.plotText, '<plot>first plan</plot>');
assert.equal(chat[2].extra.st_bme_plot, undefined);
}
{
const runtime = createRerollRecallInput({
getCurrentChatId: () => 'chat-a',
normalizeChatIdCandidate: (value) => String(value || '').trim(),
normalizeRecallInputText: (value) => String(value || '').trim(),
hashRecallInput: (value) => `hash:${String(value || '').length}`,
});
const handoff = runtime.preparePlannerPlotRecordHandoff({
chatId: 'chat-a',
rawUserInput: 'raw input',
plannerAugmentedMessage: 'raw input\n\n<plot>next</plot>',
plotText: '<plot>next</plot>',
});
assert.ok(handoff?.id?.includes(':plot:'));
assert.equal(handoff.plotText, '<plot>next</plot>');
assert.equal(runtime.peekPlannerRecallHandoff('chat-a'), null);
assert.equal(runtime.peekPlannerPlotRecordHandoff('chat-a')?.plotText, '<plot>next</plot>');
assert.equal(runtime.consumePlannerPlotRecordHandoff('chat-a')?.plotText, '<plot>next</plot>');
assert.equal(runtime.peekPlannerPlotRecordHandoff('chat-a'), null);
}
{
const order = [];
const result = applyPlannerResultAndSend({
textarea: null,
button: { click: () => order.push('click') },
});
assert.deepEqual(result, { applied: false, reason: 'missing-target' });
assert.deepEqual(order, []);
}
console.log('ena-planner-plots tests passed');

View File

@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import {
buildPersistedReuseRecallInput,
isActiveRecallInputSource,
isNoNewUserGenerationType,
isTrustedUserFloorRecallSource,
normalizeRecallGenerationType,
normalizeRecallTargetUserMessageIndex,
normalizeRecallTextForRuntime,
} from '../retrieval/recall-controller.js';
assert.equal(normalizeRecallGenerationType(' regenerate '), 'regenerate');
assert.equal(normalizeRecallGenerationType(''), 'normal');
assert.equal(normalizeRecallGenerationType(null), 'normal');
assert.equal(normalizeRecallTargetUserMessageIndex(3.9), 3);
assert.equal(normalizeRecallTargetUserMessageIndex(Number.NaN), null);
assert.equal(normalizeRecallTargetUserMessageIndex('3'), null);
assert.equal(normalizeRecallTextForRuntime(null, ' a\r\nb '), 'a\nb');
assert.equal(
normalizeRecallTextForRuntime({ normalizeRecallInputText: (value) => `x:${String(value).trim()}` }, ' a '),
'x:a',
);
for (const source of [
'send-intent',
'generation-started-send-intent',
'generation-started-textarea',
'host-generation-lifecycle',
'textarea-live',
'planner-handoff',
]) {
assert.equal(isActiveRecallInputSource(source), true, source);
}
assert.equal(isActiveRecallInputSource('chat-last-user'), false);
for (const generationType of ['swipe', 'regenerate', 'continue', 'history']) {
assert.equal(isNoNewUserGenerationType(generationType), true, generationType);
}
assert.equal(isNoNewUserGenerationType('normal'), false);
for (const source of [
'chat-last-user',
'chat-latest-user',
'chat-tail-user',
'message-sent',
'persisted-user-floor',
]) {
assert.equal(isTrustedUserFloorRecallSource(source), true, source);
}
assert.equal(isTrustedUserFloorRecallSource('textarea-live'), false);
{
const recallInput = {
source: 'chat-last-user',
sourceLabel: '历史最后用户楼层',
reason: 'chat-tail-fallback',
authoritativeInputUsed: false,
boundUserFloorText: ' fallback floor ',
deliveryMode: 'deferred',
};
const record = {
authoritativeInputUsed: true,
boundUserFloorText: ' persisted floor ',
};
const result = buildPersistedReuseRecallInput(recallInput, record, {
normalizeRecallInputText: (value) => String(value || '').trim().toUpperCase(),
});
assert.equal(result.source, 'persisted-user-floor');
assert.equal(result.sourceLabel, '复用用户楼层召回');
assert.equal(result.reason, 'persisted-user-floor-reuse');
assert.equal(result.authoritativeInputUsed, true);
assert.equal(result.boundUserFloorText, 'PERSISTED FLOOR');
assert.equal(result.deliveryMode, 'deferred');
}
{
const result = buildPersistedReuseRecallInput(
{ authoritativeInputUsed: true, boundUserFloorText: 'input\r\ntext' },
{},
null,
);
assert.equal(result.authoritativeInputUsed, true);
assert.equal(result.boundUserFloorText, 'input\ntext');
}
console.log('recall-controller-helpers tests passed');