diff --git a/AGENTS.md b/AGENTS.md index 9519caf..9e98acd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 子进程,经 `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`;日志保留在 `/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` 渲染 `` 分区、`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`。 - 上游来源: - 初始导入快照:`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` 渲染为 `` 分区,不得替换 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/` 源文件。不得安装缺失组件或改写受管块之外的用户配置;卸载不得删除用户记忆数据,其他共享机器级工具保持不变。 - `/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`(跨工具工作流规则,渲染为 ``)、`sections.jsonc`(分区顺序)。 +- 其他组合包扩展不得返回 `systemPrompt`,也不得在 `context` 钩子中注入提示性消息;需要动态提示时只能写入 `event.systemPromptOptions.sections.` 并在 `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 目录下扩展运行时产生的数据——`/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 目录下扩展运行时产生的数据——`/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 同名闭合),不能匹配带属性的标签:`` 嵌套在 `` 内,只有裸标签才是分区边界。 -- 封面文件是已安装组合包内的 `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` 分区。 +- 封面拼接边界必须是独占一行的裸标签,不能匹配带属性的标签:`` 嵌套在 `` 内,只有裸标签才是分区边界。 +- `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` 开发约定 diff --git a/README.md b/README.md index 89f83aa..ff94ac2 100644 --- a/README.md +++ b/README.md @@ -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` 在每轮开始前按当前激活工具渲染为 `` 分区。代码结构、调用关系和待修改 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 目录),同时**保留其余全部分区及其原始顺序**:``、``、``、``、``、`` 都不会被删除或重新排序。 +`extensions/system-prompt.ts` 是组合包唯一的系统提示词出口,排在 `pi.extensions` 最后加载。提示词内容全部由 `config/prompt/` 管理:`preamble.md` 替换 Pi 默认开场白(其余 ``、``、``、``、`` 等分区及顺序原样保留),`tools.jsonc` 覆盖各工具的简介与规则,`routing.jsonc` 渲染 `` 分区,`sections.jsonc` 决定各扩展贡献分区的顺序。 -实现上使用 Pi 的 `forceSystemPrompt` 投影路径(每轮把渲染结果作为强制定本发送)而不是 `customPrompt` 选项:后者会连带抑制 `tools`/`rules`/`docs` 三个分区,且回填只能追加到末尾,无法保持顺序。封面只在**请求**时应用,不修改 transcript 中已持久化的结构化分区。 +其他扩展不直接改写提示词:pi-chrome、pi-ssh、pi-hermes-memory 等通过 `systemPromptOptions.sections.` 贡献结构化分区,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 diff --git a/config/system-prompt-cover.md b/config/prompt/preamble.md similarity index 100% rename from config/system-prompt-cover.md rename to config/prompt/preamble.md diff --git a/config/prompt/routing.jsonc b/config/prompt/routing.jsonc new file mode 100644 index 0000000..a1ce734 --- /dev/null +++ b/config/prompt/routing.jsonc @@ -0,0 +1,72 @@ +{ + // Cross-tool workflow rules rendered by extensions/system-prompt.ts into the + // 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 "- ". + "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." + } + ] +} diff --git a/config/prompt/sections.jsonc b/config/prompt/sections.jsonc new file mode 100644 index 0000000..a8602e6 --- /dev/null +++ b/config/prompt/sections.jsonc @@ -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"] +} diff --git a/config/prompt/tools.jsonc b/config/prompt/tools.jsonc new file mode 100644 index 0000000..7b0e970 --- /dev/null +++ b/config/prompt/tools.jsonc @@ -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 entry (null hides it) + // "guidelines" — replaces the tool's 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 } + } +} diff --git a/extensions/context-mode-filter.ts b/extensions/context-mode-filter.ts new file mode 100644 index 0000000..82ff752 --- /dev/null +++ b/extensions/context-mode-filter.ts @@ -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; + +export function filterContextModeMessages( + 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[]); +} diff --git a/extensions/context-mode.ts b/extensions/context-mode.ts new file mode 100644 index 0000000..d4b30d4 --- /dev/null +++ b/extensions/context-mode.ts @@ -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 })); +} diff --git a/extensions/fff-override.ts b/extensions/fff-override.ts index f6720f9..64e9d07 100644 --- a/extensions/fff-override.ts +++ b/extensions/fff-override.ts @@ -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)); } diff --git a/extensions/hashline.ts b/extensions/hashline.ts index 92d3c7f..13c7bf3 100644 --- a/extensions/hashline.ts +++ b/extensions/hashline.ts @@ -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 { deployBundleHashlineConfig(getAgentDir()); const { default: hashlineExtension } = await import("../node_modules/pi-hashline-edit/index.ts"); - hashlineExtension(pi); + hashlineExtension(guardExtensionApi(pi)); } diff --git a/extensions/lsp.ts b/extensions/lsp.ts index e792c7c..b63ad16 100644 --- a/extensions/lsp.ts +++ b/extensions/lsp.ts @@ -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)); } diff --git a/extensions/prompt-guard.ts b/extensions/prompt-guard.ts new file mode 100644 index 0000000..7c174c9 --- /dev/null +++ b/extensions/prompt-guard.ts @@ -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; + +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) }; + 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) : {}), + 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; + }, + }); +} diff --git a/extensions/system-prompt-config.ts b/extensions/system-prompt-config.ts new file mode 100644 index 0000000..5516521 --- /dev/null +++ b/extensions/system-prompt-config.ts @@ -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; +} + +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; + toolGuidelines?: Record; + promptGuidelines?: string[]; + sections?: Record; +} + +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(); + +/** + * 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(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 { + 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 = {}; + 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 `` line (`null` hides it), `guidelines` replaces + * the tool's `` 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 `- ` 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, + order: readonly string[], +): Record { + const ordered: Record = {}; + 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 `` 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); +} diff --git a/extensions/system-prompt-cover-config.ts b/extensions/system-prompt-cover-config.ts deleted file mode 100644 index 69a0f5b..0000000 --- a/extensions/system-prompt-cover-config.ts +++ /dev/null @@ -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 `` followed by sections wrapped in tags of - * the same name (for example ``). Nested markup such as - * `` 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); -} diff --git a/extensions/system-prompt-cover.ts b/extensions/system-prompt-cover.ts deleted file mode 100644 index 7fc0cb0..0000000 --- a/extensions/system-prompt-cover.ts +++ /dev/null @@ -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) }; - }); -} diff --git a/extensions/system-prompt.ts b/extensions/system-prompt.ts new file mode 100644 index 0000000..5791936 --- /dev/null +++ b/extensions/system-prompt.ts @@ -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 `` 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 `` 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(); + + 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("")) { + // Degrade path: the prompt was frozen by forceSystemPrompt, so the + // contributed section cannot render — append it as plain text. + rendered += `\n\n\n${routingText}\n`; + } + + 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"); + }, + }); +} diff --git a/extensions/tool-routing.ts b/extensions/tool-routing.ts deleted file mode 100644 index 2cf6bb6..0000000 --- a/extensions/tool-routing.ts +++ /dev/null @@ -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 = ""; -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"); - }, - }); -} diff --git a/package.json b/package.json index b365360..e1f61f7 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pi-chrome/UPSTREAM.md b/pi-chrome/UPSTREAM.md index 893eb22..dd5d2c1 100644 --- a/pi-chrome/UPSTREAM.md +++ b/pi-chrome/UPSTREAM.md @@ -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 集成。 diff --git a/pi-chrome/extensions/chrome-profile-bridge/index.ts b/pi-chrome/extensions/chrome-profile-bridge/index.ts index 0b5687c..0f8b8b6 100644 --- a/pi-chrome/extensions/chrome-profile-bridge/index.ts +++ b/pi-chrome/extensions/chrome-profile-bridge/index.ts @@ -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 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. -`; - 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 } + | 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. diff --git a/pi-hermes-memory/UPSTREAM.md b/pi-hermes-memory/UPSTREAM.md index 3d9f099..39b745e 100644 --- a/pi-hermes-memory/UPSTREAM.md +++ b/pi-hermes-memory/UPSTREAM.md @@ -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` 文本拼接行为。 ### 工具与常驻 diff --git a/pi-hermes-memory/src/index.ts b/pi-hermes-memory/src/index.ts index a23cf12..c7c8be1 100644 --- a/pi-hermes-memory/src/index.ts +++ b/pi-hermes-memory/src/index.ts @@ -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).memory_policy = promptContext; return; } diff --git a/pi-hermes-memory/tests/prompt-injection.test.ts b/pi-hermes-memory/tests/prompt-injection.test.ts index a446d40..f71d16d 100644 --- a/pi-hermes-memory/tests/prompt-injection.test.ts +++ b/pi-hermes-memory/tests/prompt-injection.test.ts @@ -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", ); }); diff --git a/pi-permission-system/docs/architecture/architecture.md b/pi-permission-system/docs/architecture/architecture.md index 8ec85ae..a71ccfb 100644 --- a/pi-permission-system/docs/architecture/architecture.md +++ b/pi-permission-system/docs/architecture/architecture.md @@ -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 diff --git a/pi-permission-system/src/handlers/before-agent-start.ts b/pi-permission-system/src/handlers/before-agent-start.ts index 105251e..39d0a1d 100644 --- a/pi-permission-system/src/handlers/before-agent-start.ts +++ b/pi-permission-system/src/handlers/before-agent-start.ts @@ -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, diff --git a/pi-permission-system/src/skill-prompt-sanitizer.ts b/pi-permission-system/src/skill-prompt-sanitizer.ts index c5df0fb..7666172 100644 --- a/pi-permission-system/src/skill-prompt-sanitizer.ts +++ b/pi-permission-system/src/skill-prompt-sanitizer.ts @@ -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 ``. + */ +export function resolveStructuredSkillEntries( + skills: readonly StructuredSkillLike[], + permissionManager: SkillPermissionChecker, + agentName: string | null, + normalizer: PathNormalizer, +): { skills: StructuredSkillLike[]; entries: SkillPromptEntry[] } { + const permissionCache = new Map(); + 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[], diff --git a/pi-permission-system/test/handlers/before-agent-start.test.ts b/pi-permission-system/test/handlers/before-agent-start.test.ts index 27b54ae..6bcc76b 100644 --- a/pi-permission-system/test/handlers/before-agent-start.test.ts +++ b/pi-permission-system/test/handlers/before-agent-start.test.ts @@ -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", + ]); + }); }); diff --git a/pi-permission-system/test/skill-prompt-sanitizer.test.ts b/pi-permission-system/test/skill-prompt-sanitizer.test.ts index 5ec2620..5ad4612 100644 --- a/pi-permission-system/test/skill-prompt-sanitizer.test.ts +++ b/pi-permission-system/test/skill-prompt-sanitizer.test.ts @@ -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 = {}, + ): Record => ({ + 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", () => { diff --git a/pi-rtk-optimizer/CHANGELOG.md b/pi-rtk-optimizer/CHANGELOG.md index 29d34dd..c09d087 100644 --- a/pi-rtk-optimizer/CHANGELOG.md +++ b/pi-rtk-optimizer/CHANGELOG.md @@ -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 `/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 diff --git a/pi-rtk-optimizer/src/index.test.ts b/pi-rtk-optimizer/src/index.test.ts index e704d89..c8ddac5 100644 --- a/pi-rtk-optimizer/src/index.test.ts +++ b/pi-rtk-optimizer/src/index.test.ts @@ -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-specific instructions", - ].join("\n"); + const handlers: Record = {}; + 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"); - - 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 () => { diff --git a/pi-rtk-optimizer/src/index.ts b/pi-rtk-optimizer/src/index.ts index d633420..d97c8a0 100644 --- a/pi-rtk-optimizer/src/index.ts +++ b/pi-rtk-optimizer/src/index.ts @@ -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("./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) => { diff --git a/pi-rtk-optimizer/src/types-shims.d.ts b/pi-rtk-optimizer/src/types-shims.d.ts index 9cb1719..3304be5 100644 --- a/pi-rtk-optimizer/src/types-shims.d.ts +++ b/pi-rtk-optimizer/src/types-shims.d.ts @@ -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; + }, ctx: ExtensionContext, ) => MaybePromise<{ systemPrompt: string } | Record | void>, ): void; diff --git a/pi-ssh/UPSTREAM.md b/pi-ssh/UPSTREAM.md index 1e498cc..a27b41f 100644 --- a/pi-ssh/UPSTREAM.md +++ b/pi-ssh/UPSTREAM.md @@ -10,3 +10,7 @@ 纯 `ssh2` 连接管理在设计时参考了 中 `@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` 控制。 diff --git a/pi-ssh/index.ts b/pi-ssh/index.ts index 0469586..9211d4a 100644 --- a/pi-ssh/index.ts +++ b/pi-ssh/index.ts @@ -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 } + | undefined; + if (!promptOptions) return; // hosts without structured options just skip the section + (promptOptions.sections ??= {}).remote_ssh = guidance; }); } diff --git a/tests/bundle-config.test.ts b/tests/bundle-config.test.ts index dedcb8b..b0f6d65 100644 --- a/tests/bundle-config.test.ts +++ b/tests/bundle-config.test.ts @@ -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; diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index 3e41a29..e3c837f 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -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"), diff --git a/tests/system-prompt-cover.test.ts b/tests/system-prompt-cover.test.ts deleted file mode 100644 index b87bce6..0000000 --- a/tests/system-prompt-cover.test.ts +++ /dev/null @@ -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, - "", - "- read: reads files", - "", - "", - "- Be concise", - "", - "", - "", - " ", - " s", - " ", - "", - "", - "", - '', - "Nested content with a -looking line is not a tag.", - "", - "", - "", - "/work", - "", - ].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")); - 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"))); - 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 inside the bare tag, so the - // boundary must be the first *bare* tag line and the nested markup must survive verbatim. - const rendered = [ - "ORIGINAL COVER", - "", - "- read: reads files", - "", - "", - '', - "nested instructions", - "", - "", - "", - "/work", - "", - ].join("\n"); - - const spliced = spliceCover(rendered, "NEW"); - assert.equal(tagsOf(spliced).join(","), "tools,project_context,cwd"); - assert.ok(spliced.startsWith("NEW\n")); - assert.ok( - spliced.includes('\nnested instructions\n'), - "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 }); - } -}); diff --git a/tests/system-prompt.test.ts b/tests/system-prompt.test.ts new file mode 100644 index 0000000..b710d5e --- /dev/null +++ b/tests/system-prompt.test.ts @@ -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 { + 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, + "", + "- read: reads files", + "", + "", + "- Be concise", + "", + "", + '', + "Nested content with a -looking line is not a tag.", + "", + "", + "", + "/work", + "", + ].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")); + 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"))); +}); + +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(`\n${snippets.map(([n, s]) => `- ${n}: ${s}`).join("\n")}\n`); + } + const rules = [ + ...(options.promptGuidelines ?? []), + ...Object.entries(options.toolGuidelines ?? {}).flatMap(([name, lines]) => + lines.map((line) => `${name}: ${line}`), + ), + ]; + if (rules.length > 0) parts.push(`\n${rules.join("\n")}\n`); + parts.push("\n/work\n"); + for (const [name, text] of Object.entries(options.sections ?? {})) { + parts.push(`<${name}>\n${text}\n`); + } + return parts.join("\n"); +} + +function makeEvent(options: FakePromptOptions | undefined) { + return { + systemPromptOptions: options, + get systemPrompt() { + return options ? renderPiPrompt(options) : "PI DEFAULT PREAMBLE\n\n- x\n"; + }, + }; +} + +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, /\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("") < output.indexOf(""), + "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\nkept\n", + }; + 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 ?? "", //); + assert.match(first?.systemPrompt ?? "", /\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\n- x\n" }; + + const result = composer.handleBeforeAgentStart(event as never, ctx); + const output = result?.systemPrompt ?? ""; + assert.ok(output.startsWith(BUNDLE_PREAMBLE)); + assert.match(output, //); + assert.match(output, /\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 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; + 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\nx\n` }, + ]; + const filtered = filterContextModeMessages(before, after); + assert.equal(filtered.length, 2); + assert.equal(filtered[1].content, "\nx\n"); +}); + +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 }; + 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 } | 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, //); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/tests/tool-routing.test.ts b/tests/tool-routing.test.ts deleted file mode 100644 index 5d3608e..0000000 --- a/tests/tool-routing.test.ts +++ /dev/null @@ -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(//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 } | 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, //); - assert.match(notification, new RegExp(outputPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); - } finally { - await rm(cwd, { recursive: true, force: true }); - } -});