梨园拆解的维护记录
这是梨园 Liyuan背后的事。正常阅读不用看,要对照上游更新、核对出处、重跑实验时再看。
版本和素材
- 上游:weidu12123/Liyuan,PolyForm Noncommercial 1.0.0 许可,许可全文随源码存档
- 读的版本:
1e9448e(2026-10-16 提交),2026-10-19 读。各页不逐处标版本,上游更新了再对照这一个点 - 源码存档:
assets/liyuan-src/是这个版本里细讲页引到的那部分文件,逐字未改;extensions/roleplay.ts在原仓库的路径是.liyuan/extensions/roleplay.ts,存档避开了点开头的目录 - 行号:存档里有的文件按存档算;没存档的(
server/、docs/、src/memory/、src/draft.ts、src/card-authoring.ts、src/asset-vault.ts等)是原仓库根下这个版本的行号 - 截图:主界面、决策卡、登场名录、角色卡工坊四张取自原仓库的
docs/images/;决策卡那张是早期界面 - 配图:11 张信息图是本站生成的,用了哪些排版和主题色见配图的维护记录
实验设置
细讲页里标着「效果」的块,来自 2026-10-20 的一次实跑,材料在 assets/sim/:
- 拼接是真的:system、末端注入、关键词扫描、世界书检索、场记 prompt、patch 合并、压缩和复盘的 prompt、预设声明、沙箱判定、检查点,全部直接调梨园源码里的函数,输出原样保存。唯一的例外是收尾合并
mergeFinalText:engine.ts整个导入要拉起 MCP 等依赖,就把它和它用到的几个函数原样抠出来单独跑 - 模型是真的:主演用
deepseek-v4-pro、开思考;场记、压缩、复盘、合并、预设声明用deepseek-flash、关思考,经本站的 agent-io 调用;梨园的 33 个工具说明原样发给模型 - 简化的地方:工具回执是简版;
ask弹出后模拟成用户选第一项;agent 轮的四个文件工具用的是简写的说明,不是 pi 的原文;构造的输入(越界 patch、格式块、手写的另一局复盘、演示预设)在页面上都注明了 - 素材:随包的默认卡「青梧」,加一本自写的六条演示世界书「栖水镇」
- 跑了什么:扮演三轮(打听人、要选项、改上一轮),第一轮重新生成一次;压缩一次;跨局记忆复盘、合并、删一局再合并;预设声明一次;agent 模式改两章。主演共调模型 16 次,旁路 8 次
- 用量:扮演的 13 次主演调用共约 10.6 万输入 token,其中 10.2 万命中前缀缓存,输出约 6700 token;旁路和 agent 轮没记用量
- 收笔即停:第一次跑时脚本在收笔后没停,又调了几次模型;梨园在这一步就结束本轮,记录按这条规则裁掉了多出来的调用,脚本也已改正
- 隔离:梨园的代码只在一个没有 API key 的 Node 进程里跑,模型请求由另一个脚本发
实跑脚本
lab.mjs:只调梨园源码里的函数,不发网络请求
// 只调梨园源码里的函数(钉 1e9448e),不发网络请求、进程里没有 API key。
// 用法:node lab.mjs <命令> < 输入.json > 输出.json
import { readFileSync } from "node:fs";
const R = process.env.LIYUAN_REPO, H = process.env.LAB_HOME;
const imp = (p) => import(`file://${R}/${p}`);
const { loadCardFile } = await imp("src/card.ts");
const { normalizeEntries, scanEntries, searchEntries, constantEntries } = await imp("src/lorebook.ts");
const { buildStageSystemPrompt, buildStageInjection, formatLoreIndex, rebuildHistory, stateFromBranch } = await imp("src/stage/assemble.ts");
const { defaultState, applyPatch, formatState, formatRosterIndex } = await imp("src/state.ts");
const { buildScribeTurnPrompt, parseScribeResult, buildRpSummaryPrompt } = await imp("src/scribe.ts");
const { stageTools, writeTools } = await imp("src/stage/tools.ts");
const { serializeForSummary } = await imp("src/stage/compact.ts");
const { buildRecapPrompt, buildConsolidatePrompt, parseConsolidateResult, fitResidentSummary } = await imp("src/card-memory.ts");
const { loadPresetDoc } = await imp("src/preset-doc.ts");
const { assembleForDeclare, declarePieces, buildDeclarePrompt, parseDeclareResponse, translatePresetWithDeclaration } = await imp("src/preset-declare.ts");
const { renderForModel } = await imp("src/prompt-entries.ts");
const { sandboxVerdict } = await imp("src/sandbox.ts");
const { parseDenylist } = await imp("src/bash-denylist.ts");
const { checkWriteGate } = await imp("src/tools/gate.ts");
const { agentSystemPrompt, buildAgentStateBlock } = await imp("src/stage/agent.ts");
const { StoryHistory, listStoryFiles } = await imp("src/stage/story-history.ts");
const { DEFAULT_CONFIG } = await imp("src/types.ts");
const { planNewSave } = await imp("src/worldline.ts");
const { mergeFinalText } = await import(`file://${H}/tail-merge.ts`);
const input = JSON.parse(readFileSync(0, "utf8") || "{}");
const card = loadCardFile(`${R}/assets/cards/default_Qingwu.json`);
const config = { ...DEFAULT_CONFIG };
const lore = normalizeEntries(JSON.parse(readFileSync(`${H}/demo-lorebook.json`, "utf8")).entries);
const st = (s) => s ?? defaultState();
let out = {};
switch (process.argv[2]) {
case "system": {
const liyuan = buildStageSystemPrompt({ card, config, constantLore: constantEntries(lore),
userRules: { global: readFileSync(`${R}/assets/APPEND_SYSTEM.md`, "utf8") }, tools: true });
const base = `${readFileSync(`${R}/assets/SYSTEM.md`, "utf8")}\nCurrent working directory: ${input.cwd}\n`;
const all = [...writeTools(config.language), ...stageTools(config.language)];
out = { system: `${base}\n\n${liyuan}`, base, liyuan,
greeting: `【开场 · ${card.name}】\n${card.firstMes.replaceAll("{{user}}", config.userName).replaceAll("{{char}}", card.name)}`,
tools: all.map(({ name, description, parameters }) => ({ name, description, parameters })) };
break; }
case "inject": {
const activated = scanEntries(lore, input.windowText, config.maxLoreInjections);
out = { injection: buildStageInjection({ state: st(input.state), activatedLore: activated, card, config, passiveLore: !!input.passiveLore,
loreIndex: formatLoreIndex(lore), rosterIndex: formatRosterIndex(st(input.state)) }), activated: activated.map((e) => e.comment) };
break; }
case "search": out = { hits: searchEntries(lore, input.query, 3).map((h) => ({ title: h.entry.comment, score: h.score, content: h.entry.content })) }; break;
case "state": out = { formatted: formatState(st(input.state)), roster: formatRosterIndex(st(input.state)) }; break;
case "scribe-prompt": out = buildScribeTurnPrompt({ state: st(input.state), userText: input.userText, assistantText: input.assistantText, charName: card.name, userName: config.userName }); break;
case "patch": { // raw(模型原文)或 patch(直接给)
const parsed = input.raw !== undefined ? parseScribeResult(input.raw) : { patch: input.patch };
const r = parsed ? applyPatch(st(input.state), parsed.patch) : null;
out = { parsed, applied: r?.applied, warnings: r?.warnings, state: r?.state ?? st(input.state), formatted: formatState(r?.state ?? st(input.state)), roster: formatRosterIndex(r?.state ?? st(input.state)) };
break; }
case "gate": out = { verdicts: input.cases.map((c) => ({ ...c, verdict: checkWriteGate({ ...c, creationMode: "ask" }) })) }; break;
case "tail": out = { merged: mergeFinalText(input.draft, input.text) }; break;
case "branch": out = { branches: input.branches.map((b) => {
const { history } = rebuildHistory(b); const s = stateFromBranch(b);
return { history, state: formatState(s) }; }) }; break;
case "summary-prompt": {
const conversationText = serializeForSummary(input.entries, config.userName, card.name);
out = { conversationText, ...buildRpSummaryPrompt({ conversationText, stateSnapshot: JSON.stringify(st(input.state), null, 2), language: config.language, userName: config.userName }) };
break; }
case "recap-prompt": out = buildRecapPrompt({ evidence: input.evidence, chatName: input.chatName, language: config.language, userName: config.userName, charName: card.name }); break;
case "consolidate-prompt": out = buildConsolidatePrompt({ diff: input.diff, handbook: input.handbook, resident: input.resident, language: config.language, userName: config.userName, charName: card.name }); break;
case "consolidate-parse": out = parseConsolidateResult(input.raw) ?? { error: "解析失败" }; break;
case "resident-fit": out = { fitted: fitResidentSummary(input.text, input.otherChars) ?? null }; break;
case "declare-prompt": {
const doc = loadPresetDoc(JSON.parse(readFileSync(`${H}/sample-preset.json`, "utf8")), "演示预设");
const pieces = declarePieces(assembleForDeclare(doc, { charName: card.name, userName: config.userName }));
out = { pieces: pieces.map((p) => ({ id: p.id, name: p.name, role: p.role, where: p.where })), ...buildDeclarePrompt(pieces, { preset: "演示预设" }) };
break; }
case "declare-apply": {
const doc = loadPresetDoc(JSON.parse(readFileSync(`${H}/sample-preset.json`, "utf8")), "演示预设");
const pieces = declarePieces(assembleForDeclare(doc, { charName: card.name, userName: config.userName }));
const parsed = parseDeclareResponse(input.raw, pieces);
const declaration = { version: 1, preset: "演示预设", createdAt: "2026-10-20", model: input.model, entries: parsed.entries };
const t = translatePresetWithDeclaration(doc, declaration, { charName: card.name, userName: config.userName });
out = { entries: parsed.entries.map(({ identifier, name, station }) => ({ identifier, name, station })), errors: parsed.errors,
appendMarkdown: t.appendMarkdown, agentsSection: t.agentsSection };
break; }
case "render": out = { rendered: renderForModel(readFileSync(`${H}/sample-agents.md`, "utf8")) }; break;
case "sandbox": {
const W = `${H}/ws`;
const scope = { cwd: W, cardDir: `${W}/cards/青梧`, chatDir: `${W}/cards/青梧/对话/局A`, roots: [`${W}/cards/青梧`, `${W}/cards/青梧/创作`],
readRoots: [`${W}/assets/lorebooks`, `${W}/assets/presets`, `${W}/skills`], denyRules: parseDenylist(readFileSync(`${R}/assets/BASH_DENYLIST.txt`, "utf8")) };
const grants = { dirs: [], bashAllow: ["ls", "cat"] };
out = { cases: input.cases.map((c) => ({ ...c, verdict: sandboxVerdict(c.tool, c.input, scope, grants) })) };
break; }
case "agent-system": {
const macro = { charName: card.name, userName: config.userName };
out = { system: agentSystemPrompt({ cwd: input.cwd, cardPath: input.cardPath, storyDir: input.storyDir, macro }),
stateBlock: buildAgentStateBlock({ state: st(input.state), rosterIndex: formatRosterIndex(st(input.state)), files: listStoryFiles(input.storyAbs) }) };
break; }
case "worldline": {
// 树:g→u1→a1(存档「雨夜问名」)→u2→a2(主线存档);回到 a1 换个走向:a1→u2b→a2b
const parent = { u1: "g", a1: "u1", u2: "a1", a2: "u2", u2b: "a1", a2b: "u2b" };
const anc = (id) => { const s = new Set(); for (let x = id; x; x = parent[x]) s.add(x); return s; };
const S1 = { id: "s1", name: "雨夜问名", entryId: "a1", worldlineId: "w-main", worldlineName: "主线", createdAt: 1 };
const S2 = { id: "s2", name: "直问生死", entryId: "a2", worldlineId: "w-main", worldlineName: "主线", createdAt: 2 };
const plan = (leaf, prev) => planNewSave({ name: input.names[leaf], prevOnBranch: prev, branchEntryIds: anc(leaf), allSaves: [S1, S2], ancestorsOf: anc, now: 3 });
out = { sameLine: plan("a2", S1), fork: plan("a2b", S1), first: planNewSave({ name: "开场", prevOnBranch: null, branchEntryIds: anc("g"), allSaves: [], ancestorsOf: anc, now: 0 }) };
break; }
case "story-commit": {
const h = new StoryHistory(input.chatDir);
out = { checkpoint: h.commit({ author: input.author, message: input.message }) ?? null };
break; }
}
process.stdout.write(JSON.stringify(out));
tail-merge.ts:从 engine.ts 原样抠出的收尾合并
import { classifyTag, scanTaggedBlocks } from "file:///tmp/liyuan-src/Liyuan/src/postprocess.ts";
// 原样摘自 src/stage/engine.ts:461-599(钉 1e9448e),引擎文件依赖未安装的包,不能整体导入
const TAG_NAME_SRC = "[A-Za-z_\\u4e00-\\u9fff][\\w\\u4e00-\\u9fff.\\-]*";
const FENCE_LINE_RE = /^```/m;
const FENCE_BLOCK_RE = /```[\s\S]*?```/g;
/** 逐个扫标签名,用于跳过 fold/strip 类(它们不是格式内容) */
const TAG_SCAN_RE = new RegExp(`<(${TAG_NAME_SRC})(?:\\s[^>]*)?\\/?\\s*>`, "g");
/** 成对块,用于把 fold/strip 类整块从尾巴里剔掉 */
const SELF_CLOSING_RE = new RegExp(`<(${TAG_NAME_SRC})(?:\\s[^>]*)?\\/\\s*>`, "g");
/**
* 尾巴里格式内容的起点(第一个尖括号标签或行首 ``` 围栏);没有 → -1。
*
* 8/10 实弹收口:旧口径整串检验、整串拼接——元话语(收笔自检逐条、ask 开场白
* 起了又劝退)挂在格式块前面时跟着一起进定稿(HK 5 会话 11 拍:7 个裸尾巴段
* 里 3 个带元话语,41 个稿段 0 违约)。改为**只取格式内容**:从第一个标签/围栏
* 起切,之前的自由文本一律丢弃;纯自由文本尾巴(闲聊收笔)仍整段不进正文。
*/
export const formatTailStart = (tail: string): number => {
TAG_SCAN_RE.lastIndex = 0;
let tag = -1;
for (let m = TAG_SCAN_RE.exec(tail); m; m = TAG_SCAN_RE.exec(tail)) {
if (classifyTag(m[1]!) === "unwrap") {
tag = m.index;
break;
}
}
const fence = FENCE_LINE_RE.exec(tail)?.index ?? -1;
if (tag < 0) return fence;
return fence < 0 ? tag : Math.min(tag, fence);
};
/**
* 尾巴 → **只留格式内容**:格式类标签块 + ``` 围栏块,按原序拼回;块之外的自由文本
* 一律丢弃。
*
* 8/10 只挡了「格式块之前」的自由文本(起点切一刀)。8/16 实弹暴露另一半:模型在
* 尾巴里把**整段正文重述了一遍**,夹在 `<time_format>` 与 `<options>` 之间——起点切
* 不到它(它在第一个格式块之后),`trimContentBodyRepeat` 也管不到(它只认
* `<content>` 包裹的重述)。于是定稿里正文出现两遍。
*
* 判据仍是「块 vs 非块」,不认名字:块=成对标签(policy 由 classifyTag 定,fold/strip
* 类不算格式内容)或 ``` 围栏。
*/
const formatContentOnly = (tail: string): string => {
type Span = { start: number; end: number; text: string };
const spans: Span[] = [];
// 先剥 HTML 注释再扫块(同 extractDraftBody):注释里的 `<Prism>` 这类会被当成无闭合
// 标签,一路吃到文末,把注释后面的裸重述正文整段包进「格式块」(8/16 实弹踩到)。
const src = tail.replace(/<!--[\s\S]*?-->/g, "");
FENCE_BLOCK_RE.lastIndex = 0;
for (let m = FENCE_BLOCK_RE.exec(src); m; m = FENCE_BLOCK_RE.exec(src)) {
spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
}
for (const b of scanTaggedBlocks(src)) {
if (b.policy !== "unwrap") continue; // fold/strip 类不是格式内容
if (b.hanging) continue; // 无闭合=不是成形的格式块,别拿它当筐把正文装进来
spans.push({ start: b.start, end: b.end, text: b.raw });
}
// 自闭合格式标签(`<StatusPlaceHolderImpl/>` 这类占位符)——scanTaggedBlocks 只找成对块
SELF_CLOSING_RE.lastIndex = 0;
for (let m = SELF_CLOSING_RE.exec(src); m; m = SELF_CLOSING_RE.exec(src)) {
if (classifyTag(m[1]!) !== "unwrap") continue;
spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
}
spans.sort((a, b) => a.start - b.start || b.end - a.end);
const kept: Span[] = [];
for (const s of spans) {
const last = kept[kept.length - 1];
if (last && s.start < last.end) continue; // 被前一块覆盖(嵌套/重叠)
kept.push(s);
}
return kept
.map((s) => s.text.trim())
.filter(Boolean)
.join("\n\n");
};
/**
* 逐字相同的格式块去重(8/10 实弹:预设状态栏规则+谢幕注入双指令源下,
* 模型把同一份状态栏在一条尾巴里输出了两遍)。零名单零识别——块=任意
* `<Tag>…</Tag>`,只删与已见块**完全相同**的重复,内容有任何差异都不动。
*/
export const dedupeIdenticalBlocks = (s: string): string => {
const seen = new Set<string>();
return s
.replace(/<([A-Za-z][\w-]*)>[\s\S]*?<\/\1>/g, (block) => {
const key = block.trim();
if (seen.has(key)) return "";
seen.add(key);
return block;
})
.replace(/\n{3,}/g, "\n\n")
.trim();
};
/**
* 尾巴里 `<content>` 块以正文结尾文字开头(模型按卡格式在 `<content>` 里重述正文)时,
* 裁掉重复前缀——正文以稿件为准,定稿不得出现两遍。
* (8/13 实弹:B1 的 `<content>` 以正文末段开头 + 新内容;B2 的 `<content>` 整段重述正文。)
*/
const trimContentBodyRepeat = (body: string, inner: string): string => {
const d = body.trim();
const i0 = inner.trimStart();
if (!d || !i0) return inner;
for (let n = Math.min(d.length, i0.length); n > 0; n--) {
const suffix = d.slice(d.length - n);
if (i0.startsWith(suffix)) {
const rest = i0.slice(n).trim();
return rest ? `\n${rest}` : "";
}
}
return inner;
};
export const mergeFinalText = (draft: string, text: string): string => {
const d = draft.trim();
const t = text.trim();
if (!d) return dedupeIdenticalBlocks(t);
if (!t || d === t) return d;
// 稿件已包含 text(模型边写边交,text 是半截):稿件已是全量
if (d.includes(t)) return d;
// 只认「稿件在尾巴开头」的续写增量(正文在前、尾巴在后);稿件出现在尾巴中段
// 不切——格式块(state1/options 等)在正文之前,indexOf 会把它当「增量起点」把
// 前面的格式块一起切掉(8/13 实弹:<state1>…<content>正文</content>,状态栏全丢)。
let tail = t;
if (t.startsWith(d)) tail = t.slice(d.length);
const from = formatTailStart(tail);
if (from < 0) return d;
// 尾巴只留格式内容(块之外的自由文本/重述正文一律丢);`<content>` 里重述的正文再裁一次
const tailPart = formatContentOnly(tail.slice(from))
.replace(/<content>([\s\S]*?)<\/content>/g, (whole, inner: string) => {
const trimmed = trimContentBodyRepeat(d, inner);
return trimmed ? `<content>${trimmed}</content>` : "";
})
.replace(/\n{3,}/g, "\n\n")
.trim();
return dedupeIdenticalBlocks([d, tailPart].filter(Boolean).join("\n\n"));
};
run2.py:主控,模型调用走 agent-io 的 DeepSeek 中转
"""实跑:拼接与规则全部调梨园源码(lab.mjs,进程里没有 API key);模型调用走 agent-io 的 DeepSeek 中转。"""
import json, os, re, subprocess, sys, shutil
API = "https://deepseek-proxy.liuhetian.work/chat/completions"
KEY = os.environ["MY_API_KEY"]
H = "/tmp/liyuan-sim/v2"; OUT = f"{H}/out"; os.makedirs(OUT, exist_ok=True)
MAIN, SIDE = "deepseek-v4-pro", "deepseek-flash"
NODE_ENV = {k: v for k, v in os.environ.items() if k != "MY_API_KEY"} | {"LIYUAN_REPO": "/tmp/liyuan-src/Liyuan", "LAB_HOME": H}
def lab(cmd, payload=None):
r = subprocess.run(["node", f"{H}/lab.mjs", cmd], input=json.dumps(payload or {}), capture_output=True, text=True, env=NODE_ENV)
if r.returncode: sys.exit(f"lab {cmd} 失败:{r.stderr[-500:]}")
return json.loads(r.stdout)
def chat(body):
p = f"{OUT}/_req.json"; json.dump(body, open(p, "w"), ensure_ascii=False)
for _ in range(3):
r = subprocess.run(["curl", "-sS", "--max-time", "400", API, "-H", f"Authorization: Bearer {KEY}", "-H", "Content-Type: application/json", "-d", f"@{p}"], capture_output=True, text=True)
try:
d = json.loads(r.stdout)
if "choices" in d: return d
except Exception: pass
sys.exit(f"API 出错:{r.stdout[:300]}")
def side(system, user, max_tokens=4096):
d = chat({"model": SIDE, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "max_tokens": max_tokens, "thinking": {"type": "disabled"}})
return d["choices"][0]["message"]["content"], d.get("usage")
def save(name, obj): json.dump(obj, open(f"{OUT}/{name}.json", "w"), ensure_ascii=False, indent=1)
sysd = lab("system", {"cwd": "/home/user/liyuan"})
save("system", {k: sysd[k] for k in ("system", "base", "liyuan", "greeting")} | {"toolNames": [t["name"] for t in sysd["tools"]], "toolChars": len(json.dumps(sysd["tools"], ensure_ascii=False))})
TOOLS = [{"type": "function", "function": t} for t in sysd["tools"]]
def run_round(tag, history, state, user_text, prev=None, pick=0):
"""跑一轮扮演。history 是之前各轮(只有原话和定稿),prev 是上一轮定稿(供 previous_draft_*)。"""
window = "\n".join(m["content"] for m in history[-3:]) + "\n" + user_text
inj = lab("inject", {"state": state, "windowText": window})
msgs = [{"role": "system", "content": sysd["system"]}, *history, {"role": "user", "content": f"{inj['injection']}\n\n{user_text}"}]
rec = {"user": user_text, "injection": inj["injection"], "activated": inj["activated"], "messages_sent": [{"role": m["role"], "content": m["content"] if m["role"] != "system" else "(system,见 system.json)"} for m in msgs], "steps": []}
draft, ver, sealed, pv = "", 0, False, 0
for rnd in range(1, 21):
d = chat({"model": MAIN, "messages": msgs, "tools": TOOLS, "thinking": {"type": "enabled"}, "reasoning_effort": "high"})
m = d["choices"][0]["message"]
step = {"round": rnd, "reasoning": m.get("reasoning_content"), "content": m.get("content"), "calls": [], "usage": d.get("usage")}
rec["steps"].append(step)
msgs.append({k: m[k] for k in ("role", "content", "reasoning_content", "tool_calls") if m.get(k) is not None})
if not m.get("tool_calls"):
if not draft and (m.get("content") or "").strip() and prev is None:
draft, sealed = m["content"].strip(), True; step["note"] = "直出:代收为 draft_write 并收笔"
break
for c in m["tool_calls"]:
n, a = c["function"]["name"], json.loads(c["function"]["arguments"] or "{}")
if n == "draft_write" and not sealed: draft = a["content"]; ver += 1; res = {"ok": True, "version": ver, "chars": len(draft)}
elif n == "draft_append" and not sealed: draft += a.get("separator", "\n\n") + a["content"]; ver += 1; res = {"ok": True, "version": ver, "chars": len(draft)}
elif n == "draft_edit" and not sealed:
for e in a["edits"]: draft = draft.replace(e["old"], e["new"], 1)
ver += 1; res = {"ok": True, "version": ver, "chars": len(draft)}
elif n in ("draft_write", "draft_append", "draft_edit"): res = {"ok": False, "error": "本轮已收笔,不能再改"}
elif n == "draft_read": res = {"version": ver, "content": draft}
elif n == "draft_seal": sealed = True; res = {"ok": True, "sealed": True, "version": ver}
elif n == "previous_draft_read": res = {"version": pv, "content": prev["text"]} if prev else {"error": "没有上一轮"}
elif n == "previous_draft_edit" and prev:
for e in a["edits"]: prev["text"] = prev["text"].replace(e["old"], e["new"], 1)
pv += 1; res = {"ok": True, "version": pv}
elif n == "ask": res = {"answer": a["options"][pick], "by": "user"}
elif n == "lorebook_search": res = lab("search", {"query": a["query"]})["hits"]
elif n == "lorebook_list": res = ["栖水镇(常驻)", "听雨轩", "旧油纸伞", "怀瑾", "镇北渡口", "更夫"]
elif n == "world_state_get": res = lab("state", {"state": state})["formatted"]
elif n == "memory_search": res = []
else: res = {"error": "(模拟中未实现)"}
step["calls"].append({"name": n, "args": a, "result": res})
msgs.append({"role": "tool", "tool_call_id": c["id"], "content": json.dumps(res, ensure_ascii=False)})
# 梨园在 draft_seal / previous_draft_edit 成功后结束本轮(src/stage/engine.ts:1883)
if any(c["name"] in ("draft_seal", "previous_draft_edit") and isinstance(c["result"], dict) and c["result"].get("ok") for c in step["calls"]):
break
rec |= {"final": draft, "sealed": sealed}
if sealed and draft:
sp = lab("scribe-prompt", {"state": state, "userText": user_text, "assistantText": draft})
raw, usage = side(sp["systemPrompt"], sp["userText"], 2048)
p = lab("patch", {"state": state, "raw": raw})
rec["scribe"] = {"systemPrompt": sp["systemPrompt"], "userText": sp["userText"], "raw": raw, "applied": p["applied"], "warnings": p["warnings"], "formatted": p["formatted"], "roster": p["roster"]}
state = p["state"]
save(tag, rec)
print(f"{tag}: 调用 {len(rec['steps'])} 次,工具 {[c['name'] for s in rec['steps'] for c in s['calls']]},收笔 {sealed}", flush=True)
return rec, state
G = {"role": "assistant", "content": sysd["greeting"]}
U1 = "我把湿透的外衣搭在椅背上,接过茶喝了一口:「住三天。顺便打听个人——十年前从镇北渡口走的那位怀瑾公子,掌柜可还记得?」"
U2 = "她提到那个名字时的反应让我心里一沉。接下来我该怎么做?给我几个选项。"
r1, s1 = run_round("round1", [G], None, U1)
r1b, s1b = run_round("round1-swipe", [G], None, U1) # 同一输入再生成一次=同级分支
h2 = [G, {"role": "user", "content": U1}, {"role": "assistant", "content": r1["final"]}]
r2, s2 = run_round("round2", h2, s1, U2)
quotes = re.findall(r"「[^「」]+」", r2["final"])
U3 = f"上一轮她最后那句{quotes[-1]},改成「这些年,他过得好吗?」" if quotes else "上一轮最后一句对白改得再轻一点。"
prev = {"text": r2["final"]}
h3 = h2 + [{"role": "user", "content": U2}, {"role": "assistant", "content": r2["final"]}]
r3, s3 = run_round("round3", h3, s2, U3, prev=prev)
r3["previous_after"] = prev["text"]; save("round3", r3)
# ---- 纯函数实验 ----
save("lab-passive", lab("inject", {"state": None, "windowText": sysd["greeting"] + "\n" + U1, "passiveLore": True}))
save("lab-gate", lab("gate", {"cases": [
{"toolName": "lorebook_write", "lastUserText": "把怀瑾的事记下来,以后还要用。"},
{"toolName": "lorebook_write", "lastUserText": "她看起来很难过。"},
{"toolName": "lorebook_delete", "lastUserText": "那条记错了,删掉吧。"}]}))
save("lab-patch", lab("patch", {"state": s1, "patch": {"characters": {"青梧": {"affinity": 150}}, "mood": "阴郁", "inventory": ["黄铜怀表(旅人持有)", 42]}}))
# 文字通道输入借用第一次实跑里模型的一段真实输出(当时脚本收笔后没停,它把正文缩写着重述了一遍)
trail = "*青梧在账本上停了一瞬,转身取下墙上那把青灰色的旧油纸伞,用袖口慢慢擦了一遍,才挂回去。*\n\n「记得。这镇子小,一条街,一个渡口。走一个人,十年也散不干净。」\n\n*她转回柜台前,替你续了半盏热茶,茶烟隔在你们中间。*\n\n「客官想打听他的什么?」"
save("lab-tail", {"draft": r1["final"], "text": trail, **lab("tail", {"draft": r1["final"], "text": trail}), "withFormat": lab("tail", {"draft": r1["final"], "text": "<status>好感 +2|地点:听雨轩</status>"})["merged"]})
ent = lambda i, t, **kw: {"id": i, "type": t, **kw}
gE = ent("g", "custom_message", customType="rp-greeting", content=sysd["greeting"])
u1E = ent("u1", "message", message={"role": "user", "content": U1})
A = [gE, u1E, ent("a1", "message", message={"role": "assistant", "content": r1["final"]}), ent("s1", "custom", customType="rp-state", data=s1)]
B = [gE, u1E, ent("a1b", "message", message={"role": "assistant", "content": r1b["final"]}), ent("s1b", "custom", customType="rp-state", data=s1b)]
save("lab-branch", lab("branch", {"branches": [A, B]}))
save("lab-render", lab("render"))
W = f"{H}/ws"
save("lab-sandbox", lab("sandbox", {"cases": [
{"tool": "write", "input": {"path": "cards/青梧/补充设定集.json"}},
{"tool": "read", "input": {"path": "assets/lorebooks/栖水镇.json"}},
{"tool": "write", "input": {"path": "assets/lorebooks/栖水镇.json"}},
{"tool": "read", "input": {"path": "cards/青梧/对话/局B/会话/s.jsonl"}},
{"tool": "write", "input": {"path": "cards/青梧/对话/局A/历史/检查点.jsonl"}},
{"tool": "write", "input": {"path": "notes.txt"}},
{"tool": "bash", "input": {"command": "ls cards"}},
{"tool": "bash", "input": {"command": "cd cards && rm -rf 青梧"}},
{"tool": "bash", "input": {"command": "rm -rf /"}},
{"tool": "bash", "input": {"command": "python cleanup.py"}}]}))
# ---- 剧情压缩 ----
ents = A + [ent("u2", "message", message={"role": "user", "content": U2}), ent("a2", "message", message={"role": "assistant", "content": r2["final"]})]
sp = lab("summary-prompt", {"entries": ents, "state": s2})
summary, _ = side(sp["systemPrompt"], sp["userText"], 4096)
U3E = ent("u3", "message", message={"role": "user", "content": "(下一轮)我点点头,从怀里取出一封受潮的信。"})
after = lab("branch", {"branches": [ents + [ent("sum", "custom", customType="rp-summary", data={"summary": summary, "coversThroughId": "a2"}), U3E]]})["branches"][0]
save("compact", {"systemPrompt": sp["systemPrompt"], "userText": sp["userText"], "summary": summary,
"summaryMessage": f"【前情提要】以下是更早剧情的接力摘要,是既定事实:\n\n{summary}", "historyAfter": after["history"]})
print("compact ok", flush=True)
# ---- 跨局记忆:复盘 → 合并 → 遗忘 ----
transcript = sp["conversationText"]
rp = lab("recap-prompt", {"evidence": {"transcript": transcript, "omittedChars": 0, "beats": 2, "stateSnapshot": json.dumps(s2, ensure_ascii=False, indent=2)}, "chatName": "雨夜问名"})
recapA, _ = side(rp["systemPrompt"], rp["userText"], 4096)
other = open(f"{H}/recap-other.md").read()
cp1 = lab("consolidate-prompt", {"diff": {"changed": [{"chatId": "jA", "name": "雨夜问名", "text": recapA}, {"chatId": "jB", "name": "茶田来信", "text": other}], "deleted": [], "hashes": {}}})
raw1, _ = side(cp1["systemPrompt"], cp1["userText"], 8192)
m1 = lab("consolidate-parse", {"raw": raw1})
cp2 = lab("consolidate-prompt", {"diff": {"changed": [], "deleted": ["jB"], "hashes": {}}, "handbook": m1.get("handbook"), "resident": m1.get("resident")})
raw2, _ = side(cp2["systemPrompt"], cp2["userText"], 8192)
m2 = lab("consolidate-parse", {"raw": raw2})
fit = {str(n): lab("resident-fit", {"text": m1.get("resident", ""), "otherChars": n})["fitted"] for n in (3900, 4200)}
save("memory", {"recapPrompt": rp, "recapA": recapA, "recapB": other, "mergePrompt": cp1, "merged": m1, "forgetPrompt": cp2, "afterForget": m2, "fit": fit})
print("memory ok", flush=True)
# ---- 预设声明 ----
dp = lab("declare-prompt")
draw, _ = side(dp["systemPrompt"], dp["userText"], 16384)
save("declare", {"prompt": dp, "raw": draw, **lab("declare-apply", {"raw": draw, "model": SIDE})})
print("declare ok", flush=True)
# ---- agent 模式:改两章,轮末检查点 ----
chat_dir = f"{W}/cards/青梧/对话/局C"; story = f"{chat_dir}/正文"
shutil.rmtree(chat_dir, ignore_errors=True); os.makedirs(story)
open(f"{story}/000-开场.md", "w").write(sysd["greeting"].split("\n", 1)[1])
open(f"{story}/001-雨夜问名.md", "w").write(r1["final"])
open(f"{story}/002-坦言.md", "w").write(r2["final"])
c0 = lab("story-commit", {"chatDir": chat_dir, "author": "user", "message": "导入三章"})
asys = lab("agent-system", {"cwd": W, "cardPath": "cards/青梧/default_Qingwu.json", "storyDir": "cards/青梧/对话/局C/正文", "storyAbs": story, "state": s2})
q1 = re.findall(r"「[^「」]+」", r1["final"])
UA = f"第一章里她答的那句{q1[0] if q1 else '第一句对白'}太干了,改得更有余味一点;第二章结尾补一句雨停了的描写。"
FT = [{"type": "function", "function": f} for f in [
{"name": "read", "description": "Read a file (path relative to cwd).", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "grep", "description": "Search file contents.", "parameters": {"type": "object", "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}}, "required": ["pattern"]}},
{"name": "edit", "description": "Replace exact text in a file.", "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "oldText": {"type": "string"}, "newText": {"type": "string"}}, "required": ["path", "oldText", "newText"]}},
{"name": "write", "description": "Write a whole file.", "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}]]
msgs = [{"role": "system", "content": asys["system"]}, {"role": "user", "content": f"{asys['stateBlock']}\n\n{UA}"}]
arec = {"user": UA, "system": asys["system"], "stateBlock": asys["stateBlock"], "steps": []}
for rnd in range(1, 21):
d = chat({"model": MAIN, "messages": msgs, "tools": FT, "thinking": {"type": "enabled"}, "reasoning_effort": "high"})
m = d["choices"][0]["message"]; step = {"round": rnd, "reasoning": m.get("reasoning_content"), "content": m.get("content"), "calls": []}
arec["steps"].append(step); msgs.append({k: m[k] for k in ("role", "content", "reasoning_content", "tool_calls") if m.get(k) is not None})
if not m.get("tool_calls"): break
for c in m["tool_calls"]:
n, a = c["function"]["name"], json.loads(c["function"]["arguments"] or "{}")
path = os.path.join(W, a.get("path", "")) if a.get("path") else story
try:
if n == "read": res = open(path).read()
elif n == "grep": res = "\n".join(f"{os.path.relpath(os.path.join(story, f), W)}: {l}" for f in sorted(os.listdir(story)) for l in open(os.path.join(story, f)).read().splitlines() if re.search(a["pattern"], l))
elif n == "edit":
t = open(path).read()
if a["oldText"] not in t: res = "Error: oldText not found"
else: open(path, "w").write(t.replace(a["oldText"], a["newText"], 1)); res = "ok"
elif n == "write": open(path, "w").write(a["content"]); res = "ok"
else: res = "unknown tool"
except Exception as e: res = f"Error: {e}"
step["calls"].append({"name": n, "args": a, "result": res if len(res) < 800 else res[:800] + "…"})
msgs.append({"role": "tool", "tool_call_id": c["id"], "content": res})
c1 = lab("story-commit", {"chatDir": chat_dir, "author": "agent", "message": UA[:60]})
arec |= {"checkpoint0": c0["checkpoint"], "checkpoint1": c1["checkpoint"], "chapters": {f: open(f"{story}/{f}").read() for f in sorted(os.listdir(story))},
"objects": sorted(os.listdir(f"{chat_dir}/历史/对象")), "log": open(f"{chat_dir}/历史/检查点.jsonl").read()}
save("agent", arec)
print("agent ok", flush=True)