引入上下文片段、固定槽与投影排序,并完善机遇裁定与游玩期 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

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,
});
}