跳转至

酒馆拆解的维护记录

这是酒馆 SillyTavern背后的事。正常阅读不用看,要对照上游更新、核对出处、重跑实验时再看。

版本和素材

  • 上游:SillyTavern/SillyTavern,AGPL-3.0 许可,许可全文随源码存档;文档仓库 SillyTavern/SillyTavern-Docs 只当线索读
  • 读的版本:release 分支 06bde93(1.19.0,2026-09-14 提交),2026-10-20 读;文档仓库 ed5423d。各页不逐处标版本,上游更新了再对照这一个点
  • 源码存档:assets/st-src/ 是这个版本里细讲页引到的文件,逐字未改,目录结构和原仓库一致,行号按存档算
  • 素材:随包的角色卡 Seraphina(default/content/ 下的 PNG)和世界书 Eldoria,本站手译成中文(塞拉菲娜、埃尔多利亚),结构、宏、字段、关键词数量一概不动;酒馆自带的英文提示词(主提示、末尾指令、群聊提示、摘要指令等)保持原文。世界书原件在 assets/st-src/default/content/Eldoria.json,从卡里读出的英文 JSON 和两份译本在 assets/sim/material/

实验设置

细讲页里标着「效果」的块,来自 2026-10-20 的一次实跑,材料在 assets/sim/:

  • 怎么跑的:没起酒馆,在 Node 里用加载钩子原样加载酒馆的前端模块:世界书、Prompt Manager、openai.js、作者注释、宏引擎、正则引擎、instruct、bookmarks.js 等 33 个(清单见 harness/real.txt),src/prompt-converters.js 和服务端组 DeepSeek 请求的那段也原样调用。其余模块换成桩,桩的导出名按全仓库实际 import 了哪些名字自动生成
  • script.js 和 group-chats.js:这两个文件一加载就要整个前端,不能直接 import。make-stubs.mjs 按名字把用得到的五十来个函数原样切出来(getCharacterCardFields、cleanUpMessage、saveChat、syncMesToSwipe、群聊调度等),拼成替身模块,函数体一字不改,切出的范围写在生成文件的头注释里;只有渲染消息、图片附件这类纯界面函数换成空函数
  • 胶水:lib.mjs 照 Generate() 在 Chat Completion 路径上的顺序依次调这些函数,只有调用顺序是写的,拼接、筛选、预算、清洗都是酒馆的原函数。界面上的 swipe、群聊的逐个生成、摘要扩展的手动触发,各照原函数对数据做的几步改,脚本注释里写了对应的行号
  • 桩被碰到的地方:主线跑完统计过(out/main-stub-hits.json),只有斜杠命令注册、DOM 查询、getPresetManager 三类,拼 prompt 的路径没碰到桩。getPresetManager 返回空,等于预设没带正则脚本,和出厂一致
  • token 计数:前端本来发给服务端数,这里在同一进程里用酒馆服务端同款的 DeepSeek 分词器数
  • 设置:新装酒馆的出厂设置,只在连接面板上改了三项:来源 DeepSeek、模型 deepseek-flash、关流式。出厂上下文 4095、回复 300、温度 1、世界书深度 2、预算 25%、递归开,取自 default/content/settings.json
  • 改过出厂设置的地方:摘要来源从已废弃的 Extras 切到主模型;第二次摘要把「覆盖回复长度」调到 1024。其余实验里调过的设置(作者注释、角色笔记、深度注入、Join、正则脚本、Text Completion 的 ChatML)都在页面上注明了
  • 模型调用:酒馆服务端会把请求发到 https://api.deepseek.com/beta/chat/completions,脚本只把域名换成本站的 DeepSeek 中转,路径和请求体一字不改
  • 构造的输入:第二个角色洛恩、完整字段版塞拉菲娜(原卡只有描述和开场白,示范对话拆到自己的字段,补了性格、场景、卡内 system prompt、末尾指令、备选开场白、角色笔记)、世界书各项实验的条目、状态栏消息、文件头里的好感变量、监听器插的那条 system,页面上都注明了
  • 随机数:群聊调度和开场白用固定种子;第一轮群聊的种子 group-r1-1 是挑过的,为了让两人都开口。{{random}} 用的是酒馆自带的熵源,重跑结果会变;{{pick}} 以聊天 ID 为种子,可复现
  • 跑了什么:扮演主线五轮,含一次 swipe、两次 continue、一次 impersonate;摘要两次(出厂、1024);群聊两轮三次生成;其余实验不调模型
  • 用量:主线 12 次调用共 24488 输入 token,其中 20224 命中前缀缓存,输出 3294;群聊 3 次共 1938 输入、493 输出。主线用量表里第 10 条是摘要脚本改版前多跑的一次出厂摘要,输出已被后两次覆盖,只留了用量
  • 隔离:酒馆的代码只在一个没有 API key 的 Node 进程里跑(启动时环境变量里带 KEY、TOKEN、SECRET 的全部去掉),模型请求由另一个 Python 进程发

重跑:把上游克隆到本地,ST_ROOT 指过去,在 harness/ 里装好 package.json 的依赖,跑 run_all.sh,再跑 pack.py 生成页面上的效果块。脚本里的输出目录是本机路径,换机器要改。

实跑脚本

hooks.mjs:加载钩子,指定模块原样加载,其余换桩
// Node 模块加载钩子:原样加载酒馆的源码文件,只把指定的依赖模块换成桩。
// 桩模块的导出名 = 全仓库实际 import 了它哪些名字;stubs/<相对 public 的路径>.mjs 里手写了的用手写的,其余给空桩。
import { pathToFileURL, fileURLToPath } from 'node:url';
import fs from 'node:fs';
import path from 'node:path';

const HARNESS = path.dirname(fileURLToPath(import.meta.url));
const ST = process.env.ST_ROOT;
const PUB = path.join(ST, 'public');
const SRC = path.join(ST, 'src');
const STUB_DIR = path.join(HARNESS, 'stubs');
const SHIMS_URL = pathToFileURL(path.join(HARNESS, 'shims.mjs')).href;
const BARE_PARENT = pathToFileURL(path.join(HARNESS, 'package.json')).href;

// public/ 下只有 ST_REAL 列出的模块原样加载,其余一律换成桩;src/ 下只有 ST_STUBS 列出的换成桩
const REAL_FILE = path.join(HARNESS, 'real.txt');
const REAL = new Set([
    ...(process.env.ST_REAL || '').split(','),
    ...(fs.existsSync(REAL_FILE) ? fs.readFileSync(REAL_FILE, 'utf8').split('\n').map(s => s.replace(/#.*$/, '').trim()) : []),
].filter(Boolean));
const SRC_STUBS = new Set(['src/util.js', ...(process.env.ST_STUBS || '').split(',').filter(Boolean)]);
const STUBBED = {
    has(rel) {
        if (rel.startsWith('public' + path.sep)) return !REAL.has(rel);
        return SRC_STUBS.has(rel);
    },
};

function isBare(s) {
    return !(s.startsWith('.') || s.startsWith('/') || s.startsWith('node:') || s.startsWith('file:') || s.startsWith('data:'));
}

// ---------- 扫描:每个模块被 import 了哪些名字 ----------
let importMap = null;
function walk(dir, out = []) {
    for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
        if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
        const p = path.join(dir, e.name);
        if (e.isDirectory()) walk(p, out);
        else if (e.name.endsWith('.js')) out.push(p);
    }
    return out;
}
function resolveSpec(fromFile, spec) {
    if (spec.startsWith('/')) return path.join(PUB, spec);
    if (spec.startsWith('.')) return path.resolve(path.dirname(fromFile), spec);
    return null;
}
function buildImportMap() {
    const map = new Map();
    const add = (target, name) => {
        if (!map.has(target)) map.set(target, new Set());
        map.get(target).add(name);
    };
    const files = [...walk(path.join(PUB, 'scripts')), path.join(PUB, 'script.js'), ...walk(SRC)];
    const re = /(?:import|export)\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g;
    for (const f of files) {
        const text = fs.readFileSync(f, 'utf8');
        for (const m of text.matchAll(re)) {
            const clause = m[1];
            const target = resolveSpec(f, m[2]);
            if (!target) continue;
            if (/^[\s\S]*\*\s+as\s+\w+/.test(clause) && !clause.includes('{')) { add(target, '*'); continue; }
            const braces = clause.match(/\{([\s\S]*?)\}/);
            if (braces) {
                for (let part of braces[1].split(',')) {
                    part = part.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim();
                    if (!part) continue;
                    part = part.replace(/^type\s+/, '');
                    const name = part.split(/\s+as\s+/)[0].trim();
                    if (/^[A-Za-z_$][\w$]*$/.test(name)) add(target, name === 'default' ? 'default' : name);
                }
            }
            const head = clause.replace(/\{[\s\S]*?\}/, '').replace(/,/g, ' ').trim();
            if (/^[A-Za-z_$][\w$]*$/.test(head) && !clause.startsWith('*') && m[0].startsWith('import')) add(target, 'default');
        }
    }
    return map;
}

function exportedNames(file, seen = new Set()) {
    const names = new Set();
    if (!fs.existsSync(file) || seen.has(file)) return names;
    seen.add(file);
    const text = fs.readFileSync(file, 'utf8');
    for (const m of text.matchAll(/export\s+(?:async\s+)?(?:const|let|var|function\*?|class)\s+([A-Za-z_$][\w$]*)/g)) names.add(m[1]);
    for (const m of text.matchAll(/export\s*\{([^}]*)\}(?!\s*from)/g)) {
        for (const p of m[1].split(',')) {
            const n = p.trim().split(/\s+as\s+/).pop().trim();
            if (n) names.add(n);
        }
    }
    for (const m of text.matchAll(/export\s*\{([^}]*)\}\s*from/g)) {
        for (const p of m[1].split(',')) {
            const n = p.trim().split(/\s+as\s+/).pop().trim();
            if (n) names.add(n);
        }
    }
    for (const m of text.matchAll(/export\s*\*\s*from\s*['"]([^'"]+)['"]/g)) {
        const target = m[1].startsWith('.') ? path.resolve(path.dirname(file), m[1]) : m[1];
        for (const n of exportedNames(target, seen)) if (n !== 'default') names.add(n);
    }
    if (/export\s+default\s/.test(text)) names.add('default');
    return names;
}

function wantedMissing(target) {
    importMap ??= buildImportMap();
    const rel = path.relative(ST, target);
    const overrideFile = path.join(STUB_DIR, rel + '.mjs');
    const have = exportedNames(overrideFile);
    const wanted = importMap.get(target) || new Set();
    return { rel, overrideFile, have, missing: [...wanted].filter(n => n !== '*' && n !== 'default' && !have.has(n)), wantsDefault: wanted.has('default') };
}

function genStub(target) {
    const { rel, overrideFile, have, wantsDefault } = wantedMissing(target);
    const lines = [];
    if (fs.existsSync(overrideFile)) lines.push(`export * from ${JSON.stringify(pathToFileURL(overrideFile).href)};`);
    lines.push(`export * from ${JSON.stringify('ststub-auto:' + target)};`);
    if (have.has('default')) lines.push(`export { default } from ${JSON.stringify(pathToFileURL(overrideFile).href)};`);
    else if (wantsDefault) lines.push(`export { default } from ${JSON.stringify('ststub-auto:' + target)};`);
    return lines.join('\n');
}

// 自动空桩单独成模块,只依赖 shims:循环依赖里先被读到也不会卡在初始化之前
function genAuto(target) {
    const { rel, have, missing, wantsDefault } = wantedMissing(target);
    const lines = [`import { anyStub as __any } from ${JSON.stringify(SHIMS_URL)};`];
    for (const n of missing) lines.push(`export const ${n} = __any(${JSON.stringify(rel + ':' + n)});`);
    if (wantsDefault && !have.has('default')) lines.push(`export default __any(${JSON.stringify(rel + ':default')});`);
    return lines.join('\n');
}

export async function resolve(specifier, context, nextResolve) {
    if (specifier.startsWith('ststub-auto:')) return { url: specifier, shortCircuit: true, format: 'module' };
    const parent = context.parentURL?.startsWith('file:') ? fileURLToPath(context.parentURL) : '';
    const inST = parent.startsWith(ST);
    if (inST && specifier.startsWith('/') && !specifier.startsWith(ST)) {
        specifier = pathToFileURL(path.join(PUB, specifier)).href;
    }
    if ((inST || parent.startsWith(STUB_DIR)) && isBare(specifier)) {
        return nextResolve(specifier, { ...context, parentURL: BARE_PARENT });
    }
    const r = await nextResolve(specifier, context);
    if (r.url.startsWith('file:')) {
        const file = fileURLToPath(r.url);
        if (file.startsWith(ST) && STUBBED.has(path.relative(ST, file))) {
            return { url: 'ststub:' + file, shortCircuit: true, format: 'module' };
        }
    }
    return r;
}

