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>
This commit is contained in:
云服务部-叶林立
2026-10-20 17:53:04 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent dc4fe92e27
commit ba164896d2
39 changed files with 1863 additions and 742 deletions
+1
View File
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Move the global config directory to `<agentDir>/my-pi/rtk-optimizer/` under the bundle-owned rebuild tree (`config.json` is still created on demand by `ensureConfigExists`; the old `extensions/pi-rtk-optimizer/` location is ignored).
- Contribute the source-filter troubleshooting note through `systemPromptOptions.promptGuidelines` instead of rewriting the returned system prompt: `extensions/system-prompt.ts` is the bundle's single system-prompt owner, so handlers must never return `systemPrompt`. `injectGuidelineIntoPrompt` is removed.
## [0.9.0] - 2026-07-03
+30 -63
View File
@@ -35,7 +35,7 @@ mock.module("@earendil-works/pi-tui", {
});
const indexModule = await import("./index.ts");
const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote, injectGuidelineIntoPrompt } = indexModule;
const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote } = indexModule;
const rtkIntegrationExtension = indexModule.default;
const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts");
@@ -206,72 +206,39 @@ runTest("source-filter note skipped when all read filtering safeguards are disab
);
});
runTest("injectGuidelineIntoPrompt inserts bullet inside Guidelines section when header is at index 0", () => {
const guideline = "Test guideline for RTK troubleshooting.";
await runTest("before_agent_start contributes the source-filter note via promptGuidelines", async () => {
const config = configWith({
readCompactionEnabled: true,
sourceFilteringEnabled: true,
sourceFilteringLevel: "minimal",
smartTruncateEnabled: true,
});
writeFileSync(TEST_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
const prompt = [
"Guidelines:",
"- Use mcp for MCP discovery first",
"- Be concise in your responses",
"",
"<project_context>",
"Project-specific instructions",
].join("\n");
const handlers: Record<string, ExtensionHandler> = {};
rtkIntegrationExtension({
exec: async () => ({ code: 1, stdout: "", stderr: "missing rtk" }),
on(eventName: string, handler: ExtensionHandler) {
handlers[eventName] = handler;
},
registerCommand() {},
} as never);
const result = injectGuidelineIntoPrompt(prompt, guideline);
const beforeAgentStart = handlers.before_agent_start;
assert.ok(beforeAgentStart);
assert.ok(result.includes(guideline), "guideline must be present");
const event = {
systemPrompt: "base prompt",
systemPromptOptions: { promptGuidelines: [] as string[] },
};
const result = await beforeAgentStart(event, createNotificationContext([]));
assert.ok(!result || !("systemPrompt" in result), "must not force the system prompt");
assert.equal(event.systemPromptOptions.promptGuidelines.length, 1);
assert.match(event.systemPromptOptions.promptGuidelines[0], /RTK note:/);
const bullet = `- ${guideline}`;
assert.ok(result.includes(bullet), "guideline must be a bullet line");
const guidelinesStart = result.indexOf("Guidelines:\n");
const bulletIndex = result.indexOf(bullet);
const projectContextIndex = result.indexOf("\n<project_context>");
assert.equal(guidelinesStart, 0, "Guidelines header must be at index 0");
assert.ok(bulletIndex > guidelinesStart, "bullet must be after Guidelines header");
assert.ok(projectContextIndex !== -1, "project_context section must exist");
assert.ok(bulletIndex < projectContextIndex, "bullet must be before project_context section");
assert.equal(injectGuidelineIntoPrompt(result, guideline), result, "should be idempotent");
});
runTest("injectGuidelineIntoPrompt inserts bullet inside Guidelines section when header is mid-prompt", () => {
const guideline = "Test guideline for RTK troubleshooting.";
const prompt = [
"You are an expert coding assistant.",
"",
"Guidelines:",
"- Be concise in your responses",
"",
"Pi documentation:",
"- Main documentation: /path/to/readme",
].join("\n");
const result = injectGuidelineIntoPrompt(prompt, guideline);
const bullet = `- ${guideline}`;
assert.ok(result.includes(bullet), "guideline must be a bullet line");
const guidelinesStart = result.indexOf("\nGuidelines:\n");
const bulletIndex = result.indexOf(bullet);
const piDocsIndex = result.indexOf("\nPi documentation:");
assert.ok(guidelinesStart !== -1, "Guidelines header must exist");
assert.ok(bulletIndex > guidelinesStart, "bullet must be after Guidelines header");
assert.ok(bulletIndex < piDocsIndex, "bullet must be before Pi documentation section");
});
runTest("injectGuidelineIntoPrompt falls back to appending when no Guidelines section exists", () => {
const guideline = "Test guideline for RTK troubleshooting.";
const prompt = "You are a coding assistant with no guidelines section.";
const result = injectGuidelineIntoPrompt(prompt, guideline);
assert.ok(result.includes(guideline), "guideline must be present");
assert.ok(result.endsWith(guideline), "guideline must be appended at the end");
await beforeAgentStart(event, createNotificationContext([]));
assert.equal(event.systemPromptOptions.promptGuidelines.length, 1, "must not duplicate the note");
writeTestConfig(false);
});
await runTest("session_start refreshes RTK provenance and runtime guard skips missing rewrites", async () => {
+9 -65
View File
@@ -32,61 +32,6 @@ function trimMessage(raw: string, maxLength = 220): string {
const SOURCE_FILTER_TROUBLESHOOTING_NOTE =
"RTK note: If file edits repeatedly fail because old text does not match, ask the user to manually run '/rtk' in the Pi TUI, disable 'Read compaction enabled', re-read the file, apply the edit, then ask the user to manually re-enable it in the Pi TUI.";
/**
* Inject a guideline bullet into the Guidelines section of the system prompt.
*
* Locates the `Guidelines:` block and inserts the bullet after the last
* existing guideline, preserving the section structure. Falls back to
* appending at the end when the Guidelines section cannot be found.
*/
export function injectGuidelineIntoPrompt(systemPrompt: string, guideline: string): string {
if (!systemPrompt || systemPrompt.includes(guideline)) {
return systemPrompt;
}
const bullet = `- ${guideline}`;
// "Guidelines:" may appear at the very start of the prompt (index 0) or
// after a newline. Check both cases so the header is always detected.
let guidelinesHeaderIndex = systemPrompt.indexOf("\nGuidelines:\n");
let headerLength = "\nGuidelines:\n".length;
if (guidelinesHeaderIndex === -1 && systemPrompt.startsWith("Guidelines:\n")) {
guidelinesHeaderIndex = 0;
headerLength = "Guidelines:\n".length;
}
if (guidelinesHeaderIndex === -1) {
return `${systemPrompt}\n\n${guideline}`;
}
const linesStart = guidelinesHeaderIndex + headerLength;
const remainder = systemPrompt.slice(linesStart);
const lines = remainder.split("\n");
let consumedChars = 0;
for (const line of lines) {
if (line === "") {
break;
}
if (/^[-*+\s]/.test(line)) {
consumedChars += line.length + 1;
continue;
}
break;
}
const insertAt = consumedChars === 0 ? linesStart : linesStart + consumedChars - 1;
const before = systemPrompt.slice(0, insertAt);
const after = systemPrompt.slice(insertAt);
const needsNewlineBefore = before.length > 0 && !before.endsWith("\n");
const needsNewlineAfter = after.length > 0 && !after.startsWith("\n");
return [before, needsNewlineBefore ? "\n" : "", bullet, needsNewlineAfter ? "\n" : "", after].join("");
}
const loadOutputCompactorModule = createLazyModuleLoader<typeof import("./output-compactor.js")>("./output-compactor.js");
export function shouldInjectSourceFilterTroubleshootingNote(config: RtkIntegrationConfig): boolean {
@@ -436,19 +381,18 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
return {};
}
if (event.systemPrompt.includes(SOURCE_FILTER_TROUBLESHOOTING_NOTE)) {
// Contribute structurally: the bundle's single system-prompt owner
// (extensions/system-prompt.ts) composes the final prompt; extensions
// must not return `systemPrompt` themselves.
const promptOptions = event.systemPromptOptions;
if (!promptOptions) {
return {};
}
const updatedPrompt = injectGuidelineIntoPrompt(event.systemPrompt, SOURCE_FILTER_TROUBLESHOOTING_NOTE);
if (updatedPrompt === event.systemPrompt) {
return {};
const guidelines = (promptOptions.promptGuidelines ??= []);
if (!guidelines.includes(SOURCE_FILTER_TROUBLESHOOTING_NOTE)) {
guidelines.push(SOURCE_FILTER_TROUBLESHOOTING_NOTE);
}
return {
systemPrompt: updatedPrompt,
};
return {};
});
pi.on("tool_call", async (event, ctx) => {
+4 -1
View File
@@ -120,7 +120,10 @@ declare module "@earendil-works/pi-coding-agent" {
on(
eventName: "before_agent_start",
handler: (
event: { systemPrompt: string },
event: {
systemPrompt: string;
systemPromptOptions?: { promptGuidelines?: string[] } & Record<string, unknown>;
},
ctx: ExtensionContext,
) => MaybePromise<{ systemPrompt: string } | Record<string, unknown> | void>,
): void;