Files
writing-agent/src/skills/context-order.ts
moranzhi 94f67fa744 引入上下文片段、固定槽与投影排序,并完善机遇裁定与游玩期 UI。
把创作产物收敛为可挂载片段与 play_slots/context_order,同步修订世界模拟器模块与运行时拼装。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 01:36:32 +08:00

512 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 上下文投影排序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,
});
}