export async function load(url, context, nextLoad) {
    if (url.startsWith('ststub-auto:')) {
        return { format: 'module', source: genAuto(url.slice('ststub-auto:'.length)), shortCircuit: true };
    }
    if (url.startsWith('ststub:')) {
        return { format: 'module', source: genStub(url.slice('ststub:'.length)), shortCircuit: true };
    }
    return nextLoad(url, context);
}
make-stubs.mjs:从 script.js、group-chats.js 按名字原样切函数
// 生成三个桩模块,里面的函数全部从酒馆源码按名字原样切出(extract.mjs),一字不改:
//   stubs/public/script-state.mjs      script.js 的常量和模块级状态(单独成模块,没有 import,循环依赖时先初始化)
//   stubs/public/script.js.mjs         script.js 里用得到的函数;纯界面的几个(渲染消息、图片附件)换成空函数
//   stubs/public/scripts/group-chats.js.mjs  群聊调度函数(原文件没有导出它们)
// script.js / group-chats.js 一加载就要整个前端(顶层等 document ready、操作 DOM),所以不能直接 import。
import fs from 'node:fs';
import path from 'node:path';
import { extractDecls } from './extract.mjs';

const ST = process.env.ST_ROOT;
const PUB = path.join(ST, 'public');
const HERE = path.dirname(new URL(import.meta.url).pathname);
const P = (rel) => JSON.stringify(path.join(PUB, rel));
const listing = (file, decls) => `// 原样切自 ${file}(行号是原文件的):\n` + decls.map(d => `//   ${d.name}  ${d.startLine}-${d.endLine}`).join('\n');

// ---------- script-state.mjs ----------
const CONSTS = ['neutralCharacterName', 'extension_prompt_types', 'extension_prompt_roles', 'MAX_INJECTION_DEPTH', 'talkativeness_default',
    'depth_prompt_depth_default', 'depth_prompt_role_default', 'default_avatar', 'system_avatar'];
const constDecls = [
    ...extractDecls(path.join(PUB, 'scripts/system-messages.js'), ['system_message_types']),
    ...extractDecls(path.join(PUB, 'script.js'), CONSTS),
];
const STATE = {
    name1: '\'User\'', name2: '\'SillyTavern System\'', chat: '[]', characters: '[]', this_chid: 'undefined',
    chat_metadata: '{}', extension_prompts: '{}', main_api: '\'openai\'', online_status: '\'no_connection\'',
    user_avatar: '\'user-default.png\'', is_send_press: 'false', generation_started: 'new Date()',
    max_context: '8192', amount_gen: '350',
};
fs.writeFileSync(path.join(HERE, 'stubs/public/script-state.mjs'),
    `// 自动生成,勿手改。script.js 的常量和模块级状态;实跑脚本用 __set 改状态。\n${listing('public/script.js 与 public/scripts/system-messages.js', constDecls)}\n`
    + constDecls.map(d => d.text).join('\n') + '\n\n'
    + Object.entries(STATE).map(([k, v]) => `export let ${k} = ${v};`).join('\n') + '\n'
    + `export function __set(key, value) {\n    switch (key) {\n${Object.keys(STATE).map(k => `        case '${k}': ${k} = value; break;`).join('\n')}\n        default: throw new Error('unknown key ' + key);\n    }\n}\n`);

// ---------- script.js.mjs ----------
const fromScript = extractDecls(path.join(PUB, 'script.js'), [
    'getCurrentChatId', 'substituteParams', 'addPersonaDescriptionExtensionPrompt', 'getAllExtensionPrompts',
    'getExtensionPromptByName', 'getExtensionPromptMaxDepth', 'getExtensionPrompt', 'baseChatReplace',
    'createLazyFields', 'getCharacterCardFieldsLazy', 'getCharacterCardFields', 'parseMesExamples',
    'extractMessageBias', 'getBiasStrings', 'flushWIInjections', 'setExtensionPrompt',
    'getExtensionPromptRoleByName', 'removeDepthPrompts', 'syncMesToSwipe', 'syncSwipeToMes', 'ensureSwipes',
    'getMaxContextTokens', 'getMaxResponseTokens', 'getMaxPromptTokens',
    'saveReply', 'cleanUpMessage', 'cleanGroupMessage', 'getStoppingStrings', 'extractMessageFromData',
    'getGeneratingApi', 'getGeneratingModel', 'doChatInject', 'getFirstMessage',
    'substituteParamsExtended', 'getMediaDisplay', 'getMediaIndex', 'substituteParamsLegacy',
    'saveChat', 'getCurrentChatDetails', 'updateMessage', 'removeMacros',
]);
const scriptHeader = `// 自动生成,勿手改。
${listing('public/script.js', fromScript)}
export * from './script-state.mjs';
import { ${[...CONSTS, 'system_message_types', ...Object.keys(STATE)].join(', ')} } from './script-state.mjs';
import { Handlebars } from ${P('lib.js')};
import { power_user, collapseNewlines, fixMarkdown, getCustomStoppingStrings } from ${P('scripts/power-user.js')};
import { persona_description_positions } from ${P('scripts/personas.js')};
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from ${P('scripts/authors-note.js')};
import { extension_settings } from ${P('scripts/extensions.js')};
import { selected_group, groups, getGroupCharacterCardsLazy, getGroupNames, group_generation_id } from ${P('scripts/group-chats.js')};
import { inject_ids, MEDIA_DISPLAY } from ${P('scripts/constants.js')};
import { MacroEnvBuilder } from ${P('scripts/macros/engine/MacroEnvBuilder.js')};
import { MacroEngine } from ${P('scripts/macros/engine/MacroEngine.js')};
import { getChatCompletionModel, oai_settings } from ${P('scripts/openai.js')};
import { getRegexedString, regex_placement } from ${P('scripts/extensions/regex/engine.js')};
import { escapeRegex, trimToEndSentence } from ${P('scripts/utils.js')};
import { PromptReasoning, parseReasoningInSwipes } from ${P('scripts/reasoning.js')};
import { getInstructStoppingSequences } from ${P('scripts/instruct-mode.js')};
import { getMessageTimeStamp } from ${P('scripts/RossAscends-mods.js')};
import { getTokenCountAsync } from ${P('scripts/tokenizers.js')};
import { eventSource, event_types } from ${P('scripts/events.js')};
import { evaluateMacros } from ${P('scripts/macros.js')};
import { compressRequest } from ${P('scripts/request-compression.js')};
import { uuidv4 } from ${P('scripts/utils.js')};
export { eventSource, event_types };

// ---- 纯界面的函数换成空函数:渲染消息、图片附件、头像缩略图 ----
function addOneMessage() {}
async function processImageAttachment() {}
function getThumbnailUrl() { return ''; }
function statMesProcess() {}
// ---- 其余 script.js 导出,实跑用不到具体行为 ----
export function getRequestHeaders() { return { 'Content-Type': 'application/json' }; }
export function saveSettingsDebounced() {}
export async function saveChatConditional() {}
export async function saveMetadata() {}
export function getCharacterAvatar() { return ''; }
`;
fs.writeFileSync(path.join(HERE, 'stubs/public/script.js.mjs'), scriptHeader + '\n'
    + fromScript.map(d => d.text).join('\n\n')
    + '\nexport { flushWIInjections, addPersonaDescriptionExtensionPrompt, getAllExtensionPrompts, cleanGroupMessage, doChatInject, getFirstMessage, updateMessage };\n');

// ---------- group-chats.js.mjs ----------
const fromGroup = extractDecls(path.join(PUB, 'scripts/group-chats.js'), [
    'group_activation_strategy', 'group_generation_mode',
    'getGroupNames', 'getGroupDepthPrompts', 'getGroupCharacterCardsLazy',
    'activateImpersonate', 'activateSwipe', 'activateListOrder', 'activatePooledOrder', 'activateNaturalOrder',
    'getFirstCharacterMessage',
]);
fs.mkdirSync(path.join(HERE, 'stubs/public/scripts'), { recursive: true });
fs.writeFileSync(path.join(HERE, 'stubs/public/scripts/group-chats.js.mjs'), `// 自动生成,勿手改。
${listing('public/scripts/group-chats.js', fromGroup)}
import { characters, chat, substituteParams, baseChatReplace, getExtensionPromptRoleByName, talkativeness_default, depth_prompt_depth_default, depth_prompt_role_default, createLazyFields, chat_metadata, name1, name2, eventSource, event_types, default_avatar } from ${P('script.js')};
import { getMessageTimeStamp } from ${P('scripts/RossAscends-mods.js')};
const getThumbnailUrl = () => '';
import { shuffle, extractAllWords, onlyUnique } from ${P('scripts/utils.js')};
import { power_user } from ${P('scripts/power-user.js')};
import { extension_settings } from ${P('scripts/extensions.js')};
export let groups = [];
export let selected_group = null;
export function __setGroup(list, id) { groups = list; selected_group = id; }
export let group_generation_id = null;
export function __setGroupGenId(v) { group_generation_id = v; }

` + fromGroup.map(d => d.text).join('\n\n')
    + '\nexport { activateImpersonate, activateSwipe, activateListOrder, activatePooledOrder, activateNaturalOrder, getFirstCharacterMessage };\n');
// ---------- server-count.mjs:服务端按 DeepSeek 分词器数 token 的那几行 ----------
const fromTok = extractDecls(path.join(ST, 'src/endpoints/tokenizers.js'), ['countWebTokenizerTokens']);
fs.writeFileSync(path.join(HERE, 'stubs/server-count.mjs'), `// 自动生成,勿手改。\n${listing('src/endpoints/tokenizers.js', fromTok)}\n`
    + `function guesstimate(str) { return Math.ceil(new TextEncoder().encode(str).length / 3.35); }\n` + fromTok.map(d => d.text).join('\n') + '\n');
console.log('ok', constDecls.length, fromScript.length, fromGroup.length);

// ---------- memory-ext.mjs:摘要扩展里决定「何时摘要、提示词是什么、摘要放哪」的几段 ----------
const MEM = path.join(PUB, 'scripts/extensions/memory/index.js');
const fromMem = extractDecls(MEM, ['MODULE_NAME', 'summary_sources', 'prompt_builders', 'defaultPrompt', 'defaultTemplate', 'defaultSettings',
    'formatMemoryValue', 'getSummaryPromptForNow', 'setMemoryContext']);
fs.writeFileSync(path.join(HERE, 'stubs/memory-ext.mjs'), `// 自动生成,勿手改。\n${listing('public/scripts/extensions/memory/index.js', fromMem)}
import { substituteParamsExtended, setExtensionPrompt, extension_prompt_types, extension_prompt_roles, is_send_press } from ${P('script.js')};
import { extension_settings, getContext } from ${P('scripts/extensions.js')};
import { selected_group, is_group_generating } from ${P('scripts/group-chats.js')};
import { waitUntilCondition, extractAllWords } from ${P('scripts/utils.js')};
const saveChatDebounced = () => {};
` + fromMem.map(d => d.text).join('\n\n') + '\nexport { MODULE_NAME, defaultSettings, formatMemoryValue, getSummaryPromptForNow, setMemoryContext };\n');
extract.mjs:按名字切出函数声明的扫描器
// 按名字从酒馆源码里原样切出顶层声明(函数、常量),返回 { name, start, end, text }。
// 切的时候跳过字符串、模板串、注释里的括号;切出来的文本一字不改。
import fs from 'node:fs';

function findEnd(src, from, untilSemicolon = false) {
    let i = from, depth = 0, started = false;
    const stack = [];
    while (i < src.length) {
        const c = src[i], n = src[i + 1];
        if (c === '/' && n === '/') { i = src.indexOf('\n', i); if (i < 0) return src.length; continue; }
        if (c === '/' && n === '*') { i = src.indexOf('*/', i + 2) + 2; continue; }
        if (c === '/') {
            // 正则字面量:斜杠前是运算符、开括号、逗号等,按正则跳过(含字符类里的斜杠)
            let k = i - 1;
            while (k >= 0 && /\s/.test(src[k])) k--;
            if (k < 0 || '(,=:[!&|?{};+-*%<>~^'.includes(src[k]) || /\b(return|typeof|case|in|of)$/.test(src.slice(Math.max(0, k - 6), k + 1))) {
                i++;
                let inClass = false;
                while (i < src.length) {
                    if (src[i] === '\\') { i += 2; continue; }
                    if (src[i] === '[') inClass = true;
                    else if (src[i] === ']') inClass = false;
                    else if (src[i] === '/' && !inClass) break;
                    else if (src[i] === '\n') break;
                    i++;
                }
                i++;
                continue;
            }
        }
        if (c === '\'' || c === '"') {
            i++;
            while (i < src.length && src[i] !== c) { if (src[i] === '\\') i++; i++; }
            i++; continue;
        }
        if (c === '`') {
            i++;
            while (i < src.length && src[i] !== '`') {
                if (src[i] === '\\') { i += 2; continue; }
                if (src[i] === '$' && src[i + 1] === '{') {
                    // 模板里的 ${...}:递归找配对
                    const end = findEnd(src, i + 1);
                    i = end; continue;
                }
                i++;
            }
            i++; continue;
        }
        if (c === '{' || c === '(' || c === '[') { depth++; started = true; stack.push(c); }
        else if (c === '}' || c === ')' || c === ']') {
            depth--; stack.pop();
            if (started && depth === 0 && !untilSemicolon) {
                // 声明到 } 为止;常量声明要吃掉结尾的分号
                let j = i + 1;
                if (src[j] === ';') j++;
                return j;
            }
        } else if (c === ';' && depth === 0 && (untilSemicolon || !started)) { return i + 1; }
        i++;
    }
    return src.length;
}

