引入上下文片段、固定槽与投影排序,并完善机遇裁定与游玩期 UI。

把创作产物收敛为可挂载片段与 play_slots/context_order,同步修订世界模拟器模块与运行时拼装。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 01:36:32 +08:00
parent 00dfcb6615
commit 94f67fa744
69 changed files with 7786 additions and 1283 deletions

View File

@@ -32,7 +32,7 @@ function buildMainAgentSystemPrompt(
4. 当信息不足时,使用 ask_userassessment 是主内容完备度评价questions 挂在其下且用户可跳过;一次 12 题。
5. 当需要执行任务时,使用 run_worker只指定 workerId。不要指定 inputTags 或 outputTags——Runtime 从 Worker Skill 读取。
6. requiresApproval 表示运行 worker 前是否需要用户确认。代笔模式通常为 true。
7. run_worker 可选 workerContext{ "roleId": "A" },用于 role-decide 等指定当前决策角色Runtime 写入 世界.当前角色.id)。
7. run_worker 可选 workerContext{ "roleId": "A" } 或 { "chance": { "op":"roll", "expression":"2d6" } }(机遇裁定走程序,勿让模型编随机)。
8. 你不能把未验收内容当作事实。
9. 向用户提问是 worker 的技能ask_user tool不是独立 worker。编排器只在调度层提问。
10. blackboardIndex 只有 tag 索引,不含正文 content。
@@ -175,9 +175,27 @@ export function parseMainAgentDecision(raw: string): MainAgentDecision {
let workerContext: MainAgentDecision["workerContext"];
const ctxRaw = obj.workerContext;
if (ctxRaw && typeof ctxRaw === "object" && !Array.isArray(ctxRaw)) {
const roleIdRaw = (ctxRaw as Record<string, unknown>).roleId;
const ctx = ctxRaw as Record<string, unknown>;
const roleIdRaw = ctx.roleId;
const roleId = typeof roleIdRaw === "string" ? roleIdRaw.trim() : undefined;
if (roleId) workerContext = { roleId };
const chanceRaw = ctx.chance;
const chance =
chanceRaw && typeof chanceRaw === "object" && !Array.isArray(chanceRaw)
? (chanceRaw as Record<string, unknown>)
: undefined;
if (roleId || chance) {
workerContext = {
...(roleId ? { roleId } : {}),
...(chance ? { chance } : {}),
};
}
}
// 兼容 tool 扁平参数 chance与 workerContext.chance 等价)
if (!workerContext?.chance && obj.chance && typeof obj.chance === "object") {
workerContext = {
...(workerContext ?? {}),
chance: obj.chance as Record<string, unknown>,
};
}
const assessmentRaw =

View File

@@ -133,7 +133,7 @@ export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [
function: {
name: "run_worker",
description:
"调度 worker 执行任务。只传 workerIdinputTags/outputTags 由 Runtime 从 Worker Skill 读取。",
"调度 worker。常规只传 workerId。调度 chance机遇裁定时传 chance 请求对象(程序掷骰/比点/抽签,禁止让模型编随机)。",
parameters: {
type: "object",
properties: {
@@ -147,6 +147,11 @@ export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [
type: "string",
description: "role-decide 等 worker 的当前角色 id",
},
chance: {
type: "object",
description:
"仅 workerId=chance{ op:'roll'|'compare'|'draw'|'pick', ... }。例 roll:{expression:'2d6+1'}draw:{pool:['a','b'],count:1}",
},
},
required: ["workerId", "reason", "requiresApproval"],
additionalProperties: false,

View File

@@ -686,6 +686,10 @@ export function applyEvent(
const mode = session.acceptanceMode ?? "user_confirmed";
if (mode === "user_confirmed" && result.session.pendingArtifactId) {
const sidecar = asOptionalSidecarQuestions(event.payload.questions);
const assessment =
typeof event.payload.assessment === "string"
? event.payload.assessment.trim()
: "";
const effects = [...result.effects];
if (sidecar?.length) {
effects.push({
@@ -700,6 +704,7 @@ export function applyEvent(
kind: "review_artifact",
artifactId: result.session.pendingArtifactId,
questions: sidecar,
assessment: assessment || undefined,
}),
};
}

View File

@@ -32,6 +32,10 @@ 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 {
DIALOGUE_HISTORY_TAG,
appendDialogueHistoryTurn,
} from "../skills/dialogue-history.js";
import { extractIntakeFromMessage } from "../intake/extract.js";
import { readIntakeValues } from "../intake/intake.js";
import { compressAfterWorkerAccept } from "./compress-after-worker.js";
@@ -65,6 +69,10 @@ import {
} from "../skills/creation-flow.js";
import { normalizeQuestions } from "../skills/question-protocol.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import {
executeChance,
resolveChanceRequest,
} from "../skills/chance-tools.js";
import {
resolveAcceptanceModeForWorker,
resolveRunnableWorker,
@@ -385,6 +393,10 @@ export class PhaseRuntime {
content: latestInput.trim(),
source: "user",
});
// 游玩期把用户话追加进可投影的「对话.历史」标签
if (inferLifecycleStage(session) === "play") {
this.appendDialogueHistory("用户", latestInput.trim());
}
}
const workerReply = session.slots["用户.worker答复"];
if (typeof workerReply === "string" && workerReply.trim()) {
@@ -501,6 +513,7 @@ export class PhaseRuntime {
sourceTag: declaration.sourceTag,
accepted: declaration.accepted,
playWorkerIds: declaration.playWorkerIds,
onDemandWorkerIds: declaration.onDemandWorkerIds,
designEndWorkerIds: declaration.designEndWorkerIds,
activeWorkerIds: declaration.activeWorkerIds,
},
@@ -715,11 +728,18 @@ export class PhaseRuntime {
if (!activeSkill?.name) {
throw new Error("当前没有 active skill无法运行 worker");
}
const workerId = resolveWorkerId(effect.workerId);
// 机遇裁定:纯程序,不走 LLM
if (workerId === "chance") {
await this.runChanceWorker(effect);
return;
}
if (!this.llm) {
throw new Error("未配置 LLM无法运行 worker");
}
const workerId = resolveWorkerId(effect.workerId);
this.lastWorkerRunSnapshot = {
workerId,
runtimeSession: structuredClone(this.session),
@@ -842,6 +862,7 @@ export class PhaseRuntime {
artifactId: artifact.id,
// 有产物时 askUser 挂到验收态,不阻断 Accept
questions: result.askUser?.length ? result.askUser : undefined,
assessment: result.askAssessment?.trim() || undefined,
},
});
this.maybeCompressAcceptedArtifact(artifact.id);
@@ -850,6 +871,83 @@ export class PhaseRuntime {
}
}
/** 机遇裁定:程序掷骰/比点/抽签,写入 运行.本轮.机遇 */
private async runChanceWorker(
effect: Extract<PhaseEffect, { type: "run_worker" }>,
): Promise<void> {
const workerId = "chance";
this.lastWorkerRunSnapshot = {
workerId,
runtimeSession: structuredClone(this.session),
blackboardItems: this.blackboard.exportItems(),
};
const acceptanceMode = resolveAcceptanceModeForWorker({
session: this.session,
blackboard: this.blackboard,
workerId: effect.workerId,
});
this.session = (
await this.dispatch({
type: "worker_started",
payload: {
workerId: effect.workerId,
stepId: this.session.currentStepId,
acceptanceMode:
this.session.resumeContext?.acceptanceMode ?? acceptanceMode,
},
})
).session;
const boardRaw = this.blackboard.getContentByTag("运行.机会请求");
const request = resolveChanceRequest({
workerContext: effect.workerContext ?? null,
blackboardRequestJson: boardRaw,
});
const result = request
? executeChance(request)
: {
schema: "chance.v1" as const,
op: "roll" as const,
ok: false,
summary: "机遇失败缺少请求workerContext.chance 或 运行.机会请求)",
detail: {},
error: "missing_request",
};
const content = JSON.stringify(result, null, 2);
const written = this.writeWorkerTagContent(
"运行.本轮.机遇",
content,
workerId,
);
this.session = {
...this.session,
slots: { ...this.session.slots, "运行.本轮.机遇": written },
};
const artifact = createArtifact({
workerId: effect.workerId,
stepId: this.session.currentStepId,
outputTags: ["运行.本轮.机遇"],
summary: result.summary,
});
this.session = {
...this.session,
artifacts: [...this.session.artifacts, artifact],
};
this.onMessage(`[机遇裁定] ${result.summary}\n\n${content}`);
await this.dispatch({
type: "worker_completed",
payload: { artifactId: artifact.id },
});
this.maybeCompressAcceptedArtifact(artifact.id);
}
/**
* 占位 workerworker_started → 可选自动 worker_completed。
* 演示与单测用,真实环境应替换为真实 worker 调度。
@@ -951,12 +1049,43 @@ export class PhaseRuntime {
content: toWrite,
source: workerId,
});
if (
inferLifecycleStage(this.session) === "play" &&
(tag === "输出.用户展示" || tag === "输出.开场白")
) {
this.appendDialogueHistory("助手", toWrite);
}
if (nextDoc) {
this.applyTableSideEffects(prevDoc, nextDoc, workerId);
}
return toWrite;
}
/** 避免 syncSlots 重复把同一句用户输入追加进历史 */
private lastAppendedUserHistory = "";
/** 追加一轮到黑板「对话.历史」(排序表可投影裁剪) */
private appendDialogueHistory(role: "用户" | "助手" | "系统", text: string): void {
const body = text.trim();
if (!body) return;
if (role === "用户") {
if (this.lastAppendedUserHistory === body) return;
this.lastAppendedUserHistory = body;
}
const prev = this.blackboard.getContentByTag(DIALOGUE_HISTORY_TAG) ?? "";
const next = appendDialogueHistoryTurn(prev, { role, text: body });
if (next === prev.trim()) return;
this.blackboard.write({
tag: DIALOGUE_HISTORY_TAG,
content: next,
source: "runtime",
});
this.session = {
...this.session,
slots: { ...this.session.slots, [DIALOGUE_HISTORY_TAG]: next },
};
}
/** 从 Worker 集 tables.side_effects 算边沿触发并写 tag / 记 fired */
private applyTableSideEffects(
prev: TableDoc | null,

View File

@@ -79,12 +79,24 @@ export function toolCallToDecision(call: ParsedToolCall): MainAgentDecision {
const reason = requireString(args, "reason");
const requiresApproval = Boolean(args.requiresApproval);
const roleId = optionalString(args, "roleId");
const chanceRaw = args.chance;
const chance =
chanceRaw && typeof chanceRaw === "object" && !Array.isArray(chanceRaw)
? (chanceRaw as Record<string, unknown>)
: undefined;
const workerContext =
roleId || chance
? {
...(roleId ? { roleId } : {}),
...(chance ? { chance } : {}),
}
: undefined;
return {
id: randomUUID(),
action: "run_worker",
reason,
workerId,
workerContext: roleId ? { roleId } : undefined,
workerContext,
requiresApproval,
statePatchAllowed: false,
};

View File

@@ -27,7 +27,8 @@ const WORKER_LABELS: Record<string, string> = {
"agent-burst": "编排器调度",
narrator: "叙事转述",
"role-decide": "角色决策",
"world-simulator": "世界推演",
"world-simulator": "世界",
chance: "机遇裁定",
"round-present": "回合呈现",
outline: "大纲 / 细纲",
"chapter-writer": "章节正文",
@@ -36,7 +37,7 @@ const WORKER_LABELS: Record<string, string> = {
const FIXED_TOPIC_LABELS: Record<string, string> = {
"aesthetics-interaction": "美学纲领与交互范式",
interaction: "交互范式",
narrative_guide: "叙事指南",
narrative_guide: "叙事指南与故事推进",
input_protocol: "输入协议",
core_premise: "核心前提",
aesthetics: "美学纲领",

View File

@@ -67,6 +67,15 @@ import {
formatWorkerSetForUser,
type WorkerSetUserView,
} from "../skills/worker-set-view.js";
import {
CONTEXT_ORDER_TAG,
applyContextOrderEdit,
mergeContextOrderIntoWorkerSetJson,
parseContextOrder,
serializeContextOrder,
synthesizeContextOrderFromWorkers,
type ContextOrderEdit,
} from "../skills/context-order.js";
import {
CREATION_FLOW_TAG,
CREATION_SELECTED_RECIPE_TAG,
@@ -292,6 +301,80 @@ export class SessionManager {
return this.toView(sessionId);
}
/**
* 编排上下文投影排序:写入 设计.worker集.context_order若有规格
* 并同步 设计.上下文投影排序。下次声明驱动拼装即按新序。
*/
patchContextOrder(sessionId: string, edit: ContextOrderEdit): SessionView {
const s = this.require(sessionId);
const bb = s.runtime.getBlackboard();
const workerSetRaw =
bb.getContentByTag("设计.worker集")?.trim() ||
bb.getContentByTag("设计.worker集.草稿")?.trim() ||
"";
const orderTagRaw = bb.getContentByTag(CONTEXT_ORDER_TAG)?.trim() || "";
let current = parseContextOrder(
workerSetRaw
? parseWorkerSetYaml(workerSetRaw).context_order
: undefined,
);
if (!current) current = parseContextOrder(orderTagRaw);
if (!current && workerSetRaw) {
const parsed = parseWorkerSetYaml(workerSetRaw);
// 与检查器合成逻辑一致:缺 context 时用包内默认契约(视图侧也会合成)
current = synthesizeContextOrderFromWorkers(
parsed.workers.map((w) => ({
ref: w.ref,
name: w.name,
context: w.context,
})),
parsed.play_slots,
);
}
if (!current && edit.action !== "replace") {
throw new Error("尚无上下文投影排序可编辑;请先完成游玩拓扑/细化终稿,或提交完整 replace");
}
if (!current && edit.action === "replace") {
current = parseContextOrder(edit.context_order);
if (!current) throw new Error("context_order 无法解析");
}
const next = applyContextOrderEdit(current!, edit);
const serialized = serializeContextOrder(next);
bb.write({
tag: CONTEXT_ORDER_TAG,
content: serialized,
source: "user",
});
if (workerSetRaw) {
const targetTag = bb.getContentByTag("设计.worker集")?.trim()
? "设计.worker集"
: bb.getContentByTag("设计.worker集.草稿")?.trim()
? "设计.worker集.草稿"
: null;
if (targetTag) {
bb.write({
tag: targetTag,
content: mergeContextOrderIntoWorkerSetJson(workerSetRaw, next),
source: "user",
});
}
} else {
// 尚无规格:至少落下排序表,供细化终稿合并
bb.write({
tag: CONTEXT_ORDER_TAG,
content: serialized,
source: "user",
});
}
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
/**
* 打开 Book优先恢复磁盘快照无快照则新建 Session。
* 若该 Book 已在内存中,直接返回当前视图。

View File

@@ -291,6 +291,96 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === "POST" && sub === "/context-order") {
const body = JSON.parse(await readBody(req)) as {
action?: string;
slotRef?: string;
index?: number;
delta?: number;
anchor?: string;
projection?: string;
context_order?: unknown;
};
try {
const action = body.action?.trim();
if (action === "replace") {
const view = sessionManager.patchContextOrder(sessionId, {
action: "replace",
context_order: body.context_order,
});
json(res, 200, view);
return;
}
if (action === "move") {
const delta = body.delta === 1 || body.delta === -1 ? body.delta : 0;
if (!body.slotRef?.trim() || typeof body.index !== "number" || !delta) {
json(res, 400, { error: "move 需要 slotRef、index、delta(±1)" });
return;
}
const view = sessionManager.patchContextOrder(sessionId, {
action: "move",
slotRef: body.slotRef.trim(),
index: body.index,
delta,
});
json(res, 200, view);
return;
}
if (action === "set_anchor") {
if (
!body.slotRef?.trim() ||
typeof body.index !== "number" ||
(body.anchor !== "pre_history" && body.anchor !== "post_history")
) {
json(res, 400, {
error: "set_anchor 需要 slotRef、index、anchor(pre_history|post_history)",
});
return;
}
const view = sessionManager.patchContextOrder(sessionId, {
action: "set_anchor",
slotRef: body.slotRef.trim(),
index: body.index,
anchor: body.anchor,
});
json(res, 200, view);
return;
}
if (action === "set_projection") {
const proj = body.projection;
if (
!body.slotRef?.trim() ||
typeof body.index !== "number" ||
(proj !== "fixed" &&
proj !== "full" &&
proj !== "summary" &&
proj !== "fields")
) {
json(res, 400, {
error: "set_projection 需要 slotRef、index、projection",
});
return;
}
const view = sessionManager.patchContextOrder(sessionId, {
action: "set_projection",
slotRef: body.slotRef.trim(),
index: body.index,
projection: proj,
});
json(res, 200, view);
return;
}
json(res, 400, {
error: "action 须为 move | set_anchor | set_projection | replace",
});
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "编排失败",
});
}
return;
}
if (req.method === "POST" && sub === "/context-traces/prune") {
try {
const result = sessionManager.pruneSessionContextTraces(sessionId);

325
src/skills/chance-tools.ts Normal file
View File

@@ -0,0 +1,325 @@
/**
* 程序机遇工具:骰子、比点、抽签/加权抽取。
* 供按需执行单元 `chance` 使用;禁止用模型「假装随机」。
*/
export type ChanceRollRequest = {
op: "roll";
/** 如 1d20、2d6+3、d100空格可忽略 */
expression: string;
reason?: string;
};
export type ChanceCompareRequest = {
op: "compare";
left: number;
right: number;
mode?: "gt" | "gte" | "lt" | "lte" | "eq";
reason?: string;
};
export type ChanceDrawRequest = {
op: "draw";
pool: string[];
count?: number;
/** 默认 true不放回 */
unique?: boolean;
reason?: string;
};
export type ChancePickRequest = {
op: "pick";
items: Array<{ id: string; weight?: number }>;
count?: number;
unique?: boolean;
reason?: string;
};
export type ChanceRequest =
| ChanceRollRequest
| ChanceCompareRequest
| ChanceDrawRequest
| ChancePickRequest;
export type ChanceResult = {
schema: "chance.v1";
op: ChanceRequest["op"];
ok: boolean;
reason?: string;
/** 人话摘要 */
summary: string;
detail: Record<string, unknown>;
error?: string;
};
const DICE_RE = /^\s*(\d*)\s*[dD]\s*(\d+)\s*([+-]\s*\d+)?\s*$/;
function randInt(min: number, max: number): number {
const lo = Math.ceil(min);
const hi = Math.floor(max);
return lo + Math.floor(Math.random() * (hi - lo + 1));
}
export function parseChanceRequest(raw: unknown): ChanceRequest | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const op = typeof row.op === "string" ? row.op.trim() : "";
if (op === "roll") {
const expression = typeof row.expression === "string" ? row.expression : "";
if (!expression.trim()) return null;
return {
op: "roll",
expression: expression.trim(),
reason: typeof row.reason === "string" ? row.reason : undefined,
};
}
if (op === "compare") {
if (typeof row.left !== "number" || typeof row.right !== "number") return null;
const mode = row.mode;
const okMode =
mode === "gt" ||
mode === "gte" ||
mode === "lt" ||
mode === "lte" ||
mode === "eq" ||
mode === undefined;
if (!okMode) return null;
return {
op: "compare",
left: row.left,
right: row.right,
mode,
reason: typeof row.reason === "string" ? row.reason : undefined,
};
}
if (op === "draw") {
if (!Array.isArray(row.pool)) return null;
const pool = row.pool.filter((x): x is string => typeof x === "string");
if (!pool.length) return null;
return {
op: "draw",
pool,
count: typeof row.count === "number" ? row.count : undefined,
unique: typeof row.unique === "boolean" ? row.unique : undefined,
reason: typeof row.reason === "string" ? row.reason : undefined,
};
}
if (op === "pick") {
if (!Array.isArray(row.items)) return null;
const items: Array<{ id: string; weight?: number }> = [];
for (const it of row.items) {
if (!it || typeof it !== "object" || Array.isArray(it)) continue;
const id = (it as { id?: unknown }).id;
const weight = (it as { weight?: unknown }).weight;
if (typeof id !== "string" || !id.trim()) continue;
items.push({
id: id.trim(),
weight: typeof weight === "number" ? weight : undefined,
});
}
if (!items.length) return null;
return {
op: "pick",
items,
count: typeof row.count === "number" ? row.count : undefined,
unique: typeof row.unique === "boolean" ? row.unique : undefined,
reason: typeof row.reason === "string" ? row.reason : undefined,
};
}
return null;
}
export function executeChance(request: ChanceRequest): ChanceResult {
const reason = request.reason;
try {
switch (request.op) {
case "roll":
return executeRoll(request, reason);
case "compare":
return executeCompare(request, reason);
case "draw":
return executeDraw(request, reason);
case "pick":
return executePick(request, reason);
default:
return fail("unknown", "不支持的 op", reason);
}
} catch (e) {
return fail(
(request as ChanceRequest).op,
e instanceof Error ? e.message : String(e),
reason,
);
}
}
function fail(
op: string,
error: string,
reason?: string,
): ChanceResult {
return {
schema: "chance.v1",
op: op as ChanceRequest["op"],
ok: false,
reason,
summary: `机遇失败:${error}`,
detail: {},
error,
};
}
function executeRoll(req: ChanceRollRequest, reason?: string): ChanceResult {
const m = req.expression.replace(/\s+/g, "").match(DICE_RE);
if (!m) {
return fail("roll", `无法解析骰式:${req.expression}`, reason);
}
const count = m[1] ? Number(m[1]) : 1;
const sides = Number(m[2]);
const mod = m[3] ? Number(m[3].replace(/\s+/g, "")) : 0;
if (!Number.isFinite(count) || count < 1 || count > 100) {
return fail("roll", "骰子个数须在 1100", reason);
}
if (!Number.isFinite(sides) || sides < 2 || sides > 1000) {
return fail("roll", "面数须在 21000", reason);
}
const dice: number[] = [];
let sum = 0;
for (let i = 0; i < count; i++) {
const v = randInt(1, sides);
dice.push(v);
sum += v;
}
const total = sum + mod;
const modText = mod === 0 ? "" : mod > 0 ? `+${mod}` : `${mod}`;
return {
schema: "chance.v1",
op: "roll",
ok: true,
reason,
summary: `掷骰 ${count}d${sides}${modText} → [${dice.join(",")}]${modText} = ${total}`,
detail: {
expression: `${count}d${sides}${modText}`,
dice,
modifier: mod,
total,
},
};
}
function executeCompare(
req: ChanceCompareRequest,
reason?: string,
): ChanceResult {
const mode = req.mode ?? "gte";
let win = false;
switch (mode) {
case "gt":
win = req.left > req.right;
break;
case "gte":
win = req.left >= req.right;
break;
case "lt":
win = req.left < req.right;
break;
case "lte":
win = req.left <= req.right;
break;
case "eq":
win = req.left === req.right;
break;
}
return {
schema: "chance.v1",
op: "compare",
ok: true,
reason,
summary: `比点 ${req.left} ${mode} ${req.right}${win ? "成立" : "不成立"}`,
detail: { left: req.left, right: req.right, mode, win },
};
}
function executeDraw(req: ChanceDrawRequest, reason?: string): ChanceResult {
const count = req.count ?? 1;
const unique = req.unique !== false;
if (count < 1 || count > 50) {
return fail("draw", "抽取数量须在 150", reason);
}
if (unique && count > req.pool.length) {
return fail("draw", "不放回抽取数量超过池大小", reason);
}
const bag = [...req.pool];
const drawn: string[] = [];
for (let i = 0; i < count; i++) {
const idx = randInt(0, bag.length - 1);
drawn.push(bag[idx]!);
if (unique) bag.splice(idx, 1);
}
return {
schema: "chance.v1",
op: "draw",
ok: true,
reason,
summary: `抽签 ×${count}${drawn.join("、")}`,
detail: { drawn, unique, poolSize: req.pool.length },
};
}
function executePick(req: ChancePickRequest, reason?: string): ChanceResult {
const count = req.count ?? 1;
const unique = req.unique !== false;
if (count < 1 || count > 50) {
return fail("pick", "抽取数量须在 150", reason);
}
let bag = req.items.map((it) => ({
id: it.id,
weight: it.weight && it.weight > 0 ? it.weight : 1,
}));
if (unique && count > bag.length) {
return fail("pick", "不放回加权抽取数量超过条目数", reason);
}
const picked: string[] = [];
for (let i = 0; i < count; i++) {
const totalW = bag.reduce((s, x) => s + x.weight, 0);
let r = Math.random() * totalW;
let chosen = bag[0]!;
for (const it of bag) {
r -= it.weight;
if (r <= 0) {
chosen = it;
break;
}
}
picked.push(chosen.id);
if (unique) bag = bag.filter((x) => x.id !== chosen.id);
}
return {
schema: "chance.v1",
op: "pick",
ok: true,
reason,
summary: `加权抽取 ×${count}${picked.join("、")}`,
detail: { picked, unique },
};
}
/** 从黑板正文或 workerContext 解析请求 */
export function resolveChanceRequest(params: {
workerContext?: Record<string, unknown> | null;
blackboardRequestJson?: string | null;
}): ChanceRequest | null {
const ctx = params.workerContext;
if (ctx && typeof ctx === "object") {
const nested = ctx.chance ?? ctx.request ?? ctx;
const parsed = parseChanceRequest(nested);
if (parsed) return parsed;
}
const raw = params.blackboardRequestJson?.trim();
if (!raw) return null;
try {
return parseChanceRequest(JSON.parse(raw));
} catch {
return null;
}
}

View File

@@ -0,0 +1,352 @@
/**
* 上下文片段产物context-fragment.v1解析与展示辅助。
* 规范docs/context-fragment-design.md
*/
import { extractJsonObjectText } from "./worker-set-parse.js";
import {
normalizeQuestions,
type QuestionItem,
} from "./question-protocol.js";
export const CONTEXT_FRAGMENT_SCHEMA = "context-fragment.v1" as const;
export type ContextFragmentStability = "stable" | "semi" | "volatile";
export type ContextFragment = {
schema: typeof CONTEXT_FRAGMENT_SCHEMA;
技能: string;
brief: string;
mount: string[];
稳变?: ContextFragmentStability;
正文: unknown;
开放问题: string[];
};
export type ContextFragmentView = {
ok: boolean;
parseError?: string;
fragment?: ContextFragment;
sections: Array<{ title: string; lines: string[] }>;
};
function asString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
const t = v.trim();
return t || undefined;
}
function asStringList(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.map((x) => asString(x)).filter((x): x is string => Boolean(x));
}
function normalizeStability(v: unknown): ContextFragmentStability | undefined {
if (v === "stable" || v === "semi" || v === "volatile") return v;
if (v === "稳" || v === "少变") return "stable";
if (v === "中" || v === "偶发") return "semi";
if (v === "变" || v === "常变") return "volatile";
return undefined;
}
/** 从任意 JSON 文本或对象解析片段;兼容无 schema 但含 brief+正文 的旧形 */
export function parseContextFragment(raw: unknown): ContextFragment | undefined {
let row: Record<string, unknown> | null = null;
if (typeof raw === "string") {
const text = extractJsonObjectText(raw) ?? raw.trim();
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>;
}
} catch {
return undefined;
}
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
row = raw as Record<string, unknown>;
}
if (!row) return undefined;
const hasSchema = row.schema === CONTEXT_FRAGMENT_SCHEMA;
const hasBody = "正文" in row || "body" in row;
const brief = asString(row.brief) ?? asString(row.);
if (!hasSchema && !(brief && hasBody)) return undefined;
const skill =
asString(row.) ?? asString(row.skill) ?? asString(row.name) ?? "";
const = row. !== undefined ? row.正文 : row.body !== undefined ? row.body : {};
const mount = asStringList(row.mount ?? row.);
const = asStringList(row. ?? row.open_questions);
return {
schema: CONTEXT_FRAGMENT_SCHEMA,
技能: skill,
brief: brief ?? "",
mount,
稳变: normalizeStability(row. ?? row.stability),
,
,
};
}
export function isContextFragmentDoc(doc: unknown): boolean {
return Boolean(parseContextFragment(doc));
}
/** 投影级别summary 优先 brieffields 尝试列出正文顶层键 */
export function projectFragmentContent(
raw: string,
projection: string,
): string {
const frag = parseContextFragment(raw);
const mode = projection.trim().toLowerCase();
if (!frag) {
if (mode === "summary" && raw.length > 800) return `${raw.slice(0, 800)}`;
return raw;
}
if (mode === "summary" || mode === "brief") {
return frag.brief || raw;
}
if (mode === "fields") {
if (frag. && typeof frag. === "object" && !Array.isArray(frag.)) {
const keys = Object.keys(frag. as object);
return [`brief: ${frag.brief}`, `字段: ${keys.join("、") || "(无)"}`].join("\n");
}
return frag.brief || raw;
}
if (mode === "fixed") {
return frag.brief || raw;
}
// full
return JSON.stringify(
{
schema: frag.schema,
技能: frag.技能,
brief: frag.brief,
mount: frag.mount,
稳变: frag.稳变,
正文: frag.正文,
开放问题: frag.开放问题,
},
null,
2,
);
}
export function contextFragmentToView(raw: unknown): ContextFragmentView {
const fragment = parseContextFragment(raw);
if (!fragment) {
return { ok: false, parseError: "无法解析为 context-fragment.v1", sections: [] };
}
const sections: Array<{ title: string; lines: string[] }> = [];
if (fragment.brief) sections.push({ title: "概要", lines: [fragment.brief] });
const meta: string[] = [];
if (fragment.) meta.push(`技能:${fragment.}`);
if (fragment.mount.length) meta.push(`挂载:${fragment.mount.join("、")}`);
if (fragment.) meta.push(`稳变:${fragment.}`);
if (meta.length) sections.push({ title: "挂载", lines: meta });
if (fragment. != null && fragment. !== "") {
if (typeof fragment. === "string") {
sections.push({ title: "正文", lines: [fragment.] });
} else if (typeof fragment. === "object") {
const lines = flattenObjectLines(fragment., 0, 4);
if (lines.length) sections.push({ title: "正文", lines });
}
}
const rawDoc =
typeof raw === "object" && raw && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: null;
const selfScore = rawDoc?.;
if (selfScore && typeof selfScore === "object" && !Array.isArray(selfScore)) {
const row = selfScore as Record<string, unknown>;
const dims = Array.isArray(row.) ? row. : [];
const lines = dims.map((d) => {
if (!d || typeof d !== "object") return String(d);
const r = d as Record<string, unknown>;
return `${r. ?? "?"}${r. ?? "?"}%${r. ? `${r.}` : ""}`;
});
if (typeof row. === "string" && row..trim()) {
lines.push(`薄弱点:${row..trim()}`);
}
if (lines.length) sections.push({ title: "自评", lines });
}
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. : [];
qs.forEach((q, i) => {
if (!q || typeof q !== "object") return;
const r = q as Record<string, unknown>;
lines.push(`${i + 1}. ${r. ?? "?"}`);
if (Array.isArray(r.) && r..length) {
lines.push(`选项:${r..map(String).join(" / ")}`);
}
if (typeof r. === "string" && r..trim()) {
lines.push(`示例:${r..trim()}`);
}
});
if (lines.length) sections.push({ title: "追问", lines });
}
if (fragment..length) {
sections.push({ title: "开放问题", lines: fragment.开放问题 });
}
return { ok: true, fragment, sections };
}
/** 从片段 JSON 抽出追问→询问卡;自评→评估导语(供验收挂载) */
export function extractFragmentAskSidecar(raw: unknown): {
questions: QuestionItem[];
assessment: string;
} {
let row: Record<string, unknown> | null = null;
if (typeof raw === "string") {
const text = extractJsonObjectText(raw) ?? raw.trim();
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>;
}
} catch {
return { questions: [], assessment: "" };
}
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
row = raw as Record<string, unknown>;
}
if (!row) return { questions: [], assessment: "" };
const looksFragment =
row.schema === CONTEXT_FRAGMENT_SCHEMA ||
(typeof row.brief === "string" && ("正文" in row || "body" in row));
if (!looksFragment) return { questions: [], assessment: "" };
const assessmentParts: string[] = [];
const selfScore = row.;
if (selfScore && typeof selfScore === "object" && !Array.isArray(selfScore)) {
const sc = selfScore as Record<string, unknown>;
const dims = Array.isArray(sc.) ? sc. : [];
for (const d of dims) {
if (!d || typeof d !== "object") continue;
const r = d as Record<string, unknown>;
const name = asString(r.) ?? asString(r.) ?? "?";
const score = r.;
const note = asString(r.);
const scoreText =
typeof score === "number" || typeof score === "string"
? `${score}%`
: "?";
assessmentParts.push(
note ? `${name} ${scoreText}${note}` : `${name} ${scoreText}`,
);
}
const weak = asString(sc.);
if (weak) assessmentParts.push(`薄弱点:${weak}`);
}
const probe = row.;
const rawQs: unknown[] = [];
let lead = "";
if (probe && typeof probe === "object" && !Array.isArray(probe)) {
const p = probe as Record<string, unknown>;
lead = asString(p.) ?? "";
const topics = Array.isArray(p.) ? p. : [];
for (let i = 0; i < topics.length; i++) {
const t = topics[i];
if (!t || typeof t !== "object") continue;
const q = t as Record<string, unknown>;
const prompt = asString(q.) ?? asString(q.prompt);
if (!prompt) continue;
const opts = Array.isArray(q.)
? q.建议选项
: Array.isArray(q.options)
? q.options
: [];
const example = asString(q.);
const optLabels = opts
.map((o) => {
if (typeof o === "string") return o.trim();
if (o && typeof o === "object") {
const r = o as Record<string, unknown>;
return asString(r.label) ?? asString(r.) ?? asString(r.text) ?? "";
}
return String(o ?? "").trim();
})
.filter(Boolean);
// 建议选项 → 选择题;示例并入题干提示,便于 ask 卡展示
rawQs.push({
id: `frag-q${i + 1}`,
prompt: example ? `${prompt}\n示例${example}` : prompt,
options: optLabels,
allowOther: true,
required: false,
});
}
}
// 开放问题兜底成无选项追问
const openQs = asStringList(row.);
if (!rawQs.length && openQs.length) {
for (let i = 0; i < openQs.length; i++) {
rawQs.push({
id: `frag-open${i + 1}`,
prompt: openQs[i],
allowOther: true,
required: false,
});
}
}
const questions = normalizeQuestions(rawQs);
const assessment = [lead, ...assessmentParts].filter(Boolean).join("\n");
return { questions, assessment };
}
function flattenObjectLines(value: unknown, depth: number, maxDepth: number): string[] {
if (value == null) return [];
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return [String(value)];
}
if (Array.isArray(value)) {
if (!value.length) return ["(空)"];
return value.flatMap((v, i) => {
if (v == null || typeof v !== "object") return [String(v)];
const row = v as Record<string, unknown>;
const title =
[row., row., row.id, row.rule_id, row.id, row.id, row.id]
.find((x) => typeof x === "string" && x.trim()) ?? `#${i + 1}`;
const nested = flattenObjectLines(v, depth + 1, maxDepth);
if (depth + 1 >= maxDepth) {
const summary = Object.entries(row)
.filter(([, x]) => x != null && x !== "" && typeof x !== "object")
.slice(0, 4)
.map(([k, x]) => `${k}=${x}`)
.join(" · ");
return [`${title}${summary ? `${summary}` : ""}`];
}
return [`${title}`, ...nested.map((line) => ` ${line}`)];
});
}
if (typeof value === "object" && depth < maxDepth) {
const out: string[] = [];
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (v == null || v === "") continue;
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
out.push(`${k}${v}`);
} else if (Array.isArray(v) || typeof v === "object") {
out.push(`${k}`);
for (const line of flattenObjectLines(v, depth + 1, maxDepth)) {
out.push(` ${line}`);
}
} else {
out.push(`${k}${String(v)}`);
}
}
return out;
}
if (typeof value === "object") {
const keys = Object.keys(value as object);
return [`(嵌套 ${keys.length} 键:${keys.slice(0, 8).join("、")}${keys.length > 8 ? "…" : ""}`];
}
return [String(value)];
}

511
src/skills/context-order.ts Normal file
View File

@@ -0,0 +1,511 @@
/**
* 上下文投影排序context-order.v1→ contextSegments。
* 扁平数字序;「对话.历史」也是可投影标签,不是硬分区锚点。
* 规范docs/context-fragment-design.md
*/
import type { ContextSegmentDef } from "./context-segments.js";
import { extractJsonObjectText } from "./worker-set-parse.js";
import { parsePlaySlots, type PlaySlotsConfig } from "./play-slots.js";
import {
DIALOGUE_HISTORY_TAG,
isDialogueHistoryRef,
} from "./dialogue-history.js";
export const CONTEXT_ORDER_SCHEMA = "context-order.v1" as const;
export const CONTEXT_ORDER_TAG = "设计.上下文投影排序";
export const WORKER_PERSONA_REF = "worker.persona";
export { DIALOGUE_HISTORY_TAG };
/** @deprecated 仅兼容旧表;拼装按扁平 order历史靠 ref=对话.历史 */
export type ContextOrderAnchor = "pre_history" | "post_history";
export type ContextOrderProjection = "fixed" | "full" | "summary" | "fields";
export type ContextOrderInsert = {
order: number;
/** @deprecated 可选set_anchor 语义改为「移到历史标签前/后」 */
anchor?: ContextOrderAnchor;
ref: string;
projection: ContextOrderProjection;
note?: string;
label?: string;
};
export type ContextOrderSlot = {
ref: string;
label?: string;
inserts: ContextOrderInsert[];
};
export type ContextOrderDoc = {
schema: typeof CONTEXT_ORDER_SCHEMA;
brief?: string;
play_slots?: PlaySlotsConfig;
slots: ContextOrderSlot[];
};
function asString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
const t = v.trim();
return t || undefined;
}
function parseAnchor(v: unknown): ContextOrderAnchor {
if (v === "post_history" || v === "dynamic" || v === "历史后") return "post_history";
return "pre_history";
}
function parseProjection(v: unknown): ContextOrderProjection {
if (v === "summary" || v === "fields" || v === "fixed" || v === "full") return v;
if (v === "摘要") return "summary";
if (v === "字段") return "fields";
return "full";
}
function parseInsert(raw: unknown): ContextOrderInsert | 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.tag) ?? asString(row.);
if (!ref) return undefined;
const orderRaw = row.order ?? row.;
const order = typeof orderRaw === "number" && Number.isFinite(orderRaw) ? orderRaw : Number(orderRaw);
const hasAnchor = row.anchor != null || row. != null;
return {
order: Number.isFinite(order) ? order : 99,
anchor: hasAnchor ? parseAnchor(row.anchor ?? row.) : undefined,
ref,
projection: parseProjection(row.projection ?? row.),
note: asString(row.note) ?? asString(row.),
label: asString(row.label) ?? asString(row.),
};
}
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 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);
return {
ref,
label: asString(row.label) ?? asString(row.name),
inserts,
};
}
export function parseContextOrder(raw: unknown): ContextOrderDoc | undefined {
let row: Record<string, unknown> | null = null;
if (typeof raw === "string") {
const text = extractJsonObjectText(raw) ?? raw.trim();
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>;
}
} catch {
return undefined;
}
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
row = raw as Record<string, unknown>;
}
if (!row) return undefined;
const slotsRaw = Array.isArray(row.slots) ? row.slots : [];
const slots = slotsRaw
.map(parseSlot)
.filter((x): x is ContextOrderSlot => Boolean(x));
if (!slots.length && row.schema !== CONTEXT_ORDER_SCHEMA) return undefined;
return {
schema: CONTEXT_ORDER_SCHEMA,
brief: asString(row.brief),
play_slots: parsePlaySlots(row.play_slots ?? row.playSlots),
slots,
};
}
export function slotOrderForRef(
doc: ContextOrderDoc | undefined,
workerRef: string,
): ContextOrderSlot | undefined {
if (!doc) return undefined;
const id = workerRef.trim();
return doc.slots.find((s) => s.ref.trim() === id);
}
/**
* 将某槽的 inserts 转为 contextSegments严格按扁平 order。
* worker.persona 用 inline对话.历史走专用 tag拼装时动态投影
*/
export function contextOrderToSegments(params: {
slot: ContextOrderSlot;
personaText?: string;
}): ContextSegmentDef[] {
const sorted = renumberContextOrder({
schema: CONTEXT_ORDER_SCHEMA,
slots: [params.slot],
}).slots[0]!.inserts;
const historyIdx = sorted.findIndex((i) => isDialogueHistoryRef(i.ref));
const segments: ContextSegmentDef[] = [];
for (const ins of sorted) {
const afterHistory =
historyIdx >= 0 && sorted.indexOf(ins) > historyIdx;
// tier 仅作缓存提示;拼装按数组顺序,不再重排
const tier = afterHistory ? "dynamic" : "static";
const id = `ord${ins.order}-${ins.ref}`.replace(/[^\w.\u4e00-\u9fff-]+/g, "_");
const label =
ins.label ||
(ins.note ? `## ${ins.ref}${ins.note}` : `## ${ins.ref}`);
if (ins.ref === WORKER_PERSONA_REF || ins.ref === "persona") {
const inline = params.personaText?.trim();
if (!inline) continue;
segments.push({
id,
tier,
tags: [],
label,
inline,
projection: ins.projection,
});
continue;
}
const tag = isDialogueHistoryRef(ins.ref)
? DIALOGUE_HISTORY_TAG
: ins.ref;
segments.push({
id,
tier,
tags: [tag],
label,
projection: ins.projection,
});
}
return segments;
}
export type ContextOrderInsertView = {
index: number;
order: number;
anchor?: ContextOrderAnchor;
ref: string;
projection: ContextOrderProjection;
note?: string;
label?: string;
isHistory?: boolean;
/** 展示一行 */
line: string;
};
export function contextOrderToView(doc: ContextOrderDoc): {
brief?: string;
editable: true;
slots: Array<{
ref: string;
label?: string;
inserts: ContextOrderInsertView[];
lines: string[];
}>;
} {
return {
brief: doc.brief,
editable: true,
slots: doc.slots.map((s) => {
const inserts: ContextOrderInsertView[] = s.inserts.map((i, index) => {
const hist = isDialogueHistoryRef(i.ref);
const line =
`${i.order} · ${hist ? "对话.历史" : i.ref}` +
(i.projection !== "full" ? ` · ${i.projection}` : "") +
(i.note ? `${i.note}` : "");
return {
index,
order: i.order,
anchor: i.anchor,
ref: hist ? DIALOGUE_HISTORY_TAG : i.ref,
projection: i.projection,
note: i.note,
label: i.label,
isHistory: hist,
line,
};
});
return {
ref: s.ref,
label: s.label,
inserts,
lines: inserts.map((x) => x.line),
};
}),
};
}
/** 扁平重编号 0..npersona 尽量置顶;保证至多一条对话.历史 */
export function renumberContextOrder(doc: ContextOrderDoc): ContextOrderDoc {
return {
...doc,
schema: CONTEXT_ORDER_SCHEMA,
slots: doc.slots.map((slot) => {
let inserts = slot.inserts.map((i) => ({
...i,
ref: isDialogueHistoryRef(i.ref) ? DIALOGUE_HISTORY_TAG : i.ref,
}));
// 合并重复历史标签:保留第一条
const seenHist = new Set<number>();
inserts = inserts.filter((i, idx) => {
if (!isDialogueHistoryRef(i.ref)) return true;
if (seenHist.size) return false;
seenHist.add(idx);
return true;
});
const personaIdx = inserts.findIndex(
(i) => i.ref === WORKER_PERSONA_REF || i.ref === "persona",
);
if (personaIdx > 0) {
const [p] = inserts.splice(personaIdx, 1);
inserts.unshift(p!);
}
return {
...slot,
inserts: inserts.map((i, idx) => ({
...i,
order: idx,
anchor: undefined,
})),
};
}),
};
}
export type ContextOrderEdit =
| { action: "move"; slotRef: string; index: number; delta: -1 | 1 }
| {
action: "set_anchor";
slotRef: string;
index: number;
anchor: ContextOrderAnchor;
}
| {
action: "set_projection";
slotRef: string;
index: number;
projection: ContextOrderProjection;
}
| { action: "replace"; context_order: unknown };
function cloneDoc(doc: ContextOrderDoc): ContextOrderDoc {
return {
schema: CONTEXT_ORDER_SCHEMA,
brief: doc.brief,
play_slots: doc.play_slots ? { ...doc.play_slots } : undefined,
slots: doc.slots.map((s) => ({
...s,
inserts: s.inserts.map((i) => ({ ...i })),
})),
};
}
/**
* 扁平列表编排:
* - move与相邻项交换
* - set_anchor把该项移到「对话.历史」之前pre或之后post无历史则先插入历史标签
* - set_projection只改投影
*/
export function applyContextOrderEdit(
doc: ContextOrderDoc,
edit: ContextOrderEdit,
): ContextOrderDoc {
if (edit.action === "replace") {
const parsed = parseContextOrder(edit.context_order);
if (!parsed) throw new Error("context_order 无法解析");
return renumberContextOrder(parsed);
}
const next = cloneDoc(doc);
const slot = next.slots.find((s) => s.ref.trim() === edit.slotRef.trim());
if (!slot) throw new Error(`未找到槽 ${edit.slotRef}`);
if (edit.index < 0 || edit.index >= slot.inserts.length) {
throw new Error("插入项下标越界");
}
if (edit.action === "set_projection") {
slot.inserts[edit.index]!.projection = edit.projection;
return renumberContextOrder(next);
}
if (edit.action === "set_anchor") {
const item = slot.inserts[edit.index]!;
if (isDialogueHistoryRef(item.ref)) {
return renumberContextOrder(next);
}
slot.inserts.splice(edit.index, 1);
ensureHistoryInsert(slot);
const histIdx = slot.inserts.findIndex((i) => isDialogueHistoryRef(i.ref));
const insertAt =
edit.anchor === "pre_history" ? Math.max(0, histIdx) : histIdx + 1;
slot.inserts.splice(insertAt, 0, item);
return renumberContextOrder(next);
}
// move整表相邻交换
const to = edit.index + edit.delta;
if (to < 0 || to >= slot.inserts.length) {
return renumberContextOrder(next);
}
const tmp = slot.inserts[edit.index]!;
slot.inserts[edit.index] = slot.inserts[to]!;
slot.inserts[to] = tmp;
return renumberContextOrder(next);
}
function ensureHistoryInsert(slot: ContextOrderSlot): void {
if (slot.inserts.some((i) => isDialogueHistoryRef(i.ref))) return;
// 插在中段persona 后、易变前
const mid = Math.min(
Math.max(1, Math.ceil(slot.inserts.length / 2)),
slot.inserts.length,
);
slot.inserts.splice(mid, 0, {
order: mid,
ref: DIALOGUE_HISTORY_TAG,
projection: "summary",
note: "对话历史(按投影裁剪)",
});
}
export function serializeContextOrder(doc: ContextOrderDoc): string {
const normalized = renumberContextOrder(doc);
return JSON.stringify(
{
schema: CONTEXT_ORDER_SCHEMA,
brief: normalized.brief,
play_slots: normalized.play_slots,
slots: normalized.slots.map((s) => ({
ref: s.ref,
label: s.label,
inserts: s.inserts.map((i) => ({
order: i.order,
ref: i.ref,
projection: i.projection,
...(i.note ? { note: i.note } : {}),
...(i.label ? { label: i.label } : {}),
})),
})),
},
null,
2,
);
}
/** 把排序表写入运行规格 JSON 文本的 context_order 字段 */
export function mergeContextOrderIntoWorkerSetJson(
workerSetRaw: string,
order: ContextOrderDoc,
): string {
const text = extractJsonObjectText(workerSetRaw) ?? workerSetRaw.trim();
let row: Record<string, unknown>;
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("设计.worker集 不是 JSON 对象");
}
row = parsed as Record<string, unknown>;
} catch {
throw new Error("设计.worker集 无法解析为 JSON");
}
const normalized = renumberContextOrder(order);
row.context_order = JSON.parse(serializeContextOrder(normalized)) as unknown;
delete row.contextOrder;
return JSON.stringify(row, null, 2);
}
/**
* 无排序表时,从 worker 声明的 static/dynamic 合成可编辑初稿(含 persona
*/
/** 声明缺 context 时的兜底(与模板/检查器默认对齐的最小集) */
const SYNTH_FALLBACK_CONTEXT: Record<
string,
{ static: string[]; dynamic: string[] }
> = {
"world-simulator": {
static: ["设计.变量设计与更新规则"],
dynamic: ["变量.当前", "运行.事件流", "用户.最新输入", "上下文.角色态度"],
},
narrator: {
static: ["设计.叙事指南"],
dynamic: ["运行.本轮.裁决", "用户.最新输入"],
},
"role-decide": {
static: [],
dynamic: ["用户.最新输入", "运行.本轮.裁决"],
},
};
export function synthesizeContextOrderFromWorkers(
workers: Array<{
ref?: string;
name?: string;
context?: { static?: string[]; dynamic?: string[] };
}>,
playSlots?: PlaySlotsConfig,
): ContextOrderDoc | undefined {
const slots: ContextOrderSlot[] = [];
for (const w of workers) {
const ref = w.ref?.trim();
if (!ref) continue;
const fallback = SYNTH_FALLBACK_CONTEXT[ref];
const staticTags = w.context?.static ?? fallback?.static ?? [];
const dynamicTags = w.context?.dynamic ?? fallback?.dynamic ?? ["用户.最新输入"];
const inserts: ContextOrderInsert[] = [
{
order: 0,
ref: WORKER_PERSONA_REF,
projection: "fixed",
note: "槽位人设",
},
];
let o = 1;
for (const tag of staticTags) {
if (!tag?.trim() || tag === "设计.worker集") continue;
if (isDialogueHistoryRef(tag)) continue;
inserts.push({
order: o++,
ref: tag.trim(),
projection: tag.includes("事件流") ? "summary" : "full",
});
}
inserts.push({
order: o++,
ref: DIALOGUE_HISTORY_TAG,
projection: "summary",
note: "对话历史(按投影裁剪)",
});
for (const tag of dynamicTags) {
if (!tag?.trim()) continue;
if (isDialogueHistoryRef(tag)) continue;
// 事件流已可由历史兜底;仍可单独挂
inserts.push({
order: o++,
ref: tag.trim(),
projection: "full",
});
}
slots.push({
ref,
label: w.name,
inserts,
});
}
if (!slots.length) return undefined;
return renumberContextOrder({
schema: CONTEXT_ORDER_SCHEMA,
brief: "扁平投影序(含对话.历史标签,可编排)",
play_slots: playSlots,
slots,
});
}

