mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
Compare commits
2
Commits
da9908fb6e
...
ba164896d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba164896d2 | ||
|
|
dc4fe92e27 |
@@ -23,14 +23,14 @@
|
|||||||
- `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。
|
- `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。
|
||||||
- `pi-web-search/`:完全自维护的统一在线搜索扩展。对模型只暴露 `web_search` / `web_fetch`,schema 与提示词不含厂商名;`web_search` 由当前主模型(`ctx.model` + `ctx.modelRegistry.complete`)按内置路由规则在 Tavily / Exa / Keenable 间选择并经统一参数翻译调用,`details` 记录实际 provider 与路由方式,决策按会话内 LRU 缓存,路由失败或被选厂商报错按可用性顺序降级;`web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily。`clients/` 为纯 `fetch` 直连:Tavily 走 `api.tavily.com`(必须有 key),Keenable 复刻上游 client 保留 keyless `/public` 端点与 SSRF 防护,Exa 直接对 `mcp.exa.ai/mcp` 发 JSON-RPC(缓存 `mcp-session-id`,404 自动重建会话,key 只走 `x-api-key` 头),不使用 `pi-mcp-adapter` 或任何上游扩展包。
|
- `pi-web-search/`:完全自维护的统一在线搜索扩展。对模型只暴露 `web_search` / `web_fetch`,schema 与提示词不含厂商名;`web_search` 由当前主模型(`ctx.model` + `ctx.modelRegistry.complete`)按内置路由规则在 Tavily / Exa / Keenable 间选择并经统一参数翻译调用,`details` 记录实际 provider 与路由方式,决策按会话内 LRU 缓存,路由失败或被选厂商报错按可用性顺序降级;`web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily。`clients/` 为纯 `fetch` 直连:Tavily 走 `api.tavily.com`(必须有 key),Keenable 复刻上游 client 保留 keyless `/public` 端点与 SSRF 防护,Exa 直接对 `mcp.exa.ai/mcp` 发 JSON-RPC(缓存 `mcp-session-id`,404 自动重建会话,key 只走 `x-api-key` 头),不使用 `pi-mcp-adapter` 或任何上游扩展包。
|
||||||
- `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"` 表达。
|
- `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` 工具,以 `execFile` argv 形式调用机器级 `codegraph` CLI(每次调用为短命进程,无常驻 server/daemon);未初始化项目先经 `ctx.ui.confirm` 确认再 `codegraph init` 并自动重试,拒绝或失败则把结果打回让模型改用 `find`/`grep`/`read`/LSP;结果按 `codegraph.db` mtime 做有界缓存,索引一变即失效。
|
- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp -p <root>` 子进程,经 `extensions/codegraph-client.ts` 的组合包自有 stdio 逐行 JSON-RPC 客户端直连(不依赖 pi-mcp-adapter 或 MCP SDK);serve 进程内置文件监听自动同步索引,无结果缓存。子进程 10 分钟无调用自动关闭,调用中子进程死亡则换新客户端透明重试一次;未初始化项目先经 `ctx.ui.confirm` 确认再 `codegraph init` 并自动重试,拒绝记入会话级 declined 不再重复询问;所有失败(未授权、init 失败、spawn ENOENT、调用失败)都以文本打回让模型改用 `find`/`grep`/`read`/LSP,绝不抛出。`codegraph serve` 可能启动的上游 daemon(`.codegraph/daemon.sock`)在子进程退出后可多驻留 ≤5 分钟,属上游行为,组合包不管理。
|
||||||
- `extensions/plugin-init.ts`:已移除;原 `/plugin_init` 的 CodeGraph 初始化职责由 `extensions/codegraph.ts` 在首次 `codegraph_explore` 调用内按需确认完成。
|
- `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/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。
|
||||||
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,自身不部署、不播种、不迁移任何配置。扩展运行时直接读取已安装组合包内的 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc`;日志保留在 `<agentDir>/my-pi/permission-system/logs/`。旧 `extensions/pi-permission-system/` 位置的残留文件被忽略,不迁移也不清理。项目级 policy 不读取。
|
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,自身不部署、不播种、不迁移任何配置。扩展运行时直接读取已安装组合包内的 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc`;日志保留在 `<agentDir>/my-pi/permission-system/logs/`。旧 `extensions/pi-permission-system/` 位置的残留文件被忽略,不迁移也不清理。项目级 policy 不读取。
|
||||||
- `extensions/lsp.ts`:把组合包内 TypeScript Language Server CLI 物化为绝对命令路径,部署 TypeScript/Kotlin/JDT LS 全局配置并加载 `pi-lsp`。
|
- `extensions/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/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-cover.ts`:把 Pi 默认系统提示词的开头替换为组合包权威 `config/system-prompt-cover.md`(扩展运行时直接读取已安装组合包内文件,不部署到 agent 目录),并用文本拼接保留其余全部分区及其原始顺序。必须注册在 `tool-routing.ts` **之前**:`tool-routing` 会以 `forceSystemPrompt` 压平提示词,在其后改 `systemPromptOptions` 将不再影响本轮请求。封面文件缺失或空白时不干预。
|
- `extensions/system-prompt.ts`:组合包唯一的系统提示词出口,必须排在 `pi.extensions` 最后。每轮读取 `config/prompt/`(`preamble.md` 开场白、`tools.jsonc` 工具简介与规则、`routing.jsonc` 渲染 `<tool_routing>` 分区、`sections.jsonc` 分区顺序),在现代 Pi 上通过修改 `systemPromptOptions` 保留分区结构,旧宿主自动降级为文本拼接,最终返回 `systemPrompt`;检测到 `forceSystemPrompt` 已被设置或存在未登记工具时各提示一次,配置读取失败时警告一次并保持 Pi 默认提示词。提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。
|
||||||
- `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。
|
- `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。
|
||||||
- 上游来源:<https://github.com/tianrendong/pi-chrome>
|
- 上游来源:<https://github.com/tianrendong/pi-chrome>
|
||||||
- 初始导入快照:`017ff4b9a639f0b8b213e58a3f30613fc38edcc8`(`0.15.46`,该 commit 无对应 tag)。
|
- 初始导入快照:`017ff4b9a639f0b8b213e58a3f30613fc38edcc8`(`0.15.46`,该 commit 无对应 tag)。
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
- Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文;FFF 仍负责精确字面搜索,RTK 仍处理未走 Context Mode 的普通输出。
|
- 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、撤销或授权过期时清理。
|
- `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` 读取。
|
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
|
||||||
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索由 `pi-web-search` 内部路由,模型不感知厂商:路由规则为广泛发现、新闻和候选来源偏 Tavily,官方/技术/论文和高确定性来源偏 Exa,中文、站点限定和日期筛选偏 Keenable;路由决策由当前主模型做出,确定性逻辑只处理可用性与失败兜底,不得改写成启发式断路。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
|
- 工具路由规则由 `config/prompt/routing.jsonc` 管理、`extensions/system-prompt.ts` 渲染为 `<tool_routing>` 分区,不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索由 `pi-web-search` 内部路由,模型不感知厂商:路由规则为广泛发现、新闻和候选来源偏 Tavily,官方/技术/论文和高确定性来源偏 Exa,中文、站点限定和日期筛选偏 Keenable;路由决策由当前主模型做出,确定性逻辑只处理可用性与失败兜底,不得改写成启发式断路。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
|
||||||
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具支持交互式 TUI 与具备 Pi extension UI bridge 的 RPC 客户端、顺序执行和最多 8 题;RPC 必须使用标准 `select` / `input` 对话框逐题收集,JSON/print 等无 UI 模式保持拒绝。取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。
|
- `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 顺序与最近淘汰原因。
|
- 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 不能替代每次执行时的授权校验。
|
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。
|
||||||
@@ -87,9 +87,9 @@
|
|||||||
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。
|
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。
|
||||||
- `pi-worktree` 只注册 `/wt` / `/worktree` 斜杠命令,不暴露任何模型可调用工具;其 git 操作由用户显式触发,经 `execFile` argv 直接调用(带超时、不经 shell),按设计绕过 Bash 工具权限策略,不新增权限审批层。
|
- `pi-worktree` 只注册 `/wt` / `/worktree` 斜杠命令,不暴露任何模型可调用工具;其 git 操作由用户显式触发,经 `execFile` argv 直接调用(带超时、不经 shell),按设计绕过 Bash 工具权限策略,不新增权限审批层。
|
||||||
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
|
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
|
||||||
- CodeGraph 是机器级依赖:`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后,扩展才可以 `codegraph init` 当前目录。init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。
|
- CodeGraph 是机器级依赖:`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后,扩展才可以 `codegraph init` 当前目录,拒绝结果在本会话内记住不再询问。init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。运行时每个索引根惰性常驻一个 `codegraph serve --mcp` 子进程(闲置 10 分钟关闭、会话结束统一关闭),其启动的上游 daemon 退出后可多驻留 ≤5 分钟属上游行为。
|
||||||
- `codegraph_explore` 为原生 Pi 工具而非 MCP 工具:每次调用通过 `execFile`/`pi.exec` 以 argv 形式运行 `codegraph explore`(带超时、不经 shell),结果按 `.codegraph/codegraph.db` mtime 缓存;CLI 缺失、init 被拒绝或执行失败都不得抛出或阻止其他工具,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。
|
- `codegraph_explore` 为原生 Pi 工具而非 MCP 工具:每个索引根由 `codegraph serve --mcp -p <root>` 子进程服务,经组合包自有 stdio JSON-RPC 客户端收发(不经 shell、不带 pi-mcp-adapter 或 MCP SDK);serve 的文件监听自动同步索引,无结果缓存。子进程死亡导致调用失败时换新客户端透明重试一次;CLI 缺失、init 被拒绝或服务调用失败都不得抛出或阻止其他工具,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。
|
||||||
- 组合包不加载任何 MCP adapter;Exa 由 `pi-web-search/clients/exa.ts` 以纯 `fetch` JSON-RPC 直连托管端点 `https://mcp.exa.ai/mcp`:`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次。`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。
|
- 组合包不加载任何 MCP adapter 或 MCP SDK;Exa 由 `pi-web-search/clients/exa.ts` 以纯 `fetch` JSON-RPC 直连托管端点 `https://mcp.exa.ai/mcp`:`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次;CodeGraph 由 `extensions/codegraph-client.ts` 以 stdio 逐行 JSON-RPC 直连 `codegraph serve --mcp`。`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。
|
||||||
- 在线搜索对模型只暴露 `web_search` / `web_fetch` 两个统一工具名,不得重新引入按厂商命名的工具;`web_search` 必须经 `router.ts` 的主模型分类选择 provider,`web_fetch` 固定优先 Exa。Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 auth.json 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。
|
- 在线搜索对模型只暴露 `web_search` / `web_fetch` 两个统一工具名,不得重新引入按厂商命名的工具;`web_search` 必须经 `router.ts` 的主模型分类选择 provider,`web_fetch` 固定优先 Exa。Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 auth.json 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。
|
||||||
- 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 与 `$VAR` 间接寻址,`configure.sh search` / `search-auth.mjs` 只输出配置状态,禁止回显 key。旧的 `search.env` 不再被读取,仅由 `--migrate` 一次性导入。
|
- 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 与 `$VAR` 间接寻址,`configure.sh search` / `search-auth.mjs` 只输出配置状态,禁止回显 key。旧的 `search.env` 不再被读取,仅由 `--migrate` 一次性导入。
|
||||||
- `extensions/context-view.ts` 是只读的本地 effective-context observer,只读取 `pi-compaction/src/service.ts` 发布的 projection metrics,不注册生产 `context` writer;`pi-minimal-footer` 优先显示同一 effective usage,snapshot 尚未建立时才回退宿主原始 usage。
|
- `extensions/context-view.ts` 是只读的本地 effective-context observer,只读取 `pi-compaction/src/service.ts` 发布的 projection metrics,不注册生产 `context` writer;`pi-minimal-footer` 优先显示同一 effective usage,snapshot 尚未建立时才回退宿主原始 usage。
|
||||||
@@ -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/` 源文件。不得安装缺失组件或改写受管块之外的用户配置;卸载不得删除用户记忆数据,其他共享机器级工具保持不变。
|
- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件;单独的 `pi update` 只在新版本存在时整体覆盖已安装副本(含 `config/`),`update.sh` 每次成功升级后都会把受管 checkout 还原到已发布版本,两者都不逐字段迁移或合并本机对已安装副本的改动;需要长期保留的自定义必须改仓库 `config/` 源文件。不得安装缺失组件或改写受管块之外的用户配置;卸载不得删除用户记忆数据,其他共享机器级工具保持不变。
|
||||||
- `<agentDir>/my-pi/` 只存放可随时丢弃的运行时状态(日志、缓存),不存放配置;组合包配置一律以仓库 `config/` 为唯一来源并由扩展直接读取。用户数据(记忆、会话、`auth.json`、`models.json`、`settings.json`、`search.env` 等)不得放入该目录。外部 npm 扩展固定路径的 `hashline.json`、`lsp.json` 位于 agent 根目录,是唯一例外(扩展加载时从 `config/` 覆盖写入);旧位置(`extensions/pi-permission-system/`、`settings.json` 的 `toolSearch` 段、agent 根 `SYSTEM_COVER.md`)的残留不迁移也不清理。
|
- `<agentDir>/my-pi/` 只存放可随时丢弃的运行时状态(日志、缓存),不存放配置;组合包配置一律以仓库 `config/` 为唯一来源并由扩展直接读取。用户数据(记忆、会话、`auth.json`、`models.json`、`settings.json`、`search.env` 等)不得放入该目录。外部 npm 扩展固定路径的 `hashline.json`、`lsp.json` 位于 agent 根目录,是唯一例外(扩展加载时从 `config/` 覆盖写入);旧位置(`extensions/pi-permission-system/`、`settings.json` 的 `toolSearch` 段、agent 根 `SYSTEM_COVER.md`)的残留不迁移也不清理。
|
||||||
|
|
||||||
|
## 系统提示词单一所有权
|
||||||
|
|
||||||
|
- `extensions/system-prompt.ts` 是组合包唯一的系统提示词出口,必须排在 `pi.extensions` 最后,也是唯一允许在 `before_agent_start` 返回 `systemPrompt` 的扩展。提示词内容统一由 `config/prompt/` 管理:`preamble.md`(开场白)、`tools.jsonc`(每个工具的简介、规则及第三方工具说明覆盖)、`routing.jsonc`(跨工具工作流规则,渲染为 `<tool_routing>`)、`sections.jsonc`(分区顺序)。
|
||||||
|
- 其他组合包扩展不得返回 `systemPrompt`,也不得在 `context` 钩子中注入提示性消息;需要动态提示时只能写入 `event.systemPromptOptions.sections.<name>` 并在 `sections.jsonc` 登记顺序,静态规则写入 `promptGuidelines` 或该工具在 `tools.jsonc` 中的条目。
|
||||||
|
- 第三方扩展必须经 `extensions/` 下的组合包包装文件加载,由 `extensions/prompt-guard.ts` 的代理 API 拦截其对系统提示词的直接改写,并在注册工具时应用 `tools.jsonc` 的 `description` 覆盖;上游硬编码且无配置开关的注入只能在对应包装文件内处理,并以测试锁定所依赖的上游原文(依赖保持精确版本)。
|
||||||
|
- 新增工具必须在 `tools.jsonc` 登记(覆盖说明,或以 `"inherit": true` 表示已按本组合包审阅上游原文),否则 `tests/system-prompt.test.ts` 失败;`config/prompt/` 中的文字不得引用被策略禁止或被替换的用法。运行时若检测到其他扩展已定死提示词或存在未登记工具,`system-prompt.ts` 会各提示一次。
|
||||||
|
|
||||||
## 组合包配置原则
|
## 组合包配置原则
|
||||||
|
|
||||||
- 本组合包是私有组合包,所有机器上的组合包配置必须一致。组合包自有扩展的全部配置只以仓库 `config/` 为唯一来源,扩展运行时直接读取已安装组合包内的 `config/` 文件,不复制、不播种、不迁移到 agent 目录。
|
- 本组合包是私有组合包,所有机器上的组合包配置必须一致。组合包自有扩展的全部配置只以仓库 `config/` 为唯一来源,扩展运行时直接读取已安装组合包内的 `config/` 文件,不复制、不播种、不迁移到 agent 目录。
|
||||||
@@ -132,7 +139,7 @@
|
|||||||
|
|
||||||
- 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。
|
- 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。
|
||||||
- 保留原项目的 `LICENSE`、版权信息和必要的来源说明。
|
- 保留原项目的 `LICENSE`、版权信息和必要的来源说明。
|
||||||
- agent 目录下扩展运行时产生的数据——`<agentDir>/my-pi/` 可丢弃运行时目录、旧 `extensions/pi-permission-system/` 残留、日志、构建产物、覆盖率目录和依赖目录——属于本地状态,不应提交;`config/` 下的全部文件(`pi-permission-policy.jsonc`、`pi-permission-hard-guards.jsonc`、`tool-search.json`、`rtk-optimizer.json`、`pi-compaction.json`、`pi-worktree.json`、`pi-hermes-memory.json`、`system-prompt-cover.md`、`pi-hashline-edit.json`、`lsp.json`、`p10k.zsh` 等)是组合包权威源配置,必须提交并维护。
|
- agent 目录下扩展运行时产生的数据——`<agentDir>/my-pi/` 可丢弃运行时目录、旧 `extensions/pi-permission-system/` 残留、日志、构建产物、覆盖率目录和依赖目录——属于本地状态,不应提交;`config/` 下的全部文件(`pi-permission-policy.jsonc`、`pi-permission-hard-guards.jsonc`、`tool-search.json`、`rtk-optimizer.json`、`pi-compaction.json`、`pi-worktree.json`、`pi-hermes-memory.json`、`prompt/preamble.md`、`prompt/tools.jsonc`、`prompt/routing.jsonc`、`prompt/sections.jsonc`、`pi-hashline-edit.json`、`lsp.json`、`p10k.zsh` 等)是组合包权威源配置,必须提交并维护。
|
||||||
- 外部 Pi 扩展依赖必须在根 `package.json` 中使用精确版本,自维护扩展必须使用本地 `file:` 依赖,并更新根 `package-lock.json`;不要用仓库级 `.pi/settings.json` 代替组合包依赖。
|
- 外部 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` 无需执行,不得为此新增放行条目。
|
- 需要原生构建的依赖只按锁定版本加入根 `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 安装策略。
|
- Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。
|
||||||
@@ -249,18 +256,17 @@
|
|||||||
- SSH 工具的权限预览必须包含连接请求或当前远端的 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。
|
- SSH 工具的权限预览必须包含连接请求或当前远端的 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。
|
||||||
- `pi-ssh` 只用于从本地项目显式操作服务器,不得自动探测、读取或向系统提示词注入远端 `AGENTS.md`、`CLAUDE.md` 或其他项目说明;远端内容只能由明确且已授权的 `ssh_*` 工具调用获取。
|
- `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 隔离安装验证。未经明确要求不得连接真实服务器或把真实密码写入测试。
|
- 修改 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`,扩展运行时直接读取已安装组合包内文件,不经任何脚本部署。
|
- 扩展入口是 `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 的 `forceSystemPrompt` 投影路径(`before_agent_start` 返回 `{ systemPrompt }`)配合文本拼接来保持分区顺序;不得改用 `customPrompt` 选项。后者会抑制 `tools` / `rules` / `docs` 三个分区,而回填只能追加到末尾,无法恢复原顺序。
|
- 现代 Pi 下必须通过修改规范化 `systemPromptOptions`(`toolSnippets` / `toolGuidelines` / `sections`)保留分区结构,再读取重渲染的 `event.systemPrompt` 拼入封面并返回 `systemPrompt`;宿主不提供结构化 options 或 options 不参与重渲染时自动降级为文本拼接。不得改用 `customPrompt` 选项——它会抑制 `tools` / `rules` / `docs` 分区。
|
||||||
- 不得在 handler 中写 `customPrompt` 或依赖其生效:`forceSystemPrompt` 优先级更高,二者同时存在时 `customPrompt` 会被静默忽略而不报错。
|
- 封面拼接边界必须是独占一行的裸标签,不能匹配带属性的标签:`<project_instructions path="...">` 嵌套在 `<project_context>` 内,只有裸标签才是分区边界。
|
||||||
- 分割边界必须是独占一行的裸标签(带 backreference 同名闭合),不能匹配带属性的标签:`<project_instructions path="...">` 嵌套在 `<project_context>` 内,只有裸标签才是分区边界。
|
- `preamble.md` 缺失、不可读或空白时保持 Pi 默认封面,不得写入空 preamble;`config/prompt/` 解析或校验失败时 `before_agent_start` 警告一次并整体 no-op,绝不抛出。
|
||||||
- 封面文件是已安装组合包内的 `config/system-prompt-cover.md`,不得改名为 `SYSTEM.md` 或移到 agent 根目录:后者会被 Pi 先当成 `customPrompt` 读入,封面扩展拿到的提示词会缺少可回填的分区。
|
- 检测到其他扩展已设置 `forceSystemPrompt`、或激活工具未在 `tools.jsonc` 登记时各提示一次(warning);本扩展返回的 `systemPrompt` 把提示词冻结为最终文本是刻意设计——这也是它必须最后加载的原因。
|
||||||
- 文件缺失、不可读或为空白时必须静默 no-op,保持 Pi 默认封面;不得写入空 preamble,也不得因读取失败阻止其他扩展加载。
|
- `config/prompt/` 随组合包发布,`pi install` / `pi update` 后即就位,无需任何部署 helper;对已安装副本的临时修改由 `pi update` 覆盖。
|
||||||
- 封面文件随组合包发布,`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/`。
|
||||||
- 修改 `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`。
|
|
||||||
|
|
||||||
|
|
||||||
## `pi-ask-user` 开发约定
|
## `pi-ask-user` 开发约定
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
- 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。
|
- 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。
|
||||||
- `context-mode@1.0.169`:通过 Pi adapter 提供 `ctx_*` 工具、上下文隔离和会话连续性。
|
- `context-mode@1.0.169`:通过 Pi adapter 提供 `ctx_*` 工具、上下文隔离和会话连续性。
|
||||||
- 本仓库自维护的 `pi-web-search`:对模型只暴露 `web_search` / `web_fetch` 两个工具;`web_search` 由当前主模型在 Tavily / Exa / Keenable 间路由,`web_fetch` 固定走 Exa,三家均以纯 `fetch` 直连(Exa 为 `mcp.exa.ai` 的 JSON-RPC,不经 MCP adapter)。
|
- 本仓库自维护的 `pi-web-search`:对模型只暴露 `web_search` / `web_fetch` 两个工具;`web_search` 由当前主模型在 Tavily / Exa / Keenable 间路由,`web_fetch` 固定走 Exa,三家均以纯 `fetch` 直连(Exa 为 `mcp.exa.ai` 的 JSON-RPC,不经 MCP adapter)。
|
||||||
- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,每次调用以短命 `codegraph explore` CLI 进程完成,无常驻 server/daemon;未初始化项目经确认后才 `codegraph init`,结果按索引库 mtime 缓存。
|
- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,由组合包自有 stdio JSON-RPC 客户端直连(无 MCP adapter/SDK);serve 的文件监听自动同步索引,无结果缓存;未初始化项目经确认后才 `codegraph init`,拒绝记入会话不再重复询问。
|
||||||
- `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。
|
- `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。
|
||||||
- `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。
|
- `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。
|
||||||
- `pi-hermes-memory/`:从官方 `pi-hermes-memory v0.9.10` 源码导入的持久记忆扩展,提供全局/项目/用户/失败四类记忆、SQLite FTS5 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测与自动 consolidation。
|
- `pi-hermes-memory/`:从官方 `pi-hermes-memory v0.9.10` 源码导入的持久记忆扩展,提供全局/项目/用户/失败四类记忆、SQLite FTS5 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测与自动 consolidation。
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
- 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。
|
- 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。
|
||||||
- 本仓库保留维护的 `pi-subagents` 源码及权限集成实现,但当前已从根组合包依赖和默认扩展加载列表中移除,不会随核心包启用。
|
- 本仓库保留维护的 `pi-subagents` 源码及权限集成实现,但当前已从根组合包依赖和默认扩展加载列表中移除,不会随核心包启用。
|
||||||
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
|
- 本仓库维护的 `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` 导出当前有效提示词。
|
||||||
|
|
||||||
## 安装、升级与卸载
|
## 安装、升级与卸载
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ Tool Search 常驻,保证 policy 指示与模型可见工具一致。
|
|||||||
`config/pi-hermes-memory.json`。
|
`config/pi-hermes-memory.json`。
|
||||||
|
|
||||||
CodeGraph 采用惰性初始化:`codegraph_explore` 在未建立索引的项目上被调用时会先向用户确认,
|
CodeGraph 采用惰性初始化:`codegraph_explore` 在未建立索引的项目上被调用时会先向用户确认,
|
||||||
确认后才运行 `codegraph init` 并自动重试本次探索;不需要手动执行任何初始化命令。
|
确认后才运行 `codegraph init` 并自动重试本次探索,拒绝一次后本会话不再询问;不需要手动执行任何初始化命令。
|
||||||
|
|
||||||
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。
|
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ CodeGraph 的更新或遥测设置。
|
|||||||
|
|
||||||
### 工具与搜索路由
|
### 工具与搜索路由
|
||||||
|
|
||||||
`extensions/tool-routing.ts` 不替换 Pi 默认系统提示词,而是在每轮开始前根据当前激活工具追加简短规则。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
|
`config/prompt/routing.jsonc` 定义跨工具路由规则,由 `extensions/system-prompt.ts` 在每轮开始前按当前激活工具渲染为 `<tool_routing>` 分区。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
|
||||||
|
|
||||||
在线搜索默认按任务只选一个服务:关键词不确定、新闻/近期话题或需要较多候选来源时优先 Tavily;官方/API 文档、版本信息、论文和高确定性事实确认优先 Exa;中文政策、政府公告、中文网页、站点限定和日期筛选优先 Keenable。只有来源质量或不确定性确实需要时才跨服务复核,避免默认把三套结果全部塞进上下文。
|
在线搜索默认按任务只选一个服务:关键词不确定、新闻/近期话题或需要较多候选来源时优先 Tavily;官方/API 文档、版本信息、论文和高确定性事实确认优先 Exa;中文政策、政府公告、中文网页、站点限定和日期筛选优先 Keenable。只有来源质量或不确定性确实需要时才跨服务复核,避免默认把三套结果全部塞进上下文。
|
||||||
|
|
||||||
@@ -145,17 +145,13 @@ CodeGraph 的更新或遥测设置。
|
|||||||
|
|
||||||
使用 `/dump-system-prompt` 可将当前扩展所见的有效提示词写入当前项目的 `.pi-debug/effective-system-prompt.md`。`.pi-debug/` 属于本地诊断输出,默认不提交。
|
使用 `/dump-system-prompt` 可将当前扩展所见的有效提示词写入当前项目的 `.pi-debug/effective-system-prompt.md`。`.pi-debug/` 属于本地诊断输出,默认不提交。
|
||||||
|
|
||||||
### 系统提示词封面
|
### 系统提示词单一入口
|
||||||
|
|
||||||
`extensions/system-prompt-cover.ts` 把 Pi 默认系统提示词的**开头**替换为组合包权威的 `config/system-prompt-cover.md`(扩展直接读取已安装组合包内的该文件,不复制到 agent 目录),同时**保留其余全部分区及其原始顺序**:`<tools>`、`<rules>`、`<docs>`、`<skills>`、`<project_context>`、`<cwd>` 都不会被删除或重新排序。
|
`extensions/system-prompt.ts` 是组合包唯一的系统提示词出口,排在 `pi.extensions` 最后加载。提示词内容全部由 `config/prompt/` 管理:`preamble.md` 替换 Pi 默认开场白(其余 `<tools>`、`<rules>`、`<skills>`、`<project_context>`、`<cwd>` 等分区及顺序原样保留),`tools.jsonc` 覆盖各工具的简介与规则,`routing.jsonc` 渲染 `<tool_routing>` 分区,`sections.jsonc` 决定各扩展贡献分区的顺序。
|
||||||
|
|
||||||
实现上使用 Pi 的 `forceSystemPrompt` 投影路径(每轮把渲染结果作为强制定本发送)而不是 `customPrompt` 选项:后者会连带抑制 `tools`/`rules`/`docs` 三个分区,且回填只能追加到末尾,无法保持顺序。封面只在**请求**时应用,不修改 transcript 中已持久化的结构化分区。
|
其他扩展不直接改写提示词:pi-chrome、pi-ssh、pi-hermes-memory 等通过 `systemPromptOptions.sections.<name>` 贡献结构化分区,pi-rtk-optimizer 通过 `promptGuidelines` 补充规则;第三方扩展(FFF、Hashline、LSP、Context Mode)经 `extensions/` 下的包装文件与 `prompt-guard.ts` 代理加载,拦截直接的提示词改写并按 `tools.jsonc` 应用工具说明覆盖。
|
||||||
|
|
||||||
封面每轮重新读取,因此修改已安装组合包内的 `config/system-prompt-cover.md` 后在**下一轮**即生效,不需要重开会话;已经结束的历史对话不受影响。文件缺失或为空白时不干预,Pi 继续使用自身默认封面。
|
`config/prompt/` 每轮重新读取(按 mtime 缓存),修改已安装组合包内的文件后下一轮即生效;配置解析失败时警告一次并保持 Pi 默认提示词,不阻止会话。该目录由组合包直接维护并随组合包发布;对已安装副本的本地修改不做保护,`pi update` 会直接覆盖;要长期自定义请直接编辑仓库中的 `config/prompt/`。
|
||||||
|
|
||||||
该文件由组合包直接维护并随组合包发布;对已安装副本的本地修改不做保护,`pi update` 会直接覆盖;要长期自定义请直接编辑仓库中的 `config/system-prompt-cover.md`。
|
|
||||||
|
|
||||||
`system-prompt-cover.md` 必须使用这个名字:不能改名为 `SYSTEM.md`,否则 Pi 会先把它当作 `customPrompt` 读入,封面扩展拿到的提示词就已经缺少可供回填的分区了。
|
|
||||||
|
|
||||||
### 接管当前 Chrome
|
### 接管当前 Chrome
|
||||||
|
|
||||||
@@ -273,17 +269,21 @@ key 值支持 auth.json 原生解析语义:`$VAR` / `${VAR}` 环境变量插
|
|||||||
|
|
||||||
### CodeGraph 是原生工具
|
### CodeGraph 是原生工具
|
||||||
|
|
||||||
`extensions/codegraph.ts` 注册原生 `codegraph_explore` 工具,不走 MCP:每次调用以 argv 形式
|
`extensions/codegraph.ts` 注册原生 `codegraph_explore` 工具,不走 MCP adapter/SDK:每个索引根
|
||||||
直接执行机器级 `codegraph explore`(上游保证其输出与 `codegraph_explore` MCP 工具一致),
|
惰性启动一个常驻 `codegraph serve --mcp -p <root>` 子进程,由组合包自有的
|
||||||
进程随调用结束,不维护常驻 server 或 daemon。`projectPath` 缺省为会话 cwd,沿目录向上寻找
|
`extensions/codegraph-client.ts` 以逐行 JSON-RPC over stdio 直连(与 `pi-web-search` 直连 Exa
|
||||||
最近的 `.codegraph/codegraph.db`;`maxFiles` 透传 `--max-files`。
|
同一思路),`initialize` → `tools/list` → 每次调用一次 `tools/call`。`projectPath` 缺省为会话 cwd,
|
||||||
|
沿目录向上寻找最近的 `.codegraph/codegraph.db` 决定索引根;调用参数只透传上游
|
||||||
|
`inputSchema` 声明的属性。
|
||||||
|
|
||||||
结果按 `(索引根, query, maxFiles)` 缓存在扩展内,并以索引库 mtime 作为有效性判据:任何
|
与一次性 `codegraph explore` CLI 不同,serve 进程内置文件监听会自动同步索引,新符号数秒内
|
||||||
重建或同步都会更新 mtime 使旧缓存自动失效,模型对同一区域反复探索时重复调用立即返回。
|
可搜到,因此扩展不再做结果缓存。子进程闲置 10 分钟或会话结束时关闭;调用因子进程死亡失败时
|
||||||
|
换新客户端透明重试一次。serve 可能拉起 CodeGraph 自己的 daemon(`.codegraph/daemon.sock`),
|
||||||
|
子进程退出后最多再驻留 5 分钟——这是上游行为,组合包不管理它。
|
||||||
|
|
||||||
若目标目录没有索引,工具先弹出确认;用户同意后运行 `codegraph init` 并自动重试本次调用,
|
若目标目录没有索引,工具先弹出确认;用户同意后运行 `codegraph init` 并自动重试本次调用,
|
||||||
拒绝或 CLI 不可用/执行失败时把结果打回并提示改用 `find`/`grep`/`read`/LSP,不影响其他扩展。
|
拒绝一次后本会话不再询问;CLI 不可用或服务调用失败时把结果打回并提示改用
|
||||||
索引状态可在项目内自行检查:
|
`find`/`grep`/`read`/LSP,不影响其他扩展。索引状态可在项目内自行检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codegraph status
|
codegraph status
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
// Cross-tool workflow rules rendered by extensions/system-prompt.ts into the
|
||||||
|
// <tool_routing> section. A rule is included when every tool in "whenAll"
|
||||||
|
// and at least one tool in "whenAny" is active; a rule with neither is
|
||||||
|
// always included. Rules render in file order as "- <text>".
|
||||||
|
"version": 1,
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"whenAny": ["codegraph_explore"],
|
||||||
|
"text": "Use codegraph_explore first for architecture, symbol relationships, code flows, and symbols you are about to change. Treat the source it returns as already read for understanding; do not repeat the same exploration with read or search unless information is missing. Before editing, still read the exact target range to get fresh LINE#HASH anchors."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["lsp_definition", "lsp_references", "lsp_hover", "lsp_symbols", "lsp_diagnostics"],
|
||||||
|
"text": "Use LSP for symbol definitions, references, hover/type information, and post-edit diagnostics when the language server supports the file."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["find", "grep", "multi_grep"],
|
||||||
|
"text": "For literal search, narrow in stages instead of requesting a repository-wide dump:\n 1. Use find to identify likely files or directories. Constrain path/glob and keep the result limit small.\n 2. Use grep or multi_grep only inside the narrowed path or file to obtain matching line numbers. Start with a specific or literal pattern, a small context window, and a bounded limit.\n 3. Use read with offset/limit only for the exact matching region when full source is still needed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["find", "grep", "multi_grep"],
|
||||||
|
"text": "Do not run a broad search expected to return hundreds or thousands of files or matches. If results truncate or hit a limit, narrow the path, glob, or pattern instead of increasing the limit or dumping all results."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["bash"],
|
||||||
|
"text": "Use bash for builds, tests, git, package scripts, and other programs. Never search files by running grep, find, rg, or fd through bash; use the find/grep tools."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["ssh_connect"],
|
||||||
|
"text": "Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["ssh_cd"],
|
||||||
|
"text": "Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["ssh_find", "ssh_grep"],
|
||||||
|
"text": "For remote SSH searches, use ssh_find to narrow remote file paths before ssh_grep searches file contents. Keep path and limit bounded; when a result says truncated, narrow the path or pattern instead of increasing the limit. Do not run find, fd, grep, or rg through ssh_bash."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["web_search", "web_fetch"],
|
||||||
|
"text": "Use web_search for current, real-world, or citable web information; provider selection is handled internally. Keep queries focused and use site/published_* filters only when they materially narrow results. Read selected pages with web_fetch instead of repeating similar searches."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAll": ["web_fetch", "ctx_fetch_and_index"],
|
||||||
|
"text": "Read a web page with web_fetch. Use ctx_fetch_and_index only when the content should be indexed into the context-mode knowledge base for repeated ctx_search queries."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["ctx_execute", "ctx_execute_file", "ctx_batch_execute"],
|
||||||
|
"text": "Use Context Mode for logs, test/build output, generated data, large-file exploration/analysis/summarization, and any command whose output may be large or unpredictable. Prefer one ctx_batch_execute over several ctx_execute calls for multi-command research, and print only the derived answer needed for the task."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["ctx_execute_file"],
|
||||||
|
"text": "ctx_execute_file analyzes a file and never edits it; make changes with read + edit. It is confined to the current project root: do not call it for absolute paths outside the workspace, ../ traversal, or symlinks that resolve outside the workspace; host permission approval does not bypass this boundary. Use another explicitly authorized tool for those files."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAll": ["chrome_snapshot", "ctx_execute_file"],
|
||||||
|
"text": "After chrome_snapshot or a Chrome action with includeSnapshot=true, immediately analyze the returned workspace-relative capture path with ctx_execute_file; never use read or cat for the raw JSON. Print only the uids, text, or state needed for the next action, and take a fresh snapshot after any interaction because the previous capture may be stale."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["read"],
|
||||||
|
"text": "Use read only when exact source is needed or the file is small. Before editing, request the smallest useful offset/limit range; do not read a complete large file merely to copy, compare, hash, count, explore, or summarize it."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAll": ["read", "edit"],
|
||||||
|
"text": "For existing-file changes, obtain fresh LINE#HASH anchors with a small read (find/grep output has no anchors), then use anchored edit operations. Reuse fresh anchors returned by successful edits for chained changes; re-read only when anchors are stale or the next target region was not shown."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Stop searching once the current evidence is sufficient. Avoid repeating the same lookup through CodeGraph, find, grep, and read without a concrete information gap."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
// Bundle-authoritative tool guidance, owned by extensions/system-prompt.ts.
|
||||||
|
//
|
||||||
|
// Every tool the bundle can expose must have an entry. Per field:
|
||||||
|
// "snippet" — replaces the tool's one-line <tools> entry (null hides it)
|
||||||
|
// "guidelines" — replaces the tool's <rules> lines
|
||||||
|
// "description" — replaces the tool description at registration; only
|
||||||
|
// applied to third-party tools loaded through a bundle
|
||||||
|
// adapter (in-repo tools edit their own source instead)
|
||||||
|
// An absent field keeps the upstream text. "inherit": true means the
|
||||||
|
// upstream text was reviewed against this bundle and is kept as-is.
|
||||||
|
"version": 1,
|
||||||
|
"tools": {
|
||||||
|
"bash": {
|
||||||
|
"snippet": "Run shell commands for builds, tests, git, package scripts, and other programs; search files with the find/grep tools, not bash"
|
||||||
|
},
|
||||||
|
"read": { "inherit": true },
|
||||||
|
"edit": {
|
||||||
|
"guidelines": [
|
||||||
|
"Use edit with LINE#HASH anchors from the latest read or edit result for all file changes; batch every change to one file into a single edit call.",
|
||||||
|
"find/grep/multi_grep results carry plain line numbers, not LINE#HASH anchors; read the exact target range before editing.",
|
||||||
|
"Never reuse anchors from an earlier read or edit response once a newer one exists for the same file; treat only the most recent response as carrying valid anchors.",
|
||||||
|
"After a successful edit, the returned --- Anchors --- block replaces a re-read for nearby follow-up edits.",
|
||||||
|
"On [E_STALE_ANCHOR], the error lists the stale refs and any content-matched candidate anchors; re-read the file to get current anchors before retrying."
|
||||||
|
],
|
||||||
|
"description": "Patch a text file at `LINE#HASH` anchors copied verbatim from the latest read result or the anchors block of a previous edit. find/grep results have no anchors — read the target range first.\n\nBatch every change to a file into one `edit` call: all operations go in the `edits` array, every edit sets `op`, and all anchors must come from the same pre-edit read. Edits validate against one snapshot and apply together, so line numbers never shift between entries of the same call.\n\nOps:\n- `replace` — replace the single line at `pos`, or the inclusive span `pos`..`end`. `lines` is the complete new content for the whole span; `lines: []` deletes it. Without `end`, exactly one line is replaced no matter how many entries `lines` has.\n- `append` — insert `lines` after `pos`; omit `pos` to append at end of file.\n- `prepend` — insert `lines` before `pos`; omit `pos` to insert at start of file.\n\nExample — single-line and span replace in one call:\n```json\n{ \"path\": \"src/main.ts\", \"edits\": [\n { \"op\": \"replace\", \"pos\": \"12#MQ\", \"lines\": [\"const x = 1;\"] },\n { \"op\": \"replace\", \"pos\": \"5#VR\", \"end\": \"8#QV\", \"lines\": [\n \"function greet(name) {\",\n \" return `Hello, ${name}`;\",\n \"}\"\n ] }\n] }\n```\n\nRules:\n- `lines` is literal file content with exact indentation. Never include `LINE#HASH:` or bare `HH:` prefixes, diff `+`/`-` markers, or a copy of a neighboring line — the `:content` part of an anchor is context for you, not payload, and repeating a boundary line duplicates it in the file.\n- Anchors are opaque: copy them exactly, never compute, shift, or guess one.\n- An anchor may keep the `:content` suffix from read output (`\"12#MQ: const x = 1;\"`). The runtime cross-checks that content against the file — catching hash collisions and recovering whitespace-only drift — so keep it for high-risk anchors such as range endpoints. Copy the content as rendered; if you shorten it, keep the start of the line and mark the cut with `...`.\n- Edits in one call must not overlap or touch adjacent lines — merge such changes into a single edit."
|
||||||
|
},
|
||||||
|
"write": { "inherit": true },
|
||||||
|
"ls": { "inherit": true },
|
||||||
|
"powershell": { "inherit": true },
|
||||||
|
"grep": { "inherit": true },
|
||||||
|
"find": { "inherit": true },
|
||||||
|
"multi_grep": { "inherit": true },
|
||||||
|
"tool_search": { "inherit": true },
|
||||||
|
|
||||||
|
"codegraph_explore": { "inherit": true },
|
||||||
|
"lsp_diagnostics": { "inherit": true },
|
||||||
|
"lsp_definition": { "inherit": true },
|
||||||
|
"lsp_references": { "inherit": true },
|
||||||
|
"lsp_hover": { "inherit": true },
|
||||||
|
"lsp_symbols": { "inherit": true },
|
||||||
|
|
||||||
|
"web_search": { "inherit": true },
|
||||||
|
"web_fetch": { "inherit": true },
|
||||||
|
|
||||||
|
"ctx_execute": { "inherit": true },
|
||||||
|
"ctx_execute_file": { "inherit": true },
|
||||||
|
"ctx_batch_execute": { "inherit": true },
|
||||||
|
"ctx_index": { "inherit": true },
|
||||||
|
"ctx_search": { "inherit": true },
|
||||||
|
"ctx_fetch_and_index": { "inherit": true },
|
||||||
|
"ctx_stats": { "inherit": true },
|
||||||
|
"ctx_doctor": { "inherit": true },
|
||||||
|
"ctx_insight": { "inherit": true },
|
||||||
|
"ctx_upgrade": { "inherit": true },
|
||||||
|
"ctx_purge": { "inherit": true },
|
||||||
|
|
||||||
|
"memory_search": { "inherit": true },
|
||||||
|
"session_search": { "inherit": true },
|
||||||
|
"memory_add": { "inherit": true },
|
||||||
|
"memory_replace": { "inherit": true },
|
||||||
|
"memory_remove": { "inherit": true },
|
||||||
|
"skill_manage": { "inherit": true },
|
||||||
|
"context_tree_query": { "inherit": true },
|
||||||
|
|
||||||
|
"ask_user_question": { "inherit": true },
|
||||||
|
|
||||||
|
"ssh_connect": { "inherit": true },
|
||||||
|
"ssh_cd": { "inherit": true },
|
||||||
|
"ssh_read": { "inherit": true },
|
||||||
|
"ssh_write": { "inherit": true },
|
||||||
|
"ssh_edit": { "inherit": true },
|
||||||
|
"ssh_find": { "inherit": true },
|
||||||
|
"ssh_grep": { "inherit": true },
|
||||||
|
"ssh_bash": { "inherit": true },
|
||||||
|
|
||||||
|
"Agent": { "inherit": true },
|
||||||
|
"get_subagent_result": { "inherit": true },
|
||||||
|
"steer_subagent": { "inherit": true },
|
||||||
|
|
||||||
|
"chrome_launch": { "inherit": true },
|
||||||
|
"chrome_tab": { "inherit": true },
|
||||||
|
"chrome_navigate": { "inherit": true },
|
||||||
|
"chrome_snapshot": { "inherit": true },
|
||||||
|
"chrome_find": { "inherit": true },
|
||||||
|
"chrome_inspect": { "inherit": true },
|
||||||
|
"chrome_wait_for": { "inherit": true },
|
||||||
|
"chrome_screenshot": { "inherit": true },
|
||||||
|
"chrome_click": { "inherit": true },
|
||||||
|
"chrome_type": { "inherit": true },
|
||||||
|
"chrome_fill": { "inherit": true },
|
||||||
|
"chrome_key": { "inherit": true },
|
||||||
|
"chrome_hover": { "inherit": true },
|
||||||
|
"chrome_drag": { "inherit": true },
|
||||||
|
"chrome_tap": { "inherit": true },
|
||||||
|
"chrome_scroll": { "inherit": true },
|
||||||
|
"chrome_evaluate": { "inherit": true },
|
||||||
|
"chrome_list_console_messages": { "inherit": true },
|
||||||
|
"chrome_list_network_requests": { "inherit": true },
|
||||||
|
"chrome_get_network_request": { "inherit": true },
|
||||||
|
"chrome_upload_file": { "inherit": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* Minimal stdio JSON-RPC 2.0 client for `codegraph serve --mcp -p <root>`.
|
||||||
|
*
|
||||||
|
* Same approach as pi-web-search/clients/exa.ts (plain JSON-RPC, no MCP
|
||||||
|
* adapter or SDK): `codegraph serve --mcp` speaks one JSON object per line on
|
||||||
|
* stdio. Unlike the one-shot `codegraph explore` CLI, the serve process runs a
|
||||||
|
* file watcher that keeps the index synced, so newly written symbols become
|
||||||
|
* searchable without a manual `codegraph sync`.
|
||||||
|
*
|
||||||
|
* One client exists per index root; `extensions/codegraph.ts` owns the map,
|
||||||
|
* idle closing, and the one-shot respawn retry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
spawn as nodeSpawn,
|
||||||
|
type ChildProcess,
|
||||||
|
type SpawnOptions,
|
||||||
|
} from "node:child_process";
|
||||||
|
|
||||||
|
const PROTOCOL_VERSION = "2025-06-18";
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
||||||
|
const STDERR_TAIL_LINES = 20;
|
||||||
|
const CLOSE_GRACE_MS = 1_000;
|
||||||
|
|
||||||
|
export type CodegraphSpawn = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: SpawnOptions,
|
||||||
|
) => ChildProcess;
|
||||||
|
|
||||||
|
export interface CodegraphRpcClientOptions {
|
||||||
|
/** Index root — passed to `codegraph serve --mcp -p <root>` and used as cwd. */
|
||||||
|
root: string;
|
||||||
|
/** Test seam: replace the child_process spawn. */
|
||||||
|
spawn?: CodegraphSpawn;
|
||||||
|
/** Default "codegraph". */
|
||||||
|
command?: string;
|
||||||
|
/** Default ["serve", "--mcp", "-p", root]. */
|
||||||
|
args?: string[];
|
||||||
|
/** Per-request timeout; default 120 s. */
|
||||||
|
requestTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingRequest {
|
||||||
|
resolve: (result: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentText(result: Record<string, unknown>): string {
|
||||||
|
const content = Array.isArray(result.content)
|
||||||
|
? (result.content as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
return content
|
||||||
|
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
||||||
|
.map((part) => part.text as string)
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CodegraphRpcClient {
|
||||||
|
private readonly spawnFn: CodegraphSpawn;
|
||||||
|
private readonly command: string;
|
||||||
|
private readonly args: string[];
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
|
||||||
|
private child: ChildProcess | undefined;
|
||||||
|
private stdoutBuffer = "";
|
||||||
|
private stderrLines: string[] = [];
|
||||||
|
private pending = new Map<number, PendingRequest>();
|
||||||
|
private nextId = 1;
|
||||||
|
private startPromise: Promise<void> | undefined;
|
||||||
|
/** null = upstream tool declares no inputSchema.properties → forward all args. */
|
||||||
|
private schemaProps: ReadonlySet<string> | null = null;
|
||||||
|
private deadFlag = false;
|
||||||
|
private closedFlag = false;
|
||||||
|
private deathFinalized = false;
|
||||||
|
private exitDetail = "";
|
||||||
|
private deathReason = "codegraph serve 子进程已退出";
|
||||||
|
|
||||||
|
private readonly options: CodegraphRpcClientOptions;
|
||||||
|
|
||||||
|
constructor(options: CodegraphRpcClientOptions) {
|
||||||
|
this.options = options;
|
||||||
|
this.spawnFn = options.spawn ?? nodeSpawn;
|
||||||
|
this.command = options.command ?? "codegraph";
|
||||||
|
this.args =
|
||||||
|
options.args ?? ["serve", "--mcp", "-p", options.root];
|
||||||
|
this.timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once the child has exited or errored — the handler respawns instead. */
|
||||||
|
get dead(): boolean {
|
||||||
|
return this.deadFlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Property names of the upstream codegraph_explore inputSchema. */
|
||||||
|
schemaPropertyNames(): readonly string[] {
|
||||||
|
return this.schemaProps === null ? [] : [...this.schemaProps];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spawn + initialize + tools/list. Memoized: concurrent callers share it. */
|
||||||
|
start(): Promise<void> {
|
||||||
|
if (!this.startPromise) {
|
||||||
|
this.startPromise = this.doStart();
|
||||||
|
// A dead child during startup rejects this promise; attach a no-op
|
||||||
|
// branch so it never surfaces as an unhandled rejection when a
|
||||||
|
// racing close() kills the child with nobody awaiting start().
|
||||||
|
this.startPromise.catch(() => {});
|
||||||
|
}
|
||||||
|
return this.startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doStart(): Promise<void> {
|
||||||
|
let child: ChildProcess;
|
||||||
|
try {
|
||||||
|
child = this.spawnFn(this.command, this.args, {
|
||||||
|
cwd: this.options.root,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.markDead(
|
||||||
|
`无法启动 codegraph serve:${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
throw this.deathError();
|
||||||
|
}
|
||||||
|
this.child = child;
|
||||||
|
this.attach(child);
|
||||||
|
await this.request("initialize", {
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "my-pi-codegraph", version: "1.0.0" },
|
||||||
|
});
|
||||||
|
this.notify("notifications/initialized");
|
||||||
|
const list = (await this.request("tools/list", {})) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const tools = Array.isArray(list.tools) ? list.tools : [];
|
||||||
|
const tool = tools.find(
|
||||||
|
(entry) => isRecord(entry) && entry.name === "codegraph_explore",
|
||||||
|
);
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error("codegraph serve 未提供 codegraph_explore 工具");
|
||||||
|
}
|
||||||
|
const properties =
|
||||||
|
isRecord(tool) && isRecord(tool.inputSchema)
|
||||||
|
? tool.inputSchema.properties
|
||||||
|
: undefined;
|
||||||
|
this.schemaProps = isRecord(properties)
|
||||||
|
? new Set(Object.keys(properties))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `tools/call codegraph_explore`. Forwards only the argument properties the
|
||||||
|
* upstream inputSchema declares (e.g. `projectPath` is forwarded only when
|
||||||
|
* upstream lists it). Returns the raw result object; the handler passes
|
||||||
|
* `result.content` through verbatim.
|
||||||
|
*/
|
||||||
|
async callExplore(
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
await this.start();
|
||||||
|
const forwarded: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(args)) {
|
||||||
|
if (this.schemaProps === null || this.schemaProps.has(key)) {
|
||||||
|
forwarded[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await this.request(
|
||||||
|
"tools/call",
|
||||||
|
{ name: "codegraph_explore", arguments: forwarded },
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (isRecord(result) && result.isError) {
|
||||||
|
throw new Error(
|
||||||
|
`codegraph_explore 返回错误:${contentText(result) || "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return isRecord(result) ? result : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End stdin, then SIGTERM after a 1 s grace. Idempotent. */
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.closedFlag) return;
|
||||||
|
this.closedFlag = true;
|
||||||
|
const child = this.child;
|
||||||
|
this.failAll(new Error("codegraph serve 客户端已关闭"));
|
||||||
|
if (!child) return;
|
||||||
|
try {
|
||||||
|
child.stdin?.end();
|
||||||
|
} catch {
|
||||||
|
// stdin already closed.
|
||||||
|
}
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}, CLOSE_GRACE_MS);
|
||||||
|
timer.unref();
|
||||||
|
child.once("exit", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
child.once("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private attach(child: ChildProcess): void {
|
||||||
|
child.stdout?.setEncoding("utf8");
|
||||||
|
child.stdout?.on("data", (chunk: string) => {
|
||||||
|
this.stdoutBuffer += chunk;
|
||||||
|
for (;;) {
|
||||||
|
const newline = this.stdoutBuffer.indexOf("\n");
|
||||||
|
if (newline < 0) break;
|
||||||
|
const line = this.stdoutBuffer.slice(0, newline).trim();
|
||||||
|
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
|
||||||
|
if (line) this.handleLine(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr?.setEncoding("utf8");
|
||||||
|
child.stderr?.on("data", (chunk: string) => {
|
||||||
|
for (const line of chunk.split(/\r?\n/u)) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
this.stderrLines.push(line);
|
||||||
|
if (this.stderrLines.length > STDERR_TAIL_LINES) {
|
||||||
|
this.stderrLines.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("error", (error) => {
|
||||||
|
// Spawn failure (e.g. ENOENT) — the message carries the detail.
|
||||||
|
this.markDead(`无法启动 codegraph serve:${error.message}`);
|
||||||
|
});
|
||||||
|
child.once("exit", (code, signal) => {
|
||||||
|
this.deadFlag = true;
|
||||||
|
this.exitDetail =
|
||||||
|
signal !== null ? `signal ${signal}` : `code ${code}`;
|
||||||
|
});
|
||||||
|
// Reject pending on "close" (stdio drained), not "exit" — otherwise the
|
||||||
|
// final stderr lines may not have been delivered yet.
|
||||||
|
child.once("close", () => {
|
||||||
|
this.markDead(`codegraph serve 子进程退出(${this.exitDetail})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLine(line: string): void {
|
||||||
|
let message: unknown;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return; // Not JSON — server chatter; ignore.
|
||||||
|
}
|
||||||
|
if (!isRecord(message)) return;
|
||||||
|
|
||||||
|
// Server → client request: we implement no methods; reply -32601.
|
||||||
|
if (typeof message.method === "string" && message.id !== undefined) {
|
||||||
|
this.sendRaw({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: message.id,
|
||||||
|
error: { code: -32601, message: "Method not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Response to one of our requests.
|
||||||
|
if (message.id !== undefined) {
|
||||||
|
const entry = this.pending.get(Number(message.id));
|
||||||
|
if (!entry) return;
|
||||||
|
if (message.error !== undefined) {
|
||||||
|
const detail = isRecord(message.error)
|
||||||
|
? (message.error.message ?? JSON.stringify(message.error))
|
||||||
|
: String(message.error);
|
||||||
|
entry.reject(new Error(`codegraph serve ${String(detail)}`));
|
||||||
|
} else {
|
||||||
|
entry.resolve(message.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Anything else (server notifications) is ignored.
|
||||||
|
}
|
||||||
|
|
||||||
|
private request(
|
||||||
|
method: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (this.deadFlag || this.closedFlag) {
|
||||||
|
return Promise.reject(this.deathError());
|
||||||
|
}
|
||||||
|
const stdin = this.child?.stdin;
|
||||||
|
if (!this.child || !stdin || stdin.destroyed) {
|
||||||
|
return Promise.reject(new Error("codegraph serve 子进程不可用"));
|
||||||
|
}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return Promise.reject(new Error("codegraph serve 请求已中止"));
|
||||||
|
}
|
||||||
|
const id = this.nextId++;
|
||||||
|
return new Promise<unknown>((resolve, reject) => {
|
||||||
|
let onAbort: (() => void) | undefined;
|
||||||
|
const finish = (action: () => void): void => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
||||||
|
this.pending.delete(id);
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
finish(() =>
|
||||||
|
reject(new Error(`codegraph serve 请求 ${method} 超时`)),
|
||||||
|
);
|
||||||
|
}, this.timeoutMs);
|
||||||
|
timer.unref();
|
||||||
|
onAbort = () => {
|
||||||
|
this.notify("notifications/cancelled", {
|
||||||
|
requestId: id,
|
||||||
|
reason: "aborted",
|
||||||
|
});
|
||||||
|
finish(() => reject(new Error("codegraph serve 请求已中止")));
|
||||||
|
};
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
this.pending.set(id, {
|
||||||
|
resolve: (result) => finish(() => resolve(result)),
|
||||||
|
reject: (error) => finish(() => reject(error)),
|
||||||
|
});
|
||||||
|
this.sendRaw({ jsonrpc: "2.0", id, method, params });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(method: string, params?: Record<string, unknown>): void {
|
||||||
|
this.sendRaw({ jsonrpc: "2.0", method, params });
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendRaw(message: Record<string, unknown>): void {
|
||||||
|
try {
|
||||||
|
this.child?.stdin?.write(`${JSON.stringify(message)}\n`);
|
||||||
|
} catch {
|
||||||
|
// stdin already closed — the exit handler will fail pending calls.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markDead(reason: string): void {
|
||||||
|
if (this.deathFinalized) return;
|
||||||
|
this.deathFinalized = true;
|
||||||
|
this.deadFlag = true;
|
||||||
|
const tail = this.stderrLines.join("\n").trim();
|
||||||
|
this.deathReason = tail ? `${reason};stderr: ${tail}` : reason;
|
||||||
|
this.failAll(this.deathError());
|
||||||
|
}
|
||||||
|
|
||||||
|
private deathError(): Error {
|
||||||
|
return new Error(
|
||||||
|
this.closedFlag && !this.deadFlag
|
||||||
|
? "codegraph serve 客户端已关闭"
|
||||||
|
: this.deathReason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private failAll(error: Error): void {
|
||||||
|
const pending = [...this.pending.values()];
|
||||||
|
this.pending.clear();
|
||||||
|
for (const entry of pending) entry.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Pure filter applied after context-mode's `context` handler: its upstream
|
||||||
|
* build appends one `{ role: "user", content }` message whose parts are
|
||||||
|
* `"\n\n"`-joined and whose first part is a routing anchor that duplicates —
|
||||||
|
* and contradicts — the bundle-owned `config/prompt/routing.jsonc`. The anchor
|
||||||
|
* part is stripped; remaining parts (active memory, resume snapshot) stay.
|
||||||
|
* No other message is touched; when nothing changes the input array is
|
||||||
|
* returned unchanged (same reference).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CONTEXT_MODE_ANCHOR_PREFIX = "context-mode active. Hierarchy:";
|
||||||
|
|
||||||
|
type MessageLike = { role?: unknown; content?: unknown } & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function filterContextModeMessages<T extends MessageLike>(
|
||||||
|
before: readonly T[],
|
||||||
|
after: readonly T[],
|
||||||
|
): T[] {
|
||||||
|
if (after.length <= before.length) return after as T[];
|
||||||
|
let result: T[] | null = null;
|
||||||
|
for (let i = after.length - 1; i >= before.length; i--) {
|
||||||
|
const message = after[i];
|
||||||
|
if (message === null || typeof message !== "object") continue;
|
||||||
|
if (message.role !== "user" || typeof message.content !== "string") continue;
|
||||||
|
const parts = message.content.split("\n\n");
|
||||||
|
const kept = parts.filter((part) => !part.startsWith(CONTEXT_MODE_ANCHOR_PREFIX));
|
||||||
|
if (kept.length === parts.length) continue;
|
||||||
|
result ??= after.slice();
|
||||||
|
if (kept.length === 0) {
|
||||||
|
result.splice(i, 1);
|
||||||
|
} else {
|
||||||
|
result[i] = { ...message, content: kept.join("\n\n") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result ?? (after as T[]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
// The adapter is a prebuilt .js bundle, not a TypeScript source.
|
||||||
|
import piExtension from "../node_modules/context-mode/build/adapters/pi/extension.js";
|
||||||
|
|
||||||
|
import { filterContextModeMessages } from "./context-mode-filter.ts";
|
||||||
|
import { guardExtensionApi } from "./prompt-guard.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load context-mode through the prompt guard: the bundle keeps sole ownership
|
||||||
|
* of the system prompt, so the upstream routing anchor appended to the
|
||||||
|
* `context` message list is filtered out (bundle routing lives in
|
||||||
|
* `config/prompt/routing.jsonc`), while upstream active-memory/resume parts
|
||||||
|
* pass through untouched. The upstream code this filter depends on is pinned
|
||||||
|
* by `tests/system-prompt.test.ts` against the exact `context-mode` version.
|
||||||
|
*/
|
||||||
|
export default function bundledContextModeExtension(pi: ExtensionAPI): void {
|
||||||
|
piExtension(guardExtensionApi(pi, { contextFilter: filterContextModeMessages }));
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import fffExtension from "../node_modules/@ff-labs/pi-fff/src/index.ts";
|
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. */
|
/** Load FFF as the bundle's canonical `find` and `grep` implementation. */
|
||||||
export default function fffOverrideExtension(pi: ExtensionAPI): void {
|
export default function fffOverrideExtension(pi: ExtensionAPI): void {
|
||||||
process.env.PI_FFF_MODE = "override";
|
process.env.PI_FFF_MODE = "override";
|
||||||
fffExtension(pi);
|
fffExtension(guardExtensionApi(pi));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import { deployBundleHashlineConfig } from "./hashline-config.ts";
|
import { deployBundleHashlineConfig } from "./hashline-config.ts";
|
||||||
|
import { guardExtensionApi } from "./prompt-guard.ts";
|
||||||
|
|
||||||
/** Deploy bundle defaults before pi-hashline-edit reads its module-level config. */
|
/**
|
||||||
|
* Deploy bundle defaults before pi-hashline-edit reads its module-level config.
|
||||||
|
* The guard applies the catalog's `edit` description override and keeps the
|
||||||
|
* third-party extension from writing the system prompt directly.
|
||||||
|
*/
|
||||||
export default async function bundledHashlineExtension(pi: ExtensionAPI): Promise<void> {
|
export default async function bundledHashlineExtension(pi: ExtensionAPI): Promise<void> {
|
||||||
deployBundleHashlineConfig(getAgentDir());
|
deployBundleHashlineConfig(getAgentDir());
|
||||||
const { default: hashlineExtension } = await import("../node_modules/pi-hashline-edit/index.ts");
|
const { default: hashlineExtension } = await import("../node_modules/pi-hashline-edit/index.ts");
|
||||||
hashlineExtension(pi);
|
hashlineExtension(guardExtensionApi(pi));
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@ import lspExtension from "../node_modules/pi-lsp/extensions/pi-lsp/index.ts";
|
|||||||
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { deployBundleLspConfig } from "./lsp-config.ts";
|
import { deployBundleLspConfig } from "./lsp-config.ts";
|
||||||
|
import { guardExtensionApi } from "./prompt-guard.ts";
|
||||||
|
|
||||||
const TYPESCRIPT_LANGUAGE_SERVER_CLI = fileURLToPath(
|
const TYPESCRIPT_LANGUAGE_SERVER_CLI = fileURLToPath(
|
||||||
new URL("../node_modules/typescript-language-server/lib/cli.mjs", import.meta.url),
|
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,
|
nodeBinary: process.execPath,
|
||||||
serverCliPath: TYPESCRIPT_LANGUAGE_SERVER_CLI,
|
serverCliPath: TYPESCRIPT_LANGUAGE_SERVER_CLI,
|
||||||
});
|
});
|
||||||
lspExtension(pi);
|
lspExtension(guardExtensionApi(pi));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
import { loadToolCatalog, type ToolCatalog } from "./system-prompt-config.ts";
|
||||||
|
|
||||||
|
type AnyMessage = { role?: string; content?: unknown } & Record<string, unknown>;
|
||||||
|
|
||||||
|
type AnyHandler = (event: never, ctx: never) => unknown;
|
||||||
|
|
||||||
|
export interface PromptGuardOptions {
|
||||||
|
/**
|
||||||
|
* Post-process the messages a wrapped `context` handler leaves behind.
|
||||||
|
* Receives the message list snapshot from before the handler ran and the
|
||||||
|
* list after; returns the list the handler's result should report.
|
||||||
|
*/
|
||||||
|
contextFilter?: (before: AnyMessage[], after: AnyMessage[]) => AnyMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy an `ExtensionAPI` for third-party extensions so they cannot bypass the
|
||||||
|
* bundle's single system-prompt owner (`extensions/system-prompt.ts`):
|
||||||
|
*
|
||||||
|
* - `registerTool` applies the tool's `description` override from
|
||||||
|
* `config/prompt/tools.jsonc` when the catalog carries one (lazily loaded;
|
||||||
|
* a catalog error keeps the upstream description).
|
||||||
|
* - `before_agent_start` handlers keep every returned field except
|
||||||
|
* `systemPrompt`, which is dropped.
|
||||||
|
* - `context` handlers are post-processed through `opts.contextFilter`.
|
||||||
|
* - Everything else passes through unchanged.
|
||||||
|
*/
|
||||||
|
export function guardExtensionApi(pi: ExtensionAPI, opts: PromptGuardOptions = {}): ExtensionAPI {
|
||||||
|
let catalogCache: ToolCatalog | null | undefined;
|
||||||
|
const catalog = (): ToolCatalog | null => {
|
||||||
|
if (catalogCache === undefined) {
|
||||||
|
try {
|
||||||
|
catalogCache = loadToolCatalog();
|
||||||
|
} catch {
|
||||||
|
catalogCache = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return catalogCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerTool: ExtensionAPI["registerTool"] = (tool) => {
|
||||||
|
const entry = catalog()?.tools[tool.name];
|
||||||
|
if (entry !== undefined && !("inherit" in entry) && typeof entry.description === "string") {
|
||||||
|
pi.registerTool({ ...tool, description: entry.description });
|
||||||
|
} else {
|
||||||
|
pi.registerTool(tool);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const on: ExtensionAPI["on"] = ((event: string, handler: AnyHandler): unknown => {
|
||||||
|
let wrapped = handler;
|
||||||
|
if (event === "before_agent_start") {
|
||||||
|
wrapped = (async (eventPayload: unknown, ctx: unknown) => {
|
||||||
|
const result = await handler(eventPayload as never, ctx as never);
|
||||||
|
if (result !== null && typeof result === "object" && "systemPrompt" in result) {
|
||||||
|
const rest = { ...(result as Record<string, unknown>) };
|
||||||
|
delete rest.systemPrompt;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}) as AnyHandler;
|
||||||
|
} else if (event === "context" && opts.contextFilter) {
|
||||||
|
const filter = opts.contextFilter;
|
||||||
|
wrapped = (async (eventPayload: { messages?: AnyMessage[] }, ctx: unknown) => {
|
||||||
|
const messages = Array.isArray(eventPayload.messages) ? eventPayload.messages : [];
|
||||||
|
const before = messages.slice();
|
||||||
|
const result = await handler(eventPayload as never, ctx as never);
|
||||||
|
const resultMessages =
|
||||||
|
result !== null &&
|
||||||
|
typeof result === "object" &&
|
||||||
|
Array.isArray((result as { messages?: unknown }).messages)
|
||||||
|
? ((result as { messages: AnyMessage[] }).messages)
|
||||||
|
: messages;
|
||||||
|
const filtered = filter(before, resultMessages);
|
||||||
|
if (filtered === resultMessages) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(result !== null && typeof result === "object" ? (result as Record<string, unknown>) : {}),
|
||||||
|
messages: filtered,
|
||||||
|
};
|
||||||
|
}) as AnyHandler;
|
||||||
|
}
|
||||||
|
// `on` is overloaded per event name; drop to a loosely typed call and
|
||||||
|
// forward whatever the real registration returns (e.g. a disposer).
|
||||||
|
return (pi.on as (name: string, h: AnyHandler) => unknown)(event, wrapped);
|
||||||
|
}) as ExtensionAPI["on"];
|
||||||
|
|
||||||
|
return new Proxy(pi, {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (prop === "registerTool") return registerTool;
|
||||||
|
if (prop === "on") return on;
|
||||||
|
const value = Reflect.get(target, prop, receiver);
|
||||||
|
return typeof value === "function" ? value.bind(target) : value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { readFileSync, statSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helpers for the bundle's single system-prompt owner
|
||||||
|
* (`extensions/system-prompt.ts`). No Pi runtime imports so `node --test` can
|
||||||
|
* exercise everything here directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Entry in `config/prompt/tools.jsonc`. */
|
||||||
|
export type ToolCatalogEntry =
|
||||||
|
| { inherit: true }
|
||||||
|
| { snippet?: string | null; guidelines?: string[]; description?: string };
|
||||||
|
|
||||||
|
export interface ToolCatalog {
|
||||||
|
version: 1;
|
||||||
|
tools: Record<string, ToolCatalogEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingRule {
|
||||||
|
whenAll?: string[];
|
||||||
|
whenAny?: string[];
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingConfig {
|
||||||
|
version: 1;
|
||||||
|
rules: RoutingRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionsConfig {
|
||||||
|
version: 1;
|
||||||
|
order: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of Pi's (normalized) systemPromptOptions this module reads and
|
||||||
|
* mutates. Declared structurally so the installed host version does not leak
|
||||||
|
* into the config helper's types.
|
||||||
|
*/
|
||||||
|
export interface MutablePromptOptions {
|
||||||
|
selectedTools?: string[];
|
||||||
|
toolSnippets?: Record<string, string>;
|
||||||
|
toolGuidelines?: Record<string, string[]>;
|
||||||
|
promptGuidelines?: string[];
|
||||||
|
sections?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMPT_CONFIG_DIR = new URL("../config/prompt/", import.meta.url);
|
||||||
|
|
||||||
|
export function resolvePreamblePath(): string {
|
||||||
|
return fileURLToPath(new URL("preamble.md", PROMPT_CONFIG_DIR));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveToolsPath(): string {
|
||||||
|
return fileURLToPath(new URL("tools.jsonc", PROMPT_CONFIG_DIR));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRoutingPath(): string {
|
||||||
|
return fileURLToPath(new URL("routing.jsonc", PROMPT_CONFIG_DIR));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSectionsPath(): string {
|
||||||
|
return fileURLToPath(new URL("sections.jsonc", PROMPT_CONFIG_DIR));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip `//` and `/* ... *\/` comments while respecting string literals, so a
|
||||||
|
* `//` inside a quoted value (for example `"https://…"`) is never removed.
|
||||||
|
*/
|
||||||
|
export function stripJsoncComments(source: string): string {
|
||||||
|
let out = "";
|
||||||
|
let inString = false;
|
||||||
|
for (let i = 0; i < source.length; i++) {
|
||||||
|
const ch = source[i];
|
||||||
|
if (inString) {
|
||||||
|
out += ch;
|
||||||
|
if (ch === "\\") {
|
||||||
|
if (i + 1 < source.length) out += source[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') inString = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = true;
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/" && source[i + 1] === "/") {
|
||||||
|
while (i < source.length && source[i] !== "\n") i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/" && source[i + 1] === "*") {
|
||||||
|
i += 2;
|
||||||
|
while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) i++;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileCacheEntry {
|
||||||
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCache = new Map<string, FileCacheEntry>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read, strip, parse and validate a JSONC config file, cached by mtime+size so
|
||||||
|
* a `config/prompt/` edit takes effect on the next turn without re-parsing
|
||||||
|
* unchanged files every turn.
|
||||||
|
*/
|
||||||
|
function loadJsonc<T>(path: string, validate: (raw: unknown) => T): T {
|
||||||
|
const stat = statSync(path);
|
||||||
|
const cached = fileCache.get(path);
|
||||||
|
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
||||||
|
return cached.value as T;
|
||||||
|
}
|
||||||
|
const raw = JSON.parse(stripJsoncComments(readFileSync(path, "utf8")));
|
||||||
|
const value = validate(raw);
|
||||||
|
fileCache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, value });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(path: string, message: string): never {
|
||||||
|
throw new Error(`config/prompt/${path}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringArray(value: unknown): value is string[] {
|
||||||
|
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_ENTRY_KEYS = new Set(["inherit", "snippet", "guidelines", "description"]);
|
||||||
|
|
||||||
|
function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
fail("tools.jsonc", `tool "${name}" must be an object`);
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(raw)) {
|
||||||
|
if (!TOOL_ENTRY_KEYS.has(key)) {
|
||||||
|
fail("tools.jsonc", `tool "${name}" has unknown key "${key}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("inherit" in raw) {
|
||||||
|
if (raw.inherit !== true || Object.keys(raw).length !== 1) {
|
||||||
|
fail("tools.jsonc", `tool "${name}": "inherit" must be the only key and equal true`);
|
||||||
|
}
|
||||||
|
return { inherit: true };
|
||||||
|
}
|
||||||
|
const hasField =
|
||||||
|
"snippet" in raw || "guidelines" in raw || "description" in raw;
|
||||||
|
if (!hasField) {
|
||||||
|
fail("tools.jsonc", `tool "${name}" needs "inherit": true or at least one of snippet/guidelines/description`);
|
||||||
|
}
|
||||||
|
if ("snippet" in raw && raw.snippet !== null && typeof raw.snippet !== "string") {
|
||||||
|
fail("tools.jsonc", `tool "${name}".snippet must be a string or null`);
|
||||||
|
}
|
||||||
|
if ("guidelines" in raw && !isStringArray(raw.guidelines)) {
|
||||||
|
fail("tools.jsonc", `tool "${name}".guidelines must be an array of strings`);
|
||||||
|
}
|
||||||
|
if ("description" in raw && typeof raw.description !== "string") {
|
||||||
|
fail("tools.jsonc", `tool "${name}".description must be a string`);
|
||||||
|
}
|
||||||
|
return raw as ToolCatalogEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateToolCatalog(raw: unknown): ToolCatalog {
|
||||||
|
if (!isPlainObject(raw) || raw.version !== 1 || !isPlainObject(raw.tools)) {
|
||||||
|
fail("tools.jsonc", 'expected { "version": 1, "tools": { ... } }');
|
||||||
|
}
|
||||||
|
const tools: Record<string, ToolCatalogEntry> = {};
|
||||||
|
for (const [name, entry] of Object.entries(raw.tools)) {
|
||||||
|
tools[name] = validateToolEntry(name, entry);
|
||||||
|
}
|
||||||
|
return { version: 1, tools };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTING_RULE_KEYS = new Set(["whenAll", "whenAny", "text"]);
|
||||||
|
|
||||||
|
function validateRoutingRule(index: number, raw: unknown): RoutingRule {
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
fail("routing.jsonc", `rule #${index} must be an object`);
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(raw)) {
|
||||||
|
if (!ROUTING_RULE_KEYS.has(key)) {
|
||||||
|
fail("routing.jsonc", `rule #${index} has unknown key "${key}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof raw.text !== "string") {
|
||||||
|
fail("routing.jsonc", `rule #${index}.text must be a string`);
|
||||||
|
}
|
||||||
|
if ("whenAll" in raw && !isStringArray(raw.whenAll)) {
|
||||||
|
fail("routing.jsonc", `rule #${index}.whenAll must be an array of strings`);
|
||||||
|
}
|
||||||
|
if ("whenAny" in raw && !isStringArray(raw.whenAny)) {
|
||||||
|
fail("routing.jsonc", `rule #${index}.whenAny must be an array of strings`);
|
||||||
|
}
|
||||||
|
return raw as RoutingRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRoutingConfig(raw: unknown): RoutingConfig {
|
||||||
|
if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.rules)) {
|
||||||
|
fail("routing.jsonc", 'expected { "version": 1, "rules": [ ... ] }');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
rules: raw.rules.map((rule, index) => validateRoutingRule(index, rule)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_NAME_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
||||||
|
|
||||||
|
function validateSectionsConfig(raw: unknown): SectionsConfig {
|
||||||
|
if (!isPlainObject(raw) || raw.version !== 1 || !isStringArray(raw.order)) {
|
||||||
|
fail("sections.jsonc", 'expected { "version": 1, "order": [ ... ] }');
|
||||||
|
}
|
||||||
|
for (const name of raw.order) {
|
||||||
|
if (!SECTION_NAME_PATTERN.test(name) || name === "preamble") {
|
||||||
|
fail("sections.jsonc", `"${name}" is not a valid section name`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { version: 1, order: raw.order };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadToolCatalog(): ToolCatalog {
|
||||||
|
return loadJsonc(resolveToolsPath(), validateToolCatalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadRoutingRules(): RoutingConfig {
|
||||||
|
return loadJsonc(resolveRoutingPath(), validateRoutingConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSectionsConfig(): SectionsConfig {
|
||||||
|
return loadJsonc(resolveSectionsPath(), validateSectionsConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the bundle-managed prompt preamble.
|
||||||
|
*
|
||||||
|
* Returns `undefined` when the file is absent, unreadable, or blank so the
|
||||||
|
* caller leaves Pi's own default preamble untouched instead of forcing an
|
||||||
|
* empty one.
|
||||||
|
*/
|
||||||
|
export function readPreamble(): string | undefined {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = readFileSync(resolvePreamblePath(), "utf8");
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = raw.replace(/\s+$/, "");
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the tool catalog to the mutable prompt options: a `snippet` string
|
||||||
|
* replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces
|
||||||
|
* the tool's `<rules>` lines; `{inherit:true}` entries are left untouched.
|
||||||
|
* `description` is not applied here — it is a registration-time concern handled
|
||||||
|
* by `extensions/prompt-guard.ts`.
|
||||||
|
*
|
||||||
|
* Returns the selected tool names that have no catalog entry at all.
|
||||||
|
*/
|
||||||
|
export function applyToolCatalog(options: MutablePromptOptions, catalog: ToolCatalog): string[] {
|
||||||
|
const snippets = (options.toolSnippets ??= {});
|
||||||
|
const guidelines = (options.toolGuidelines ??= {});
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const name of options.selectedTools ?? []) {
|
||||||
|
const entry = catalog.tools[name];
|
||||||
|
if (entry === undefined) {
|
||||||
|
missing.push(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ("inherit" in entry) continue;
|
||||||
|
if (entry.snippet !== undefined) {
|
||||||
|
if (entry.snippet === null) {
|
||||||
|
delete snippets[name];
|
||||||
|
} else {
|
||||||
|
snippets[name] = entry.snippet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.guidelines !== undefined) {
|
||||||
|
guidelines[name] = [...entry.guidelines];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the matching routing rules as `- <text>` lines joined by newlines.
|
||||||
|
* A rule applies when every `whenAll` tool is active and at least one
|
||||||
|
* `whenAny` tool is active (a rule with neither always applies). An absent
|
||||||
|
* `selectedTools` means every tool counts as active, matching the previous
|
||||||
|
* tool-routing behavior.
|
||||||
|
*/
|
||||||
|
export function renderRouting(rules: RoutingRule[], selectedTools: string[] | undefined): string {
|
||||||
|
const isActive = (name: string) => selectedTools === undefined || selectedTools.includes(name);
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.whenAll !== undefined && !rule.whenAll.every(isActive)) continue;
|
||||||
|
if (rule.whenAny !== undefined && !rule.whenAny.some(isActive)) continue;
|
||||||
|
lines.push(`- ${rule.text}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the sections map in configured order; contributed sections not
|
||||||
|
* listed keep their original insertion order after the listed ones.
|
||||||
|
*/
|
||||||
|
export function orderSections(
|
||||||
|
sections: Record<string, string>,
|
||||||
|
order: readonly string[],
|
||||||
|
): Record<string, string> {
|
||||||
|
const ordered: Record<string, string> = {};
|
||||||
|
for (const name of order) {
|
||||||
|
if (Object.hasOwn(sections, name)) ordered[name] = sections[name];
|
||||||
|
}
|
||||||
|
for (const [name, value] of Object.entries(sections)) {
|
||||||
|
if (!Object.hasOwn(ordered, name)) ordered[name] = value;
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the newline-delimited boundary before the first top-level prompt
|
||||||
|
* section. Nested markup such as `<project_instructions path="...">` carries
|
||||||
|
* attributes and therefore never matches a bare tag line, so the first match
|
||||||
|
* is the end of the preamble.
|
||||||
|
*/
|
||||||
|
const FIRST_SECTION_BOUNDARY = /\n<[a-z][a-z0-9_-]*>\n/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the rendered prompt's preamble with `preamble`, keeping everything
|
||||||
|
* after the first top-level section byte-for-byte identical.
|
||||||
|
*
|
||||||
|
* Pi builds the preamble at the top of the rendered prompt, so swapping that
|
||||||
|
* prefix preserves section order: options-based replacement (`customPrompt`)
|
||||||
|
* would drop `tools`/`rules`/`docs` and rebuild them at the end. A rendered
|
||||||
|
* prompt without any top-level section degenerates to the preamble alone.
|
||||||
|
*/
|
||||||
|
export function spliceCover(rendered: string, preamble: string): string {
|
||||||
|
const trimmed = preamble.replace(/\s+$/, "");
|
||||||
|
const boundary = rendered.search(FIRST_SECTION_BOUNDARY);
|
||||||
|
if (boundary < 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed + rendered.slice(boundary);
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
/** File name of the bundle-managed system prompt cover inside the bundle's `config/` directory. */
|
|
||||||
export const COVER_FILE_NAME = "system-prompt-cover.md";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches the newline-delimited boundary before the first top-level prompt section.
|
|
||||||
*
|
|
||||||
* Pi renders the system prompt as `<preamble>` followed by sections wrapped in tags of
|
|
||||||
* the same name (for example `<tools>`). Nested markup such as
|
|
||||||
* `<project_instructions path="...">` carries attributes and therefore never matches a
|
|
||||||
* bare tag line, so the first match is the end of the preamble.
|
|
||||||
*/
|
|
||||||
const FIRST_SECTION_BOUNDARY = /\n<[a-z][a-z0-9_-]*>\n/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the cover path inside the installed bundle's `config/` directory —
|
|
||||||
* the single source of truth, read directly at runtime with no deployment step.
|
|
||||||
*/
|
|
||||||
export function resolveCoverPath(): string {
|
|
||||||
return fileURLToPath(new URL(`../config/${COVER_FILE_NAME}`, import.meta.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the bundle-managed cover file.
|
|
||||||
*
|
|
||||||
* Returns `undefined` when the file is absent, unreadable, or blank so the caller can
|
|
||||||
* leave Pi's own default prompt untouched instead of forcing an empty preamble.
|
|
||||||
*/
|
|
||||||
export function readCoverFile(coverPath: string): string | undefined {
|
|
||||||
let raw: string;
|
|
||||||
try {
|
|
||||||
raw = readFileSync(coverPath, "utf8");
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const trimmed = raw.replace(/\s+$/, "");
|
|
||||||
return trimmed.length > 0 ? trimmed : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the rendered prompt's preamble with `cover`, keeping everything after the first
|
|
||||||
* top-level section byte-for-byte identical.
|
|
||||||
*
|
|
||||||
* Pi builds the preamble at the top of the rendered prompt, so swapping that prefix is
|
|
||||||
* what preserves section order: options-based replacement (`customPrompt`) would drop
|
|
||||||
* `tools`/`rules`/`docs` and rebuild them at the end, reordering the prompt. Splicing the
|
|
||||||
* rendered text keeps the existing order untouched.
|
|
||||||
*
|
|
||||||
* A rendered prompt without any top-level section degenerates to the cover alone.
|
|
||||||
*/
|
|
||||||
export function spliceCover(rendered: string, cover: string): string {
|
|
||||||
const trimmed = cover.replace(/\s+$/, "");
|
|
||||||
const boundary = rendered.search(FIRST_SECTION_BOUNDARY);
|
|
||||||
if (boundary < 0) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
return trimmed + rendered.slice(boundary);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
||||||
|
|
||||||
import { readCoverFile, resolveCoverPath, spliceCover } from "./system-prompt-cover-config.ts";
|
|
||||||
|
|
||||||
export { COVER_FILE_NAME, readCoverFile, resolveCoverPath, spliceCover } from "./system-prompt-cover-config.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces Pi's default system prompt preamble with the bundle-managed cover while
|
|
||||||
* preserving every other prompt section in its original position.
|
|
||||||
*
|
|
||||||
* The cover is read on each `before_agent_start`, so edits to `system-prompt-cover.md` take
|
|
||||||
* effect on the next turn of an already-running session rather than requiring a restart.
|
|
||||||
*
|
|
||||||
* Returning `systemPrompt` sets Pi's `forceSystemPrompt`, which is projected onto the
|
|
||||||
* request at send time. That is deliberate: it is the only path that keeps the rendered
|
|
||||||
* section order intact. The options-based alternative (`customPrompt`) would suppress
|
|
||||||
* `tools`, `rules`, and `docs`, and any later re-injection cannot restore their original
|
|
||||||
* positions. Pi still diffs and persists the structured sections in the transcript.
|
|
||||||
*/
|
|
||||||
export default function systemPromptCoverExtension(pi: ExtensionAPI): void {
|
|
||||||
pi.on("before_agent_start", (event) => {
|
|
||||||
const cover = readCoverFile(resolveCoverPath());
|
|
||||||
if (cover === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return { systemPrompt: spliceCover(event.systemPrompt, cover) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { BeforeAgentStartEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyToolCatalog,
|
||||||
|
loadRoutingRules,
|
||||||
|
loadSectionsConfig,
|
||||||
|
loadToolCatalog,
|
||||||
|
orderSections,
|
||||||
|
readPreamble,
|
||||||
|
renderRouting,
|
||||||
|
spliceCover,
|
||||||
|
type MutablePromptOptions,
|
||||||
|
type RoutingConfig,
|
||||||
|
type SectionsConfig,
|
||||||
|
type ToolCatalog,
|
||||||
|
} from "./system-prompt-config.ts";
|
||||||
|
|
||||||
|
const DUMP_RELATIVE_PATH = ".pi-debug/effective-system-prompt.md";
|
||||||
|
|
||||||
|
export function getSystemPromptDumpPath(cwd: string): string {
|
||||||
|
return resolve(cwd, DUMP_RELATIVE_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The options object as Pi 1.3 normalizes it; the vendored types predate `sections`/`forceSystemPrompt`. */
|
||||||
|
type RuntimePromptOptions = MutablePromptOptions & { forceSystemPrompt?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injectable seams so the composer can be exercised under `node --test`
|
||||||
|
* without touching the real `config/prompt/` files.
|
||||||
|
*/
|
||||||
|
export interface SystemPromptComposerDeps {
|
||||||
|
loadToolCatalog?: () => ToolCatalog;
|
||||||
|
loadRoutingRules?: () => RoutingConfig;
|
||||||
|
loadSectionsConfig?: () => SectionsConfig;
|
||||||
|
readPreamble?: () => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemPromptComposer {
|
||||||
|
handleBeforeAgentStart: (
|
||||||
|
event: BeforeAgentStartEvent,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
) => { systemPrompt: string } | undefined;
|
||||||
|
getLastComposed: () => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bundle's single system-prompt owner. It is the only extension allowed to
|
||||||
|
* return `systemPrompt`; every other bundle or third-party extension must
|
||||||
|
* contribute structurally through `event.systemPromptOptions`.
|
||||||
|
*
|
||||||
|
* Per turn it applies `config/prompt/tools.jsonc` (tool snippets/guidelines),
|
||||||
|
* renders `config/prompt/routing.jsonc` into a `<tool_routing>` section, orders
|
||||||
|
* contributed sections per `config/prompt/sections.jsonc`, then reads the
|
||||||
|
* re-rendered prompt and splices in `config/prompt/preamble.md` before
|
||||||
|
* returning the final text.
|
||||||
|
*
|
||||||
|
* If an earlier extension violated the contract by setting
|
||||||
|
* `forceSystemPrompt`, the prompt is already frozen: the composer degrades to
|
||||||
|
* text splicing (preamble + appended `<tool_routing>` when absent) and warns
|
||||||
|
* once. Config load/validation failures warn once and leave Pi's default
|
||||||
|
* prompt untouched — the handler never throws.
|
||||||
|
*/
|
||||||
|
export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}): SystemPromptComposer {
|
||||||
|
const loadCatalog = deps.loadToolCatalog ?? loadToolCatalog;
|
||||||
|
const loadRouting = deps.loadRoutingRules ?? loadRoutingRules;
|
||||||
|
const loadSections = deps.loadSectionsConfig ?? loadSectionsConfig;
|
||||||
|
const loadPreamble = deps.readPreamble ?? readPreamble;
|
||||||
|
|
||||||
|
let lastComposed: string | undefined;
|
||||||
|
let forcedNotified = false;
|
||||||
|
let configErrorNotified = false;
|
||||||
|
const notifiedMissingSets = new Set<string>();
|
||||||
|
|
||||||
|
function handleBeforeAgentStart(
|
||||||
|
event: BeforeAgentStartEvent,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
): { systemPrompt: string } | undefined {
|
||||||
|
const options = event.systemPromptOptions as unknown as RuntimePromptOptions | undefined;
|
||||||
|
const forced = options !== undefined && options.forceSystemPrompt !== undefined;
|
||||||
|
if (forced && ctx.hasUI && !forcedNotified) {
|
||||||
|
forcedNotified = true;
|
||||||
|
ctx.ui.notify(
|
||||||
|
"检测到其他扩展直接改写了系统提示词(违反单一入口规则),本轮降级为文本拼接。",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let catalog: ToolCatalog;
|
||||||
|
let routing: RoutingConfig;
|
||||||
|
let sections: SectionsConfig;
|
||||||
|
let preamble: string | undefined;
|
||||||
|
try {
|
||||||
|
catalog = loadCatalog();
|
||||||
|
routing = loadRouting();
|
||||||
|
sections = loadSections();
|
||||||
|
preamble = loadPreamble();
|
||||||
|
} catch (error) {
|
||||||
|
if (ctx.hasUI && !configErrorNotified) {
|
||||||
|
configErrorNotified = true;
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
ctx.ui.notify(`系统提示词配置加载失败:${detail}`, "warning");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let routingText = "";
|
||||||
|
if (options) {
|
||||||
|
const missing = applyToolCatalog(options, catalog);
|
||||||
|
if (missing.length > 0 && ctx.hasUI) {
|
||||||
|
const key = [...missing].sort().join("\n");
|
||||||
|
if (!notifiedMissingSets.has(key)) {
|
||||||
|
notifiedMissingSets.add(key);
|
||||||
|
ctx.ui.notify(`以下工具未在 config/prompt/tools.jsonc 登记:${missing.join(", ")}`, "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routingText = renderRouting(routing.rules, options.selectedTools);
|
||||||
|
options.sections ??= {};
|
||||||
|
if (routingText.length > 0) {
|
||||||
|
options.sections.tool_routing = routingText;
|
||||||
|
}
|
||||||
|
options.sections = orderSections(options.sections, sections.order);
|
||||||
|
} else {
|
||||||
|
routingText = renderRouting(routing.rules, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered = event.systemPrompt;
|
||||||
|
if (routingText.length > 0 && !rendered.includes("<tool_routing>")) {
|
||||||
|
// Degrade path: the prompt was frozen by forceSystemPrompt, so the
|
||||||
|
// contributed section cannot render — append it as plain text.
|
||||||
|
rendered += `\n\n<tool_routing>\n${routingText}\n</tool_routing>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const final = preamble ? spliceCover(rendered, preamble) : rendered;
|
||||||
|
lastComposed = final;
|
||||||
|
return { systemPrompt: final };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleBeforeAgentStart,
|
||||||
|
getLastComposed: () => lastComposed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function systemPromptExtension(pi: ExtensionAPI): void {
|
||||||
|
const composer = createSystemPromptComposer();
|
||||||
|
|
||||||
|
pi.on("before_agent_start", (event, ctx) => composer.handleBeforeAgentStart(event, ctx));
|
||||||
|
|
||||||
|
pi.registerCommand("dump-system-prompt", {
|
||||||
|
description: "Write the effective Pi system prompt to .pi-debug/effective-system-prompt.md",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const outputPath = getSystemPromptDumpPath(ctx.cwd);
|
||||||
|
const effectivePrompt = composer.getLastComposed() ?? ctx.getSystemPrompt();
|
||||||
|
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, effectivePrompt, "utf8");
|
||||||
|
ctx.ui.notify(`System prompt written to ${outputPath} (${effectivePrompt.length} chars)`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import type { BuildSystemPromptOptions, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
|
|
||||||
const ROUTING_MARKER = "<!-- my-pi-tool-routing -->";
|
|
||||||
const DUMP_RELATIVE_PATH = ".pi-debug/effective-system-prompt.md";
|
|
||||||
|
|
||||||
type SelectedTools = BuildSystemPromptOptions["selectedTools"];
|
|
||||||
|
|
||||||
function hasTool(selectedTools: SelectedTools, name: string): boolean {
|
|
||||||
return selectedTools === undefined || selectedTools.includes(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAnyTool(selectedTools: SelectedTools, names: string[]): boolean {
|
|
||||||
return names.some((name) => hasTool(selectedTools, name));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildToolRoutingSection(selectedTools: SelectedTools): string {
|
|
||||||
const rules: string[] = [];
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "codegraph_explore")) {
|
|
||||||
rules.push(
|
|
||||||
"- Use codegraph_explore first for architecture, symbol relationships, code flows, and symbols you are about to change. Treat source returned by it as already read; do not repeat the same exploration with read or search unless information is missing.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
hasTool(selectedTools, "lsp_definition") ||
|
|
||||||
hasTool(selectedTools, "lsp_references") ||
|
|
||||||
hasTool(selectedTools, "lsp_diagnostics")
|
|
||||||
) {
|
|
||||||
rules.push(
|
|
||||||
"- Use LSP for symbol definitions, references, hover/type information, and post-edit diagnostics when the language server supports the file.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "find") || hasTool(selectedTools, "grep") || hasTool(selectedTools, "multi_grep")) {
|
|
||||||
rules.push(
|
|
||||||
"- For literal search, narrow in stages instead of requesting a repository-wide dump:",
|
|
||||||
" 1. Use find to identify likely files or directories. Constrain path/glob and keep the result limit small.",
|
|
||||||
" 2. Use grep or multi_grep only inside the narrowed path or file to obtain matching line numbers. Start with a specific or literal pattern, a small context window, and a bounded limit.",
|
|
||||||
" 3. Use read with offset/limit only for the exact matching region when full source is still needed.",
|
|
||||||
"- Do not run a broad search expected to return hundreds or thousands of files or matches. If results truncate or hit a limit, narrow the path, glob, or pattern instead of increasing the limit or dumping all results.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "ssh_connect")) {
|
|
||||||
rules.push(
|
|
||||||
"- Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "ssh_cd")) {
|
|
||||||
rules.push(
|
|
||||||
"- Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "ssh_find") || hasTool(selectedTools, "ssh_grep")) {
|
|
||||||
rules.push(
|
|
||||||
"- For remote SSH searches, use ssh_find to narrow remote file paths before ssh_grep searches file contents. Keep path and limit bounded; when a result says truncated, narrow the path or pattern instead of increasing the limit. Do not run find, fd, grep, or rg through ssh_bash.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasAnyTool(selectedTools, ["web_search", "web_fetch"])) {
|
|
||||||
rules.push(
|
|
||||||
"- Use web_search for current, real-world, or citable web information; provider selection is handled internally. Keep queries focused and use site/published_* filters only when they materially narrow results. Then read selected pages with web_fetch instead of repeating similar searches.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "ctx_execute") || hasTool(selectedTools, "ctx_execute_file")) {
|
|
||||||
rules.push(
|
|
||||||
"- Use Context Mode for logs, test/build output, generated data, large-file exploration/analysis/summarization, and any command whose output may be large or unpredictable. Prefer ctx_execute_file over read when deriving an answer from a large file inside the workspace, and print only the derived answer needed for the task.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "ctx_execute_file")) {
|
|
||||||
rules.push(
|
|
||||||
"- ctx_execute_file is confined to the current project root. Do not call it for absolute paths outside the workspace, ../ traversal, or symlinks that resolve outside the workspace; host permission approval does not bypass this Context Mode boundary. Use another explicitly authorized tool for those files.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "chrome_snapshot") && hasTool(selectedTools, "ctx_execute_file")) {
|
|
||||||
rules.push(
|
|
||||||
"- After chrome_snapshot or a Chrome action with includeSnapshot=true, immediately analyze the returned workspace-relative capture path with ctx_execute_file; never use read or cat for the raw JSON. Print only the uids, text, or state needed for the next action, and take a fresh snapshot after any interaction because the previous capture may be stale.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "read")) {
|
|
||||||
rules.push(
|
|
||||||
"- Use read only when exact source is needed or the file is small. Before editing, request the smallest useful offset/limit range; do not read a complete large file merely to copy, compare, hash, count, explore, or summarize it.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "read") && hasTool(selectedTools, "edit")) {
|
|
||||||
rules.push(
|
|
||||||
"- For existing-file changes, obtain fresh LINE#HASH anchors with a small read, then use anchored edit operations. Reuse fresh anchors returned by successful edits for chained changes; re-read only when anchors are stale or the next target region was not shown.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
rules.push(
|
|
||||||
"- Stop searching once the current evidence is sufficient. Avoid repeating the same lookup through CodeGraph, find, grep, and read without a concrete information gap.",
|
|
||||||
);
|
|
||||||
|
|
||||||
return `${ROUTING_MARKER}\n## Tool Routing\n\n${rules.join("\n")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendToolRouting(systemPrompt: string, selectedTools: SelectedTools): string {
|
|
||||||
if (systemPrompt.includes(ROUTING_MARKER)) {
|
|
||||||
return systemPrompt;
|
|
||||||
}
|
|
||||||
return `${systemPrompt}\n\n${buildToolRoutingSection(selectedTools)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSystemPromptDumpPath(cwd: string): string {
|
|
||||||
return resolve(cwd, DUMP_RELATIVE_PATH);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function toolRoutingExtension(pi: ExtensionAPI): void {
|
|
||||||
let lastEffectivePrompt: string | undefined;
|
|
||||||
|
|
||||||
pi.on("before_agent_start", (event) => {
|
|
||||||
lastEffectivePrompt = appendToolRouting(event.systemPrompt, event.systemPromptOptions.selectedTools);
|
|
||||||
return { systemPrompt: lastEffectivePrompt };
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerCommand("dump-system-prompt", {
|
|
||||||
description: "Write the effective Pi system prompt to .pi-debug/effective-system-prompt.md",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
const outputPath = getSystemPromptDumpPath(ctx.cwd);
|
|
||||||
const selectedTools = ctx.getSystemPromptOptions().selectedTools;
|
|
||||||
const effectivePrompt = lastEffectivePrompt ?? appendToolRouting(ctx.getSystemPrompt(), selectedTools);
|
|
||||||
|
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
|
||||||
await writeFile(outputPath, effectivePrompt, "utf8");
|
|
||||||
ctx.ui.notify(`System prompt written to ${outputPath} (${effectivePrompt.length} chars)`, "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+3
-4
@@ -52,7 +52,7 @@
|
|||||||
"./extensions/codegraph.ts",
|
"./extensions/codegraph.ts",
|
||||||
"./pi-rtk-optimizer/index.ts",
|
"./pi-rtk-optimizer/index.ts",
|
||||||
"./extensions/hashline.ts",
|
"./extensions/hashline.ts",
|
||||||
"./node_modules/context-mode/build/adapters/pi/extension.js",
|
"./extensions/context-mode.ts",
|
||||||
"./extensions/lsp.ts",
|
"./extensions/lsp.ts",
|
||||||
"./pi-hermes-memory/src/index.ts",
|
"./pi-hermes-memory/src/index.ts",
|
||||||
"./pi-compaction/index.ts",
|
"./pi-compaction/index.ts",
|
||||||
@@ -64,9 +64,8 @@
|
|||||||
"./pi-ask-user/index.ts",
|
"./pi-ask-user/index.ts",
|
||||||
"./pi-worktree/index.ts",
|
"./pi-worktree/index.ts",
|
||||||
"./pi-chrome/extensions/chrome-profile-bridge/index.ts",
|
"./pi-chrome/extensions/chrome-profile-bridge/index.ts",
|
||||||
"./extensions/system-prompt-cover.ts",
|
"./pi-tool-search/extensions/index.ts",
|
||||||
"./extensions/tool-routing.ts",
|
"./extensions/system-prompt.ts"
|
||||||
"./pi-tool-search/extensions/index.ts"
|
|
||||||
],
|
],
|
||||||
"skills": [
|
"skills": [
|
||||||
"./node_modules/context-mode/skills"
|
"./node_modules/context-mode/skills"
|
||||||
|
|||||||
@@ -19,5 +19,6 @@
|
|||||||
- 每个 Session/目标只保留最新快照;浏览器交互会使陈旧捕获失效,Session 关闭时移除该 Session 目录。
|
- 每个 Session/目标只保留最新快照;浏览器交互会使陈旧捕获失效,Session 关闭时移除该 Session 目录。
|
||||||
- `chrome_find` 返回紧凑的排序匹配,同时把完整结构化快照保留在 Session 本地捕获中,供可选的 `ctx_execute_file` 分析使用。
|
- `chrome_find` 返回紧凑的排序匹配,同时把完整结构化快照保留在 Session 本地捕获中,供可选的 `ctx_execute_file` 分析使用。
|
||||||
- click/type/fill/key 上的 `includeSnapshot=true` 会保存动作后的快照并返回其捕获路径,而不是把快照嵌入工具结果。
|
- 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 集成。
|
同步上游时,请以导入 commit 为基准比较并选择性移植改动。不得整体覆盖这些本地生命周期、隐私和 Context Mode 集成。
|
||||||
|
|||||||
@@ -923,11 +923,12 @@ export default function (pi: ExtensionAPI): void {
|
|||||||
|
|
||||||
pi.on("before_agent_start", (event) => {
|
pi.on("before_agent_start", (event) => {
|
||||||
if (!chromeToolsRegistered || !chromeControlAuthorized()) {
|
if (!chromeToolsRegistered || !chromeControlAuthorized()) {
|
||||||
return { systemPrompt: event.systemPrompt };
|
return;
|
||||||
}
|
}
|
||||||
const primer = `
|
// Contribute structurally: the bundle's single system-prompt owner
|
||||||
<chrome-profile-bridge>
|
// (extensions/system-prompt.ts) composes the final prompt; extensions
|
||||||
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.
|
// 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):
|
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.
|
- 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).
|
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.
|
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.
|
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.
|
8. Run /chrome doctor when in doubt about connectivity or capabilities.`;
|
||||||
</chrome-profile-bridge>`;
|
const promptOptions = event.systemPromptOptions as unknown as
|
||||||
return { systemPrompt: event.systemPrompt + primer };
|
| { sections?: Record<string, string> }
|
||||||
|
| undefined;
|
||||||
|
if (!promptOptions) return; // hosts without structured options just skip the section
|
||||||
|
(promptOptions.sections ??= {}).chrome_profile_bridge = primer;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Shared handlers, dispatched by the unified /chrome command below.
|
// Shared handlers, dispatched by the unified /chrome command below.
|
||||||
|
|||||||
@@ -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` 保持上游原样。
|
- `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` 文本拼接行为。
|
||||||
|
|
||||||
### 工具与常驻
|
### 工具与常驻
|
||||||
|
|
||||||
|
|||||||
@@ -306,10 +306,12 @@ export default function (pi: ExtensionAPI) {
|
|||||||
if (!promptContext) return;
|
if (!promptContext) return;
|
||||||
|
|
||||||
// pi 0.86+ hands handlers a normalized clone of these options and re-renders
|
// 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
|
// prompt a set of named sections; returning `systemPrompt` instead forces the
|
||||||
// rendered text and flattens the sections for every later handler and for
|
// 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
|
// 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
|
// 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.
|
// 0.86+ runner normalizes `sections` onto the clone it passes.
|
||||||
const promptOptions = event.systemPromptOptions;
|
const promptOptions = event.systemPromptOptions;
|
||||||
if (promptOptions && "sections" in promptOptions) {
|
if (promptOptions && "sections" in promptOptions) {
|
||||||
promptOptions.appendSystemPrompt = [promptOptions.appendSystemPrompt, promptContext]
|
(promptOptions.sections as Record<string, string>).memory_policy = promptContext;
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n\n");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
* `before_agent_start` prompt injection (#251).
|
* `before_agent_start` prompt injection (#251).
|
||||||
*
|
*
|
||||||
* pi 0.86+ passes a normalized *clone* of the prompt options and re-renders the
|
* 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
|
* 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
|
* 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
|
* long-lived base options and only apply a returned `systemPrompt` — a mutation
|
||||||
@@ -123,31 +125,38 @@ after(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("before_agent_start prompt injection", () => {
|
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();
|
await registerWith();
|
||||||
const event = { systemPrompt: "base prompt", systemPromptOptions: structuredOptions() };
|
const event = { systemPrompt: "base prompt", systemPromptOptions: structuredOptions() };
|
||||||
|
|
||||||
const result = await emit("before_agent_start", event);
|
const result = await emit("before_agent_start", event);
|
||||||
|
|
||||||
assert.equal(result, undefined, "structured events must not force a full prompt replacement");
|
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();
|
await registerWith();
|
||||||
const event = {
|
const event = {
|
||||||
systemPrompt: "base prompt",
|
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);
|
await emit("before_agent_start", event);
|
||||||
|
|
||||||
const appended = event.systemPromptOptions.appendSystemPrompt;
|
assert.equal(event.systemPromptOptions.sections.other_section, "earlier content");
|
||||||
assert.match(appended, /earlier handler text/);
|
assert.match(event.systemPromptOptions.sections.memory_policy, /memory-policy/);
|
||||||
assert.match(appended, /memory-policy/);
|
assert.equal(
|
||||||
assert.ok(
|
event.systemPromptOptions.appendSystemPrompt,
|
||||||
appended.indexOf("earlier handler text") < appended.indexOf("memory-policy"),
|
"earlier handler text",
|
||||||
"the policy goes after earlier contributions",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -585,7 +585,7 @@ src/
|
|||||||
├── permission-manager.ts Unified intent evaluation
|
├── permission-manager.ts Unified intent evaluation
|
||||||
├── access-intent/ Resolved intent types and resolver
|
├── access-intent/ Resolved intent types and resolver
|
||||||
├── handlers/
|
├── 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
|
│ └── gates/ skill -> path -> bash -> tool pipelines
|
||||||
├── authority/
|
├── authority/
|
||||||
│ ├── authorizer-selection.ts Configured authorizer resolution
|
│ ├── authorizer-selection.ts Configured authorizer resolution
|
||||||
|
|||||||
@@ -4,17 +4,30 @@ import type {
|
|||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import type { PermissionResolver } from "#src/permission-resolver";
|
import type { PermissionResolver } from "#src/permission-resolver";
|
||||||
import type { PermissionSession } from "#src/permission-session";
|
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. */
|
/** Minimal subset of BeforeAgentStartEvent used by this handler. */
|
||||||
interface BeforeAgentStartPayload {
|
interface BeforeAgentStartPayload {
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
|
systemPromptOptions?: {
|
||||||
|
skills?: StructuredSkillLike[] | unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepares permission state before an agent turn without changing Pi's active
|
* 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
|
* tool set. Tool Search is the bundle's sole active-tool controller; this
|
||||||
* handler only refreshes permission state and sanitizes denied skill entries.
|
* 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 {
|
export class AgentPrepHandler {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -36,6 +49,20 @@ export class AgentPrepHandler {
|
|||||||
this.session.refreshConfig(ctx, ctx.isProjectTrusted());
|
this.session.refreshConfig(ctx, ctx.isProjectTrusted());
|
||||||
|
|
||||||
const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
|
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(
|
const skillPromptResult = resolveSkillPromptEntries(
|
||||||
event.systemPrompt,
|
event.systemPrompt,
|
||||||
this.resolver,
|
this.resolver,
|
||||||
|
|||||||
@@ -251,6 +251,82 @@ export function resolveSkillPromptEntries(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal structural shape of Pi's `Skill` as carried on
|
||||||
|
* `systemPromptOptions.skills` (Pi 1.x). Fields not listed here are passed
|
||||||
|
* through untouched.
|
||||||
|
*/
|
||||||
|
export interface StructuredSkillLike {
|
||||||
|
name?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
filePath?: unknown;
|
||||||
|
baseDir?: unknown;
|
||||||
|
disableModelInvocation?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured counterpart of `resolveSkillPromptEntries`: Pi 1.x exposes the
|
||||||
|
* loaded skills as objects on `systemPromptOptions.skills`, so denied skills
|
||||||
|
* are filtered out of that array instead of being cut from rendered text.
|
||||||
|
* Skills that Pi itself hides from the prompt (`disableModelInvocation`) stay
|
||||||
|
* in the array but are excluded from the active-entry list, matching the text
|
||||||
|
* path where they never appeared in `<available_skills>`.
|
||||||
|
*/
|
||||||
|
export function resolveStructuredSkillEntries(
|
||||||
|
skills: readonly StructuredSkillLike[],
|
||||||
|
permissionManager: SkillPermissionChecker,
|
||||||
|
agentName: string | null,
|
||||||
|
normalizer: PathNormalizer,
|
||||||
|
): { skills: StructuredSkillLike[]; entries: SkillPromptEntry[] } {
|
||||||
|
const permissionCache = new Map<string, PermissionState>();
|
||||||
|
const visibleSkills: StructuredSkillLike[] = [];
|
||||||
|
const entries: SkillPromptEntry[] = [];
|
||||||
|
|
||||||
|
for (const skill of skills) {
|
||||||
|
const name = typeof skill?.name === "string" ? skill.name : "";
|
||||||
|
if (!name) {
|
||||||
|
visibleSkills.push(skill);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const state = resolvePermissionState(
|
||||||
|
name,
|
||||||
|
permissionManager,
|
||||||
|
agentName,
|
||||||
|
permissionCache,
|
||||||
|
);
|
||||||
|
if (state === "deny") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visibleSkills.push(skill);
|
||||||
|
if (skill.disableModelInvocation === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const location =
|
||||||
|
typeof skill.filePath === "string" && skill.filePath
|
||||||
|
? skill.filePath
|
||||||
|
: typeof skill.baseDir === "string"
|
||||||
|
? skill.baseDir
|
||||||
|
: "";
|
||||||
|
const baseDir =
|
||||||
|
typeof skill.baseDir === "string" && skill.baseDir
|
||||||
|
? skill.baseDir
|
||||||
|
: location
|
||||||
|
? dirname(location)
|
||||||
|
: "";
|
||||||
|
entries.push({
|
||||||
|
name,
|
||||||
|
description:
|
||||||
|
typeof skill.description === "string" ? skill.description : "",
|
||||||
|
location,
|
||||||
|
state,
|
||||||
|
normalizedLocation: normalizer.comparableValue(location),
|
||||||
|
normalizedBaseDir: normalizer.comparableValue(baseDir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { skills: visibleSkills, entries };
|
||||||
|
}
|
||||||
|
|
||||||
export function findSkillPathMatch(
|
export function findSkillPathMatch(
|
||||||
normalizedPath: string,
|
normalizedPath: string,
|
||||||
entries: readonly SkillPromptEntry[],
|
entries: readonly SkillPromptEntry[],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function makeSetup() {
|
|||||||
return {
|
return {
|
||||||
handler,
|
handler,
|
||||||
session,
|
session,
|
||||||
|
permissionManager,
|
||||||
configStore,
|
configStore,
|
||||||
forwarding,
|
forwarding,
|
||||||
warmParser,
|
warmParser,
|
||||||
@@ -55,4 +56,35 @@ describe("AgentPrepHandler", () => {
|
|||||||
expect(result).toEqual({});
|
expect(result).toEqual({});
|
||||||
expect(session.getActiveSkillEntries()).toEqual([]);
|
expect(session.getActiveSkillEntries()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters denied skills out of structured systemPromptOptions.skills", async () => {
|
||||||
|
const { handler, session, permissionManager } = makeSetup();
|
||||||
|
vi.mocked(permissionManager.check).mockImplementation((intent) =>
|
||||||
|
makeCheckResult({
|
||||||
|
state:
|
||||||
|
intent.kind === "tool" &&
|
||||||
|
(intent.input as { name?: string })?.name === "dangerous"
|
||||||
|
? "deny"
|
||||||
|
: "allow",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
skills: [
|
||||||
|
{ name: "librarian", description: "d", filePath: "/skills/librarian/SKILL.md", baseDir: "/skills/librarian" },
|
||||||
|
{ name: "dangerous", description: "d", filePath: "/skills/dangerous/SKILL.md", baseDir: "/skills/dangerous" },
|
||||||
|
{ name: "manual", description: "d", filePath: "/skills/manual/SKILL.md", baseDir: "/skills/manual", disableModelInvocation: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = await handler.handle(
|
||||||
|
{ systemPrompt: "You are an assistant.", systemPromptOptions: options },
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
expect(options.skills.map((skill) => skill.name)).toEqual(["librarian", "manual"]);
|
||||||
|
expect(session.getActiveSkillEntries().map((entry) => entry.name)).toEqual([
|
||||||
|
"librarian",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
findSkillPathMatch,
|
findSkillPathMatch,
|
||||||
parseAllSkillPromptSections,
|
parseAllSkillPromptSections,
|
||||||
resolveSkillPromptEntries,
|
resolveSkillPromptEntries,
|
||||||
|
resolveStructuredSkillEntries,
|
||||||
type SkillPermissionChecker,
|
type SkillPermissionChecker,
|
||||||
} from "#src/skill-prompt-sanitizer";
|
} from "#src/skill-prompt-sanitizer";
|
||||||
import type { PermissionCheckResult } from "#src/types";
|
import type { PermissionCheckResult } from "#src/types";
|
||||||
@@ -182,6 +183,92 @@ describe("resolveSkillPromptEntries", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── resolveStructuredSkillEntries ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("resolveStructuredSkillEntries", () => {
|
||||||
|
const makeSkill = (
|
||||||
|
name: string,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
): Record<string, unknown> => ({
|
||||||
|
name,
|
||||||
|
description: `Description of ${name}`,
|
||||||
|
filePath: `/skills/${name}/SKILL.md`,
|
||||||
|
baseDir: `/skills/${name}`,
|
||||||
|
disableModelInvocation: false,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters denied skills out of the array and keeps them out of entries", () => {
|
||||||
|
const manager = makeManager("allow", { dangerous: "deny" });
|
||||||
|
const skills = [makeSkill("librarian"), makeSkill("dangerous")];
|
||||||
|
const result = resolveStructuredSkillEntries(
|
||||||
|
skills,
|
||||||
|
manager,
|
||||||
|
null,
|
||||||
|
normalizer,
|
||||||
|
);
|
||||||
|
expect(result.skills.map((skill) => skill.name)).toEqual(["librarian"]);
|
||||||
|
expect(result.entries.map((entry) => entry.name)).toEqual(["librarian"]);
|
||||||
|
expect(result.entries[0].state).toBe("allow");
|
||||||
|
expect(result.entries[0].location).toBe("/skills/librarian/SKILL.md");
|
||||||
|
expect(result.entries[0].normalizedBaseDir).toBe("/skills/librarian");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps disableModelInvocation skills in the array but out of entries", () => {
|
||||||
|
const manager = makeManager("allow");
|
||||||
|
const skills = [
|
||||||
|
makeSkill("visible"),
|
||||||
|
makeSkill("manual", { disableModelInvocation: true }),
|
||||||
|
];
|
||||||
|
const result = resolveStructuredSkillEntries(
|
||||||
|
skills,
|
||||||
|
manager,
|
||||||
|
null,
|
||||||
|
normalizer,
|
||||||
|
);
|
||||||
|
expect(result.skills.map((skill) => skill.name)).toEqual([
|
||||||
|
"visible",
|
||||||
|
"manual",
|
||||||
|
]);
|
||||||
|
expect(result.entries.map((entry) => entry.name)).toEqual(["visible"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through entries without a usable name", () => {
|
||||||
|
const manager = makeManager("deny");
|
||||||
|
const skills = [{ description: "no name" }];
|
||||||
|
const result = resolveStructuredSkillEntries(
|
||||||
|
skills,
|
||||||
|
manager,
|
||||||
|
null,
|
||||||
|
normalizer,
|
||||||
|
);
|
||||||
|
expect(result.skills).toHaveLength(1);
|
||||||
|
expect(manager.checkPermission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caches permission results per unique skill name", () => {
|
||||||
|
const manager = makeManager("allow");
|
||||||
|
const skills = [makeSkill("librarian"), makeSkill("librarian")];
|
||||||
|
resolveStructuredSkillEntries(skills, manager, null, normalizer);
|
||||||
|
expect(manager.checkPermission).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes agentName to the permission check", () => {
|
||||||
|
const manager = makeManager("allow");
|
||||||
|
resolveStructuredSkillEntries(
|
||||||
|
[makeSkill("librarian")],
|
||||||
|
manager,
|
||||||
|
"my-agent",
|
||||||
|
normalizer,
|
||||||
|
);
|
||||||
|
expect(manager.checkPermission).toHaveBeenCalledWith(
|
||||||
|
"skill",
|
||||||
|
{ name: "librarian" },
|
||||||
|
"my-agent",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── findSkillPathMatch ──────────────────────────────────────────────────────
|
// ── findSkillPathMatch ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("findSkillPathMatch", () => {
|
describe("findSkillPathMatch", () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Move the global config directory to `<agentDir>/my-pi/rtk-optimizer/` under the bundle-owned rebuild tree (`config.json` is still created on demand by `ensureConfigExists`; the old `extensions/pi-rtk-optimizer/` location is ignored).
|
- Move the global config directory to `<agentDir>/my-pi/rtk-optimizer/` under the bundle-owned rebuild tree (`config.json` is still created on demand by `ensureConfigExists`; the old `extensions/pi-rtk-optimizer/` location is ignored).
|
||||||
|
- Contribute the source-filter troubleshooting note through `systemPromptOptions.promptGuidelines` instead of rewriting the returned system prompt: `extensions/system-prompt.ts` is the bundle's single system-prompt owner, so handlers must never return `systemPrompt`. `injectGuidelineIntoPrompt` is removed.
|
||||||
|
|
||||||
## [0.9.0] - 2026-07-03
|
## [0.9.0] - 2026-07-03
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ mock.module("@earendil-works/pi-tui", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const indexModule = await import("./index.ts");
|
const indexModule = await import("./index.ts");
|
||||||
const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote, injectGuidelineIntoPrompt } = indexModule;
|
const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote } = indexModule;
|
||||||
const rtkIntegrationExtension = indexModule.default;
|
const rtkIntegrationExtension = indexModule.default;
|
||||||
const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts");
|
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", () => {
|
await runTest("before_agent_start contributes the source-filter note via promptGuidelines", async () => {
|
||||||
const guideline = "Test guideline for RTK troubleshooting.";
|
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 = [
|
const handlers: Record<string, ExtensionHandler> = {};
|
||||||
"Guidelines:",
|
rtkIntegrationExtension({
|
||||||
"- Use mcp for MCP discovery first",
|
exec: async () => ({ code: 1, stdout: "", stderr: "missing rtk" }),
|
||||||
"- Be concise in your responses",
|
on(eventName: string, handler: ExtensionHandler) {
|
||||||
"",
|
handlers[eventName] = handler;
|
||||||
"<project_context>",
|
},
|
||||||
"Project-specific instructions",
|
registerCommand() {},
|
||||||
].join("\n");
|
} 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}`;
|
await beforeAgentStart(event, createNotificationContext([]));
|
||||||
assert.ok(result.includes(bullet), "guideline must be a bullet line");
|
assert.equal(event.systemPromptOptions.promptGuidelines.length, 1, "must not duplicate the note");
|
||||||
|
writeTestConfig(false);
|
||||||
const guidelinesStart = result.indexOf("Guidelines:\n");
|
|
||||||
const bulletIndex = result.indexOf(bullet);
|
|
||||||
const projectContextIndex = result.indexOf("\n<project_context>");
|
|
||||||
|
|
||||||
assert.equal(guidelinesStart, 0, "Guidelines header must be at index 0");
|
|
||||||
assert.ok(bulletIndex > guidelinesStart, "bullet must be after Guidelines header");
|
|
||||||
assert.ok(projectContextIndex !== -1, "project_context section must exist");
|
|
||||||
assert.ok(bulletIndex < projectContextIndex, "bullet must be before project_context section");
|
|
||||||
|
|
||||||
assert.equal(injectGuidelineIntoPrompt(result, guideline), result, "should be idempotent");
|
|
||||||
});
|
|
||||||
|
|
||||||
runTest("injectGuidelineIntoPrompt inserts bullet inside Guidelines section when header is mid-prompt", () => {
|
|
||||||
const guideline = "Test guideline for RTK troubleshooting.";
|
|
||||||
|
|
||||||
const prompt = [
|
|
||||||
"You are an expert coding assistant.",
|
|
||||||
"",
|
|
||||||
"Guidelines:",
|
|
||||||
"- Be concise in your responses",
|
|
||||||
"",
|
|
||||||
"Pi documentation:",
|
|
||||||
"- Main documentation: /path/to/readme",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const result = injectGuidelineIntoPrompt(prompt, guideline);
|
|
||||||
|
|
||||||
const bullet = `- ${guideline}`;
|
|
||||||
assert.ok(result.includes(bullet), "guideline must be a bullet line");
|
|
||||||
|
|
||||||
const guidelinesStart = result.indexOf("\nGuidelines:\n");
|
|
||||||
const bulletIndex = result.indexOf(bullet);
|
|
||||||
const piDocsIndex = result.indexOf("\nPi documentation:");
|
|
||||||
|
|
||||||
assert.ok(guidelinesStart !== -1, "Guidelines header must exist");
|
|
||||||
assert.ok(bulletIndex > guidelinesStart, "bullet must be after Guidelines header");
|
|
||||||
assert.ok(bulletIndex < piDocsIndex, "bullet must be before Pi documentation section");
|
|
||||||
});
|
|
||||||
|
|
||||||
runTest("injectGuidelineIntoPrompt falls back to appending when no Guidelines section exists", () => {
|
|
||||||
const guideline = "Test guideline for RTK troubleshooting.";
|
|
||||||
const prompt = "You are a coding assistant with no guidelines section.";
|
|
||||||
|
|
||||||
const result = injectGuidelineIntoPrompt(prompt, guideline);
|
|
||||||
|
|
||||||
assert.ok(result.includes(guideline), "guideline must be present");
|
|
||||||
assert.ok(result.endsWith(guideline), "guideline must be appended at the end");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await runTest("session_start refreshes RTK provenance and runtime guard skips missing rewrites", async () => {
|
await runTest("session_start refreshes RTK provenance and runtime guard skips missing rewrites", async () => {
|
||||||
|
|||||||
@@ -32,61 +32,6 @@ function trimMessage(raw: string, maxLength = 220): string {
|
|||||||
const SOURCE_FILTER_TROUBLESHOOTING_NOTE =
|
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.";
|
"RTK note: If file edits repeatedly fail because old text does not match, ask the user to manually run '/rtk' in the Pi TUI, disable 'Read compaction enabled', re-read the file, apply the edit, then ask the user to manually re-enable it in the Pi TUI.";
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject a guideline bullet into the Guidelines section of the system prompt.
|
|
||||||
*
|
|
||||||
* Locates the `Guidelines:` block and inserts the bullet after the last
|
|
||||||
* existing guideline, preserving the section structure. Falls back to
|
|
||||||
* appending at the end when the Guidelines section cannot be found.
|
|
||||||
*/
|
|
||||||
export function injectGuidelineIntoPrompt(systemPrompt: string, guideline: string): string {
|
|
||||||
if (!systemPrompt || systemPrompt.includes(guideline)) {
|
|
||||||
return systemPrompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bullet = `- ${guideline}`;
|
|
||||||
|
|
||||||
// "Guidelines:" may appear at the very start of the prompt (index 0) or
|
|
||||||
// after a newline. Check both cases so the header is always detected.
|
|
||||||
let guidelinesHeaderIndex = systemPrompt.indexOf("\nGuidelines:\n");
|
|
||||||
let headerLength = "\nGuidelines:\n".length;
|
|
||||||
|
|
||||||
if (guidelinesHeaderIndex === -1 && systemPrompt.startsWith("Guidelines:\n")) {
|
|
||||||
guidelinesHeaderIndex = 0;
|
|
||||||
headerLength = "Guidelines:\n".length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (guidelinesHeaderIndex === -1) {
|
|
||||||
return `${systemPrompt}\n\n${guideline}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const linesStart = guidelinesHeaderIndex + headerLength;
|
|
||||||
const remainder = systemPrompt.slice(linesStart);
|
|
||||||
const lines = remainder.split("\n");
|
|
||||||
|
|
||||||
let consumedChars = 0;
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line === "") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (/^[-*+\s]/.test(line)) {
|
|
||||||
consumedChars += line.length + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertAt = consumedChars === 0 ? linesStart : linesStart + consumedChars - 1;
|
|
||||||
|
|
||||||
const before = systemPrompt.slice(0, insertAt);
|
|
||||||
const after = systemPrompt.slice(insertAt);
|
|
||||||
|
|
||||||
const needsNewlineBefore = before.length > 0 && !before.endsWith("\n");
|
|
||||||
const needsNewlineAfter = after.length > 0 && !after.startsWith("\n");
|
|
||||||
|
|
||||||
return [before, needsNewlineBefore ? "\n" : "", bullet, needsNewlineAfter ? "\n" : "", after].join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadOutputCompactorModule = createLazyModuleLoader<typeof import("./output-compactor.js")>("./output-compactor.js");
|
const loadOutputCompactorModule = createLazyModuleLoader<typeof import("./output-compactor.js")>("./output-compactor.js");
|
||||||
|
|
||||||
export function shouldInjectSourceFilterTroubleshootingNote(config: RtkIntegrationConfig): boolean {
|
export function shouldInjectSourceFilterTroubleshootingNote(config: RtkIntegrationConfig): boolean {
|
||||||
@@ -436,19 +381,18 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
|||||||
return {};
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
const guidelines = (promptOptions.promptGuidelines ??= []);
|
||||||
const updatedPrompt = injectGuidelineIntoPrompt(event.systemPrompt, SOURCE_FILTER_TROUBLESHOOTING_NOTE);
|
if (!guidelines.includes(SOURCE_FILTER_TROUBLESHOOTING_NOTE)) {
|
||||||
|
guidelines.push(SOURCE_FILTER_TROUBLESHOOTING_NOTE);
|
||||||
if (updatedPrompt === event.systemPrompt) {
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
return {};
|
||||||
return {
|
|
||||||
systemPrompt: updatedPrompt,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("tool_call", async (event, ctx) => {
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
|||||||
+4
-1
@@ -120,7 +120,10 @@ declare module "@earendil-works/pi-coding-agent" {
|
|||||||
on(
|
on(
|
||||||
eventName: "before_agent_start",
|
eventName: "before_agent_start",
|
||||||
handler: (
|
handler: (
|
||||||
event: { systemPrompt: string },
|
event: {
|
||||||
|
systemPrompt: string;
|
||||||
|
systemPromptOptions?: { promptGuidelines?: string[] } & Record<string, unknown>;
|
||||||
|
},
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
) => MaybePromise<{ systemPrompt: string } | Record<string, unknown> | void>,
|
) => MaybePromise<{ systemPrompt: string } | Record<string, unknown> | void>,
|
||||||
): void;
|
): void;
|
||||||
|
|||||||
@@ -10,3 +10,7 @@
|
|||||||
纯 `ssh2` 连接管理在设计时参考了 <https://github.com/99percentpeople/pi-extensions> 中 `@99percentpeople/pi-ssh-remote` 的 commit `fe4c44f3b4d4b52bd2c31d6d4d5a833c4ad60d9d`。本地实现保留本 fork 专属的 `ssh_*` 工具,不导入该扩展的本地工具覆盖或 OpenSSH 回退模型。
|
纯 `ssh2` 连接管理在设计时参考了 <https://github.com/99percentpeople/pi-extensions> 中 `@99percentpeople/pi-ssh-remote` 的 commit `fe4c44f3b4d4b52bd2c31d6d4d5a833c4ad60d9d`。本地实现保留本 fork 专属的 `ssh_*` 工具,不导入该扩展的本地工具覆盖或 OpenSSH 回退模型。
|
||||||
|
|
||||||
本目录不是 Git submodule,且有意不包含嵌套 `.git`、`node_modules` 或生成的构建产物。未来同步上游时必须从明确 tag 或 commit 审查并移植,不得覆盖本地修改。
|
本目录不是 Git submodule,且有意不包含嵌套 `.git`、`node_modules` 或生成的构建产物。未来同步上游时必须从明确 tag 或 commit 审查并移植,不得覆盖本地修改。
|
||||||
|
|
||||||
|
## 本地改动
|
||||||
|
|
||||||
|
- 已建立连接时的 `before_agent_start` 不再向 `systemPrompt` 文本追加远端指引,而是写入 `event.systemPromptOptions.sections.remote_ssh` 结构化分区;组合包单一提示词出口 `extensions/system-prompt.ts` 统一组装,顺序由 `config/prompt/sections.jsonc` 控制。
|
||||||
|
|||||||
+8
-2
@@ -355,8 +355,10 @@ export default function piSshExtension(pi: ExtensionAPI): void {
|
|||||||
|
|
||||||
pi.on("before_agent_start", async (event) => {
|
pi.on("before_agent_start", async (event) => {
|
||||||
if (!connection) return;
|
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 =
|
const guidance =
|
||||||
`\n\n# Remote SSH connection\n\n` +
|
|
||||||
`An SSH2 connection to configured host ${connection.remote} on port ${connection.port} is active. ` +
|
`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. ` +
|
`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. ` +
|
`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. ` +
|
`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. ` +
|
`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.`;
|
`Each ssh_bash call starts a fresh non-interactive shell, so use command-local cd only for an intentionally temporary change; cd, exports, aliases, and other shell state inside one command do not persist to the next call.`;
|
||||||
return { systemPrompt: `${event.systemPrompt}${guidance}` };
|
const promptOptions = event.systemPromptOptions as unknown as
|
||||||
|
| { sections?: Record<string, string> }
|
||||||
|
| undefined;
|
||||||
|
if (!promptOptions) return; // hosts without structured options just skip the section
|
||||||
|
(promptOptions.sections ??= {}).remote_ssh = guidance;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "../pi-rtk-optimizer/src/constants.ts";
|
} from "../pi-rtk-optimizer/src/constants.ts";
|
||||||
import { CONFIG_PATH as COMPACTION_CONFIG_PATH } from "../pi-compaction/src/config.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 { 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 repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const bundleConfigDir = join(repositoryRoot, "config");
|
const bundleConfigDir = join(repositoryRoot, "config");
|
||||||
@@ -42,7 +42,10 @@ const BUNDLE_CONFIG_FILES = [
|
|||||||
"pi-compaction.json",
|
"pi-compaction.json",
|
||||||
"pi-worktree.json",
|
"pi-worktree.json",
|
||||||
"pi-hermes-memory.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",
|
"pi-hashline-edit.json",
|
||||||
"lsp.json",
|
"lsp.json",
|
||||||
"p10k.zsh",
|
"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(COMPACTION_CONFIG_PATH, "pi-compaction.json");
|
||||||
inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json");
|
inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json");
|
||||||
inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json");
|
inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json");
|
||||||
inBundleConfig(resolveCoverPath(), "system-prompt-cover.md");
|
inBundleConfig(resolvePreamblePath(), "prompt/preamble.md");
|
||||||
} finally {
|
} finally {
|
||||||
if (savedToolSearch === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
|
if (savedToolSearch === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
|
||||||
else process.env.PI_TOOL_SEARCH_CONFIG_PATH = savedToolSearch;
|
else process.env.PI_TOOL_SEARCH_CONFIG_PATH = savedToolSearch;
|
||||||
|
|||||||
+474
-90
@@ -1,49 +1,124 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
|
import {
|
||||||
|
execFileSync,
|
||||||
|
spawn,
|
||||||
|
type ChildProcess,
|
||||||
|
} from "node:child_process";
|
||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildExploreArgs,
|
CodegraphRpcClient,
|
||||||
|
type CodegraphRpcClientOptions,
|
||||||
|
} from "../extensions/codegraph-client.ts";
|
||||||
|
import {
|
||||||
createCodegraphExploreHandler,
|
createCodegraphExploreHandler,
|
||||||
findIndexedRoot,
|
findIndexedRoot,
|
||||||
isNotIndexedOutput,
|
|
||||||
type CodegraphExec,
|
type CodegraphExec,
|
||||||
|
type CodegraphExploreClient,
|
||||||
type ExploreParams,
|
type ExploreParams,
|
||||||
} from "../extensions/codegraph.ts";
|
} from "../extensions/codegraph.ts";
|
||||||
|
|
||||||
async function makeIndexedProject(): Promise<{ root: string; cleanup: () => Promise<void> }> {
|
const FIXTURE = fileURLToPath(
|
||||||
|
new URL("./fixtures/fake-codegraph-mcp.mjs", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// ── fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function makeIndexedProject(): Promise<{
|
||||||
|
root: string;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}> {
|
||||||
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||||
await mkdir(join(root, ".codegraph"));
|
await mkdir(join(root, ".codegraph"));
|
||||||
await writeFile(join(root, ".codegraph", "codegraph.db"), "");
|
await writeFile(join(root, ".codegraph", "codegraph.db"), "");
|
||||||
return { root, cleanup: () => rm(root, { recursive: true, force: true }) };
|
return { root, cleanup: () => rm(root, { recursive: true, force: true }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockCtx(cwd: string, confirm?: () => Promise<boolean>) {
|
/** Spawn the .mjs fixture under process.execPath with the given env. */
|
||||||
|
function fixtureSpawn(extraEnv: Record<string, string>, children?: ChildProcess[]) {
|
||||||
|
return (_command: string, _args: string[], options: object) => {
|
||||||
|
const child = spawn(process.execPath, [FIXTURE], {
|
||||||
|
...(options as object),
|
||||||
|
env: { ...process.env, ...extraEnv },
|
||||||
|
});
|
||||||
|
children?.push(child);
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixtureClient(
|
||||||
|
root: string,
|
||||||
|
extraEnv: Record<string, string> = {},
|
||||||
|
overrides: Partial<CodegraphRpcClientOptions> = {},
|
||||||
|
children?: ChildProcess[],
|
||||||
|
): CodegraphRpcClient {
|
||||||
|
return new CodegraphRpcClient({
|
||||||
|
root,
|
||||||
|
requestTimeoutMs: 5_000,
|
||||||
|
spawn: fixtureSpawn(
|
||||||
|
{ FAKE_CG_MODE: "ok", ...extraEnv },
|
||||||
|
children,
|
||||||
|
),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockCtx(
|
||||||
|
cwd: string,
|
||||||
|
ui?: { confirm?: () => Promise<boolean>; notify?: (m: string, t?: string) => void },
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
cwd,
|
cwd,
|
||||||
hasUI: confirm !== undefined,
|
hasUI: ui !== undefined,
|
||||||
ui: { confirm: confirm ?? (async () => false) },
|
ui: {
|
||||||
|
confirm: ui?.confirm ?? (async () => false),
|
||||||
|
notify: ui?.notify ?? (() => {}),
|
||||||
|
},
|
||||||
} as never;
|
} as never;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockExec(handler: (command: string, args: string[]) => { code?: number; stdout?: string; stderr?: string }) {
|
function mockExec(
|
||||||
|
handler: (command: string, args: string[]) => {
|
||||||
|
code?: number;
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
const calls: Array<{ command: string; args: string[] }> = [];
|
const calls: Array<{ command: string; args: string[] }> = [];
|
||||||
const exec: CodegraphExec = async (command, args) => {
|
const exec: CodegraphExec = async (command, args) => {
|
||||||
calls.push({ command, args });
|
calls.push({ command, args });
|
||||||
const result = handler(command, args);
|
const result = handler(command, args);
|
||||||
return { code: result.code ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "", killed: false };
|
return {
|
||||||
|
code: result.code ?? 0,
|
||||||
|
stdout: result.stdout ?? "",
|
||||||
|
stderr: result.stderr ?? "",
|
||||||
|
killed: false,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
return { exec, calls };
|
return { exec, calls };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHandler(exec: CodegraphExec) {
|
function makeHandler(
|
||||||
return {
|
exec: CodegraphExec,
|
||||||
handle: createCodegraphExploreHandler(exec, new Map(), new Map()),
|
createClient: (root: string) => CodegraphExploreClient,
|
||||||
};
|
) {
|
||||||
|
return createCodegraphExploreHandler({ exec, createClient });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── findIndexedRoot (unchanged behavior) ────────────────────────────────────
|
||||||
|
|
||||||
test("findIndexedRoot locates the nearest ancestor index", async () => {
|
test("findIndexedRoot locates the nearest ancestor index", async () => {
|
||||||
const { root, cleanup } = await makeIndexedProject();
|
const { root, cleanup } = await makeIndexedProject();
|
||||||
try {
|
try {
|
||||||
@@ -65,71 +140,229 @@ test("findIndexedRoot returns undefined when no index exists", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildExploreArgs maps params to CLI argv", () => {
|
// ── CodegraphRpcClient against the fixture ──────────────────────────────────
|
||||||
assert.deepEqual(buildExploreArgs({ query: "auth flow" }, "/repo"), ["explore", "-p", "/repo", "auth flow"]);
|
|
||||||
assert.deepEqual(
|
|
||||||
buildExploreArgs({ query: "q", maxFiles: 5, projectPath: "/ignored" }, "/repo"),
|
|
||||||
["explore", "-p", "/repo", "--max-files", "5", "q"],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("isNotIndexedOutput detects the upstream marker", () => {
|
test("client handshake + explore returns upstream content verbatim", async () => {
|
||||||
assert.equal(isNotIndexedOutput("✗ CodeGraph isn't available here — no .codegraph/ index exists in /x."), true);
|
|
||||||
assert.equal(isNotIndexedOutput("Found 3 symbols"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("explore runs the CLI and caches by database mtime", async () => {
|
|
||||||
const { root, cleanup } = await makeIndexedProject();
|
const { root, cleanup } = await makeIndexedProject();
|
||||||
const { exec, calls } = mockExec(() => ({ stdout: "explore output" }));
|
const client = fixtureClient(root);
|
||||||
const { handle } = makeHandler(exec);
|
|
||||||
try {
|
try {
|
||||||
const first = await handle({ query: "auth" }, mockCtx(root));
|
await client.start();
|
||||||
assert.equal(first.content[0].text, "explore output");
|
const result = await client.callExplore({
|
||||||
const second = await handle({ query: "auth" }, mockCtx(root));
|
query: "alphaOne",
|
||||||
assert.equal(second.content[0].text, "explore output");
|
maxFiles: 5,
|
||||||
assert.equal(calls.length, 1, "identical query on unchanged index must hit the cache");
|
projectPath: "/some/dir",
|
||||||
|
});
|
||||||
// Simulate a reindex: bump the db mtime.
|
const content = result.content as Array<{ type: string; text: string }>;
|
||||||
await utimes(join(root, ".codegraph", "codegraph.db"), new Date(), new Date(Date.now() + 5000));
|
const args = JSON.parse(content[0].text.slice("explore:".length));
|
||||||
await handle({ query: "auth" }, mockCtx(root));
|
assert.deepEqual(args, {
|
||||||
assert.equal(calls.length, 2, "mtime change must invalidate the cached result");
|
query: "alphaOne",
|
||||||
|
maxFiles: 5,
|
||||||
|
projectPath: "/some/dir",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
await client.close();
|
||||||
await cleanup();
|
await cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uninitialized project asks the user and indexes on approval", async () => {
|
test("client forwards only properties the upstream schema declares", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
const { root, cleanup } = await makeIndexedProject();
|
||||||
const { exec, calls } = mockExec((_command, args) => {
|
const client = fixtureClient(root, {
|
||||||
if (args[0] === "init") {
|
FAKE_CG_SCHEMA: '["query","maxFiles"]',
|
||||||
// Emulate codegraph init creating the index.
|
|
||||||
return { stdout: "indexed" };
|
|
||||||
}
|
|
||||||
return { stdout: "explore output" };
|
|
||||||
});
|
});
|
||||||
const { handle } = makeHandler(exec);
|
|
||||||
try {
|
try {
|
||||||
const result = await handle({ query: "auth" }, mockCtx(root, async () => true));
|
await client.start();
|
||||||
assert.equal(result.content[0].text, "explore output");
|
const result = await client.callExplore({
|
||||||
assert.deepEqual(
|
query: "q",
|
||||||
calls.map(({ args }) => args[0]),
|
maxFiles: 3,
|
||||||
["init", "explore"],
|
projectPath: "/should/not/forward",
|
||||||
);
|
});
|
||||||
assert.deepEqual(calls[0].args, ["init", root]);
|
const content = result.content as Array<{ text: string }>;
|
||||||
|
const args = JSON.parse(content[0].text.slice("explore:".length));
|
||||||
|
assert.deepEqual(args, { query: "q", maxFiles: 3 });
|
||||||
} finally {
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client start fails when the server lacks codegraph_explore", async () => {
|
||||||
|
const { root, cleanup } = await makeIndexedProject();
|
||||||
|
const client = fixtureClient(root, { FAKE_CG_MODE: "no-tool" });
|
||||||
|
try {
|
||||||
|
await assert.rejects(client.start(), /未提供 codegraph_explore 工具/);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client abort sends notifications/cancelled and rejects", async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||||
|
const logFile = join(dir, "rpc.log");
|
||||||
|
const client = fixtureClient(dir, {
|
||||||
|
FAKE_CG_MODE: "hang",
|
||||||
|
FAKE_CG_LOG: logFile,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.start();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const call = client.callExplore({ query: "q" }, controller.signal);
|
||||||
|
setTimeout(() => controller.abort(), 50);
|
||||||
|
await assert.rejects(call);
|
||||||
|
await client.close();
|
||||||
|
const lines = (await readFile(logFile, "utf8"))
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => JSON.parse(l));
|
||||||
|
const cancelled = lines.find(
|
||||||
|
(m) => m.method === "notifications/cancelled",
|
||||||
|
);
|
||||||
|
assert.ok(cancelled, "server must see notifications/cancelled");
|
||||||
|
assert.equal(cancelled.params.requestId, 3);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client request timeout rejects a hung call", async () => {
|
||||||
|
const { root, cleanup } = await makeIndexedProject();
|
||||||
|
const client = fixtureClient(
|
||||||
|
root,
|
||||||
|
{ FAKE_CG_MODE: "hang" },
|
||||||
|
{ requestTimeoutMs: 300 },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await client.start();
|
||||||
|
await assert.rejects(client.callExplore({ query: "q" }), /超时/);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client exit rejects the pending call with the stderr tail", async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||||
|
const client = fixtureClient(dir, {
|
||||||
|
FAKE_CG_MODE: "crash-first",
|
||||||
|
FAKE_CG_MARKER: join(dir, "crash.marker"),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.start();
|
||||||
|
await assert.rejects(
|
||||||
|
client.callExplore({ query: "q" }),
|
||||||
|
/index corrupted/,
|
||||||
|
);
|
||||||
|
assert.equal(client.dead, true);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client close() terminates the child process", async () => {
|
||||||
|
const { root, cleanup } = await makeIndexedProject();
|
||||||
|
const children: ChildProcess[] = [];
|
||||||
|
const client = fixtureClient(root, {}, {}, children);
|
||||||
|
try {
|
||||||
|
await client.start();
|
||||||
|
await client.close();
|
||||||
|
assert.equal(children.length, 1);
|
||||||
|
assert.ok(
|
||||||
|
children[0].exitCode !== null || children[0].signalCode !== null,
|
||||||
|
"child must be gone after close()",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── handler ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("indexed project explores through the serve child", async () => {
|
||||||
|
const { root, cleanup } = await makeIndexedProject();
|
||||||
|
const { exec, calls } = mockExec(() => ({ stdout: "unreachable" }));
|
||||||
|
const handler = makeHandler(exec, (r) => fixtureClient(r));
|
||||||
|
try {
|
||||||
|
const result = await handler.handle({ query: "auth" }, mockCtx(root));
|
||||||
|
const content = result.content as Array<{ text: string }>;
|
||||||
|
assert.match(content[0].text, /^explore:\{/);
|
||||||
|
assert.equal(calls.length, 0, "indexed project never runs codegraph init");
|
||||||
|
} finally {
|
||||||
|
await handler.close();
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declined init is remembered for the rest of the session", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||||
|
let confirmCalls = 0;
|
||||||
|
const { exec, calls } = mockExec(() => ({ stdout: "unreachable" }));
|
||||||
|
const handler = makeHandler(exec, (r) => fixtureClient(r));
|
||||||
|
const ctx = mockCtx(root, {
|
||||||
|
confirm: async () => {
|
||||||
|
confirmCalls += 1;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const first = await handler.handle({ query: "a" }, ctx);
|
||||||
|
const second = await handler.handle({ query: "b" }, ctx);
|
||||||
|
assert.match(first.content[0].text ?? "", /find\/grep\/read\/LSP/);
|
||||||
|
assert.match(second.content[0].text ?? "", /find\/grep\/read\/LSP/);
|
||||||
|
assert.equal(confirmCalls, 1, "decline is remembered — no second prompt");
|
||||||
|
assert.equal(calls.length, 0, "declined init must not spawn the CLI");
|
||||||
|
} finally {
|
||||||
|
await handler.close();
|
||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uninitialized project bounces to built-in tools when declined", async () => {
|
test("no UI bounces to built-in tools without prompting", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||||
const { exec, calls } = mockExec(() => ({ stdout: "unreachable" }));
|
const { exec, calls } = mockExec(() => ({ stdout: "unreachable" }));
|
||||||
const { handle } = makeHandler(exec);
|
const handler = makeHandler(exec, (r) => fixtureClient(r));
|
||||||
try {
|
try {
|
||||||
const result = await handle({ query: "auth" }, mockCtx(root, async () => false));
|
const result = await handler.handle({ query: "auth" }, mockCtx(root));
|
||||||
assert.match(result.content[0].text ?? "", /find\/grep\/read\/LSP/);
|
assert.match(result.content[0].text ?? "", /find\/grep\/read\/LSP/);
|
||||||
assert.equal(calls.length, 0, "declined init must not spawn the CLI at all");
|
assert.equal(calls.length, 0);
|
||||||
} finally {
|
} finally {
|
||||||
|
await handler.close();
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uninitialized project inits on approval then explores", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||||
|
const { exec, calls } = mockExec((_command, args) => {
|
||||||
|
if (args[0] === "init") return { stdout: "indexed" };
|
||||||
|
return { stdout: "unreachable" };
|
||||||
|
});
|
||||||
|
// Emulate codegraph init materializing the index.
|
||||||
|
const execWithSideEffect: CodegraphExec = async (command, args, options) => {
|
||||||
|
const result = await exec(command, args, options);
|
||||||
|
if (args[0] === "init") {
|
||||||
|
await mkdir(join(root, ".codegraph"));
|
||||||
|
await writeFile(join(root, ".codegraph", "codegraph.db"), "");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const handler = makeHandler(execWithSideEffect, (r) => fixtureClient(r));
|
||||||
|
try {
|
||||||
|
const result = await handler.handle(
|
||||||
|
{ query: "auth" },
|
||||||
|
mockCtx(root, { confirm: async () => true }),
|
||||||
|
);
|
||||||
|
const content = result.content as Array<{ text: string }>;
|
||||||
|
assert.match(content[0].text, /^explore:\{/);
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map(({ args }) => args[0]),
|
||||||
|
["init"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls[0].args, ["init", root]);
|
||||||
|
} finally {
|
||||||
|
await handler.close();
|
||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -138,21 +371,28 @@ test("concurrent calls on an uninitialized project share one init decision", asy
|
|||||||
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||||
let confirmCalls = 0;
|
let confirmCalls = 0;
|
||||||
const { exec, calls } = mockExec(() => ({ stdout: "ok" }));
|
const { exec, calls } = mockExec(() => ({ stdout: "ok" }));
|
||||||
const pending = new Map<string, Promise<string | undefined>>();
|
const execWithSideEffect: CodegraphExec = async (command, args, options) => {
|
||||||
const handle = createCodegraphExploreHandler(exec, new Map(), pending);
|
const result = await exec(command, args, options);
|
||||||
|
if (args[0] === "init") {
|
||||||
|
await mkdir(join(root, ".codegraph"));
|
||||||
|
await writeFile(join(root, ".codegraph", "codegraph.db"), "");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const handler = makeHandler(execWithSideEffect, (r) => fixtureClient(r));
|
||||||
try {
|
try {
|
||||||
|
const ctx = mockCtx(root, {
|
||||||
|
confirm: async () => {
|
||||||
|
confirmCalls += 1;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
const [a, b] = await Promise.all([
|
const [a, b] = await Promise.all([
|
||||||
handle({ query: "one" }, mockCtx(root, async () => {
|
handler.handle({ query: "one" }, ctx),
|
||||||
confirmCalls += 1;
|
handler.handle({ query: "two" }, ctx),
|
||||||
return true;
|
|
||||||
})),
|
|
||||||
handle({ query: "two" }, mockCtx(root, async () => {
|
|
||||||
confirmCalls += 1;
|
|
||||||
return true;
|
|
||||||
})),
|
|
||||||
]);
|
]);
|
||||||
assert.equal(a.content[0].text, "ok");
|
assert.equal(a.content[0].type, "text");
|
||||||
assert.equal(b.content[0].text, "ok");
|
assert.equal(b.content[0].type, "text");
|
||||||
assert.equal(confirmCalls, 1, "parallel callers must not double-prompt");
|
assert.equal(confirmCalls, 1, "parallel callers must not double-prompt");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
calls.filter(({ args }) => args[0] === "init").length,
|
calls.filter(({ args }) => args[0] === "init").length,
|
||||||
@@ -160,49 +400,193 @@ test("concurrent calls on an uninitialized project share one init decision", asy
|
|||||||
"parallel callers must share a single init",
|
"parallel callers must share a single init",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
await handler.close();
|
||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("init failure is reported instead of exploring", async () => {
|
test("init failure bounces with exit code instead of exploring", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||||
const { exec } = mockExec((_command, args) =>
|
const { exec } = mockExec((_command, args) =>
|
||||||
args[0] === "init" ? { code: 1, stderr: "boom" } : { stdout: "unreachable" },
|
args[0] === "init" ? { code: 1, stderr: "boom" } : { stdout: "unreachable" },
|
||||||
);
|
);
|
||||||
const { handle } = makeHandler(exec);
|
const handler = makeHandler(exec, (r) => fixtureClient(r));
|
||||||
try {
|
try {
|
||||||
const result = await handle({ query: "auth" }, mockCtx(root, async () => true));
|
const result = await handler.handle(
|
||||||
assert.match(result.content[0].text ?? "", /codegraph init 失败/);
|
{ query: "auth" },
|
||||||
|
mockCtx(root, { confirm: async () => true }),
|
||||||
|
);
|
||||||
|
assert.match(result.content[0].text ?? "", /codegraph init 失败(退出码 1)/);
|
||||||
} finally {
|
} finally {
|
||||||
|
await handler.close();
|
||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a dead serve child is transparently replaced once", async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||||
|
await mkdir(join(dir, ".codegraph"));
|
||||||
|
await writeFile(join(dir, ".codegraph", "codegraph.db"), "");
|
||||||
|
const env = {
|
||||||
|
FAKE_CG_MODE: "crash-first",
|
||||||
|
FAKE_CG_MARKER: join(dir, "crash.marker"),
|
||||||
|
};
|
||||||
|
let created = 0;
|
||||||
|
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
|
||||||
|
const handler = makeHandler(exec, (r) => {
|
||||||
|
created += 1;
|
||||||
|
return fixtureClient(r, env);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await handler.handle({ query: "auth" }, mockCtx(dir));
|
||||||
|
const content = result.content as Array<{ text: string }>;
|
||||||
|
assert.match(content[0].text, /^explore:\{/);
|
||||||
|
assert.equal(created, 2, "the crashed client is replaced exactly once");
|
||||||
|
} finally {
|
||||||
|
await handler.close();
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a persistently crashing child bounces after one retry", async () => {
|
||||||
|
const { root, cleanup } = await makeIndexedProject();
|
||||||
|
let created = 0;
|
||||||
|
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
|
||||||
|
const handler = makeHandler(exec, (r) => {
|
||||||
|
created += 1;
|
||||||
|
return fixtureClient(r, { FAKE_CG_MODE: "crash" });
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await handler.handle({ query: "auth" }, mockCtx(root));
|
||||||
|
assert.match(result.content[0].text ?? "", /CodeGraph 服务调用失败/);
|
||||||
|
assert.equal(created, 2, "one retry, then bounce");
|
||||||
|
} finally {
|
||||||
|
await handler.close();
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("missing CLI surfaces an install hint instead of throwing", async () => {
|
test("missing CLI surfaces an install hint instead of throwing", async () => {
|
||||||
const { root, cleanup } = await makeIndexedProject();
|
const { root, cleanup } = await makeIndexedProject();
|
||||||
const exec: CodegraphExec = async () => {
|
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
|
||||||
throw new Error("spawn codegraph ENOENT");
|
const handler = makeHandler(exec, () => ({
|
||||||
};
|
dead: false,
|
||||||
const { handle } = makeHandler(exec);
|
start: async () => {
|
||||||
|
throw new Error("spawn codegraph ENOENT");
|
||||||
|
},
|
||||||
|
callExplore: async () => ({}),
|
||||||
|
close: async () => {},
|
||||||
|
schemaPropertyNames: () => [],
|
||||||
|
}));
|
||||||
try {
|
try {
|
||||||
const result = await handle({ query: "auth" }, mockCtx(root));
|
const result = await handler.handle({ query: "auth" }, mockCtx(root));
|
||||||
assert.match(result.content[0].text ?? "", /无法执行 codegraph CLI/);
|
assert.match(result.content[0].text ?? "", /未找到 codegraph CLI/);
|
||||||
|
assert.match(result.content[0].text ?? "", /install\.sh/);
|
||||||
} finally {
|
} finally {
|
||||||
|
await handler.close();
|
||||||
await cleanup();
|
await cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("projectPath overrides the session cwd", async () => {
|
test("projectPath targets a different indexed root with its own client", async () => {
|
||||||
const { root, cleanup } = await makeIndexedProject();
|
const main = await makeIndexedProject();
|
||||||
const other = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
const other = await makeIndexedProject();
|
||||||
const { exec, calls } = mockExec(() => ({ stdout: "ok" }));
|
const roots: string[] = [];
|
||||||
const { handle } = makeHandler(exec);
|
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
|
||||||
|
const handler = makeHandler(exec, (r) => {
|
||||||
|
roots.push(r);
|
||||||
|
return fixtureClient(r);
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const params: ExploreParams = { query: "auth", projectPath: root };
|
await handler.handle({ query: "a" }, mockCtx(main.root));
|
||||||
await handle(params, mockCtx(other));
|
await handler.handle(
|
||||||
assert.deepEqual(calls[0].args, ["explore", "-p", root, "auth"]);
|
{ query: "b", projectPath: other.root } satisfies ExploreParams,
|
||||||
|
mockCtx(main.root),
|
||||||
|
);
|
||||||
|
assert.deepEqual(roots, [main.root, other.root]);
|
||||||
} finally {
|
} finally {
|
||||||
await cleanup();
|
await handler.close();
|
||||||
await rm(other, { recursive: true, force: true });
|
await main.cleanup();
|
||||||
|
await other.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("schema drift notifies once per process", async () => {
|
||||||
|
const { root, cleanup } = await makeIndexedProject();
|
||||||
|
const notices: string[] = [];
|
||||||
|
const { exec } = mockExec(() => ({ stdout: "unreachable" }));
|
||||||
|
const handler = makeHandler(exec, (r) =>
|
||||||
|
fixtureClient(r, { FAKE_CG_SCHEMA: '["query","maxFiles"]' }),
|
||||||
|
);
|
||||||
|
const ctx = mockCtx(root, {
|
||||||
|
confirm: async () => true,
|
||||||
|
notify: (message: string) => {
|
||||||
|
notices.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await handler.handle({ query: "a" }, ctx);
|
||||||
|
await handler.handle({ query: "b" }, ctx);
|
||||||
|
assert.equal(notices.length, 1);
|
||||||
|
assert.match(notices[0], /参数已变化/);
|
||||||
|
} finally {
|
||||||
|
await handler.close();
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── real CLI integration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const codegraphAvailable = (() => {
|
||||||
|
try {
|
||||||
|
execFileSync("codegraph", ["--version"], { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
test(
|
||||||
|
"real codegraph serve --mcp keeps the index synced via its watcher",
|
||||||
|
{ skip: !codegraphAvailable },
|
||||||
|
async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-real-"));
|
||||||
|
const client = new CodegraphRpcClient({ root: dir });
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["init", "-q"], { cwd: dir, stdio: "ignore" });
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "a.ts"),
|
||||||
|
"export function alphaOne() { return 1; }\n",
|
||||||
|
);
|
||||||
|
execFileSync("codegraph", ["init", dir], {
|
||||||
|
cwd: dir,
|
||||||
|
stdio: "ignore",
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await client.callExplore({ query: "alphaOne" });
|
||||||
|
const firstText = (first.content as Array<{ text?: string }>)
|
||||||
|
.map((c) => c.text ?? "")
|
||||||
|
.join("\n");
|
||||||
|
assert.match(firstText, /alphaOne/);
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "a.ts"),
|
||||||
|
"export function alphaOne() { return 1; }\nexport function betaTwo() { return 2; }\n",
|
||||||
|
);
|
||||||
|
const deadline = Date.now() + 15_000;
|
||||||
|
let found = false;
|
||||||
|
while (Date.now() < deadline && !found) {
|
||||||
|
const res = await client.callExplore({ query: "betaTwo" });
|
||||||
|
found = (res.content as Array<{ text?: string }>).some((c) =>
|
||||||
|
(c.text ?? "").includes("betaTwo"),
|
||||||
|
);
|
||||||
|
if (!found) await sleep(500);
|
||||||
|
}
|
||||||
|
assert.ok(found, "watcher should make betaTwo searchable within 15 s");
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { mkdir, mkdtemp, readFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join, resolve } from "node:path";
|
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"),
|
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
|
||||||
"the bundle must load the locally maintained Hermes memory extension",
|
"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;
|
// extensions/system-prompt.ts is the bundle's single system-prompt owner and
|
||||||
// once flattened, later handlers cannot restore the section structure.
|
// 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(
|
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"),
|
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(
|
assert.equal(
|
||||||
packageJson.pi.extensions.indexOf("./extensions/system-prompt-cover.ts") <
|
packageJson.pi.extensions.includes("./extensions/tool-routing.ts"),
|
||||||
packageJson.pi.extensions.indexOf("./extensions/tool-routing.ts"),
|
false,
|
||||||
"the system prompt cover must run before tool-routing forces the prompt",
|
"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.equal(packageJson.dependencies["pi-hermes-memory"], "file:./pi-hermes-memory");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
packageJson.files.includes("pi-hermes-memory"),
|
packageJson.files.includes("pi-hermes-memory"),
|
||||||
|
|||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Fake `codegraph serve --mcp`: newline-delimited JSON-RPC 2.0 on stdio, for
|
||||||
|
* tests of extensions/codegraph-client.ts.
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* FAKE_CG_MODE "ok" (default) | "no-tool" | "crash-first" | "crash" | "hang"
|
||||||
|
* no-tool: tools/list returns no codegraph_explore
|
||||||
|
* crash-first: on the first tools/call, write FAKE_CG_MARKER
|
||||||
|
* and exit(1) — a second spawned process sees
|
||||||
|
* the marker and serves normally
|
||||||
|
* crash: exit(1) on every tools/call
|
||||||
|
* hang: never reply to tools/call
|
||||||
|
* FAKE_CG_SCHEMA JSON array of inputSchema property names
|
||||||
|
* (default ["query","maxFiles","projectPath"])
|
||||||
|
* FAKE_CG_MARKER marker file path for crash-first
|
||||||
|
* FAKE_CG_LOG file to append every inbound message as a JSON line
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import readline from "node:readline";
|
||||||
|
|
||||||
|
const mode = process.env.FAKE_CG_MODE || "ok";
|
||||||
|
const marker = process.env.FAKE_CG_MARKER;
|
||||||
|
const logFile = process.env.FAKE_CG_LOG;
|
||||||
|
const schemaProps = (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(process.env.FAKE_CG_SCHEMA || "");
|
||||||
|
} catch {
|
||||||
|
return ["query", "maxFiles", "projectPath"];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function log(entry) {
|
||||||
|
if (logFile) fs.appendFileSync(logFile, JSON.stringify(entry) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(msg) {
|
||||||
|
process.stdout.write(JSON.stringify(msg) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: process.stdin });
|
||||||
|
rl.on("line", (line) => {
|
||||||
|
const text = line.trim();
|
||||||
|
if (!text) return;
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!msg || typeof msg !== "object") return;
|
||||||
|
log(msg);
|
||||||
|
if (typeof msg.method !== "string") return;
|
||||||
|
|
||||||
|
switch (msg.method) {
|
||||||
|
case "initialize":
|
||||||
|
send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: msg.id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: "2025-06-18",
|
||||||
|
capabilities: {},
|
||||||
|
serverInfo: { name: "fake-codegraph", version: "0.0.0" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "tools/list": {
|
||||||
|
const tools =
|
||||||
|
mode === "no-tool"
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: "codegraph_explore",
|
||||||
|
description: "fake explore",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: Object.fromEntries(
|
||||||
|
schemaProps.map((name) => [name, { type: "string" }]),
|
||||||
|
),
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
send({ jsonrpc: "2.0", id: msg.id, result: { tools } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "tools/call": {
|
||||||
|
const shouldCrash =
|
||||||
|
mode === "crash" ||
|
||||||
|
(mode === "crash-first" && marker && !fs.existsSync(marker));
|
||||||
|
if (shouldCrash) {
|
||||||
|
if (mode === "crash-first") fs.writeFileSync(marker, "crashed");
|
||||||
|
// Synchronous write — process.exit() can truncate pending pipe output.
|
||||||
|
fs.writeSync(2, "fatal: codegraph index corrupted\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (mode === "hang") return; // Never reply — timeout/abort test.
|
||||||
|
const args = (msg.params && msg.params.arguments) || {};
|
||||||
|
send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: msg.id,
|
||||||
|
result: {
|
||||||
|
content: [{ type: "text", text: `explore:${JSON.stringify(args)}` }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Notifications (initialized, cancelled) need no reply; unknown
|
||||||
|
// requests get -32601.
|
||||||
|
if (msg.id !== undefined) {
|
||||||
|
send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: msg.id,
|
||||||
|
error: { code: -32601, message: "Method not found" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { dirname, join, resolve } from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
import {
|
|
||||||
readCoverFile,
|
|
||||||
resolveCoverPath,
|
|
||||||
spliceCover,
|
|
||||||
} from "../extensions/system-prompt-cover-config.ts";
|
|
||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const bundledCoverPath = join(repositoryRoot, "config", "system-prompt-cover.md");
|
|
||||||
|
|
||||||
const DEFAULT_PREAMBLE =
|
|
||||||
"You are an expert coding assistant operating inside pi, a coding agent harness. " +
|
|
||||||
"You help users by reading files, executing commands, editing code, and writing new files.";
|
|
||||||
|
|
||||||
/** A rendered prompt shaped like Pi's: preamble, then tag-wrapped top-level sections. */
|
|
||||||
function renderedPrompt(preamble = DEFAULT_PREAMBLE) {
|
|
||||||
return [
|
|
||||||
preamble,
|
|
||||||
"<tools>",
|
|
||||||
"- read: reads files",
|
|
||||||
"</tools>",
|
|
||||||
"<rules>",
|
|
||||||
"- Be concise",
|
|
||||||
"</rules>",
|
|
||||||
"<skills>",
|
|
||||||
"<available_skills>",
|
|
||||||
" <skill>",
|
|
||||||
" <name>s</name>",
|
|
||||||
" </skill>",
|
|
||||||
"</available_skills>",
|
|
||||||
"</skills>",
|
|
||||||
"<project_context>",
|
|
||||||
'<project_instructions path="/work/AGENTS.md">',
|
|
||||||
"Nested content with a </project_context>-looking line is not a tag.",
|
|
||||||
"</project_instructions>",
|
|
||||||
"</project_context>",
|
|
||||||
"<cwd>",
|
|
||||||
"/work",
|
|
||||||
"</cwd>",
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function tagsOf(text: string): string[] {
|
|
||||||
return [...text.matchAll(/^<([a-z][a-z0-9_-]*)>$/gm)].map((match) => match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeTempDir() {
|
|
||||||
return mkdtemp(join(tmpdir(), "my-pi-cover-"));
|
|
||||||
}
|
|
||||||
|
|
||||||
test("bundled cover holds Pi's default preamble text", async () => {
|
|
||||||
const content = await readFile(bundledCoverPath, "utf8");
|
|
||||||
assert.equal(content.replace(/\s+$/, ""), DEFAULT_PREAMBLE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("spliceCover replaces only the preamble and preserves the remaining bytes", () => {
|
|
||||||
const rendered = renderedPrompt();
|
|
||||||
const spliced = spliceCover(rendered, "MY CUSTOM COVER");
|
|
||||||
|
|
||||||
assert.ok(spliced.startsWith("MY CUSTOM COVER\n"));
|
|
||||||
|
|
||||||
// Everything after the preamble's terminating newline is untouched.
|
|
||||||
const originalTail = rendered.slice(rendered.indexOf("\n<tools>"));
|
|
||||||
assert.ok(spliced.endsWith(originalTail));
|
|
||||||
|
|
||||||
// Section order — including nested tags — is identical to the unmodified prompt.
|
|
||||||
assert.deepEqual(tagsOf(spliced), tagsOf(rendered));
|
|
||||||
assert.deepEqual(tagsOf(spliced), [
|
|
||||||
"tools",
|
|
||||||
"rules",
|
|
||||||
"skills",
|
|
||||||
"available_skills",
|
|
||||||
"project_context",
|
|
||||||
"cwd",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("spliceCover keeps section order rather than appending recovered sections", () => {
|
|
||||||
const rendered = renderedPrompt("ORIGINAL COVER");
|
|
||||||
const spliced = spliceCover(rendered, "NEW COVER");
|
|
||||||
const order = tagsOf(spliced).join(",");
|
|
||||||
|
|
||||||
assert.equal(order, "tools,rules,skills,available_skills,project_context,cwd");
|
|
||||||
assert.ok(
|
|
||||||
order.indexOf("tools") < order.indexOf("cwd"),
|
|
||||||
"tools must still precede cwd; a re-appended section list would invert this",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
order.indexOf("project_context") < order.indexOf("cwd"),
|
|
||||||
"project_context must keep its original position",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("spliceCover tolerates a trailing newline from the editor and a blank cover", () => {
|
|
||||||
const rendered = renderedPrompt();
|
|
||||||
|
|
||||||
assert.equal(spliceCover(rendered, "COVER\n\n"), spliceCover(rendered, "COVER"));
|
|
||||||
// A whitespace-only cover leaves an empty preamble but still keeps every section.
|
|
||||||
const blank = spliceCover(rendered, " \n");
|
|
||||||
assert.equal(blank, rendered.slice(rendered.indexOf("\n<tools>")));
|
|
||||||
assert.deepEqual(tagsOf(blank), tagsOf(rendered));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("spliceCover degenerates to the cover when the prompt has no sections", () => {
|
|
||||||
assert.equal(spliceCover("just a preamble", "NEW"), "NEW");
|
|
||||||
assert.equal(spliceCover("", "NEW"), "NEW");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("spliceCover keeps an attribute-bearing nested tag inside its parent section", () => {
|
|
||||||
// Pi nests <project_instructions path="..."> inside the bare <project_context> tag, so the
|
|
||||||
// boundary must be the first *bare* tag line and the nested markup must survive verbatim.
|
|
||||||
const rendered = [
|
|
||||||
"ORIGINAL COVER",
|
|
||||||
"<tools>",
|
|
||||||
"- read: reads files",
|
|
||||||
"</tools>",
|
|
||||||
"<project_context>",
|
|
||||||
'<project_instructions path="/work/AGENTS.md">',
|
|
||||||
"nested instructions",
|
|
||||||
"</project_instructions>",
|
|
||||||
"</project_context>",
|
|
||||||
"<cwd>",
|
|
||||||
"/work",
|
|
||||||
"</cwd>",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const spliced = spliceCover(rendered, "NEW");
|
|
||||||
assert.equal(tagsOf(spliced).join(","), "tools,project_context,cwd");
|
|
||||||
assert.ok(spliced.startsWith("NEW\n<tools>"));
|
|
||||||
assert.ok(
|
|
||||||
spliced.includes('<project_instructions path="/work/AGENTS.md">\nnested instructions\n</project_instructions>'),
|
|
||||||
"nested project instructions must be preserved byte-for-byte",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveCoverPath points at the bundle config file", () => {
|
|
||||||
assert.equal(resolveCoverPath(), bundledCoverPath);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("readCoverFile returns undefined when the file is absent or blank", async () => {
|
|
||||||
const dir = await makeTempDir();
|
|
||||||
try {
|
|
||||||
const coverPath = join(dir, "system-prompt-cover.md");
|
|
||||||
assert.equal(readCoverFile(coverPath), undefined);
|
|
||||||
|
|
||||||
await writeFile(coverPath, " \n\n", "utf8");
|
|
||||||
assert.equal(readCoverFile(coverPath), undefined);
|
|
||||||
|
|
||||||
await writeFile(coverPath, " REAL COVER \n", "utf8");
|
|
||||||
assert.equal(readCoverFile(coverPath), " REAL COVER");
|
|
||||||
} finally {
|
|
||||||
await rm(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyToolCatalog,
|
||||||
|
loadRoutingRules,
|
||||||
|
loadSectionsConfig,
|
||||||
|
loadToolCatalog,
|
||||||
|
orderSections,
|
||||||
|
readPreamble,
|
||||||
|
renderRouting,
|
||||||
|
resolvePreamblePath,
|
||||||
|
spliceCover,
|
||||||
|
stripJsoncComments,
|
||||||
|
type MutablePromptOptions,
|
||||||
|
type RoutingRule,
|
||||||
|
} from "../extensions/system-prompt-config.ts";
|
||||||
|
import systemPromptExtension, {
|
||||||
|
createSystemPromptComposer,
|
||||||
|
getSystemPromptDumpPath,
|
||||||
|
} from "../extensions/system-prompt.ts";
|
||||||
|
import { guardExtensionApi } from "../extensions/prompt-guard.ts";
|
||||||
|
import {
|
||||||
|
CONTEXT_MODE_ANCHOR_PREFIX,
|
||||||
|
filterContextModeMessages,
|
||||||
|
} from "../extensions/context-mode-filter.ts";
|
||||||
|
import { BUNDLE_GROUP_DEFINITIONS } from "../pi-tool-search/extensions/bundle-groups.ts";
|
||||||
|
import { BUNDLE_TOOL_SEARCH_DEFAULTS } from "../pi-tool-search/extensions/config.ts";
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const promptConfigDir = join(repositoryRoot, "config", "prompt");
|
||||||
|
|
||||||
|
const BUNDLE_PREAMBLE =
|
||||||
|
"You are an expert coding assistant operating inside pi, a coding agent harness. " +
|
||||||
|
"You help users by reading files, executing commands, editing code, and writing new files.";
|
||||||
|
|
||||||
|
// ── JSONC parsing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("stripJsoncComments removes comments but keeps // inside strings", () => {
|
||||||
|
const source = `{
|
||||||
|
// line comment
|
||||||
|
"url": "https://example.com//path", /* block comment */
|
||||||
|
"escaped": "a\\"//not a comment"
|
||||||
|
}`;
|
||||||
|
assert.deepEqual(JSON.parse(stripJsoncComments(source)), {
|
||||||
|
url: "https://example.com//path",
|
||||||
|
escaped: 'a"//not a comment',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Config files load and validate ──────────────────────────────────────────
|
||||||
|
|
||||||
|
test("config/prompt files load with the expected shapes", () => {
|
||||||
|
const catalog = loadToolCatalog();
|
||||||
|
assert.equal(catalog.version, 1);
|
||||||
|
assert.ok(Object.keys(catalog.tools).length > 0);
|
||||||
|
|
||||||
|
const routing = loadRoutingRules();
|
||||||
|
assert.equal(routing.version, 1);
|
||||||
|
assert.ok(routing.rules.length > 0);
|
||||||
|
for (const rule of routing.rules) assert.equal(typeof rule.text, "string");
|
||||||
|
|
||||||
|
const sections = loadSectionsConfig();
|
||||||
|
assert.equal(sections.version, 1);
|
||||||
|
assert.deepEqual(sections.order, [
|
||||||
|
"memory_policy",
|
||||||
|
"remote_ssh",
|
||||||
|
"chrome_profile_bridge",
|
||||||
|
"tool_routing",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readPreamble reads the bundle preamble from config/prompt/", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolvePreamblePath(),
|
||||||
|
join(promptConfigDir, "preamble.md"),
|
||||||
|
);
|
||||||
|
assert.equal(readPreamble(), BUNDLE_PREAMBLE);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Catalog completeness ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CORE_TOOL_NAMES = [
|
||||||
|
"read",
|
||||||
|
"bash",
|
||||||
|
"edit",
|
||||||
|
"write",
|
||||||
|
"grep",
|
||||||
|
"find",
|
||||||
|
"ls",
|
||||||
|
"powershell",
|
||||||
|
"tool_search",
|
||||||
|
];
|
||||||
|
|
||||||
|
function requiredToolNames(): Set<string> {
|
||||||
|
const required = new Set(CORE_TOOL_NAMES);
|
||||||
|
for (const group of BUNDLE_GROUP_DEFINITIONS) {
|
||||||
|
for (const name of group.tools) required.add(name);
|
||||||
|
}
|
||||||
|
for (const name of BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled) required.add(name);
|
||||||
|
return required;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("tools.jsonc covers every tool the bundle can expose, and nothing else", () => {
|
||||||
|
const required = requiredToolNames();
|
||||||
|
const catalog = loadToolCatalog();
|
||||||
|
for (const name of required) {
|
||||||
|
assert.ok(name in catalog.tools, `tool "${name}" is missing from config/prompt/tools.jsonc`);
|
||||||
|
}
|
||||||
|
for (const name of Object.keys(catalog.tools)) {
|
||||||
|
assert.ok(required.has(name), `tools.jsonc entry "${name}" is not a bundle-exposed tool`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Forbidden phrases ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("prompt config carries no retired guidance phrasing", () => {
|
||||||
|
const forbidden = [
|
||||||
|
"ls, grep, find",
|
||||||
|
"read, grep, or edit",
|
||||||
|
"read/grep",
|
||||||
|
"replace_text",
|
||||||
|
"Read/edit files",
|
||||||
|
"ctx_fetch_and_index then ctx_search",
|
||||||
|
];
|
||||||
|
for (const file of ["preamble.md", "tools.jsonc", "routing.jsonc"]) {
|
||||||
|
const source = readFileSync(join(promptConfigDir, file), "utf8");
|
||||||
|
for (const phrase of forbidden) {
|
||||||
|
assert.ok(!source.includes(phrase), `config/prompt/${file} must not contain "${phrase}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── applyToolCatalog ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("applyToolCatalog applies snippet/guideline overrides and reports unregistered tools", () => {
|
||||||
|
const catalog = {
|
||||||
|
version: 1 as const,
|
||||||
|
tools: {
|
||||||
|
bash: { snippet: "custom bash line" },
|
||||||
|
edit: { guidelines: ["rule one", "rule two"] },
|
||||||
|
read: { inherit: true as const },
|
||||||
|
write: { snippet: null },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const options: MutablePromptOptions = {
|
||||||
|
selectedTools: ["bash", "edit", "read", "write", "mystery_tool"],
|
||||||
|
toolSnippets: { bash: "orig bash", write: "orig write", read: "orig read" },
|
||||||
|
toolGuidelines: { edit: ["old rule"] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const missing = applyToolCatalog(options, catalog);
|
||||||
|
|
||||||
|
assert.deepEqual(missing, ["mystery_tool"]);
|
||||||
|
assert.equal(options.toolSnippets?.bash, "custom bash line");
|
||||||
|
assert.equal(options.toolSnippets?.read, "orig read", "inherit entries keep upstream text");
|
||||||
|
assert.ok(!("write" in (options.toolSnippets ?? {})), "snippet: null hides the tools entry");
|
||||||
|
assert.deepEqual(options.toolGuidelines?.edit, ["rule one", "rule two"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── renderRouting ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("renderRouting honours whenAll, whenAny and unconditional rules", () => {
|
||||||
|
const rules: RoutingRule[] = [
|
||||||
|
{ whenAll: ["a", "b"], text: "all" },
|
||||||
|
{ whenAny: ["x", "y"], text: "any" },
|
||||||
|
{ text: "always" },
|
||||||
|
{ whenAll: ["a"], whenAny: ["b", "z"], text: "both" },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(renderRouting(rules, ["a", "b", "x"]), "- all\n- any\n- always\n- both");
|
||||||
|
assert.equal(renderRouting(rules, ["a"]), "- always", "whenAny must match at least one");
|
||||||
|
assert.equal(renderRouting(rules, []), "- always");
|
||||||
|
assert.equal(
|
||||||
|
renderRouting(rules, undefined),
|
||||||
|
"- all\n- any\n- always\n- both",
|
||||||
|
"absent selectedTools means every tool is active",
|
||||||
|
);
|
||||||
|
assert.equal(renderRouting([], ["a"]), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bundled routing rules select the same guidance the retired tool-routing extension emitted", () => {
|
||||||
|
const routing = loadRoutingRules();
|
||||||
|
|
||||||
|
const literal = renderRouting(routing.rules, ["find", "grep", "read"]);
|
||||||
|
assert.match(literal, /Use find to identify likely files or directories/);
|
||||||
|
assert.match(literal, /narrow the path, glob, or pattern instead of increasing the limit/);
|
||||||
|
|
||||||
|
const ssh = renderRouting(routing.rules, ["ssh_connect", "ssh_cd", "ssh_bash"]);
|
||||||
|
assert.match(ssh, /only when the user explicitly names an imported host/);
|
||||||
|
assert.match(ssh, /command-local directory change/);
|
||||||
|
|
||||||
|
const chrome = renderRouting(routing.rules, ["chrome_snapshot", "ctx_execute_file"]);
|
||||||
|
assert.match(chrome, /immediately analyze the returned workspace-relative capture path/);
|
||||||
|
|
||||||
|
const minimal = renderRouting(routing.rules, ["read"]);
|
||||||
|
assert.doesNotMatch(minimal, /codegraph_explore first/);
|
||||||
|
assert.doesNotMatch(minimal, /For literal search/);
|
||||||
|
assert.doesNotMatch(minimal, /ssh_connect only when/);
|
||||||
|
assert.match(minimal, /Stop searching once the current evidence is sufficient/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── orderSections ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("orderSections applies the configured order and keeps unlisted sections last", () => {
|
||||||
|
const sections = {
|
||||||
|
late_unlisted: "L",
|
||||||
|
tool_routing: "R",
|
||||||
|
memory_policy: "M",
|
||||||
|
early_unlisted: "E",
|
||||||
|
};
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(orderSections(sections, ["memory_policy", "tool_routing"])),
|
||||||
|
["memory_policy", "tool_routing", "late_unlisted", "early_unlisted"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(orderSections({}, ["memory_policy"]), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── spliceCover (moved from the retired cover extension) ────────────────────
|
||||||
|
|
||||||
|
function renderedPrompt(preamble = "PI DEFAULT PREAMBLE") {
|
||||||
|
return [
|
||||||
|
preamble,
|
||||||
|
"<tools>",
|
||||||
|
"- read: reads files",
|
||||||
|
"</tools>",
|
||||||
|
"<rules>",
|
||||||
|
"- Be concise",
|
||||||
|
"</rules>",
|
||||||
|
"<project_context>",
|
||||||
|
'<project_instructions path="/work/AGENTS.md">',
|
||||||
|
"Nested content with a </project_context>-looking line is not a tag.",
|
||||||
|
"</project_instructions>",
|
||||||
|
"</project_context>",
|
||||||
|
"<cwd>",
|
||||||
|
"/work",
|
||||||
|
"</cwd>",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagsOf(text: string): string[] {
|
||||||
|
return [...text.matchAll(/^<([a-z][a-z0-9_-]*)>$/gm)].map((match) => match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("spliceCover replaces only the preamble and preserves the remaining bytes", () => {
|
||||||
|
const rendered = renderedPrompt();
|
||||||
|
const spliced = spliceCover(rendered, "MY CUSTOM COVER");
|
||||||
|
|
||||||
|
assert.ok(spliced.startsWith("MY CUSTOM COVER\n"));
|
||||||
|
const originalTail = rendered.slice(rendered.indexOf("\n<tools>"));
|
||||||
|
assert.ok(spliced.endsWith(originalTail));
|
||||||
|
assert.deepEqual(tagsOf(spliced), tagsOf(rendered));
|
||||||
|
assert.deepEqual(tagsOf(spliced), ["tools", "rules", "project_context", "cwd"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spliceCover tolerates a trailing newline and a whitespace-only preamble", () => {
|
||||||
|
const rendered = renderedPrompt();
|
||||||
|
assert.equal(spliceCover(rendered, "COVER\n\n"), spliceCover(rendered, "COVER"));
|
||||||
|
const blank = spliceCover(rendered, " \n");
|
||||||
|
assert.equal(blank, rendered.slice(rendered.indexOf("\n<tools>")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spliceCover degenerates to the preamble when the prompt has no sections", () => {
|
||||||
|
assert.equal(spliceCover("just a preamble", "NEW"), "NEW");
|
||||||
|
assert.equal(spliceCover("", "NEW"), "NEW");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Composer: modern structured path ────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FakePromptOptions extends MutablePromptOptions {
|
||||||
|
forceSystemPrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simplified re-render of Pi's prompt from the mutable options object. */
|
||||||
|
function renderPiPrompt(options: FakePromptOptions): string {
|
||||||
|
if (options.forceSystemPrompt !== undefined) return options.forceSystemPrompt;
|
||||||
|
const parts = ["PI DEFAULT PREAMBLE"];
|
||||||
|
const snippets = Object.entries(options.toolSnippets ?? {});
|
||||||
|
if (snippets.length > 0) {
|
||||||
|
parts.push(`<tools>\n${snippets.map(([n, s]) => `- ${n}: ${s}`).join("\n")}\n</tools>`);
|
||||||
|
}
|
||||||
|
const rules = [
|
||||||
|
...(options.promptGuidelines ?? []),
|
||||||
|
...Object.entries(options.toolGuidelines ?? {}).flatMap(([name, lines]) =>
|
||||||
|
lines.map((line) => `${name}: ${line}`),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (rules.length > 0) parts.push(`<rules>\n${rules.join("\n")}\n</rules>`);
|
||||||
|
parts.push("<cwd>\n/work\n</cwd>");
|
||||||
|
for (const [name, text] of Object.entries(options.sections ?? {})) {
|
||||||
|
parts.push(`<${name}>\n${text}\n</${name}>`);
|
||||||
|
}
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEvent(options: FakePromptOptions | undefined) {
|
||||||
|
return {
|
||||||
|
systemPromptOptions: options,
|
||||||
|
get systemPrompt() {
|
||||||
|
return options ? renderPiPrompt(options) : "PI DEFAULT PREAMBLE\n<tools>\n- x\n</tools>";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUiCtx() {
|
||||||
|
const notices: string[] = [];
|
||||||
|
const ctx = {
|
||||||
|
hasUI: true,
|
||||||
|
ui: { notify: (message: string) => notices.push(message) },
|
||||||
|
};
|
||||||
|
return { notices, ctx: ctx as never };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("composer splices the preamble, applies the catalog, and orders sections", () => {
|
||||||
|
const composer = createSystemPromptComposer();
|
||||||
|
const options: FakePromptOptions = {
|
||||||
|
selectedTools: ["read", "bash", "edit", "codegraph_explore", "tool_search"],
|
||||||
|
toolSnippets: {
|
||||||
|
read: "reads files",
|
||||||
|
bash: "runs commands",
|
||||||
|
edit: "edits",
|
||||||
|
codegraph_explore: "explores",
|
||||||
|
tool_search: "searches",
|
||||||
|
},
|
||||||
|
toolGuidelines: { edit: ["upstream edit rule"] },
|
||||||
|
sections: { memory_policy: "policy text" },
|
||||||
|
};
|
||||||
|
const { notices, ctx } = makeUiCtx();
|
||||||
|
|
||||||
|
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
|
||||||
|
const output = result?.systemPrompt;
|
||||||
|
assert.ok(output);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
output.startsWith(BUNDLE_PREAMBLE),
|
||||||
|
"the bundle preamble replaces Pi's default preamble",
|
||||||
|
);
|
||||||
|
assert.match(output, /<tool_routing>\n- Use codegraph_explore first/);
|
||||||
|
assert.match(output, /search files with the find\/grep tools, not bash/);
|
||||||
|
assert.match(output, /LINE#HASH anchors/);
|
||||||
|
assert.ok(
|
||||||
|
!output.includes("upstream edit rule"),
|
||||||
|
"the edit guidelines entry replaces upstream rules",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
output.indexOf("<memory_policy>") < output.indexOf("<tool_routing>"),
|
||||||
|
"sections.jsonc orders memory_policy before tool_routing",
|
||||||
|
);
|
||||||
|
assert.deepEqual(notices, []);
|
||||||
|
assert.equal(composer.getLastComposed(), output);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("composer degrades to text splicing when an earlier extension froze the prompt", () => {
|
||||||
|
const composer = createSystemPromptComposer();
|
||||||
|
const options: FakePromptOptions = {
|
||||||
|
selectedTools: ["read", "bash"],
|
||||||
|
forceSystemPrompt: "FROZEN PROMPT\n<frozen>\nkept\n</frozen>",
|
||||||
|
};
|
||||||
|
const { notices, ctx } = makeUiCtx();
|
||||||
|
const event = makeEvent(options);
|
||||||
|
|
||||||
|
const first = composer.handleBeforeAgentStart(event as never, ctx);
|
||||||
|
composer.handleBeforeAgentStart(event as never, ctx);
|
||||||
|
|
||||||
|
assert.match(notices[0] ?? "", /单一入口规则/);
|
||||||
|
assert.equal(notices.length, 1, "the violation warning is reported once per process");
|
||||||
|
assert.match(first?.systemPrompt ?? "", /<tool_routing>/);
|
||||||
|
assert.match(first?.systemPrompt ?? "", /<frozen>\nkept\n<\/frozen>/);
|
||||||
|
assert.ok(
|
||||||
|
(first?.systemPrompt ?? "").startsWith(BUNDLE_PREAMBLE),
|
||||||
|
"the preamble is still spliced onto the frozen prompt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("composer falls back to text appends when the host exposes no prompt options", () => {
|
||||||
|
const composer = createSystemPromptComposer();
|
||||||
|
const { ctx } = makeUiCtx();
|
||||||
|
const event = { systemPrompt: "PI DEFAULT PREAMBLE\n<tools>\n- x\n</tools>" };
|
||||||
|
|
||||||
|
const result = composer.handleBeforeAgentStart(event as never, ctx);
|
||||||
|
const output = result?.systemPrompt ?? "";
|
||||||
|
assert.ok(output.startsWith(BUNDLE_PREAMBLE));
|
||||||
|
assert.match(output, /<tool_routing>/);
|
||||||
|
assert.match(output, /<tools>\n- x\n<\/tools>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("composer reports unregistered tools once per distinct name set", () => {
|
||||||
|
const composer = createSystemPromptComposer();
|
||||||
|
const { notices, ctx } = makeUiCtx();
|
||||||
|
const options = (): FakePromptOptions => ({
|
||||||
|
selectedTools: ["read", "ghost_tool"],
|
||||||
|
toolSnippets: { read: "r" },
|
||||||
|
});
|
||||||
|
|
||||||
|
composer.handleBeforeAgentStart(makeEvent(options()) as never, ctx);
|
||||||
|
composer.handleBeforeAgentStart(makeEvent(options()) as never, ctx);
|
||||||
|
assert.equal(notices.length, 1);
|
||||||
|
assert.match(notices[0], /未在 config\/prompt\/tools\.jsonc 登记:.*ghost_tool/);
|
||||||
|
|
||||||
|
composer.handleBeforeAgentStart(
|
||||||
|
makeEvent({ selectedTools: ["other_tool"], toolSnippets: {} }) as never,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.equal(notices.length, 2, "a different missing-tool set notifies again");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("composer warns once and returns nothing when the prompt config fails to load", () => {
|
||||||
|
const composer = createSystemPromptComposer({
|
||||||
|
loadToolCatalog: () => {
|
||||||
|
throw new Error("invalid jsonc");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { notices, ctx } = makeUiCtx();
|
||||||
|
const options: FakePromptOptions = { selectedTools: ["read"], toolSnippets: { read: "r" } };
|
||||||
|
|
||||||
|
assert.equal(composer.handleBeforeAgentStart(makeEvent(options) as never, ctx), undefined);
|
||||||
|
assert.equal(composer.handleBeforeAgentStart(makeEvent(options) as never, ctx), undefined);
|
||||||
|
assert.equal(notices.length, 1);
|
||||||
|
assert.match(notices[0], /invalid jsonc/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── prompt guard ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeRecordingPi() {
|
||||||
|
const tools: Array<{ name: string; description?: string }> = [];
|
||||||
|
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
||||||
|
const pi = {
|
||||||
|
registerTool(def: { name: string; description?: string }) {
|
||||||
|
tools.push(def);
|
||||||
|
},
|
||||||
|
on(name: string, handler: (event: unknown, ctx: unknown) => unknown) {
|
||||||
|
handlers.set(name, handler);
|
||||||
|
},
|
||||||
|
someOtherApi: () => "passthrough",
|
||||||
|
};
|
||||||
|
return { pi, tools, handlers };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("prompt guard applies catalog description overrides at registration", () => {
|
||||||
|
const { pi, tools } = makeRecordingPi();
|
||||||
|
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
|
||||||
|
|
||||||
|
guarded.registerTool({ name: "edit", description: "upstream edit" } as never);
|
||||||
|
guarded.registerTool({ name: "grep", description: "upstream grep" } as never);
|
||||||
|
|
||||||
|
const override = loadToolCatalog().tools.edit;
|
||||||
|
assert.ok(override && !("inherit" in override) && typeof override.description === "string");
|
||||||
|
assert.equal(tools[0].description, override.description);
|
||||||
|
assert.equal(tools[1].description, "upstream grep", "inherit entries keep the upstream description");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prompt guard strips a returned systemPrompt but keeps other fields", async () => {
|
||||||
|
const { pi, handlers } = makeRecordingPi();
|
||||||
|
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
|
||||||
|
|
||||||
|
guarded.on("before_agent_start", (async () => ({
|
||||||
|
systemPrompt: "forced by third party",
|
||||||
|
message: "kept",
|
||||||
|
})) as never);
|
||||||
|
|
||||||
|
const result = (await handlers.get("before_agent_start")?.({}, {})) as Record<string, unknown>;
|
||||||
|
assert.deepEqual(result, { message: "kept" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prompt guard runs context results through the provided contextFilter", async () => {
|
||||||
|
const { pi, handlers } = makeRecordingPi();
|
||||||
|
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI, {
|
||||||
|
contextFilter: (before, after) => after.slice(0, before.length),
|
||||||
|
});
|
||||||
|
|
||||||
|
guarded.on("context", (async (event: { messages: Array<{ role: string; content: string }> }) => {
|
||||||
|
event.messages.push({ role: "user", content: "injected" });
|
||||||
|
return { messages: event.messages };
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
const event = { messages: [{ role: "user", content: "real" }] };
|
||||||
|
const result = (await handlers.get("context")?.(event, {})) as {
|
||||||
|
messages: Array<{ content: string }>;
|
||||||
|
};
|
||||||
|
assert.deepEqual(result.messages.map((m) => m.content), ["real"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prompt guard returns the handler result untouched when the filter changes nothing", async () => {
|
||||||
|
const { pi, handlers } = makeRecordingPi();
|
||||||
|
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI, {
|
||||||
|
contextFilter: filterContextModeMessages,
|
||||||
|
});
|
||||||
|
const expected = { messages: [{ role: "user", content: "plain" }] };
|
||||||
|
|
||||||
|
guarded.on("context", (async () => expected) as never);
|
||||||
|
|
||||||
|
const result = await handlers.get("context")?.({ messages: expected.messages.slice() }, {});
|
||||||
|
assert.equal(result, expected, "a no-op filter must preserve result identity");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prompt guard passes unrelated API members through to the real pi object", () => {
|
||||||
|
const { pi } = makeRecordingPi();
|
||||||
|
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
|
||||||
|
assert.equal((guarded as unknown as { someOtherApi: () => string }).someOtherApi(), "passthrough");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── context-mode filter ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("context-mode filter drops only the routing-anchor part", () => {
|
||||||
|
const before = [{ role: "user", content: "real" }];
|
||||||
|
const anchor = `${CONTEXT_MODE_ANCHOR_PREFIX} ctx_batch_execute > ctx_execute`;
|
||||||
|
const after = [
|
||||||
|
...before,
|
||||||
|
{ role: "user", content: `${anchor}\n\n<active_memory>\nx\n</active_memory>` },
|
||||||
|
];
|
||||||
|
const filtered = filterContextModeMessages(before, after);
|
||||||
|
assert.equal(filtered.length, 2);
|
||||||
|
assert.equal(filtered[1].content, "<active_memory>\nx\n</active_memory>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("context-mode filter removes the appended message when only the anchor remains", () => {
|
||||||
|
const before = [{ role: "user", content: "real" }];
|
||||||
|
const after = [
|
||||||
|
...before,
|
||||||
|
{ role: "user", content: `${CONTEXT_MODE_ANCHOR_PREFIX} only anchor` },
|
||||||
|
];
|
||||||
|
assert.deepEqual(filterContextModeMessages(before, after), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("context-mode filter returns the same reference when nothing changed", () => {
|
||||||
|
const before = [{ role: "user", content: "real" }];
|
||||||
|
const after = [...before, { role: "user", content: "no anchor here" }];
|
||||||
|
assert.equal(filterContextModeMessages(before, after), after);
|
||||||
|
assert.equal(filterContextModeMessages(after, after), after);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("context-mode filter ignores appended non-user or non-string messages", () => {
|
||||||
|
const before = [{ role: "user", content: "real" }];
|
||||||
|
const after = [
|
||||||
|
...before,
|
||||||
|
{ role: "assistant", content: `${CONTEXT_MODE_ANCHOR_PREFIX} not ours` },
|
||||||
|
{ role: "user", content: [{ type: "text", text: "structured" }] },
|
||||||
|
];
|
||||||
|
assert.equal(filterContextModeMessages(before, after), after);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Pinned upstream text ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("context-mode upstream still appends the single anchor-bearing user message", async () => {
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
await readFile(join(repositoryRoot, "package.json"), "utf8"),
|
||||||
|
) as { dependencies: Record<string, string> };
|
||||||
|
assert.equal(
|
||||||
|
packageJson.dependencies["context-mode"],
|
||||||
|
"1.0.169",
|
||||||
|
"the context-filter pin requires the exact upstream version",
|
||||||
|
);
|
||||||
|
const upstream = await readFile(
|
||||||
|
join(repositoryRoot, "node_modules/context-mode/build/adapters/pi/extension.js"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.ok(upstream.includes('parts.push("context-mode active. Hierarchy:'));
|
||||||
|
assert.ok(upstream.includes('_pendingContext = extraParts.join("\\n\\n")'));
|
||||||
|
assert.ok(upstream.includes("event.messages.push({"));
|
||||||
|
assert.ok(upstream.includes('role: "user"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── /dump-system-prompt ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("dump-system-prompt writes the last composed prompt under .pi-debug", async () => {
|
||||||
|
let beforeAgentStart: ((event: unknown, ctx: unknown) => unknown) | undefined;
|
||||||
|
let command: { handler: (args: string, ctx: unknown) => Promise<void> } | undefined;
|
||||||
|
const pi = {
|
||||||
|
on(name: string, handler: typeof beforeAgentStart) {
|
||||||
|
if (name === "before_agent_start") beforeAgentStart = handler;
|
||||||
|
},
|
||||||
|
registerCommand(name: string, definition: typeof command) {
|
||||||
|
if (name === "dump-system-prompt") command = definition;
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
systemPromptExtension(pi);
|
||||||
|
assert.ok(beforeAgentStart);
|
||||||
|
assert.ok(command);
|
||||||
|
|
||||||
|
const { ctx } = makeUiCtx();
|
||||||
|
beforeAgentStart(
|
||||||
|
makeEvent({ selectedTools: ["read"], toolSnippets: { read: "r" } }) as never,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cwd = await mkdtemp(join(tmpdir(), "my-pi-system-prompt-"));
|
||||||
|
try {
|
||||||
|
await command.handler("", {
|
||||||
|
cwd,
|
||||||
|
getSystemPrompt: () => "fallback prompt",
|
||||||
|
ui: { notify() {} },
|
||||||
|
});
|
||||||
|
const dumped = await readFile(getSystemPromptDumpPath(cwd), "utf8");
|
||||||
|
assert.ok(dumped.startsWith(BUNDLE_PREAMBLE));
|
||||||
|
assert.match(dumped, /<tool_routing>/);
|
||||||
|
} finally {
|
||||||
|
await rm(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import toolRoutingExtension, {
|
|
||||||
appendToolRouting,
|
|
||||||
buildToolRoutingSection,
|
|
||||||
getSystemPromptDumpPath,
|
|
||||||
} from "../extensions/tool-routing.ts";
|
|
||||||
|
|
||||||
test("search routing narrows files before requesting matching line numbers", () => {
|
|
||||||
const section = buildToolRoutingSection(["find", "grep", "read"]);
|
|
||||||
|
|
||||||
assert.match(section, /Use find to identify likely files or directories/);
|
|
||||||
assert.match(section, /Use grep or multi_grep only inside the narrowed path or file/);
|
|
||||||
assert.match(section, /obtain matching line numbers/);
|
|
||||||
assert.match(section, /narrow the path, glob, or pattern instead of increasing the limit/);
|
|
||||||
assert.match(section, /Use read with offset\/limit only for the exact matching region/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("SSH connection routing requires an explicit named host and concrete task", () => {
|
|
||||||
const section = buildToolRoutingSection(["ssh_connect"]);
|
|
||||||
assert.match(section, /only when the user explicitly names an imported host/);
|
|
||||||
assert.match(section, /separate step and wait for success before calling other ssh_\* tools/);
|
|
||||||
assert.match(section, /never infer or substitute a different host/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("remote cwd routing separates persistent workspace changes from shell commands", () => {
|
|
||||||
const section = buildToolRoutingSection(["ssh_cd", "ssh_bash"]);
|
|
||||||
assert.match(section, /call it as a separate step and wait for success/);
|
|
||||||
assert.match(section, /relative ssh_read\/ssh_write\/ssh_edit\/ssh_find\/ssh_grep/);
|
|
||||||
assert.match(section, /intentionally temporary, command-local directory change/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("remote search routing uses structured bounded SSH tools", () => {
|
|
||||||
const section = buildToolRoutingSection(["ssh_find", "ssh_grep"]);
|
|
||||||
assert.match(section, /use ssh_find to narrow remote file paths before ssh_grep/);
|
|
||||||
assert.match(section, /Do not run find, fd, grep, or rg through ssh_bash/);
|
|
||||||
assert.match(section, /when a result says truncated, narrow the path or pattern/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("context routing analyzes large files without a full read", () => {
|
|
||||||
const section = buildToolRoutingSection(["ctx_execute", "ctx_execute_file"]);
|
|
||||||
|
|
||||||
assert.match(section, /Prefer ctx_execute_file over read when deriving an answer from a large file/);
|
|
||||||
assert.match(section, /ctx_execute_file is confined to the current project root/);
|
|
||||||
assert.match(section, /host permission approval does not bypass this Context Mode boundary/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Chrome snapshot routing keeps raw page data out of model context", () => {
|
|
||||||
const section = buildToolRoutingSection(["chrome_snapshot", "ctx_execute_file"]);
|
|
||||||
|
|
||||||
assert.match(section, /immediately analyze the returned workspace-relative capture path with ctx_execute_file/);
|
|
||||||
assert.match(section, /never use read or cat for the raw JSON/);
|
|
||||||
assert.match(section, /take a fresh snapshot after any interaction/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hashline routing uses small reads and fresh anchors for edits", () => {
|
|
||||||
const section = buildToolRoutingSection(["read", "edit"]);
|
|
||||||
|
|
||||||
assert.match(section, /smallest useful offset\/limit range/);
|
|
||||||
assert.match(section, /obtain fresh LINE#HASH anchors with a small read/);
|
|
||||||
assert.match(section, /Reuse fresh anchors returned by successful edits/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("LSP routing covers navigation and post-edit diagnostics", () => {
|
|
||||||
const section = buildToolRoutingSection(["lsp_definition", "lsp_references", "lsp_diagnostics"]);
|
|
||||||
|
|
||||||
assert.match(section, /Use LSP for symbol definitions, references/);
|
|
||||||
assert.match(section, /post-edit diagnostics/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("online search routing uses the unified web tools without naming providers", () => {
|
|
||||||
const section = buildToolRoutingSection(["web_search", "web_fetch"]);
|
|
||||||
assert.match(section, /Use web_search for current, real-world, or citable web information/);
|
|
||||||
assert.match(section, /provider selection is handled internally/);
|
|
||||||
assert.match(section, /read selected pages with web_fetch/);
|
|
||||||
assert.doesNotMatch(section, /tavily|exa|keenable/iu);
|
|
||||||
|
|
||||||
const searchOnly = buildToolRoutingSection(["web_search"]);
|
|
||||||
assert.match(searchOnly, /Use web_search/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("routing includes only guidance for active optional tools", () => {
|
|
||||||
const section = buildToolRoutingSection(["read"]);
|
|
||||||
|
|
||||||
assert.doesNotMatch(section, /codegraph_explore first/);
|
|
||||||
assert.doesNotMatch(section, /For literal search/);
|
|
||||||
assert.doesNotMatch(section, /Use Context Mode/);
|
|
||||||
assert.doesNotMatch(section, /ctx_execute_file is confined/);
|
|
||||||
assert.doesNotMatch(section, /fresh LINE#HASH anchors/);
|
|
||||||
assert.doesNotMatch(section, /Use web_search for current/);
|
|
||||||
assert.match(section, /Use read only when exact source is needed/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("routing is appended once", () => {
|
|
||||||
const once = appendToolRouting("base prompt", ["find", "grep"]);
|
|
||||||
const twice = appendToolRouting(once, ["find", "grep"]);
|
|
||||||
|
|
||||||
assert.equal(twice, once);
|
|
||||||
assert.equal((once.match(/<!-- my-pi-tool-routing -->/g) ?? []).length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dump path stays under the current project", () => {
|
|
||||||
assert.equal(
|
|
||||||
getSystemPromptDumpPath("/workspace/project"),
|
|
||||||
"/workspace/project/.pi-debug/effective-system-prompt.md",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dump-system-prompt writes the last routed prompt seen by the extension", async () => {
|
|
||||||
let beforeAgentStart: ((event: any) => { systemPrompt: string }) | undefined;
|
|
||||||
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
|
|
||||||
const pi = {
|
|
||||||
on(name: string, handler: typeof beforeAgentStart) {
|
|
||||||
if (name === "before_agent_start") beforeAgentStart = handler;
|
|
||||||
},
|
|
||||||
registerCommand(_name: string, definition: typeof command) {
|
|
||||||
command = definition;
|
|
||||||
},
|
|
||||||
} as unknown as ExtensionAPI;
|
|
||||||
toolRoutingExtension(pi);
|
|
||||||
assert.ok(beforeAgentStart);
|
|
||||||
assert.ok(command);
|
|
||||||
beforeAgentStart({
|
|
||||||
systemPrompt: "prompt already modified by an earlier extension",
|
|
||||||
systemPromptOptions: { selectedTools: ["find", "grep"] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const cwd = await mkdtemp(join(tmpdir(), "my-pi-tool-routing-"));
|
|
||||||
try {
|
|
||||||
let notification = "";
|
|
||||||
await command.handler("", {
|
|
||||||
cwd,
|
|
||||||
getSystemPrompt: () => "base prompt",
|
|
||||||
getSystemPromptOptions: () => ({ selectedTools: ["find", "grep"] }),
|
|
||||||
ui: {
|
|
||||||
notify(message: string) {
|
|
||||||
notification = message;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const outputPath = getSystemPromptDumpPath(cwd);
|
|
||||||
const dumped = await readFile(outputPath, "utf8");
|
|
||||||
assert.match(dumped, /^prompt already modified by an earlier extension/);
|
|
||||||
assert.match(dumped, /<!-- my-pi-tool-routing -->/);
|
|
||||||
assert.match(notification, new RegExp(outputPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
||||||
} finally {
|
|
||||||
await rm(cwd, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user