export function extractDecls(file, names) {
    const src = fs.readFileSync(file, 'utf8');
    const lines = src.split('\n');
    const out = [];
    for (const name of names) {
        const re = new RegExp(`^(export\\s+)?(async\\s+)?(function\\*?\\s+${name}\\s*\\(|(const|let|var)\\s+${name}\\s*=)`, 'm');
        const m = re.exec(src);
        if (!m) throw new Error(`没找到 ${name}`);
        const start = m.index;
        let end;
        if (m[3].startsWith('function')) {
            // 跳过参数表,再找函数体
            const paren = src.indexOf('(', start);
            const afterParams = findEnd(src, paren);
            const brace = src.indexOf('{', afterParams);
            end = findEnd(src, brace);
        } else {
            end = findEnd(src, start + m[0].length, true);
        }
        const startLine = src.slice(0, start).split('\n').length;
        const endLine = src.slice(0, end).split('\n').length;
        out.push({ name, startLine, endLine, text: lines.slice(startLine - 1, endLine).join('\n') });
    }
    return out;
}
lib.mjs:按 Generate() 的顺序调酒馆函数
// 实跑用的酒馆会话。照 public/script.js 里 Generate() 在 Chat Completion(main_api = 'openai')路径上的顺序,
// 依次调酒馆自己的函数:世界书、作者注释、Prompt Manager、预算、服务端的 DeepSeek 后处理。
// 这里只有「按顺序调用」的胶水,拼接、筛选、预算、清洗全是酒馆的原函数。注释里的行号都指 public/script.js,另注明的除外。
// 进程里没有 API key:generate() 只产出请求体,由另一个进程发给模型,回包再交给 receive()。
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';

const ST = process.env.ST_ROOT;
const PUB = path.join(ST, 'public');
const HERE = path.dirname(fileURLToPath(import.meta.url));

export const S = await import(path.join(PUB, 'script.js'));
export const OAI = await import(path.join(PUB, 'scripts/openai.js'));
export const PM = await import(path.join(PUB, 'scripts/PromptManager.js'));
export const WI = await import(path.join(PUB, 'scripts/world-info.js'));
export const PU = await import(path.join(PUB, 'scripts/power-user.js'));
export const AN = await import(path.join(PUB, 'scripts/authors-note.js'));
export const MS = await import(path.join(PUB, 'scripts/macros/macro-system.js'));
export const C = await import(path.join(PUB, 'scripts/constants.js'));
export const RX = await import(path.join(PUB, 'scripts/extensions/regex/engine.js'));
export const RS = await import(path.join(PUB, 'scripts/reasoning.js'));
export const G = await import(path.join(PUB, 'scripts/group-chats.js'));
export const EXT = await import(path.join(PUB, 'scripts/extensions.js'));
export const PC = await import(path.join(ST, 'src/prompt-converters.js'));
const { countWebTokenizerTokens } = await import(path.join(HERE, 'stubs/server-count.mjs'));
export const MEM = await import(path.join(HERE, 'stubs/memory-ext.mjs'));

// ---------- token 计数:前端 countTokensOpenAIAsync 发 /api/tokenizers/openai/count,这里在本进程里答 ----------
// 服务端对 DeepSeek 用 SillyTavern-Tokenizers 仓库的 deepseek.json(src/endpoints/tokenizers.js:264、990-995)
const tokenizerFile = path.join(HERE, '../tokenizers/deepseek.json.gz');
// 这个包看到全局有 window/document 就按浏览器加载 wasm,建分词器时临时移开前端的全局桩
const dsTokenizer = await (async () => {
    const saved = { window: globalThis.window, document: globalThis.document };
    delete globalThis.window; delete globalThis.document;
    try {
        const { Tokenizer } = await import('@agnai/web-tokenizers');
        return await Tokenizer.fromJSON(zlib.gunzipSync(fs.readFileSync(tokenizerFile)));
    } finally { Object.assign(globalThis, saved); }
})();
globalThis.__ajax = async (opts) => {
    if (String(opts.url).startsWith('/api/tokenizers/openai/count')) {
        const messages = JSON.parse(opts.data);
        return { token_count: countWebTokenizerTokens(dsTokenizer, messages) };
    }
    throw new Error('unexpected ajax ' + opts.url);
};
// ---------- 服务端的两个聊天接口:前端 fetch 到这里,按服务端的写法落盘 ----------
// /api/chats/save:src/endpoints/chats.js:545-575 → trySaveChat(532-543)把数组逐条 JSON.stringify、按行拼成 JSONL
// /api/characters/chats:列出这张卡已有的聊天文件名
export const DATA_DIR = process.env.ST_DATA || path.join(HERE, '../out/data');
const realFetch = globalThis.fetch;
globalThis.fetch = async (url, init = {}) => {
    const u = String(url);
    if (u === '/api/chats/save') {
        const body = JSON.parse(init.body);
        const cardName = String(body.avatar_url).replace('.png', '');
        const dir = path.join(DATA_DIR, 'chats', cardName);
        fs.mkdirSync(dir, { recursive: true });
        const jsonlData = body.chat?.map(m => JSON.stringify(m)).join('\n');
        fs.writeFileSync(path.join(dir, `${body.file_name}.jsonl`), jsonlData);
        return new Response(JSON.stringify({ ok: true }), { status: 200 });
    }
    if (u === '/api/characters/chats') {
        const body = JSON.parse(init.body);
        const dir = path.join(DATA_DIR, 'chats', String(body.avatar_url).replace('.png', ''));
        const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')) : [];
        return new Response(JSON.stringify(files.map(f => ({ file_name: f }))), { status: 200 });
    }
    if (u.startsWith('/')) throw new Error('unexpected fetch ' + u);
    return realFetch(url, init);
};

export function countTokens(text) { return dsTokenizer.encode(text).length; }

// 世界书每轮激活了哪些条目:getWorldInfoPrompt 发 WORLD_INFO_ACTIVATED(world-info.js:900-903),这里接住
let lastActivated = [];
let lastPromptReasoning = null;   // receive 里 continue 要用它切掉思考前缀(5509-5512)
S.eventSource.on(S.event_types.WORLD_INFO_ACTIVATED, (entries) => { lastActivated = entries; });

const settingsJson = () => JSON.parse(fs.readFileSync(path.join(ST, 'default/content/settings.json'), 'utf8'));
let initialized = false;
let wiInitialized = false;
let baseOai = null;

/**
 * 新装酒馆 + 一张卡 + 若干本世界书,开一段新聊天。
 * oaiPatch:用户在连接面板上改的设置(换成 DeepSeek、关流式);其余全是 default/content/settings.json 的出厂值。
 */
export async function init({ card, books = {}, name1 = 'User', persona = '', oaiPatch = {}, wiPatch = {}, powerUserPatch = {}, chatMetadata = {}, globalBooks = [] }) {
    const settings = settingsJson();
    // power_user:出厂设置覆盖模块里的默认值(power-user.js 的 loadPowerUserSettings 做的是同一件事)
    Object.assign(PU.power_user, structuredClone(settings.power_user), powerUserPatch);
    PU.power_user.persona_description = persona;
    if (!initialized) {
        MS.initRegisterMacros();                        // 新宏引擎注册内置宏(macro-system.js:66)
        AN.initAuthorsNote();                           // 作者注释:CHAT_CHANGED 时载入默认值(authors-note.js:476、582)
        initialized = true;
    }
    // 聊天补全设置:Default 预设 + 出厂设置,再叠用户在连接面板上的改动(openai.js:4320 loadOpenAISettings)
    // loadOpenAISettings 和 Prompt Manager 的初始化都是启动时调一次的;之后每次 init 从第一次的快照还原,再叠这次的改动
    if (!baseOai) {
        const preset = fs.readFileSync(path.join(ST, 'default/content/presets/openai/Default.json'), 'utf8');
        OAI.loadOpenAISettings({ openai_setting_names: ['Default'], openai_settings: [preset] }, { ...settings.oai_settings });
        OAI.setupChatCompletionPromptManager(OAI.oai_settings);
        baseOai = structuredClone(OAI.oai_settings);
    }
    for (const [k, v] of Object.entries(baseOai)) OAI.oai_settings[k] = structuredClone(v);
    Object.assign(OAI.oai_settings, structuredClone(oaiPatch));
    // 扩展设置回到出厂(作者注释、摘要、正则)
    EXT.extension_settings.note = { default: '', chara: [], wiAddition: [], allowWIScan: false };
    EXT.extension_settings.memory = {};
    EXT.extension_settings.regex = [];
    // 世界书:出厂设置 + 已有的书(world-info.js:917 setWorldInfoSettings)
    // setWorldInfoSettings 是启动时调一次的(会追加已选的书、注册事件),之后换设置用 updateWorldInfoSettings(world-info.js:819)
    const wiSettings = { ...structuredClone(settings.world_info_settings), ...wiPatch };
    if (!wiInitialized) {
        WI.setWorldInfoSettings({ ...wiSettings, world_info: { globalSelect: [] } }, { world_names: Object.keys(books) });
        wiInitialized = true;
    }
    WI.updateWorldInfoSettings(wiSettings, globalBooks);
    WI.worldInfoCache.clear?.();
    for (const [name, book] of Object.entries(books)) WI.worldInfoCache.set(name, structuredClone(book));
    // 角色和聊天
    S.__set('characters', [{ ...structuredClone(card), avatar: 'seraphina.png', chat: `${card.name} - 实跑` }]);
    S.__set('this_chid', 0);
    S.__set('name1', name1);
    S.__set('name2', card.name);
    S.__set('chat_metadata', structuredClone(chatMetadata));
    S.__set('chat', []);
    S.__set('extension_prompts', {});
    G.__setGroup([], null);
    // getChatResult(7684-7706):空聊天先放开场白
    const first = S.getFirstMessage();
    if (first.mes) S.chat.push(first);
    await S.eventSource.emit(S.event_types.CHAT_CHANGED, S.getCurrentChatId());
}

/** 群聊:多张卡,selected_group 指向一个群(group-chats.js 的数据形状) */
export function setGroup(cards, group) {
    S.__set('characters', cards.map(c => structuredClone(c)));
    G.__setGroup([group], group.id);
}

export function setCharacter(chid) {
    S.__set('this_chid', chid);
    S.__set('name2', S.characters[chid].name);
}

/**
 * Generate(type) 在 Chat Completion 路径上的拼装。返回发往服务端的 generate_data、服务端发给 DeepSeek 的请求体,
 * 以及这一轮的中间产物(世界书结果、注入表、每条消息的 token 数)。
 */