View File

@@ -9,16 +9,28 @@ import {
CREATION_ACCEPTED_CONTENT_TAG,
formatAcceptedContentForPrompt,
} from "./creation-units.js";
import { projectFragmentContent } from "./context-fragment.js";
import {
buildHistoryFallback,
isDialogueHistoryRef,
projectDialogueHistory,
DIALOGUE_HISTORY_TAG,
} from "./dialogue-history.js";
export type ContextSegmentTier = "static" | "dynamic";
export type ContextSegmentDef = {
id: string;
/** 缓存提示;有序拼装时不再按 tier 重排 */
tier: ContextSegmentTier;
tags: string[];
label?: string;
/** latest | concat | tail_lines_N */
policy?: string;
/** 不读黑板,直接注入(如 worker.persona */
inline?: string;
/** context-order 投影级别full | summary | fields | fixed */
projection?: string;
};
export function parseContextSegments(raw: unknown): ContextSegmentDef[] {
@@ -32,13 +44,16 @@ export function parseContextSegments(raw: unknown): ContextSegmentDef[] {
const tags = Array.isArray(r.tags)
? r.tags.filter((t): t is string => typeof t === "string" && t.trim()).map((t) => t.trim())
: [];
if (!id || tags.length === 0) continue;
const inline = typeof r.inline === "string" ? r.inline.trim() : undefined;
if (!id || (tags.length === 0 && !inline)) continue;
out.push({
id,
tier,
tags,
label: typeof r.label === "string" ? r.label.trim() : undefined,
policy: typeof r.policy === "string" ? r.policy.trim() : undefined,
inline: inline || undefined,
projection: typeof r.projection === "string" ? r.projection.trim() : undefined,
});
}
return out;
@@ -78,20 +93,50 @@ function formatSegmentBody(
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
): string {
if (segment.inline?.trim()) {
return applyProjection(segment.inline.trim(), segment.projection);
}
const parts: string[] = [];
for (const tag of segment.tags) {
let content = readTagContent(tag, inputs, blackboard, inputMerge);
if (!content) continue;
if (tag === CREATION_ACCEPTED_CONTENT_TAG) {
content = formatAcceptedContentForPrompt(content);
let content: string;
if (isDialogueHistoryRef(tag)) {
content = resolveDialogueHistory(inputs, blackboard, inputMerge);
content = projectDialogueHistory(content, segment.projection);
} else {
content = readTagContent(tag, inputs, blackboard, inputMerge);
if (!content) continue;
if (tag === CREATION_ACCEPTED_CONTENT_TAG) {
content = formatAcceptedContentForPrompt(content);
}
content = applyPolicy(content, segment.policy);
content = applyProjection(content, segment.projection);
}
content = applyPolicy(content, segment.policy);
if (!content.trim()) continue;
parts.push(content.trim());
}
return parts.join("\n\n");
}
function resolveDialogueHistory(
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
): string {
const direct =
readTagContent(DIALOGUE_HISTORY_TAG, inputs, blackboard, inputMerge) ||
readTagContent("对话历史", inputs, blackboard, inputMerge);
return buildHistoryFallback({
dialogueHistory: direct,
eventStream: readTagContent("运行.事件流", inputs, blackboard, "concat"),
latestUser: readTagContent("用户.最新输入", inputs, blackboard, inputMerge),
});
}
function applyProjection(content: string, projection?: string): string {
if (!projection || projection === "full") return content;
return projectFragmentContent(content, projection);
}
/**
* 按 contextSegments 拼装 user 侧上下文。
* 无 segments 时回退为 JSON inputs兼容旧 skill
@@ -124,8 +169,6 @@ export function assembleWorkerContext(params: {
);
}
const staticSegs = segments.filter((s) => s.tier === "static");
const dynamicSegs = segments.filter((s) => s.tier === "dynamic");
const blocks: string[] = [];
const render = (seg: ContextSegmentDef) => {
@@ -138,8 +181,8 @@ export function assembleWorkerContext(params: {
}
};
for (const seg of staticSegs) render(seg);
for (const seg of dynamicSegs) render(seg);
// 严格按 segments 数组顺序(投影排序表顺序);不再按 static/dynamic 重排
for (const seg of segments) render(seg);
// 定稿摘要:若未在 segments 中声明,仍附在末尾
const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim();

View File

@@ -598,17 +598,25 @@ function slugFromName(name: string): string {
: "interaction",
: "aesthetics",
: "narrative",
: "narrative",
: "mechanism",
: "world-blueprint",
: "world-blueprint",
: "world-blueprint", // 旧称
: "world-blueprint", // 旧简称
: "generation-rules",
: "concrete-instances",
: "topology",
: "status-bar",
: "status-bar",
: "variable-design",
: "variable-context",
: "reply-format",
"Worker 规格": "worker-spec",
: "reply-format",
: "worker-spec",
"Worker 规格": "worker-spec", // 旧称,等同游玩拓扑
稿: "refine",
: "opening-setup",
: "opening-setup",
};
return map[name] ?? name;
}

View File

@@ -112,7 +112,7 @@ export const FIXED_CONTEXT_CATALOG: Array<{
{
id: "fixed:narrative_guide",
flavor: "narrative_guide",
label: "叙事指南",
label: "叙事指南与故事推进",
weighty: true,
hint: "世界态度与体验边界(残酷/不有求必应/随机危险等);≠ 文风",
},

View File

@@ -24,6 +24,13 @@ import {
parseResidentContext,
residentTagFor,
} from "./resident-context.js";
import {
CONTEXT_ORDER_TAG,
contextOrderToSegments,
parseContextOrder,
slotOrderForRef,
} from "./context-order.js";
import type { ContextSegmentDef } from "./context-segments.js";
type WorkerTemplateDoc = {
id?: string;
@@ -106,7 +113,14 @@ export async function resolveRunnableWorker(params: {
}
const raw = readWorkerSetYamlForDeclaration(params.blackboard, params.session);
const parsed = raw ? parseWorkerSetYaml(raw.yaml) : null;
let parsed = raw ? parseWorkerSetYaml(raw.yaml) : null;
// 用户编排的排序表优先:规格内 context_order → 独立 tag
if (parsed && !parseContextOrder(parsed.context_order)) {
const fromTag = parseContextOrder(
params.blackboard.getContentByTag(CONTEXT_ORDER_TAG),
);
if (fromTag) parsed = { ...parsed, context_order: fromTag };
}
const entry = parsed?.workers.find(
(w) => w.ref?.trim() === params.workerId.trim(),
);
@@ -190,16 +204,6 @@ export function buildDeclaredWorkerSkill(params: {
.filter((e) => e.position === "dynamic")
.map(residentTagFor);
const inputTags = [
...new Set([
...staticTags,
...residentStaticTags,
...dynamicTags,
...residentDynamicTags,
CONTEXT_BRIEF_TAG,
]),
];
const duty =
params.entry.duty?.trim() ||
params.template?.duty?.trim() ||
@@ -212,6 +216,59 @@ 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 orderDoc = parseContextOrder(params.workerSet?.context_order);
const orderSlot = slotOrderForRef(orderDoc, id);
let contextSegments: ContextSegmentDef[] | undefined;
if (orderSlot && orderSlot.inserts.length) {
contextSegments = contextOrderToSegments({
slot: orderSlot,
personaText,
});
}
const orderTags = (contextSegments ?? []).flatMap((s) => s.tags);
const inputTags = [
...new Set([
...staticTags,
...residentStaticTags,
...dynamicTags,
...residentDynamicTags,
...orderTags,
CONTEXT_BRIEF_TAG,
]),
];
// 无 context_order 时:沿用模板 static→dynamic 两档 segments
if (!contextSegments?.length) {
contextSegments = [
...staticTags.map((tag, i) => ({
id: `static-${i}`,
tier: "static" as const,
tags: [tag],
label: `## ${tag}`,
})),
...residentStaticTags.map((tag, i) => ({
id: `resident-s-${i}`,
tier: "static" as const,
tags: [tag],
label: `## ${tag}`,
})),
...dynamicTags.map((tag, i) => ({
id: `dynamic-${i}`,
tier: "dynamic" as const,
tags: [tag],
label: `## ${tag}`,
})),
...residentDynamicTags.map((tag, i) => ({
id: `resident-d-${i}`,
tier: "dynamic" as const,
tags: [tag],
label: `## ${tag}`,
})),
];
}
const body = [
`# ${params.template?.label ?? id}`,
"",
@@ -248,6 +305,7 @@ export function buildDeclaredWorkerSkill(params: {
inputMerge: "latest",
path: `declaration:${id}`,
body,
contextSegments,
};
const promptBody = [

View File

@@ -0,0 +1,96 @@
/**
* 对话.历史:排序表中的可投影「标签」,不是硬锚点分区。
* 规范docs/context-fragment-design.md
*/
export const DIALOGUE_HISTORY_TAG = "对话.历史";
/** 兼容别名(排序表 / 旧文档) */
const HISTORY_ALIASES = new Set([
DIALOGUE_HISTORY_TAG,
"history",
"chat.history",
"对话历史",
"历史对话",
]);
export function isDialogueHistoryRef(ref: string): boolean {
return HISTORY_ALIASES.has(ref.trim());
}
export type HistoryProjection = "fixed" | "full" | "summary" | "fields";
/**
* 按投影级别裁剪历史正文。
* - fixed几乎不注入占位一句
* - fields最近约 5 段
* - summary最近约 20 段
* - full最近约 80 段(或整段若更短)
*/
export function projectDialogueHistory(
raw: string,
projection?: string,
): string {
const text = (raw || "").trim();
const mode = (projection || "full").trim().toLowerCase();
if (!text) {
return mode === "fixed" ? "" : "(尚无对话历史)";
}
if (mode === "fixed") return "(本步不注入历史正文)";
const blocks = splitHistoryBlocks(text);
const n =
mode === "fields" ? 5 : mode === "summary" || mode === "brief" ? 20 : 80;
const sliced = blocks.length > n ? blocks.slice(-n) : blocks;
const body = sliced.join("\n\n");
if (blocks.length > n) {
return `(仅最近 ${n} 段,更早已省略)\n\n${body}`;
}
return body;
}
function splitHistoryBlocks(text: string): string[] {
const byFence = text
.split(/\n(?=##\s||[:]|[:]|[:])/u)
.map((s) => s.trim())
.filter(Boolean);
if (byFence.length >= 2) return byFence;
const byBlank = text
.split(/\n{2,}/)
.map((s) => s.trim())
.filter(Boolean);
if (byBlank.length >= 2) return byBlank;
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
return lines.length ? lines : [text];
}
/** 追加一轮用户/系统可见内容到历史正文 */
export function appendDialogueHistoryTurn(
existing: string,
turn: { role: "用户" | "助手" | "系统"; text: string },
): string {
const body = turn.text.trim();
if (!body) return (existing || "").trim();
const block = `## ${turn.role}\n\n${body}`;
const prev = (existing || "").trim();
return prev ? `${prev}\n\n${block}` : block;
}
/**
* 若尚无 对话.历史,用事件流等拼一份可读底稿(仅用于投影,不强制回写)。
*/
export function buildHistoryFallback(params: {
dialogueHistory?: string;
eventStream?: string;
latestUser?: string;
}): string {
const hist = params.dialogueHistory?.trim();
if (hist) return hist;
const parts: string[] = [];
if (params.eventStream?.trim()) {
parts.push(`## 事件流\n\n${params.eventStream.trim()}`);
}
if (params.latestUser?.trim()) {
parts.push(`## 用户\n\n${params.latestUser.trim()}`);
}
return parts.join("\n\n");
}

265
src/skills/play-slots.ts Normal file
View File

@@ -0,0 +1,265 @@
/**
* 固定游玩槽位:世界模拟路径不再自由发明 workers[]
* 以勾选 gm / narrator / perspective 展开为声明条目。
* `chance` 为按需程序槽:不进每轮管线,可被 run_worker / toolcall 调用。
*/
import type { WorkerAcceptance, WorkerSetEntry } from "./worker-set-parse.js";
export type PlaySlotId = "gm" | "narrator" | "perspective";
/** 按需槽(不进入 perspective→gm→narrator 管线) */
export type OnDemandSlotId = "chance";
export type PlaySlotsConfig = {
/** 主世界层(裁决);默认 true */
gm: boolean;
/** 叙事转述;默认 true */
narrator: boolean;
/** 单角色知密视角;默认 false */
perspective: boolean;
/**
* 机遇裁定(程序骰子/抽签/比点);默认 false。
* 启用后可按需 run_worker不自动每轮上场。
*/
chance?: boolean;
/** 可选覆盖默认 ref */
refs?: Partial<Record<PlaySlotId | OnDemandSlotId, string>>;
};
export const DEFAULT_PLAY_SLOT_REFS: Record<PlaySlotId, string> = {
gm: "world-simulator",
narrator: "narrator",
perspective: "role-decide",
};
export const DEFAULT_ON_DEMAND_REFS: Record<OnDemandSlotId, string> = {
chance: "chance",
};
export const PLAY_SLOT_META: Record<
PlaySlotId,
{ label: string; purpose: string; defaultAcceptance: WorkerAcceptance }
> = {
gm: {
label: "主世界层",
purpose: "读真值与 Progressive 投影,输出结构化裁决包;可提议变量变更。",
defaultAcceptance: "continue",
},
narrator: {
label: "叙事转述",
purpose: "只读裁决包 + 文风常驻,输出用户可见正文。",
defaultAcceptance: "review",
},
perspective: {
label: "角色视角",
purpose: "强信息隔离时出反应建议;不写真值、不写终稿。",
defaultAcceptance: "continue",
},
};
export const ON_DEMAND_SLOT_META: Record<
OnDemandSlotId,
{ label: string; purpose: string; defaultAcceptance: WorkerAcceptance }
> = {
chance: {
label: "机遇裁定",
purpose:
"程序工具:掷骰、比点、抽签/加权抽取;按需调用,禁止模型编造随机结果。",
defaultAcceptance: "continue",
},
};
/** 推荐回合顺序(启用的槽按此排序;不含按需槽) */
export const PLAY_SLOT_ORDER: PlaySlotId[] = ["perspective", "gm", "narrator"];
export function defaultPlaySlots(): PlaySlotsConfig {
return { gm: true, narrator: true, perspective: false, chance: false };
}
export function parsePlaySlots(raw: unknown): PlaySlotsConfig | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
const base = defaultPlaySlots();
if (typeof row.gm === "boolean") base.gm = row.gm;
if (typeof row.narrator === "boolean") base.narrator = row.narrator;
if (typeof row.perspective === "boolean") base.perspective = row.perspective;
if (typeof row.chance === "boolean") base.chance = row.chance;
// 兼容旧键名
if (typeof row.world === "boolean") base.gm = row.world;
if (typeof row.transcription === "boolean") base.narrator = row.transcription;
if (typeof row.oracle === "boolean") base.chance = row.oracle;
if (typeof row.rng === "boolean") base.chance = row.rng;
const refsRaw = row.refs;
if (refsRaw && typeof refsRaw === "object" && !Array.isArray(refsRaw)) {
const refs: Partial<Record<PlaySlotId | OnDemandSlotId, string>> = {};
for (const id of Object.keys(DEFAULT_PLAY_SLOT_REFS) as PlaySlotId[]) {
const v = (refsRaw as Record<string, unknown>)[id];
if (typeof v === "string" && v.trim()) refs[id] = v.trim();
}
for (const id of Object.keys(DEFAULT_ON_DEMAND_REFS) as OnDemandSlotId[]) {
const v = (refsRaw as Record<string, unknown>)[id];
if (typeof v === "string" && v.trim()) refs[id] = v.trim();
}
if (Object.keys(refs).length) base.refs = refs;
}
return base;
}
export function refForSlot(
slots: PlaySlotsConfig,
id: PlaySlotId | OnDemandSlotId,
): string {
if (id === "chance") {
return slots.refs?.chance?.trim() || DEFAULT_ON_DEMAND_REFS.chance;
}
return slots.refs?.[id]?.trim() || DEFAULT_PLAY_SLOT_REFS[id];
}
export function enabledPlaySlotIds(slots: PlaySlotsConfig): PlaySlotId[] {
return PLAY_SLOT_ORDER.filter((id) => Boolean(slots[id]));
}
export function enabledOnDemandSlotIds(slots: PlaySlotsConfig): OnDemandSlotId[] {
return slots.chance ? ["chance"] : [];
}
/** 每轮管线 refs不含按需槽 */
export function refsFromPlaySlots(slots: PlaySlotsConfig): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const id of enabledPlaySlotIds(slots)) {
const ref = refForSlot(slots, id);
if (seen.has(ref)) continue;
seen.add(ref);
out.push(ref);
}
return out;
}
/** 按需可调度 refs可并入 activeWorkerIds但不进自动回合序 */
export function onDemandRefsFromPlaySlots(slots: PlaySlotsConfig): string[] {
return enabledOnDemandSlotIds(slots).map((id) => refForSlot(slots, id));
}
export function workerEntryForSlot(
slots: PlaySlotsConfig,
id: PlaySlotId,
): WorkerSetEntry {
const meta = PLAY_SLOT_META[id];
const ref = refForSlot(slots, id);
return {
ref,
name: meta.label,
role: id,
duty: meta.purpose,
when:
id === "perspective"
? "强信息隔离且本轮需要该角色独立反应时"
: id === "gm"
? "每轮用户输入后"
: "主世界层裁决包就绪后",
rationale:
id === "perspective"
? "知密内容不能进主世界层上下文"
: id === "gm"
? "裁决与真值变更需要独立推理槽"
: "用户可见正文与裁决分离,避免文风与规则互相挤压",
acceptance: meta.defaultAcceptance,
invocation: "turn",
};
}
export function workerEntryForOnDemandSlot(
slots: PlaySlotsConfig,
id: OnDemandSlotId,
): WorkerSetEntry {
const meta = ON_DEMAND_SLOT_META[id];
const ref = refForSlot(slots, id);
return {
ref,
name: meta.label,
role: id,
duty: meta.purpose,
when: "需要程序随机、骰子、比点或抽签时由编排器按需调用",
rationale: "真随机必须由程序给出,不能靠模型编造",
acceptance: meta.defaultAcceptance,
invocation: "on_demand",
};
}
/** 仅由 play_slots 展开 workers含按需槽条目 */
export function expandPlaySlotsToWorkers(slots: PlaySlotsConfig): WorkerSetEntry[] {
return [
...enabledPlaySlotIds(slots).map((id) => workerEntryForSlot(slots, id)),
...enabledOnDemandSlotIds(slots).map((id) =>
workerEntryForOnDemandSlot(slots, id),
),
];
}
/**
* 若声明了 play_slots用槽位展开补齐/覆盖同 ref 的骨架;
* 已有 workers 里同 ref 的 context/outputs/name 覆盖保留。
*/
export function mergeWorkersWithPlaySlots(
existing: WorkerSetEntry[],
slots: PlaySlotsConfig | undefined,
): WorkerSetEntry[] {
if (!slots) return existing;
const expanded = expandPlaySlotsToWorkers(slots);
if (!existing.length) return expanded;
const byRef = new Map<string, WorkerSetEntry>();
for (const w of existing) {
const ref = w.ref?.trim();
if (ref) byRef.set(ref, w);
}
const merged: WorkerSetEntry[] = expanded.map((slotEntry) => {
const ref = slotEntry.ref!.trim();
const prev = byRef.get(ref);
if (!prev) return slotEntry;
return {
...slotEntry,
name: prev.name ?? slotEntry.name,
duty: prev.duty ?? slotEntry.duty,
when: prev.when ?? slotEntry.when,
rationale: prev.rationale ?? slotEntry.rationale,
acceptance: prev.acceptance ?? slotEntry.acceptance,
invocation: prev.invocation ?? slotEntry.invocation,
context: prev.context ?? slotEntry.context,
outputs: prev.outputs ?? slotEntry.outputs,
presentation: prev.presentation ?? slotEntry.presentation,
merge_considered: prev.merge_considered ?? slotEntry.merge_considered,
};
});
// 保留不在固定槽内的额外 worker兼容旧规格 / 扩写路径)
const slotRefs = new Set(expanded.map((e) => e.ref!.trim()));
for (const w of existing) {
const ref = w.ref?.trim();
if (!ref || slotRefs.has(ref)) continue;
merged.push(w);
}
return merged;
}
export function inferPlaySlotsFromWorkers(
workers: WorkerSetEntry[],
): PlaySlotsConfig | undefined {
if (!workers.length) return undefined;
const refs = new Set(
workers.map((w) => w.ref?.trim()).filter((r): r is string => Boolean(r)),
);
const hasGm = refs.has(DEFAULT_PLAY_SLOT_REFS.gm);
const hasNarrator = refs.has(DEFAULT_PLAY_SLOT_REFS.narrator);
const hasPerspective = refs.has(DEFAULT_PLAY_SLOT_REFS.perspective);
const hasChance = refs.has(DEFAULT_ON_DEMAND_REFS.chance);
if (!hasGm && !hasNarrator && !hasPerspective && !hasChance) return undefined;
return {
gm: hasGm,
narrator: hasNarrator,
perspective: hasPerspective,
chance: hasChance,
};
}

View File

@@ -0,0 +1,231 @@
/**
* 主世界层 → 叙事转述的裁决包settlement / direction packet
* 存黑板 tag运行.本轮.裁决
*/
import { extractJsonObjectText } from "./worker-set-parse.js";
export const SETTLEMENT_SCHEMA = "settlement.v1" as const;
export const SETTLEMENT_TAG = "运行.本轮.裁决";
export type SettlementNpcMove = {
who: string;
move: string;
wants?: string;
refuses_to_say?: string;
};
export type SettlementVariableChange = {
key: string;
from?: unknown;
to?: unknown;
delta?: number;
note?: string;
};
export type SettlementPacket = {
schema: typeof SETTLEMENT_SCHEMA;
/** 玩家本轮行动摘要 */
player_action?: string;
/** 已落地的客观变化(短句列表) */
resolved?: string[];
/** 转述应呈现的场面现状(玩家安全) */
visible_now?: string;
/** NPC 主动动作binding move */
npc_moves?: SettlementNpcMove[];
/** 真值变更提议Runtime 合并进 变量.当前) */
variable_changes?: SettlementVariableChange[];
/** 禁止写入用户正文的内容 */
do_not_say?: string[];
/** 语气提示(通常来自 Progressive 投影) */
tone_hint?: string;
/** 可选:建议玩家选项(无主语动作短语) */
suggested_actions?: string[];
};
export type SettlementPacketView = {
ok: boolean;
parseError?: string;
packet?: SettlementPacket;
/** 用户友好分节,供 UI 渲染 */
sections: Array<{ title: string; lines: string[] }>;
};
function asString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
const t = v.trim();
return t || undefined;
}
function asStringList(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.map((x) => asString(x)).filter((x): x is string => Boolean(x));
}
function parseNpcMoves(raw: unknown): SettlementNpcMove[] | undefined {
if (!Array.isArray(raw)) return undefined;
const out: SettlementNpcMove[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const who = asString(row.who) ?? asString(row.actor) ?? asString(row.name);
const move = asString(row.move) ?? asString(row.action);
if (!who || !move) continue;
out.push({
who,
move,
wants: asString(row.wants),
refuses_to_say: asString(row.refuses_to_say) ?? asString(row.refusesToSay),
});
}
return out.length ? out : undefined;
}
function parseVariableChanges(
raw: unknown,
): SettlementVariableChange[] | undefined {
if (!Array.isArray(raw)) return undefined;
const out: SettlementVariableChange[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const key = asString(row.key) ?? asString(row.name) ?? asString(row.);
if (!key) continue;
const delta =
typeof row.delta === "number" && Number.isFinite(row.delta)
? row.delta
: undefined;
out.push({
key,
from: row.from ?? row.,
to: row.to ?? row. ?? row.value,
delta,
note: asString(row.note) ?? asString(row.),
});
}
return out.length ? out : undefined;
}
export function normalizeSettlementPacket(
row: Record<string, unknown>,
): SettlementPacket {
const resolved = asStringList(row.resolved);
const resolvedZh = asStringList(row.);
const doNot = asStringList(row.do_not_say);
const doNotZh = asStringList(row.);
const suggested = asStringList(row.suggested_actions);
const suggestedZh = asStringList(row.);
return {
schema: SETTLEMENT_SCHEMA,
player_action: asString(row.player_action) ?? asString(row.),
resolved: resolved.length ? resolved : resolvedZh.length ? resolvedZh : undefined,
visible_now: asString(row.visible_now) ?? asString(row.),
npc_moves: parseNpcMoves(row.npc_moves ?? row.npcMoves ?? row.),
variable_changes: parseVariableChanges(
row.variable_changes ?? row.variableChanges ?? row.,
),
do_not_say: doNot.length ? doNot : doNotZh.length ? doNotZh : undefined,
tone_hint: asString(row.tone_hint) ?? asString(row.),
suggested_actions: suggested.length
? suggested
: suggestedZh.length
? suggestedZh
: undefined,
};
}
export function parseSettlementPacket(raw: string | undefined | null): SettlementPacketView {
const text = raw?.trim();
if (!text) {
return { ok: false, parseError: "空裁决包", sections: [] };
}
const jsonText = extractJsonObjectText(text) ?? (text.startsWith("{") ? text : null);
if (!jsonText) {
// 兼容旧散文裁决:整段当作 visible_now
return {
ok: true,
packet: {
schema: SETTLEMENT_SCHEMA,
visible_now: text,
},
sections: [{ title: "场面(未结构化)", lines: [text] }],
};
}
try {
const doc = JSON.parse(jsonText) as unknown;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
return { ok: false, parseError: "裁决包不是 JSON 对象", sections: [] };
}
const packet = normalizeSettlementPacket(doc as Record<string, unknown>);
return { ok: true, packet, sections: settlementSections(packet) };
} catch (err) {
return {
ok: false,
parseError: err instanceof Error ? err.message : "裁决包 JSON 解析失败",
sections: [],
};
}
}
export function settlementSections(packet: SettlementPacket): Array<{
title: string;
lines: string[];
}> {
const sections: Array<{ title: string; lines: string[] }> = [];
if (packet.player_action) {
sections.push({ title: "玩家行动", lines: [packet.player_action] });
}
if (packet.resolved?.length) {
sections.push({ title: "已落地", lines: packet.resolved });
}
if (packet.visible_now) {
sections.push({ title: "可见现状", lines: [packet.visible_now] });
}
if (packet.npc_moves?.length) {
sections.push({
title: "角色动作",
lines: packet.npc_moves.map((m) => {
const extra = [m.wants && `想要:${m.wants}`, m.refuses_to_say && `回避:${m.refuses_to_say}`]
.filter(Boolean)
.join("");
return extra ? `${m.who}${m.move}${extra}` : `${m.who}${m.move}`;
}),
});
}
if (packet.variable_changes?.length) {
sections.push({
title: "变量变更",
lines: packet.variable_changes.map((c) => {
if (c.delta != null) {
return `${c.key} ${c.delta >= 0 ? "+" : ""}${c.delta}` + (c.note ? `${c.note}` : "");
}
if (c.from !== undefined || c.to !== undefined) {
return `${c.key}${String(c.from ?? "?")}${String(c.to ?? "?")}`;
}
return c.key;
}),
});
}
if (packet.tone_hint) {
sections.push({ title: "语气", lines: [packet.tone_hint] });
}
if (packet.do_not_say?.length) {
sections.push({ title: "勿写入正文", lines: packet.do_not_say });
}
if (packet.suggested_actions?.length) {
sections.push({ title: "建议行动", lines: packet.suggested_actions });
}
return sections;
}
export function isSettlementLikeObject(doc: unknown): boolean {
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false;
const row = doc as Record<string, unknown>;
if (row.schema === SETTLEMENT_SCHEMA) return true;
// 启发式:有裁决包典型键
const keys = ["player_action", "visible_now", "resolved", "npc_moves", "variable_changes"];
let hits = 0;
for (const k of keys) if (k in row) hits += 1;
return hits >= 2;
}

