Files
my-pi/extensions/system-prompt-config.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

360 lines
11 KiB
TypeScript

import { readFileSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* Pure helpers for the bundle's single system-prompt owner
* (`extensions/system-prompt.ts`). No Pi runtime imports so `node --test` can
* exercise everything here directly.
*/
/** Entry in `config/prompt/tools.jsonc`. */
export type ToolCatalogEntry =
| { inherit: true }
| { snippet?: string | null; guidelines?: string[]; description?: string };
export interface ToolCatalog {
version: 1;
tools: Record<string, ToolCatalogEntry>;
}
export interface RoutingRule {
whenAll?: string[];
whenAny?: string[];
text: string;
}
export interface RoutingConfig {
version: 1;
rules: RoutingRule[];
}
export interface SectionsConfig {
version: 1;
order: string[];
}
/**
* The subset of Pi's (normalized) systemPromptOptions this module reads and
* mutates. Declared structurally so the installed host version does not leak
* into the config helper's types.
*/
export interface MutablePromptOptions {
selectedTools?: string[];
toolSnippets?: Record<string, string>;
toolGuidelines?: Record<string, string[]>;
promptGuidelines?: string[];
sections?: Record<string, string>;
}
const PROMPT_CONFIG_DIR = new URL("../config/prompt/", import.meta.url);
export function resolvePreamblePath(): string {
return fileURLToPath(new URL("preamble.md", PROMPT_CONFIG_DIR));
}
export function resolveToolsPath(): string {
return fileURLToPath(new URL("tools.jsonc", PROMPT_CONFIG_DIR));
}
export function resolveRoutingPath(): string {
return fileURLToPath(new URL("routing.jsonc", PROMPT_CONFIG_DIR));
}
export function resolveSectionsPath(): string {
return fileURLToPath(new URL("sections.jsonc", PROMPT_CONFIG_DIR));
}
/**
* Strip `//` and `/* ... *\/` comments while respecting string literals, so a
* `//` inside a quoted value (for example `"https://…"`) is never removed.
*/
export function stripJsoncComments(source: string): string {
let out = "";
let inString = false;
for (let i = 0; i < source.length; i++) {
const ch = source[i];
if (inString) {
out += ch;
if (ch === "\\") {
if (i + 1 < source.length) out += source[i + 1];
i += 1;
continue;
}
if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
out += ch;
continue;
}
if (ch === "/" && source[i + 1] === "/") {
while (i < source.length && source[i] !== "\n") i++;
continue;
}
if (ch === "/" && source[i + 1] === "*") {
i += 2;
while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) i++;
i += 2;
continue;
}
out += ch;
}
return out;
}
interface FileCacheEntry {
mtimeMs: number;
size: number;
value: unknown;
}
const fileCache = new Map<string, FileCacheEntry>();
/**
* Read, strip, parse and validate a JSONC config file, cached by mtime+size so
* a `config/prompt/` edit takes effect on the next turn without re-parsing
* unchanged files every turn.
*/
function loadJsonc<T>(path: string, validate: (raw: unknown) => T): T {
const stat = statSync(path);
const cached = fileCache.get(path);
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
return cached.value as T;
}
const raw = JSON.parse(stripJsoncComments(readFileSync(path, "utf8")));
const value = validate(raw);
fileCache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, value });
return value;
}
function fail(path: string, message: string): never {
throw new Error(`config/prompt/${path}: ${message}`);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
}
const TOOL_ENTRY_KEYS = new Set(["inherit", "snippet", "guidelines", "description"]);
function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
if (!isPlainObject(raw)) {
fail("tools.jsonc", `tool "${name}" must be an object`);
}
for (const key of Object.keys(raw)) {
if (!TOOL_ENTRY_KEYS.has(key)) {
fail("tools.jsonc", `tool "${name}" has unknown key "${key}"`);
}
}
if ("inherit" in raw) {
if (raw.inherit !== true || Object.keys(raw).length !== 1) {
fail("tools.jsonc", `tool "${name}": "inherit" must be the only key and equal true`);
}
return { inherit: true };
}
const hasField =
"snippet" in raw || "guidelines" in raw || "description" in raw;
if (!hasField) {
fail("tools.jsonc", `tool "${name}" needs "inherit": true or at least one of snippet/guidelines/description`);
}
if ("snippet" in raw && raw.snippet !== null && typeof raw.snippet !== "string") {
fail("tools.jsonc", `tool "${name}".snippet must be a string or null`);
}
if ("guidelines" in raw && !isStringArray(raw.guidelines)) {
fail("tools.jsonc", `tool "${name}".guidelines must be an array of strings`);
}
if ("description" in raw && typeof raw.description !== "string") {
fail("tools.jsonc", `tool "${name}".description must be a string`);
}
return raw as ToolCatalogEntry;
}
function validateToolCatalog(raw: unknown): ToolCatalog {
if (!isPlainObject(raw) || raw.version !== 1 || !isPlainObject(raw.tools)) {
fail("tools.jsonc", 'expected { "version": 1, "tools": { ... } }');
}
const tools: Record<string, ToolCatalogEntry> = {};
for (const [name, entry] of Object.entries(raw.tools)) {
tools[name] = validateToolEntry(name, entry);
}
return { version: 1, tools };
}
const ROUTING_RULE_KEYS = new Set(["whenAll", "whenAny", "text"]);
function validateRoutingRule(index: number, raw: unknown): RoutingRule {
if (!isPlainObject(raw)) {
fail("routing.jsonc", `rule #${index} must be an object`);
}
for (const key of Object.keys(raw)) {
if (!ROUTING_RULE_KEYS.has(key)) {
fail("routing.jsonc", `rule #${index} has unknown key "${key}"`);
}
}
if (typeof raw.text !== "string") {
fail("routing.jsonc", `rule #${index}.text must be a string`);
}
if ("whenAll" in raw && !isStringArray(raw.whenAll)) {
fail("routing.jsonc", `rule #${index}.whenAll must be an array of strings`);
}
if ("whenAny" in raw && !isStringArray(raw.whenAny)) {
fail("routing.jsonc", `rule #${index}.whenAny must be an array of strings`);
}
return raw as RoutingRule;
}
function validateRoutingConfig(raw: unknown): RoutingConfig {
if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.rules)) {
fail("routing.jsonc", 'expected { "version": 1, "rules": [ ... ] }');
}
return {
version: 1,
rules: raw.rules.map((rule, index) => validateRoutingRule(index, rule)),
};
}
const SECTION_NAME_PATTERN = /^[a-z][a-z0-9_-]*$/;
function validateSectionsConfig(raw: unknown): SectionsConfig {
if (!isPlainObject(raw) || raw.version !== 1 || !isStringArray(raw.order)) {
fail("sections.jsonc", 'expected { "version": 1, "order": [ ... ] }');
}
for (const name of raw.order) {
if (!SECTION_NAME_PATTERN.test(name) || name === "preamble") {
fail("sections.jsonc", `"${name}" is not a valid section name`);
}
}
return { version: 1, order: raw.order };
}
export function loadToolCatalog(): ToolCatalog {
return loadJsonc(resolveToolsPath(), validateToolCatalog);
}
export function loadRoutingRules(): RoutingConfig {
return loadJsonc(resolveRoutingPath(), validateRoutingConfig);
}
export function loadSectionsConfig(): SectionsConfig {
return loadJsonc(resolveSectionsPath(), validateSectionsConfig);
}
/**
* Read the bundle-managed prompt preamble.
*
* Returns `undefined` when the file is absent, unreadable, or blank so the
* caller leaves Pi's own default preamble untouched instead of forcing an
* empty one.
*/
export function readPreamble(): string | undefined {
let raw: string;
try {
raw = readFileSync(resolvePreamblePath(), "utf8");
} catch {
return undefined;
}
const trimmed = raw.replace(/\s+$/, "");
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* Apply the tool catalog to the mutable prompt options: a `snippet` string
* replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces
* the tool's `<rules>` lines; `{inherit:true}` entries are left untouched.
* `description` is not applied here — it is a registration-time concern handled
* by `extensions/prompt-guard.ts`.
*
* Returns the selected tool names that have no catalog entry at all.
*/
export function applyToolCatalog(options: MutablePromptOptions, catalog: ToolCatalog): string[] {
const snippets = (options.toolSnippets ??= {});
const guidelines = (options.toolGuidelines ??= {});
const missing: string[] = [];
for (const name of options.selectedTools ?? []) {
const entry = catalog.tools[name];
if (entry === undefined) {
missing.push(name);
continue;
}
if ("inherit" in entry) continue;
if (entry.snippet !== undefined) {
if (entry.snippet === null) {
delete snippets[name];
} else {
snippets[name] = entry.snippet;
}
}
if (entry.guidelines !== undefined) {
guidelines[name] = [...entry.guidelines];
}
}
return missing;
}
/**
* Render the matching routing rules as `- <text>` lines joined by newlines.
* A rule applies when every `whenAll` tool is active and at least one
* `whenAny` tool is active (a rule with neither always applies). An absent
* `selectedTools` means every tool counts as active, matching the previous
* tool-routing behavior.
*/
export function renderRouting(rules: RoutingRule[], selectedTools: string[] | undefined): string {
const isActive = (name: string) => selectedTools === undefined || selectedTools.includes(name);
const lines: string[] = [];
for (const rule of rules) {
if (rule.whenAll !== undefined && !rule.whenAll.every(isActive)) continue;
if (rule.whenAny !== undefined && !rule.whenAny.some(isActive)) continue;
lines.push(`- ${rule.text}`);
}
return lines.join("\n");
}
/**
* Rebuild the sections map in configured order; contributed sections not
* listed keep their original insertion order after the listed ones.
*/
export function orderSections(
sections: Record<string, string>,
order: readonly string[],
): Record<string, string> {
const ordered: Record<string, string> = {};
for (const name of order) {
if (Object.hasOwn(sections, name)) ordered[name] = sections[name];
}
for (const [name, value] of Object.entries(sections)) {
if (!Object.hasOwn(ordered, name)) ordered[name] = value;
}
return ordered;
}
/**
* Matches the newline-delimited boundary before the first top-level prompt
* section. Nested markup such as `<project_instructions path="...">` carries
* attributes and therefore never matches a bare tag line, so the first match
* is the end of the preamble.
*/
const FIRST_SECTION_BOUNDARY = /\n<[a-z][a-z0-9_-]*>\n/;
/**
* Replace the rendered prompt's preamble with `preamble`, keeping everything
* after the first top-level section byte-for-byte identical.
*
* Pi builds the preamble at the top of the rendered prompt, so swapping that
* prefix preserves section order: options-based replacement (`customPrompt`)
* would drop `tools`/`rules`/`docs` and rebuild them at the end. A rendered
* prompt without any top-level section degenerates to the preamble alone.
*/
export function spliceCover(rendered: string, preamble: string): string {
const trimmed = preamble.replace(/\s+$/, "");
const boundary = rendered.search(FIRST_SECTION_BOUNDARY);
if (boundary < 0) {
return trimmed;
}
return trimmed + rendered.slice(boundary);
}