export async function generate({ type = 'normal', text = '', dryRun = false, quietPrompt = '', skipWIAN = false } = {}) {
    const isContinue = type == 'continue';
    const isImpersonate = type == 'impersonate';
    const chat = S.chat;

    // 4405-4412:regenerate 先删掉最后一条非用户消息
    if (type === 'regenerate' && chat.length && !chat[chat.length - 1].is_user) chat.pop();

    // 4433:bias
    const { messageBias, promptBias } = S.getBiasStrings(text, type);

    // 4447-4454 → sendMessageAsUser(5874-5923):用户消息先进 chat
    if (text && !['regenerate', 'swipe', 'impersonate', 'quiet', 'continue'].includes(type) && !dryRun) {
        const messageText = RX.getRegexedString(text, RX.regex_placement.USER_INPUT);
        const message = {
            name: S.name1,
            is_user: true,
            is_system: false,
            send_date: new Date().toISOString(),
            mes: S.substituteParams(messageText),
            extra: { isSmallSys: false },
        };
        if (messageBias) message.extra.bias = messageBias;
        S.chat_metadata.tainted = true;
        chat.push(message);
    }

    // 4460-4470:卡字段
    let { description, personality, persona, scenario, mesExamples, system, jailbreak, charDepthPrompt, creatorNotes } = S.getCharacterCardFields();

    // 4472-4486:角色笔记(Character's Note)注册成 IN_CHAT 注入
    S.removeDepthPrompts();
    const groupDepthPrompts = G.getGroupDepthPrompts(G.selected_group, Number(S.this_chid));
    if (G.selected_group && Array.isArray(groupDepthPrompts) && groupDepthPrompts.length > 0) {
        groupDepthPrompts.forEach((value, index) => {
            const role = S.getExtensionPromptRoleByName(value.role);
            S.setExtensionPrompt(C.inject_ids.DEPTH_PROMPT_INDEX(index), value.text, S.extension_prompt_types.IN_CHAT, value.depth, EXT.extension_settings.note.allowWIScan, role);
        });
    } else {
        const depthPromptText = charDepthPrompt || '';
        const depthPromptDepth = S.characters[S.this_chid]?.data?.extensions?.depth_prompt?.depth ?? S.depth_prompt_depth_default;
        const depthPromptRole = S.getExtensionPromptRoleByName(S.characters[S.this_chid]?.data?.extensions?.depth_prompt?.role ?? S.depth_prompt_role_default);
        S.setExtensionPrompt(C.inject_ids.DEPTH_PROMPT, depthPromptText, S.extension_prompt_types.IN_CHAT, depthPromptDepth, EXT.extension_settings.note.allowWIScan, depthPromptRole);
    }

    // 4488-4491:开场白每轮重算宏
    if (chat.length) chat[0].mes = S.substituteParams(chat[0].mes);

    // 4493-4529:参与拼装的消息——隐藏的(is_system)去掉;swipe 时去掉当前这条
    let coreChat = chat.filter(x => !x.is_system);
    if (type === 'swipe') coreChat.pop();
    coreChat = coreChat.map((chatItem, index) => {
        const regexType = chatItem.is_user ? RX.regex_placement.USER_INPUT : RX.regex_placement.AI_OUTPUT;
        const options = { isPrompt: true, depth: (coreChat.length - index - (isContinue ? 2 : 1)) };
        return { ...chatItem, mes: RX.getRegexedString(chatItem.mes, regexType, options), index };
    });
    // 4530-4557:思考内容回放(默认 add_to_prompts 关,只是走一遍)
    const promptReasoning = new RS.PromptReasoning();
    lastPromptReasoning = promptReasoning;
    for (let i = coreChat.length - 1; i >= 0; i--) {
        const depth = coreChat.length - i - (isContinue ? 2 : 1);
        const isPrefix = isContinue && i === coreChat.length - 1;
        coreChat[i] = { ...coreChat[i], mes: promptReasoning.addToMessage(coreChat[i].mes, String(coreChat[i].extra?.reasoning ?? ''), isPrefix, coreChat[i].extra?.reasoning_duration) };
        if (promptReasoning.isLimitReached()) break;
    }

    // 4559-4560:上下文上限(Chat Completion 下 = openai_max_context − openai_max_tokens)
    const this_max_context = S.getMaxPromptTokens();
    // 4562-4573:生成拦截器——这次没装扩展,跳过

    // 4616:示范对话按 <START> 切块
    let mesExamplesArray = S.parseMesExamples(mesExamples, false);

    // 4619:作者注释
    AN.setFloatingPrompt();

    // 4621-4636:世界书
    S.setExtensionPrompt(C.inject_ids.QUIET_PROMPT, quietPrompt || '', S.extension_prompt_types.IN_PROMPT, 0, true);
    const chatForWI = coreChat.map(x => WI.world_info_include_names ? `${x.name}: ${x.mes}` : x.mes).reverse();
    const globalScanData = {
        personaDescription: persona,
        characterDescription: description,
        characterPersonality: personality,
        characterDepthPrompt: charDepthPrompt,
        scenario: scenario,
        creatorNotes: creatorNotes,
        trigger: C.GENERATION_TYPE_TRIGGERS.includes(type) ? type : 'normal',
    };
    lastActivated = [];
    const wiResult = await WI.getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData);
    const { worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth, outletEntries } = wiResult;
    S.setExtensionPrompt(C.inject_ids.QUIET_PROMPT, '', S.extension_prompt_types.IN_PROMPT, 0, true);

    // 4638-4655:世界书里「示范对话前 / 后」的条目并进示范对话
    for (const example of worldInfoExamples) {
        const exampleMessage = example.content;
        if (exampleMessage.length === 0) continue;
        const cleanedExample = S.parseMesExamples(S.baseChatReplace(exampleMessage), false);
        if (example.position === WI.wi_anchor_position.before) mesExamplesArray.unshift(...cleanedExample);
        else mesExamplesArray.push(...cleanedExample);
    }

    // 4664-4681:世界书 @depth 条目注册成 IN_CHAT 注入
    if (skipWIAN !== true) {
        S.flushWIInjections();
        if (Array.isArray(worldInfoDepth)) {
            worldInfoDepth.forEach((e) => {
                const joinedEntries = e.entries.join('\n');
                S.setExtensionPrompt(C.inject_ids.CUSTOM_WI_DEPTH_ROLE(e.depth, e.role), joinedEntries, S.extension_prompt_types.IN_CHAT, e.depth, false, e.role);
            });
        }
        if (outletEntries && typeof outletEntries === 'object' && Object.keys(outletEntries).length > 0) {
            Object.entries(outletEntries).forEach(([key, value]) => {
                S.setExtensionPrompt(C.inject_ids.CUSTOM_WI_OUTLET(key), value.join('\n'), S.extension_prompt_types.NONE, 0);
            });
        }
    }

    // 4684:用户人设
    S.addPersonaDescriptionExtensionPrompt();

    // 4767-4780、4866-4871、4976-4980:continue 时被续写的那条单独拿出来
    let cyclePrompt = '';
    if (isContinue && coreChat.length) {
        cyclePrompt = coreChat[coreChat.length - 1].mes;
        if (!cyclePrompt.endsWith(' ')) cyclePrompt += OAI.oai_settings.continue_postfix;
    }

    // 4833-4836:转成 Chat Completion 的消息
    const oaiMessages = OAI.setOpenAIMessages(coreChat);
    const oaiMessageExamples = OAI.setOpenAIMessageExamples(mesExamplesArray);

    // 5288-5303 → openai.js:1542 prepareOpenAIMessages:槽位、注入、预算
    const extensionPromptsSnapshot = structuredClone(Object.fromEntries(Object.entries(S.extension_prompts).map(([k, v]) => [k, { ...v, filter: undefined }])));
    const [prompt, counts] = await OAI.prepareOpenAIMessages({
        name2: S.name2,
        charDescription: description,
        charPersonality: personality,
        scenario: scenario,
        worldInfoBefore: worldInfoBefore,
        worldInfoAfter: worldInfoAfter,
        extensionPrompts: S.extension_prompts,
        bias: promptBias,
        type: type,
        quietPrompt: quietPrompt,
        quietImage: null,
        cyclePrompt: cyclePrompt,
        systemPromptOverride: system,
        jailbreakPromptOverride: jailbreak,
        messages: oaiMessages,
        messageExamples: oaiMessageExamples,
    }, dryRun);

    // 每条进了上下文的消息的 identifier 和 token 数(ChatCompletion 记账,供 Prompt Manager 显示)
    const cc = OAI.promptManager.messages;
    const ledger = cc ? flattenLedger(cc) : null;

    // openai.js:3138 sendOpenAIRequest → 2691 createGenerationParameters
    const model = OAI.getChatCompletionModel(OAI.oai_settings);
    const { generate_data } = await OAI.createGenerationParameters(OAI.oai_settings, model, type, prompt);

    // 服务端 src/endpoints/backends/chat-completions.js:1071-1139 sendDeepSeekRequest 拼请求体
    const deepseek = buildDeepSeekRequest(generate_data);

    return {
        type, coreChatLength: coreChat.length, this_max_context,
        wi: summarizeWI(wiResult, lastActivated), chatForWI,
        extensionPrompts: extensionPromptsSnapshot,
        prompt, counts, ledger, generate_data, deepseek,
        continueMag: isContinue ? cyclePrompt : '',
    };
}

function flattenLedger(collection) {
    const out = [];
    const walk = (item) => {
        if (item && Array.isArray(item.collection)) {
            for (const x of item.collection) walk(x);
            return;
        }
        if (item && item.role) out.push({ identifier: item.identifier, role: item.role, tokens: item.tokens, content: item.content });
    };
    walk(collection);
    return out;
}

function summarizeWI(r, activated) {
    return {
        before: r.worldInfoBefore, after: r.worldInfoAfter,
        examples: r.worldInfoExamples, depth: r.worldInfoDepth,
        anBefore: r.anBefore, anAfter: r.anAfter, outlets: r.outletEntries,
        activated: activated.map(e => ({ world: e.world, uid: e.uid, comment: e.comment, key: e.key, position: e.position, depth: e.depth, order: e.order })),
    };
}

/** chat-completions.js:1071-1139 里 sendDeepSeekRequest 拼请求体的部分,原样照抄;发请求那步交给别的进程 */
export function buildDeepSeekRequest(body) {
    const request = { body: structuredClone(body) };
    const API_DEEPSEEK = 'https://api.deepseek.com/beta';                              // chat-completions.js:86
    const apiUrl = new URL(request.body.reverse_proxy || API_DEEPSEEK).toString();
    let bodyParams = {};
    if (request.body.logprobs > 0) {
        bodyParams['top_logprobs'] = request.body.logprobs;
        bodyParams['logprobs'] = true;
    }
    const processedMessages = PC.addAssistantPrefix(PC.postProcessPrompt(request.body.messages, PC.PROMPT_PROCESSING_TYPE.SEMI_TOOLS, PC.getPromptNames(request)), bodyParams.tools, 'prefix');
    PC.addReasoningContentToToolCalls(processedMessages);
    if (request.body.include_reasoning && request.body.reasoning_effort) {
        bodyParams['reasoning_effort'] = request.body.reasoning_effort;
    }
    const requestBody = {
        'messages': processedMessages,
        'model': request.body.model,
        'temperature': request.body.temperature,
        'max_tokens': request.body.max_tokens,
        'stream': request.body.stream,
        'presence_penalty': request.body.presence_penalty,
        'frequency_penalty': request.body.frequency_penalty,
        'top_p': request.body.top_p,
        'stop': request.body.stop,
        'seed': request.body.seed,
        'thinking': { type: request.body.include_reasoning ? 'enabled' : 'disabled' },
        ...bodyParams,
    };
    return { url: apiUrl + '/chat/completions', body: JSON.parse(JSON.stringify(requestBody)) };
}

/** Generate 的 onSuccess(5486-5535):取正文和思考、清洗、存进 chat */
export async function receive({ type = 'normal', data, continueMag = '' }) {
    const isImpersonate = type == 'impersonate';
    const isContinue = type == 'continue';
    let getMessage = S.extractMessageFromData(data);
    let reasoning = RS.extractReasoningFromData(data);
    getMessage = S.cleanUpMessage({ getMessage, isImpersonate, isContinue, displayIncompleteSentences: false });
    reasoning = RX.getRegexedString(reasoning, RX.regex_placement.REASONING);
    if (PU.power_user.trim_spaces) reasoning = reasoning.trim();
    if (isContinue) {
        continueMag = lastPromptReasoning ? lastPromptReasoning.removePrefix(continueMag) : continueMag;
        getMessage = continueMag + getMessage;
    }
    getMessage = S.cleanUpMessage({ getMessage, isImpersonate, isContinue, displayIncompleteSentences: type === 'quiet' });
    if (isImpersonate || type === 'quiet') return { type, getMessage, reasoning };
    const res = await S.saveReply({ type: isContinue ? 'appendFinal' : type, getMessage, reasoning });
    return { ...res, reasoning, message: S.chat[S.chat.length - 1] };
}

export function snapshot() {
    return { chat: structuredClone(S.chat), chat_metadata: structuredClone(S.chat_metadata) };
}

export function stubHits() {
    return Object.fromEntries([...globalThis.__stubHits.entries()].filter(([k]) => !k.startsWith('$(') && !k.startsWith('toastr')));
}
stlab.py:主控,启动无密钥的 Node 进程,模型请求在这里发
"""主控:酒馆的代码在一个没有 API key 的 Node 进程里跑(server.mjs),模型请求由本进程发。

酒馆服务端把请求发到 https://api.deepseek.com/beta/chat/completions;这里只把域名换成本站的 DeepSeek 中转,
路径、请求体一字不改。
"""
import json, os, subprocess, sys, time, urllib.request

HARNESS = os.path.dirname(os.path.abspath(__file__))
ST_ROOT = os.environ.get('ST_ROOT', '/data2/work/lht/study/26.06/_ext/st/SillyTavern')
PROXY = 'https://deepseek-proxy.liuhetian.work'
ORIGIN = 'https://api.deepseek.com'


def _key():
    key = os.environ.get('MY_API_KEY')
    if key:
        return key
    for line in open('/data2/work/lht/study/26.06/zensical-wiki/.env'):
        if line.startswith('MY_API_KEY='):
            return line.split('=', 1)[1].strip().strip('"\'')
    sys.exit('MY_API_KEY 没配')


class Lab:
    def __init__(self):
        env = {k: v for k, v in os.environ.items() if 'KEY' not in k and 'TOKEN' not in k and 'SECRET' not in k}
        env['ST_ROOT'] = ST_ROOT
        self.p = subprocess.Popen(['node', '--import', './register.mjs', 'server.mjs'], cwd=HARNESS, env=env,
                                  stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)
        self.n = 0
        ready = json.loads(self.p.stdout.readline())
        assert ready['result'] == 'ready', ready

    def call(self, cmd, **args):
        self.n += 1
        self.p.stdin.write(json.dumps({'id': self.n, 'cmd': cmd, 'args': args}, ensure_ascii=False) + '\n')
        self.p.stdin.flush()
        r = json.loads(self.p.stdout.readline())
        if not r['ok']:
            raise RuntimeError(r['error'])
        return r.get('result')

    def eval(self, code, **args):
        return self.call('eval', code=code, args=args)

    def close(self):
        self.p.stdin.close()
        self.p.wait(timeout=30)


def send(deepseek, log=None):
    """把酒馆服务端拼好的请求发出去;url 只换域名。返回 (回包, 耗时秒)。"""
    url = deepseek['url'].replace(ORIGIN, PROXY)
    data = json.dumps(deepseek['body'], ensure_ascii=False).encode()
    req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _key()})
    t = time.time()
    for attempt in range(3):
        try:
            with urllib.request.urlopen(req, timeout=600) as r:
                resp = json.loads(r.read())
            break
        except Exception as e:  # noqa: BLE001
            if attempt == 2:
                raise
            time.sleep(3)
    if log is not None:
        log.append({'url': deepseek['url'], 'request': deepseek['body'], 'response': resp})
    return resp, round(time.time() - t, 1)
run_main.py、run_memory.py、run_group.py:扮演主线、摘要、群聊
"""扮演主线:中文版塞拉菲娜,4 轮,中间做一次 swipe、一次 continue、一次 impersonate,最后用摘要扩展的提示词跑一次摘要。

每一步存三样:酒馆拼出的中间产物(世界书结果、注入表、每条消息的 token 数、发往服务端的 generate_data)、
服务端发给 DeepSeek 的请求体、DeepSeek 的回包。全部拼接都在 server.mjs 里调酒馆的函数,本进程只发请求。
"""
import json, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from stlab import Lab, send

OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/main'
M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/'
os.makedirs(OUT, exist_ok=True)

CONNECTION = {  # 用户在连接面板上改的:换成 DeepSeek、关流式;其余全是出厂设置
    'chat_completion_source': 'deepseek',
    'deepseek_model': 'deepseek-flash',
    'stream_openai': False,
}

lab = Lab()
card = json.load(open(M + 'seraphina.zh.json'))
book = json.load(open(M + 'eldoria.zh.json'))
lab.call('init', card=card, books={'埃尔多利亚': book}, name1='旅人', oaiPatch=CONNECTION)
json.dump(lab.call('snapshot'), open(f'{OUT}/00-init.json', 'w'), ensure_ascii=False, indent=1)

usage = []


def step(tag, type_, text=''):
    g = lab.call('generate', type=type_, text=text)
    log = []
    resp, sec = send(g['deepseek'], log)
    rec = lab.call('receive', type=type_, data=resp, continueMag=g.get('continueMag', ''))
    usage.append({'step': tag, 'type': type_, 'seconds': sec, 'usage': resp.get('usage')})
    json.dump({'user': text, 'generate': g, 'response': resp, 'received': rec}, open(f'{OUT}/{tag}.json', 'w'), ensure_ascii=False, indent=1)
    snap = lab.call('snapshot')
    json.dump(snap, open(f'{OUT}/{tag}-chat.json', 'w'), ensure_ascii=False, indent=1)
    msg = resp['choices'][0]['message']
    print(f'== {tag} [{type_}] {sec}s finish={resp["choices"][0]["finish_reason"]} usage={resp.get("usage", {}).get("completion_tokens")}')
    print('WI:', [a['comment'] for a in g['wi']['activated']])
    print((rec.get('getMessage') or '')[:400])
    return g, resp, rec


step('r1', 'normal', '*我撑起身子,接过茶杯* 谢谢你……这片森林里到底有什么怪物?')
step('r2', 'normal', '你的魔法真厉害。我的伤要多久才能好?我还得赶去北边的镇子。')

# swipe:右滑到头生成新候选。界面上的 swipe()(script.js:9954 起)对数据只做三步:syncMesToSwipe 把当前候选写回
# swipes(10340);swipe_id 指到新槽位(10396-10400);clearMessageData 清掉消息上的附加数据(10411、10134-10150)。
# 然后 Generate('swipe')。这里照这三步改数据,syncMesToSwipe 是原样切出的。
lab.eval('''
const chat = L.S.chat; const mesId = chat.length - 1; const m = chat[mesId];
L.S.syncMesToSwipe(mesId);
m.swipe_id = m.swipes.length;
for (const k of ['memory', 'display_text', 'media', 'inline_image', 'files', 'fileLength', 'generationType', 'negative', 'title', 'append_title']) delete m.extra?.[k];
delete m.gen_started; delete m.gen_finished;
''')
step('r2-swipe', 'swipe')

step('r3', 'normal', '你一个人守在这里多久了?给我讲讲你是怎么成为森林守护者的吧。')
step('r3-continue', 'continue')
g, resp, rec = step('r4-impersonate', 'impersonate')
step('r4', 'normal', rec['getMessage'])
step('r4-continue', 'continue')     # r4 顶到了 300 token 的回复上限,点「继续」
step('r5', 'normal', '……谢谢你愿意跟我说这些。等伤好了,我想陪你去点亮更多的灯。')

json.dump(lab.call('stubHits'), open(f'{OUT}/stub-hits.json', 'w'), ensure_ascii=False, indent=1)
json.dump(usage, open(f'{OUT}/usage.json', 'w'), ensure_ascii=False, indent=1)
lab.close()
"""聊久了:接着扮演主线 r5 之后的状态,跑摘要扩展。

摘要扩展的 Classic 方式 = 带完整上下文做一次 quiet 生成,摘要指令放最后(extensions/memory/index.js:681-753 → generateQuietPrompt)。
先照出厂设置跑一次(摘要的回复长度跟主回复一样是 300);再像用户那样把摘要的「覆盖回复长度」调到 1024 跑一次——
generateQuietPrompt 用 TempResponseLength(script.js:4153-4200)临时改 oai_settings.openai_max_tokens,跑完还原,这里照做。
"""
import json, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from stlab import Lab, send

OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/main'
M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/'
CONNECTION = {'chat_completion_source': 'deepseek', 'deepseek_model': 'deepseek-flash', 'stream_openai': False}

lab = Lab()
lab.call('init', card=json.load(open(M + 'seraphina.zh.json')), books={'埃尔多利亚': json.load(open(M + 'eldoria.zh.json'))}, name1='旅人', oaiPatch=CONNECTION)
state = json.load(open(f'{OUT}/r5-chat.json'))
lab.eval('L.S.__set("chat", args.chat); L.S.__set("chat_metadata", args.meta);', chat=state['chat'], meta=state['chat_metadata'])
usage = json.load(open(f'{OUT}/usage.json'))


def summarize(tag, override):
    # 用户在摘要扩展里把来源从 Extras 切到主模型(出厂是 extras,没装 Extras 时什么也不做),覆盖回复长度按参数设
    lab.eval('L.EXT.extension_settings.memory = { ...structuredClone(L.MEM.defaultSettings), source: "main", overrideResponseLength: args.o };', o=override)
    # 触发判断原样调 getSummaryPromptForNow(memory/index.js:565-626):距上次摘要满 10 条才给提示词
    prompt = lab.eval('return await L.MEM.getSummaryPromptForNow(L.EXT.getContext(), false);')
    saved = lab.eval('const o = L.OAI.oai_settings.openai_max_tokens; if (args.o > 0) L.OAI.oai_settings.openai_max_tokens = args.o; return o;', o=override)
    g = lab.call('generate', type='quiet', quietPrompt=prompt)
    lab.eval('L.OAI.oai_settings.openai_max_tokens = args.v;', v=saved)
    resp, sec = send(g['deepseek'])
    rec = lab.call('receive', type='quiet', data=resp)
    summary = lab.eval('return L.RS.removeReasoningFromString(args.s);', s=rec['getMessage'])
    usage.append({'step': tag, 'type': 'quiet', 'seconds': sec, 'usage': resp.get('usage')})
    json.dump({'prompt': prompt, 'overrideResponseLength': override, 'generate': g, 'response': resp, 'received': rec, 'summary': summary},
              open(f'{OUT}/{tag}.json', 'w'), ensure_ascii=False, indent=1)
    print(f'== {tag} max_tokens={g["deepseek"]["body"]["max_tokens"]} finish={resp["choices"][0]["finish_reason"]} usage={resp.get("usage")}')
    print(summary)
    return summary


summarize('summary-factory', 0)
summary = summarize('summary', 1024)
# 存摘要:setMemoryContext(memory/index.js:964-985)——注册 1_memory 注入,并挂在倒数第二条消息的 extra.memory 上
lab.eval('L.MEM.setMemoryContext(args.s, true);', s=summary)
json.dump(lab.call('snapshot'), open(f'{OUT}/summary-chat.json', 'w'), ensure_ascii=False, indent=1)
# 摘要之后下一轮拼成什么样(dry run,不发请求)
json.dump(lab.call('generate', type='normal', dryRun=True), open(f'{OUT}/r6-dry.json', 'w'), ensure_ascii=False, indent=1)
# /hide 0-4:前 5 条只留给人看(chats.js:147-169 hideChatMessageRange 把 is_system 置 true)
lab.eval('for (let i = 0; i <= 4; i++) L.S.chat[i].is_system = true;')
json.dump(lab.call('generate', type='normal', dryRun=True), open(f'{OUT}/r6-hide-dry.json', 'w'), ensure_ascii=False, indent=1)
json.dump(usage, open(f'{OUT}/usage.json', 'w'), ensure_ascii=False, indent=1)
lab.close()
"""群聊实跑:塞拉菲娜(译本)+ 洛恩(构造),Natural 调度、Swap 拼卡(出厂默认),两轮。

generateGroupWrapper(group-chats.js:945-1091)的流程:先用调度函数选出这轮发言的人,再逐个切换当前角色、调一次普通的 Generate;
用户这句只在第一个人生成时进聊天。这里照这个顺序调;调度用带种子的随机数,可复现。
"""
import json, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from stlab import Lab, send

OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/group'
M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/'
os.makedirs(OUT, exist_ok=True)
CONNECTION = {'chat_completion_source': 'deepseek', 'deepseek_model': 'deepseek-flash', 'stream_openai': False}

lab = Lab()
sera = json.load(open(M + 'seraphina.zh.json')); sera['avatar'] = 'seraphina.png'
lorne = json.load(open(M + 'lorne.constructed.json')); lorne['avatar'] = 'lorne.png'
book = json.load(open(M + 'eldoria.zh.json'))
GROUP = {'id': '1', 'name': '林间空地', 'members': ['seraphina.png', 'lorne.png'], 'activation_strategy': 0, 'generation_mode': 0,
         'disabled_members': [], 'allow_self_responses': False, 'chat_id': '林间空地 - 实跑', 'chats': ['林间空地 - 实跑']}

lab.call('init', card=sera, books={'埃尔多利亚': book}, name1='旅人', oaiPatch=CONNECTION)
# 新开的群聊:每个成员按顺序发一条开场白(group-chats.js:281-301 → getFirstCharacterMessage 572-608)
lab.eval('''
const seedrandom = (await import('seedrandom')).default;
L.setGroup(args.cards, args.group);
L.S.__set('chat', []);
Math.random = seedrandom('group-greeting');
for (const member of args.group.members) {
    const character = L.S.characters.find(x => x.avatar === member);
    const mes = await L.G.getFirstCharacterMessage(character);
    if (mes?.mes) L.S.chat.push(mes);
}
''', cards=[sera, lorne], group=GROUP)
json.dump(lab.call('snapshot'), open(f'{OUT}/g-00-greetings.json', 'w'), ensure_ascii=False, indent=1)

usage = []


def group_round(tag, text, seed):
    act = lab.eval('''
const seedrandom = (await import('seedrandom')).default;
Math.random = seedrandom(args.seed);
const g = L.G.groups[0];
const lastMessage = L.S.chat[L.S.chat.length - 1];
const enabled = g.members.filter(x => !g.disabled_members.includes(x));
const ids = L.G.activateNaturalOrder(enabled, args.text, lastMessage, g.allow_self_responses, true);
L.G.__setGroupGenId(Date.now());   // group-chats.js:992 group_generation_id = Date.now()
return ids.map(i => ({ id: i, name: L.S.characters[i].name }));
''', text=text, seed=seed)
    print(f'== {tag} activated:', [a['name'] for a in act])
    first = True
    for a in act:
        lab.eval('L.setCharacter(args.id);', id=a['id'])
        g = lab.call('generate', type='normal', text=text if first else '')
        first = False
        log = []
        resp, sec = send(g['deepseek'], log)
        rec = lab.call('receive', type='normal', data=resp)
        usage.append({'step': f'{tag}-{a["name"]}', 'seconds': sec, 'usage': resp.get('usage')})
        json.dump({'user': text, 'activated': act, 'speaker': a['name'], 'generate': g, 'response': resp, 'received': rec},
                  open(f'{OUT}/g-{tag}-{a["name"]}.json', 'w'), ensure_ascii=False, indent=1)
        print(f'  [{a["name"]}] {sec}s finish={resp["choices"][0]["finish_reason"]}')
        print('  ', (rec.get('getMessage') or '')[:300])
    json.dump(lab.call('snapshot'), open(f'{OUT}/g-{tag}-chat.json', 'w'), ensure_ascii=False, indent=1)


group_round('r1', '你们两位认识很久了吗?', 'group-r1-1')   # 种子挑过:让第一轮两人都开口
group_round('r2', '洛恩,你守在森林边上,见过影牙吗?', 'group-r2')
json.dump(usage, open(f'{OUT}/usage.json', 'w'), ensure_ascii=False, indent=1)
lab.close()
不调模型的六组实验
// 世界书实验:全部直接调 world-info.js 的 checkWorldInfo / getWorldInfoPrompt,不调模型。
// 判定过程取自酒馆自己打的 [WI] 调试日志(world-info.js 的 console.debug),这里只是接住、去掉条目对象本身。
// 随机数(概率、互斥组)换成带种子的 seedrandom,结果可复现。
import fs from 'node:fs';
import seedrandom from 'seedrandom';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? (typeof x === 'object' ? JSON.stringify(x)?.slice(0,300) : String(x))).join(' ').slice(0, 2000) + '\n');
const L = await import('./lib.mjs');
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/lorebook';
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
fs.mkdirSync(OUT, { recursive: true });
const card = JSON.parse(fs.readFileSync(M + 'seraphina.zh.json'));
const eldoria = JSON.parse(fs.readFileSync(M + 'eldoria.zh.json'));
const TEMPLATE = eldoria.entries['0'];
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };

function entry(uid, o) {
    return { ...structuredClone(TEMPLATE), uid, displayIndex: uid, key: [], keysecondary: [], comment: '', content: '', ...o };
}
function book(entries) { return { entries: Object.fromEntries(entries.map(e => [String(e.uid), e])) }; }

