上下文按规则、历史、产物分段拼装,便于吃提示缓存并核对费用。 Co-authored-by: Cursor <cursoragent@cursor.com>
722 lines
25 KiB
TypeScript
722 lines
25 KiB
TypeScript
/**
|
||
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
|
||
* 再在同一条消息前缀上循环 insert。已通过的不再覆盖。
|
||
* 没有未通过项时,同一次请求里的 reply_user 才给用户。
|
||
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
|
||
*/
|
||
import { randomUUID } from "node:crypto";
|
||
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
|
||
import type { AgentDriver } from "../runtime/driver.js";
|
||
import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
|
||
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
|
||
import { buildDictateContext, type DictateSegmentChars } from "./context.js";
|
||
import {
|
||
CREATION_PLAN_SCHEMA,
|
||
CREATION_PLAN_TAG,
|
||
formatCreationPlanCapabilityCatalog,
|
||
formatCreationPlanProgress,
|
||
parseCreationPlan,
|
||
} from "./creation-plan.js";
|
||
import { isDictateOpeningTag } from "./experience-anchor.js";
|
||
import {
|
||
buildDictateReplyPrompt,
|
||
buildDictateSelectPrompt,
|
||
buildDictateSystemPrompt,
|
||
formatDictateRoundTail,
|
||
type DictatePromptTailOpts,
|
||
} from "./prompt.js";
|
||
import {
|
||
parseDictateModuleReadState,
|
||
readLibraryEntries,
|
||
serializeDictateModuleReadState,
|
||
DICTATE_MODULE_READ_STATE_TAG,
|
||
type DictateModuleReadPayload,
|
||
} from "./read-module.js";
|
||
import { resolveModuleForInsertTag } from "./insert-feedback.js";
|
||
import type { DictateChatTurn, DictateProduct } from "./types.js";
|
||
import { isAllowedProductTag } from "./types.js";
|
||
import { VALUE_MAP_TAG } from "../skills/value-map.js";
|
||
import { VARIABLE_CATALOG_TAG } from "../skills/variable-catalog.js";
|
||
|
||
const READ_EXEMPT = new Set([
|
||
"narrative",
|
||
"variable-context",
|
||
"context-order",
|
||
"worker-spec",
|
||
"refine",
|
||
]);
|
||
|
||
type Apply = (calls: DriverToolCall[]) => DriverStepOutcome | Promise<DriverStepOutcome>;
|
||
|
||
export type PlannedRoundParams = {
|
||
llm: LlmProvider;
|
||
dialogue: DictateChatTurn[];
|
||
handlers: {
|
||
listProducts: () => DictateProduct[];
|
||
readTag: (tag: string) => string | undefined;
|
||
writeTag: (tag: string, content: string) => void;
|
||
prepareOpening?: () => void;
|
||
/** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */
|
||
lookupInsertFeedback?: (tag: string) => string | undefined;
|
||
onThinkingDelta?: (delta: string) => void;
|
||
onThinkingDone?: (text: string) => void;
|
||
};
|
||
moduleCatalog?: import("../skills/creation-flow.js").ModuleCatalog | null;
|
||
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
|
||
moduleGuide?: string;
|
||
recipeExamples?: string;
|
||
recipeName?: string;
|
||
recipeBrief?: string;
|
||
systemPrompt?: string;
|
||
};
|
||
|
||
type PlanInsert = {
|
||
tag: string;
|
||
moduleId: string;
|
||
libraryId?: string;
|
||
entryIds: string[];
|
||
reason?: string;
|
||
};
|
||
|
||
type SelectPlan = {
|
||
clarify: string[];
|
||
inserts: PlanInsert[];
|
||
deletes: string[];
|
||
/** 模型对象上的字段名,空计划时写进回复,方便核对解析有没有对上 */
|
||
keys: string[];
|
||
};
|
||
|
||
|
||
type ItemFailure = { tag: string; error: string };
|
||
|
||
|
||
const INSERT_ATTEMPTS = 4;
|
||
|
||
export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver {
|
||
return {
|
||
async run(input) {
|
||
const reply = await runPlannedDictateRound(params, input.handleStep);
|
||
return { iterations: reply.modelCalls, stop: { kind: "text", content: reply.text } };
|
||
},
|
||
};
|
||
}
|
||
|
||
async function runPlannedDictateRound(
|
||
params: PlannedRoundParams,
|
||
apply: Apply,
|
||
): Promise<{ text: string; modelCalls: number }> {
|
||
const turnId = randomUUID();
|
||
const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt();
|
||
const tailOpts = tailState(params);
|
||
const selectTail = formatDictateRoundTail({ ...tailOpts, phase: "select" });
|
||
const selectCtx = buildDictateContext({
|
||
systemPrompt: buildDictateSelectPrompt(),
|
||
products: params.handlers.listProducts(),
|
||
dialogue: params.dialogue,
|
||
phase: "select",
|
||
tail: selectTail,
|
||
});
|
||
const selected = await params.llm.completeWithTools(selectCtx.messages, {
|
||
caller: "dictate_agent",
|
||
label: "对话落盘 选题",
|
||
tools: [DICTATE_SELECT_TOOL],
|
||
toolChoice: { type: "function", function: { name: "select_nodes" } },
|
||
usageDetail: detail(turnId, "select", 0, selectCtx.segmentChars),
|
||
usageSink: sinkFor(turnId, "select"),
|
||
});
|
||
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
|
||
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
|
||
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
|
||
if (plan.clarify.length >= 2 && plan.inserts.length === 0 && plan.deletes.length === 0) {
|
||
return {
|
||
modelCalls: 1,
|
||
text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"),
|
||
};
|
||
}
|
||
if (plan.inserts.length === 0 && plan.deletes.length === 0) {
|
||
const fields = plan.keys.length ? plan.keys.join("、") : "空对象";
|
||
return {
|
||
modelCalls: 1,
|
||
text: `这一轮没有写入产物。选题返回的字段是 ${fields},没有可执行的写入项。可以说得更具体些,或点明先写哪一块。`,
|
||
};
|
||
}
|
||
|
||
const blocked = plan.inserts.filter((item) => item.tag.startsWith("!"));
|
||
const inserts = plan.inserts.filter((item) => !item.tag.startsWith("!"));
|
||
const openings = inserts.filter((item) => isDictateOpeningTag(item.tag));
|
||
const rest = inserts.filter((item) => !isDictateOpeningTag(item.tag));
|
||
const blockedLines = blocked.map(
|
||
(item) => `${item.tag.slice(1)}(${item.reason || "计划未通过程序核验"})`,
|
||
);
|
||
if (openings.length > 0) params.handlers.prepareOpening?.();
|
||
const injected = injectGroup(params, [...rest, ...openings]);
|
||
const tail = formatDictateRoundTail({
|
||
...tailOpts,
|
||
phase: "fill",
|
||
checklist: formatChecklist([...rest, ...openings], plan.deletes, []),
|
||
});
|
||
const ctx = buildDictateContext({
|
||
systemPrompt: fillSystem,
|
||
products: params.handlers.listProducts(),
|
||
dialogue: params.dialogue,
|
||
phase: "fill",
|
||
injected: injected.text,
|
||
tail,
|
||
});
|
||
const messages: ChatMessage[] = [...ctx.messages];
|
||
const passed = new Set<string>();
|
||
const pending = new Set([...rest, ...openings].map((item) => item.tag));
|
||
const pendingDeletes = new Set(plan.deletes);
|
||
const errors = new Map<string, string>();
|
||
let replyFromTool = "";
|
||
let lastProse = "";
|
||
let modelCalls = 1;
|
||
|
||
for (let attempt = 0; attempt < INSERT_ATTEMPTS && (pending.size > 0 || pendingDeletes.size > 0); attempt++) {
|
||
const step = attempt === 0 ? "fill" : "fill-retry";
|
||
const filled = await params.llm.completeWithTools(messages, {
|
||
caller: "dictate_agent",
|
||
label: attempt === 0 ? "对话落盘 填写" : "对话落盘 重写",
|
||
tools: DICTATE_WRITE_LOOP_TOOLS,
|
||
toolChoice: "required",
|
||
usageDetail: {
|
||
...detail(turnId, step, attempt, ctx.segmentChars),
|
||
plannedTags: [...pending, ...pendingDeletes],
|
||
},
|
||
usageSink: sinkFor(turnId, step),
|
||
});
|
||
modelCalls += 1;
|
||
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
|
||
const prose = filled.content?.trim() ?? "";
|
||
if (prose) lastProse = prose;
|
||
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
|
||
id: call.id || `call-${attempt}-${index}`,
|
||
name: call.name,
|
||
arguments: call.arguments,
|
||
}));
|
||
if (calls.length === 0) break;
|
||
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
|
||
messages.push(assistantToolMessage(filled.content, calls));
|
||
for (const item of settled.results) {
|
||
messages.push({ role: "tool", content: item.content, tool_call_id: item.callId });
|
||
}
|
||
if (pending.size === 0 && pendingDeletes.size === 0) {
|
||
replyFromTool = settled.reply;
|
||
break;
|
||
}
|
||
}
|
||
|
||
let reply = replyFromTool;
|
||
if (
|
||
!replyFromTool &&
|
||
pending.size === 0 &&
|
||
pendingDeletes.size === 0 &&
|
||
passed.size > 0
|
||
) {
|
||
reply = await runLeanReply(params, turnId, passed, plan.deletes, lastProse);
|
||
modelCalls += 1;
|
||
} else if (!reply) {
|
||
reply = lastProse;
|
||
}
|
||
|
||
const failed = [...pending, ...pendingDeletes].map(
|
||
(tag) => `${tag}(${errors.get(tag) ?? "尚未通过"})`,
|
||
);
|
||
const pendingLines = [...blockedLines, ...failed];
|
||
const text = pendingLines.length
|
||
? [reply, `尚未完成:${pendingLines.join("、")}。`].filter(Boolean).join("\n")
|
||
: reply;
|
||
return { text, modelCalls };
|
||
}
|
||
|
||
async function runLeanReply(
|
||
params: PlannedRoundParams,
|
||
turnId: string,
|
||
passed: Set<string>,
|
||
plannedDeletes: string[],
|
||
fallbackProse: string,
|
||
): Promise<string> {
|
||
const writtenTags = [...passed].filter((tag) => !tag.startsWith("del:"));
|
||
const deletedTags = [...passed]
|
||
.filter((tag) => tag.startsWith("del:"))
|
||
.map((tag) => tag.slice(4))
|
||
.filter(Boolean);
|
||
const products = params.handlers
|
||
.listProducts()
|
||
.filter((product) => writtenTags.some((tag) => product.tag === tag || product.tag.startsWith(`${tag}#`)));
|
||
const feedbackBlocks = writtenTags
|
||
.map((tag) => params.handlers.lookupInsertFeedback?.(tag)?.trim())
|
||
.filter((text): text is string => Boolean(text));
|
||
if (writtenTags.length > 1 && feedbackBlocks.length > 1) {
|
||
feedbackBlocks.unshift(
|
||
"本轮写入了多个产物。用一段自然回复综合说明本轮变化、共同强项与关键缺口;不要逐模块套标题或重复模板。只有缺口确需用户决定时才提问。",
|
||
);
|
||
}
|
||
const tailOpts = tailState(params);
|
||
const deleteNote =
|
||
deletedTags.length || plannedDeletes.length
|
||
? `本轮已删除:${(deletedTags.length ? deletedTags : plannedDeletes).join("、")}`
|
||
: "";
|
||
const replyTail = [
|
||
formatDictateRoundTail({
|
||
...tailOpts,
|
||
phase: "reply",
|
||
checklist: [
|
||
writtenTags.length ? `本轮已写入:${writtenTags.join("、")}` : "",
|
||
deleteNote,
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n"),
|
||
}),
|
||
feedbackBlocks.length ? `## 本轮关注(按能力回复口径)\n\n${feedbackBlocks.join("\n\n")}` : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
const replyCtx = buildDictateContext({
|
||
systemPrompt: buildDictateReplyPrompt(),
|
||
products,
|
||
dialogue: params.dialogue,
|
||
phase: "reply",
|
||
tail: replyTail,
|
||
});
|
||
const filled = await params.llm.completeWithTools(replyCtx.messages, {
|
||
caller: "dictate_agent",
|
||
label: "对话落盘 回复",
|
||
tools: [DICTATE_REPLY_TOOL],
|
||
toolChoice: { type: "function", function: { name: DICTATE_REPLY_TOOL.function.name } },
|
||
usageDetail: {
|
||
...detail(turnId, "reply", 0, replyCtx.segmentChars),
|
||
plannedTags: writtenTags,
|
||
},
|
||
usageSink: sinkFor(turnId, "reply"),
|
||
});
|
||
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
|
||
const call = filled.toolCalls.find((item) => item.name === DICTATE_REPLY_TOOL.function.name);
|
||
if (call) {
|
||
const text = replyText({
|
||
id: call.id || "reply",
|
||
name: call.name,
|
||
arguments: call.arguments,
|
||
});
|
||
if (text) return text;
|
||
}
|
||
return filled.content?.trim() || fallbackProse;
|
||
}
|
||
|
||
function assistantToolMessage(content: string | null, calls: DriverToolCall[]): ChatMessage {
|
||
return {
|
||
role: "assistant",
|
||
content,
|
||
tool_calls: calls.map((call) => ({
|
||
id: call.id,
|
||
type: "function" as const,
|
||
function: { name: call.name, arguments: call.arguments },
|
||
})),
|
||
};
|
||
}
|
||
|
||
async function settleWriteStep(
|
||
params: PlannedRoundParams,
|
||
apply: Apply,
|
||
calls: DriverToolCall[],
|
||
passed: Set<string>,
|
||
pending: Set<string>,
|
||
pendingDeletes: Set<string>,
|
||
errors: Map<string, string>,
|
||
): Promise<{ results: Array<{ callId: string; content: string }>; reply: string }> {
|
||
const results: Array<{ callId: string; content: string }> = [];
|
||
const forward: DriverToolCall[] = [];
|
||
let reply = "";
|
||
for (const call of calls) {
|
||
if (call.name === DICTATE_REPLY_TOOL.function.name) continue;
|
||
const tag = callTag(call, params);
|
||
if (call.name === "insert" && tag && passed.has(tag)) {
|
||
results.push({
|
||
callId: call.id,
|
||
content: JSON.stringify({ error: "已通过,本步不要再写", tag }),
|
||
});
|
||
continue;
|
||
}
|
||
if (call.name === "delete" && tag && passed.has(`del:${tag}`)) {
|
||
results.push({
|
||
callId: call.id,
|
||
content: JSON.stringify({ error: "已通过,本步不要再删除", tag }),
|
||
});
|
||
continue;
|
||
}
|
||
if (call.name === "insert" && tag) {
|
||
const args = asRecord(safeJson(call.arguments));
|
||
forward.push({ ...call, arguments: JSON.stringify({ ...args, position: tag }) });
|
||
continue;
|
||
}
|
||
forward.push(call);
|
||
}
|
||
const outcome = await apply(forward);
|
||
if (outcome.kind === "continue") {
|
||
for (const result of outcome.results) {
|
||
results.push(result);
|
||
const call = forward.find((item) => item.id === result.callId);
|
||
const payload = asRecord(safeJson(result.content));
|
||
const tag = call ? callTag(call, params) : "";
|
||
if (payload.ok === true && tag) {
|
||
if (call?.name === "delete") {
|
||
passed.add(`del:${tag}`);
|
||
pendingDeletes.delete(tag);
|
||
} else {
|
||
passed.add(tag);
|
||
for (const planned of [...pending]) {
|
||
if (planned === tag || tag.startsWith(`${planned}#`)) pending.delete(planned);
|
||
}
|
||
}
|
||
errors.delete(tag);
|
||
} else if (typeof payload.error === "string" && tag) {
|
||
const planned = [...pending, ...pendingDeletes].find(
|
||
(item) => item === tag || tag.startsWith(`${item}#`),
|
||
);
|
||
errors.set(planned ?? tag, payload.error);
|
||
}
|
||
}
|
||
}
|
||
for (const call of calls) {
|
||
if (call.name !== DICTATE_REPLY_TOOL.function.name) continue;
|
||
const text = replyText(call);
|
||
if (pending.size > 0 || pendingDeletes.size > 0) {
|
||
const left = [...pending, ...pendingDeletes].join("、");
|
||
results.push({
|
||
callId: call.id,
|
||
content: JSON.stringify({
|
||
error: `还有未通过:${left}。这份回复先不给用户。只再写入这些。`,
|
||
}),
|
||
});
|
||
continue;
|
||
}
|
||
reply = text;
|
||
results.push({ callId: call.id, content: JSON.stringify({ ok: true }) });
|
||
}
|
||
return { results, reply };
|
||
}
|
||
|
||
function callTag(call: DriverToolCall, params: PlannedRoundParams): string {
|
||
const args = asRecord(safeJson(call.arguments));
|
||
const raw = String(args.position ?? args.tag ?? "").trim();
|
||
if (!raw) return "";
|
||
return canonicalProductTag(raw, "", params.moduleCatalog)?.tag ?? raw;
|
||
}
|
||
|
||
function replyText(call: DriverToolCall): string {
|
||
const args = asRecord(safeJson(call.arguments));
|
||
return String(args.text ?? args.reply ?? args.content ?? args.message ?? "").trim();
|
||
}
|
||
|
||
function sinkFor(_turnId: string, _step: string): UsageSink {
|
||
return {};
|
||
}
|
||
|
||
function detail(
|
||
turnId: string,
|
||
step: UsageDetail["step"],
|
||
retryIndex: number,
|
||
segmentChars: DictateSegmentChars,
|
||
): UsageDetail {
|
||
return { turnId, step, retryIndex, segmentChars };
|
||
}
|
||
|
||
function tailState(params: PlannedRoundParams): DictatePromptTailOpts {
|
||
const products = params.handlers.listProducts();
|
||
const plan = parseCreationPlan(
|
||
products.find((product) => product.tag === CREATION_PLAN_TAG)?.content,
|
||
);
|
||
if (plan) {
|
||
return {
|
||
creationPlanState: formatCreationPlanProgress({
|
||
plan,
|
||
catalog: params.moduleCatalog,
|
||
products,
|
||
}),
|
||
};
|
||
}
|
||
return {
|
||
capabilityCatalog:
|
||
formatCreationPlanCapabilityCatalog(params.moduleCatalog) || params.moduleGuide,
|
||
recipeExamples: params.recipeExamples,
|
||
recipeName: params.recipeName,
|
||
recipeBrief: params.recipeBrief,
|
||
moduleGuide: params.moduleGuide,
|
||
};
|
||
}
|
||
|
||
function parseSelect(raw: unknown, params: PlannedRoundParams): SelectPlan {
|
||
const row = asRecord(raw);
|
||
const clarify = stringList(row.clarify);
|
||
let deletes = stringList(row.deletes).filter((tag) => isAllowedProductTag(tag));
|
||
const inserts: PlanInsert[] = [];
|
||
let items = Array.isArray(row.nodes)
|
||
? row.nodes
|
||
: Array.isArray(row.inserts)
|
||
? row.inserts
|
||
: [];
|
||
if (items.length === 0 && clarify.length < 2 && deletes.length === 0) {
|
||
const recovered = recoverSelectWork(row);
|
||
items = recovered.items;
|
||
deletes = recovered.deletes.filter((tag) => isAllowedProductTag(tag));
|
||
}
|
||
for (const item of items) {
|
||
const rec = asRecord(item);
|
||
const moduleIdHint = String(rec.module_id ?? rec.moduleId ?? "").trim();
|
||
let tag = String(rec.tag ?? rec.position ?? "").trim();
|
||
if (!tag && moduleIdHint) {
|
||
tag =
|
||
params.moduleCatalog?.modules.find((module) => module.id === moduleIdHint)?.artifact?.trim() ??
|
||
"";
|
||
}
|
||
if (!tag) continue;
|
||
const canonical = canonicalProductTag(tag, moduleIdHint, params.moduleCatalog);
|
||
if (canonical) tag = canonical.tag;
|
||
const resolvedHint = canonical?.moduleId || moduleIdHint;
|
||
const module =
|
||
params.moduleReadIndex?.get(resolvedHint) ??
|
||
params.moduleReadIndex?.get(tag) ??
|
||
undefined;
|
||
const fromTag = resolveModuleForInsertTag(tag, params.moduleCatalog);
|
||
const moduleId = module?.module_id || canonical?.moduleId || fromTag?.id || moduleIdHint;
|
||
const entryIds = stringList(rec.entry_ids);
|
||
const reason = String(rec.reason ?? "").trim();
|
||
if (!isAllowedProductTag(tag) && tag !== VARIABLE_CATALOG_TAG && tag !== VALUE_MAP_TAG) {
|
||
inserts.push({ tag: `!${tag || "空"}`, moduleId, entryIds, reason: "产物 tag 不合法" });
|
||
continue;
|
||
}
|
||
const payload = moduleId ? params.moduleReadIndex?.get(moduleId) : undefined;
|
||
if (payload && !READ_EXEMPT.has(payload.module_id) && payload.libraries.length) {
|
||
const libraryId = String(rec.library_id ?? payload.libraries[0]?.library_id ?? "").trim();
|
||
if (entryIds.length > 2 && !reason) {
|
||
inserts.push({
|
||
tag: `!${tag}`,
|
||
moduleId,
|
||
entryIds,
|
||
reason: "默认最多两个库条目",
|
||
});
|
||
continue;
|
||
}
|
||
if (libraryId && entryIds.length) {
|
||
const bound = payload.libraries.some((library) => library.library_id === libraryId);
|
||
if (!bound) {
|
||
inserts.push({ tag: `!${tag}`, moduleId, entryIds, reason: `能力未绑定库 ${libraryId}` });
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
inserts.push({
|
||
tag,
|
||
moduleId,
|
||
libraryId: String(rec.library_id ?? "").trim() || undefined,
|
||
entryIds,
|
||
reason: reason || undefined,
|
||
});
|
||
}
|
||
return { clarify, inserts, deletes, keys: Object.keys(row) };
|
||
}
|
||
|
||
/** 选题没交 inserts 时,从方案正文、operations 或别名字段收出本轮要写的能力,再自动发填写。 */
|
||
function recoverSelectWork(row: Record<string, unknown>): {
|
||
items: unknown[];
|
||
deletes: string[];
|
||
} {
|
||
if (isCreationPlanShape(row)) {
|
||
return { items: [{ tag: CREATION_PLAN_TAG, module_id: "creation-plan" }], deletes: [] };
|
||
}
|
||
const items: unknown[] = [];
|
||
const deletes: string[] = [];
|
||
for (const list of [row.modules, row.selections, row.selected, row.writes, row.products, row.operations]) {
|
||
for (const item of asItemList(list)) {
|
||
const rec = flattenItem(item);
|
||
if (
|
||
typeof rec.tier === "string" &&
|
||
typeof rec.why === "string" &&
|
||
rec.tag == null &&
|
||
rec.position == null
|
||
) {
|
||
continue;
|
||
}
|
||
const op = operationName(rec);
|
||
if (op === "read_module" || op === "read_library_entry" || op === "prepare_opening") {
|
||
continue;
|
||
}
|
||
const moduleId = String(rec.module_id ?? rec.moduleId ?? "").trim();
|
||
const tag = String(rec.tag ?? rec.position ?? rec.artifact ?? "").trim();
|
||
if (op === "delete" || op === "remove") {
|
||
if (tag) deletes.push(tag);
|
||
continue;
|
||
}
|
||
if (
|
||
op === "declare_variable" ||
|
||
op === "undeclare_variable" ||
|
||
op === "declare_map" ||
|
||
op === "remove_map"
|
||
) {
|
||
continue;
|
||
}
|
||
if (!tag && !moduleId) continue;
|
||
items.push({
|
||
tag,
|
||
module_id: moduleId,
|
||
...(rec.library_id != null ? { library_id: rec.library_id } : {}),
|
||
...(rec.entry_ids != null ? { entry_ids: rec.entry_ids } : {}),
|
||
...(rec.reason != null ? { reason: rec.reason } : {}),
|
||
});
|
||
}
|
||
}
|
||
return { items, deletes };
|
||
}
|
||
|
||
function isCreationPlanShape(row: Record<string, unknown>): boolean {
|
||
if (row.schema === CREATION_PLAN_SCHEMA) return true;
|
||
if (!Array.isArray(row.items) || row.items.length === 0) return false;
|
||
return row.items.every((item) => {
|
||
const rec = asRecord(item);
|
||
return (
|
||
typeof rec.module_id === "string" &&
|
||
rec.module_id.trim().length > 0 &&
|
||
typeof rec.tier === "string" &&
|
||
rec.tag == null &&
|
||
rec.position == null
|
||
);
|
||
});
|
||
}
|
||
|
||
function injectGroup(
|
||
params: PlannedRoundParams,
|
||
group: PlanInsert[],
|
||
): { text: string } {
|
||
const blocks: string[] = [];
|
||
const state = parseDictateModuleReadState(
|
||
params.handlers.readTag(DICTATE_MODULE_READ_STATE_TAG),
|
||
);
|
||
let changed = false;
|
||
for (const item of group) {
|
||
const payload = item.moduleId ? params.moduleReadIndex?.get(item.moduleId) : undefined;
|
||
if (!payload) continue;
|
||
const catalogEntry = resolveModuleForInsertTag(item.tag, params.moduleCatalog);
|
||
if (!READ_EXEMPT.has(payload.module_id) && !catalogEntry?.auto) {
|
||
state.modules[payload.module_id] = payload.version;
|
||
state.active_module_id = payload.module_id;
|
||
changed = true;
|
||
}
|
||
const parts = [
|
||
`### ${payload.module_id} @ ${payload.version}`,
|
||
`产物 tag:${item.tag}`,
|
||
payload.score_dimensions.length
|
||
? `self_score 维度(这次写入必填,name 必须一致):${payload.score_dimensions.join("、")}`
|
||
: "",
|
||
`#### task\n${payload.task}`,
|
||
`#### principles\n${payload.principles}`,
|
||
`#### output\n${payload.output}`,
|
||
`#### score\n${payload.score}`,
|
||
];
|
||
if (item.libraryId && item.entryIds.length) {
|
||
const loaded = readLibraryEntries({
|
||
module: payload,
|
||
libraryId: item.libraryId,
|
||
entryIds: item.entryIds,
|
||
reason: item.reason,
|
||
});
|
||
if (loaded.entries) {
|
||
for (const entry of loaded.entries) {
|
||
const row = entry as { id?: string; content?: string; name?: string };
|
||
parts.push(`#### 库 ${item.libraryId} / ${row.id ?? ""}\n${row.content ?? ""}`);
|
||
}
|
||
} else if (loaded.error) {
|
||
parts.push(`#### 库读取失败\n${loaded.error}`);
|
||
}
|
||
}
|
||
blocks.push(parts.filter(Boolean).join("\n\n"));
|
||
}
|
||
if (changed) {
|
||
params.handlers.writeTag(DICTATE_MODULE_READ_STATE_TAG, serializeDictateModuleReadState(state));
|
||
}
|
||
return { text: blocks.join("\n\n") };
|
||
}
|
||
|
||
function canonicalProductTag(
|
||
tag: string,
|
||
moduleIdHint: string,
|
||
catalog: PlannedRoundParams["moduleCatalog"],
|
||
): { tag: string; moduleId: string } | null {
|
||
const bare = tag.replace(/^(?:用户|设计)\./, "").split("#")[0]?.trim() ?? "";
|
||
const entry =
|
||
catalog?.modules.find((module) => module.id === moduleIdHint) ??
|
||
resolveModuleForInsertTag(tag, catalog) ??
|
||
catalog?.modules.find(
|
||
(module) =>
|
||
module.name === bare ||
|
||
module.id === bare ||
|
||
module.artifact === tag ||
|
||
module.artifact.endsWith(`.${bare}`),
|
||
);
|
||
if (!entry?.artifact?.trim()) return null;
|
||
const hash = tag.indexOf("#");
|
||
const suffix = hash > 0 ? tag.slice(hash) : "";
|
||
const next =
|
||
entry.repeatable && suffix.startsWith("#") && suffix.length > 1
|
||
? `${entry.artifact.trim()}${suffix}`
|
||
: entry.artifact.trim();
|
||
return { tag: next, moduleId: entry.id };
|
||
}
|
||
|
||
|
||
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
|
||
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
|
||
const lines = [
|
||
nodes.length ? `第一轮选定的节点:${nodes.join("、")}` : "",
|
||
group.length ? `本轮写入:${group.map((item) => item.tag).join("、")}` : "",
|
||
deletes.length ? `本轮删除:${deletes.join("、")}` : "",
|
||
errors.length
|
||
? `只重写未成功项:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
|
||
: "",
|
||
];
|
||
return lines.filter(Boolean).join("\n");
|
||
}
|
||
|
||
function asItemList(value: unknown): unknown[] {
|
||
if (Array.isArray(value)) return value;
|
||
const rec = asRecord(value);
|
||
const values = Object.values(rec);
|
||
if (values.length > 0 && values.every((item) => item && typeof item === "object")) {
|
||
return values;
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function flattenItem(item: unknown): Record<string, unknown> {
|
||
if (typeof item === "string") {
|
||
const text = item.trim();
|
||
if (!text) return {};
|
||
if (text.startsWith("设计.") || text.startsWith("用户.")) return { tag: text };
|
||
return { module_id: text };
|
||
}
|
||
const rec = asRecord(item);
|
||
const nested = asRecord(rec.arguments ?? rec.args ?? rec.payload);
|
||
return { ...rec, ...nested };
|
||
}
|
||
|
||
function operationName(rec: Record<string, unknown>): string {
|
||
return String(rec.op ?? rec.name ?? rec.type ?? rec.action ?? "insert").trim();
|
||
}
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||
return value as Record<string, unknown>;
|
||
}
|
||
|
||
function stringList(value: unknown): string[] {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.map((item) => String(item ?? "").trim()).filter(Boolean);
|
||
}
|
||
|
||
function safeJson(raw: string): unknown {
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|