mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
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:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
dc4fe92e27
commit
ba164896d2
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Pure filter applied after context-mode's `context` handler: its upstream
|
||||
* build appends one `{ role: "user", content }` message whose parts are
|
||||
* `"\n\n"`-joined and whose first part is a routing anchor that duplicates —
|
||||
* and contradicts — the bundle-owned `config/prompt/routing.jsonc`. The anchor
|
||||
* part is stripped; remaining parts (active memory, resume snapshot) stay.
|
||||
* No other message is touched; when nothing changes the input array is
|
||||
* returned unchanged (same reference).
|
||||
*/
|
||||
|
||||
export const CONTEXT_MODE_ANCHOR_PREFIX = "context-mode active. Hierarchy:";
|
||||
|
||||
type MessageLike = { role?: unknown; content?: unknown } & Record<string, unknown>;
|
||||
|
||||
export function filterContextModeMessages<T extends MessageLike>(
|
||||
before: readonly T[],
|
||||
after: readonly T[],
|
||||
): T[] {
|
||||
if (after.length <= before.length) return after as T[];
|
||||
let result: T[] | null = null;
|
||||
for (let i = after.length - 1; i >= before.length; i--) {
|
||||
const message = after[i];
|
||||
if (message === null || typeof message !== "object") continue;
|
||||
if (message.role !== "user" || typeof message.content !== "string") continue;
|
||||
const parts = message.content.split("\n\n");
|
||||
const kept = parts.filter((part) => !part.startsWith(CONTEXT_MODE_ANCHOR_PREFIX));
|
||||
if (kept.length === parts.length) continue;
|
||||
result ??= after.slice();
|
||||
if (kept.length === 0) {
|
||||
result.splice(i, 1);
|
||||
} else {
|
||||
result[i] = { ...message, content: kept.join("\n\n") };
|
||||
}
|
||||
}
|
||||
return result ?? (after as T[]);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
// The adapter is a prebuilt .js bundle, not a TypeScript source.
|
||||
import piExtension from "../node_modules/context-mode/build/adapters/pi/extension.js";
|
||||
|
||||
import { filterContextModeMessages } from "./context-mode-filter.ts";
|
||||
import { guardExtensionApi } from "./prompt-guard.ts";
|
||||
|
||||
/**
|
||||
* Load context-mode through the prompt guard: the bundle keeps sole ownership
|
||||
* of the system prompt, so the upstream routing anchor appended to the
|
||||
* `context` message list is filtered out (bundle routing lives in
|
||||
* `config/prompt/routing.jsonc`), while upstream active-memory/resume parts
|
||||
* pass through untouched. The upstream code this filter depends on is pinned
|
||||
* by `tests/system-prompt.test.ts` against the exact `context-mode` version.
|
||||
*/
|
||||
export default function bundledContextModeExtension(pi: ExtensionAPI): void {
|
||||
piExtension(guardExtensionApi(pi, { contextFilter: filterContextModeMessages }));
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import fffExtension from "../node_modules/@ff-labs/pi-fff/src/index.ts";
|
||||
import { guardExtensionApi } from "./prompt-guard.ts";
|
||||
|
||||
/** Load FFF as the bundle's canonical `find` and `grep` implementation. */
|
||||
export default function fffOverrideExtension(pi: ExtensionAPI): void {
|
||||
process.env.PI_FFF_MODE = "override";
|
||||
fffExtension(pi);
|
||||
fffExtension(guardExtensionApi(pi));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { deployBundleHashlineConfig } from "./hashline-config.ts";
|
||||
import { guardExtensionApi } from "./prompt-guard.ts";
|
||||
|
||||
/** Deploy bundle defaults before pi-hashline-edit reads its module-level config. */
|
||||
/**
|
||||
* Deploy bundle defaults before pi-hashline-edit reads its module-level config.
|
||||
* The guard applies the catalog's `edit` description override and keeps the
|
||||
* third-party extension from writing the system prompt directly.
|
||||
*/
|
||||
export default async function bundledHashlineExtension(pi: ExtensionAPI): Promise<void> {
|
||||
deployBundleHashlineConfig(getAgentDir());
|
||||
const { default: hashlineExtension } = await import("../node_modules/pi-hashline-edit/index.ts");
|
||||
hashlineExtension(pi);
|
||||
hashlineExtension(guardExtensionApi(pi));
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ import lspExtension from "../node_modules/pi-lsp/extensions/pi-lsp/index.ts";
|
||||
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { deployBundleLspConfig } from "./lsp-config.ts";
|
||||
import { guardExtensionApi } from "./prompt-guard.ts";
|
||||
|
||||
const TYPESCRIPT_LANGUAGE_SERVER_CLI = fileURLToPath(
|
||||
new URL("../node_modules/typescript-language-server/lib/cli.mjs", import.meta.url),
|
||||
@@ -13,5 +14,5 @@ export default function bundledLspExtension(pi: ExtensionAPI): void {
|
||||
nodeBinary: process.execPath,
|
||||
serverCliPath: TYPESCRIPT_LANGUAGE_SERVER_CLI,
|
||||
});
|
||||
lspExtension(pi);
|
||||
lspExtension(guardExtensionApi(pi));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import { loadToolCatalog, type ToolCatalog } from "./system-prompt-config.ts";
|
||||
|
||||
type AnyMessage = { role?: string; content?: unknown } & Record<string, unknown>;
|
||||
|
||||
type AnyHandler = (event: never, ctx: never) => unknown;
|
||||
|
||||
export interface PromptGuardOptions {
|
||||
/**
|
||||
* Post-process the messages a wrapped `context` handler leaves behind.
|
||||
* Receives the message list snapshot from before the handler ran and the
|
||||
* list after; returns the list the handler's result should report.
|
||||
*/
|
||||
contextFilter?: (before: AnyMessage[], after: AnyMessage[]) => AnyMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy an `ExtensionAPI` for third-party extensions so they cannot bypass the
|
||||
* bundle's single system-prompt owner (`extensions/system-prompt.ts`):
|
||||
*
|
||||
* - `registerTool` applies the tool's `description` override from
|
||||
* `config/prompt/tools.jsonc` when the catalog carries one (lazily loaded;
|
||||
* a catalog error keeps the upstream description).
|
||||
* - `before_agent_start` handlers keep every returned field except
|
||||
* `systemPrompt`, which is dropped.
|
||||
* - `context` handlers are post-processed through `opts.contextFilter`.
|
||||
* - Everything else passes through unchanged.
|
||||
*/
|
||||
export function guardExtensionApi(pi: ExtensionAPI, opts: PromptGuardOptions = {}): ExtensionAPI {
|
||||
let catalogCache: ToolCatalog | null | undefined;
|
||||
const catalog = (): ToolCatalog | null => {
|
||||
if (catalogCache === undefined) {
|
||||
try {
|
||||
catalogCache = loadToolCatalog();
|
||||
} catch {
|
||||
catalogCache = null;
|
||||
}
|
||||
}
|
||||
return catalogCache;
|
||||
};
|
||||
|
||||
const registerTool: ExtensionAPI["registerTool"] = (tool) => {
|
||||
const entry = catalog()?.tools[tool.name];
|
||||
if (entry !== undefined && !("inherit" in entry) && typeof entry.description === "string") {
|
||||
pi.registerTool({ ...tool, description: entry.description });
|
||||
} else {
|
||||
pi.registerTool(tool);
|
||||
}
|
||||
};
|
||||
|
||||
const on: ExtensionAPI["on"] = ((event: string, handler: AnyHandler): unknown => {
|
||||
let wrapped = handler;
|
||||
if (event === "before_agent_start") {
|
||||
wrapped = (async (eventPayload: unknown, ctx: unknown) => {
|
||||
const result = await handler(eventPayload as never, ctx as never);
|
||||
if (result !== null && typeof result === "object" && "systemPrompt" in result) {
|
||||
const rest = { ...(result as Record<string, unknown>) };
|
||||
delete rest.systemPrompt;
|
||||
return rest;
|
||||
}
|
||||
return result;
|
||||
}) as AnyHandler;
|
||||
} else if (event === "context" && opts.contextFilter) {
|
||||
const filter = opts.contextFilter;
|
||||
wrapped = (async (eventPayload: { messages?: AnyMessage[] }, ctx: unknown) => {
|
||||
const messages = Array.isArray(eventPayload.messages) ? eventPayload.messages : [];
|
||||
const before = messages.slice();
|
||||
const result = await handler(eventPayload as never, ctx as never);
|
||||
const resultMessages =
|
||||
result !== null &&
|
||||
typeof result === "object" &&
|
||||
Array.isArray((result as { messages?: unknown }).messages)
|
||||
? ((result as { messages: AnyMessage[] }).messages)
|
||||
: messages;
|
||||
const filtered = filter(before, resultMessages);
|
||||
if (filtered === resultMessages) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...(result !== null && typeof result === "object" ? (result as Record<string, unknown>) : {}),
|
||||
messages: filtered,
|
||||
};
|
||||
}) as AnyHandler;
|
||||
}
|
||||
// `on` is overloaded per event name; drop to a loosely typed call and
|
||||
// forward whatever the real registration returns (e.g. a disposer).
|
||||
return (pi.on as (name: string, h: AnyHandler) => unknown)(event, wrapped);
|
||||
}) as ExtensionAPI["on"];
|
||||
|
||||
return new Proxy(pi, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "registerTool") return registerTool;
|
||||
if (prop === "on") return on;
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** File name of the bundle-managed system prompt cover inside the bundle's `config/` directory. */
|
||||
export const COVER_FILE_NAME = "system-prompt-cover.md";
|
||||
|
||||
/**
|
||||
* Matches the newline-delimited boundary before the first top-level prompt section.
|
||||
*
|
||||
* Pi renders the system prompt as `<preamble>` followed by sections wrapped in tags of
|
||||
* the same name (for example `<tools>`). 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/;
|
||||
|
||||
/**
|
||||
* Resolve the cover path inside the installed bundle's `config/` directory —
|
||||
* the single source of truth, read directly at runtime with no deployment step.
|
||||
*/
|
||||
export function resolveCoverPath(): string {
|
||||
return fileURLToPath(new URL(`../config/${COVER_FILE_NAME}`, import.meta.url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the bundle-managed cover file.
|
||||
*
|
||||
* Returns `undefined` when the file is absent, unreadable, or blank so the caller can
|
||||
* leave Pi's own default prompt untouched instead of forcing an empty preamble.
|
||||
*/
|
||||
export function readCoverFile(coverPath: string): string | undefined {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(coverPath, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.replace(/\s+$/, "");
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the rendered prompt's preamble with `cover`, 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 is
|
||||
* what preserves section order: options-based replacement (`customPrompt`) would drop
|
||||
* `tools`/`rules`/`docs` and rebuild them at the end, reordering the prompt. Splicing the
|
||||
* rendered text keeps the existing order untouched.
|
||||
*
|
||||
* A rendered prompt without any top-level section degenerates to the cover alone.
|
||||
*/
|
||||
export function spliceCover(rendered: string, cover: string): string {
|
||||
const trimmed = cover.replace(/\s+$/, "");
|
||||
const boundary = rendered.search(FIRST_SECTION_BOUNDARY);
|
||||
if (boundary < 0) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed + rendered.slice(boundary);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import { readCoverFile, resolveCoverPath, spliceCover } from "./system-prompt-cover-config.ts";
|
||||
|
||||
export { COVER_FILE_NAME, readCoverFile, resolveCoverPath, spliceCover } from "./system-prompt-cover-config.ts";
|
||||
|
||||
/**
|
||||
* Replaces Pi's default system prompt preamble with the bundle-managed cover while
|
||||
* preserving every other prompt section in its original position.
|
||||
*
|
||||
* The cover is read on each `before_agent_start`, so edits to `system-prompt-cover.md` take
|
||||
* effect on the next turn of an already-running session rather than requiring a restart.
|
||||
*
|
||||
* Returning `systemPrompt` sets Pi's `forceSystemPrompt`, which is projected onto the
|
||||
* request at send time. That is deliberate: it is the only path that keeps the rendered
|
||||
* section order intact. The options-based alternative (`customPrompt`) would suppress
|
||||
* `tools`, `rules`, and `docs`, and any later re-injection cannot restore their original
|
||||
* positions. Pi still diffs and persists the structured sections in the transcript.
|
||||
*/
|
||||
export default function systemPromptCoverExtension(pi: ExtensionAPI): void {
|
||||
pi.on("before_agent_start", (event) => {
|
||||
const cover = readCoverFile(resolveCoverPath());
|
||||
if (cover === undefined) {
|
||||
return;
|
||||
}
|
||||
return { systemPrompt: spliceCover(event.systemPrompt, cover) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { BuildSystemPromptOptions, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const ROUTING_MARKER = "<!-- my-pi-tool-routing -->";
|
||||
const DUMP_RELATIVE_PATH = ".pi-debug/effective-system-prompt.md";
|
||||
|
||||
type SelectedTools = BuildSystemPromptOptions["selectedTools"];
|
||||
|
||||
function hasTool(selectedTools: SelectedTools, name: string): boolean {
|
||||
return selectedTools === undefined || selectedTools.includes(name);
|
||||
}
|
||||
|
||||
function hasAnyTool(selectedTools: SelectedTools, names: string[]): boolean {
|
||||
return names.some((name) => hasTool(selectedTools, name));
|
||||
}
|
||||
|
||||
export function buildToolRoutingSection(selectedTools: SelectedTools): string {
|
||||
const rules: string[] = [];
|
||||
|
||||
if (hasTool(selectedTools, "codegraph_explore")) {
|
||||
rules.push(
|
||||
"- Use codegraph_explore first for architecture, symbol relationships, code flows, and symbols you are about to change. Treat source returned by it as already read; do not repeat the same exploration with read or search unless information is missing.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
hasTool(selectedTools, "lsp_definition") ||
|
||||
hasTool(selectedTools, "lsp_references") ||
|
||||
hasTool(selectedTools, "lsp_diagnostics")
|
||||
) {
|
||||
rules.push(
|
||||
"- Use LSP for symbol definitions, references, hover/type information, and post-edit diagnostics when the language server supports the file.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "find") || hasTool(selectedTools, "grep") || hasTool(selectedTools, "multi_grep")) {
|
||||
rules.push(
|
||||
"- For literal search, narrow in stages instead of requesting a repository-wide dump:",
|
||||
" 1. Use find to identify likely files or directories. Constrain path/glob and keep the result limit small.",
|
||||
" 2. Use grep or multi_grep only inside the narrowed path or file to obtain matching line numbers. Start with a specific or literal pattern, a small context window, and a bounded limit.",
|
||||
" 3. Use read with offset/limit only for the exact matching region when full source is still needed.",
|
||||
"- Do not run a broad search expected to return hundreds or thousands of files or matches. If results truncate or hit a limit, narrow the path, glob, or pattern instead of increasing the limit or dumping all results.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "ssh_connect")) {
|
||||
rules.push(
|
||||
"- Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "ssh_cd")) {
|
||||
rules.push(
|
||||
"- Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "ssh_find") || hasTool(selectedTools, "ssh_grep")) {
|
||||
rules.push(
|
||||
"- For remote SSH searches, use ssh_find to narrow remote file paths before ssh_grep searches file contents. Keep path and limit bounded; when a result says truncated, narrow the path or pattern instead of increasing the limit. Do not run find, fd, grep, or rg through ssh_bash.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasAnyTool(selectedTools, ["web_search", "web_fetch"])) {
|
||||
rules.push(
|
||||
"- Use web_search for current, real-world, or citable web information; provider selection is handled internally. Keep queries focused and use site/published_* filters only when they materially narrow results. Then read selected pages with web_fetch instead of repeating similar searches.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "ctx_execute") || hasTool(selectedTools, "ctx_execute_file")) {
|
||||
rules.push(
|
||||
"- Use Context Mode for logs, test/build output, generated data, large-file exploration/analysis/summarization, and any command whose output may be large or unpredictable. Prefer ctx_execute_file over read when deriving an answer from a large file inside the workspace, and print only the derived answer needed for the task.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "ctx_execute_file")) {
|
||||
rules.push(
|
||||
"- ctx_execute_file is confined to the current project root. Do not call it for absolute paths outside the workspace, ../ traversal, or symlinks that resolve outside the workspace; host permission approval does not bypass this Context Mode boundary. Use another explicitly authorized tool for those files.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "chrome_snapshot") && hasTool(selectedTools, "ctx_execute_file")) {
|
||||
rules.push(
|
||||
"- After chrome_snapshot or a Chrome action with includeSnapshot=true, immediately analyze the returned workspace-relative capture path with ctx_execute_file; never use read or cat for the raw JSON. Print only the uids, text, or state needed for the next action, and take a fresh snapshot after any interaction because the previous capture may be stale.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "read")) {
|
||||
rules.push(
|
||||
"- Use read only when exact source is needed or the file is small. Before editing, request the smallest useful offset/limit range; do not read a complete large file merely to copy, compare, hash, count, explore, or summarize it.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "read") && hasTool(selectedTools, "edit")) {
|
||||
rules.push(
|
||||
"- For existing-file changes, obtain fresh LINE#HASH anchors with a small read, then use anchored edit operations. Reuse fresh anchors returned by successful edits for chained changes; re-read only when anchors are stale or the next target region was not shown.",
|
||||
);
|
||||
}
|
||||
|
||||
rules.push(
|
||||
"- Stop searching once the current evidence is sufficient. Avoid repeating the same lookup through CodeGraph, find, grep, and read without a concrete information gap.",
|
||||
);
|
||||
|
||||
return `${ROUTING_MARKER}\n## Tool Routing\n\n${rules.join("\n")}`;
|
||||
}
|
||||
|
||||
export function appendToolRouting(systemPrompt: string, selectedTools: SelectedTools): string {
|
||||
if (systemPrompt.includes(ROUTING_MARKER)) {
|
||||
return systemPrompt;
|
||||
}
|
||||
return `${systemPrompt}\n\n${buildToolRoutingSection(selectedTools)}`;
|
||||
}
|
||||
|
||||
export function getSystemPromptDumpPath(cwd: string): string {
|
||||
return resolve(cwd, DUMP_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
export default function toolRoutingExtension(pi: ExtensionAPI): void {
|
||||
let lastEffectivePrompt: string | undefined;
|
||||
|
||||
pi.on("before_agent_start", (event) => {
|
||||
lastEffectivePrompt = appendToolRouting(event.systemPrompt, event.systemPromptOptions.selectedTools);
|
||||
return { systemPrompt: lastEffectivePrompt };
|
||||
});
|
||||
|
||||
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 selectedTools = ctx.getSystemPromptOptions().selectedTools;
|
||||
const effectivePrompt = lastEffectivePrompt ?? appendToolRouting(ctx.getSystemPrompt(), selectedTools);
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, effectivePrompt, "utf8");
|
||||
ctx.ui.notify(`System prompt written to ${outputPath} (${effectivePrompt.length} chars)`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user