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
@@ -4,17 +4,30 @@ import type {
} from "@earendil-works/pi-coding-agent";
import type { PermissionResolver } from "#src/permission-resolver";
import type { PermissionSession } from "#src/permission-session";
import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
import {
resolveSkillPromptEntries,
resolveStructuredSkillEntries,
type StructuredSkillLike,
} from "#src/skill-prompt-sanitizer";
/** Minimal subset of BeforeAgentStartEvent used by this handler. */
interface BeforeAgentStartPayload {
systemPrompt: string;
systemPromptOptions?: {
skills?: StructuredSkillLike[] | unknown;
};
}
/**
* Prepares permission state before an agent turn without changing Pi's active
* tool set. Tool Search is the bundle's sole active-tool controller; this
* handler only refreshes permission state and sanitizes denied skill entries.
*
* Under the bundle's single system-prompt owner (extensions/system-prompt.ts)
* no handler may return `systemPrompt`: when the event carries the structured
* `systemPromptOptions.skills` array the denied entries are filtered out of it
* directly. The rendered-text rewrite remains only as a fallback for hosts
* that do not expose structured skills.
*/
export class AgentPrepHandler {
constructor(
@@ -36,6 +49,20 @@ export class AgentPrepHandler {
this.session.refreshConfig(ctx, ctx.isProjectTrusted());
const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
const options = event.systemPromptOptions;
if (options && Array.isArray(options.skills)) {
const structured = resolveStructuredSkillEntries(
options.skills,
this.resolver,
agentName,
this.session.getPathNormalizer(),
);
options.skills = structured.skills;
this.session.setActiveSkillEntries(structured.entries);
return {};
}
const skillPromptResult = resolveSkillPromptEntries(
event.systemPrompt,
this.resolver,
@@ -251,6 +251,82 @@ export function resolveSkillPromptEntries(
};
}
/**
* Minimal structural shape of Pi's `Skill` as carried on
* `systemPromptOptions.skills` (Pi 1.x). Fields not listed here are passed
* through untouched.
*/
export interface StructuredSkillLike {
name?: unknown;
description?: unknown;
filePath?: unknown;
baseDir?: unknown;
disableModelInvocation?: unknown;
}
/**
* Structured counterpart of `resolveSkillPromptEntries`: Pi 1.x exposes the
* loaded skills as objects on `systemPromptOptions.skills`, so denied skills
* are filtered out of that array instead of being cut from rendered text.
* Skills that Pi itself hides from the prompt (`disableModelInvocation`) stay
* in the array but are excluded from the active-entry list, matching the text
* path where they never appeared in `<available_skills>`.
*/
export function resolveStructuredSkillEntries(
skills: readonly StructuredSkillLike[],
permissionManager: SkillPermissionChecker,
agentName: string | null,
normalizer: PathNormalizer,
): { skills: StructuredSkillLike[]; entries: SkillPromptEntry[] } {
const permissionCache = new Map<string, PermissionState>();
const visibleSkills: StructuredSkillLike[] = [];
const entries: SkillPromptEntry[] = [];
for (const skill of skills) {
const name = typeof skill?.name === "string" ? skill.name : "";
if (!name) {
visibleSkills.push(skill);
continue;
}
const state = resolvePermissionState(
name,
permissionManager,
agentName,
permissionCache,
);
if (state === "deny") {
continue;
}
visibleSkills.push(skill);
if (skill.disableModelInvocation === true) {
continue;
}
const location =
typeof skill.filePath === "string" && skill.filePath
? skill.filePath
: typeof skill.baseDir === "string"
? skill.baseDir
: "";
const baseDir =
typeof skill.baseDir === "string" && skill.baseDir
? skill.baseDir
: location
? dirname(location)
: "";
entries.push({
name,
description:
typeof skill.description === "string" ? skill.description : "",
location,
state,
normalizedLocation: normalizer.comparableValue(location),
normalizedBaseDir: normalizer.comparableValue(baseDir),
});
}
return { skills: visibleSkills, entries };
}
export function findSkillPathMatch(
normalizedPath: string,
entries: readonly SkillPromptEntry[],