把代入名片从开场白拆到主角设定短码槽,并收紧对话落盘写法。

多开场可各自绑定名片;开玩挂载跳过开场产物并认用户需求类标签。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-24 00:59:05 +08:00
co-authored by Cursor
parent da459fb34c
commit a4ad552aff
17 changed files with 417 additions and 58 deletions
+14 -12
View File
@@ -48,6 +48,7 @@ import {
/** 开场白进对话历史,不重复挂进每轮投影 */
const SKIP_BIND_TAGS = new Set([
"设计.开场白",
"设计.开场白与开场变量",
"设计.worker集",
"设计.worker集.草稿",
CONTEXT_ORDER_TAG,
@@ -57,9 +58,20 @@ const SKIP_BIND_TAGS = new Set([
VALUE_MAP_TAG,
]);
/** 偏文风/呈现:有叙事转述时挂转述;否则仍挂主世界层 */
function shouldSkipBindTag(tag: string): boolean {
const t = tag.trim();
if (!t) return true;
if (SKIP_BIND_TAGS.has(t)) return true;
if (t.startsWith("设计.开场白#")) return true;
if (t.startsWith("输出.")) return true;
if (t.startsWith("运行.")) return true;
if (t.startsWith("创作.")) return true;
return false;
}
/** 偏文风/呈现/硬约束:有叙事转述时挂转述;否则仍挂主世界层 */
const STYLE_TAG_RE =
/叙事指南|故事推进|文风|美学|纲领|禁忌|示例|模仿|正文组成|回复格式|监控栏|篇幅|结构/;
/叙事指南|故事推进|文风|美学|纲领|禁忌|用户约束|用户需求|示例|模仿|正文组成|回复格式|监控栏|篇幅|结构/;
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
@@ -75,16 +87,6 @@ function isStyleProductTag(tag: string): boolean {
return STYLE_TAG_RE.test(tag);
}
function shouldSkipBindTag(tag: string): boolean {
const t = tag.trim();
if (!t) return true;
if (SKIP_BIND_TAGS.has(t)) return true;
if (t.startsWith("输出.")) return true;
if (t.startsWith("运行.")) return true;
if (t.startsWith("创作.")) return true;
return false;
}
/** 从黑板收集对话落盘产物(有正文的 用户.* / 设计.*) */
export function collectDictateBindProducts(board: Blackboard): DictateProduct[] {
const products: DictateProduct[] = [];
+6 -6
View File
@@ -85,10 +85,10 @@ tool 跑完后写给用户看的 markdown。作用:确认本轮落了什么、
## 分清落盘目标
- 正文组成(格式)→ insert \`设计.正文组成\`(须「回复呈现」等上游条件已满足,见能力表)
- 开场白(内容)→ insert \`设计.开场白\`;落档/开玩同步 \`输出.开场白\`(收口;上游未齐则先补上游,不硬塞开场)
- 开场白(内容)→ insert \`设计.开场白\` 或 \`设计.开场白#场景短码\`(多开场互不覆盖;改某一条则 insert 同一完整 tag);落档/开玩同步 \`输出.开场白\`
- 代入名片 → insert \`设计.主角设定\` 或 \`设计.主角设定#同短码\`(与开场白同槽即绑定该开局;无槽则全局默认)
- 多篇无关联短文(生产在游玩)→ 创作期钉 \`设计.模仿范例\` / \`设计.模仿要点\` / \`设计.故事推进\`(大纲扩写);文风细则另 insert \`设计.叙事指南\`
- 好感分档性格等 → declare_variable + declare_map;分档文案走映射,不 insert 整表散文
- 代入名片(名字/身份/金手指)→ insert \`设计.主角设定\`(对照「主角设定」何时用;名字写成具体称呼,不用 @玩家 / 用户 / 主角)
${recipeBlock}${moduleBlock}
## 按用户本轮意图择一为主(仍须过「何时用」)
落盘后的可见回复一律按「对用户可见回复」写;下列各节只约定本轮落什么。
@@ -100,11 +100,11 @@ ${recipeBlock}${moduleBlock}
「回复呈现」等上游已齐时才 insert \`设计.正文组成\`。
### C · 生成开场白
收口:先有正文组成且体验已锚定;insert \`设计.开场白\`(岔路用 \`## 岔路\`)。
**形状:** meta + 正文。有代入名片时,开头 YAML \`用户角色.名字\` / \`用户角色.简介\`,再 \`---\`,下面才是开场正文。不同开场可有不同用户角色。
收口:体验已锚定。首条可用 \`设计.开场白\`;再写别的开局用 \`设计.开场白#场景短码\`(与生成规则同款:同 tag 覆盖,新短码新建)。禁止用 \`## 岔路\` 塞进同一份。
**形状:** 只要叙事正文。禁止把 \`用户角色.名字\` / \`用户角色.简介\` 写进开场白。代入名片 → \`设计.主角设定\` 或 \`设计.主角设定#同短码\`。
**正文格式:** 已有 \`设计.正文组成\` 时,正文必须是 present.v1 JSON(\`shell\` 与正文组成一致,主读写 \`blocks.body\`),不要只交裸 Markdown;尚未钉正文组成时才用 Markdown。
**content 规格:** 正文至少一处字面 \`@玩家\` 作用户角色名位;姓名位用 \`@玩家\`,不写死具体姓名;全文不能只有「你」而零个 \`@玩家\`。缺则改写后再 insert。进游玩时程序替换 \`@玩家\`。
进玩时若有用户角色,程序提示是否切换到这个预设。
进玩时在游玩辅助「开场」Tab 点选开局;有绑定名片则可切到该预设。
### D · 钉变量与映射
仅当「变量设计」何时用成立(体验须跨轮记住状态等):declare_variable → 有分档换文再 declare_map。配方名带「数据化」也不自动开跑 D。
@@ -113,7 +113,7 @@ ${recipeBlock}${moduleBlock}
必须用 insert 写齐游玩上下文:\`设计.模仿范例\`、\`设计.模仿要点\`、\`设计.故事推进\`(用户输入用法:大纲扩写;扮演停笔关掉)。文风细则对照「叙事指南」何时用再 insert \`设计.叙事指南\`。缺范例/要点/故事推进任一就先补产物,再说可以开玩。创作期不写完整短文。开玩后程序只挂载这些产物:主世界出内容,转述出文风。
### F · 快穿短局
仅当本局配方是快穿短局,或用户只要一核短局。先 insert 美学纲领(这一核、怎么代入、何时等用户);代入有名字/简介则写进开场白的用户角色 meta(不单独 insert 主角设定)。核是变造法则再 insert 实现机制;有门控才 declare 至多三个真值。点名文风 → insert 叙事指南;点名推进/停笔 → insert 故事推进(条件独立)。核可玩即 insert 开场白:用户未点名分区/壳则跳过正文组成(覆盖 C),开玩吃默认散文。舞台骨架/生成规则/拓扑即使「何时用」成立也不 insert,把地点与规则折进美学核或开场白。用户改口要铺可经营世界 → 本局仍只收这一核,并告知改用「数据化跑团体验」或「回合推演」。
仅当本局配方是快穿短局,或用户只要一核短局。先 insert 美学纲领(这一核、怎么代入、何时等用户);代入有名字/简介 → insert \`设计.主角设定\`(不要写进开场白)。核是变造法则再 insert 实现机制;有门控才 declare 至多三个真值。点名文风 → insert 叙事指南;点名推进/停笔 → insert 故事推进(条件独立)。核可玩即 insert 开场白:用户未点名分区/壳则跳过正文组成(覆盖 C),开玩吃默认散文。舞台骨架/生成规则/拓扑即使「何时用」成立也不 insert,把地点与规则折进美学核或开场白。用户改口要铺可经营世界 → 本局仍只收这一核,并告知改用「数据化跑团体验」或「回合推演」。
## 常用序感
- 需求/美学等不变设定:靠前(负)
+4 -2
View File
@@ -153,9 +153,11 @@ export function resolveRepeatableInsertTag(params: {
.filter((t) => t && dictateProductFamily(t) === family);
if (!slot) {
if (siblings.length > 0 && raw === family) {
// 允许覆盖基名自身;若已有其它 #槽位,禁止再用裸基名(须写明 #id)
const otherSiblings = siblings.filter((t) => t !== family);
if (otherSiblings.length > 0 && raw === family) {
return {
error: `「${family}」是可增殖产物,追加请用「${family}#唯一id」(生成规则用 rule_id,具体实例用 batch_id/姓名),不要覆盖基 tag。改某一条则 insert 同一完整 tag。`,
error: `「${family}」是可增殖产物,追加请用「${family}#唯一id」(生成规则用 rule_id,具体实例用 batch_id/姓名,开场白/主角设定用场景短码),不要覆盖基 tag。改某一条则 insert 同一完整 tag。`,
};
}
return { tag: family };
+2 -2
View File
@@ -16,12 +16,12 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
position: {
type: "string",
description:
"产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id。勿用本工具写变量目录/映射。",
"产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白、设计.开场白#dorm;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id、设计.主角设定#dorm。勿用本工具写变量目录/映射。",
},
content: {
type: "string",
description:
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=meta + 正文(有正文组成时正文为 present.v1 JSON;否则 Markdown),必须含字面 @玩家(用户角色名位,禁止写死姓名)",
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白[#短码]=叙事正文(有正文组成时为 present.v1 JSON;否则 Markdown),必须含字面 @玩家,禁止写入用户角色.名字/简介;设计.主角设定[#同短码]=名字/背景/特殊设定 JSON,与开场同短码即绑定该开局",
},
order: {
type: "number",
+121 -9
View File
@@ -1,5 +1,6 @@
/**
* 开场白 meta 里的用户角色(名字+简介)是本条开场的初始设定。
* 用户代入名片写在「设计.主角设定」;开场白只留叙事。
* 旧稿若把名字/简介塞进开场 meta,读取时仍可回退,并建议提升到主角设定节点。
* 用户选用后只覆盖本局 @玩家 / @人设,不写进全局角色列表。
*/
import type { Blackboard } from "../blackboard/blackboard.js";
@@ -11,13 +12,116 @@ import {
import {
OPENING_PERSONA_CHOICE_TAG,
OPENING_PERSONA_TAG,
OPENING_PRODUCT_FAMILY,
OPENING_SETUP_ARTIFACT_TAG,
parseOpeningSealPayload,
readSealedOpeningPersona,
splitOpeningDocument,
type OpeningUserPersona,
} from "../skills/opening-seal.js";
import {
buildRepeatableProductTag,
dictateProductFamily,
dictateProductSlot,
} from "../dictate/repeatable-tags.js";
export const PROTAGONIST_TAG = "设计.主角设定";
/** 对话落盘额外可增殖族(catalog 未标 repeatable 的简化 tag) */
export const DICTATE_OPENING_REPEATABLE_FAMILIES = [
OPENING_PRODUCT_FAMILY,
PROTAGONIST_TAG,
] as const;
/** 与开场同槽:设计.开场白#dorm → 设计.主角设定#dorm;无槽则基名 */
export function protagonistTagForOpeningSlot(openingTag: string): string {
const family = dictateProductFamily(openingTag);
if (family !== OPENING_PRODUCT_FAMILY && family !== OPENING_SETUP_ARTIFACT_TAG) {
return PROTAGONIST_TAG;
}
const slot = dictateProductSlot(openingTag);
return slot ? buildRepeatableProductTag(PROTAGONIST_TAG, slot) : PROTAGONIST_TAG;
}
/** 把开场/运行里的名字+简介收成可复制的「主角设定」产物。 */
export function protagonistFragmentFromPersona(
persona: OpeningUserPersona,
): string {
const background = persona.description.trim() || "未定";
return JSON.stringify(
{
schema: "context-fragment.v1",
技能: "主角设定",
brief: `${persona.name} · 代入名片`,
mount: ["world-simulator"],
稳变: "stable",
正文: {
名字: persona.name,
背景: background,
特殊设定: { 身份: "无", 金手指: "无", 其它: "无" },
},
自评: {
维度: [
{ 名: "可代入", 分数: 8, 说明: "由开场/用户原话收成" },
{ 名: "够用", 分数: 7, 说明: "简介作背景;特殊设定未另钉" },
],
薄弱点: "特殊设定未展开",
},
追问: { 导语: "", 题目: [] },
开放问题: [],
},
null,
2,
);
}
/** 黑板上尚无该 tag 的主角设定时写入(可指定开场同槽 tag)。 */
export function ensureProtagonistFromPersona(
board: Blackboard,
persona: OpeningUserPersona | null | undefined,
tag: string = PROTAGONIST_TAG,
): boolean {
if (!persona?.name?.trim()) return false;
const writeTag = tag.trim() || PROTAGONIST_TAG;
if (board.getContentByTag(writeTag)?.trim()) return false;
board.write({
tag: writeTag,
content: protagonistFragmentFromPersona(persona),
source: "runtime",
});
return true;
}
export function readProtagonistPersonaAt(
board: Blackboard,
tag: string = PROTAGONIST_TAG,
): OpeningUserPersona | null {
const spec = parseProtagonistSpec(board.getContentByTag(tag));
if (!spec) return null;
return {
name: spec.name,
description: formatPersonaDescription(spec),
};
}
/** 某条开场绑定的名片:同槽主角设定 → 基名主角设定 → 开场旧 meta */
export function personaBoundToOpening(
board: Blackboard,
openingTag: string,
openingContent: string,
): OpeningUserPersona | null {
const slotted = readProtagonistPersonaAt(
board,
protagonistTagForOpeningSlot(openingTag),
);
if (slotted) return slotted;
if (dictateProductSlot(openingTag)) {
const base = readProtagonistPersonaAt(board, PROTAGONIST_TAG);
if (base) return base;
}
return splitOpeningDocument(openingContent).persona;
}
const RESERVED_NAMES = new Set(["@玩家", "用户", "主角", CREATION_PLACEHOLDER_NAME]);
export type ProtagonistSpec = {
@@ -50,8 +154,22 @@ export function readSessionProtagonist(
const sealed = readSealedOpeningPersona(
board.getContentByTag(OPENING_PERSONA_TAG),
);
if (sealed) {
return {
name: sealed.name,
description: sealed.description,
summary: firstSentence(sealed.description),
};
}
const spec = parseProtagonistSpec(board.getContentByTag(PROTAGONIST_TAG));
if (spec) {
return {
name: spec.name,
description: formatPersonaDescription(spec),
summary: formatProtagonistSummary(spec),
};
}
const fromOpening =
sealed ??
parseOpeningSealPayload(board.getContentByTag("设计.开场白与开场变量"))
?.persona ??
splitOpeningDocument(board.getContentByTag("设计.开场白")).persona;
@@ -62,13 +180,7 @@ export function readSessionProtagonist(
summary: firstSentence(fromOpening.description),
};
}
const spec = parseProtagonistSpec(board.getContentByTag(PROTAGONIST_TAG));
if (!spec) return null;
return {
name: spec.name,
description: formatPersonaDescription(spec),
summary: formatProtagonistSummary(spec),
};
return null;
}
export function formatPersonaDescription(spec: ProtagonistSpec): string {
+3 -1
View File
@@ -36,7 +36,7 @@ import { toActiveSkillSnapshot } from "../skills/snapshot.js";
import { runWorkerSkill } from "../worker/executor.js";
import { resolveWorkerId } from "../worker/resolve-id.js";
import { resolveWorkerLlmProvider } from "../skills/worker-llm.js";
import { overlayPlayPersona } from "../persona/protagonist-bind.js";
import { ensureProtagonistFromPersona, overlayPlayPersona } from "../persona/protagonist-bind.js";
import {
DIALOGUE_HISTORY_TAG,
appendDialogueHistoryTurn,
@@ -1991,6 +1991,7 @@ export class PhaseRuntime {
serializeOpeningPersona(payload.persona),
"opening-setup",
);
ensureProtagonistFromPersona(this.blackboard, payload.persona);
}
if (payload?.variables.length) {
@@ -2045,6 +2046,7 @@ export class PhaseRuntime {
/** 收口后若还没有运行规格,用草稿或默认槽位补一份,才能进游玩 */
private ensurePlaySpecReady(): void {
// 对话落盘:落档/开玩时自动挂载产物;种变量初值;按映射重投影
// 偏好/文风改由节点产物(设计.用户需求 / 设计.叙事指南*)挂载,不再全量自动摊库
if (this.isDictateIntakeMode()) {
const bound = applyDictatePlayBind(this.blackboard);
if (bound.boundTags.length) {
+59 -2
View File
@@ -19,6 +19,8 @@ import {
} from "./present-packet.js";
export const OPENING_SETUP_ARTIFACT_TAG = "设计.开场白与开场变量";
/** 对话落盘开场正文族(可 设计.开场白#槽位 增殖) */
export const OPENING_PRODUCT_FAMILY = "设计.开场白";
export const OPENING_OUTPUT_TAG = "输出.开场白";
export const OPENING_INITIAL_VARS_TAG = "运行.初始变量";
export const OPENING_CURRENT_VARS_TAG = "变量.当前";
@@ -222,20 +224,75 @@ export function parseUserPersonaField(raw: unknown): OpeningUserPersona | null {
return { name, description };
}
/** 去掉文首用户角色 meta 行(含无 --- 篱笆的旧稿),避免 meta 进开场正文。 */
export function stripLeadingPersonaMeta(raw: string): string {
const lines = raw.split(/\r?\n/);
let i = 0;
while (i < lines.length && !lines[i]!.trim()) i++;
let saw = false;
let inUserRoleBlock = false;
while (i < lines.length) {
const line = lines[i]!;
const trimmed = line.trim();
if (!trimmed) {
if (saw) {
i++;
continue;
}
break;
}
if (/^---+$/.test(trimmed) && saw) {
i++;
break;
}
if (/^用户角色\s*:\s*$/.test(trimmed)) {
saw = true;
inUserRoleBlock = true;
i++;
continue;
}
if (
inUserRoleBlock &&
/^\s+(?:名字|简介|name|description|人设|描述)\s*[::]/.test(line)
) {
i++;
continue;
}
inUserRoleBlock = false;
if (/^(?:用户角色\.)?(?:名字|简介)[::]/.test(trimmed)) {
saw = true;
i++;
continue;
}
break;
}
return lines.slice(i).join("\n").trim();
}
function splitYamlFrontmatter(raw: string): {
text: string;
persona: OpeningUserPersona | null;
} {
const text = raw.trim();
const fence = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!fence) return { text, persona: parseOpeningUserPersona(text) };
if (!fence) {
const persona = parseOpeningUserPersona(text);
if (!persona) return { text, persona: null };
const stripped = stripLeadingPersonaMeta(text);
// 能认出文首 meta 才剥;正文里偶然出现「名字:」不剥
if (stripped !== text.trim()) return { text: stripped, persona };
if (/^(?:用户角色\.|用户角色\s*:)/m.test(text)) {
return { text: stripped, persona };
}
return { text, persona };
}
const persona =
parseUserPersonaField(parseSimpleYamlMap(fence[1] ?? "")) ||
parseOpeningUserPersona(fence[1] ?? "");
return { text: (fence[2] ?? "").trim(), persona };
}
/** 开场正文前的 YAML 式 meta:用户角色名字+简介,其余当正文。 */
/** 开场正文与用户角色 meta 分离;meta 不应进「系统 开场白」。 */
export function splitOpeningDocument(raw: string | null | undefined): {
text: string;
persona: OpeningUserPersona | null;
+56 -7
View File
@@ -6,8 +6,17 @@ import {
isTableCellEditable,
parseTableDoc,
} from "../blackboard/table-cells.js";
import {
dictateProductFamily,
dictateProductSlot,
} from "../dictate/repeatable-tags.js";
import {
DICTATE_ORDER_META_KEY,
effectiveDictateOrder,
} from "../dictate/types.js";
import {
OPENING_PERSONA_CHOICE_TAG,
OPENING_PRODUCT_FAMILY,
OPENING_SETUP_ARTIFACT_TAG,
SLOT_OPENING_SELECTED_INDEX,
parseOpeningSealPayload,
@@ -15,7 +24,10 @@ import {
type OpeningUserPersona,
} from "./opening-seal.js";
import { CURRENT_VARS_TAG } from "./variable-catalog.js";
import { readSessionProtagonist } from "../persona/protagonist-bind.js";
import {
personaBoundToOpening,
readSessionProtagonist,
} from "../persona/protagonist-bind.js";
import { presentReadablePlain } from "./present-packet.js";
export type PlayAuxTableRow = {
@@ -29,6 +41,9 @@ export type PlayAuxTableRow = {
export type PlayAuxOpening = {
index: number;
/** 产物 tag:设计.开场白 或 设计.开场白#槽位;opening-setup 候选为假想 tag */
tag: string;
slot: string;
text: string;
persona: OpeningUserPersona | null;
};
@@ -51,7 +66,8 @@ function selectedOpeningIndex(slots: Record<string, unknown> | undefined): numbe
return 0;
}
function collectOpenings(board: Blackboard): PlayAuxOpening[] {
/** 黑板上全部可选用开场(工序候选优先;否则对话落盘 设计.开场白[#槽]) */
export function collectOpenings(board: Blackboard): PlayAuxOpening[] {
const payload = parseOpeningSealPayload(
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
0,
@@ -62,18 +78,50 @@ function collectOpenings(board: Blackboard): PlayAuxOpening[] {
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
index,
);
const tag = `${OPENING_SETUP_ARTIFACT_TAG}#${index + 1}`;
return {
index,
tag,
slot: String(index + 1),
text: presentReadablePlain(text) || text,
persona: one?.persona ?? payload.persona,
};
});
}
const split = splitOpeningDocument(board.getContentByTag("设计.开场白"));
if (split.text) {
return [{ index: 0, text: split.text, persona: split.persona }];
const rows: Array<{
tag: string;
content: string;
order: number;
}> = [];
for (const e of board.listTagIndex()) {
if (dictateProductFamily(e.tag) !== OPENING_PRODUCT_FAMILY) continue;
const item = board.getLatestByTag(e.tag);
const content = item?.content?.trim() ?? "";
if (!content) continue;
const rawOrder = item?.metadata?.[DICTATE_ORDER_META_KEY];
const order =
typeof rawOrder === "number" && Number.isFinite(rawOrder)
? rawOrder
: effectiveDictateOrder({ tag: e.tag });
rows.push({ tag: e.tag, content, order });
}
return [];
rows.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.tag.localeCompare(b.tag, "zh");
});
return rows.map((row, index) => {
const split = splitOpeningDocument(row.content);
const text = split.text.trim() || row.content;
return {
index,
tag: row.tag,
slot: dictateProductSlot(row.tag),
text: presentReadablePlain(text) || text,
persona: personaBoundToOpening(board, row.tag, row.content),
};
});
}
export function buildPlayAuxView(
@@ -100,6 +148,7 @@ export function buildPlayAuxView(
const selectedOpeningIndexClamped = openings.length
? Math.min(idx, openings.length - 1)
: 0;
const picked = openings[selectedOpeningIndexClamped];
const sealed = readSessionProtagonist(board);
const choiceRaw = board.getContentByTag(OPENING_PERSONA_CHOICE_TAG)?.trim();
const openingPersonaChoice =
@@ -111,7 +160,7 @@ export function buildPlayAuxView(
selectedOpeningIndex: selectedOpeningIndexClamped,
openingPersona: sealed
? { name: sealed.name, description: sealed.description }
: openings[selectedOpeningIndexClamped]?.persona ?? null,
: picked?.persona ?? null,
openingPersonaChoice,
};
}