引入游玩回合与开场收口,抽出可替换 agent 司机并支持运行中止;完善预设洞位、世界书拼装与试跑,并打磨会话运行时与 Web 交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 01:13:48 +08:00
co-authored by Cursor
parent ec474cb946
commit b82da4c5a5
95 changed files with 6781 additions and 1344 deletions
+24 -5
View File
@@ -163,12 +163,22 @@ export function projectFragmentContent(
const frag = parseContextFragment(raw);
const mode = projection.trim().toLowerCase();
if (!frag) {
if (mode === "summary" && raw.length > 800) return `${raw.slice(0, 800)}…`;
if ((mode === "summary" || mode === "brief" || mode === "index") && raw.length > 800) {
return `${raw.slice(0, 800)}…`;
}
return raw;
}
if (mode === "summary" || mode === "brief") {
return frag.brief || raw;
}
if (mode === "index") {
const lines: string[] = [];
if (frag.技能) lines.push(`技能:${frag.技能}`);
if (frag.brief) lines.push(frag.brief);
if (frag.mount.length) lines.push(`挂载:${frag.mount.join("、")}`);
if (frag.稳变) lines.push(`稳变:${frag.稳变}`);
return lines.join("\n") || frag.brief || raw;
}
if (mode === "fields") {
if (frag.正文 && typeof frag.正文 === "object" && !Array.isArray(frag.正文)) {
const keys = Object.keys(frag.正文 as object);
@@ -237,9 +247,11 @@ export function contextFragmentToView(raw: unknown): ContextFragmentView {
const probe = rawDoc?.追问;
if (probe && typeof probe === "object" && !Array.isArray(probe)) {
const row = probe as Record<string, unknown>;
const lines: string[] = [];
if (typeof row.导语 === "string" && row.导语.trim()) lines.push(row.导语.trim());
const qs = Array.isArray(row.题目) ? row.题目 : [];
const lines: string[] = [];
if (qs.length && typeof row.导语 === "string" && row.导语.trim()) {
lines.push(row.导语.trim());
}
qs.forEach((q, i) => {
if (!q || typeof q !== "object") return;
const r = q as Record<string, unknown>;
@@ -282,7 +294,9 @@ export function extractFragmentAskSidecar(raw: unknown): {
const looksFragment =
row.schema === CONTEXT_FRAGMENT_SCHEMA ||
(typeof row.brief === "string" && ("正文" in row || "body" in row));
typeof row.技能 === "string" ||
(typeof row.brief === "string" &&
("正文" in row || "body" in row || "追问" in row));
if (!looksFragment) return { questions: [], assessment: "" };
const assessmentParts: string[] = [];
@@ -358,7 +372,12 @@ export function extractFragmentAskSidecar(raw: unknown): {
}
const questions = normalizeQuestions(rawQs);
const assessment = [lead, ...assessmentParts].filter(Boolean).join("\n");
const assessment = [
questions.length ? lead : "",
...assessmentParts,
]
.filter(Boolean)
.join("\n");
return { questions, assessment };
}
+60 -6
View File
@@ -6,9 +6,14 @@
import type { ContextSegmentDef } from "./context-segments.js";
import { extractJsonObjectText } from "./worker-set-parse.js";
import {
DEFAULT_ON_DEMAND_REFS,
DEFAULT_PLAY_SLOT_REFS,
defaultPlaySlots,
parsePlaySlots,
playSlotIdForRef,
PLAY_SLOT_META,
PLAY_SLOTS_WITHOUT_DIALOGUE_HISTORY,
resolvePlayAgentId,
type PlaySlotsConfig,
} from "./play-slots.js";
import {
@@ -87,20 +92,53 @@ function parseInsert(raw: unknown): ContextOrderInsert | undefined {
function parseSlot(raw: unknown): ContextOrderSlot | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
const ref = asString(row.ref) ?? asString(row.id);
if (!ref) return undefined;
const rawRef = asString(row.ref) ?? asString(row.id);
if (!rawRef) return undefined;
const slotId = resolvePlayAgentId(rawRef);
const ref = slotId
? slotId === "chance"
? DEFAULT_ON_DEMAND_REFS.chance
: DEFAULT_PLAY_SLOT_REFS[slotId]
: rawRef;
const insertsRaw = Array.isArray(row.inserts) ? row.inserts : [];
const inserts = insertsRaw
.map(parseInsert)
.filter((x): x is ContextOrderInsert => Boolean(x))
.sort((a, b) => a.order - b.order);
const label =
asString(row.label) ??
asString(row.name) ??
(slotId && slotId !== "chance" ? PLAY_SLOT_META[slotId].label : undefined);
return {
ref,
label: asString(row.label) ?? asString(row.name),
label,
inserts,
};
}
function agentEnabledFlag(row: Record<string, unknown>): boolean {
if (typeof row.enabled === "boolean") return row.enabled;
if (row.enabled === "false" || row.enabled === 0) return false;
return true;
}
function playSlotsFromAgents(
rows: Record<string, unknown>[],
): PlaySlotsConfig {
const base = defaultPlaySlots();
for (const row of rows) {
const rawId = asString(row.id) ?? asString(row.ref) ?? asString(row.label);
if (!rawId) continue;
const slotId = resolvePlayAgentId(rawId);
if (!slotId) continue;
const on = agentEnabledFlag(row);
if (slotId === "chance") base.chance = on;
else if (slotId === "auditor") base.auditor = on;
else base[slotId] = on;
}
return base;
}
export function parseContextOrder(raw: unknown): ContextOrderDoc | undefined {
let row: Record<string, unknown> | null = null;
if (typeof raw === "string") {
@@ -118,16 +156,32 @@ export function parseContextOrder(raw: unknown): ContextOrderDoc | undefined {
}
if (!row) return undefined;
const slotsRaw = Array.isArray(row.slots) ? row.slots : [];
const slots = slotsRaw
const agentsRaw = Array.isArray(row.agents) ? row.agents : [];
const agentRows = agentsRaw.filter(
(a): a is Record<string, unknown> =>
Boolean(a) && typeof a === "object" && !Array.isArray(a),
);
const fromAgents = agentRows
.filter(agentEnabledFlag)
.map(parseSlot)
.filter((x): x is ContextOrderSlot => Boolean(x));
const slotsRaw = Array.isArray(row.slots) ? row.slots : [];
const fromSlots = slotsRaw
.map(parseSlot)
.filter((x): x is ContextOrderSlot => Boolean(x));
const slots = fromAgents.length ? fromAgents : fromSlots;
if (!slots.length && row.schema !== CONTEXT_ORDER_SCHEMA) return undefined;
const play_slots = fromAgents.length
? playSlotsFromAgents(agentRows)
: parsePlaySlots(row.play_slots ?? row.playSlots);
return {
schema: CONTEXT_ORDER_SCHEMA,
brief: asString(row.brief),
play_slots: parsePlaySlots(row.play_slots ?? row.playSlots),
play_slots,
slots,
};
}
+159 -39
View File
@@ -89,17 +89,40 @@ function readTagContent(
return items[items.length - 1]?.content?.trim() ?? "";
}
/** 单段投影正文(不含 label)。供世界书 marker 填洞复用。 */
export function renderContextSegmentBody(
segment: ContextSegmentDef,
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
skipExact?: Set<string>,
skipTags?: Set<string>,
): string {
return formatSegmentBody(
segment,
inputs,
blackboard,
inputMerge,
skipExact,
skipTags,
);
}
function formatSegmentBody(
segment: ContextSegmentDef,
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
skipExact?: Set<string>,
skipTags?: Set<string>,
): string {
if (segment.inline?.trim()) {
return applyProjection(segment.inline.trim(), segment.projection);
}
const parts: string[] = [];
const seen = new Set<string>();
for (const tag of segment.tags) {
if (skipTags?.has(tag)) continue;
let content: string;
if (isDialogueHistoryRef(tag)) {
content = resolveDialogueHistory(inputs, blackboard, inputMerge);
@@ -115,8 +138,22 @@ function formatSegmentBody(
content = applyPolicy(content, segment.policy);
content = applyProjection(content, segment.projection);
}
if (!content.trim()) continue;
parts.push(content.trim());
const trimmed = content.trim();
if (!trimmed) continue;
if (skipExact?.has(trimmed)) continue;
if (seen.has(trimmed)) continue;
if (
parts.some(
(p) =>
p === trimmed ||
p.includes(`\n\n${trimmed}`) ||
p.startsWith(`${trimmed}\n\n`),
)
) {
continue;
}
seen.add(trimmed);
parts.push(trimmed);
}
return parts.join("\n\n");
}
@@ -141,6 +178,61 @@ function applyProjection(content: string, projection?: string): string {
return projectFragmentContent(content, projection);
}
const USER_REQUIREMENT_TAGS = new Set([
"用户.需求",
"book.brief",
"用户.最新输入",
"用户.worker答复",
"用户.修订说明",
]);
const CREATION_REVIEW_WORKERS = new Set([
"design-step",
"design-flow",
"opening-generator",
]);
function alreadyCovered(parts: string[], next: string): boolean {
const trimmed = next.trim();
if (!trimmed) return true;
return parts.some(
(p) =>
p === trimmed ||
p.includes(`\n\n${trimmed}`) ||
p.startsWith(`${trimmed}\n\n`),
);
}
function pushUnique(parts: string[], next: string): void {
const trimmed = next.trim();
if (!trimmed || alreadyCovered(parts, trimmed)) return;
parts.push(trimmed);
}
/** 创作步:用户要求按时间排;冲突只看较新,旧条未点名推翻则仍有效。 */
function collectUserRequirementBlock(params: {
inputs: Record<string, string>;
blackboard: Blackboard;
inputMerge: BlackboardInputMerge;
}): string {
const { inputs, blackboard, inputMerge } = params;
const ordered = [
readTagContent("用户.需求", inputs, blackboard, inputMerge),
readTagContent("book.brief", inputs, blackboard, inputMerge),
readTagContent("用户.worker答复", inputs, blackboard, inputMerge),
readTagContent("用户.最新输入", inputs, blackboard, inputMerge),
readTagContent("用户.修订说明", inputs, blackboard, inputMerge),
];
const parts: string[] = [];
for (const item of ordered) pushUnique(parts, item);
if (!parts.length) return "";
return [
"## 【用户要求】按先后排列。冲突时以较新的为准;未被较新要求改写的旧条仍有效。",
"",
...parts,
].join("\n\n");
}
/**
* 按 contextSegments 拼装 user 侧上下文。
* 无 segments 时回退为 JSON inputs(兼容旧 skill)。
@@ -178,10 +270,49 @@ export function assembleWorkerContext(params: {
);
}
const revisionNote = readTagContent(
"用户.修订说明",
params.inputs,
params.blackboard,
inputMerge,
).trim();
const inheritExisting = segments.some((s) => s.id === "inherit-existing");
const splitUserReq = CREATION_REVIEW_WORKERS.has(params.workerId);
const draftBlocks: string[] = [];
for (const tag of params.outputTags) {
const alreadyInSegments = segments.some((s) => s.tags.includes(tag));
if (alreadyInSegments) continue;
const draft = readTagContent(
tag,
params.inputs,
params.blackboard,
inputMerge,
).trim();
if (!draft) continue;
draftBlocks.push(`### \`${tag}\`\n\n${draft}`);
}
// 只有本步真有底稿(或回头修改)才算修订;上一步残留的修订说明仍作为用户要求保留
const inheritMode = inheritExisting || (Boolean(revisionNote) && draftBlocks.length > 0);
const skipTags = new Set<string>();
if (splitUserReq) {
for (const tag of USER_REQUIREMENT_TAGS) skipTags.add(tag);
}
const blocks: string[] = [];
const render = (seg: ContextSegmentDef) => {
const body = formatSegmentBody(seg, params.inputs, params.blackboard, inputMerge);
if (inheritMode && seg.id === "module-opening") return;
const body = formatSegmentBody(
seg,
params.inputs,
params.blackboard,
inputMerge,
undefined,
skipTags,
);
if (!body) return;
if (seg.label) {
blocks.push(`${seg.label}\n\n${body}`);
@@ -193,6 +324,15 @@ export function assembleWorkerContext(params: {
// 严格按 segments 数组顺序(投影排序表顺序);不再按 static/dynamic 重排
for (const seg of segments) render(seg);
if (splitUserReq) {
const reqBlock = collectUserRequirementBlock({
inputs: params.inputs,
blackboard: params.blackboard,
inputMerge,
});
if (reqBlock) blocks.push(reqBlock);
}
// 定稿摘要:若未在 segments 中声明,仍附在末尾
const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim();
const briefInSegments = segments.some((s) => s.tags.includes(CONTEXT_BRIEF_TAG));
@@ -200,39 +340,17 @@ export function assembleWorkerContext(params: {
blocks.push(`## 上下文.定稿摘要\n\n${brief}`);
}
// 修订态:把本步 outputTags 上已有正文钉成「待改底稿」,禁止无故推倒重写
const revisionNote = readTagContent(
"用户.修订说明",
params.inputs,
params.blackboard,
inputMerge,
).trim();
const draftBlocks: string[] = [];
if (revisionNote) {
for (const tag of params.outputTags) {
const alreadyInSegments = segments.some((s) => s.tags.includes(tag));
if (alreadyInSegments) continue;
const draft = readTagContent(
tag,
params.inputs,
params.blackboard,
inputMerge,
).trim();
if (!draft) continue;
draftBlocks.push(`### \`${tag}\`\n\n${draft}`);
}
if (draftBlocks.length) {
blocks.push(
[
"## 【待改底稿】",
"",
"下列为**当前已有产物**。按「用户.修订说明」以及「用户.worker答复」中的追问作答(选项与补充)在其上修改,并写回同名 outputTags。",
"节点方法(task / principles / probe / output)仍须遵守。保留未要求改动的结构与结论;禁止无故整份重写。",
"",
...draftBlocks,
].join("\n"),
);
}
if (inheritMode && draftBlocks.length) {
blocks.push(
[
"## 【待改底稿】",
"",
"下列为**当前已有产物**(只保留这一份最新稿)。在其上落实【用户要求】里较新的条目;与旧要求冲突时以较新的为准,未点名改写的旧条仍须保留在产物里。",
"节点方法(task / principles / probe / output)仍须遵守。禁止无视较新要求原样交回。",
"",
...draftBlocks,
].join("\n"),
);
}
const taskLines = [
@@ -243,10 +361,12 @@ export function assembleWorkerContext(params: {
`- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`,
"",
];
if (revisionNote) {
if (inheritMode) {
taskLines.push(
"**修订模式**:接着上方【待改底稿】(若有)与依赖产物,落实「用户.修订说明」以及「用户.worker答复」里的追问作答(选项与补充意见)。",
"不要从零另起一份;不要扩大修改面。标为「只读 / 已定稿」的分区仍不可改。节点方法仍有效。",
inheritExisting
? "**回头修改**:接着上方【既有产物 · 继承修改】(以及【待改底稿】若有)继续改,写回同一产物。"
: "**修订模式**:以【待改底稿】为底,落实【用户要求】;冲突时以较新的为准。",
"不要从零另起一份。标为「只读 / 已定稿」的分区仍不可改。",
);
} else {
taskLines.push(
+316 -63
View File
@@ -53,9 +53,41 @@ const DEFAULT_SKILLS_ROOT = path.resolve(
"../../skills",
);
/** 步骤调用参数(编排期钉死;执行期只读) */
/** 步骤调用参数。普通节点:编排期钉死、执行只读。〔先验产物〕:编排器规划的产物内容(可空),执行时注入为【规划产物】。 */
export type CreationFlowStepParams = Record<string, unknown>;
/**
* 节点特性。缺省 = 普通执行步。后续会加更多 kind。
* prior-artifact = 先验产物:须先定「写什么」(生成规则、具体实例);编排器可提前规划,步内再钉/修订。
*/
export const MODULE_NODE_KINDS = ["prior-artifact"] as const;
export type ModuleNodeKind = (typeof MODULE_NODE_KINDS)[number];
export const MODULE_NODE_KIND_FLAGS: Record<ModuleNodeKind, string> = {
"prior-artifact": "〔先验产物〕",
};
export function parseModuleNodeKind(raw: unknown): ModuleNodeKind | undefined {
if (typeof raw !== "string") return undefined;
const key = raw.trim();
return (MODULE_NODE_KINDS as readonly string[]).includes(key)
? (key as ModuleNodeKind)
: undefined;
}
export function isPriorArtifactModule(
module: { kind?: ModuleNodeKind } | null | undefined,
): boolean {
return module?.kind === "prior-artifact";
}
/**
* 本步怎么跑:
* - fresh(缺省)= 从零产出。〔可反复〕再编入新 id 必须用这个——彻底新建,不改旧条。
* - revise = 回头修改:继承 `revises` 所指那步的既有产物继续改。
*/
export type CreationFlowStepMode = "fresh" | "revise";
export type CreationFlowStep = {
/**
* 本局步骤唯一 id(验收与 depends_on 用这个)。
@@ -67,10 +99,15 @@ export type CreationFlowStep = {
/** 依赖的其它步骤 id(旧稿若 name 唯一也可写 name) */
depends_on: string[];
/**
* 本步调用参数。有「编排参数」声明的能力必须在进 design-step 前钉齐必填项。
* 例:生成规则 → target;具体实例 → rule_id。
* 本步调用参数。
* 普通节点:有必填声明时须在进执行前钉齐。
* 〔先验产物〕:编排器规划的产物内容(可空);执行注入【规划产物】,步内可修订。
*/
params?: CreationFlowStepParams;
/** 缺省 fresh。revise = 继承旧产物修改,不是再生成一条。 */
mode?: CreationFlowStepMode;
/** mode=revise 时:被改的既有步骤 id(须更前、同 name) */
revises?: string;
};
export type CreationFlowStatus = "open" | "closed";
@@ -111,14 +148,29 @@ export type ModuleCatalogEntry = {
* 程序不硬拦;给编排与校验提示。
*/
repeatable?: boolean;
/**
* 可选:创作终节点。选定后程序收口并保存,其后不要再追加步骤。
*/
closer?: boolean;
/**
* 可选:程序步。不抛默认问题、不经「同意并开始」,提案后直接执行;
* 产物仍走验收。适合投影排序这类只排序、几乎不问用户的收成步。
*/
auto?: boolean;
/**
* 可选:节点特性。缺省为普通执行步。
* prior-artifact = 先验产物,须先定写什么;params 是规划产物而非拦执行的必填项。
*/
kind?: ModuleNodeKind;
/**
* 可选:默认问题(开场白)。优先用 prompt.md 的 ```opening 块;
* catalog 写了则作覆盖。程序发出,不经 LLM。
*/
opening?: string;
/**
* 可选:编排期步骤参数声明。
* 有 required 项时,steps[].params 必须在进执行前钉齐;勿把选型推迟到 design-step。
* 可选:步骤参数声明。
* 普通节点:required 须在进执行前钉齐。
* 〔先验产物〕:规划产物字段;可提前写入 params,空则步内钉,不拦确认开干。
*/
params?: ModuleParamSpec[];
/** 来自 prompt.md ```meta:何时该选用(编排选型) */
@@ -191,15 +243,21 @@ export type CreationFlowUserView = {
id: string;
name: string;
depends_on: string[];
/** 同能力第几次(>1 时 UI 可标「再来」) */
/** 同能力第几次(>1 时 UI 可标「再来」;回头修改不计入「第 N 次新建」) */
occurrence?: number;
/** fresh=从零新建;revise=回头修改 */
mode?: CreationFlowStepMode;
/** 回头修改时:被改的既有步骤 id */
revises?: string;
/** 目录里的短声明(有则展示) */
declaration?: string;
repeatable?: boolean;
/** 本步调用参数(编排期钉死) */
/** 本步调用参数(普通节点编排钉死;先验产物=规划内容) */
params?: CreationFlowStepParams;
/** 参数缺必填项时的提示(给人看) */
/** 参数缺必填项时的提示(给人看);先验产物不拦执行,不出现此项 */
paramsMissing?: string[];
/** 节点特性(如先验产物) */
kind?: ModuleNodeKind;
/** 已执行 / 将要执行 / 未执行 */
runState: FlowStepRunState;
}>;
@@ -241,11 +299,12 @@ export function normalizeStepParams(
return Object.keys(out).length > 0 ? out : undefined;
}
/** 目录声明的必填参数中,本步仍缺失的 key(按声明顺序) */
/** 目录声明的必填参数中,本步仍缺失的 key(按声明顺序)。〔先验产物〕不拦执行,恒为 []。 */
export function missingRequiredStepParams(
step: Pick<CreationFlowStep, "params">,
module: ModuleCatalogEntry | null | undefined,
): string[] {
if (isPriorArtifactModule(module)) return [];
const specs = module?.params ?? [];
if (specs.length === 0) return [];
const params = step.params ?? {};
@@ -278,14 +337,120 @@ export function formatStepParamsForPrompt(
.join("\n");
}
/** 为缺 id 的步骤补齐唯一 id;同 name 多次 → name#2、name#3… */
const PRIOR_ARTIFACT_CONTEXT_TITLE =
"## 【规划产物】编排器预先为本步规划的产物内容(先验;缺则步内钉;步内可修订)";
/** 〔先验产物〕注入块:规划可空,身份写在片段自身。 */
export function formatPriorArtifactContext(
params: CreationFlowStepParams | null | undefined,
): string {
const body = formatStepParamsForPrompt(params);
const note =
!params || Object.keys(params).length === 0
? "编排器尚未规划本步具体写什么。先在本步与用户钉「写什么」,再填产物。"
: "以上为先验规划,不是锁死合同。用户改对象或范围时,以本步最新认定为准,并重钉对应英文 id。";
return `${PRIOR_ARTIFACT_CONTEXT_TITLE}\n\n${body}\n\n${note}`;
}
const REVISE_MODE_ALIASES = new Set([
"revise",
"amend",
"edit",
"修改",
"回头修改",
"继承修改",
]);
const FRESH_MODE_ALIASES = new Set([
"fresh",
"new",
"create",
"新建",
"再来",
"再来一次",
]);
/** 解析 DAG 步骤的 mode;无法识别则视为缺省(fresh) */
export function parseCreationFlowStepMode(raw: unknown): CreationFlowStepMode | undefined {
if (typeof raw !== "string") return undefined;
const key = raw.trim().toLowerCase();
if (!key) return undefined;
if (REVISE_MODE_ALIASES.has(key) || REVISE_MODE_ALIASES.has(raw.trim())) {
return "revise";
}
if (FRESH_MODE_ALIASES.has(key) || FRESH_MODE_ALIASES.has(raw.trim())) {
return "fresh";
}
return undefined;
}
export function isReviseStep(
step: Pick<CreationFlowStep, "mode"> | null | undefined,
): boolean {
return step?.mode === "revise";
}
function parseStepRevisesRef(raw: Record<string, unknown>): string | undefined {
for (const key of ["revises", "revise_of", "revises_id"] as const) {
const v = raw[key];
if (typeof v === "string" && v.trim()) return v.trim();
}
return undefined;
}
type DraftCreationFlowStep = {
id?: string;
name: string;
depends_on: string[];
params?: CreationFlowStepParams;
mode?: CreationFlowStepMode;
revises?: string;
};
function withStepModeFields(
step: DraftCreationFlowStep,
): Pick<CreationFlowStep, "mode" | "revises"> {
return {
...(step.mode === "revise" ? { mode: "revise" as const } : {}),
...(step.revises ? { revises: step.revises } : {}),
};
}
/**
* 回头修改步:补 revises(最近一条同名更前步),并继承其 params(本步已写的优先)。
*/
export function normalizeReviseSteps(
steps: CreationFlowStep[],
): CreationFlowStep[] {
return steps.map((step, i) => {
if (step.mode !== "revise") return step;
let revises = step.revises?.trim() || "";
if (!revises) {
for (let j = i - 1; j >= 0; j--) {
if (steps[j]?.name === step.name) {
revises = steps[j]!.id;
break;
}
}
}
const origin = revises
? steps.find((s) => s.id === revises || s.name === revises)
: undefined;
const inherited = origin?.params;
const params =
inherited && Object.keys(inherited).length
? { ...inherited, ...(step.params ?? {}) }
: step.params;
return {
...step,
...(revises ? { revises } : {}),
...(params && Object.keys(params).length ? { params } : {}),
};
});
}
/** 为缺 id 的步骤补齐唯一 id;同 name 多次 → name#n;回头修改 → name·改 */
export function ensureCreationFlowStepIds(
steps: Array<{
id?: string;
name: string;
depends_on: string[];
params?: CreationFlowStepParams;
}>,
steps: Array<DraftCreationFlowStep>,
): CreationFlowStep[] {
const used = new Set<string>();
const nameCount = new Map<string, number>();
@@ -295,10 +460,20 @@ export function ensureCreationFlowStepIds(
const name = raw.name.trim();
const n = (nameCount.get(name) ?? 0) + 1;
nameCount.set(name, n);
const revise = raw.mode === "revise";
let id = typeof raw.id === "string" ? raw.id.trim() : "";
if (!id) {
id = n === 1 ? name : `${name}#${n}`;
if (revise) {
id = n === 1 ? `${name}·改` : `${name}·改`;
if (used.has(id)) {
let i = 2;
while (used.has(`${name}·改#${i}`)) i++;
id = `${name}·改#${i}`;
}
} else {
id = n === 1 ? name : `${name}#${n}`;
}
}
if (used.has(id)) {
let i = 2;
@@ -311,9 +486,10 @@ export function ensureCreationFlowStepIds(
name,
depends_on: raw.depends_on.map((d) => d.trim()).filter(Boolean),
...(raw.params ? { params: raw.params } : {}),
...withStepModeFields(raw),
});
}
return out;
return normalizeReviseSteps(out);
}
/** 按 id 或(唯一)name 解析步骤引用 */
@@ -378,12 +554,7 @@ export function parseCreationFlow(raw: string | undefined | null): CreationFlow
const stepsRaw = row.steps;
if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) return null;
const drafted: Array<{
id?: string;
name: string;
depends_on: string[];
params?: CreationFlowStepParams;
}> = [];
const drafted: DraftCreationFlowStep[] = [];
for (const item of stepsRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const s = item as Record<string, unknown>;
@@ -395,7 +566,17 @@ export function parseCreationFlow(raw: string | undefined | null): CreationFlow
? depsRaw.map((d) => String(d).trim()).filter(Boolean)
: [];
const params = normalizeStepParams(s.params);
drafted.push({ id, name, depends_on, ...(params ? { params } : {}) });
const mode =
parseCreationFlowStepMode(s.mode) ?? parseCreationFlowStepMode(s.intent);
const revises = parseStepRevisesRef(s);
drafted.push({
id,
name,
depends_on,
...(params ? { params } : {}),
...(mode ? { mode } : {}),
...(revises ? { revises } : {}),
});
}
const steps = ensureCreationFlowStepIds(drafted);
@@ -440,6 +621,9 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
? m.opening.trim()
: undefined;
const repeatable = m.repeatable === true;
const closer = m.closer === true;
const auto = m.auto === true;
const kind = parseModuleNodeKind(m.kind);
const params = parseModuleParamSpecs(m.params);
modules.push({
id,
@@ -447,6 +631,9 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
declaration,
artifact,
...(repeatable ? { repeatable: true } : {}),
...(closer ? { closer: true } : {}),
...(auto ? { auto: true } : {}),
...(kind ? { kind } : {}),
...(opening ? { opening } : {}),
...(params ? { params } : {}),
});
@@ -702,7 +889,9 @@ export function mergeCreationFlowPreservingAccepted(params: {
const changed =
current.name !== frozen.name ||
JSON.stringify(current.depends_on) !== JSON.stringify(frozen.depends_on) ||
JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null);
JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null) ||
(current.mode ?? "fresh") !== (frozen.mode ?? "fresh") ||
(current.revises ?? "") !== (frozen.revises ?? "");
if (changed) {
steps[at] = frozen;
restored.push(frozen.id);
@@ -795,29 +984,49 @@ export async function loadModuleCatalog(
/** 注入 design-flow:能力名 + 选型字段(meta)+ 编排参数;非执行全文 */
export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
const lines = catalog.modules.map((m) => {
const flags = m.repeatable ? "〔可反复〕" : "";
const flags = [
m.repeatable ? "〔可反复〕" : "",
m.closer ? "〔收口〕" : "",
m.auto ? "〔程序步〕" : "",
m.kind ? MODULE_NODE_KIND_FLAGS[m.kind] : "",
]
.filter(Boolean)
.join("");
const parts: string[] = [`- ${m.name}${flags}:${m.declaration}`];
if (m.when) parts.push(` 何时用:${indentMultiline(m.when, " ")}`);
if (m.when_not) parts.push(` 何时不用:${indentMultiline(m.when_not, " ")}`);
if (m.boundary) parts.push(` 边界:${indentMultiline(m.boundary, " ")}`);
if (m.params && m.params.length > 0) {
parts.push(
` 编排参数:${m.params
.map((p) => {
const req = p.required ? "必填" : "可选";
const hint = p.hint ? `,${p.hint}` : "";
return `${p.key}(${p.label},${req}${hint})`;
})
.join(";")}`,
);
if (isPriorArtifactModule(m)) {
parts.push(
` 规划产物字段(可提前写入 params;空则步内钉):${m.params
.map((p) => {
const hint = p.hint ? `,${p.hint}` : "";
return `${p.key}(${p.label}${hint})`;
})
.join(";")}`,
);
} else {
parts.push(
` 编排参数:${m.params
.map((p) => {
const req = p.required ? "必填" : "可选";
const hint = p.hint ? `,${p.hint}` : "";
return `${p.key}(${p.label},${req}${hint})`;
})
.join(";")}`,
);
}
}
return parts.join("\n");
});
return [
"【能力 · 可选工序】",
"按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入。",
"选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta);有「编排参数」的步骤必须在 DAG 里写齐 params,缺参时用 askUser 选项+其它,禁止空壳进执行。",
"生成规则:改 target 时必须同步改 rule_id(英文 kebab-case)与步骤 id(生成规则·{对象});禁止只改中文对象沿用旧后台 id。",
"按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入;标〔收口〕的是终节点:选定后结束创作并保存,排在细化终稿之后,其后不要再追加步骤;标〔程序步〕的确认编排后直接执行(不抛默认问题、不经同意并开始),产物仍验收。",
"标〔先验产物〕的步骤(如生成规则、具体实例):须先定「写什么」。编排器可以提前规划多条并写入 params——这是正确的;执行时程序注入【规划产物】。规划可空,「生成什么」在步内钉。禁止为钉对象而 askUser 拦在确认开干前。规划或修订对象时同步改 rule_id(英文 kebab-case)与步骤 id(如 生成规则·{对象}),禁止只改中文沿用旧后台 id。",
"其它有「编排参数」的步骤:确认开干前写齐必填 params;缺参时用 askUser 选项+其它。",
"选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta)。",
"两种「再来一次」必须分开:〔可反复〕再编入新 id(mode 缺省/fresh)= 彻底新建一条;回头修改已完成节点 = mode=revise + revises=原步 id,继承旧产物继续改。禁止把回头修改写成又一条 fresh。",
"不要把能力执行全文塞进本步;执行由 design-step 注入。",
lines.join("\n"),
].join("\n");
@@ -1153,8 +1362,8 @@ export function formatFlowProgressForAgent(params: {
repeatable: boolean,
) => {
const flag = repeatable
? "〔可反复:可再追加不同 id〕"
: "〔不可反复:禁止再排一次执行〕";
? "〔可反复:mode=fresh 再追加 = 彻底新建;要改旧条用 mode=revise〕"
: "〔不可反复:禁止再排新建;要改已完成节点用 mode=revise 回头修改〕";
const idBit = id ? `(id: ${id})` : "";
doneLines.push(`- ${name}${idBit} · ${why} ${flag}`);
doneNames.add(name);
@@ -1196,9 +1405,12 @@ export function formatFlowProgressForAgent(params: {
const draftLines: string[] = [];
if (flow) {
for (const step of flow.steps) {
if (doneIds.has(step.id) || doneNames.has(step.name)) continue;
if (doneIds.has(step.id) || isStepAccepted(step, accepted)) continue;
const reviseBit = isReviseStep(step)
? ` · 回头修改${step.revises ? `(revises: ${step.revises})` : ""}`
: "";
draftLines.push(
`- ${step.name}(id: ${step.id})· 已在草案,保留原 id;不要当作新规划再写一遍`,
`- ${step.name}(id: ${step.id})${reviseBit} · 已在草案,保留原 id;不要当作新规划再写一遍`,
);
}
}
@@ -1210,9 +1422,9 @@ export function formatFlowProgressForAgent(params: {
const lines = [
"【流程进度】(程序钉死,必须遵守)",
"你的任务是追加缺口,不是从头规划。",
"已完成的非反复技能:只保留原 id 作 depends_on 锚点,禁止再作为将要执行的一步。",
"草案里已有的步骤:原样保留,不要重排一遍开局。",
"只有能力目录标了〔可反复〕的(如生成规则、具体实例)才允许再编入新 id。",
"已完成的非反复技能:禁止再排一次「新建」(mode=fresh)。用户要改已完成节点 → 追加新 id,mode=revise,revises=原步 id;程序会继承既有产物。",
"〔可反复〕再编入新 id 且 mode 缺省/fresh = 彻底新建一条(新对象/新批次),不是改旧的。要改已有那一条 → 同样用 mode=revise。",
"草案里已有的步骤:原样保留,不要重排一遍开局。回头修改步插在最近已验收步之后、尚未执行的步之前。",
];
if (doneLines.length) {
lines.push("", "已完成:", ...doneLines);
@@ -1258,10 +1470,7 @@ export function validateCreationFlow(
const allowed = catalog
? new Set(catalog.modules.map((m) => m.name))
: null;
const nameCount = new Map<string, number>();
for (const step of flow.steps) {
nameCount.set(step.name, (nameCount.get(step.name) ?? 0) + 1);
}
const reportedFreshDup = new Set<string>();
for (let i = 0; i < flow.steps.length; i++) {
const step = flow.steps[i]!;
@@ -1275,14 +1484,44 @@ export function validateCreationFlow(
}
const mod = catalog?.modules.find((m) => m.name === step.name);
if (
if (isReviseStep(step)) {
const ref = step.revises?.trim() ?? "";
if (!ref) {
errors.push(
`「${step.id}」是回头修改,但未标明 revises(应指向更前的同名步骤 id)`,
);
} else {
const origin = findStepByRef(flow, ref);
if (!origin) {
errors.push(
`「${step.id}」回头修改「${ref}」,但流程中没有该步骤`,
);
} else if (origin.id === step.id) {
errors.push(`「${step.id}」不能回头修改自己`);
} else if (origin.name !== step.name) {
errors.push(
`「${step.id}」回头修改「${origin.id}」,但能力名不同(${step.name} ≠ ${origin.name})`,
);
} else {
const originIndex = flow.steps.findIndex((s) => s.id === origin.id);
if (originIndex >= i) {
errors.push(
`「${step.id}」回头修改「${origin.id}」,但「${origin.id}」未排在其前面`,
);
}
}
}
} else if (
catalog &&
(nameCount.get(step.name) ?? 0) > 1 &&
mod &&
mod.repeatable !== true
mod.repeatable !== true &&
!reportedFreshDup.has(step.name) &&
flow.steps.filter((s) => s.name === step.name && !isReviseStep(s)).length >
1
) {
reportedFreshDup.add(step.name);
errors.push(
`「${step.name}」出现多次,但目录未标 repeatable(仅可反复能力可同名多次)`,
`「${step.name}」出现多次新建,但目录未标 repeatable(非反复技能要改旧稿请用 mode=revise)`,
);
}
@@ -1306,7 +1545,7 @@ export function validateCreationFlow(
return { ok: errors.length === 0, errors };
}
/** 进 design-step 前:本步必填编排参数是否已齐 */
/** 进 design-step 前:本步必填编排参数是否已齐。〔先验产物〕不拦。 */
export function validateStepReadyToRun(
step: CreationFlowStep,
catalog: ModuleCatalog | null,
@@ -1362,14 +1601,19 @@ export function formatCreationFlowForUser(
const decl = new Map(
(catalog?.modules ?? []).map((m) => [m.name, m] as const),
);
const seenName = new Map<string, number>();
const seenFreshName = new Map<string, number>();
const next = nextPendingStep(flow, acceptedStepIds);
return {
brief: flow.brief,
status: flow.status,
steps: flow.steps.map((s, i) => {
const n = (seenName.get(s.name) ?? 0) + 1;
seenName.set(s.name, n);
const revise = isReviseStep(s);
let occurrence: number | undefined;
if (!revise) {
const n = (seenFreshName.get(s.name) ?? 0) + 1;
seenFreshName.set(s.name, n);
occurrence = n;
}
const mod = decl.get(s.name);
const paramsMissing = missingRequiredStepParams(s, mod);
const runState: FlowStepRunState = isStepAccepted(s, acceptedStepIds)
@@ -1382,9 +1626,12 @@ export function formatCreationFlowForUser(
id: s.id,
name: s.name,
depends_on: s.depends_on,
occurrence: n,
occurrence,
...(revise ? { mode: "revise" as const } : {}),
...(s.revises ? { revises: s.revises } : {}),
declaration: mod?.declaration,
repeatable: mod?.repeatable,
...(mod?.kind ? { kind: mod.kind } : {}),
runState,
...(s.params ? { params: s.params } : {}),
...(paramsMissing.length > 0 ? { paramsMissing } : {}),
@@ -1443,6 +1690,7 @@ export function nextPendingStep(
acceptedStepIds: readonly string[],
): CreationFlowStep | null {
if (!flow?.steps.length) return null;
const ready: CreationFlowStep[] = [];
for (const step of flow.steps) {
if (isStepAccepted(step, acceptedStepIds)) continue;
const depsOk = step.depends_on.every((dep) => {
@@ -1450,9 +1698,9 @@ export function nextPendingStep(
if (!depStep) return false;
return isStepAccepted(depStep, acceptedStepIds);
});
if (depsOk) return step;
if (depsOk) ready.push(step);
}
return null;
return ready.find((s) => isReviseStep(s)) ?? ready[0] ?? null;
}
/** 当前已列出的步骤是否都已验收(不管 status) */
@@ -1532,8 +1780,10 @@ export type DesignStepBinding = {
module: ModuleCatalogEntry;
depTags: string[];
modulePrompt: string;
/** 程序开场白;无则本步直接调 LLM */
/** 程序开场白;无则本步直接调 LLM。回头修改不抛 opening。 */
opening: string | null;
/** 回头修改时注入的既有产物 tag */
inheritTag: string | null;
};
/**
@@ -1570,7 +1820,9 @@ export async function resolveDesignStepBinding(params: {
`# ${module.name}\n\n(模块 prompt.md 缺失,请补充 skills/.../modules/${module.id}/prompt.md)`;
const opening =
module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null;
isReviseStep(step) || module.auto
? null
: module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null;
return {
step,
@@ -1578,5 +1830,6 @@ export async function resolveDesignStepBinding(params: {
depTags: dependencyArtifactTags(step, catalog, flow),
modulePrompt: formatModulePromptForLlm(modulePromptRaw),
opening,
inheritTag: isReviseStep(step) ? module.artifact : null,
};
}
+13 -4
View File
@@ -31,6 +31,7 @@ import {
slotOrderForRef,
} from "./context-order.js";
import type { ContextSegmentDef } from "./context-segments.js";
import { PLAY_VISIBLE_BODY_INSTRUCTION } from "./present-packet.js";
type WorkerTemplateDoc = {
id?: string;
@@ -44,8 +45,9 @@ type WorkerTemplateDoc = {
/**
* 解析本次 worker 的验收模式(创作 / run 共用入口)。
* - design-* / opening-generator 磁盘创作 worker → 始终 user_confirmed
* - 游玩回合:一律 no_confirmation(一句输入连跑,不穿插验收)
* - Worker 集 acceptance: review → user_confirmed;continue → no_confirmation
* - 未声明 acceptance:design 生命周期默认确认;play 缺省按 review(稳妥)
* - 未声明 acceptance:创作默认确认
*/
export function resolveAcceptanceModeForWorker(params: {
session: RuntimeSession;
@@ -56,6 +58,9 @@ export function resolveAcceptanceModeForWorker(params: {
if (isDesignDiskWorker(workerId) || workerId === "opening-generator") {
return "user_confirmed";
}
if (inferLifecycleStage(params.session) === "play") {
return "no_confirmation";
}
const decl = buildInstanceWorkerDeclaration(
params.session,
@@ -66,7 +71,6 @@ export function resolveAcceptanceModeForWorker(params: {
if (entry?.acceptance === "continue") return "no_confirmation";
if (entry?.acceptance === "review") return "user_confirmed";
// 未写明:创作阶段默认验收;游玩缺省也验收(避免静默连跑)
return "user_confirmed";
}
@@ -210,6 +214,11 @@ export function buildDeclaredWorkerSkill(params: {
params.template?.duty?.trim() ||
`执行 ${id}`;
const excerpt = params.template?.prompt_excerpt?.trim() || "";
const visibleBodyRule =
id === "narrator" || id === "round-present"
? PLAY_VISIBLE_BODY_INSTRUCTION
: "";
const excerptWithRule = [excerpt, visibleBodyRule].filter(Boolean).join("\n\n");
const presentation = params.entry.presentation
? JSON.stringify(params.entry.presentation, null, 2)
: "";
@@ -217,7 +226,7 @@ export function buildDeclaredWorkerSkill(params: {
const premises = (params.workerSet?.core_premises ?? []).filter(Boolean);
const residentSection = formatResidentPromptSection(resident, id);
const personaText = [duty, excerpt].filter(Boolean).join("\n\n");
const personaText = [duty, excerptWithRule].filter(Boolean).join("\n\n");
const orderDoc = parseContextOrder(params.workerSet?.context_order);
const orderSlot = slotOrderForRef(orderDoc, id);
let contextSegments: ContextSegmentDef[] | undefined;
@@ -277,7 +286,7 @@ export function buildDeclaredWorkerSkill(params: {
"",
duty,
"",
excerpt ? `## 写法要点\n\n${excerpt}` : "",
excerptWithRule ? `## 写法要点\n\n${excerptWithRule}` : "",
presentation ? `## presentation(实例)\n\n\`\`\`json\n${presentation}\n\`\`\`` : "",
narrative ? `## 叙事指南\n\n${narrative}` : "",
premises.length
+40 -6
View File
@@ -516,7 +516,9 @@ export async function loadWorkerSkillWithContext(
resolveDesignStepBinding,
CREATION_CURRENT_STEP_TAG,
CREATION_MODULE_OPENING_TAG,
formatPriorArtifactContext,
formatStepParamsForPrompt,
isPriorArtifactModule,
} = await import("./creation-flow.js");
const binding = await resolveDesignStepBinding({
skillPackRoot: skill.skillPackRoot,
@@ -526,11 +528,27 @@ export async function loadWorkerSkillWithContext(
skillsRoot,
});
if (binding) {
const inheritTag = binding.inheritTag?.trim() || "";
const inheritNote = inheritTag
? `\n\n【回头修改】本步继承既有产物继续改,不是从零再生成。程序已注入【既有产物 · 继承修改】。保留未点名要改的部分,写回同一产物。禁止推倒重写,禁止再走一遍开场询问。`
: "";
const openingNote = binding.opening
? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM);用户首答在「用户.worker答复」。勿重复同一开场白,在其答复与提示词基础上继续追问或产出。`
: "";
const paramsBlock = `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
modulePromptBlock = `${paramsBlock}\n\n## 【本步方法 · ${binding.module.name}】\n\n${binding.modulePrompt.trim()}${openingNote}`;
const paramsBlock = isPriorArtifactModule(binding.module)
? formatPriorArtifactContext(binding.step.params)
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
let rosterBlock = "";
const depProjection =
binding.module.id === "context-order" ? "index" : undefined;
if (binding.module.id === "context-order") {
const { formatPlayAgentRosterForPrompt } = await import("./play-slots.js");
rosterBlock = `${formatPlayAgentRosterForPrompt()}\n\n`;
}
modulePromptBlock = `${rosterBlock}${paramsBlock}\n\n## 【本步方法 · ${binding.module.name}】\n\n${binding.modulePrompt.trim()}${openingNote}${inheritNote}`;
const depTags = inheritTag
? binding.depTags.filter((tag) => tag !== inheritTag)
: binding.depTags;
const baseInputs = [
"用户.需求",
"book.brief",
@@ -540,16 +558,29 @@ export async function loadWorkerSkillWithContext(
"设计.创作流程",
CREATION_CURRENT_STEP_TAG,
CREATION_MODULE_OPENING_TAG,
...binding.depTags,
...depTags,
...(inheritTag ? [inheritTag] : []),
];
const inputTags = [...new Set(baseInputs)];
const outputTags = [binding.module.artifact];
const depSegments = binding.depTags.map((tag, i) => ({
const depSegments = depTags.map((tag, i) => ({
id: `dep-${i}`,
tier: "static" as const,
tags: [tag],
label: `## 【依赖产物 · ${tag}】只读`,
label: `## 【依赖产物 · ${tag}】只读${depProjection === "index" ? "(仅 brief / 挂载 / 稳变)" : ""}`,
...(depProjection ? { projection: depProjection } : {}),
}));
const inheritSegment = inheritTag
? [
{
id: "inherit-existing",
tier: "static" as const,
tags: [inheritTag],
label:
"## 【既有产物 · 继承修改】在下列正文上继续改,写回同一产物;禁止从零另起一份",
},
]
: [];
const openingSegment = binding.opening
? [
{
@@ -562,13 +593,16 @@ export async function loadWorkerSkillWithContext(
: [];
patchedWorker = {
...worker,
name: `创作 · ${binding.module.name}`,
name: inheritTag
? `创作 · 回头修改 · ${binding.module.name}`
: `创作 · ${binding.module.name}`,
description: binding.module.declaration,
inputTags,
outputTags,
contextSegments: [
...(worker.contextSegments ?? []),
...openingSegment,
...inheritSegment,
...depSegments,
],
};
+200
View File
@@ -0,0 +1,200 @@
/**
* 开场白终节点:选定后落库、关 DAG。
* 能力 `opening-setup` 与磁盘 worker `opening-generator` 共用这条收口。
*/
import {
parseTableDoc,
stringifyTableDoc,
type TableDoc,
} from "../blackboard/table-cells.js";
import {
parseCreationFlow,
stringifyCreationFlow,
} from "./creation-flow.js";
import { parseContextFragment } from "./context-fragment.js";
export const OPENING_SETUP_ARTIFACT_TAG = "设计.开场白与开场变量";
export const OPENING_OUTPUT_TAG = "输出.开场白";
export const OPENING_INITIAL_VARS_TAG = "运行.初始变量";
export const OPENING_CURRENT_VARS_TAG = "变量.当前";
export const SLOT_CREATION_SEALED_BY_OPENING = "creationSealedByOpening";
export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex";
export const CREATION_SEALED_WAITING_MESSAGE =
"开场已选定,创作已收口并保存。可切换到「游玩」。";
export const INSTANCE_OPENING_SNAPSHOT_LABEL = "创作定稿(开场)";
export function isOpeningLockTag(tag: string): boolean {
const t = tag.trim();
return t === OPENING_OUTPUT_TAG || t === OPENING_INITIAL_VARS_TAG;
}
export function isOpeningSealArtifact(artifact: {
workerId?: string;
outputTags?: readonly string[];
}): boolean {
if ((artifact.workerId ?? "").trim() === "opening-generator") return true;
return (artifact.outputTags ?? []).includes(OPENING_SETUP_ARTIFACT_TAG);
}
export type OpeningVariable = {
name: string;
value: unknown;
note?: string;
};
export type OpeningSealPayload = {
candidates: string[];
selectedIndex: number;
selectedText: string;
variables: OpeningVariable[];
};
function asTrimmed(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
const t = v.trim();
return t || undefined;
}
function collectCandidateTexts(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const out: string[] = [];
for (const item of raw) {
if (typeof item === "string" && item.trim()) {
out.push(item.trim());
continue;
}
if (item && typeof item === "object" && !Array.isArray(item)) {
const row = item as Record<string, unknown>;
const text =
asTrimmed(row.全文) ||
asTrimmed(row.text) ||
asTrimmed(row.开场白) ||
asTrimmed(row.内容);
if (text) out.push(text);
}
}
return out;
}
function collectVariables(raw: unknown): OpeningVariable[] {
if (!Array.isArray(raw)) return [];
const out: OpeningVariable[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const name =
asTrimmed(row.名) || asTrimmed(row.name) || asTrimmed(row.key) || "";
if (!name) continue;
const note = asTrimmed(row.依据) || asTrimmed(row.note);
out.push({
name,
value: row.值 !== undefined ? row.值 : row.value,
...(note ? { note } : {}),
});
}
return out;
}
function bodyRecord(fragmentBody: unknown): Record<string, unknown> | null {
if (!fragmentBody || typeof fragmentBody !== "object" || Array.isArray(fragmentBody)) {
return null;
}
return fragmentBody as Record<string, unknown>;
}
/** 从开场白 fragment(或裸 JSON)抽出候选与变量。 */
export function parseOpeningSealPayload(
raw: string | null | undefined,
selectedIndex = 0,
): OpeningSealPayload | null {
if (!raw?.trim()) return null;
const fragment = parseContextFragment(raw);
const body = bodyRecord(fragment?.正文) ?? (() => {
try {
const parsed = JSON.parse(raw) as unknown;
return bodyRecord(parsed) ?? bodyRecord((parsed as { 正文?: unknown })?.正文);
} catch {
return null;
}
})();
const primary =
asTrimmed(body?.开场白全文) ||
asTrimmed(body?.["输出.开场白"]) ||
(typeof fragment?.正文 === "string" ? asTrimmed(fragment.正文) : undefined);
const fromList = collectCandidateTexts(body?.开场白候选);
const candidates: string[] = [];
const seen = new Set<string>();
const push = (text: string | undefined) => {
if (!text || seen.has(text)) return;
seen.add(text);
candidates.push(text);
};
push(primary);
for (const t of fromList) push(t);
if (candidates.length === 0) return null;
const max = candidates.length - 1;
const idx = Number.isFinite(selectedIndex)
? Math.min(max, Math.max(0, Math.trunc(selectedIndex)))
: 0;
return {
candidates,
selectedIndex: idx,
selectedText: candidates[idx] ?? candidates[0]!,
variables: collectVariables(body?.开场变量),
};
}
export function openingVariablesToTableDoc(
variables: readonly OpeningVariable[],
source = "worker:opening-setup",
): TableDoc {
const now = new Date().toISOString();
return {
rows: variables.map((v) => ({
key: v.name,
value: v.value,
rev: 1,
updatedAt: now,
source,
visibility: "visible" as const,
...(v.note ? { note: v.note } : {}),
})),
};
}
export function mergeOpeningTablePatch(
currentRaw: string | null | undefined,
variables: readonly OpeningVariable[],
source = "worker:opening-setup",
): string | null {
if (!variables.length) return null;
const patch = openingVariablesToTableDoc(variables, source);
const current = parseTableDoc(currentRaw);
if (!current) return stringifyTableDoc(patch);
const byKey = new Map(current.rows.map((r) => [r.key, r]));
const now = new Date().toISOString();
for (const row of patch.rows) {
const prev = byKey.get(row.key);
if (prev && String(prev.source).startsWith("user")) continue;
byKey.set(row.key, {
...row,
rev: (prev?.rev ?? 0) + 1,
updatedAt: now,
});
}
return stringifyTableDoc({ rows: [...byKey.values()] });
}
export function closeCreationFlowRaw(raw: string | null | undefined): string | null {
const flow = parseCreationFlow(raw);
if (!flow) return raw?.trim() ? raw : null;
if (flow.status === "closed") return stringifyCreationFlow(flow);
return stringifyCreationFlow({ ...flow, status: "closed" });
}
+51
View File
@@ -319,3 +319,54 @@ export function inferPlaySlotsFromWorkers(
chance: hasChance,
};
}
const AGENT_ID_ALIASES: Record<string, PlaySlotId | OnDemandSlotId> = {
auditor: "auditor",
旁观维护: "auditor",
表格管理: "auditor",
gm: "gm",
"world-simulator": "gm",
主世界层: "gm",
narrator: "narrator",
叙事转述: "narrator",
转述: "narrator",
perspective: "perspective",
"role-decide": "perspective",
角色视角: "perspective",
chance: "chance",
机遇裁定: "chance",
};
/** 把模型写的 id / 中文名 / worker ref 收成槽键 */
export function resolvePlayAgentId(
raw: string,
): PlaySlotId | OnDemandSlotId | undefined {
const t = raw.trim();
return AGENT_ID_ALIASES[t] ?? AGENT_ID_ALIASES[t.toLowerCase()];
}
/** 注入投影排序等晚段步骤:名册由程序钉死,模型只抄不发明。 */
export function formatPlayAgentRosterForPrompt(): string {
const lines = [
"## 【本局固定执行单元 · 程序钉死】",
"",
"禁止增删改名,禁止发明未入白名单的槽(如画图)。先抄名册,再给每个已启用者分配上下文 inserts。",
"",
"每轮管线(顺序固定):",
];
for (const id of PLAY_SLOT_ORDER) {
const meta = PLAY_SLOT_META[id];
const def = id === "perspective" ? "默认关" : "默认开";
lines.push(
`- \`${id}\` → ref=\`${DEFAULT_PLAY_SLOT_REFS[id]}\` **${meta.label}**(${def}):${meta.purpose}`,
);
}
lines.push("", "按需(不进每轮;enabled 时可空 inserts):");
for (const id of Object.keys(DEFAULT_ON_DEMAND_REFS) as OnDemandSlotId[]) {
const meta = ON_DEMAND_SLOT_META[id];
lines.push(
`- \`${id}\` → ref=\`${DEFAULT_ON_DEMAND_REFS[id]}\` **${meta.label}**(默认关):${meta.purpose}`,
);
}
return lines.join("\n");
}
+33
View File
@@ -0,0 +1,33 @@
/**
* 游玩回合:按运行规格 playWorkerIds 连跑(旁观 → 视角? → 主世界层 → 转述)。
* 与创作总管无关;收口后的输入走这里。
*/
export const SLOT_PLAY_TURN_QUEUE = "playTurnQueue";
export const SLOT_PLAY_LAYER_ACTIVE = "playLayerActive";
export const PLAY_WORKING_SNAPSHOT_ID = "play-working";
export const PLAY_WORKING_SNAPSHOT_LABEL = "当前游玩";
export function isPlayLayerActive(slots: Record<string, unknown> | undefined): boolean {
return Boolean(slots?.[SLOT_PLAY_LAYER_ACTIVE]);
}
export function readPlayTurnQueue(
slots: Record<string, unknown> | undefined,
): string[] {
const raw = slots?.[SLOT_PLAY_TURN_QUEUE];
if (!Array.isArray(raw)) return [];
return raw
.filter((id): id is string => typeof id === "string" && Boolean(id.trim()))
.map((id) => id.trim());
}
export function withPlayTurnQueue(
slots: Record<string, unknown>,
queue: string[] | undefined,
): Record<string, unknown> {
return {
...slots,
[SLOT_PLAY_TURN_QUEUE]: queue?.length ? queue : undefined,
};
}
+25 -2
View File
@@ -132,6 +132,9 @@ function normalizeBlocks(raw: unknown): PresentBlocks {
if (out.monitor === undefined && row.监控 !== undefined) {
out.monitor = row.监控 as PresentBlocks["monitor"];
}
if (typeof out.body === "string") {
out.body = stripPresentSourceFences(out.body);
}
return out;
}
@@ -170,15 +173,35 @@ export function presentFromPlainText(
return {
schema: PRESENT_SCHEMA,
shell,
blocks: { body: text },
blocks: { body: stripPresentSourceFences(text) },
};
}
/** 模型常把 tag 名写进正文开头;展示前剥掉 */
export function stripPresentSourceFences(raw: string): string {
let s = raw.trim();
for (let i = 0; i < 3; i += 1) {
const next = s
.replace(/^(#{1,6}\s*)?输出[.::]?\s*(用户展示|开场白)(?:\s*\n+|\s*$)/u, "")
.replace(/^【\s*输出[.::]?\s*(用户展示|开场白)\s*】(?:\s*\n+|\s*$)/u, "")
.trim();
if (next === s) break;
s = next;
}
return s;
}
export const PLAY_VISIBLE_BODY_INSTRUCTION = [
"用户可见正文(present.v1 的 blocks.body,或纯 Markdown 主读)默认 **500~2000 字**(按汉字计)。",
"不要写成几句气泡短信就结束,除非用户明确要求极短。",
"禁止把 tag 名(如「输出.用户展示」)、压缩摘要、过程日志写进正文。",
].join("\n");
export function parsePresentPacket(
raw: string | undefined | null,
fallbackShell: PresentShellId = "prose",
): PresentPacketView {
const text = raw?.trim() ?? "";
const text = stripPresentSourceFences(raw?.trim() ?? "");
if (!text) {
return {
ok: true,
+4 -2
View File
@@ -1,5 +1,6 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { RuntimeSession } from "../types/runtime.js";
import { isPlayLayerActive } from "./play-turn.js";
import {
deriveDesignStageScope,
deriveOnDemandWorkerScope,
@@ -45,8 +46,9 @@ export function canEnterPlay(session: RuntimeSession): boolean {
}
export function inferLifecycleStage(session: RuntimeSession): LifecycleStage {
const override = session.slots.uiLifecycleStage;
if (override === "play" && canEnterPlay(session)) return "play";
if (!canEnterPlay(session)) return "design";
if (session.slots.uiLifecycleStage === "play") return "play";
if (isPlayLayerActive(session.slots)) return "play";
return "design";
}
+7 -19
View File
@@ -3,16 +3,13 @@ import {
OpenAiCompatibleProvider,
type LlmProvider,
} from "../llm/client.js";
import { PresetLlmProvider } from "../llm/preset-wrapper.js";
import { resolveActivePreset } from "../preset/store.js";
import { loadAppSettings } from "../config/settings.js";
import {
TokenTrackingProvider,
type LlmTrackingContext,
} from "../llm/token-tracker.js";
wrapLlmForSession,
type LlmTrackingRef,
} from "../llm/preset-wrapper.js";
import type { ParsedWorkerSkill, SkillWorkerLlmBindings } from "./types.js";
type LlmTrackingRef = { current: LlmTrackingContext };
export type { LlmTrackingContext } from "../llm/token-tracker.js";
/** 按 ApiProfile.id 构建 LLM;找不到 profile 时回退 fallback */
export function createLlmForProfileId(
@@ -25,17 +22,10 @@ export function createLlmForProfileId(
return fallback;
}
let inner: LlmProvider = new OpenAiCompatibleProvider(
profileToLlmConfig(profile),
return wrapLlmForSession(
new OpenAiCompatibleProvider(profileToLlmConfig(profile)),
trackingRef,
);
const preset = resolveActivePreset(loadAppSettings().activePresetId);
if (preset) {
inner = new PresetLlmProvider(inner, () =>
resolveActivePreset(loadAppSettings().activePresetId),
);
}
if (!trackingRef) return inner;
return new TokenTrackingProvider(inner, () => trackingRef.current);
}
/**
@@ -142,5 +132,3 @@ export function filterInputsForRolePerspective(
}
return filtered;
}
export type { LlmTrackingContext };