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
+21 -15
View File
@@ -25,12 +25,12 @@
- `configure.sh search` 与 `scripts/search-auth.mjs`:以交互或参数方式把 `tavily` / `exa` / `keenable` 三条 `api_key` 凭据写入 Pi 的 `auth.json`(`$PI_CODING_AGENT_DIR/auth.json`,默认 `~/.pi/agent/auth.json`);加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 导入。`pi-web-search/auth.ts` 每次调用读取并复刻上游解析语义(`!command` 执行、`$VAR`/`${VAR}` 插值、credential `env` 优先于进程 env、`$$`/`$!` 转义);显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。
- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp -p <root>` 子进程,经 `extensions/codegraph-client.ts` 的组合包自有 stdio 逐行 JSON-RPC 客户端直连(不依赖 pi-mcp-adapter 或 MCP SDK);serve 进程内置文件监听自动同步索引,无结果缓存。子进程 10 分钟无调用自动关闭,调用中子进程死亡则换新客户端透明重试一次;未初始化项目先经 `ctx.ui.confirm` 确认再 `codegraph init` 并自动重试,拒绝记入会话级 declined 不再重复询问;所有失败(未授权、init 失败、spawn ENOENT、调用失败)都以文本打回让模型改用 `find`/`grep`/`read`/LSP,绝不抛出。`codegraph serve` 可能启动的上游 daemon(`.codegraph/daemon.sock`)在子进程退出后可多驻留 ≤5 分钟,属上游行为,组合包不管理。
- `extensions/plugin-init.ts`:已移除;原 `/plugin_init` 的 CodeGraph 初始化职责由 `extensions/codegraph.ts` 在首次 `codegraph_explore` 调用内按需确认完成。
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。
- `extensions/context-mode.ts`:经 `extensions/prompt-guard.ts` 代理加载上游 `context-mode` Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性;`extensions/context-mode-filter.ts` 过滤其 context 注入中与本组合包路由重复且冲突的锚点段。
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,自身不部署、不播种、不迁移任何配置。扩展运行时直接读取已安装组合包内的 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc`;日志保留在 `<agentDir>/my-pi/permission-system/logs/`。旧 `extensions/pi-permission-system/` 位置的残留文件被忽略,不迁移也不清理。项目级 policy 不读取。
- `extensions/lsp.ts`:把组合包内 TypeScript Language Server CLI 物化为绝对命令路径,部署 TypeScript/Kotlin/JDT LS 全局配置并加载 `pi-lsp`。
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,在每轮开始前按激活工具追加简短路由规则,并提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。
- `extensions/system-prompt-cover.ts`:把 Pi 默认系统提示词的开头替换为组合包权威 `config/system-prompt-cover.md`(扩展运行时直接读取已安装组合包内文件,不部署到 agent 目录),并用文本拼接保留其余全部分区及其原始顺序。必须注册在 `tool-routing.ts` **之前**:`tool-routing` 会以 `forceSystemPrompt` 压平提示词,在其后改 `systemPromptOptions` 将不再影响本轮请求。封面文件缺失或空白时不干预。
- `extensions/prompt-guard.ts`:第三方扩展的代理 `ExtensionAPI`——丢弃 `before_agent_start` handler 返回的 `systemPrompt`(保留其他字段),在 `registerTool` 时应用 `config/prompt/tools.jsonc` 的 `description` 覆盖,并可对 `context` handler 的结果套用过滤器;`extensions/hashline.ts`、`extensions/fff-override.ts`、`extensions/lsp.ts` 与 `extensions/context-mode.ts` 均通过它加载上游扩展。
- `extensions/system-prompt.ts`:组合包唯一的系统提示词出口,必须排在 `pi.extensions` 最后。每轮读取 `config/prompt/`(`preamble.md` 开场白、`tools.jsonc` 工具简介与规则、`routing.jsonc` 渲染 `<tool_routing>` 分区、`sections.jsonc` 分区顺序),在现代 Pi 上通过修改 `systemPromptOptions` 保留分区结构,旧宿主自动降级为文本拼接,最终返回 `systemPrompt`;检测到 `forceSystemPrompt` 已被设置或存在未登记工具时各提示一次,配置读取失败时警告一次并保持 Pi 默认提示词。提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。
- `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。
- 上游来源:<https://github.com/tianrendong/pi-chrome>
- 初始导入快照:`017ff4b9a639f0b8b213e58a3f30613fc38edcc8`(`0.15.46`,该 commit 无对应 tag)。
@@ -79,7 +79,7 @@
- Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文;FFF 仍负责精确字面搜索,RTK 仍处理未走 Context Mode 的普通输出。
- `chrome_snapshot`、`chrome_find` 以及带 `includeSnapshot=true` 的交互工具必须把完整结构化快照写入当前项目 `.pi/chrome-context/` 下的 Session 临时文件,工具结果只返回相对路径与有界摘要;模型必须立即用 `ctx_execute_file` 提取下一步所需信息,不得用 `read` / `cat` 把原始 JSON 重新塞入上下文。每个 Session/目标只保留最新快照,页面交互后旧快照视为失效,Session shutdown、撤销或授权过期时清理。
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索由 `pi-web-search` 内部路由,模型不感知厂商:路由规则为广泛发现、新闻和候选来源偏 Tavily,官方/技术/论文和高确定性来源偏 Exa,中文、站点限定和日期筛选偏 Keenable;路由决策由当前主模型做出,确定性逻辑只处理可用性与失败兜底,不得改写成启发式断路。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
- 工具路由规则由 `config/prompt/routing.jsonc` 管理、`extensions/system-prompt.ts` 渲染为 `<tool_routing>` 分区,不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索由 `pi-web-search` 内部路由,模型不感知厂商:路由规则为广泛发现、新闻和候选来源偏 Tavily,官方/技术/论文和高确定性来源偏 Exa,中文、站点限定和日期筛选偏 Keenable;路由决策由当前主模型做出,确定性逻辑只处理可用性与失败兜底,不得改写成启发式断路。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具支持交互式 TUI 与具备 Pi extension UI bridge 的 RPC 客户端、顺序执行和最多 8 题;RPC 必须使用标准 `select` / `input` 对话框逐题收集,JSON/print 等无 UI 模式保持拒绝。取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute`、六个记忆工具与 `context_tree_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。
@@ -120,6 +120,13 @@
- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件;单独的 `pi update` 只在新版本存在时整体覆盖已安装副本(含 `config/`),`update.sh` 每次成功升级后都会把受管 checkout 还原到已发布版本,两者都不逐字段迁移或合并本机对已安装副本的改动;需要长期保留的自定义必须改仓库 `config/` 源文件。不得安装缺失组件或改写受管块之外的用户配置;卸载不得删除用户记忆数据,其他共享机器级工具保持不变。
- `<agentDir>/my-pi/` 只存放可随时丢弃的运行时状态(日志、缓存),不存放配置;组合包配置一律以仓库 `config/` 为唯一来源并由扩展直接读取。用户数据(记忆、会话、`auth.json`、`models.json`、`settings.json`、`search.env` 等)不得放入该目录。外部 npm 扩展固定路径的 `hashline.json`、`lsp.json` 位于 agent 根目录,是唯一例外(扩展加载时从 `config/` 覆盖写入);旧位置(`extensions/pi-permission-system/`、`settings.json` 的 `toolSearch` 段、agent 根 `SYSTEM_COVER.md`)的残留不迁移也不清理。
## 系统提示词单一所有权
- `extensions/system-prompt.ts` 是组合包唯一的系统提示词出口,必须排在 `pi.extensions` 最后,也是唯一允许在 `before_agent_start` 返回 `systemPrompt` 的扩展。提示词内容统一由 `config/prompt/` 管理:`preamble.md`(开场白)、`tools.jsonc`(每个工具的简介、规则及第三方工具说明覆盖)、`routing.jsonc`(跨工具工作流规则,渲染为 `<tool_routing>`)、`sections.jsonc`(分区顺序)。
- 其他组合包扩展不得返回 `systemPrompt`,也不得在 `context` 钩子中注入提示性消息;需要动态提示时只能写入 `event.systemPromptOptions.sections.<name>` 并在 `sections.jsonc` 登记顺序,静态规则写入 `promptGuidelines` 或该工具在 `tools.jsonc` 中的条目。
- 第三方扩展必须经 `extensions/` 下的组合包包装文件加载,由 `extensions/prompt-guard.ts` 的代理 API 拦截其对系统提示词的直接改写,并在注册工具时应用 `tools.jsonc` 的 `description` 覆盖;上游硬编码且无配置开关的注入只能在对应包装文件内处理,并以测试锁定所依赖的上游原文(依赖保持精确版本)。
- 新增工具必须在 `tools.jsonc` 登记(覆盖说明,或以 `"inherit": true` 表示已按本组合包审阅上游原文),否则 `tests/system-prompt.test.ts` 失败;`config/prompt/` 中的文字不得引用被策略禁止或被替换的用法。运行时若检测到其他扩展已定死提示词或存在未登记工具,`system-prompt.ts` 会各提示一次。
## 组合包配置原则
- 本组合包是私有组合包,所有机器上的组合包配置必须一致。组合包自有扩展的全部配置只以仓库 `config/` 为唯一来源,扩展运行时直接读取已安装组合包内的 `config/` 文件,不复制、不播种、不迁移到 agent 目录。
@@ -132,7 +139,7 @@
- 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。
- 保留原项目的 `LICENSE`、版权信息和必要的来源说明。
- agent 目录下扩展运行时产生的数据——`<agentDir>/my-pi/` 可丢弃运行时目录、旧 `extensions/pi-permission-system/` 残留、日志、构建产物、覆盖率目录和依赖目录——属于本地状态,不应提交;`config/` 下的全部文件(`pi-permission-policy.jsonc`、`pi-permission-hard-guards.jsonc`、`tool-search.json`、`rtk-optimizer.json`、`pi-compaction.json`、`pi-worktree.json`、`pi-hermes-memory.json`、`system-prompt-cover.md`、`pi-hashline-edit.json`、`lsp.json`、`p10k.zsh` 等)是组合包权威源配置,必须提交并维护。
- agent 目录下扩展运行时产生的数据——`<agentDir>/my-pi/` 可丢弃运行时目录、旧 `extensions/pi-permission-system/` 残留、日志、构建产物、覆盖率目录和依赖目录——属于本地状态,不应提交;`config/` 下的全部文件(`pi-permission-policy.jsonc`、`pi-permission-hard-guards.jsonc`、`tool-search.json`、`rtk-optimizer.json`、`pi-compaction.json`、`pi-worktree.json`、`pi-hermes-memory.json`、`prompt/preamble.md`、`prompt/tools.jsonc`、`prompt/routing.jsonc`、`prompt/sections.jsonc`、`pi-hashline-edit.json`、`lsp.json`、`p10k.zsh` 等)是组合包权威源配置,必须提交并维护。
- 外部 Pi 扩展依赖必须在根 `package.json` 中使用精确版本,自维护扩展必须使用本地 `file:` 依赖,并更新根 `package-lock.json`;不要用仓库级 `.pi/settings.json` 代替组合包依赖。
- 需要原生构建的依赖只按锁定版本加入根 `allowScripts`;当前 `better-sqlite3@12.11.1` 仍由 Context Mode 使用并保持精确放行。`pi-hermes-memory` 的 `better-sqlite3@13` 运行时使用包内 N-API 预编译二进制,npm 因 `binding.gyp` 推断出的 `node-gyp rebuild` 无需执行,不得为此新增放行条目。
- Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。
@@ -249,18 +256,17 @@
- SSH 工具的权限预览必须包含连接请求或当前远端的 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。
- `pi-ssh` 只用于从本地项目显式操作服务器,不得自动探测、读取或向系统提示词注入远端 `AGENTS.md`、`CLAUDE.md` 或其他项目说明;远端内容只能由明确且已授权的 `ssh_*` 工具调用获取。
- 修改 SSH transport、vault、导入、权限集成或加载入口后至少运行 `pi-ssh` 包内 `npm test`、根脚本语法与 mock 配置测试、`pi-permission-system` 的 `npm run typecheck` / `npm run test` / `npm run build`、根扩展联合加载和实际 packed tarball 隔离安装验证。未经明确要求不得连接真实服务器或把真实密码写入测试。
- 根加载顺序必须保持 `pi-permission-system` → `pi-ssh` → `pi-chrome` → `system-prompt-cover` → `tool-routing` → `pi-tool-search`,确保内置 authorizer 与权限服务由同一扩展先注册/发布、SSH 桥接随后安装、Chrome 工具可在授权后进入目录、系统提示词封面在 `tool-routing` 压平提示词之前改写开头,且 Tool Search 最后收集完整工具目录。
- 根加载顺序必须保持 `pi-permission-system` → `pi-ssh` → `pi-chrome` → `pi-tool-search` → `system-prompt`(`extensions/system-prompt.ts` 为最后一个条目),确保内置 authorizer 与权限服务由同一扩展先注册/发布、SSH 桥接随后安装、Chrome 工具可在授权后进入目录、Tool Search 在提示词组装前建立完整工具目录,且单一提示词出口最后汇总所有扩展的结构化贡献。
## `extensions/system-prompt-cover.ts` 开发约定
## `extensions/system-prompt.ts` 开发约定
- 扩展入口是 `extensions/system-prompt-cover.ts`;纯 helper(`spliceCover` / `readCoverFile` / `resolveCoverPath` / `COVER_FILE_NAME`)位于 `extensions/system-prompt-cover-config.ts` 以便 `node --test` 单测;组合包权威源是 `config/system-prompt-cover.md`,扩展运行时直接读取已安装组合包内文件,不经任何脚本部署。
- 只能使用 Pi 的 `forceSystemPrompt` 投影路径(`before_agent_start` 返回 `{ systemPrompt }`)配合文本拼接来保持分区顺序;不得改用 `customPrompt` 选项。后者会抑制 `tools` / `rules` / `docs` 三个分区,而回填只能追加到末尾,无法恢复原顺序。
- 不得在 handler 中写 `customPrompt` 或依赖其生效:`forceSystemPrompt` 优先级更高,二者同时存在时 `customPrompt` 会被静默忽略而不报错。
- 分割边界必须是独占一行的裸标签(带 backreference 同名闭合),不能匹配带属性的标签:`<project_instructions path="...">` 嵌套在 `<project_context>` 内,只有裸标签才是分区边界。
- 封面文件是已安装组合包内的 `config/system-prompt-cover.md`,不得改名为 `SYSTEM.md` 或移到 agent 根目录:后者会被 Pi 先当成 `customPrompt` 读入,封面扩展拿到的提示词会缺少可回填的分区。
- 文件缺失、不可读或为空白时必须静默 no-op,保持 Pi 默认封面;不得写入空 preamble,也不得因读取失败阻止其他扩展加载。
- 封面文件随组合包发布,`pi install` / `pi update` 后即就位,无需任何部署 helper;对已安装副本的临时修改由 `pi update` 覆盖。
- 修改 `spliceCover` 语义、注册顺序或封面路径后,必须运行 `node --test tests/system-prompt-cover.test.ts`、`node --test tests/bundle-config.test.ts`、`node --test tests/extension-load.test.ts`,并确认 `npm pack --dry-run` 包含 `config/system-prompt-cover.md`。
- 扩展入口是 `extensions/system-prompt.ts`,必须排在 `pi.extensions` 最后;纯 helper(JSONC 加载与校验、`applyToolCatalog` / `renderRouting` / `orderSections` / `spliceCover` / `readPreamble` 等)位于 `extensions/system-prompt-config.ts` 以便 `node --test` 单测;组合包权威源是 `config/prompt/`(`preamble.md` / `tools.jsonc` / `routing.jsonc` / `sections.jsonc`),扩展运行时直接读取已安装组合包内文件,不经任何脚本部署。
- 现代 Pi 下必须通过修改规范化 `systemPromptOptions`(`toolSnippets` / `toolGuidelines` / `sections`)保留分区结构,再读取重渲染的 `event.systemPrompt` 拼入封面并返回 `systemPrompt`;宿主不提供结构化 options 或 options 不参与重渲染时自动降级为文本拼接。不得改用 `customPrompt` 选项——它会抑制 `tools` / `rules` / `docs` 分区。
- 封面拼接边界必须是独占一行的裸标签,不能匹配带属性的标签:`<project_instructions path="...">` 嵌套在 `<project_context>` 内,只有裸标签才是分区边界。
- `preamble.md` 缺失、不可读或空白时保持 Pi 默认封面,不得写入空 preamble;`config/prompt/` 解析或校验失败时 `before_agent_start` 警告一次并整体 no-op,绝不抛出。
- 检测到其他扩展已设置 `forceSystemPrompt`、或激活工具未在 `tools.jsonc` 登记时各提示一次(warning);本扩展返回的 `systemPrompt` 把提示词冻结为最终文本是刻意设计——这也是它必须最后加载的原因。
- `config/prompt/` 随组合包发布,`pi install` / `pi update` 后即就位,无需任何部署 helper;对已安装副本的临时修改由 `pi update` 覆盖。
- 修改 loader、`spliceCover` 语义、注册顺序或 `config/prompt/` 路径后,必须运行 `node --test tests/system-prompt.test.ts`、`node --test tests/bundle-config.test.ts`、`node --test tests/extension-load.test.ts`,并确认 `npm pack --dry-run` 包含 `config/prompt/`。
## `pi-ask-user` 开发约定
+6 -10
View File
@@ -20,7 +20,7 @@
- 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。
- 本仓库保留维护的 `pi-subagents` 源码及权限集成实现,但当前已从根组合包依赖和默认扩展加载列表中移除,不会随核心包启用。
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
- `extensions/system-prompt.ts`:组合包唯一的系统提示词出口(排在最后加载),按 `config/prompt/` 组合开场白、工具简介与跨工具路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
## 安装、升级与卸载
@@ -137,7 +137,7 @@ CodeGraph 的更新或遥测设置。
### 工具与搜索路由
`extensions/tool-routing.ts` 不替换 Pi 默认系统提示词,而是在每轮开始前根据当前激活工具追加简短规则。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
`config/prompt/routing.jsonc` 定义跨工具路由规则,由 `extensions/system-prompt.ts` 在每轮开始前按当前激活工具渲染为 `<tool_routing>` 分区。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
在线搜索默认按任务只选一个服务:关键词不确定、新闻/近期话题或需要较多候选来源时优先 Tavily;官方/API 文档、版本信息、论文和高确定性事实确认优先 Exa;中文政策、政府公告、中文网页、站点限定和日期筛选优先 Keenable。只有来源质量或不确定性确实需要时才跨服务复核,避免默认把三套结果全部塞进上下文。
@@ -145,17 +145,13 @@ CodeGraph 的更新或遥测设置。
使用 `/dump-system-prompt` 可将当前扩展所见的有效提示词写入当前项目的 `.pi-debug/effective-system-prompt.md`。`.pi-debug/` 属于本地诊断输出,默认不提交。
### 系统提示词封面
### 系统提示词单一入口
`extensions/system-prompt-cover.ts` 把 Pi 默认系统提示词的**开头**替换为组合包权威的 `config/system-prompt-cover.md`(扩展直接读取已安装组合包内的该文件,不复制到 agent 目录),同时**保留其余全部分区及其原始顺序**:`<tools>`、`<rules>`、`<docs>`、`<skills>`、`<project_context>`、`<cwd>` 都不会被删除或重新排序。
`extensions/system-prompt.ts` 是组合包唯一的系统提示词出口,排在 `pi.extensions` 最后加载。提示词内容全部由 `config/prompt/` 管理:`preamble.md` 替换 Pi 默认开场白(其余 `<tools>`、`<rules>`、`<skills>`、`<project_context>`、`<cwd>` 等分区及顺序原样保留),`tools.jsonc` 覆盖各工具的简介与规则,`routing.jsonc` 渲染 `<tool_routing>` 分区,`sections.jsonc` 决定各扩展贡献分区的顺序。
实现上使用 Pi 的 `forceSystemPrompt` 投影路径(每轮把渲染结果作为强制定本发送)而不是 `customPrompt` 选项:后者会连带抑制 `tools`/`rules`/`docs` 三个分区,且回填只能追加到末尾,无法保持顺序。封面只在**请求**时应用,不修改 transcript 中已持久化的结构化分区。
其他扩展不直接改写提示词:pi-chrome、pi-ssh、pi-hermes-memory 等通过 `systemPromptOptions.sections.<name>` 贡献结构化分区,pi-rtk-optimizer 通过 `promptGuidelines` 补充规则;第三方扩展(FFF、Hashline、LSP、Context Mode)经 `extensions/` 下的包装文件与 `prompt-guard.ts` 代理加载,拦截直接的提示词改写并按 `tools.jsonc` 应用工具说明覆盖。
封面每轮重新读取,因此修改已安装组合包内的 `config/system-prompt-cover.md` 后在**下一轮**即生效,不需要重开会话;已经结束的历史对话不受影响。文件缺失或为空白时不干预,Pi 继续使用自身默认封面。
该文件由组合包直接维护并随组合包发布;对已安装副本的本地修改不做保护,`pi update` 会直接覆盖;要长期自定义请直接编辑仓库中的 `config/system-prompt-cover.md`。
`system-prompt-cover.md` 必须使用这个名字:不能改名为 `SYSTEM.md`,否则 Pi 会先把它当作 `customPrompt` 读入,封面扩展拿到的提示词就已经缺少可供回填的分区了。
`config/prompt/` 每轮重新读取(按 mtime 缓存),修改已安装组合包内的文件后下一轮即生效;配置解析失败时警告一次并保持 Pi 默认提示词,不阻止会话。该目录由组合包直接维护并随组合包发布;对已安装副本的本地修改不做保护,`pi update` 会直接覆盖;要长期自定义请直接编辑仓库中的 `config/prompt/`。
### 接管当前 Chrome
+72
View File
@@ -0,0 +1,72 @@
{
// Cross-tool workflow rules rendered by extensions/system-prompt.ts into the
// <tool_routing> section. A rule is included when every tool in "whenAll"
// and at least one tool in "whenAny" is active; a rule with neither is
// always included. Rules render in file order as "- <text>".
"version": 1,
"rules": [
{
"whenAny": ["codegraph_explore"],
"text": "Use codegraph_explore first for architecture, symbol relationships, code flows, and symbols you are about to change. Treat the source it returns as already read for understanding; do not repeat the same exploration with read or search unless information is missing. Before editing, still read the exact target range to get fresh LINE#HASH anchors."
},
{
"whenAny": ["lsp_definition", "lsp_references", "lsp_hover", "lsp_symbols", "lsp_diagnostics"],
"text": "Use LSP for symbol definitions, references, hover/type information, and post-edit diagnostics when the language server supports the file."
},
{
"whenAny": ["find", "grep", "multi_grep"],
"text": "For literal search, narrow in stages instead of requesting a repository-wide dump:\n 1. Use find to identify likely files or directories. Constrain path/glob and keep the result limit small.\n 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.\n 3. Use read with offset/limit only for the exact matching region when full source is still needed."
},
{
"whenAny": ["find", "grep", "multi_grep"],
"text": "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."
},
{
"whenAny": ["bash"],
"text": "Use bash for builds, tests, git, package scripts, and other programs. Never search files by running grep, find, rg, or fd through bash; use the find/grep tools."
},
{
"whenAny": ["ssh_connect"],
"text": "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."
},
{
"whenAny": ["ssh_cd"],
"text": "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."
},
{
"whenAny": ["ssh_find", "ssh_grep"],
"text": "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."
},
{
"whenAny": ["web_search", "web_fetch"],
"text": "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. Read selected pages with web_fetch instead of repeating similar searches."
},
{
"whenAll": ["web_fetch", "ctx_fetch_and_index"],
"text": "Read a web page with web_fetch. Use ctx_fetch_and_index only when the content should be indexed into the context-mode knowledge base for repeated ctx_search queries."
},
{
"whenAny": ["ctx_execute", "ctx_execute_file", "ctx_batch_execute"],
"text": "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 one ctx_batch_execute over several ctx_execute calls for multi-command research, and print only the derived answer needed for the task."
},
{
"whenAny": ["ctx_execute_file"],
"text": "ctx_execute_file analyzes a file and never edits it; make changes with read + edit. It 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 boundary. Use another explicitly authorized tool for those files."
},
{
"whenAll": ["chrome_snapshot", "ctx_execute_file"],
"text": "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."
},
{
"whenAny": ["read"],
"text": "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."
},
{
"whenAll": ["read", "edit"],
"text": "For existing-file changes, obtain fresh LINE#HASH anchors with a small read (find/grep output has no anchors), 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."
},
{
"text": "Stop searching once the current evidence is sufficient. Avoid repeating the same lookup through CodeGraph, find, grep, and read without a concrete information gap."
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
// Order of named system-prompt sections contributed through
// systemPromptOptions.sections. extensions/system-prompt.ts reorders the
// contributed sections to this list; contributed sections not listed here
// keep their contribution order after the listed ones.
"version": 1,
"order": ["memory_policy", "remote_ssh", "chrome_profile_bridge", "tool_routing"]
}
+103
View File
@@ -0,0 +1,103 @@
{
// Bundle-authoritative tool guidance, owned by extensions/system-prompt.ts.
//
// Every tool the bundle can expose must have an entry. Per field:
// "snippet" — replaces the tool's one-line <tools> entry (null hides it)
// "guidelines" — replaces the tool's <rules> lines
// "description" — replaces the tool description at registration; only
// applied to third-party tools loaded through a bundle
// adapter (in-repo tools edit their own source instead)
// An absent field keeps the upstream text. "inherit": true means the
// upstream text was reviewed against this bundle and is kept as-is.
"version": 1,
"tools": {
"bash": {
"snippet": "Run shell commands for builds, tests, git, package scripts, and other programs; search files with the find/grep tools, not bash"
},
"read": { "inherit": true },
"edit": {
"guidelines": [
"Use edit with LINE#HASH anchors from the latest read or edit result for all file changes; batch every change to one file into a single edit call.",
"find/grep/multi_grep results carry plain line numbers, not LINE#HASH anchors; read the exact target range before editing.",
"Never reuse anchors from an earlier read or edit response once a newer one exists for the same file; treat only the most recent response as carrying valid anchors.",
"After a successful edit, the returned --- Anchors --- block replaces a re-read for nearby follow-up edits.",
"On [E_STALE_ANCHOR], the error lists the stale refs and any content-matched candidate anchors; re-read the file to get current anchors before retrying."
],
"description": "Patch a text file at `LINE#HASH` anchors copied verbatim from the latest read result or the anchors block of a previous edit. find/grep results have no anchors — read the target range first.\n\nBatch every change to a file into one `edit` call: all operations go in the `edits` array, every edit sets `op`, and all anchors must come from the same pre-edit read. Edits validate against one snapshot and apply together, so line numbers never shift between entries of the same call.\n\nOps:\n- `replace` — replace the single line at `pos`, or the inclusive span `pos`..`end`. `lines` is the complete new content for the whole span; `lines: []` deletes it. Without `end`, exactly one line is replaced no matter how many entries `lines` has.\n- `append` — insert `lines` after `pos`; omit `pos` to append at end of file.\n- `prepend` — insert `lines` before `pos`; omit `pos` to insert at start of file.\n\nExample — single-line and span replace in one call:\n```json\n{ \"path\": \"src/main.ts\", \"edits\": [\n { \"op\": \"replace\", \"pos\": \"12#MQ\", \"lines\": [\"const x = 1;\"] },\n { \"op\": \"replace\", \"pos\": \"5#VR\", \"end\": \"8#QV\", \"lines\": [\n \"function greet(name) {\",\n \" return `Hello, ${name}`;\",\n \"}\"\n ] }\n] }\n```\n\nRules:\n- `lines` is literal file content with exact indentation. Never include `LINE#HASH:` or bare `HH:` prefixes, diff `+`/`-` markers, or a copy of a neighboring line — the `:content` part of an anchor is context for you, not payload, and repeating a boundary line duplicates it in the file.\n- Anchors are opaque: copy them exactly, never compute, shift, or guess one.\n- An anchor may keep the `:content` suffix from read output (`\"12#MQ: const x = 1;\"`). The runtime cross-checks that content against the file — catching hash collisions and recovering whitespace-only drift — so keep it for high-risk anchors such as range endpoints. Copy the content as rendered; if you shorten it, keep the start of the line and mark the cut with `...`.\n- Edits in one call must not overlap or touch adjacent lines — merge such changes into a single edit."
},
"write": { "inherit": true },
"ls": { "inherit": true },
"powershell": { "inherit": true },
"grep": { "inherit": true },
"find": { "inherit": true },
"multi_grep": { "inherit": true },
"tool_search": { "inherit": true },
"codegraph_explore": { "inherit": true },
"lsp_diagnostics": { "inherit": true },
"lsp_definition": { "inherit": true },
"lsp_references": { "inherit": true },
"lsp_hover": { "inherit": true },
"lsp_symbols": { "inherit": true },
"web_search": { "inherit": true },
"web_fetch": { "inherit": true },
"ctx_execute": { "inherit": true },
"ctx_execute_file": { "inherit": true },
"ctx_batch_execute": { "inherit": true },
"ctx_index": { "inherit": true },
"ctx_search": { "inherit": true },
"ctx_fetch_and_index": { "inherit": true },
"ctx_stats": { "inherit": true },
"ctx_doctor": { "inherit": true },
"ctx_insight": { "inherit": true },
"ctx_upgrade": { "inherit": true },
"ctx_purge": { "inherit": true },
"memory_search": { "inherit": true },
"session_search": { "inherit": true },
"memory_add": { "inherit": true },
"memory_replace": { "inherit": true },
"memory_remove": { "inherit": true },
"skill_manage": { "inherit": true },
"context_tree_query": { "inherit": true },
"ask_user_question": { "inherit": true },
"ssh_connect": { "inherit": true },
"ssh_cd": { "inherit": true },
"ssh_read": { "inherit": true },
"ssh_write": { "inherit": true },
"ssh_edit": { "inherit": true },
"ssh_find": { "inherit": true },
"ssh_grep": { "inherit": true },
"ssh_bash": { "inherit": true },
"Agent": { "inherit": true },
"get_subagent_result": { "inherit": true },
"steer_subagent": { "inherit": true },
"chrome_launch": { "inherit": true },
"chrome_tab": { "inherit": true },
"chrome_navigate": { "inherit": true },
"chrome_snapshot": { "inherit": true },
"chrome_find": { "inherit": true },
"chrome_inspect": { "inherit": true },
"chrome_wait_for": { "inherit": true },
"chrome_screenshot": { "inherit": true },
"chrome_click": { "inherit": true },
"chrome_type": { "inherit": true },
"chrome_fill": { "inherit": true },
"chrome_key": { "inherit": true },
"chrome_hover": { "inherit": true },
"chrome_drag": { "inherit": true },
"chrome_tap": { "inherit": true },
"chrome_scroll": { "inherit": true },
"chrome_evaluate": { "inherit": true },
"chrome_list_console_messages": { "inherit": true },
"chrome_list_network_requests": { "inherit": true },
"chrome_get_network_request": { "inherit": true },
"chrome_upload_file": { "inherit": true }
}
}
+36
View File
@@ -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[]);
}
+18
View File
@@ -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 }));
}
+2 -1
View File
@@ -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));
}
+7 -2
View File
@@ -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
View File
@@ -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));
}
+99
View File
@@ -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;
},
});
}
+359
View File
@@ -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);
}
-60
View File
@@ -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);
}
-28
View File
@@ -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) };
});
}
+162
View File
@@ -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");
},
});
}
-139
View File
@@ -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");
},
});
}
+3 -4
View File
@@ -52,7 +52,7 @@
"./extensions/codegraph.ts",
"./pi-rtk-optimizer/index.ts",
"./extensions/hashline.ts",
"./node_modules/context-mode/build/adapters/pi/extension.js",
"./extensions/context-mode.ts",
"./extensions/lsp.ts",
"./pi-hermes-memory/src/index.ts",
"./pi-compaction/index.ts",
@@ -64,9 +64,8 @@
"./pi-ask-user/index.ts",
"./pi-worktree/index.ts",
"./pi-chrome/extensions/chrome-profile-bridge/index.ts",
"./extensions/system-prompt-cover.ts",
"./extensions/tool-routing.ts",
"./pi-tool-search/extensions/index.ts"
"./pi-tool-search/extensions/index.ts",
"./extensions/system-prompt.ts"
],
"skills": [
"./node_modules/context-mode/skills"
+1
View File
@@ -19,5 +19,6 @@
- 每个 Session/目标只保留最新快照;浏览器交互会使陈旧捕获失效,Session 关闭时移除该 Session 目录。
- `chrome_find` 返回紧凑的排序匹配,同时把完整结构化快照保留在 Session 本地捕获中,供可选的 `ctx_execute_file` 分析使用。
- click/type/fill/key 上的 `includeSnapshot=true` 会保存动作后的快照并返回其捕获路径,而不是把快照嵌入工具结果。
- 授权后的 `before_agent_start` 不再返回 `systemPrompt`:Chrome 使用提示写入 `event.systemPromptOptions.sections.chrome_profile_bridge` 结构化分区,由组合包单一提示词出口 `extensions/system-prompt.ts` 统一组装,顺序由 `config/prompt/sections.jsonc` 控制;未授权时不贡献任何提示词内容。
同步上游时,请以导入 commit 为基准比较并选择性移植改动。不得整体覆盖这些本地生命周期、隐私和 Context Mode 集成。
@@ -923,11 +923,12 @@ export default function (pi: ExtensionAPI): void {
pi.on("before_agent_start", (event) => {
if (!chromeToolsRegistered || !chromeControlAuthorized()) {
return { systemPrompt: event.systemPrompt };
return;
}
const primer = `
<chrome-profile-bridge>
Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile.
// Contribute structurally: the bundle's single system-prompt owner
// (extensions/system-prompt.ts) composes the final prompt; extensions
// must not return `systemPrompt` themselves.
const primer = `Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile.
Tab/window isolation (important):
- pi-chrome owns a dedicated automation window/tab. When a chrome_* tool runs with no explicit target, it acts on that pi-chrome-owned target — it never reuses or overwrites the user's currently active tab. The dedicated target is created on first use and reused afterward.
@@ -948,9 +949,12 @@ Usage rules:
5. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
6. By default chrome_* tools run in the background without focusing Chrome; pass \`background=false\` or run /chrome background off when the user wants to watch Chrome work.
7. If you hit a native file-picker or privileged browser prompt gate, tell the user; generic clicks/typing/CSP gates are handled by Chrome input.
8. Run /chrome doctor when in doubt about connectivity or capabilities.
</chrome-profile-bridge>`;
return { systemPrompt: event.systemPrompt + primer };
8. Run /chrome doctor when in doubt about connectivity or capabilities.`;
const promptOptions = event.systemPromptOptions as unknown as
| { sections?: Record<string, string> }
| undefined;
if (!promptOptions) return; // hosts without structured options just skip the section
(promptOptions.sections ??= {}).chrome_profile_bridge = primer;
});
// Shared handlers, dispatched by the unified /chrome command below.
+1
View File
@@ -25,6 +25,7 @@
### 本地改动
- `devDependencies` 中的 `@earendil-works/pi-ai`、`@earendil-works/pi-coding-agent`、`@earendil-works/pi-tui`、`typebox`、`tsx`、`typescript`、`@types/better-sqlite3` 已从上游的 `^0.80.2` 等范围改为与根包一致的精确版本。上游的宽松范围与根包的 `0.84.2` 不兼容,会让 npm 在本目录嵌套安装第二套 Pi runtime(实测 229 MB),违反组合包"不得再安装一套 Pi runtime"的纪律;`pi-notify/package.json` 是同样的先例。运行时依赖(`better-sqlite3`、`strip-ansi`)与 `peerDependencies` 保持上游原样。
- `before_agent_start` 在结构化 `systemPromptOptions` 下把记忆上下文写入 `sections.memory_policy` 命名分区(而不是上游的 `appendSystemPrompt` 追加),分区顺序由组合包 `config/prompt/sections.jsonc` 统一控制;无结构化 options 的旧宿主分支保持原有的 `systemPrompt` 文本拼接行为。
### 工具与常驻
+5 -5
View File
@@ -306,10 +306,12 @@ export default function (pi: ExtensionAPI) {
if (!promptContext) return;
// pi 0.86+ hands handlers a normalized clone of these options and re-renders
// the prompt from it, so appending through `appendSystemPrompt` keeps the
// the prompt from it, so writing a named `memory_policy` section keeps the
// prompt a set of named sections; returning `systemPrompt` instead forces the
// rendered text and flattens the sections for every later handler and for
// section-aware providers (#251).
// section-aware providers (#251). The bundle's single system-prompt owner
// (extensions/system-prompt.ts) orders the section via
// config/prompt/sections.jsonc.
//
// Branch on that mechanism, not on the field: `systemPromptOptions` is on the
// event at the 0.80.6 floor too, but 0.80.x-0.85.x pass the raw long-lived
@@ -319,9 +321,7 @@ export default function (pi: ExtensionAPI) {
// 0.86+ runner normalizes `sections` onto the clone it passes.
const promptOptions = event.systemPromptOptions;
if (promptOptions && "sections" in promptOptions) {
promptOptions.appendSystemPrompt = [promptOptions.appendSystemPrompt, promptContext]
.filter(Boolean)
.join("\n\n");
(promptOptions.sections as Record<string, string>).memory_policy = promptContext;
return;
}
+21 -12
View File
@@ -2,9 +2,11 @@
* `before_agent_start` prompt injection (#251).
*
* pi 0.86+ passes a normalized *clone* of the prompt options and re-renders the
* prompt from it, so appending the memory context through `appendSystemPrompt`
* prompt from it, so writing the memory context into `sections.memory_policy`
* keeps the prompt a set of named sections for later handlers and section-aware
* providers; returning `systemPrompt` forces the rendered text instead.
* providers; returning `systemPrompt` forces the rendered text instead. The
* bundle's single system-prompt owner (extensions/system-prompt.ts) orders the
* section via `config/prompt/sections.jsonc`.
*
* 0.80.6-0.85.x put `systemPromptOptions` on the event too, but they pass the raw
* long-lived base options and only apply a returned `systemPrompt` — a mutation
@@ -123,31 +125,38 @@ after(async () => {
});
describe("before_agent_start prompt injection", () => {
it("appends through structured prompt options instead of forcing the prompt", async () => {
it("contributes a named section through structured prompt options instead of forcing the prompt", async () => {
await registerWith();
const event = { systemPrompt: "base prompt", systemPromptOptions: structuredOptions() };
const result = await emit("before_agent_start", event);
assert.equal(result, undefined, "structured events must not force a full prompt replacement");
assert.match(event.systemPromptOptions.appendSystemPrompt, /memory-policy/);
assert.match(event.systemPromptOptions.sections.memory_policy, /memory-policy/);
assert.equal(
event.systemPromptOptions.appendSystemPrompt,
"",
"the policy is a named section, not appended text",
);
});
it("appends after an earlier handler's appendSystemPrompt", async () => {
it("keeps earlier sections while adding memory_policy", async () => {
await registerWith();
const event = {
systemPrompt: "base prompt",
systemPromptOptions: structuredOptions({ appendSystemPrompt: "earlier handler text" }),
systemPromptOptions: structuredOptions({
appendSystemPrompt: "earlier handler text",
sections: { other_section: "earlier content" },
}),
};
await emit("before_agent_start", event);
const appended = event.systemPromptOptions.appendSystemPrompt;
assert.match(appended, /earlier handler text/);
assert.match(appended, /memory-policy/);
assert.ok(
appended.indexOf("earlier handler text") < appended.indexOf("memory-policy"),
"the policy goes after earlier contributions",
assert.equal(event.systemPromptOptions.sections.other_section, "earlier content");
assert.match(event.systemPromptOptions.sections.memory_policy, /memory-policy/);
assert.equal(
event.systemPromptOptions.appendSystemPrompt,
"earlier handler text",
);
});
@@ -585,7 +585,7 @@ src/
├── permission-manager.ts Unified intent evaluation
├── access-intent/ Resolved intent types and resolver
├── handlers/
│ ├── before-agent-start.ts Session warmup and skill metadata only; no active-tool control
│ ├── before-agent-start.ts Session warmup + denied-skill filtering (structured skills array, prompt-text fallback); no active-tool control
│ └── gates/ skill -> path -> bash -> tool pipelines
├── authority/
│ ├── authorizer-selection.ts Configured authorizer resolution
@@ -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[],
@@ -18,6 +18,7 @@ function makeSetup() {
return {
handler,
session,
permissionManager,
configStore,
forwarding,
warmParser,
@@ -55,4 +56,35 @@ describe("AgentPrepHandler", () => {
expect(result).toEqual({});
expect(session.getActiveSkillEntries()).toEqual([]);
});
it("filters denied skills out of structured systemPromptOptions.skills", async () => {
const { handler, session, permissionManager } = makeSetup();
vi.mocked(permissionManager.check).mockImplementation((intent) =>
makeCheckResult({
state:
intent.kind === "tool" &&
(intent.input as { name?: string })?.name === "dangerous"
? "deny"
: "allow",
}),
);
const options = {
skills: [
{ name: "librarian", description: "d", filePath: "/skills/librarian/SKILL.md", baseDir: "/skills/librarian" },
{ name: "dangerous", description: "d", filePath: "/skills/dangerous/SKILL.md", baseDir: "/skills/dangerous" },
{ name: "manual", description: "d", filePath: "/skills/manual/SKILL.md", baseDir: "/skills/manual", disableModelInvocation: true },
],
};
const result = await handler.handle(
{ systemPrompt: "You are an assistant.", systemPromptOptions: options },
makeCtx(),
);
expect(result).toEqual({});
expect(options.skills.map((skill) => skill.name)).toEqual(["librarian", "manual"]);
expect(session.getActiveSkillEntries().map((entry) => entry.name)).toEqual([
"librarian",
]);
});
});
@@ -7,6 +7,7 @@ import {
findSkillPathMatch,
parseAllSkillPromptSections,
resolveSkillPromptEntries,
resolveStructuredSkillEntries,
type SkillPermissionChecker,
} from "#src/skill-prompt-sanitizer";
import type { PermissionCheckResult } from "#src/types";
@@ -182,6 +183,92 @@ describe("resolveSkillPromptEntries", () => {
});
});
// ── resolveStructuredSkillEntries ───────────────────────────────────────────
describe("resolveStructuredSkillEntries", () => {
const makeSkill = (
name: string,
extra: Record<string, unknown> = {},
): Record<string, unknown> => ({
name,
description: `Description of ${name}`,
filePath: `/skills/${name}/SKILL.md`,
baseDir: `/skills/${name}`,
disableModelInvocation: false,
...extra,
});
test("filters denied skills out of the array and keeps them out of entries", () => {
const manager = makeManager("allow", { dangerous: "deny" });
const skills = [makeSkill("librarian"), makeSkill("dangerous")];
const result = resolveStructuredSkillEntries(
skills,
manager,
null,
normalizer,
);
expect(result.skills.map((skill) => skill.name)).toEqual(["librarian"]);
expect(result.entries.map((entry) => entry.name)).toEqual(["librarian"]);
expect(result.entries[0].state).toBe("allow");
expect(result.entries[0].location).toBe("/skills/librarian/SKILL.md");
expect(result.entries[0].normalizedBaseDir).toBe("/skills/librarian");
});
test("keeps disableModelInvocation skills in the array but out of entries", () => {
const manager = makeManager("allow");
const skills = [
makeSkill("visible"),
makeSkill("manual", { disableModelInvocation: true }),
];
const result = resolveStructuredSkillEntries(
skills,
manager,
null,
normalizer,
);
expect(result.skills.map((skill) => skill.name)).toEqual([
"visible",
"manual",
]);
expect(result.entries.map((entry) => entry.name)).toEqual(["visible"]);
});
test("passes through entries without a usable name", () => {
const manager = makeManager("deny");
const skills = [{ description: "no name" }];
const result = resolveStructuredSkillEntries(
skills,
manager,
null,
normalizer,
);
expect(result.skills).toHaveLength(1);
expect(manager.checkPermission).not.toHaveBeenCalled();
});
test("caches permission results per unique skill name", () => {
const manager = makeManager("allow");
const skills = [makeSkill("librarian"), makeSkill("librarian")];
resolveStructuredSkillEntries(skills, manager, null, normalizer);
expect(manager.checkPermission).toHaveBeenCalledTimes(1);
});
test("passes agentName to the permission check", () => {
const manager = makeManager("allow");
resolveStructuredSkillEntries(
[makeSkill("librarian")],
manager,
"my-agent",
normalizer,
);
expect(manager.checkPermission).toHaveBeenCalledWith(
"skill",
{ name: "librarian" },
"my-agent",
);
});
});
// ── findSkillPathMatch ──────────────────────────────────────────────────────
describe("findSkillPathMatch", () => {
+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;
+4
View File
@@ -10,3 +10,7 @@
纯 `ssh2` 连接管理在设计时参考了 <https://github.com/99percentpeople/pi-extensions> 中 `@99percentpeople/pi-ssh-remote` 的 commit `fe4c44f3b4d4b52bd2c31d6d4d5a833c4ad60d9d`。本地实现保留本 fork 专属的 `ssh_*` 工具,不导入该扩展的本地工具覆盖或 OpenSSH 回退模型。
本目录不是 Git submodule,且有意不包含嵌套 `.git`、`node_modules` 或生成的构建产物。未来同步上游时必须从明确 tag 或 commit 审查并移植,不得覆盖本地修改。
## 本地改动
- 已建立连接时的 `before_agent_start` 不再向 `systemPrompt` 文本追加远端指引,而是写入 `event.systemPromptOptions.sections.remote_ssh` 结构化分区;组合包单一提示词出口 `extensions/system-prompt.ts` 统一组装,顺序由 `config/prompt/sections.jsonc` 控制。
+8 -2
View File
@@ -355,8 +355,10 @@ export default function piSshExtension(pi: ExtensionAPI): void {
pi.on("before_agent_start", async (event) => {
if (!connection) return;
// Contribute structurally via a named section: the bundle's single
// system-prompt owner (extensions/system-prompt.ts) composes the final
// prompt; extensions must not return `systemPrompt` themselves.
const guidance =
`\n\n# Remote SSH connection\n\n` +
`An SSH2 connection to configured host ${connection.remote} on port ${connection.port} is active. ` +
`The default read/write/edit/bash/find/grep tools act on the LOCAL machine. ` +
`Use ssh_cd, ssh_read, ssh_write, ssh_edit, ssh_find, ssh_grep, and ssh_bash for explicit remote operations. ` +
@@ -364,6 +366,10 @@ export default function piSshExtension(pi: ExtensionAPI): void {
`Remote operations are rooted at ${connection.remoteCwd}; relative paths resolve against that directory. ` +
`For a persistent workspace change, call ssh_cd as a separate step and wait for its successful result before issuing dependent remote tool calls. ` +
`Each ssh_bash call starts a fresh non-interactive shell, so use command-local cd only for an intentionally temporary change; cd, exports, aliases, and other shell state inside one command do not persist to the next call.`;
return { systemPrompt: `${event.systemPrompt}${guidance}` };
const promptOptions = event.systemPromptOptions as unknown as
| { sections?: Record<string, string> }
| undefined;
if (!promptOptions) return; // hosts without structured options just skip the section
(promptOptions.sections ??= {}).remote_ssh = guidance;
});
}
+6 -3
View File
@@ -19,7 +19,7 @@ import {
} from "../pi-rtk-optimizer/src/constants.ts";
import { CONFIG_PATH as COMPACTION_CONFIG_PATH } from "../pi-compaction/src/config.ts";
import { WORKTREE_CONFIG_PATH } from "../pi-worktree/src/settings.ts";
import { resolveCoverPath } from "../extensions/system-prompt-cover-config.ts";
import { resolvePreamblePath } from "../extensions/system-prompt-config.ts";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bundleConfigDir = join(repositoryRoot, "config");
@@ -42,7 +42,10 @@ const BUNDLE_CONFIG_FILES = [
"pi-compaction.json",
"pi-worktree.json",
"pi-hermes-memory.json",
"system-prompt-cover.md",
"prompt/preamble.md",
"prompt/tools.jsonc",
"prompt/routing.jsonc",
"prompt/sections.jsonc",
"pi-hashline-edit.json",
"lsp.json",
"p10k.zsh",
@@ -92,7 +95,7 @@ test("each extension resolves its config path inside the bundle config/ dir", ()
inBundleConfig(COMPACTION_CONFIG_PATH, "pi-compaction.json");
inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json");
inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json");
inBundleConfig(resolveCoverPath(), "system-prompt-cover.md");
inBundleConfig(resolvePreamblePath(), "prompt/preamble.md");
} finally {
if (savedToolSearch === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
else process.env.PI_TOOL_SEARCH_CONFIG_PATH = savedToolSearch;
+35 -7
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
@@ -154,17 +155,44 @@ test("all package extensions load together without global registration conflicts
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
"the bundle must load the locally maintained Hermes memory extension",
);
// The cover must rewrite the prompt before tool-routing flattens it into a forced prompt;
// once flattened, later handlers cannot restore the section structure.
// extensions/system-prompt.ts is the bundle's single system-prompt owner and
// must run after every tool-registration extension (including Tool Search)
// so it sees the complete tool set and any contributed sections.
assert.equal(
packageJson.pi.extensions.at(-1),
"./extensions/system-prompt.ts",
"the single system-prompt owner must be the last loaded extension",
);
assert.ok(
packageJson.pi.extensions.includes("./extensions/context-mode.ts"),
"Context Mode must load through the bundle's prompt-guarded wrapper",
);
assert.equal(
packageJson.pi.extensions.some((entry) => entry.includes("context-mode/build/")),
false,
"the raw Context Mode adapter must not be loaded alongside the wrapper",
);
assert.equal(
packageJson.pi.extensions.includes("./extensions/system-prompt-cover.ts"),
"the bundle must load the system prompt cover extension",
false,
"the retired system-prompt cover must not be loaded",
);
assert.ok(
packageJson.pi.extensions.indexOf("./extensions/system-prompt-cover.ts") <
packageJson.pi.extensions.indexOf("./extensions/tool-routing.ts"),
"the system prompt cover must run before tool-routing forces the prompt",
assert.equal(
packageJson.pi.extensions.includes("./extensions/tool-routing.ts"),
false,
"the retired tool-routing extension must not be loaded",
);
for (const retired of [
"extensions/system-prompt-cover.ts",
"extensions/system-prompt-cover-config.ts",
"extensions/tool-routing.ts",
]) {
assert.equal(
existsSync(join(repositoryRoot, retired)),
false,
`${retired} was superseded by extensions/system-prompt.ts and must be deleted`,
);
}
assert.equal(packageJson.dependencies["pi-hermes-memory"], "file:./pi-hermes-memory");
assert.ok(
packageJson.files.includes("pi-hermes-memory"),
-160
View File
@@ -1,160 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
readCoverFile,
resolveCoverPath,
spliceCover,
} from "../extensions/system-prompt-cover-config.ts";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bundledCoverPath = join(repositoryRoot, "config", "system-prompt-cover.md");
const DEFAULT_PREAMBLE =
"You are an expert coding assistant operating inside pi, a coding agent harness. " +
"You help users by reading files, executing commands, editing code, and writing new files.";
/** A rendered prompt shaped like Pi's: preamble, then tag-wrapped top-level sections. */
function renderedPrompt(preamble = DEFAULT_PREAMBLE) {
return [
preamble,
"<tools>",
"- read: reads files",
"</tools>",
"<rules>",
"- Be concise",
"</rules>",
"<skills>",
"<available_skills>",
" <skill>",
" <name>s</name>",
" </skill>",
"</available_skills>",
"</skills>",
"<project_context>",
'<project_instructions path="/work/AGENTS.md">',
"Nested content with a </project_context>-looking line is not a tag.",
"</project_instructions>",
"</project_context>",
"<cwd>",
"/work",
"</cwd>",
].join("\n");
}
function tagsOf(text: string): string[] {
return [...text.matchAll(/^<([a-z][a-z0-9_-]*)>$/gm)].map((match) => match[1]);
}
async function makeTempDir() {
return mkdtemp(join(tmpdir(), "my-pi-cover-"));
}
test("bundled cover holds Pi's default preamble text", async () => {
const content = await readFile(bundledCoverPath, "utf8");
assert.equal(content.replace(/\s+$/, ""), DEFAULT_PREAMBLE);
});
test("spliceCover replaces only the preamble and preserves the remaining bytes", () => {
const rendered = renderedPrompt();
const spliced = spliceCover(rendered, "MY CUSTOM COVER");
assert.ok(spliced.startsWith("MY CUSTOM COVER\n"));
// Everything after the preamble's terminating newline is untouched.
const originalTail = rendered.slice(rendered.indexOf("\n<tools>"));
assert.ok(spliced.endsWith(originalTail));
// Section order — including nested tags — is identical to the unmodified prompt.
assert.deepEqual(tagsOf(spliced), tagsOf(rendered));
assert.deepEqual(tagsOf(spliced), [
"tools",
"rules",
"skills",
"available_skills",
"project_context",
"cwd",
]);
});
test("spliceCover keeps section order rather than appending recovered sections", () => {
const rendered = renderedPrompt("ORIGINAL COVER");
const spliced = spliceCover(rendered, "NEW COVER");
const order = tagsOf(spliced).join(",");
assert.equal(order, "tools,rules,skills,available_skills,project_context,cwd");
assert.ok(
order.indexOf("tools") < order.indexOf("cwd"),
"tools must still precede cwd; a re-appended section list would invert this",
);
assert.ok(
order.indexOf("project_context") < order.indexOf("cwd"),
"project_context must keep its original position",
);
});
test("spliceCover tolerates a trailing newline from the editor and a blank cover", () => {
const rendered = renderedPrompt();
assert.equal(spliceCover(rendered, "COVER\n\n"), spliceCover(rendered, "COVER"));
// A whitespace-only cover leaves an empty preamble but still keeps every section.
const blank = spliceCover(rendered, " \n");
assert.equal(blank, rendered.slice(rendered.indexOf("\n<tools>")));
assert.deepEqual(tagsOf(blank), tagsOf(rendered));
});
test("spliceCover degenerates to the cover when the prompt has no sections", () => {
assert.equal(spliceCover("just a preamble", "NEW"), "NEW");
assert.equal(spliceCover("", "NEW"), "NEW");
});
test("spliceCover keeps an attribute-bearing nested tag inside its parent section", () => {
// Pi nests <project_instructions path="..."> inside the bare <project_context> tag, so the
// boundary must be the first *bare* tag line and the nested markup must survive verbatim.
const rendered = [
"ORIGINAL COVER",
"<tools>",
"- read: reads files",
"</tools>",
"<project_context>",
'<project_instructions path="/work/AGENTS.md">',
"nested instructions",
"</project_instructions>",
"</project_context>",
"<cwd>",
"/work",
"</cwd>",
].join("\n");
const spliced = spliceCover(rendered, "NEW");
assert.equal(tagsOf(spliced).join(","), "tools,project_context,cwd");
assert.ok(spliced.startsWith("NEW\n<tools>"));
assert.ok(
spliced.includes('<project_instructions path="/work/AGENTS.md">\nnested instructions\n</project_instructions>'),
"nested project instructions must be preserved byte-for-byte",
);
});
test("resolveCoverPath points at the bundle config file", () => {
assert.equal(resolveCoverPath(), bundledCoverPath);
});
test("readCoverFile returns undefined when the file is absent or blank", async () => {
const dir = await makeTempDir();
try {
const coverPath = join(dir, "system-prompt-cover.md");
assert.equal(readCoverFile(coverPath), undefined);
await writeFile(coverPath, " \n\n", "utf8");
assert.equal(readCoverFile(coverPath), undefined);
await writeFile(coverPath, " REAL COVER \n", "utf8");
assert.equal(readCoverFile(coverPath), " REAL COVER");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+605
View File
@@ -0,0 +1,605 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
applyToolCatalog,
loadRoutingRules,
loadSectionsConfig,
loadToolCatalog,
orderSections,
readPreamble,
renderRouting,
resolvePreamblePath,
spliceCover,
stripJsoncComments,
type MutablePromptOptions,
type RoutingRule,
} from "../extensions/system-prompt-config.ts";
import systemPromptExtension, {
createSystemPromptComposer,
getSystemPromptDumpPath,
} from "../extensions/system-prompt.ts";
import { guardExtensionApi } from "../extensions/prompt-guard.ts";
import {
CONTEXT_MODE_ANCHOR_PREFIX,
filterContextModeMessages,
} from "../extensions/context-mode-filter.ts";
import { BUNDLE_GROUP_DEFINITIONS } from "../pi-tool-search/extensions/bundle-groups.ts";
import { BUNDLE_TOOL_SEARCH_DEFAULTS } from "../pi-tool-search/extensions/config.ts";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const promptConfigDir = join(repositoryRoot, "config", "prompt");
const BUNDLE_PREAMBLE =
"You are an expert coding assistant operating inside pi, a coding agent harness. " +
"You help users by reading files, executing commands, editing code, and writing new files.";
// ── JSONC parsing ───────────────────────────────────────────────────────────
test("stripJsoncComments removes comments but keeps // inside strings", () => {
const source = `{
// line comment
"url": "https://example.com//path", /* block comment */
"escaped": "a\\"//not a comment"
}`;
assert.deepEqual(JSON.parse(stripJsoncComments(source)), {
url: "https://example.com//path",
escaped: 'a"//not a comment',
});
});
// ── Config files load and validate ──────────────────────────────────────────
test("config/prompt files load with the expected shapes", () => {
const catalog = loadToolCatalog();
assert.equal(catalog.version, 1);
assert.ok(Object.keys(catalog.tools).length > 0);
const routing = loadRoutingRules();
assert.equal(routing.version, 1);
assert.ok(routing.rules.length > 0);
for (const rule of routing.rules) assert.equal(typeof rule.text, "string");
const sections = loadSectionsConfig();
assert.equal(sections.version, 1);
assert.deepEqual(sections.order, [
"memory_policy",
"remote_ssh",
"chrome_profile_bridge",
"tool_routing",
]);
});
test("readPreamble reads the bundle preamble from config/prompt/", () => {
assert.equal(
resolvePreamblePath(),
join(promptConfigDir, "preamble.md"),
);
assert.equal(readPreamble(), BUNDLE_PREAMBLE);
});
// ── Catalog completeness ────────────────────────────────────────────────────
const CORE_TOOL_NAMES = [
"read",
"bash",
"edit",
"write",
"grep",
"find",
"ls",
"powershell",
"tool_search",
];
function requiredToolNames(): Set<string> {
const required = new Set(CORE_TOOL_NAMES);
for (const group of BUNDLE_GROUP_DEFINITIONS) {
for (const name of group.tools) required.add(name);
}
for (const name of BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled) required.add(name);
return required;
}
test("tools.jsonc covers every tool the bundle can expose, and nothing else", () => {
const required = requiredToolNames();
const catalog = loadToolCatalog();
for (const name of required) {
assert.ok(name in catalog.tools, `tool "${name}" is missing from config/prompt/tools.jsonc`);
}
for (const name of Object.keys(catalog.tools)) {
assert.ok(required.has(name), `tools.jsonc entry "${name}" is not a bundle-exposed tool`);
}
});
// ── Forbidden phrases ───────────────────────────────────────────────────────
test("prompt config carries no retired guidance phrasing", () => {
const forbidden = [
"ls, grep, find",
"read, grep, or edit",
"read/grep",
"replace_text",
"Read/edit files",
"ctx_fetch_and_index then ctx_search",
];
for (const file of ["preamble.md", "tools.jsonc", "routing.jsonc"]) {
const source = readFileSync(join(promptConfigDir, file), "utf8");
for (const phrase of forbidden) {
assert.ok(!source.includes(phrase), `config/prompt/${file} must not contain "${phrase}"`);
}
}
});
// ── applyToolCatalog ────────────────────────────────────────────────────────
test("applyToolCatalog applies snippet/guideline overrides and reports unregistered tools", () => {
const catalog = {
version: 1 as const,
tools: {
bash: { snippet: "custom bash line" },
edit: { guidelines: ["rule one", "rule two"] },
read: { inherit: true as const },
write: { snippet: null },
},
};
const options: MutablePromptOptions = {
selectedTools: ["bash", "edit", "read", "write", "mystery_tool"],
toolSnippets: { bash: "orig bash", write: "orig write", read: "orig read" },
toolGuidelines: { edit: ["old rule"] },
};
const missing = applyToolCatalog(options, catalog);
assert.deepEqual(missing, ["mystery_tool"]);
assert.equal(options.toolSnippets?.bash, "custom bash line");
assert.equal(options.toolSnippets?.read, "orig read", "inherit entries keep upstream text");
assert.ok(!("write" in (options.toolSnippets ?? {})), "snippet: null hides the tools entry");
assert.deepEqual(options.toolGuidelines?.edit, ["rule one", "rule two"]);
});
// ── renderRouting ───────────────────────────────────────────────────────────
test("renderRouting honours whenAll, whenAny and unconditional rules", () => {
const rules: RoutingRule[] = [
{ whenAll: ["a", "b"], text: "all" },
{ whenAny: ["x", "y"], text: "any" },
{ text: "always" },
{ whenAll: ["a"], whenAny: ["b", "z"], text: "both" },
];
assert.equal(renderRouting(rules, ["a", "b", "x"]), "- all\n- any\n- always\n- both");
assert.equal(renderRouting(rules, ["a"]), "- always", "whenAny must match at least one");
assert.equal(renderRouting(rules, []), "- always");
assert.equal(
renderRouting(rules, undefined),
"- all\n- any\n- always\n- both",
"absent selectedTools means every tool is active",
);
assert.equal(renderRouting([], ["a"]), "");
});
test("bundled routing rules select the same guidance the retired tool-routing extension emitted", () => {
const routing = loadRoutingRules();
const literal = renderRouting(routing.rules, ["find", "grep", "read"]);
assert.match(literal, /Use find to identify likely files or directories/);
assert.match(literal, /narrow the path, glob, or pattern instead of increasing the limit/);
const ssh = renderRouting(routing.rules, ["ssh_connect", "ssh_cd", "ssh_bash"]);
assert.match(ssh, /only when the user explicitly names an imported host/);
assert.match(ssh, /command-local directory change/);
const chrome = renderRouting(routing.rules, ["chrome_snapshot", "ctx_execute_file"]);
assert.match(chrome, /immediately analyze the returned workspace-relative capture path/);
const minimal = renderRouting(routing.rules, ["read"]);
assert.doesNotMatch(minimal, /codegraph_explore first/);
assert.doesNotMatch(minimal, /For literal search/);
assert.doesNotMatch(minimal, /ssh_connect only when/);
assert.match(minimal, /Stop searching once the current evidence is sufficient/);
});
// ── orderSections ───────────────────────────────────────────────────────────
test("orderSections applies the configured order and keeps unlisted sections last", () => {
const sections = {
late_unlisted: "L",
tool_routing: "R",
memory_policy: "M",
early_unlisted: "E",
};
assert.deepEqual(
Object.keys(orderSections(sections, ["memory_policy", "tool_routing"])),
["memory_policy", "tool_routing", "late_unlisted", "early_unlisted"],
);
assert.deepEqual(orderSections({}, ["memory_policy"]), {});
});
// ── spliceCover (moved from the retired cover extension) ────────────────────
function renderedPrompt(preamble = "PI DEFAULT PREAMBLE") {
return [
preamble,
"<tools>",
"- read: reads files",
"</tools>",
"<rules>",
"- Be concise",
"</rules>",
"<project_context>",
'<project_instructions path="/work/AGENTS.md">',
"Nested content with a </project_context>-looking line is not a tag.",
"</project_instructions>",
"</project_context>",
"<cwd>",
"/work",
"</cwd>",
].join("\n");
}
function tagsOf(text: string): string[] {
return [...text.matchAll(/^<([a-z][a-z0-9_-]*)>$/gm)].map((match) => match[1]);
}
test("spliceCover replaces only the preamble and preserves the remaining bytes", () => {
const rendered = renderedPrompt();
const spliced = spliceCover(rendered, "MY CUSTOM COVER");
assert.ok(spliced.startsWith("MY CUSTOM COVER\n"));
const originalTail = rendered.slice(rendered.indexOf("\n<tools>"));
assert.ok(spliced.endsWith(originalTail));
assert.deepEqual(tagsOf(spliced), tagsOf(rendered));
assert.deepEqual(tagsOf(spliced), ["tools", "rules", "project_context", "cwd"]);
});
test("spliceCover tolerates a trailing newline and a whitespace-only preamble", () => {
const rendered = renderedPrompt();
assert.equal(spliceCover(rendered, "COVER\n\n"), spliceCover(rendered, "COVER"));
const blank = spliceCover(rendered, " \n");
assert.equal(blank, rendered.slice(rendered.indexOf("\n<tools>")));
});
test("spliceCover degenerates to the preamble when the prompt has no sections", () => {
assert.equal(spliceCover("just a preamble", "NEW"), "NEW");
assert.equal(spliceCover("", "NEW"), "NEW");
});
// ── Composer: modern structured path ────────────────────────────────────────
interface FakePromptOptions extends MutablePromptOptions {
forceSystemPrompt?: string;
}
/** Simplified re-render of Pi's prompt from the mutable options object. */
function renderPiPrompt(options: FakePromptOptions): string {
if (options.forceSystemPrompt !== undefined) return options.forceSystemPrompt;
const parts = ["PI DEFAULT PREAMBLE"];
const snippets = Object.entries(options.toolSnippets ?? {});
if (snippets.length > 0) {
parts.push(`<tools>\n${snippets.map(([n, s]) => `- ${n}: ${s}`).join("\n")}\n</tools>`);
}
const rules = [
...(options.promptGuidelines ?? []),
...Object.entries(options.toolGuidelines ?? {}).flatMap(([name, lines]) =>
lines.map((line) => `${name}: ${line}`),
),
];
if (rules.length > 0) parts.push(`<rules>\n${rules.join("\n")}\n</rules>`);
parts.push("<cwd>\n/work\n</cwd>");
for (const [name, text] of Object.entries(options.sections ?? {})) {
parts.push(`<${name}>\n${text}\n</${name}>`);
}
return parts.join("\n");
}
function makeEvent(options: FakePromptOptions | undefined) {
return {
systemPromptOptions: options,
get systemPrompt() {
return options ? renderPiPrompt(options) : "PI DEFAULT PREAMBLE\n<tools>\n- x\n</tools>";
},
};
}
function makeUiCtx() {
const notices: string[] = [];
const ctx = {
hasUI: true,
ui: { notify: (message: string) => notices.push(message) },
};
return { notices, ctx: ctx as never };
}
test("composer splices the preamble, applies the catalog, and orders sections", () => {
const composer = createSystemPromptComposer();
const options: FakePromptOptions = {
selectedTools: ["read", "bash", "edit", "codegraph_explore", "tool_search"],
toolSnippets: {
read: "reads files",
bash: "runs commands",
edit: "edits",
codegraph_explore: "explores",
tool_search: "searches",
},
toolGuidelines: { edit: ["upstream edit rule"] },
sections: { memory_policy: "policy text" },
};
const { notices, ctx } = makeUiCtx();
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
const output = result?.systemPrompt;
assert.ok(output);
assert.ok(
output.startsWith(BUNDLE_PREAMBLE),
"the bundle preamble replaces Pi's default preamble",
);
assert.match(output, /<tool_routing>\n- Use codegraph_explore first/);
assert.match(output, /search files with the find\/grep tools, not bash/);
assert.match(output, /LINE#HASH anchors/);
assert.ok(
!output.includes("upstream edit rule"),
"the edit guidelines entry replaces upstream rules",
);
assert.ok(
output.indexOf("<memory_policy>") < output.indexOf("<tool_routing>"),
"sections.jsonc orders memory_policy before tool_routing",
);
assert.deepEqual(notices, []);
assert.equal(composer.getLastComposed(), output);
});
test("composer degrades to text splicing when an earlier extension froze the prompt", () => {
const composer = createSystemPromptComposer();
const options: FakePromptOptions = {
selectedTools: ["read", "bash"],
forceSystemPrompt: "FROZEN PROMPT\n<frozen>\nkept\n</frozen>",
};
const { notices, ctx } = makeUiCtx();
const event = makeEvent(options);
const first = composer.handleBeforeAgentStart(event as never, ctx);
composer.handleBeforeAgentStart(event as never, ctx);
assert.match(notices[0] ?? "", /单一入口规则/);
assert.equal(notices.length, 1, "the violation warning is reported once per process");
assert.match(first?.systemPrompt ?? "", /<tool_routing>/);
assert.match(first?.systemPrompt ?? "", /<frozen>\nkept\n<\/frozen>/);
assert.ok(
(first?.systemPrompt ?? "").startsWith(BUNDLE_PREAMBLE),
"the preamble is still spliced onto the frozen prompt",
);
});
test("composer falls back to text appends when the host exposes no prompt options", () => {
const composer = createSystemPromptComposer();
const { ctx } = makeUiCtx();
const event = { systemPrompt: "PI DEFAULT PREAMBLE\n<tools>\n- x\n</tools>" };
const result = composer.handleBeforeAgentStart(event as never, ctx);
const output = result?.systemPrompt ?? "";
assert.ok(output.startsWith(BUNDLE_PREAMBLE));
assert.match(output, /<tool_routing>/);
assert.match(output, /<tools>\n- x\n<\/tools>/);
});
test("composer reports unregistered tools once per distinct name set", () => {
const composer = createSystemPromptComposer();
const { notices, ctx } = makeUiCtx();
const options = (): FakePromptOptions => ({
selectedTools: ["read", "ghost_tool"],
toolSnippets: { read: "r" },
});
composer.handleBeforeAgentStart(makeEvent(options()) as never, ctx);
composer.handleBeforeAgentStart(makeEvent(options()) as never, ctx);
assert.equal(notices.length, 1);
assert.match(notices[0], /未在 config\/prompt\/tools\.jsonc 登记:.*ghost_tool/);
composer.handleBeforeAgentStart(
makeEvent({ selectedTools: ["other_tool"], toolSnippets: {} }) as never,
ctx,
);
assert.equal(notices.length, 2, "a different missing-tool set notifies again");
});
test("composer warns once and returns nothing when the prompt config fails to load", () => {
const composer = createSystemPromptComposer({
loadToolCatalog: () => {
throw new Error("invalid jsonc");
},
});
const { notices, ctx } = makeUiCtx();
const options: FakePromptOptions = { selectedTools: ["read"], toolSnippets: { read: "r" } };
assert.equal(composer.handleBeforeAgentStart(makeEvent(options) as never, ctx), undefined);
assert.equal(composer.handleBeforeAgentStart(makeEvent(options) as never, ctx), undefined);
assert.equal(notices.length, 1);
assert.match(notices[0], /invalid jsonc/);
});
// ── prompt guard ────────────────────────────────────────────────────────────
function makeRecordingPi() {
const tools: Array<{ name: string; description?: string }> = [];
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
const pi = {
registerTool(def: { name: string; description?: string }) {
tools.push(def);
},
on(name: string, handler: (event: unknown, ctx: unknown) => unknown) {
handlers.set(name, handler);
},
someOtherApi: () => "passthrough",
};
return { pi, tools, handlers };
}
test("prompt guard applies catalog description overrides at registration", () => {
const { pi, tools } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
guarded.registerTool({ name: "edit", description: "upstream edit" } as never);
guarded.registerTool({ name: "grep", description: "upstream grep" } as never);
const override = loadToolCatalog().tools.edit;
assert.ok(override && !("inherit" in override) && typeof override.description === "string");
assert.equal(tools[0].description, override.description);
assert.equal(tools[1].description, "upstream grep", "inherit entries keep the upstream description");
});
test("prompt guard strips a returned systemPrompt but keeps other fields", async () => {
const { pi, handlers } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
guarded.on("before_agent_start", (async () => ({
systemPrompt: "forced by third party",
message: "kept",
})) as never);
const result = (await handlers.get("before_agent_start")?.({}, {})) as Record<string, unknown>;
assert.deepEqual(result, { message: "kept" });
});
test("prompt guard runs context results through the provided contextFilter", async () => {
const { pi, handlers } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI, {
contextFilter: (before, after) => after.slice(0, before.length),
});
guarded.on("context", (async (event: { messages: Array<{ role: string; content: string }> }) => {
event.messages.push({ role: "user", content: "injected" });
return { messages: event.messages };
}) as never);
const event = { messages: [{ role: "user", content: "real" }] };
const result = (await handlers.get("context")?.(event, {})) as {
messages: Array<{ content: string }>;
};
assert.deepEqual(result.messages.map((m) => m.content), ["real"]);
});
test("prompt guard returns the handler result untouched when the filter changes nothing", async () => {
const { pi, handlers } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI, {
contextFilter: filterContextModeMessages,
});
const expected = { messages: [{ role: "user", content: "plain" }] };
guarded.on("context", (async () => expected) as never);
const result = await handlers.get("context")?.({ messages: expected.messages.slice() }, {});
assert.equal(result, expected, "a no-op filter must preserve result identity");
});
test("prompt guard passes unrelated API members through to the real pi object", () => {
const { pi } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
assert.equal((guarded as unknown as { someOtherApi: () => string }).someOtherApi(), "passthrough");
});
// ── context-mode filter ─────────────────────────────────────────────────────
test("context-mode filter drops only the routing-anchor part", () => {
const before = [{ role: "user", content: "real" }];
const anchor = `${CONTEXT_MODE_ANCHOR_PREFIX} ctx_batch_execute > ctx_execute`;
const after = [
...before,
{ role: "user", content: `${anchor}\n\n<active_memory>\nx\n</active_memory>` },
];
const filtered = filterContextModeMessages(before, after);
assert.equal(filtered.length, 2);
assert.equal(filtered[1].content, "<active_memory>\nx\n</active_memory>");
});
test("context-mode filter removes the appended message when only the anchor remains", () => {
const before = [{ role: "user", content: "real" }];
const after = [
...before,
{ role: "user", content: `${CONTEXT_MODE_ANCHOR_PREFIX} only anchor` },
];
assert.deepEqual(filterContextModeMessages(before, after), before);
});
test("context-mode filter returns the same reference when nothing changed", () => {
const before = [{ role: "user", content: "real" }];
const after = [...before, { role: "user", content: "no anchor here" }];
assert.equal(filterContextModeMessages(before, after), after);
assert.equal(filterContextModeMessages(after, after), after);
});
test("context-mode filter ignores appended non-user or non-string messages", () => {
const before = [{ role: "user", content: "real" }];
const after = [
...before,
{ role: "assistant", content: `${CONTEXT_MODE_ANCHOR_PREFIX} not ours` },
{ role: "user", content: [{ type: "text", text: "structured" }] },
];
assert.equal(filterContextModeMessages(before, after), after);
});
// ── Pinned upstream text ────────────────────────────────────────────────────
test("context-mode upstream still appends the single anchor-bearing user message", async () => {
const packageJson = JSON.parse(
await readFile(join(repositoryRoot, "package.json"), "utf8"),
) as { dependencies: Record<string, string> };
assert.equal(
packageJson.dependencies["context-mode"],
"1.0.169",
"the context-filter pin requires the exact upstream version",
);
const upstream = await readFile(
join(repositoryRoot, "node_modules/context-mode/build/adapters/pi/extension.js"),
"utf8",
);
assert.ok(upstream.includes('parts.push("context-mode active. Hierarchy:'));
assert.ok(upstream.includes('_pendingContext = extraParts.join("\\n\\n")'));
assert.ok(upstream.includes("event.messages.push({"));
assert.ok(upstream.includes('role: "user"'));
});
// ── /dump-system-prompt ─────────────────────────────────────────────────────
test("dump-system-prompt writes the last composed prompt under .pi-debug", async () => {
let beforeAgentStart: ((event: unknown, ctx: unknown) => unknown) | undefined;
let command: { handler: (args: string, ctx: unknown) => Promise<void> } | undefined;
const pi = {
on(name: string, handler: typeof beforeAgentStart) {
if (name === "before_agent_start") beforeAgentStart = handler;
},
registerCommand(name: string, definition: typeof command) {
if (name === "dump-system-prompt") command = definition;
},
} as unknown as ExtensionAPI;
systemPromptExtension(pi);
assert.ok(beforeAgentStart);
assert.ok(command);
const { ctx } = makeUiCtx();
beforeAgentStart(
makeEvent({ selectedTools: ["read"], toolSnippets: { read: "r" } }) as never,
ctx,
);
const cwd = await mkdtemp(join(tmpdir(), "my-pi-system-prompt-"));
try {
await command.handler("", {
cwd,
getSystemPrompt: () => "fallback prompt",
ui: { notify() {} },
});
const dumped = await readFile(getSystemPromptDumpPath(cwd), "utf8");
assert.ok(dumped.startsWith(BUNDLE_PREAMBLE));
assert.match(dumped, /<tool_routing>/);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
-155
View File
@@ -1,155 +0,0 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import toolRoutingExtension, {
appendToolRouting,
buildToolRoutingSection,
getSystemPromptDumpPath,
} from "../extensions/tool-routing.ts";
test("search routing narrows files before requesting matching line numbers", () => {
const section = buildToolRoutingSection(["find", "grep", "read"]);
assert.match(section, /Use find to identify likely files or directories/);
assert.match(section, /Use grep or multi_grep only inside the narrowed path or file/);
assert.match(section, /obtain matching line numbers/);
assert.match(section, /narrow the path, glob, or pattern instead of increasing the limit/);
assert.match(section, /Use read with offset\/limit only for the exact matching region/);
});
test("SSH connection routing requires an explicit named host and concrete task", () => {
const section = buildToolRoutingSection(["ssh_connect"]);
assert.match(section, /only when the user explicitly names an imported host/);
assert.match(section, /separate step and wait for success before calling other ssh_\* tools/);
assert.match(section, /never infer or substitute a different host/);
});
test("remote cwd routing separates persistent workspace changes from shell commands", () => {
const section = buildToolRoutingSection(["ssh_cd", "ssh_bash"]);
assert.match(section, /call it as a separate step and wait for success/);
assert.match(section, /relative ssh_read\/ssh_write\/ssh_edit\/ssh_find\/ssh_grep/);
assert.match(section, /intentionally temporary, command-local directory change/);
});
test("remote search routing uses structured bounded SSH tools", () => {
const section = buildToolRoutingSection(["ssh_find", "ssh_grep"]);
assert.match(section, /use ssh_find to narrow remote file paths before ssh_grep/);
assert.match(section, /Do not run find, fd, grep, or rg through ssh_bash/);
assert.match(section, /when a result says truncated, narrow the path or pattern/);
});
test("context routing analyzes large files without a full read", () => {
const section = buildToolRoutingSection(["ctx_execute", "ctx_execute_file"]);
assert.match(section, /Prefer ctx_execute_file over read when deriving an answer from a large file/);
assert.match(section, /ctx_execute_file is confined to the current project root/);
assert.match(section, /host permission approval does not bypass this Context Mode boundary/);
});
test("Chrome snapshot routing keeps raw page data out of model context", () => {
const section = buildToolRoutingSection(["chrome_snapshot", "ctx_execute_file"]);
assert.match(section, /immediately analyze the returned workspace-relative capture path with ctx_execute_file/);
assert.match(section, /never use read or cat for the raw JSON/);
assert.match(section, /take a fresh snapshot after any interaction/);
});
test("hashline routing uses small reads and fresh anchors for edits", () => {
const section = buildToolRoutingSection(["read", "edit"]);
assert.match(section, /smallest useful offset\/limit range/);
assert.match(section, /obtain fresh LINE#HASH anchors with a small read/);
assert.match(section, /Reuse fresh anchors returned by successful edits/);
});
test("LSP routing covers navigation and post-edit diagnostics", () => {
const section = buildToolRoutingSection(["lsp_definition", "lsp_references", "lsp_diagnostics"]);
assert.match(section, /Use LSP for symbol definitions, references/);
assert.match(section, /post-edit diagnostics/);
});
test("online search routing uses the unified web tools without naming providers", () => {
const section = buildToolRoutingSection(["web_search", "web_fetch"]);
assert.match(section, /Use web_search for current, real-world, or citable web information/);
assert.match(section, /provider selection is handled internally/);
assert.match(section, /read selected pages with web_fetch/);
assert.doesNotMatch(section, /tavily|exa|keenable/iu);
const searchOnly = buildToolRoutingSection(["web_search"]);
assert.match(searchOnly, /Use web_search/);
});
test("routing includes only guidance for active optional tools", () => {
const section = buildToolRoutingSection(["read"]);
assert.doesNotMatch(section, /codegraph_explore first/);
assert.doesNotMatch(section, /For literal search/);
assert.doesNotMatch(section, /Use Context Mode/);
assert.doesNotMatch(section, /ctx_execute_file is confined/);
assert.doesNotMatch(section, /fresh LINE#HASH anchors/);
assert.doesNotMatch(section, /Use web_search for current/);
assert.match(section, /Use read only when exact source is needed/);
});
test("routing is appended once", () => {
const once = appendToolRouting("base prompt", ["find", "grep"]);
const twice = appendToolRouting(once, ["find", "grep"]);
assert.equal(twice, once);
assert.equal((once.match(/<!-- my-pi-tool-routing -->/g) ?? []).length, 1);
});
test("dump path stays under the current project", () => {
assert.equal(
getSystemPromptDumpPath("/workspace/project"),
"/workspace/project/.pi-debug/effective-system-prompt.md",
);
});
test("dump-system-prompt writes the last routed prompt seen by the extension", async () => {
let beforeAgentStart: ((event: any) => { systemPrompt: string }) | undefined;
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
const pi = {
on(name: string, handler: typeof beforeAgentStart) {
if (name === "before_agent_start") beforeAgentStart = handler;
},
registerCommand(_name: string, definition: typeof command) {
command = definition;
},
} as unknown as ExtensionAPI;
toolRoutingExtension(pi);
assert.ok(beforeAgentStart);
assert.ok(command);
beforeAgentStart({
systemPrompt: "prompt already modified by an earlier extension",
systemPromptOptions: { selectedTools: ["find", "grep"] },
});
const cwd = await mkdtemp(join(tmpdir(), "my-pi-tool-routing-"));
try {
let notification = "";
await command.handler("", {
cwd,
getSystemPrompt: () => "base prompt",
getSystemPromptOptions: () => ({ selectedTools: ["find", "grep"] }),
ui: {
notify(message: string) {
notification = message;
},
},
});
const outputPath = getSystemPromptDumpPath(cwd);
const dumped = await readFile(outputPath, "utf8");
assert.match(dumped, /^prompt already modified by an earlier extension/);
assert.match(dumped, /<!-- my-pi-tool-routing -->/);
assert.match(notification, new RegExp(outputPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
} finally {
await rm(cwd, { recursive: true, force: true });
}
});