/** * The bridge protocol: what we send to ChatGPT, and what it must send back. * * Without a contract the loop degrades into free-form prose. The reply cannot be * checked, the loop cannot tell "done" from "one more step", and pi receives * whatever shape ChatGPT felt like producing. This module makes both directions * explicit and machine-checkable, while staying tolerant of how ChatGPT actually * formats things (English or Chinese headings, numbered or bulleted lists). * * Zero pi dependencies: pure data transformation. */ export const PROTOCOL_ID = "pi-bridge/1"; import { buildHello, buildRepair, buildResume, buildResync, buildRound, canonicalText, ERROR_CODES, parseFrame, PLAN_CONTRACT_VERSION, type ErrorCode, type Frame, type Session, } from "./handshake.ts"; export type Decision = "continue" | "done" | "blocked" | "need_info" | "unknown"; export interface Plan { /** Protocol id echoed by ChatGPT, if it wrote one. */ protocol: string | null; /** Contract v2 correlation line. Null for v1 replies. */ frame: Frame | null; decision: Decision; taskId: string | null; goal: string; steps: string[]; files: string[]; commands: string[]; risks: string[]; verify: string[]; notes: string; raw: string; } /* ------------------------------------------------------------------ */ /* Token accounting */ /* ------------------------------------------------------------------ */ /** * Rough token estimate. A mixed CJK/ASCII codebase is ~1 token per 2.5 chars on * the CJK side and ~1 per 4 on the ASCII side, so 3 is a deliberately * conservative middle. Only used for budgeting, never for billing. */ export function estimateTokens(text: string): number { if (!text) return 0; return Math.ceil(text.length / 3); } export function clampToTokens(text: string, maxTokens: number): { text: string; truncated: boolean } { if (estimateTokens(text) <= maxTokens) return { text, truncated: false }; const keep = Math.max(0, maxTokens * 3 - 200); const cut = text.slice(0, keep); // prefer to cut at a line boundary const lastBreak = cut.lastIndexOf("\n"); return { text: `${lastBreak > keep * 0.6 ? cut.slice(0, lastBreak) : cut}\n\n…[已按 token 预算截断]`, truncated: true, }; } /* ------------------------------------------------------------------ */ /* Response contract */ /* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */ /* Explicit prompt modes (contract v2, handshake/1) */ /* ------------------------------------------------------------------ */ const NL = String.fromCharCode(10); export type PromptMode = "hello" | "round" | "resume" | "repair" | "resync"; export interface TurnPromptOptions { mode: PromptMode; session: Session; /** Correlation line for round / repair modes. */ frame?: Frame; /** PLAN contract text. Emitted only in hello / resync. */ contract: string; capabilities?: readonly string[]; denials?: readonly string[]; objective: string; /** Per-round dynamic content: receipt, changed repo context, new instructions. */ dynamic?: string; /** The user's original request. Sent only the first time for a given qid. */ rootRequest?: string; /** * Set once the root request has already been delivered for this qid. The body * is then replaced by a reference, which is the actual token saving in steady * state -- not shortening the contract by a few percent. */ rootRequestAlreadySent?: boolean; /** Fatal error codes, for repair mode. */ errorCodes?: readonly ErrorCode[]; tokenBudget?: number; } /** Extra user instructions that arrived mid-session, if any. */ export interface RoundDynamicInput { receipt?: string; digest?: string; newInstructions?: string; } /** * Assemble the dynamic half of a ROUND. * * The repo digest stays dynamic for now: turning it into an incremental delta is * a separate design, and doing it here would make this migration harder to * review than it needs to be. */ export function composeRoundDynamic(input: RoundDynamicInput): string { const parts: string[] = []; if (input.receipt?.trim()) parts.push(`## 上轮执行回执${NL}${canonicalText(input.receipt)}`); if (input.digest?.trim()) parts.push(`## 当前仓库上下文${NL}${canonicalText(input.digest)}`); if (input.newInstructions?.trim()) parts.push(`## 用户新增指令${NL}${canonicalText(input.newInstructions)}`); return parts.join(`${NL}${NL}`); } /** * Build the message for one turn, according to `mode`. * * Why modes instead of one envelope: the old builder re-sent the preamble, the * whole contract, the full capability list and the root request on every single * turn. That is what made long prompts wedge the renderer, and it is the largest * avoidable cost in the loop. Steady state now sends one FRAME line plus what * actually changed. * * The contract is deliberately NOT subject to the token clamp: its hash is * computed over the canonical text, so a truncated contract would silently fail * verification on the other side. Only the dynamic half is clamped. */ export function buildTurnPrompt(options: TurnPromptOptions): string { const { mode, session } = options; const capabilities = options.capabilities; const denials = options.denials; switch (mode) { case "hello": // rootRequest is deliberately NOT forwarded. A handshake turn that // carries the actual question gets answered instead of acknowledged -- // observed live. The request travels in ROUND 1. return buildHello({ session, contract: options.contract, capabilities, denials, objective: options.objective, }); case "resync": return buildResync({ session, contract: options.contract, capabilities, denials, objective: options.objective, rootRequest: options.rootRequestAlreadySent ? undefined : options.rootRequest, }); case "resume": return buildResume(session); case "repair": if (!options.frame) throw new Error("repair 模式需要 frame"); return buildRepair(options.frame, options.errorCodes ?? []); case "round": { if (!options.frame) throw new Error("round 模式需要 frame"); const parts: string[] = []; if (options.rootRequest && !options.rootRequestAlreadySent) { parts.push(`## 原始请求${NL}${canonicalText(options.rootRequest)}`); } else { // Reference instead of resending. The session already carries the // contract, the capabilities and the request this qid stands for. parts.push(`## 原始请求${NL}沿用 qid=${session.qid}(首次已发送,不重复)`); } if (options.dynamic?.trim()) parts.push(canonicalText(options.dynamic)); const body = parts.join(`${NL}${NL}`); const bounded = options.tokenBudget ? clampToTokens(body, options.tokenBudget) : { text: body }; return buildRound(options.frame, bounded.text); } } } /** * The exact shape ChatGPT must return. Kept short: every extra instruction is * tokens spent on every single turn. */ export const RESPONSE_CONTRACT = `# PLAN ${PROTOCOL_ID} FRAME: s= r= p= q= DECISION: continue | done | blocked | need_info TASK: <短标识,如 fix-selector-drift> ## GOAL 一句话说明本轮要达成的目标。 ## STEPS 1. 步骤一(可执行、单一职责) 2. 步骤二 ## FILES - <相对路径> :: <要做什么改动> ## COMMANDS - <要执行的命令,没有就写 无> ## RISKS - <风险或不确定点,没有就写 无> ## VERIFY - <如何验证本轮成功了> ## NOTES <可选补充>`; /* * Code-block rules, kept separate because they are about *transport*, not * about the plan content: pi reads this reply through ChatGPT's own * "copy" button, and a code-block copy button copies only that one block. * A reply split across several blocks therefore cannot be captured whole. */ export const CODE_BLOCK_RULES = [ "整个回复只允许有 1 个代码块,就是承载上面这份方案的那一个。", "方案里要给出代码、命令或配置时,写在这个块内部(缩进或行内反引号),不要另开代码块。", "不要把一份方案拆成多个代码块:复制按钮一次只能取到其中一块,pi 会拿到残缺的方案。", "块内首行必须是 # PLAN ${PROTOCOL_ID},第二行必须是 FRAME 行,供 pi 做轮次关联。", ]; /** * `need_info` is the fourth decision, and it is not `blocked`. * * `need_info` means pi can gather what is missing using capabilities it has. * `blocked` means the requirement depends on something outside pi (a human, a * GUI, the network, account access). Conflating them either stops a loop that * could have continued, or makes pi pretend to have read source it never saw. */ export const DECISION_SEMANTICS = [ "continue:本轮给出可执行步骤,pi 执行后回传。", "done:总目标已达成,pi 停止循环。", "blocked:依赖 pi 能力之外的条件(人工、GUI、网络、账号、权限),无法自行推进。", "need_info:信息不足,但 pi 可以用自己的本领补全。在 COMMANDS 里写清楚要采什么。", "need_info 不等于 blocked,也不代表完成;pi 采集完会进入下一轮。", ]; /** * The complete normative text of the contract. * * `RESPONSE_CONTRACT` alone is not the whole contract: the tool-usage rules are * appended next to it in HELLO/RESYNC. Hashing only the former meant a change to * the code-block rules would not change the contract hash, so the other side * would never be told the contract had moved. * * Anything normative that is sent must be inside this value. */ export const FULL_CONTRACT = [RESPONSE_CONTRACT, '', '## 代码块规则', ...CODE_BLOCK_RULES.map((rule) => `- ${rule}`)].join(NL); export interface ReplyShape { /** Number of fenced code blocks found in the reply. */ codeBlocks: number; /** Fence parity: false when a fence is opened but never closed (BUG-018's * sibling gap, verdict #33: ceil(1/2)=1 used to pass an unclosed block off * as exactly one block). */ balanced: boolean; /** True when the reply satisfies the one-block rule AND fences are balanced. */ conforms: boolean; notes: string[]; } /** * Count the code blocks in a reply so a multi-block answer can be reported * (and handled correctly) instead of silently truncated. */ export function checkReplyShape(reply: string): ReplyShape { const fences = reply.split(/^\s*(?:```|~~~)/m).length - 1; const codeBlocks = Math.ceil(fences / 2); // Verdict #33: ceil(1/2) === 1, so a reply whose only fence never closed // used to pass the one-block gate. An unclosed fence means the reply cannot // be proven complete — it is a deterministic structural defect, fatal. const balanced = fences % 2 === 0; const notes: string[] = []; if (!balanced) notes.push("代码围栏未闭合,无法证明回复完整"); if (codeBlocks > 1) { notes.push(`回复包含 ${codeBlocks} 个代码块(契约要求 1 个);单块复制只能取到其中一块,因此应改用整轮复制。`); } if (codeBlocks === 0) notes.push("回复里没有代码块,无法用复制按钮取回;只能读整轮文本。"); return { codeBlocks, balanced, conforms: codeBlocks === 1 && balanced, notes }; } export function buildResponseContract(): string { return RESPONSE_CONTRACT; } /* ------------------------------------------------------------------ */ /* Outgoing prompt */ /* ------------------------------------------------------------------ */ export interface PromptContext { /** Markdown produced by buildRepoDigest(). */ digest: string; /** What pi was asked to do overall. */ objective: string; /** The concrete ask for this turn (ignored when 'relay' is set). */ ask?: string; /** Result of the previous turn's execution, to hand back. */ relay?: { iteration: number; taskId: string | null; decision: Decision; steps: string[]; commands: string[]; toolNames: string[]; result: string; }; /** Extra per-project instructions from config. */ extraInstructions?: string; tokenBudget: number; } /** * Build the standard envelope sent on every turn. * * The structure is deliberately stable across turns: a fixed preamble, the * project digest, the previous round's contract fields, then the ask. Stability * matters for two reasons -- ChatGPT keeps its footing, and the *content* of the * prompt becomes predictable enough to reason about token cost. */ export function buildPrompt(context: PromptContext): string { const parts: string[] = []; parts.push(`# PI BRIDGE REQUEST (${PROTOCOL_ID})`); parts.push( [ "你是方案设计方,pi 是执行方。你不执行任何操作,只输出方案。", "pi 只能做这些事:读写文件、跑 shell 命令、调用本仓库的测试脚本。", "下面的「本轮请求」是用户的原始提问,不要改写它的意图;信息不足就写进 RISKS。", ].join("\n"), ); parts.push("## 项目上下文\n" + context.digest.trim()); parts.push(`## 总目标\n${context.objective.trim() || "(未指定)"}`); if (context.relay) { const relay = context.relay; const lines = [`第 ${relay.iteration} 轮执行回执:`, ""]; if (relay.taskId) lines.push(`TASK: ${relay.taskId}`); lines.push(`上一轮 DECISION: ${relay.decision}`); if (relay.steps.length) lines.push(`上一轮 STEPS:\n${relay.steps.map((s) => `- ${s}`).join("\n")}`); if (relay.commands.length) lines.push(`上一轮 COMMANDS:\n${relay.commands.map((c) => `- ${c}`).join("\n")}`); if (relay.toolNames.length) lines.push(`pi 实际调用的工具: ${relay.toolNames.join(", ")}`); lines.push("", "pi 的执行结果:", relay.result.trim() || "(空)"); parts.push("## 上一轮回执\n" + lines.join("\n")); } parts.push( "## 本轮请求(用户原话,不要改写意图)\n" + ( context.relay ? "(本轮为自动接力)基于上面的执行结果判断下一步。如果总目标已经达成,DECISION 写 done。" : (context.ask ?? "").trim() || "(未指定)" ), ); if (context.extraInstructions?.trim()) { parts.push("## 项目附加要求\n" + context.extraInstructions.trim()); } parts.push( "## 返回格式(必须严格遵守)\n" + "只输出**一个代码块**,块内内容严格按下面的结构;代码块之外不要有任何文字。\n\n" + CODE_BLOCK_RULES.map((rule) => `- ${rule}`).join("\n") + "\n\n" + "```markdown\n" + buildResponseContract() + "\n```", ); parts.push( [ "你是方案设计方,pi 是执行方。你不执行任何操作,只输出方案。", "pi 只能做这些事:读写文件、跑 shell 命令、调用本仓库的测试脚本。", "下面的「本轮请求」是用户的原始提问,不要改写它的意图;信息不足就写进 RISKS。", ].join("\n"), ); parts.push("## 项目上下文\n" + context.digest.trim()); parts.push(`## 总目标\n${context.objective.trim() || "(未指定)"}`); if (context.relay) { const relay = context.relay; const lines = [`第 ${relay.iteration} 轮执行回执:`, ""]; if (relay.taskId) lines.push(`TASK: ${relay.taskId}`); lines.push(`上一轮 DECISION: ${relay.decision}`); if (relay.steps.length) lines.push(`上一轮 STEPS:\n${relay.steps.map((s) => `- ${s}`).join("\n")}`); if (relay.commands.length) lines.push(`上一轮 COMMANDS:\n${relay.commands.map((c) => `- ${c}`).join("\n")}`); if (relay.toolNames.length) lines.push(`pi 实际调用的工具: ${relay.toolNames.join(", ")}`); lines.push("", "pi 的执行结果:", relay.result.trim() || "(空)"); parts.push("## 上一轮回执\n" + lines.join("\n")); } parts.push( "## 本轮请求(用户原话,不要改写意图)\n" + ( context.relay ? "(本轮为自动接力)基于上面的执行结果判断下一步。如果总目标已经达成,DECISION 写 done。" : (context.ask ?? "").trim() || "(未指定)" ), ); if (context.extraInstructions?.trim()) { parts.push("## 项目附加要求\n" + context.extraInstructions.trim()); } parts.push( "## 返回格式(必须严格遵守)\n" + "只输出**一个代码块**,块内内容严格按下面的结构;代码块之外不要有任何文字。\n\n" + "```markdown\n" + buildResponseContract() + "\n```", ); const joined = parts.join("\n\n"); return clampToTokens(joined, context.tokenBudget).text; } /* ------------------------------------------------------------------ */ /* Incoming plan */ /* ------------------------------------------------------------------ */ // `frame` is omitted because it is not a section of the reply -- it is the correlation // line, parsed separately. Leaving it in made the Record demand an alias that cannot // exist, and made every `plan[key] = string[]` assignment look like it might be writing // a Frame. const SECTION_ALIASES: Record< keyof Omit | "notes", RegExp > = { goal: /^(goal|目标|目的|objective)$/i, steps: /^(steps?|步骤|实施步骤|执行步骤)$/i, files: /^(files?|文件|涉及文件|改动文件)$/i, commands: /^(commands?|命令|执行命令|shell)$/i, risks: /^(risks?|风险|风险与不确定点|不确定点)$/i, verify: /^(verify|verification|验证|验收|检查)$/i, notes: /^(notes?|备注|补充|说明)$/i, }; const NO_CONTENT = /^(无|none|n\/a|—|-|\(none\)|(无))$/i; function cleanLines(block: string): string[] { return block .split("\n") .map((line) => line .replace(/^\s*[-*+]\s+/, "") .replace(/^\s*\d+[.)]\s+/, "") .replace(/^\s*\[[ xX]\]\s+/, "") .trim(), ) .filter((line) => line.length > 0 && !NO_CONTENT.test(line)); } /** * Parse ChatGPT's reply into the contract fields. * * Tolerant by design: it strips markdown fences, accepts `## GOAL`, `GOAL:`, * English or Chinese headings, and numbered or bulleted lists. Anything it cannot * find stays empty -- validatePlan() is what decides whether that matters. */ export function parsePlan(text: string): Plan { const raw = text ?? ""; // Drop an outer code fence if present. const body = raw .replace(/^\s*```[a-zA-Z0-9_-]*\s*\n?/, "") .replace(/\n?```\s*$/, "") .trim(); const plan: Plan = { protocol: null, frame: null, decision: "unknown", taskId: null, goal: "", steps: [], files: [], commands: [], risks: [], verify: [], notes: "", raw, }; const protocolMatch = /#\s*PLAN\s+([a-z0-9/._-]+)/i.exec(body); if (protocolMatch) plan.protocol = protocolMatch[1]; const decisionMatch = /^\s*DECISION\s*:\s*([a-zA-Z_]+)/im.exec(body); if (decisionMatch) { const value = decisionMatch[1].toLowerCase(); plan.decision = value === "continue" || value === "done" || value === "blocked" || value === "need_info" ? (value as Decision) : "unknown"; } plan.frame = parseFrame(body); const taskMatch = /^\s*TASK\s*:\s*(.+)$/im.exec(body); if (taskMatch) plan.taskId = taskMatch[1].trim(); // Split on any line that looks like a section heading (## X, **X**, X:). const headingPattern = /^\s*(?:#{1,6}\s*|\*\*)?\s*([A-Za-z\u4e00-\u9fff ()/、]{2,20})\s*(?:\*\*)?\s*:?\s*$/; const chunks: Array<{ title: string; lines: string[] }> = []; let current: { title: string; lines: string[] } | null = null; for (const line of body.split("\n")) { if (/^\s*DECISION\s*:/i.test(line) || /^\s*TASK\s*:/i.test(line) || /^\s*FRAME\s*:/i.test(line) || /^\s*#\s*PLAN/i.test(line)) continue; const match = headingPattern.exec(line); const title = match?.[1]?.trim(); if (title && title.length <= 20 && Object.values(SECTION_ALIASES).some((re) => re.test(title))) { current = { title, lines: [] }; chunks.push(current); continue; } if (current) current.lines.push(line); } for (const chunk of chunks) { const key = (Object.keys(SECTION_ALIASES) as Array).find((candidate) => SECTION_ALIASES[candidate].test(chunk.title), ); if (!key) continue; if (key === "goal") { const goal = cleanLines(chunk.lines.join("\n")).join(" "); plan.goal = plan.goal || goal; } else if (key === "notes") { plan.notes = cleanLines(chunk.lines.join("\n")).join("\n"); } else { plan[key] = cleanLines(chunk.lines.join("\n")); } } // Fallback: if nothing structured was found at all, treat the whole reply as // steps so a well-meaning but unstructured answer is still usable. // // Only when the reply has NO protocol structure either. Otherwise the // "# PLAN" / "FRAME" / "DECISION" lines themselves become "steps", which // silently defeats the advisory checks that look for empty STEPS. const hasProtocolStructure = Boolean(plan.protocol) || plan.decision !== "unknown" || Boolean(plan.frame); if ( !hasProtocolStructure && plan.steps.length === 0 && plan.goal === "" && plan.files.length === 0 && plan.commands.length === 0 && body.length > 0 ) { plan.steps = cleanLines(body); plan.notes = "(未按协议输出,已按纯文本步骤处理)"; } return plan; } /** * Observation only — never a gate. * * The decision about whether a plan is good enough belongs to ChatGPT, not to * this code. These notes exist so the operator can see that a reply did not * follow the contract; they must not trigger a re-ask or a rewrite. The reply is * forwarded to pi exactly as ChatGPT wrote it either way. */ export function inspectPlan(plan: Plan): string[] { // Quality only. Structure -- header, FRAME, DECISION enum, TASK presence -- // belongs to validatePlanFatal(), which is the one that can block execution. // Reporting the same facts here would create a second adjudication path for // them, and two authorities over one decision is how a gate gets bypassed. const notes: string[] = []; if (plan.decision === "continue" && plan.steps.length === 0 && plan.files.length === 0) { notes.push("DECISION=continue 但 STEPS 与 FILES 都是空的"); } if (plan.decision === "blocked" && plan.risks.length === 0) { notes.push("DECISION=blocked 但没有在 RISKS 里说明原因"); } if (plan.decision === "need_info" && plan.commands.length === 0) { notes.push("DECISION=need_info 但 COMMANDS 里没说要采集什么"); } if (plan.decision === "continue" && plan.verify.length === 0) { notes.push("没有 VERIFY:pi 执行完不知道该怎么证明成功"); } return notes; } export interface FatalReport { fatalErrors: string[]; codes: ErrorCode[]; warnings: string[]; } /** * Which shape rules actually apply to the text being validated. * * The one-block rule is a statement about the REPLY's Markdown, and it assumes the text * handed in is that Markdown. A code-source capture is the body of one code block, so the * fence shape it shows is the copy affordance's output -- the rule has nothing to say about * it, and applying it anyway produced a fatal `E_BLOCKS` attributed to the other side for a * perfectly well-formed reply (BUG-015). The caller decides whether the rule applies, and it * may only say "no" from capture metadata it can prove, never from the text's own looks. */ export interface FatalCheckOptions { /** Default true. Set false only for a capture whose fence shape the reply does not decide. */ blocksApplicable?: boolean; } export function validatePlanFatal( plan: Plan, session: { sid: string; round: number; prev: string; qid: string; contractVersion?: number }, shape: { codeBlocks: number; balanced?: boolean }, options: FatalCheckOptions = {}, ): FatalReport { const fatalErrors: string[] = []; const codes: ErrorCode[] = []; if (options.blocksApplicable !== false && shape.codeBlocks !== 1) { fatalErrors.push(`代码块数量为 ${shape.codeBlocks},契约要求恰好 1 个`); codes.push(ERROR_CODES.BLOCKS); } // Verdict #33: an unclosed fence means the reply cannot be proven complete. // Fatal in the same E_BLOCKS domain. blocksApplicable === false (code-source // capture, BUG-015) sees only the block body and is exempt — do not break that. if (options.blocksApplicable !== false && shape.balanced === false) { fatalErrors.push("代码围栏未闭合,无法证明回复完整"); codes.push(ERROR_CODES.BLOCKS); } if (!plan.protocol) { fatalErrors.push(`缺少 "# PLAN ${PROTOCOL_ID}" 头部`); codes.push(ERROR_CODES.HEADER); } else if (plan.protocol !== PROTOCOL_ID) { // Present but different: a reformat cannot fix this, so it must be E_VERSION // (which routes to RESYNC) rather than E_HEADER (which routes to REPAIR). // Without this the version branch of the gate was unreachable. fatalErrors.push(`协议版本不一致(期望 ${PROTOCOL_ID},收到 ${plan.protocol})`); codes.push(ERROR_CODES.VERSION); } if (!plan.frame) { fatalErrors.push("缺少 FRAME 行"); codes.push(ERROR_CODES.FRAME); } if (plan.decision === "unknown") { fatalErrors.push("DECISION 缺失或不在枚举内"); codes.push(ERROR_CODES.DECISION); } if (!plan.taskId) { fatalErrors.push("缺少 TASK 字段"); codes.push(ERROR_CODES.REQUIRED); } if (plan.frame) { if (plan.frame.sid !== session.sid) { fatalErrors.push(`FRAME sid 不匹配(期望 ${session.sid},收到 ${plan.frame.sid})`); codes.push(ERROR_CODES.FRAME); } if (plan.frame.round !== session.round) { fatalErrors.push(`FRAME 轮次不匹配(期望 ${session.round},收到 ${plan.frame.round})`); codes.push(ERROR_CODES.FRAME); } if (plan.frame.prev !== session.prev) { fatalErrors.push(`FRAME prev 不匹配(期望 ${session.prev},收到 ${plan.frame.prev})`); codes.push(ERROR_CODES.FRAME); } if (plan.frame.qid !== session.qid) { fatalErrors.push(`FRAME qid 不匹配(期望 ${session.qid},收到 ${plan.frame.qid})`); codes.push(ERROR_CODES.FRAME); } } // Only report quality concerns when the message is decodable at all. const warnings = fatalErrors.length === 0 ? inspectPlan(plan) : []; return { fatalErrors, codes: [...new Set(codes)], warnings }; } /** One-line description for logs and status lines. */ export function summarizePlan(plan: Plan): string { const bits = [ `decision=${plan.decision}`, plan.taskId ? `task=${plan.taskId}` : null, `steps=${plan.steps.length}`, plan.files.length ? `files=${plan.files.length}` : null, plan.commands.length ? `commands=${plan.commands.length}` : null, plan.risks.length ? `risks=${plan.risks.length}` : null, ].filter(Boolean); return bits.join(" "); } /** * What the caller hands to pi. * * This is ChatGPT's reply **verbatim** — the wording, ordering and level of * detail are ChatGPT's to decide, so nothing here rewrites or re-summarises it. * Only a short machine-readable header is prepended, carrying the fields ChatGPT * itself emitted (DECISION / TASK), because the relay loop needs them and pi * benefits from seeing them as plain text. */ export function renderPlanForPi(plan: Plan): string { const header = [`[ChatGPT 方案 · ${PROTOCOL_ID}]`]; header.push(`DECISION: ${plan.decision}`); if (plan.taskId) header.push(`TASK: ${plan.taskId}`); header.push("以下正文一字未改,照此执行;不要重新规划。"); if (plan.decision === "done") { header.push("DECISION=done:不要再改动代码,总结本轮并等待下一轮。"); } const body = plan.raw.trim() || "(ChatGPT 回复为空)"; return `${header.join("\n")}\n\n---\n\n${body}`; }