Files
writing-agent/src/skills/context-segments.ts
T

381 lines
12 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.
/**
* Worker contextSegments 拼装:按 tier 顺序把黑板 tag 拼成 Markdown。
* 见 docs/context-assembly.md
*/
import type { Blackboard } from "../blackboard/blackboard.js";
import type { BlackboardInputMerge } from "../types/blackboard.js";
import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js";
import {
CREATION_ACCEPTED_CONTENT_TAG,
CREATION_ACCEPTED_UNITS_TAG,
formatAcceptedContentForPrompt,
formatAcceptedUnitsForPrompt,
} 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[] {
if (!Array.isArray(raw)) return [];
const out: ContextSegmentDef[] = [];
for (const row of raw) {
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
const r = row as Record<string, unknown>;
const id = typeof r.id === "string" ? r.id.trim() : "";
const tier = r.tier === "dynamic" ? "dynamic" : "static";
const tags = Array.isArray(r.tags)
? r.tags.filter((t): t is string => typeof t === "string" && t.trim()).map((t) => t.trim())
: [];
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;
}
function applyPolicy(content: string, policy?: string): string {
if (!policy || policy === "latest" || policy === "concat") return content;
const m = /^tail_lines_(\d+)$/.exec(policy);
if (m) {
const n = Number(m[1]);
if (Number.isFinite(n) && n > 0) {
const lines = content.split(/\r?\n/);
return lines.slice(-n).join("\n");
}
}
return content;
}
function readTagContent(
tag: string,
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
): string {
if (inputs[tag]?.trim()) return inputs[tag]!.trim();
const items = blackboard.queryByPatterns([tag], inputMerge);
if (items.length === 0) return "";
if (inputMerge === "concat") {
return items.map((i) => i.content).filter(Boolean).join("\n\n");
}
return items[items.length - 1]?.content?.trim() ?? "";
}
/** 单段投影正文(不含 label)。供世界书 marker 填洞复用。 */
export function renderContextSegmentBody(
segment: ContextSegmentDef,
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
skipExact?: Set<string>,
skipTags?: Set<string>,
): string {
return formatSegmentBody(
segment,
inputs,
blackboard,
inputMerge,
skipExact,
skipTags,
);
}
function formatSegmentBody(
segment: ContextSegmentDef,
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
skipExact?: Set<string>,
skipTags?: Set<string>,
): string {
if (segment.inline?.trim()) {
return applyProjection(segment.inline.trim(), segment.projection);
}
const parts: string[] = [];
const seen = new Set<string>();
for (const tag of segment.tags) {
if (skipTags?.has(tag)) continue;
let content: string;
if (isDialogueHistoryRef(tag)) {
content = resolveDialogueHistory(inputs, blackboard, inputMerge);
content = projectDialogueHistory(content, segment.projection);
} else {
content = readTagContent(tag, inputs, blackboard, inputMerge);
if (!content) continue;
if (tag === CREATION_ACCEPTED_CONTENT_TAG) {
content = formatAcceptedContentForPrompt(content);
} else if (tag === CREATION_ACCEPTED_UNITS_TAG) {
content = formatAcceptedUnitsForPrompt(content);
}
content = applyPolicy(content, segment.policy);
content = applyProjection(content, segment.projection);
}
const trimmed = content.trim();
if (!trimmed) continue;
if (skipExact?.has(trimmed)) continue;
if (seen.has(trimmed)) continue;
if (
parts.some(
(p) =>
p === trimmed ||
p.includes(`\n\n${trimmed}`) ||
p.startsWith(`${trimmed}\n\n`),
)
) {
continue;
}
seen.add(trimmed);
parts.push(trimmed);
}
return parts.join("\n\n");
}
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);
}
const USER_REQUIREMENT_TAGS = new Set([
"用户.需求",
"book.brief",
"用户.最新输入",
"用户.worker答复",
"用户.修订说明",
]);
const CREATION_REVIEW_WORKERS = new Set([
"design-step",
"design-flow",
"opening-generator",
]);
function alreadyCovered(parts: string[], next: string): boolean {
const trimmed = next.trim();
if (!trimmed) return true;
return parts.some(
(p) =>
p === trimmed ||
p.includes(`\n\n${trimmed}`) ||
p.startsWith(`${trimmed}\n\n`),
);
}
function pushUnique(parts: string[], next: string): void {
const trimmed = next.trim();
if (!trimmed || alreadyCovered(parts, trimmed)) return;
parts.push(trimmed);
}
/** 创作步:用户要求按时间排;冲突只看较新,旧条未点名推翻则仍有效。 */
function collectUserRequirementBlock(params: {
inputs: Record<string, string>;
blackboard: Blackboard;
inputMerge: BlackboardInputMerge;
}): string {
const { inputs, blackboard, inputMerge } = params;
const ordered = [
readTagContent("用户.需求", inputs, blackboard, inputMerge),
readTagContent("book.brief", inputs, blackboard, inputMerge),
readTagContent("用户.worker答复", inputs, blackboard, inputMerge),
readTagContent("用户.最新输入", inputs, blackboard, inputMerge),
readTagContent("用户.修订说明", inputs, blackboard, inputMerge),
];
const parts: string[] = [];
for (const item of ordered) pushUnique(parts, item);
if (!parts.length) return "";
return [
"## 【用户要求】按先后排列。冲突时以较新的为准;未被较新要求改写的旧条仍有效。",
"",
...parts,
].join("\n\n");
}
/**
* 按 contextSegments 拼装 user 侧上下文。
* 无 segments 时回退为 JSON inputs(兼容旧 skill)。
*/
export function assembleWorkerContext(params: {
inputs: Record<string, string>;
segments?: ContextSegmentDef[] | null;
blackboard: Blackboard;
inputMerge?: BlackboardInputMerge;
workerId: string;
workerName: string;
outputTags: string[];
}): string {
const inputMerge = params.inputMerge ?? "latest";
const segments = params.segments ?? [];
if (segments.length === 0) {
const revisionNote = (
params.inputs["用户.修订说明"] ??
""
).trim();
return JSON.stringify(
{
workerId: params.workerId,
workerName: params.workerName,
outputTags: params.outputTags,
inputs: params.inputs,
instruction: revisionNote
? "修订模式:在已有产物(inputs 中对应 outputTags)上按「用户.修订说明」以及「用户.worker答复」中的追问作答(选项与补充)修改;保留未点名要改的部分,禁止无故整份重写。节点方法(task/principles/probe/output)仍须遵守。"
: "根据 SKILL 说明完成任务。若 inputs 含「上下文.定稿摘要」,以终产物为准。" +
"设计.worker集 / 草稿须为 JSON 对象文本。",
},
null,
2,
);
}
const revisionNote = readTagContent(
"用户.修订说明",
params.inputs,
params.blackboard,
inputMerge,
).trim();
const inheritExisting = segments.some((s) => s.id === "inherit-existing");
const splitUserReq = CREATION_REVIEW_WORKERS.has(params.workerId);
const draftBlocks: string[] = [];
for (const tag of params.outputTags) {
const alreadyInSegments = segments.some((s) => s.tags.includes(tag));
if (alreadyInSegments) continue;
const draft = readTagContent(
tag,
params.inputs,
params.blackboard,
inputMerge,
).trim();
if (!draft) continue;
draftBlocks.push(`### \`${tag}\`\n\n${draft}`);
}
// 只有本步真有底稿(或回头修改)才算修订;上一步残留的修订说明仍作为用户要求保留
const inheritMode = inheritExisting || (Boolean(revisionNote) && draftBlocks.length > 0);
const skipTags = new Set<string>();
if (splitUserReq) {
for (const tag of USER_REQUIREMENT_TAGS) skipTags.add(tag);
}
const blocks: string[] = [];
const render = (seg: ContextSegmentDef) => {
if (inheritMode && seg.id === "module-opening") return;
const body = formatSegmentBody(
seg,
params.inputs,
params.blackboard,
inputMerge,
undefined,
skipTags,
);
if (!body) return;
if (seg.label) {
blocks.push(`${seg.label}\n\n${body}`);
} else {
blocks.push(body);
}
};
// 严格按 segments 数组顺序(投影排序表顺序);不再按 static/dynamic 重排
for (const seg of segments) render(seg);
if (splitUserReq) {
const reqBlock = collectUserRequirementBlock({
inputs: params.inputs,
blackboard: params.blackboard,
inputMerge,
});
if (reqBlock) blocks.push(reqBlock);
}
// 定稿摘要:若未在 segments 中声明,仍附在末尾
const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim();
const briefInSegments = segments.some((s) => s.tags.includes(CONTEXT_BRIEF_TAG));
if (brief && !briefInSegments) {
blocks.push(`## 上下文.定稿摘要\n\n${brief}`);
}
if (inheritMode && draftBlocks.length) {
blocks.push(
[
"## 【待改底稿】",
"",
"下列为**当前已有产物**(只保留这一份最新稿)。在其上落实【用户要求】里较新的条目;与旧要求冲突时以较新的为准,未点名改写的旧条仍须保留在产物里。",
"节点方法(task / principles / probe / output)仍须遵守。禁止无视较新要求原样交回。",
"",
...draftBlocks,
].join("\n"),
);
}
const taskLines = [
"## 本步任务",
"",
`- workerId: \`${params.workerId}\``,
`- workerName: ${params.workerName}`,
`- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`,
"",
];
if (inheritMode) {
taskLines.push(
inheritExisting
? "**回头修改**:接着上方【既有产物 · 继承修改】(以及【待改底稿】若有)继续改,写回同一产物。"
: "**修订模式**:以【待改底稿】为底,落实【用户要求】;冲突时以较新的为准。",
"不要从零另起一份。标为「只读 / 已定稿」的分区仍不可改。",
);
} else {
taskLines.push(
"按 SKILL 与上方分区完成任务。标为「只读 / 已定稿」的分区不要擅自改写;只改【本单位】范围。",
);
}
taskLines.push("设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。");
blocks.push(taskLines.join("\n"));
return blocks.join("\n\n---\n\n");
}