View File

@@ -2,6 +2,7 @@ import type { Blackboard } from "../blackboard/blackboard.js";
import type { RuntimeSession } from "../types/runtime.js";
import {
deriveDesignStageScope,
deriveOnDemandWorkerScope,
deriveRunWorkerScope,
parseWorkerSetYaml,
runWorkerMeta,
@@ -19,8 +20,10 @@ export type InstanceWorkerDeclaration = {
parsed: ParsedWorkerSet | null;
/** 当前 lifecycle 下总管可 run_worker 的 id 列表 */
activeWorkerIds: string[];
/** play 阶段声明deriveRunWorkerScope */
/** play 阶段声明deriveRunWorkerScope;每轮管线 */
playWorkerIds: string[];
/** play 按需调度chance 等;不进自动回合序) */
onDemandWorkerIds: string[];
/** 创作末尾声明(如 opening-generator */
designEndWorkerIds: string[];
};
@@ -81,6 +84,8 @@ export function buildInstanceWorkerDeclaration(
const raw = readWorkerSetYamlForDeclaration(blackboard, session);
const parsed = raw ? parseWorkerSetYaml(raw.yaml) : null;
const playWorkerIds = accepted && parsed ? deriveRunWorkerScope(parsed) : [];
const onDemandWorkerIds =
accepted && parsed ? deriveOnDemandWorkerScope(parsed) : [];
const designEndWorkerIds =
accepted && parsed ? deriveDesignStageScope(parsed) : [];
@@ -89,7 +94,13 @@ export function buildInstanceWorkerDeclaration(
let activeWorkerIds: string[];
if (lifecycle === "play") {
activeWorkerIds = [...playWorkerIds];
activeWorkerIds = [...playWorkerIds, ...onDemandWorkerIds];
const seen = new Set<string>();
activeWorkerIds = activeWorkerIds.filter((id) => {
if (seen.has(id)) return false;
seen.add(id);
return true;
});
} else if (!accepted) {
activeWorkerIds = [...designStepIds];
} else {
@@ -109,6 +120,7 @@ export function buildInstanceWorkerDeclaration(
parsed,
activeWorkerIds,
playWorkerIds,
onDemandWorkerIds,
designEndWorkerIds,
};
}

View File

@@ -1,4 +1,12 @@
import { parse as parseYaml } from "yaml";
import {
inferPlaySlotsFromWorkers,
mergeWorkersWithPlaySlots,
onDemandRefsFromPlaySlots,
parsePlaySlots,
refsFromPlaySlots,
type PlaySlotsConfig,
} from "./play-slots.js";
export type WorkerSetPresentation = {
tone?: string;
@@ -15,6 +23,9 @@ export type WorkerSetContext = {
export type WorkerAcceptance = "review" | "continue";
/** turn = 每轮管线on_demand = 仅显式 run_worker / toolcall */
export type WorkerInvocation = "turn" | "on_demand";
export type WorkerSetEntry = {
ref: string | null;
/**
@@ -31,6 +42,8 @@ export type WorkerSetEntry = {
* review = 用户验收continue = 可连跑下一 worker。
*/
acceptance?: WorkerAcceptance;
/** 缺省 turnchance 等程序工具为 on_demand */
invocation?: WorkerInvocation;
merge_considered?: string;
gap?: string | null;
presentation?: WorkerSetPresentation | null;
@@ -60,6 +73,13 @@ export type ParsedWorkerSet = {
play_morphology?: string;
input_protocol?: Record<string, string>;
workers: WorkerSetEntry[];
/**
* 固定游玩槽位勾选(世界模拟路径首选)。
* 若存在,解析时会与 workers 合并:槽位定骨架,条目可覆盖 context/outputs。
*/
play_slots?: PlaySlotsConfig;
/** 投影排序表context-order.v1拼装优先于此 */
context_order?: unknown;
tag_flow?: string[];
resident_context?: unknown[];
tables?: Record<string, unknown>;
@@ -86,7 +106,7 @@ export const DESIGN_STAGE_SKILL_META: Record<
"opening-generator": {
label: "开局 · 开场白",
purpose:
"创作末尾:结合已定世界/故事写开场白(主);表初值与开场对齐,能推则推。",
"创作末尾:优先落库设计.开场白与开场变量;否则现写开场;初值与开场同真相。",
},
};
@@ -108,8 +128,8 @@ export const INSTANTIATE_SKILL_META: Record<
{ label: string; purpose: string }
> = {
"world-blueprint": {
label: "世界蓝图",
purpose: "背景板与核心设定,供 world-simulator 等读取。",
label: "舞台骨架",
purpose: "可引用舞台(尺度、基底变造、关键舞台区),供主世界层等读取。",
},
topology: {
label: "拓扑 / 关系",
@@ -120,8 +140,8 @@ export const INSTANTIATE_SKILL_META: Record<
purpose: "元规则:如何生成 NPC、物品等实例内容。",
},
"narrative-guide": {
label: "叙事 / 描写指南",
purpose: "正文 POV、时态、文风narrator 等 static 上下文)。",
label: "叙事指南与故事推进",
purpose: "遣词、笔墨焦点、禁忌与推进口径narrator / gm 上下文)。",
},
"variable-catalog": {
label: "变量目录",
@@ -163,6 +183,10 @@ export const RUN_WORKER_META: Record<string, { label: string; purpose: string }>
label: "回合陈述",
purpose: "结构化陈述本轮事件与各方行动/思考摘要。",
},
chance: {
label: "机遇裁定",
purpose: "程序掷骰/比点/抽签;按需调用,不进每轮管线。",
},
};
function asString(value: unknown): string | undefined {
@@ -214,6 +238,11 @@ function parseWorkers(raw: unknown): WorkerSetEntry[] {
acceptanceRaw === "review" || acceptanceRaw === "continue"
? acceptanceRaw
: undefined;
const invocationRaw = asString(row.invocation);
const invocation: WorkerInvocation | undefined =
invocationRaw === "turn" || invocationRaw === "on_demand"
? invocationRaw
: undefined;
return {
ref,
name: asString(row.name),
@@ -222,6 +251,7 @@ function parseWorkers(raw: unknown): WorkerSetEntry[] {
when: asString(row.when),
rationale: asString(row.rationale),
acceptance,
invocation,
merge_considered: asString(row.merge_considered),
gap: ref == null ? asString(row.gap) ?? null : asString(row.gap) ?? null,
presentation: parsePresentation(row.presentation),
@@ -256,6 +286,13 @@ function parseWorkerSetObject(row: Record<string, unknown>): ParsedWorkerSet {
? (interactionRaw as ParsedWorkerSetInteraction)
: undefined;
const workersRaw = parseWorkers(row.workers);
let play_slots = parsePlaySlots(row.play_slots ?? row.playSlots);
if (!play_slots) {
play_slots = inferPlaySlotsFromWorkers(workersRaw);
}
const workers = mergeWorkersWithPlaySlots(workersRaw, play_slots);
return {
version: typeof row.version === "number" ? row.version : undefined,
form_summary: asString(row.form_summary),
@@ -280,7 +317,18 @@ function parseWorkerSetObject(row: Record<string, unknown>): ParsedWorkerSet {
.filter(([, v]) => v),
)
: undefined,
workers: parseWorkers(row.workers),
play_slots,
workers,
context_order:
row.context_order &&
typeof row.context_order === "object" &&
!Array.isArray(row.context_order)
? row.context_order
: row.contextOrder &&
typeof row.contextOrder === "object" &&
!Array.isArray(row.contextOrder)
? row.contextOrder
: undefined,
tag_flow: asStringList(row.tag_flow),
resident_context: Array.isArray(row.resident_context)
? row.resident_context
@@ -401,6 +449,9 @@ export function looksLikeProseNotSpec(text: string): boolean {
export function isUsableWorkerSet(parsed: ParsedWorkerSet | null | undefined): boolean {
if (!parsed || parsed.parseError) return false;
if ((parsed.workers?.length ?? 0) > 0) return true;
if (parsed.play_slots && (parsed.play_slots.gm || parsed.play_slots.narrator)) {
return true;
}
if (parsed.interaction && Object.keys(parsed.interaction).length > 0) return true;
if (parsed.form_summary?.trim() || parsed.interaction_paradigm?.trim()) return true;
if (parsed.narrative_guide?.trim() || (parsed.core_premises?.length ?? 0) > 0) {
@@ -458,15 +509,63 @@ export function deriveInstantiateScope(workerSet: ParsedWorkerSet | null): strin
return deriveDesignStageScope(workerSet);
}
/** play 阶段启用的 run worker ref 列表(保序、去重;不含 opening-generator 等 design-end skill */
function isOnDemandWorker(worker: WorkerSetEntry): boolean {
if (worker.invocation === "on_demand") return true;
if (worker.invocation === "turn") return false;
// 未标注时chance 默认按需
return worker.ref?.trim() === "chance";
}
/** play 阶段每轮管线 refs保序、去重不含按需槽与 design-end */
export function deriveRunWorkerScope(workerSet: ParsedWorkerSet | null): string[] {
if (!workerSet) return [];
// 固定槽位:按 perspective → gm → narrator 顺序
if (workerSet.play_slots) {
const fromSlots = refsFromPlaySlots(workerSet.play_slots);
if (fromSlots.length) {
const seen = new Set(fromSlots);
const ordered = [...fromSlots];
for (const worker of workerSet.workers) {
const ref = worker.ref?.trim();
if (!ref || seen.has(ref) || DESIGN_STAGE_SKILL_META[ref]) continue;
if (isOnDemandWorker(worker)) continue;
seen.add(ref);
ordered.push(ref);
}
return ordered;
}
}
const seen = new Set<string>();
const ordered: string[] = [];
for (const worker of workerSet.workers) {
const ref = worker.ref?.trim();
if (!ref || seen.has(ref)) continue;
if (DESIGN_STAGE_SKILL_META[ref]) continue;
if (isOnDemandWorker(worker)) continue;
seen.add(ref);
ordered.push(ref);
}
return ordered;
}
/** play 阶段可显式调度的按需 refsdice/抽签等) */
export function deriveOnDemandWorkerScope(
workerSet: ParsedWorkerSet | null,
): string[] {
if (!workerSet) return [];
const seen = new Set<string>();
const ordered: string[] = [];
if (workerSet.play_slots) {
for (const ref of onDemandRefsFromPlaySlots(workerSet.play_slots)) {
if (seen.has(ref)) continue;
seen.add(ref);
ordered.push(ref);
}
}
for (const worker of workerSet.workers) {
const ref = worker.ref?.trim();
if (!ref || seen.has(ref) || DESIGN_STAGE_SKILL_META[ref]) continue;
if (!isOnDemandWorker(worker)) continue;
seen.add(ref);
ordered.push(ref);
}

View File

@@ -11,6 +11,17 @@ import {
residentTagFor,
type ResidentContextEntry,
} from "./resident-context.js";
import {
PLAY_SLOT_META,
PLAY_SLOT_ORDER,
refForSlot,
type PlaySlotId,
} from "./play-slots.js";
import {
contextOrderToView,
parseContextOrder,
synthesizeContextOrderFromWorkers,
} from "./context-order.js";
export type TagLineView = {
tag: string;
@@ -93,6 +104,30 @@ export type WorkerSetUserView = {
reasoning?: string;
playModeLabel?: string;
playModeHint?: string;
/** 固定游玩槽位勾选摘要(世界模拟路径) */
playSlots?: Array<{ id: string; label: string; enabled: boolean; ref: string }>;
/** 投影排序(可编排);无表时可由 workers.context 合成 */
contextOrder?: {
brief?: string;
editable?: boolean;
/** 是否由声明合成(尚未写入规格) */
synthesized?: boolean;
slots: Array<{
ref: string;
label?: string;
lines: string[];
inserts?: Array<{
index: number;
order: number;
anchor: string;
ref: string;
projection: string;
note?: string;
label?: string;
line: string;
}>;
}>;
};
inputProtocol?: PresentationLineView[];
workers: WorkerCardView[];
/**
@@ -183,16 +218,21 @@ const DEFAULT_WORKER_CONTRACTS: Record<
"设计.worker集",
"世界.蓝图.确认稿",
"世界.拓扑.*",
"变量.目录.确认稿",
"变量.变化规则.确认稿",
"设计.变量设计与更新规则",
],
dynamic: [
"变量.当前",
"运行.事件流",
"用户.最新输入",
"上下文.角色态度",
"大纲.当前章",
],
dynamic: ["变量.当前", "运行.事件流", "用户.最新输入"],
},
outputs: ["运行.本轮.裁决", "运行.事件流"],
outputs: ["运行.本轮.裁决", "运行.事件流", "运行.本轮.变量变更"],
},
"variable-update": {
context: {
static: ["变量.目录.确认稿", "变量.变化规则.确认稿"],
static: ["设计.变量设计与更新规则"],
dynamic: ["运行.本轮.裁决", "变量.当前"],
},
outputs: ["运行.本轮.变量变更", "变量.当前"],
@@ -201,11 +241,10 @@ const DEFAULT_WORKER_CONTRACTS: Record<
context: {
static: [
"设计.worker集",
"叙事.指南.确认稿",
"语料.场景策略集.确认稿",
"输出.回复格式.规范",
"设计.叙事指南与故事推进",
"设计.叙事指南",
],
dynamic: ["运行.事件流", "变量.当前", "运行.本轮.变量变更"],
dynamic: ["运行.本轮.裁决", "用户.最新输入"],
},
outputs: ["输出.用户展示"],
},
@@ -230,6 +269,13 @@ const DEFAULT_WORKER_CONTRACTS: Record<
},
outputs: ["运行.本轮.角色决策"],
},
chance: {
context: {
static: ["设计.worker集"],
dynamic: ["运行.机会请求"],
},
outputs: ["运行.本轮.机遇"],
},
};
const INPUT_PROTOCOL_LABELS: Record<string, string> = {
@@ -243,6 +289,10 @@ const WORKER_ROLE_LABELS: Record<string, string> = {
core: "核心",
auxiliary: "辅助",
transcription: "转述",
gm: "主世界层",
narrator: "叙事转述",
perspective: "角色视角",
chance: "机遇裁定",
};
function tagMatchesPattern(tag: string, pattern: string): boolean {
@@ -692,6 +742,36 @@ export function formatWorkerSetForUser(
reasoning: parsed.reasoning,
playModeLabel: morph?.label ?? morphKey,
playModeHint: morph?.hint,
playSlots: parsed.play_slots
? PLAY_SLOT_ORDER.map((id: PlaySlotId) => ({
id,
label: PLAY_SLOT_META[id].label,
enabled: Boolean(parsed.play_slots![id]),
ref: refForSlot(parsed.play_slots!, id),
}))
: undefined,
contextOrder: (() => {
const order = parseContextOrder(parsed.context_order);
if (order) return contextOrderToView(order);
const workersForSynth = parsed.workers.map((w) => {
const ref = w.ref?.trim() ?? "";
const def = ref ? DEFAULT_WORKER_CONTRACTS[ref] : undefined;
return {
ref: w.ref,
name: w.name,
context: {
static: w.context?.static ?? def?.context.static,
dynamic: w.context?.dynamic ?? def?.context.dynamic,
},
};
});
const synth = synthesizeContextOrderFromWorkers(
workersForSynth,
parsed.play_slots,
);
if (!synth) return undefined;
return { ...contextOrderToView(synth), synthesized: true };
})(),
inputProtocol,
workers,
contextTags,

View File

@@ -41,6 +41,8 @@ export type WaitingReason =
* 有值时不阻断验收:用户可直接 Accept也可先作答再 Accept。
*/
questions?: QuestionItem[];
/** 自评/导语,随追问展示在询问卡顶部 */
assessment?: string;
pageSize?: number;
} // worker 产物待验收
| {
@@ -144,8 +146,15 @@ export type MainAgentDecision = {
action: MainAgentAction;
reason: string;
workerId?: string;
/** 调度 role-decide 等时指定当前决策角色Runtime 写入 世界.当前角色.id */
workerContext?: { roleId?: string };
/**
* 调度附加上下文:
* - roleIdrole-decide 等
* - chance机遇裁定请求op=roll|compare|draw|pick
*/
workerContext?: {
roleId?: string;
chance?: Record<string, unknown>;
};
/** ask_user给用户看的内容完备度评价写入 waitingReason.message */
assessment?: string;
/** ask_user 结构化追问(有则前端询问卡) */
@@ -228,6 +237,8 @@ export type RuntimeEvent =
artifactId: string;
/** 有产物时的可选追问,挂到 review_artifact */
questions?: QuestionItem[] | string[];
/** 自评摘要,挂到询问卡 */
assessment?: string;
};
}
| {
@@ -295,7 +306,14 @@ export type RuntimeSession = {
*/
export type PhaseEffect =
| { type: "invoke_main_agent" }
| { type: "run_worker"; workerId: string; workerContext?: { roleId?: string } }
| {
type: "run_worker";
workerId: string;
workerContext?: {
roleId?: string;
chance?: Record<string, unknown>;
};
}
| { type: "resume_worker" }
| { type: "run_programmatic_review"; artifactId: string }
| { type: "emit_message"; message: string };

View File

@@ -18,6 +18,7 @@ import {
normalizeQuestions,
type QuestionItem,
} from "../skills/question-protocol.js";
import { extractFragmentAskSidecar } from "../skills/context-fragment.js";
export type WorkerRunParams = {
skillName: string;
@@ -40,6 +41,8 @@ export type WorkerRunResult = {
summary: string;
preview: string;
askUser?: QuestionItem[];
/** 来自 context-fragment 自评/导语,挂到验收询问卡评估区 */
askAssessment?: string;
};
const WORKER_SET_OUTPUT_TAGS = new Set(["设计.worker集", "设计.worker集.草稿"]);
@@ -210,6 +213,28 @@ function parseWorkerResponse(
}
}
// context-fragment.v1正文内 追问/自评 → 挂到询问卡(不必再抄一份 askUser
let askAssessment: string | undefined;
const fragQuestions: QuestionItem[] = [];
for (const content of Object.values(outputs)) {
const side = extractFragmentAskSidecar(content);
if (side.assessment && !askAssessment) askAssessment = side.assessment;
for (const q of side.questions) {
if (!fragQuestions.some((x) => x.prompt === q.prompt)) {
fragQuestions.push(q);
}
}
}
if (fragQuestions.length) {
if (!askUser?.length) {
askUser = fragQuestions;
} else {
for (const q of fragQuestions) {
if (!askUser.some((x) => x.prompt === q.prompt)) askUser.push(q);
}
}
}
const summary =
typeof obj.summary === "string" && obj.summary.trim()
? obj.summary.trim()
@@ -226,6 +251,7 @@ function parseWorkerResponse(
summary,
preview,
askUser: askUser?.length ? askUser : undefined,
askAssessment,
});
}
@@ -233,6 +259,7 @@ function parseWorkerResponse(
export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResult {
const outputs = { ...result.outputs };
const askUser = [...(result.askUser ?? [])];
const askAssessment = result.askAssessment;
let droppedProse = false;
for (const tag of [...Object.keys(outputs)]) {
@@ -292,6 +319,7 @@ export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResu
summary,
preview,
askUser: askUser.length ? askUser : undefined,
askAssessment: askAssessment?.trim() || undefined,
};
}