// 接住 [WI] 调试日志:只留字符串部分
async function traced(fn) {
    const lines = [];
    const prev = console.debug;
    const prevLog = console.log;
    const fmt = (args) => args.filter(a => typeof a !== 'object' || a === null).map(String).join(' ');
    console.debug = (...a) => { if (String(a[0]).startsWith('[WI]')) lines.push(fmt(a)); };
    console.log = (...a) => { if (String(a[0]).startsWith('[WI]')) lines.push(fmt(a)); };
    try { return { result: await fn(), trace: lines }; } finally { console.debug = prev; console.log = prevLog; }
}
function seeded(seed) { const rng = seedrandom(seed); Math.random = rng; }

// 构造演示用解绑了世界书的卡,只让演示书生效
const bareCard = structuredClone(card); bareCard.data.extensions.world = '';
async function setup({ books, globalBooks = [], wiPatch = {}, chat = null, chatMetadata = {} }) {
    await L.init({ card: globalBooks.length ? bareCard : card, books, name1: '旅人', oaiPatch: CONN, wiPatch, globalBooks, chatMetadata });
    if (chat) L.S.__set('chat', structuredClone(chat));
}
// 用和 Generate 相同的方式取扫描文本(script.js:4624)
const chatForWI = () => L.S.chat.filter(x => !x.is_system).map(x => L.WI.world_info_include_names ? `${x.name}: ${x.mes}` : x.mes).reverse();
async function check({ dry = true } = {}) {
    const max = L.S.getMaxPromptTokens();
    return traced(() => L.WI.checkWorldInfo(chatForWI(), max, dry, { trigger: 'normal' }));
}
const names = (r) => [...r.allActivatedEntries.values()].map(e => e.comment);
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const userMsg = (mes) => ({ name: '旅人', is_user: true, is_system: false, send_date: '', mes, extra: {} });
const charMsg = (mes) => ({ name: '塞拉菲娜', is_user: false, is_system: false, send_date: '', mes, extra: {}, swipe_id: 0, swipes: [mes], swipe_info: [{}] });

// ---------- A. 真实输入:第一轮,扫描最近 2 条(开场白 + 用户这句) ----------
const main = JSON.parse(fs.readFileSync('/data2/work/lht/study/26.06/_ext/st-lab/out/main/r1.json'));
const r1chat = JSON.parse(fs.readFileSync('/data2/work/lht/study/26.06/_ext/st-lab/out/main/00-init.json')).chat;
r1chat.push(userMsg(main.user));
{
    await setup({ books: { '埃尔多利亚': eldoria }, chat: r1chat });
    const on = await check();
    await setup({ books: { '埃尔多利亚': eldoria }, chat: r1chat, wiPatch: { world_info_recursive: false } });
    const off = await check();
    save('A-real-r1', { scanText: chatForWI().slice(0, 2), recursiveOn: { activated: names(on.result), trace: on.trace }, recursiveOff: { activated: names(off.result), trace: off.trace } });
}

// ---------- B. 次关键词:同一个「苹果」,按次关键词分开 ----------
{
    const b = book([
        entry(0, { comment: '苹果公司', key: ['苹果'], keysecondary: ['手机', '发布会'], selectiveLogic: 0, content: '苹果是一家做手机和电脑的公司。' }),       // AND_ANY
        entry(1, { comment: '苹果树', key: ['苹果'], keysecondary: ['手机', '发布会'], selectiveLogic: 2, content: '院子里那棵苹果树是外婆种的。' }),          // NOT_ANY
    ]);
    const out = {};
    for (const text of ['新款苹果手机什么时候发布?', '院子里的苹果熟了,摘几个吧。']) {
        await setup({ books: { '演示·次关键词': b }, globalBooks: ['演示·次关键词'], chat: [userMsg(text)] });
        const r = await check();
        out[text] = { activated: names(r.result), trace: r.trace };
    }
    save('B-secondary', { entries: Object.values(b.entries).map(e => ({ comment: e.comment, key: e.key, keysecondary: e.keysecondary, selectiveLogic: e.selectiveLogic })), runs: out });
}

// ---------- C. 递归:条目内容里的词再去触发别的条目;preventRecursion、delayUntilRecursion ----------
{
    const mk = (prevent = false) => book([
        entry(0, { comment: '银溪谷', key: ['银溪谷'], content: '银溪谷在空地北边,如今荆棘魔盘踞在谷口。', order: 100, preventRecursion: prevent }),
        entry(1, { comment: '荆棘魔', key: ['荆棘魔'], content: '荆棘魔是被影牙诅咒的树精,怕火。', order: 90 }),
        entry(2, { comment: '影牙的来历', key: ['影牙'], content: '影牙来自森林深处的裂谷,三十年前第一次出现。', order: 80, delayUntilRecursion: true }),
    ]);
    const chat = [userMsg('明天我想从银溪谷绕过去。')];
    const out = {};
    for (const [tag, b] of [['默认', mk(false)], ['银溪谷设 preventRecursion', mk(true)]]) {
        await setup({ books: { '演示·递归': b }, globalBooks: ['演示·递归'], chat });
        const r = await check();
        out[tag] = { activated: names(r.result), trace: r.trace };
    }
    // 延迟到递归:用户直接说「影牙」,第一轮扫描不放行这条
    await setup({ books: { '演示·递归': mk(false) }, globalBooks: ['演示·递归'], chat: [userMsg('影牙到底是什么东西?')] });
    const d = await check();
    out['直接提到影牙'] = { activated: names(d.result), trace: d.trace };
    save('C-recursion', { runs: out });
}

// ---------- D. 预算:真实世界书 + 第一轮的真实聊天,用户把世界书预算从 25% 调到 10% ----------
{
    const out = {};
    for (const pct of [25, 10]) {
        await setup({ books: { '埃尔多利亚': eldoria }, chat: r1chat, wiPatch: { world_info_budget: pct } });
        const r = await check();
        out[`${pct}%`] = { activated: names(r.result), trace: r.trace.filter(l => /Budget|budget|activation successful|Context size|LOOP #|Successfully/.test(l)) };
    }
    save('D-budget', out);
}

// ---------- E. 互斥组和概率:同组只留一条,按权重抽;概率条目掷骰 ----------
{
    const b = book([
        entry(0, { comment: '天气·晴', key: ['出门', '天气'], group: '天气', groupWeight: 60, content: '今天是晴天。' }),
        entry(1, { comment: '天气·雨', key: ['出门', '天气'], group: '天气', groupWeight: 30, content: '今天在下雨。' }),
        entry(2, { comment: '天气·雾', key: ['出门', '天气'], group: '天气', groupWeight: 10, content: '林子里起了大雾。' }),
        entry(3, { comment: '偶遇商队', key: ['出门'], probability: 30, useProbability: true, content: '路上遇见一支往北走的商队。' }),
    ]);
    const runs = [];
    for (let seed = 1; seed <= 10; seed++) {
        await setup({ books: { '演示·互斥组': b }, globalBooks: ['演示·互斥组'], chat: [userMsg('我们出门走走吧。')] });
        seeded('wi-' + seed);
        const r = await check();
        runs.push({ seed: 'wi-' + seed, activated: names(r.result) });
    }
    Math.random = seedrandom();   // 恢复
    save('E-group', { weights: { 晴: 60, 雨: 30, 雾: 10 }, 商队概率: 30, runs });
}

// ---------- F. 定时效果:sticky / cooldown / delay,按聊天条数计时;swipe 时作废 ----------
{
    const b = book([
        entry(0, { comment: '地牢规则', key: ['地牢'], sticky: 2, cooldown: 3, content: '地牢里不能点火。' }),
        entry(1, { comment: '伏兵', key: ['走廊'], delay: 6, content: '走廊尽头埋伏着两个守卫。' }),
    ]);
    const lines = [
        userMsg('我们从这道暗门下到地牢。'),
        charMsg('石阶又湿又滑,她举着荧光走在前面。'),
        userMsg('前面有条走廊。'),
        charMsg('走廊很长,尽头隐约有火光。'),
        userMsg('继续往前,沿着地牢的走廊走。'),
        charMsg('脚步声在石壁间回荡。'),
        userMsg('还在地牢里吗?'),
        charMsg('是的,四周仍是潮湿的石墙。'),
    ];
    await setup({ books: { '演示·定时': b }, globalBooks: ['演示·定时'], chat: [] });
    const steps = [];
    for (let i = 0; i < lines.length; i++) {
        L.S.chat.push(structuredClone(lines[i]));
        const r = await check({ dry: false });
        steps.push({ chatLength: L.S.chat.length, last: lines[i].mes, activated: names(r.result), timed: structuredClone(L.S.chat_metadata.timedWorldInfo ?? {}) });
    }
    // swipe:最后一条重新生成——拼装时这条被去掉,扫描的聊天没有变长
    const swipeChat = L.S.chat.slice(0, 1);
    await setup({ books: { '演示·定时': b }, globalBooks: ['演示·定时'], chat: swipeChat });
    const s1 = await check({ dry: false });
    const after1 = structuredClone(L.S.chat_metadata.timedWorldInfo ?? {});
    // 同样长度再扫一次(swipe / regenerate 时聊天没有前进)
    L.S.chat.push(charMsg('(被 swipe 掉的这条回复)'));
    L.S.chat.pop();
    const s2 = await check({ dry: false });
    save('F-timed', { steps, swipe: { first: { activated: names(s1.result), timed: after1 }, again: { activated: names(s2.result), timed: structuredClone(L.S.chat_metadata.timedWorldInfo ?? {}), trace: s2.trace.filter(l => /sticky|Sticky|cooldown|delay|timed|Timed/.test(l)) } } });
}

// ---------- G. 插到哪:几条蓝灯条目各放一个位置,整轮拼一次(Generate 的真实流程,dry run) ----------
{
    const b = book([
        entry(0, { comment: '角色定义前', constant: true, position: 0, order: 100, content: '【位置 0:角色定义前】' }),
        entry(1, { comment: '角色定义后', constant: true, position: 1, order: 100, content: '【位置 1:角色定义后】' }),
        entry(2, { comment: '作者注释顶', constant: true, position: 2, order: 100, content: '【位置 2:作者注释顶部】' }),
        entry(3, { comment: '@深度 0 user', constant: true, position: 4, depth: 0, role: 1, order: 100, content: '【位置 4:@深度 0,user】' }),
        entry(4, { comment: '@深度 2 system', constant: true, position: 4, depth: 2, role: 0, order: 100, content: '【位置 4:@深度 2,system】' }),
        entry(5, { comment: '示范对话前', constant: true, position: 5, order: 100, content: '<START>\n{{user}}: 【位置 5:示范对话前】\n{{char}}: 好。' }),
    ]);
    const chat5 = JSON.parse(fs.readFileSync('/data2/work/lht/study/26.06/_ext/st-lab/out/main/r2-chat.json')).chat;
    await setup({ books: { '演示·位置': b }, globalBooks: ['演示·位置'], chat: chat5, chatMetadata: { note_prompt: '(作者注释)塞拉菲娜说话要简短。' } });
    const g = await L.generate({ type: 'normal', dryRun: true });
    save('G-positions', { ledger: g.ledger.map(x => ({ identifier: x.identifier, role: x.role, content: x.content })), prompt: g.prompt, extensionPrompts: g.extensionPrompts });
}

process.stdout.write('done\n');
process.exit(0);
// 角色卡(第 1 条线)和每轮现拼(第 3 条线)的实验:全部调酒馆的函数,不调模型。
import fs from 'node:fs';
import path from 'node:path';
import seedrandom from 'seedrandom';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? String(x)).join(' ').slice(0, 1500) + '\n');
const L = await import('./lib.mjs');
const ST = process.env.ST_ROOT;
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/assembly';
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
const MAIN = '/data2/work/lht/study/26.06/_ext/st-lab/out/main/';
fs.mkdirSync(OUT, { recursive: true });
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const card = JSON.parse(fs.readFileSync(M + 'seraphina.zh.json'));
const eldoria = JSON.parse(fs.readFileSync(M + 'eldoria.zh.json'));
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };
const books = { '埃尔多利亚': eldoria };
const chatOf = (f) => JSON.parse(fs.readFileSync(MAIN + f)).chat;
const ledger = (g) => g.ledger.map(x => ({ identifier: x.identifier, role: x.role, tokens: x.tokens, content: x.content }));

// ---------- 构造的「完整版」卡:在译本上补齐各字段,一次看清每个字段落进哪个槽 ----------
const full = structuredClone(card);
const [traits, ...rest] = card.data.description.split('<START>');
const examplesBlock = '<START>' + rest.join('<START>');
const exampleOnly = examplesBlock.slice(0, examplesBlock.lastIndexOf('\r\n[类型'));
for (const o of [full, full.data]) {
    o.description = traits.trim();
    o.personality = '温柔、护短,总觉得是自己没把人照顾好。';
    o.scenario = '旅人在塞拉菲娜的林间空地里养伤,窗外的森林被影牙占据。';
    o.mes_example = exampleOnly.trim();
}
full.data.system_prompt = '{{original}}\n塞拉菲娜说话时常带一点歉意。';
full.data.post_history_instructions = '用第三人称描写{{char}}的动作,不替{{user}}说话或行动。';
full.data.extensions.depth_prompt = { prompt: '(夜里塞拉菲娜的魔法会变弱,她不愿让人知道。)', depth: 4, role: 'system' };
full.data.alternate_greetings = ['*你醒来时,屋里只有炉火的噼啪声。门口站着一个粉发的身影,背对着你望向黑暗的林子。* “醒了?别急着起身。”'];
fs.writeFileSync(`${OUT}/card-full.zh.json`, JSON.stringify(full, null, 2));

