Files
my-pi/extensions/system-prompt.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ba164896d2 feat: 系统提示词改为单一入口统一管理
多个扩展各自整段改写系统提示词并按加载顺序接力,任一扩展定死提示词后
其余只能做字符串手术,且各上游的工具说明与组合包实际工具格局冲突。

- 新增 extensions/system-prompt.ts 作为唯一出口(加载列表最后),按
  config/prompt/(preamble.md、tools.jsonc、routing.jsonc、sections.jsonc)
  组合开场白、工具简介/规则与 <tool_routing> 分区;替代原封面与
  tool-routing 扩展,/dump-system-prompt 输出最终提示词。
- pi-chrome、pi-ssh、pi-hermes-memory 改为写命名分区,pi-rtk-optimizer
  改写 promptGuidelines,pi-permission-system 直接过滤结构化 skills,
  均不再返回 systemPrompt;pi-chrome 未授权时不再每轮冻结提示词。
- 新增 prompt-guard 代理加载第三方扩展(Hashline、FFF、LSP、
  context-mode):拦截直接改写提示词并按目录覆盖工具说明;context-mode
  去掉与组合包路由冲突的锚点段,保留记忆与恢复快照。
- 消除冲突:bash 简介不再建议 grep/find;Hashline edit 不再称可用 grep
  锚点、不再介绍已关闭的 replace_text;context-mode 读写/网页路由提示。
- 守门测试:工具目录覆盖率、禁用用法检查、context-mode 上游原文锁定;
  AGENTS.md 新增「系统提示词单一所有权」。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 17:53:04 -07:00

163 lines
5.8 KiB
TypeScript

import type { BeforeAgentStartEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import {
applyToolCatalog,
loadRoutingRules,
loadSectionsConfig,
loadToolCatalog,
orderSections,
readPreamble,
renderRouting,
spliceCover,
type MutablePromptOptions,
type RoutingConfig,
type SectionsConfig,
type ToolCatalog,
} from "./system-prompt-config.ts";
const DUMP_RELATIVE_PATH = ".pi-debug/effective-system-prompt.md";
export function getSystemPromptDumpPath(cwd: string): string {
return resolve(cwd, DUMP_RELATIVE_PATH);
}
/** The options object as Pi 1.3 normalizes it; the vendored types predate `sections`/`forceSystemPrompt`. */
type RuntimePromptOptions = MutablePromptOptions & { forceSystemPrompt?: string };
/**
* Injectable seams so the composer can be exercised under `node --test`
* without touching the real `config/prompt/` files.
*/
export interface SystemPromptComposerDeps {
loadToolCatalog?: () => ToolCatalog;
loadRoutingRules?: () => RoutingConfig;
loadSectionsConfig?: () => SectionsConfig;
readPreamble?: () => string | undefined;
}
export interface SystemPromptComposer {
handleBeforeAgentStart: (
event: BeforeAgentStartEvent,
ctx: ExtensionContext,
) => { systemPrompt: string } | undefined;
getLastComposed: () => string | undefined;
}
/**
* The bundle's single system-prompt owner. It is the only extension allowed to
* return `systemPrompt`; every other bundle or third-party extension must
* contribute structurally through `event.systemPromptOptions`.
*
* Per turn it applies `config/prompt/tools.jsonc` (tool snippets/guidelines),
* renders `config/prompt/routing.jsonc` into a `<tool_routing>` section, orders
* contributed sections per `config/prompt/sections.jsonc`, then reads the
* re-rendered prompt and splices in `config/prompt/preamble.md` before
* returning the final text.
*
* If an earlier extension violated the contract by setting
* `forceSystemPrompt`, the prompt is already frozen: the composer degrades to
* text splicing (preamble + appended `<tool_routing>` when absent) and warns
* once. Config load/validation failures warn once and leave Pi's default
* prompt untouched — the handler never throws.
*/
export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}): SystemPromptComposer {
const loadCatalog = deps.loadToolCatalog ?? loadToolCatalog;
const loadRouting = deps.loadRoutingRules ?? loadRoutingRules;
const loadSections = deps.loadSectionsConfig ?? loadSectionsConfig;
const loadPreamble = deps.readPreamble ?? readPreamble;
let lastComposed: string | undefined;
let forcedNotified = false;
let configErrorNotified = false;
const notifiedMissingSets = new Set<string>();
function handleBeforeAgentStart(
event: BeforeAgentStartEvent,
ctx: ExtensionContext,
): { systemPrompt: string } | undefined {
const options = event.systemPromptOptions as unknown as RuntimePromptOptions | undefined;
const forced = options !== undefined && options.forceSystemPrompt !== undefined;
if (forced && ctx.hasUI && !forcedNotified) {
forcedNotified = true;
ctx.ui.notify(
"检测到其他扩展直接改写了系统提示词(违反单一入口规则),本轮降级为文本拼接。",
"warning",
);
}
let catalog: ToolCatalog;
let routing: RoutingConfig;
let sections: SectionsConfig;
let preamble: string | undefined;
try {
catalog = loadCatalog();
routing = loadRouting();
sections = loadSections();
preamble = loadPreamble();
} catch (error) {
if (ctx.hasUI && !configErrorNotified) {
configErrorNotified = true;
const detail = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`系统提示词配置加载失败:${detail}`, "warning");
}
return undefined;
}
let routingText = "";
if (options) {
const missing = applyToolCatalog(options, catalog);
if (missing.length > 0 && ctx.hasUI) {
const key = [...missing].sort().join("\n");
if (!notifiedMissingSets.has(key)) {
notifiedMissingSets.add(key);
ctx.ui.notify(`以下工具未在 config/prompt/tools.jsonc 登记:${missing.join(", ")}`, "warning");
}
}
routingText = renderRouting(routing.rules, options.selectedTools);
options.sections ??= {};
if (routingText.length > 0) {
options.sections.tool_routing = routingText;
}
options.sections = orderSections(options.sections, sections.order);
} else {
routingText = renderRouting(routing.rules, undefined);
}
let rendered = event.systemPrompt;
if (routingText.length > 0 && !rendered.includes("<tool_routing>")) {
// Degrade path: the prompt was frozen by forceSystemPrompt, so the
// contributed section cannot render — append it as plain text.
rendered += `\n\n<tool_routing>\n${routingText}\n</tool_routing>`;
}
const final = preamble ? spliceCover(rendered, preamble) : rendered;
lastComposed = final;
return { systemPrompt: final };
}
return {
handleBeforeAgentStart,
getLastComposed: () => lastComposed,
};
}
export default function systemPromptExtension(pi: ExtensionAPI): void {
const composer = createSystemPromptComposer();
pi.on("before_agent_start", (event, ctx) => composer.handleBeforeAgentStart(event, ctx));
pi.registerCommand("dump-system-prompt", {
description: "Write the effective Pi system prompt to .pi-debug/effective-system-prompt.md",
handler: async (_args, ctx) => {
const outputPath = getSystemPromptDumpPath(ctx.cwd);
const effectivePrompt = composer.getLastComposed() ?? ctx.getSystemPrompt();
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, effectivePrompt, "utf8");
ctx.ui.notify(`System prompt written to ${outputPath} (${effectivePrompt.length} chars)`, "info");
},
});
}