// ---------- 1. 卡是一张 PNG:用服务端的 character-card-parser.js 把中文卡写进随包头像,再读回来 ----------
{
    const parser = await import(path.join(ST, 'src/character-card-parser.js'));
    const { default: extract } = await import('png-chunks-extract');
    const { default: PNGtext } = await import('png-chunk-text');
    const png = fs.readFileSync(path.join(ST, 'default/content/default_Seraphina.png'));
    const out = parser.write(png, JSON.stringify(card));
    fs.writeFileSync(`${OUT}/seraphina.zh.png`, out);
    const chunks = extract(new Uint8Array(out)).map(c => {
        if (c.name !== 'tEXt') return { name: c.name, bytes: c.data.length };
        const t = PNGtext.decode(c.data);
        const json = JSON.parse(Buffer.from(t.text, 'base64').toString('utf8'));
        return { name: c.name, keyword: t.keyword, base64Chars: t.text.length, spec: json.spec, spec_version: json.spec_version, name_in_card: json.data?.name };
    });
    const back = JSON.parse(parser.read(out));
    save('card-png', { chunks, readBack: { spec: back.spec, name: back.data.name, fields: Object.keys(back.data) } });
}

// ---------- 2. 字段落进哪个槽:完整版卡,第二轮的真实聊天,dry run ----------
{
    await L.init({ card: full, books, name1: '旅人', oaiPatch: CONN });
    L.S.__set('chat', chatOf('r2-chat.json'));
    const g = await L.generate({ type: 'normal', dryRun: true });
    save('card-slots', { ledger: ledger(g), extensionPrompts: g.extensionPrompts });
}

// ---------- 3. 备选开场白:开场白和 alternate_greetings 一起成为第 0 条消息的候选 ----------
{
    await L.init({ card: full, books, name1: '旅人', oaiPatch: CONN });
    const m = L.S.chat[0];
    save('card-greetings', { swipe_id: m.swipe_id, swipes: m.swipes, mes: m.mes });
}

// ---------- 4. 用户人设:放在 prompt 里 vs 深度 2 ----------
{
    const persona = '{{user}}是二十出头的猎人,左腿被影牙咬伤,说话直来直去。';
    const out = {};
    for (const [tag, pos] of [['IN_PROMPT', 0], ['AT_DEPTH', 4]]) {
        await L.init({ card, books, name1: '旅人', persona, oaiPatch: CONN, powerUserPatch: { persona_description_position: pos, persona_description_depth: 2, persona_description_role: 0 } });
        L.S.__set('chat', chatOf('r2-chat.json'));
        const g = await L.generate({ type: 'normal', dryRun: true });
        out[tag] = ledger(g).map(x => ({ identifier: x.identifier, role: x.role, content: x.content.slice(0, 60) }));
    }
    save('persona', out);
}

// ---------- 5. 宏:{{random}} 每次重抽,{{pick}} 同一个聊天里不变;换个聊天再抽 ----------
{
    await L.init({ card, books, name1: '旅人', oaiPatch: CONN });
    Math.random = seedrandom('macro');
    const tpl = { random: '{{random::金发::红发::银发}}', pick: '{{pick::金发::红发::银发}}' };
    const out = { names: L.S.substituteParams('{{char}} 对 {{user}} 说话'), sameChat: {}, otherChat: {} };
    for (const k of Object.keys(tpl)) out.sameChat[k] = [1, 2, 3, 4].map(() => L.S.substituteParams(tpl[k]));
    for (const other of ['塞拉菲娜 - 第二局', '塞拉菲娜 - 第三局', '塞拉菲娜 - 第四局']) {
        L.S.characters[0].chat = other;
        L.S.__set('chat_metadata', {});
        out.otherChat[other] = {};
        for (const k of Object.keys(tpl)) out.otherChat[other][k] = [1, 2, 3].map(() => L.S.substituteParams(tpl[k]));
    }
    Math.random = seedrandom();
    save('macros', out);
}

// ---------- 6. 槽位的顺序和「放进聊天里」:加一条自定义提示词,先按顺序放,再改成 In-Chat 深度 1 ----------
{
    const out = {};
    for (const [tag, pos] of [['relative', 0], ['in-chat@1', 1]]) {
        await L.init({ card, books, name1: '旅人', oaiPatch: CONN });
        L.S.__set('chat', chatOf('r2-chat.json'));
        const s = L.OAI.oai_settings;
        s.prompts.push({ identifier: 'writing-rules', name: '写作规则', role: 'system', content: '每次回复不超过三段。', system_prompt: false, marker: false, injection_position: pos, injection_depth: 1, injection_order: 100 });
        const order = s.prompt_order.find(x => String(x.character_id) === '100001').order;
        order.splice(order.findIndex(x => x.identifier === 'main') + 1, 0, { identifier: 'writing-rules', enabled: true });
        const g = await L.generate({ type: 'normal', dryRun: true });
        out[tag] = ledger(g).map(x => ({ identifier: x.identifier, role: x.role, content: x.content.slice(0, 40) }));
    }
    save('order', { defaultOrder: L.OAI.oai_settings.prompt_order.find(x => String(x.character_id) === '100001').order.map(x => `${x.identifier}${x.enabled ? '' : '(关)'}`), runs: out });
}

// ---------- 7. 深度注入:同一深度几样东西怎么排;完整版卡 + 作者注释 + 世界书 @深度 + /inject + 人设 @深度 ----------
{
    const wiBook = structuredClone(eldoria);
    wiBook.entries['9'] = { ...structuredClone(eldoria.entries['0']), uid: 9, displayIndex: 9, comment: '天色', key: [], constant: true, position: 4, depth: 4, role: 0, content: '(世界书 @深度 4:天快黑了。)' };
    wiBook.entries['10'] = { ...structuredClone(eldoria.entries['0']), uid: 10, displayIndex: 10, comment: '旅人的念头', key: [], constant: true, position: 4, depth: 4, role: 1, content: '(世界书 @深度 4,user:我得早点上路。)' };
    await L.init({
        card: full, books: { '埃尔多利亚': wiBook }, name1: '旅人', oaiPatch: CONN,
        persona: '{{user}}是二十出头的猎人。', powerUserPatch: { persona_description_position: 4, persona_description_depth: 2, persona_description_role: 0 },
        chatMetadata: { note_prompt: '(作者注释:塞拉菲娜这几轮话少一些。)' },
    });
    L.S.__set('chat', chatOf('r5-chat.json'));
    // /inject id=伏笔 position=chat depth=4 role=assistant(slash-commands.js:3780-3846 最终也是调 setExtensionPrompt)
    const { generate } = L;
    const origSet = L.S.setExtensionPrompt;
    const g0 = await (async () => {
        L.S.setExtensionPrompt('script_inject_伏笔', '(/inject:窗外有什么东西在看着这间屋子。)', L.S.extension_prompt_types.IN_CHAT, 4, false, L.S.extension_prompt_roles.ASSISTANT);
        return generate({ type: 'normal', dryRun: true });
    })();
    save('injections', { ledger: ledger(g0).map(x => ({ identifier: x.identifier, role: x.role, content: x.content })), extensionPrompts: g0.extensionPrompts });
}

// ---------- 8. 预算:最后的真实聊天,出厂 4095 / 用户调成 2048;完整版卡带示范对话 ----------
{
    const out = {};
    for (const ctx of [4095, 2048, 1400]) {
        await L.init({ card: full, books, name1: '旅人', oaiPatch: { ...CONN, openai_max_context: ctx } });
        L.S.__set('chat', chatOf('r5-chat.json'));
        const g = await L.generate({ type: 'normal', dryRun: true });
        const lg = ledger(g);
        out[ctx] = {
            budget: ctx - L.OAI.oai_settings.openai_max_tokens,
            total: lg.reduce((a, x) => a + (x.tokens || 0), 0),
            messagesInContext: L.OAI.openai_messages_count,
            chatLength: L.S.chat.length,
            rows: lg.map(x => ({ identifier: x.identifier, role: x.role, tokens: x.tokens, head: x.content.slice(0, 24) })),
        };
    }
    save('budget', out);
}

process.stdout.write('done\n');
process.exit(0);
// 一份内容喂各种模型(第 4 条线):服务端各供应商的转换函数、后处理几档、continue 的两种写法、Text Completion 的 instruct 拼法。
import fs from 'node:fs';
import path from 'node:path';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? String(x)).join(' ').slice(0, 1500) + '\n');
const L = await import('./lib.mjs');
const ST = process.env.ST_ROOT;
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/adapt';
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
const MAIN = '/data2/work/lht/study/26.06/_ext/st-lab/out/main/';
fs.mkdirSync(OUT, { recursive: true });
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const card = JSON.parse(fs.readFileSync(M + 'seraphina.zh.json'));
const eldoria = JSON.parse(fs.readFileSync(M + 'eldoria.zh.json'));
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };
const PC = L.PC;
const brief = (msgs) => msgs.map(m => ({ role: m.role, ...(m.name ? { name: m.name } : {}), ...(m.prefix ? { prefix: m.prefix } : {}), content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }));

// 第二轮前端拼好的消息数组(发往服务端之前),加上服务端从请求里取的名字
const r2 = JSON.parse(fs.readFileSync(MAIN + 'r2.json'));
const msgs = r2.generate.prompt;
const names = PC.getPromptNames({ body: r2.generate.generate_data });

// ---------- 1. 后处理几档:同一份消息数组 ----------
{
    const out = { input: brief(msgs) };
    for (const t of ['', 'merge', 'semi', 'strict', 'single']) {
        out[t || 'none'] = brief(PC.postProcessPrompt(structuredClone(msgs), t, names));
    }
    save('postprocess', out);
}

// ---------- 2. Claude、Gemini 的转换函数(只跑转换,不发请求) ----------
{
    const claude = PC.convertClaudeMessages(structuredClone(msgs), '', true, false, names);
    const gemini = PC.convertGooglePrompt(structuredClone(msgs), 'gemini-2.5-pro', true, names);
    save('providers', { claude, gemini });
}

// ---------- 3. continue 的两种写法:出厂的「续写提示」vs 打开 continue_prefill ----------
{
    const r3chat = JSON.parse(fs.readFileSync(MAIN + 'r3-chat.json')).chat;
    const out = {};
    for (const [tag, prefill] of [['nudge(出厂)', false], ['continue_prefill', true]]) {
        await L.init({ card, books: { '埃尔多利亚': eldoria }, name1: '旅人', oaiPatch: { ...CONN, continue_prefill: prefill } });
        L.S.__set('chat', structuredClone(r3chat));
        const g = await L.generate({ type: 'continue', dryRun: true });
        out[tag] = { frontend: brief(g.prompt).slice(-3), deepseek: brief(g.deepseek.body.messages).slice(-3) };
    }
    save('continue', out);
}

// ---------- 4. 主线里实际发出的几种请求的末尾(真实请求体) ----------
{
    const tail = (f, n = 3) => brief(JSON.parse(fs.readFileSync(MAIN + f)).generate.deepseek.body.messages).slice(-n);
    save('tails', { 'r3-continue': tail('r3-continue.json'), 'r4-impersonate': tail('r4-impersonate.json'), 'r1': tail('r1.json', 3), r1Params: Object.fromEntries(Object.entries(JSON.parse(fs.readFileSync(MAIN + 'r1.json')).generate.deepseek.body).filter(([k]) => k !== 'messages')) });
}

// ---------- 5. Text Completion 一瞥:同一段聊天按 ChatML 模板拼成一条字符串 ----------
{
    await L.init({ card, books: { '埃尔多利亚': eldoria }, name1: '旅人', oaiPatch: CONN });
    const r2chat = JSON.parse(fs.readFileSync(MAIN + 'r2-chat.json')).chat;
    L.S.__set('chat', structuredClone(r2chat));
    L.S.__set('main_api', 'textgenerationwebui');
    const IM = await import(path.join(ST, 'public/scripts/instruct-mode.js'));
    const instruct = JSON.parse(fs.readFileSync(path.join(ST, 'default/content/presets/instruct/ChatML.json')));
    const context = JSON.parse(fs.readFileSync(path.join(ST, 'default/content/presets/context/ChatML.json')));
    Object.assign(L.PU.power_user.instruct, instruct, { enabled: true });
    Object.assign(L.PU.power_user.context, context);
    const f = L.S.getCharacterCardFields();
    const story = L.PU.renderStoryString({ description: f.description, personality: f.personality, scenario: f.scenario, system: L.PU.power_user.sysprompt?.content ? L.S.substituteParams(L.PU.power_user.sysprompt.content) : '', persona: '', char: L.S.name2, user: L.S.name1, wiBefore: '', wiAfter: '', loreBefore: '', loreAfter: '', anchorBefore: '', anchorAfter: '', mesExamples: '', mesExamplesRaw: '' });
    const storyWrapped = IM.formatInstructModeStoryString(story);
    const history = r2chat.map(m => IM.formatInstructModeChat(m.name, m.mes, m.is_user, false, '', L.S.name1, L.S.name2, false));
    const lastLine = IM.formatInstructModePrompt(L.S.name2, false, '', L.S.name1, L.S.name2, false, false);
    const stops = IM.getInstructStoppingSequences();
    save('textcompletion', { instructPreset: instruct, storyWrapped, history, lastLine, stops, joined: storyWrapped + history.join('') + lastLine });
    L.S.__set('main_api', 'openai');
}

process.stdout.write('done\n');
process.exit(0);
// 重来、分叉、改历史(第 5 条线):swipe 的数据形状、编辑、隐藏、开分支。分支用 bookmarks.js 的 createBranch 原样跑,
// 它调的 saveChat 是从 script.js 原样切出的;落盘那一步由 lib.mjs 里的 fetch 拦截按服务端写法写成 JSONL。
import fs from 'node:fs';
import path from 'node:path';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? String(x)).join(' ').slice(0, 1500) + '\n');
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/branch';
process.env.ST_DATA = OUT + '/data';
fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(OUT, { recursive: true });
const L = await import('./lib.mjs');
const ST = process.env.ST_ROOT;
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
const MAIN = '/data2/work/lht/study/26.06/_ext/st-lab/out/main/';
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const card = JSON.parse(fs.readFileSync(M + 'seraphina.zh.json'));
const eldoria = JSON.parse(fs.readFileSync(M + 'eldoria.zh.json'));
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };
const BM = await import(path.join(ST, 'public/scripts/bookmarks.js'));
const brief = (g) => g.ledger.filter(x => x.identifier.startsWith('chatHistory')).map(x => `${x.role}: ${x.content.slice(0, 30)}`);

// 主线最后的状态(摘要之后)
const final = JSON.parse(fs.readFileSync(MAIN + 'summary-chat.json'));
async function load() {
    await L.init({ card, books: { '埃尔多利亚': eldoria }, name1: '旅人', oaiPatch: CONN });
    L.S.__set('chat', structuredClone(final.chat));
    L.S.__set('chat_metadata', structuredClone(final.chat_metadata));
    // 打开聊天时没有 integrity 就补一个(script.js:7665-7667);再放一个本地变量,看分支会不会带走(构造:相当于 /setvar key=好感 35)
    L.S.chat_metadata.integrity ??= 'parent-integrity-uuid';
    L.S.chat_metadata.variables = { 好感: 35 };
}

// ---------- 1. swipe:第 4 条消息有两个候选,下一轮只带选中的那个 ----------
{
    const r3 = JSON.parse(fs.readFileSync(MAIN + 'r3.json'));
    const m = final.chat[4];
    const sent = r3.generate.prompt.map(x => x.content);
    save('swipe', {
        message4: { swipe_id: m.swipe_id, swipes: m.swipes.map(s => s.slice(0, 40)), swipe_info: m.swipe_info.map(i => ({ send_date: i.send_date, model: i.extra?.model })), mes: m.mes.slice(0, 40) },
        nextRoundHasSwipe0: sent.some(c => c === m.swipes[0]),
        nextRoundHasSwipe1: sent.some(c => c === m.swipes[1]),
    });
}

// ---------- 2. 编辑:改第 2 条(塞拉菲娜的回复)里的一句,updateMessage 原样跑 ----------
{
    await load();
    const mesId = 2;
    const before = L.S.chat[mesId].mes;
    const edited = before.replace('影牙……它们是这片森林里最危险的怪物。', '影牙……它们是这片森林里最危险的怪物,怕光。');
    // updateMessage 从界面元素上取编辑框文字和消息序号;给它一个只回答这两样的替身
    const div = { closest: (sel) => sel === '.mes_block' ? { find: () => ({ val: () => edited, text: () => before }) } : { attr: () => String(mesId) } };
    L.S.updateMessage(div);
    const m = L.S.chat[mesId];
    const g = await L.generate({ type: 'normal', dryRun: true });
    save('edit', { mes: m.mes.slice(0, 60), swipeSynced: m.swipes[m.swipe_id] === m.mes, tainted: L.S.chat_metadata.tainted, inPrompt: g.prompt.some(x => x.content.includes('怕光')) });
}

// ---------- 3. 开分支:存主聊天,再从第 4 条开分支,并选它被放弃的那个候选(swipe 0) ----------
{
    await load();
    await L.S.saveChat({});                                   // 主聊天落盘
    const name = await BM.createBranch(4, { swipeId: 0 });   // bookmarks.js:187-245
    const dir = path.join(OUT, 'data/chats/seraphina');
    const read = (f) => fs.readFileSync(path.join(dir, f + '.jsonl'), 'utf8').split('\n').map(l => JSON.parse(l));
    const parent = read(L.S.characters[0].chat);
    const branch = read(name);
    const head = branch[0];
    save('branch', {
        files: fs.readdirSync(dir),
        branchName: name,
        parentLines: parent.length, branchLines: branch.length,
        branchHeader: head,
        branchMessage4: branch[5].mes.slice(0, 60),
        parentMessage4: parent[5].mes.slice(0, 60),
        parentBranchesField: L.S.chat[4].extra.branches,
    });
    fs.copyFileSync(path.join(dir, name + '.jsonl'), path.join(OUT, 'branch.jsonl'));
}

process.stdout.write('done\n');
process.exit(0);
// 群聊(第 7 条线)里不调模型的部分:发言调度函数(group-chats.js 原样切出)和 Join 模式的拼卡。随机数用带种子的 seedrandom。
import fs from 'node:fs';
import seedrandom from 'seedrandom';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? String(x)).join(' ').slice(0, 1500) + '\n');
const L = await import('./lib.mjs');
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/group';
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
fs.mkdirSync(OUT, { recursive: true });
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const sera = { ...JSON.parse(fs.readFileSync(M + 'seraphina.zh.json')), avatar: 'seraphina.png' };
const lorne = { ...JSON.parse(fs.readFileSync(M + 'lorne.constructed.json')), avatar: 'lorne.png' };
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };
const group = { id: '1', name: '林间空地', members: ['seraphina.png', 'lorne.png'], activation_strategy: 0, generation_mode: 0, disabled_members: [], allow_self_responses: false, chat_id: '林间空地 - 实跑', chats: ['林间空地 - 实跑'] };
await L.init({ card: sera, books: {}, name1: '旅人', oaiPatch: CONN });

function runNatural(cards, input, lastMessage, isUserInput, seeds = 6) {
    L.setGroup(cards, group);
    const out = [];
    for (let s = 1; s <= seeds; s++) {
        Math.random = seedrandom('group-' + s);
        const ids = L.G.activateNaturalOrder(group.members, input, lastMessage, false, isUserInput);
        out.push(ids.map(i => L.S.characters[i].name));
    }
    Math.random = seedrandom();
    return out;
}
const en = (c, name) => ({ ...structuredClone(c), name });
const mute = (c) => ({ ...structuredClone(c), talkativeness: '0' });
const runs = {
    '中文名点名:「洛恩,你怎么看?」': runNatural([sera, lorne], '洛恩,你怎么看?', null, true),
    '英文名点名:「Lorne, what do you think?」(构造:两人改用英文名)': runNatural([en(sera, 'Seraphina'), en(lorne, 'Lorne')], 'Lorne, what do you think?', null, true),
    '没点名:「你们两位认识很久了吗?」': runNatural([sera, lorne], '你们两位认识很久了吗?', null, true),
    '洛恩话痨度改成 0、也没点名(构造)': runNatural([sera, mute(lorne)], '你们两位认识很久了吗?', null, true),
    '自动模式、上一条是洛恩说的': runNatural([sera, lorne], '', { name: '洛恩', is_user: false, mes: '哼。' }, false),
};
save('scheduler', { talkativeness: { 塞拉菲娜: sera.talkativeness, 洛恩: lorne.talkativeness }, runs });

// Join 模式:几个成员的描述拼成一份(getGroupCharacterCardsLazy),dry run 看 charDescription 槽
{
    const out = {};
    for (const [tag, mode, prefix] of [['swap(出厂)', 0, ''], ['join', 1, ''], ['join + 前缀(构造)', 1, '【{{char}}】']]) {
        await L.init({ card: sera, books: {}, name1: '旅人', oaiPatch: CONN });
        L.setGroup([sera, lorne], { ...group, generation_mode: mode, generation_mode_join_prefix: prefix, generation_mode_join_suffix: '' });
        L.setCharacter(1);
        L.S.__set('chat', [L.S.chat[0] ?? { name: '塞拉菲娜', is_user: false, mes: '……', extra: {} }]);
        const g = await L.generate({ type: 'normal', dryRun: true });
        out[tag] = g.ledger.filter(x => ['charDescription', 'main', 'newMainChat'].includes(x.identifier) || x.identifier.startsWith('groupNudge') || x.identifier === 'chatHistory-1').map(x => ({ identifier: x.identifier, content: x.content }));
    }
    save('join', out);
}
process.stdout.write('done\n');
process.exit(0);
// 用户自己加规则(第 8 条线):正则把显示、存储、发给模型拆开;变量存在聊天文件头;事件总线可以原地改 prompt。
import fs from 'node:fs';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? String(x)).join(' ').slice(0, 1500) + '\n');
const L = await import('./lib.mjs');
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/rules';
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
const MAIN = '/data2/work/lht/study/26.06/_ext/st-lab/out/main/';
fs.mkdirSync(OUT, { recursive: true });
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const card = JSON.parse(fs.readFileSync(M + 'seraphina.zh.json'));
const eldoria = JSON.parse(fs.readFileSync(M + 'eldoria.zh.json'));
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };
const init = () => L.init({ card, books: { '埃尔多利亚': eldoria }, name1: '旅人', oaiPatch: CONN });

// ---------- 1. 正则:状态栏只给人看渲染后的样子;发给模型时只留最新一条的状态栏 ----------
{
    await init();
    // 构造:前两轮的回复末尾各带一个状态栏(相当于卡要求模型每条输出一段状态)
    const chat = JSON.parse(fs.readFileSync(MAIN + 'r2-chat.json')).chat.slice(0, 5);
    const bars = { 0: '好感:20|伤势:昏迷刚醒', 2: '好感:30|伤势:左腿未愈', 4: '好感:35|伤势:左腿未愈' };
    for (const [i, bar] of Object.entries(bars)) {
        chat[i].mes += `\n<状态>${bar}</状态>`;
        if (Array.isArray(chat[i].swipes)) chat[i].swipes[chat[i].swipe_id ?? 0] = chat[i].mes;
    }
    L.S.__set('chat', chat);
    // 脚本字段照 regex 扩展新建脚本的默认值(regex/index.js),只改名字、正则、替换串和三个开关
    const base = { disabled: false, trimStrings: [], placement: [2], substituteRegex: 0, runOnEdit: true, minDepth: null, maxDepth: null, markdownOnly: false, promptOnly: false };
    L.EXT.extension_settings.regex = [
        { ...base, id: 'r1', scriptName: '状态栏渲染', findRegex: '/<状态>([\\s\\S]*?)<\\/状态>/g', replaceString: '<div class="status">$1</div>', markdownOnly: true },
        { ...base, id: 'r2', scriptName: '旧状态栏不发给模型', findRegex: '/\\n?<状态>[\\s\\S]*?<\\/状态>/g', replaceString: '', promptOnly: true, minDepth: 1 },
    ];
    const n = chat.length;
    // 显示:messageFormatting 里按「倒数第几条」算 depth、isMarkdown(script.js:1851-1860)
    const display = Object.keys(bars).map(i => ({ index: Number(i), depth: n - 1 - Number(i), shown: L.RX.getRegexedString(chat[i].mes, L.RX.regex_placement.AI_OUTPUT, { isMarkdown: true, depth: n - 1 - Number(i) }).slice(-40) }));
    const stored = Object.keys(bars).map(i => ({ index: Number(i), stored: chat[i].mes.slice(-30) }));
    const g = await L.generate({ type: 'normal', dryRun: true });
    const sent = g.prompt.filter(m => m.role === 'assistant').map(m => m.content.slice(-40));
    save('regex', { scripts: L.EXT.extension_settings.regex.map(({ scriptName, findRegex, replaceString, markdownOnly, promptOnly, minDepth }) => ({ scriptName, findRegex, replaceString, markdownOnly, promptOnly, minDepth })), stored, display, sentToModel: sent });
}

// ---------- 2. 变量:宏读写,存进 chat_metadata.variables ----------
{
    await init();
    const a = L.S.substituteParams('{{setvar::好感::35}}好感是 {{getvar::好感}}');
    const b = L.S.substituteParams('{{addvar::好感::5}}现在是 {{getvar::好感}}');
    save('variables', { first: a, second: b, chat_metadata_variables: L.S.chat_metadata.variables });
}

// ---------- 3. 事件总线:监听 CHAT_COMPLETION_PROMPT_READY,原地往消息数组里塞一条 ----------
{
    await init();
    L.S.__set('chat', JSON.parse(fs.readFileSync(MAIN + 'r2-chat.json')).chat);
    const handler = (data) => {
        data.chat.splice(data.chat.length - 1, 0, { role: 'system', content: '(扩展插入:外面下起了雨。)' });
    };
    L.S.eventSource.on(L.S.event_types.CHAT_COMPLETION_PROMPT_READY, handler);
    const g = await L.generate({ type: 'normal', dryRun: true });
    L.S.eventSource.removeListener(L.S.event_types.CHAT_COMPLETION_PROMPT_READY, handler);
    save('event', { tail: g.deepseek.body.messages.slice(-3).map(m => ({ role: m.role, content: m.content.slice(0, 50) })) });
}

process.stdout.write('done\n');
process.exit(0);

其余文件(shims.mjs、server.mjs、手写的桩、translate.py、pack.py、run_all.sh)在 assets/sim/harness/ 里,不展开。